Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms. Co-authored-by: Cursor <cursoragent@cursor.com>
1133 lines
46 KiB
TypeScript
1133 lines
46 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Domain workflow screens that replace generic BusinessDocsPage stubs
|
||
* where dedicated backend models/APIs exist.
|
||
*/
|
||
import { useState } from "react";
|
||
import { Controller, useForm } from "react-hook-form";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { Plus } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { z } from "zod";
|
||
import { accountingApi } from "@/lib/accounting-api";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { formatMoney, parseMoneyInput } from "@/lib/utils";
|
||
import {
|
||
Badge,
|
||
Button,
|
||
DataTable,
|
||
DatePicker,
|
||
Dialog,
|
||
EmptyState,
|
||
ErrorState,
|
||
FormField,
|
||
Input,
|
||
JalaliDateText,
|
||
LoadingState,
|
||
MoneyInput,
|
||
PageHeader,
|
||
Select,
|
||
Textarea,
|
||
} from "@/components/ds";
|
||
|
||
const today = () => new Date().toISOString().slice(0, 10);
|
||
const req = z.string().min(1, "الزامی");
|
||
|
||
function Actions({ onCancel, pending }: { onCancel: () => void; pending?: boolean }) {
|
||
return (
|
||
<div className="flex justify-end gap-2 sm:col-span-2">
|
||
<Button type="button" variant="outline" onClick={onCancel}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={pending}>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* -------------------- Assets lifecycle -------------------- */
|
||
|
||
export function AssetTransfersPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const schema = z.object({
|
||
asset_id: req,
|
||
transfer_date: req,
|
||
from_location: z.string().optional(),
|
||
to_location: z.string().optional(),
|
||
});
|
||
const form = useForm<z.infer<typeof schema>>({
|
||
resolver: zodResolver(schema),
|
||
defaultValues: { asset_id: "", transfer_date: today(), from_location: "", to_location: "" },
|
||
});
|
||
const assetsQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "assets"],
|
||
queryFn: () => accountingApi.assets.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "asset-transfers"],
|
||
queryFn: () => accountingApi.assets.listTransfers(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (v: z.infer<typeof schema>) => accountingApi.assets.createTransfer(tenantId!, v),
|
||
onSuccess: async () => {
|
||
toast.success("انتقال دارایی ثبت شد");
|
||
setOpen(false);
|
||
form.reset({ asset_id: "", transfer_date: today(), from_location: "", to_location: "" });
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "asset-transfers"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading || assetsQ.isLoading) return <LoadingState />;
|
||
if (listQ.error || assetsQ.error)
|
||
return <ErrorState message={(listQ.error || assetsQ.error)!.message} onRetry={() => listQ.refetch()} />;
|
||
const names = new Map((assetsQ.data ?? []).map((a) => [a.id, `${a.code} — ${a.name}`]));
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="انتقال دارایی"
|
||
description="ثبت جابهجایی مکان/واحد داراییهای ثابت."
|
||
actions={
|
||
<Button onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
انتقال جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "asset_id", header: "دارایی", render: (r) => names.get(String(r.asset_id)) || String(r.asset_id) },
|
||
{ key: "transfer_date", header: "تاریخ", render: (r) => <JalaliDateText value={String(r.transfer_date)} /> },
|
||
{ key: "from_location", header: "از" },
|
||
{ key: "to_location", header: "به" },
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={
|
||
<EmptyState
|
||
title="انتقالی ثبت نشده"
|
||
action={
|
||
<Button onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
انتقال جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت انتقال دارایی" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="دارایی" error={form.formState.errors.asset_id?.message}>
|
||
<Select {...form.register("asset_id")}>
|
||
<option value="">انتخاب…</option>
|
||
{(assetsQ.data ?? []).map((a) => (
|
||
<option key={a.id} value={a.id}>
|
||
{a.code} — {a.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="تاریخ">
|
||
<Controller control={form.control} name="transfer_date" render={({ field }) => <DatePicker {...field} />} />
|
||
</FormField>
|
||
<FormField label="مکان مبدأ">
|
||
<Input {...form.register("from_location")} />
|
||
</FormField>
|
||
<FormField label="مکان مقصد">
|
||
<Input {...form.register("to_location")} />
|
||
</FormField>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AssetDisposalsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const schema = z.object({
|
||
asset_id: req,
|
||
disposal_date: req,
|
||
disposal_type: req,
|
||
sale_amount: z.string().optional(),
|
||
});
|
||
const form = useForm<z.infer<typeof schema>>({
|
||
resolver: zodResolver(schema),
|
||
defaultValues: { asset_id: "", disposal_date: today(), disposal_type: "sale", sale_amount: "0" },
|
||
});
|
||
const assetsQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "assets"],
|
||
queryFn: () => accountingApi.assets.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "asset-disposals"],
|
||
queryFn: () => accountingApi.assets.listDisposals(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (v: z.infer<typeof schema>) =>
|
||
accountingApi.assets.createDisposal(tenantId!, {
|
||
...v,
|
||
sale_amount: parseMoneyInput(v.sale_amount || "0") || undefined,
|
||
}),
|
||
onSuccess: async () => {
|
||
toast.success("خروج/فروش دارایی ثبت شد");
|
||
setOpen(false);
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "asset-disposals"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading || assetsQ.isLoading) return <LoadingState />;
|
||
if (listQ.error || assetsQ.error)
|
||
return <ErrorState message={(listQ.error || assetsQ.error)!.message} onRetry={() => listQ.refetch()} />;
|
||
const names = new Map((assetsQ.data ?? []).map((a) => [a.id, `${a.code} — ${a.name}`]));
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="معرفی و فروش دارایی"
|
||
description="ثبت خروج، اسقاط یا فروش دارایی ثابت."
|
||
actions={
|
||
<Button onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
ثبت خروج/فروش
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "asset_id", header: "دارایی", render: (r) => names.get(String(r.asset_id)) || String(r.asset_id) },
|
||
{ key: "disposal_date", header: "تاریخ", render: (r) => <JalaliDateText value={String(r.disposal_date)} /> },
|
||
{ key: "disposal_type", header: "نوع" },
|
||
{ key: "sale_amount", header: "مبلغ فروش", render: (r) => formatMoney(String(r.sale_amount ?? "0")) },
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="خروجی ثبت نشده" action={<Button onClick={() => setOpen(true)}>ثبت</Button>} />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="خروج / فروش دارایی" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="دارایی">
|
||
<Select {...form.register("asset_id")}>
|
||
<option value="">انتخاب…</option>
|
||
{(assetsQ.data ?? []).map((a) => (
|
||
<option key={a.id} value={a.id}>
|
||
{a.code} — {a.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="تاریخ">
|
||
<Controller control={form.control} name="disposal_date" render={({ field }) => <DatePicker {...field} />} />
|
||
</FormField>
|
||
<FormField label="نوع">
|
||
<Select {...form.register("disposal_type")}>
|
||
<option value="sale">فروش</option>
|
||
<option value="scrap">اسقاط</option>
|
||
<option value="donation">اهدایی</option>
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="مبلغ فروش">
|
||
<MoneyInput {...form.register("sale_amount")} />
|
||
</FormField>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AssetRevaluationsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const schema = z.object({
|
||
asset_id: req,
|
||
revaluation_date: req,
|
||
old_book_value: req,
|
||
new_book_value: req,
|
||
});
|
||
const form = useForm<z.infer<typeof schema>>({
|
||
resolver: zodResolver(schema),
|
||
defaultValues: { asset_id: "", revaluation_date: today(), old_book_value: "0", new_book_value: "0" },
|
||
});
|
||
const assetsQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "assets"],
|
||
queryFn: () => accountingApi.assets.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "asset-revaluations"],
|
||
queryFn: () => accountingApi.assets.listRevaluations(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (v: z.infer<typeof schema>) =>
|
||
accountingApi.assets.createRevaluation(tenantId!, {
|
||
...v,
|
||
old_book_value: parseMoneyInput(v.old_book_value) || "0",
|
||
new_book_value: parseMoneyInput(v.new_book_value) || "0",
|
||
}),
|
||
onSuccess: async () => {
|
||
toast.success("ارزشگذاری ثبت شد");
|
||
setOpen(false);
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "asset-revaluations"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading || assetsQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
const names = new Map((assetsQ.data ?? []).map((a) => [a.id, `${a.code} — ${a.name}`]));
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="ارزشگذاری دارایی"
|
||
description="ثبت تجدید ارزیابی ارزش دفتری دارایی."
|
||
actions={
|
||
<Button onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
ارزشگذاری جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "asset_id", header: "دارایی", render: (r) => names.get(String(r.asset_id)) || String(r.asset_id) },
|
||
{
|
||
key: "revaluation_date",
|
||
header: "تاریخ",
|
||
render: (r) => <JalaliDateText value={String(r.revaluation_date)} />,
|
||
},
|
||
{ key: "old_book_value", header: "ارزش قبلی", render: (r) => formatMoney(String(r.old_book_value)) },
|
||
{ key: "new_book_value", header: "ارزش جدید", render: (r) => formatMoney(String(r.new_book_value)) },
|
||
{ key: "difference", header: "اختلاف", render: (r) => formatMoney(String(r.difference)) },
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="ارزشگذاری ثبت نشده" action={<Button onClick={() => setOpen(true)}>ثبت</Button>} />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت ارزشگذاری" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="دارایی">
|
||
<Select {...form.register("asset_id")}>
|
||
<option value="">انتخاب…</option>
|
||
{(assetsQ.data ?? []).map((a) => (
|
||
<option key={a.id} value={a.id}>
|
||
{a.code} — {a.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="تاریخ">
|
||
<Controller
|
||
control={form.control}
|
||
name="revaluation_date"
|
||
render={({ field }) => <DatePicker {...field} />}
|
||
/>
|
||
</FormField>
|
||
<FormField label="ارزش دفتری قبلی">
|
||
<MoneyInput {...form.register("old_book_value")} />
|
||
</FormField>
|
||
<FormField label="ارزش دفتری جدید">
|
||
<MoneyInput {...form.register("new_book_value")} />
|
||
</FormField>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AssetAccumulatedPage() {
|
||
const { tenantId } = useTenantId();
|
||
const assetsQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "assets"],
|
||
queryFn: () => accountingApi.assets.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
if (!tenantId || assetsQ.isLoading) return <LoadingState />;
|
||
if (assetsQ.error) return <ErrorState message={assetsQ.error.message} onRetry={() => assetsQ.refetch()} />;
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="استهلاک انباشته"
|
||
description="داراییهای فعال؛ برنامه استهلاک از صفحه برنامه استهلاک یا دکمههای دارایی قابل مشاهده است."
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "code", header: "کد" },
|
||
{ key: "name", header: "نام" },
|
||
{ key: "status", header: "وضعیت", render: (r) => <Badge>{String(r.status)}</Badge> },
|
||
]}
|
||
rows={(assetsQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="دارایی ثبت نشده" />}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* -------------------- Payroll masters -------------------- */
|
||
|
||
export function PayrollContractsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const schema = z.object({
|
||
employee_id: req,
|
||
contract_type: req,
|
||
start_date: req,
|
||
end_date: z.string().optional(),
|
||
base_salary: req,
|
||
});
|
||
const form = useForm<z.infer<typeof schema>>({
|
||
resolver: zodResolver(schema),
|
||
defaultValues: {
|
||
employee_id: "",
|
||
contract_type: "permanent",
|
||
start_date: today(),
|
||
end_date: "",
|
||
base_salary: "0",
|
||
},
|
||
});
|
||
const employeesQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "payroll-employees"],
|
||
queryFn: () => accountingApi.payroll.listEmployees(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "payroll-contracts"],
|
||
queryFn: () => accountingApi.payroll.listContracts(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (v: z.infer<typeof schema>) =>
|
||
accountingApi.payroll.createContract(tenantId!, {
|
||
...v,
|
||
end_date: v.end_date || undefined,
|
||
base_salary: parseMoneyInput(v.base_salary) || "0",
|
||
}),
|
||
onSuccess: async () => {
|
||
toast.success("قرارداد ثبت شد");
|
||
setOpen(false);
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payroll-contracts"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading || employeesQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
const names = new Map((employeesQ.data ?? []).map((e) => [e.id, `${e.code} — ${e.name}`]));
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="تعریف قرارداد"
|
||
description="قراردادهای استخدامی کارکنان و حقوق پایه قراردادی."
|
||
actions={
|
||
<Button onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
قرارداد جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "employee_id", header: "کارمند", render: (r) => names.get(String(r.employee_id)) || String(r.employee_id) },
|
||
{ key: "contract_type", header: "نوع" },
|
||
{ key: "start_date", header: "شروع", render: (r) => <JalaliDateText value={String(r.start_date)} /> },
|
||
{ key: "base_salary", header: "حقوق پایه", render: (r) => formatMoney(String(r.base_salary)) },
|
||
{
|
||
key: "is_active",
|
||
header: "وضعیت",
|
||
render: (r) => <Badge tone={r.is_active ? "success" : "default"}>{r.is_active ? "فعال" : "غیرفعال"}</Badge>,
|
||
},
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="قراردادی ثبت نشده" action={<Button onClick={() => setOpen(true)}>ثبت</Button>} />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="قرارداد جدید" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="کارمند">
|
||
<Select {...form.register("employee_id")}>
|
||
<option value="">انتخاب…</option>
|
||
{(employeesQ.data ?? []).map((e) => (
|
||
<option key={e.id} value={e.id}>
|
||
{e.code} — {e.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="نوع قرارداد">
|
||
<Select {...form.register("contract_type")}>
|
||
<option value="permanent">دائم</option>
|
||
<option value="temporary">موقت</option>
|
||
<option value="hourly">ساعتی</option>
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="شروع">
|
||
<Controller control={form.control} name="start_date" render={({ field }) => <DatePicker {...field} />} />
|
||
</FormField>
|
||
<FormField label="پایان (اختیاری)">
|
||
<Controller control={form.control} name="end_date" render={({ field }) => <DatePicker {...field} />} />
|
||
</FormField>
|
||
<FormField label="حقوق پایه">
|
||
<MoneyInput {...form.register("base_salary")} />
|
||
</FormField>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PayrollComponentsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const schema = z.object({
|
||
code: req,
|
||
name: req,
|
||
component_type: req,
|
||
is_taxable: z.boolean().optional(),
|
||
});
|
||
const form = useForm<z.infer<typeof schema>>({
|
||
resolver: zodResolver(schema),
|
||
defaultValues: { code: "", name: "", component_type: "earning", is_taxable: true },
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "salary-components"],
|
||
queryFn: () => accountingApi.payroll.listSalaryComponents(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (v: z.infer<typeof schema>) => accountingApi.payroll.createSalaryComponent(tenantId!, v),
|
||
onSuccess: async () => {
|
||
toast.success("قلم حقوق ثبت شد");
|
||
setOpen(false);
|
||
form.reset({ code: "", name: "", component_type: "earning", is_taxable: true });
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "salary-components"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="اقلام حقوق و مزایا"
|
||
description="تعریف اجزای حقوق: مزایا، کسور و اقلام مشمول مالیات."
|
||
actions={
|
||
<Button onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
قلم جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "code", header: "کد" },
|
||
{ key: "name", header: "نام" },
|
||
{ key: "component_type", header: "نوع" },
|
||
{
|
||
key: "is_taxable",
|
||
header: "مشمول مالیات",
|
||
render: (r) => (r.is_taxable ? "بله" : "خیر"),
|
||
},
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="قلمی تعریف نشده" action={<Button onClick={() => setOpen(true)}>ثبت</Button>} />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="قلم حقوق جدید">
|
||
<form className="grid gap-3" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="کد">
|
||
<Input {...form.register("code")} />
|
||
</FormField>
|
||
<FormField label="نام">
|
||
<Input {...form.register("name")} />
|
||
</FormField>
|
||
<FormField label="نوع">
|
||
<Select {...form.register("component_type")}>
|
||
<option value="earning">مزد/مزایا</option>
|
||
<option value="deduction">کسور</option>
|
||
<option value="employer">سهم کارفرما</option>
|
||
</Select>
|
||
</FormField>
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<input type="checkbox" {...form.register("is_taxable")} />
|
||
مشمول مالیات
|
||
</label>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PayrollStructuresPage() {
|
||
return <PayrollComponentsPage />;
|
||
}
|
||
|
||
export function PayrollPaymentsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "payrolls"],
|
||
queryFn: () => accountingApi.payroll.listPayrolls(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const employeesQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "payroll-employees"],
|
||
queryFn: () => accountingApi.payroll.listEmployees(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const postM = useMutation({
|
||
mutationFn: (id: string) => accountingApi.payroll.post(tenantId!, id),
|
||
onSuccess: async () => {
|
||
toast.success("حقوق به حسابداری ارسال شد");
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payrolls"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
const names = new Map((employeesQ.data ?? []).map((e) => [e.id, e.name]));
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="پرداخت حقوق"
|
||
description="لیست محاسبات حقوق و ارسال به دفتر از طریق Posting Engine."
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "employee_id", header: "کارمند", render: (r) => names.get(String(r.employee_id)) || String(r.employee_id) },
|
||
{ key: "gross_salary", header: "ناخالص", render: (r) => formatMoney(String(r.gross_salary)) },
|
||
{ key: "net_salary", header: "خالص", render: (r) => formatMoney(String(r.net_salary)) },
|
||
{ key: "status", header: "وضعیت", render: (r) => <Badge>{String(r.status)}</Badge> },
|
||
{
|
||
key: "id",
|
||
header: "عملیات",
|
||
render: (r) =>
|
||
r.status !== "posted" ? (
|
||
<Button size="sm" variant="outline" onClick={() => postM.mutate(String(r.id))} disabled={postM.isPending}>
|
||
ثبت حسابداری
|
||
</Button>
|
||
) : (
|
||
<span className="text-xs text-[var(--muted)]">ارسال شده</span>
|
||
),
|
||
},
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="حقوقی محاسبه نشده — از محاسبه حقوق شروع کنید" />}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PayrollCalculatePage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const schema = z.object({ payroll_period_id: req, employee_id: req });
|
||
const form = useForm<z.infer<typeof schema>>({
|
||
resolver: zodResolver(schema),
|
||
defaultValues: { payroll_period_id: "", employee_id: "" },
|
||
});
|
||
const [result, setResult] = useState<{ id: string; gross_salary: string; net_salary: string; status: string } | null>(
|
||
null
|
||
);
|
||
const employeesQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "payroll-employees"],
|
||
queryFn: () => accountingApi.payroll.listEmployees(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const periodsQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "payroll-periods"],
|
||
queryFn: () => accountingApi.payroll.listPeriods(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const calcM = useMutation({
|
||
mutationFn: (v: z.infer<typeof schema>) => accountingApi.payroll.calculate(tenantId!, v),
|
||
onSuccess: async (data) => {
|
||
setResult(data);
|
||
toast.success("محاسبه انجام شد");
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payrolls"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || employeesQ.isLoading || periodsQ.isLoading) return <LoadingState />;
|
||
if (employeesQ.error || periodsQ.error)
|
||
return (
|
||
<ErrorState
|
||
message={(employeesQ.error || periodsQ.error)!.message}
|
||
onRetry={() => {
|
||
employeesQ.refetch();
|
||
periodsQ.refetch();
|
||
}}
|
||
/>
|
||
);
|
||
return (
|
||
<div>
|
||
<PageHeader title="محاسبه حقوق" description="اجرای موتور حقوق برای یک کارمند در دوره انتخابی." />
|
||
<form
|
||
className="mb-6 grid max-w-3xl gap-3 rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-4 sm:grid-cols-3"
|
||
onSubmit={form.handleSubmit((v) => calcM.mutate(v))}
|
||
>
|
||
<FormField label="دوره">
|
||
<Select {...form.register("payroll_period_id")}>
|
||
<option value="">انتخاب…</option>
|
||
{(periodsQ.data ?? []).map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="کارمند">
|
||
<Select {...form.register("employee_id")}>
|
||
<option value="">انتخاب…</option>
|
||
{(employeesQ.data ?? []).map((e) => (
|
||
<option key={e.id} value={e.id}>
|
||
{e.code} — {e.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<div className="flex items-end">
|
||
<Button type="submit" disabled={calcM.isPending} className="w-full">
|
||
محاسبه
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
{result ? (
|
||
<div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-muted)] p-4 text-sm">
|
||
ناخالص: {formatMoney(result.gross_salary)} — خالص: {formatMoney(result.net_salary)} — وضعیت: {result.status}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* -------------------- Compliance -------------------- */
|
||
|
||
export function ComplianceApprovalsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const [wfOpen, setWfOpen] = useState(false);
|
||
const reqSchema = z.object({ workflow_id: req, resource_type: req, resource_id: req });
|
||
const wfSchema = z.object({ name: req, resource_type: req });
|
||
const reqForm = useForm<z.infer<typeof reqSchema>>({
|
||
resolver: zodResolver(reqSchema),
|
||
defaultValues: { workflow_id: "", resource_type: "voucher", resource_id: "" },
|
||
});
|
||
const wfForm = useForm<z.infer<typeof wfSchema>>({
|
||
resolver: zodResolver(wfSchema),
|
||
defaultValues: { name: "", resource_type: "voucher" },
|
||
});
|
||
const workflowsQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "workflows"],
|
||
queryFn: () => accountingApi.compliance.listWorkflows(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "approvals"],
|
||
queryFn: () => accountingApi.compliance.listApprovals(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createWf = useMutation({
|
||
mutationFn: (v: z.infer<typeof wfSchema>) => accountingApi.compliance.createWorkflow(tenantId!, v),
|
||
onSuccess: async () => {
|
||
toast.success("گردش کار ایجاد شد");
|
||
setWfOpen(false);
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "workflows"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
const createReq = useMutation({
|
||
mutationFn: (v: z.infer<typeof reqSchema>) => accountingApi.compliance.requestApproval(tenantId!, v),
|
||
onSuccess: async () => {
|
||
toast.success("درخواست تأیید ثبت شد");
|
||
setOpen(false);
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "approvals"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
const approveM = useMutation({
|
||
mutationFn: (id: string) => accountingApi.compliance.approve(tenantId!, id),
|
||
onSuccess: async () => {
|
||
toast.success("تأیید شد");
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "approvals"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
const rejectM = useMutation({
|
||
mutationFn: (id: string) => accountingApi.compliance.reject(tenantId!, id, "رد از رابط کاربری"),
|
||
onSuccess: async () => {
|
||
toast.success("رد شد");
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "approvals"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading || workflowsQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="گردش تأیید"
|
||
description="تعریف گردش کار تأیید و درخواست/تأیید/رد با کنترل SoD."
|
||
actions={
|
||
<>
|
||
<Button variant="outline" onClick={() => setWfOpen(true)}>
|
||
گردش کار
|
||
</Button>
|
||
<Button onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
درخواست تأیید
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "resource_type", header: "نوع منبع" },
|
||
{ key: "resource_id", header: "شناسه" },
|
||
{ key: "requested_by", header: "درخواستکننده" },
|
||
{ key: "status", header: "وضعیت", render: (r) => <Badge>{String(r.status)}</Badge> },
|
||
{
|
||
key: "id",
|
||
header: "عملیات",
|
||
render: (r) =>
|
||
r.status === "pending" ? (
|
||
<div className="flex gap-2">
|
||
<Button size="sm" onClick={() => approveM.mutate(String(r.id))}>
|
||
تأیید
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => rejectM.mutate(String(r.id))}>
|
||
رد
|
||
</Button>
|
||
</div>
|
||
) : null,
|
||
},
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="درخواستی نیست" action={<Button onClick={() => setOpen(true)}>درخواست</Button>} />}
|
||
/>
|
||
<Dialog open={wfOpen} onClose={() => setWfOpen(false)} title="گردش کار جدید">
|
||
<form className="grid gap-3" onSubmit={wfForm.handleSubmit((v) => createWf.mutate(v))}>
|
||
<FormField label="نام">
|
||
<Input {...wfForm.register("name")} />
|
||
</FormField>
|
||
<FormField label="نوع منبع">
|
||
<Input {...wfForm.register("resource_type")} placeholder="voucher" />
|
||
</FormField>
|
||
<Actions onCancel={() => setWfOpen(false)} pending={createWf.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="درخواست تأیید" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={reqForm.handleSubmit((v) => createReq.mutate(v))}>
|
||
<FormField label="گردش کار">
|
||
<Select {...reqForm.register("workflow_id")}>
|
||
<option value="">انتخاب…</option>
|
||
{(workflowsQ.data ?? []).map((w) => (
|
||
<option key={w.id} value={w.id}>
|
||
{w.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="نوع منبع">
|
||
<Input {...reqForm.register("resource_type")} />
|
||
</FormField>
|
||
<FormField label="شناسه منبع" className="sm:col-span-2">
|
||
<Input dir="ltr" {...reqForm.register("resource_id")} />
|
||
</FormField>
|
||
<Actions onCancel={() => setOpen(false)} pending={createReq.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ComplianceSodPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const schema = z.object({
|
||
code: req,
|
||
name: req,
|
||
rule_type: z.string().default("sod"),
|
||
condition_config: req,
|
||
action_config: z.string().optional(),
|
||
});
|
||
const form = useForm<z.infer<typeof schema>>({
|
||
resolver: zodResolver(schema),
|
||
defaultValues: {
|
||
code: "",
|
||
name: "",
|
||
rule_type: "sod",
|
||
condition_config: '{"creator_ne_approver":true}',
|
||
action_config: "",
|
||
},
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "governance-rules"],
|
||
queryFn: () => accountingApi.compliance.listGovernanceRules(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (v: z.infer<typeof schema>) => accountingApi.compliance.createGovernanceRule(tenantId!, v),
|
||
onSuccess: async () => {
|
||
toast.success("قاعده SoD ثبت شد");
|
||
setOpen(false);
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "governance-rules"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="تفکیک وظایف (SoD)"
|
||
description="قواعد حاکمیتی جداسازی نقش ایجادکننده و تأییدکننده."
|
||
actions={
|
||
<Button onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
قاعده جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "code", header: "کد" },
|
||
{ key: "name", header: "نام" },
|
||
{ key: "rule_type", header: "نوع" },
|
||
{ key: "priority", header: "اولویت" },
|
||
{
|
||
key: "is_active",
|
||
header: "فعال",
|
||
render: (r) => <Badge tone={r.is_active ? "success" : "default"}>{r.is_active ? "بله" : "خیر"}</Badge>,
|
||
},
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="قاعدهای نیست" action={<Button onClick={() => setOpen(true)}>ثبت</Button>} />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="قاعده SoD" size="lg">
|
||
<form className="grid gap-3" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="کد">
|
||
<Input {...form.register("code")} />
|
||
</FormField>
|
||
<FormField label="نام">
|
||
<Input {...form.register("name")} />
|
||
</FormField>
|
||
<FormField label="پیکربندی شرط (JSON)">
|
||
<Textarea dir="ltr" {...form.register("condition_config")} />
|
||
</FormField>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ComplianceControlsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const schema = z.object({ code: req, name: req, control_type: req, description: z.string().optional() });
|
||
const form = useForm<z.infer<typeof schema>>({
|
||
resolver: zodResolver(schema),
|
||
defaultValues: { code: "", name: "", control_type: "preventive", description: "" },
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "controls"],
|
||
queryFn: () => accountingApi.compliance.listControls(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (v: z.infer<typeof schema>) => accountingApi.compliance.createControl(tenantId!, v),
|
||
onSuccess: async () => {
|
||
toast.success("کنترل ثبت شد");
|
||
setOpen(false);
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "controls"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="کنترلهای داخلی"
|
||
description="تعریف کنترلهای پیشگیرانه و کشفکننده."
|
||
actions={
|
||
<Button onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
کنترل جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "code", header: "کد" },
|
||
{ key: "name", header: "نام" },
|
||
{ key: "control_type", header: "نوع" },
|
||
{ key: "description", header: "شرح" },
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="کنترلی نیست" action={<Button onClick={() => setOpen(true)}>ثبت</Button>} />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="کنترل جدید" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="کد">
|
||
<Input {...form.register("code")} />
|
||
</FormField>
|
||
<FormField label="نام">
|
||
<Input {...form.register("name")} />
|
||
</FormField>
|
||
<FormField label="نوع">
|
||
<Select {...form.register("control_type")}>
|
||
<option value="preventive">پیشگیرانه</option>
|
||
<option value="detective">کشفکننده</option>
|
||
<option value="corrective">اصلاحی</option>
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="شرح" className="sm:col-span-2">
|
||
<Input {...form.register("description")} />
|
||
</FormField>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ComplianceViolationsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const schema = z.object({
|
||
policy_id: req,
|
||
resource_type: req,
|
||
resource_id: req,
|
||
severity: req,
|
||
description: req,
|
||
});
|
||
const form = useForm<z.infer<typeof schema>>({
|
||
resolver: zodResolver(schema),
|
||
defaultValues: {
|
||
policy_id: "",
|
||
resource_type: "voucher",
|
||
resource_id: "",
|
||
severity: "warning",
|
||
description: "",
|
||
},
|
||
});
|
||
const policiesQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "policies"],
|
||
queryFn: () => accountingApi.compliance.listPolicies(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "violations"],
|
||
queryFn: () => accountingApi.compliance.listViolations(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (v: z.infer<typeof schema>) => accountingApi.compliance.createViolation(tenantId!, v),
|
||
onSuccess: async () => {
|
||
toast.success("تخلف ثبت شد");
|
||
setOpen(false);
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "violations"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading || policiesQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="گزارش تخلفات"
|
||
description="ثبت و پایش نقض سیاستهای انطباق."
|
||
actions={
|
||
<Button onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
تخلف جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "severity", header: "شدت", render: (r) => <Badge tone="warning">{String(r.severity)}</Badge> },
|
||
{ key: "resource_type", header: "منبع" },
|
||
{ key: "resource_id", header: "شناسه" },
|
||
{ key: "description", header: "شرح" },
|
||
{ key: "detected_at", header: "زمان", render: (r) => String(r.detected_at).slice(0, 19) },
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="تخلفی نیست" action={<Button onClick={() => setOpen(true)}>ثبت</Button>} />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت تخلف" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="سیاست">
|
||
<Select {...form.register("policy_id")}>
|
||
<option value="">انتخاب…</option>
|
||
{(policiesQ.data ?? []).map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.code} — {p.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="شدت">
|
||
<Select {...form.register("severity")}>
|
||
<option value="info">اطلاعات</option>
|
||
<option value="warning">هشدار</option>
|
||
<option value="error">خطا</option>
|
||
<option value="critical">بحرانی</option>
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="نوع منبع">
|
||
<Input {...form.register("resource_type")} />
|
||
</FormField>
|
||
<FormField label="شناسه منبع">
|
||
<Input dir="ltr" {...form.register("resource_id")} />
|
||
</FormField>
|
||
<FormField label="شرح" className="sm:col-span-2">
|
||
<Textarea {...form.register("description")} />
|
||
</FormField>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ComplianceRecordsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "audit-records"],
|
||
queryFn: () => accountingApi.compliance.listAudit(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
if (!tenantId || listQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
return (
|
||
<div>
|
||
<PageHeader title="اسناد و سوابق انطباق" description="سوابق ممیزی ثبتشده در چارچوب انطباق." />
|
||
<DataTable
|
||
columns={[
|
||
{ key: "action", header: "عمل" },
|
||
{ key: "resource_type", header: "منبع" },
|
||
{ key: "actor_user_id", header: "کاربر" },
|
||
{ key: "created_at", header: "زمان", render: (r) => String(r.created_at).slice(0, 19) },
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="سابقهای نیست" />}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|