TorbatYar/frontend/app/accounting/payroll/page.tsx
Mortezakoohjani 12c8615615 Ship enterprise Accounting FE/API with CRUD parity and production wiring.
Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 15:26:43 +03:30

458 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Calculator, Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
Dialog,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Tabs,
FormField,
Select,
MoneyInput,
DatePicker,
JalaliDateText,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ds";
const deptSchema = z.object({
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
});
const employeeSchema = z.object({
employee_code: z.string().min(1, "کد پرسنلی الزامی است"),
first_name: z.string().min(1, "نام الزامی است"),
last_name: z.string().min(1, "نام خانوادگی الزامی است"),
base_salary: z.string().optional(),
department_id: z.string().optional(),
});
const periodSchema = z.object({
name: z.string().min(1, "نام دوره الزامی است"),
start_date: z.string().min(1, "تاریخ شروع الزامی است"),
end_date: z.string().min(1, "تاریخ پایان الزامی است"),
});
const calcSchema = z.object({
payroll_period_id: z.string().min(1, "دوره حقوق را انتخاب کنید"),
employee_id: z.string().min(1, "کارمند را انتخاب کنید"),
});
type TabId = "departments" | "employees" | "periods" | "operations";
export default function PayrollPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [tab, setTab] = useState<TabId>("employees");
const [deptOpen, setDeptOpen] = useState(false);
const [employeeOpen, setEmployeeOpen] = useState(false);
const [periodOpen, setPeriodOpen] = useState(false);
const [lastPayroll, setLastPayroll] = useState<{
id: string;
gross_salary: string;
net_salary: string;
status: string;
} | null>(null);
const departmentsQ = useQuery({
queryKey: ["accounting", tenantId, "payroll-departments"],
queryFn: () => accountingApi.payroll.listDepartments(tenantId!),
enabled: !!tenantId,
});
const employeesQ = useQuery({
queryKey: ["accounting", tenantId, "employees"],
queryFn: () => accountingApi.payroll.listEmployees(tenantId!),
enabled: !!tenantId,
});
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "payroll-periods"],
queryFn: () => accountingApi.payroll.listPeriods(tenantId!),
enabled: !!tenantId,
});
const deptForm = useForm<z.infer<typeof deptSchema>>({
resolver: zodResolver(deptSchema),
defaultValues: { code: "", name: "" },
});
const employeeForm = useForm<z.infer<typeof employeeSchema>>({
resolver: zodResolver(employeeSchema),
defaultValues: { employee_code: "", first_name: "", last_name: "", base_salary: "0", department_id: "" },
});
const periodForm = useForm<z.infer<typeof periodSchema>>({
resolver: zodResolver(periodSchema),
defaultValues: { name: "", start_date: "", end_date: "" },
});
const calcForm = useForm<z.infer<typeof calcSchema>>({
resolver: zodResolver(calcSchema),
defaultValues: { payroll_period_id: "", employee_id: "" },
});
const invalidateDepts = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payroll-departments"] });
const invalidateEmployees = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "employees"] });
const invalidatePeriods = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payroll-periods"] });
const createDeptM = useMutation({
mutationFn: (d: z.infer<typeof deptSchema>) => accountingApi.payroll.createDepartment(tenantId!, d),
onSuccess: async () => {
toast.success("واحد سازمانی ثبت شد");
setDeptOpen(false);
deptForm.reset();
await invalidateDepts();
},
onError: (e: Error) => toast.error(e.message),
});
const createEmployeeM = useMutation({
mutationFn: (d: z.infer<typeof employeeSchema>) =>
accountingApi.payroll.createEmployee(tenantId!, {
employee_code: d.employee_code,
first_name: d.first_name,
last_name: d.last_name,
base_salary: d.base_salary,
department_id: d.department_id || undefined,
}),
onSuccess: async () => {
toast.success("کارمند ثبت شد");
setEmployeeOpen(false);
employeeForm.reset();
await invalidateEmployees();
},
onError: (e: Error) => toast.error(e.message),
});
const createPeriodM = useMutation({
mutationFn: (d: z.infer<typeof periodSchema>) => accountingApi.payroll.createPeriod(tenantId!, d),
onSuccess: async () => {
toast.success("دوره حقوق ایجاد شد");
setPeriodOpen(false);
periodForm.reset();
await invalidatePeriods();
},
onError: (e: Error) => toast.error(e.message),
});
const calculateM = useMutation({
mutationFn: (d: z.infer<typeof calcSchema>) => accountingApi.payroll.calculate(tenantId!, d),
onSuccess: (res) => {
setLastPayroll(res);
toast.success("حقوق محاسبه شد");
},
onError: (e: Error) => toast.error(e.message),
});
const postM = useMutation({
mutationFn: (payrollId: string) => accountingApi.payroll.post(tenantId!, payrollId),
onSuccess: (res) => {
toast.success("حقوق ثبت حسابداری شد");
setLastPayroll((prev) => (prev ? { ...prev, status: res.status } : prev));
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId) return <LoadingState />;
const loading = departmentsQ.isLoading || employeesQ.isLoading || periodsQ.isLoading;
if (loading) return <LoadingState />;
const err = departmentsQ.error || employeesQ.error || periodsQ.error;
if (err) {
return (
<ErrorState
message={err instanceof Error ? err.message : "خطا"}
onRetry={() => {
void departmentsQ.refetch();
void employeesQ.refetch();
void periodsQ.refetch();
}}
/>
);
}
const tabActions: Record<TabId, React.ReactNode> = {
departments: (
<Button onClick={() => setDeptOpen(true)}>
<Plus className="h-4 w-4" />
واحد جدید
</Button>
),
employees: (
<Button onClick={() => setEmployeeOpen(true)}>
<Plus className="h-4 w-4" />
کارمند جدید
</Button>
),
periods: (
<Button onClick={() => setPeriodOpen(true)}>
<Plus className="h-4 w-4" />
دوره جدید
</Button>
),
operations: null,
};
const periodStatusTone = (s: string) =>
s === "open" ? "success" : s === "closed" ? "danger" : "default";
return (
<div>
<PageHeader
title="حقوق و دستمزد"
description="واحدها، کارکنان، دوره‌ها و ثبت حقوق."
actions={tabActions[tab]}
/>
<Tabs
className="mb-6"
value={tab}
onChange={(id) => setTab(id as TabId)}
items={[
{ id: "departments", label: "واحدها" },
{ id: "employees", label: "کارکنان" },
{ id: "periods", label: "دوره‌ها" },
{ id: "operations", label: "محاسبه و ثبت" },
]}
/>
{tab === "departments" && (
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
]}
rows={(departmentsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState action={<Button onClick={() => setDeptOpen(true)}>ثبت واحد</Button>} />}
/>
)}
{tab === "employees" && (
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
]}
rows={(employeesQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState action={<Button onClick={() => setEmployeeOpen(true)}>ثبت کارمند</Button>} />}
/>
)}
{tab === "periods" && (
<DataTable
columns={[
{ key: "name", header: "نام" },
{
key: "start_date",
header: "شروع",
render: (r) => <JalaliDateText value={String(r.start_date ?? "")} />,
},
{
key: "end_date",
header: "پایان",
render: (r) => <JalaliDateText value={String(r.end_date ?? "")} />,
},
{
key: "status",
header: "وضعیت",
render: (r) => (
<Badge tone={periodStatusTone(String(r.status))}>{String(r.status)}</Badge>
),
},
]}
rows={(periodsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState action={<Button onClick={() => setPeriodOpen(true)}>ایجاد دوره</Button>} />}
/>
)}
{tab === "operations" && (
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calculator className="h-4 w-4" />
محاسبه حقوق
</CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-3" onSubmit={calcForm.handleSubmit((d) => calculateM.mutate(d))}>
<FormField label="دوره حقوق" error={calcForm.formState.errors.payroll_period_id?.message}>
<Select {...calcForm.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="کارمند" error={calcForm.formState.errors.employee_id?.message}>
<Select {...calcForm.register("employee_id")}>
<option value="">انتخاب کارمند</option>
{(employeesQ.data ?? []).map((e) => (
<option key={e.id} value={e.id}>
{e.code} {e.name}
</option>
))}
</Select>
</FormField>
<Button type="submit" disabled={calculateM.isPending} className="w-full sm:w-auto">
محاسبه
</Button>
</form>
</CardContent>
</Card>
{lastPayroll && (
<Card>
<CardHeader>
<CardTitle>نتیجه محاسبه</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-[var(--muted)]">ناخالص</span>
<span className="font-medium">{formatMoney(lastPayroll.gross_salary)}</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--muted)]">خالص</span>
<span className="font-medium">{formatMoney(lastPayroll.net_salary)}</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--muted)]">وضعیت</span>
<Badge>{lastPayroll.status}</Badge>
</div>
<Button
type="button"
className="w-full"
disabled={postM.isPending || lastPayroll.status === "posted"}
onClick={() => postM.mutate(lastPayroll.id)}
>
ثبت حسابداری (Post)
</Button>
</CardContent>
</Card>
)}
</div>
)}
<Dialog open={deptOpen} onClose={() => setDeptOpen(false)} title="واحد سازمانی جدید">
<form className="space-y-3" onSubmit={deptForm.handleSubmit((d) => createDeptM.mutate(d))}>
<FormField label="کد" error={deptForm.formState.errors.code?.message}>
<Input {...deptForm.register("code")} />
</FormField>
<FormField label="نام" error={deptForm.formState.errors.name?.message}>
<Input {...deptForm.register("name")} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDeptOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createDeptM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={employeeOpen} onClose={() => setEmployeeOpen(false)} title="کارمند جدید">
<form className="space-y-3" onSubmit={employeeForm.handleSubmit((d) => createEmployeeM.mutate(d))}>
<FormField label="کد پرسنلی" error={employeeForm.formState.errors.employee_code?.message}>
<Input {...employeeForm.register("employee_code")} />
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="نام" error={employeeForm.formState.errors.first_name?.message}>
<Input {...employeeForm.register("first_name")} />
</FormField>
<FormField label="نام خانوادگی" error={employeeForm.formState.errors.last_name?.message}>
<Input {...employeeForm.register("last_name")} />
</FormField>
</div>
<FormField label="واحد">
<Select {...employeeForm.register("department_id")}>
<option value="">بدون واحد</option>
{(departmentsQ.data ?? []).map((d) => (
<option key={d.id} value={d.id}>
{d.code} {d.name}
</option>
))}
</Select>
</FormField>
<FormField label="حقوق پایه">
<MoneyInput {...employeeForm.register("base_salary")} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEmployeeOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createEmployeeM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={periodOpen} onClose={() => setPeriodOpen(false)} title="دوره حقوق جدید">
<form className="space-y-3" onSubmit={periodForm.handleSubmit((d) => createPeriodM.mutate(d))}>
<FormField label="نام دوره" error={periodForm.formState.errors.name?.message}>
<Input {...periodForm.register("name")} />
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="شروع" error={periodForm.formState.errors.start_date?.message}>
<Controller
control={periodForm.control}
name="start_date"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
<FormField label="پایان" error={periodForm.formState.errors.end_date?.message}>
<Controller
control={periodForm.control}
name="end_date"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setPeriodOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createPeriodM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
</div>
);
}