TorbatYar/frontend/components/accounting/WorkflowScreens.tsx
Mortezakoohjani 97a363fc53 Wire purchase returns, inventory issues, and GL transfers with auto vouchers.
Goods receipts and warehouse receipts now capture party vs warehouse separately; purchase returns link to invoices and reduce AP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 19:51:32 +03:30

1133 lines
46 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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: req,
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>
);
}