"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 (
); } /* -------------------- 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>({ 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) => 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 ; if (listQ.error || assetsQ.error) return listQ.refetch()} />; const names = new Map((assetsQ.data ?? []).map((a) => [a.id, `${a.code} — ${a.name}`])); return (
setOpen(true)}> انتقال جدید } /> names.get(String(r.asset_id)) || String(r.asset_id) }, { key: "transfer_date", header: "تاریخ", render: (r) => }, { key: "from_location", header: "از" }, { key: "to_location", header: "به" }, ]} rows={(listQ.data ?? []) as unknown as Record[]} empty={ setOpen(true)}> انتقال جدید } /> } /> setOpen(false)} title="ثبت انتقال دارایی" size="lg">
createM.mutate(v))}> } /> setOpen(false)} pending={createM.isPending} />
); } 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>({ 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) => 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 ; if (listQ.error || assetsQ.error) return listQ.refetch()} />; const names = new Map((assetsQ.data ?? []).map((a) => [a.id, `${a.code} — ${a.name}`])); return (
setOpen(true)}> ثبت خروج/فروش } /> names.get(String(r.asset_id)) || String(r.asset_id) }, { key: "disposal_date", header: "تاریخ", render: (r) => }, { key: "disposal_type", header: "نوع" }, { key: "sale_amount", header: "مبلغ فروش", render: (r) => formatMoney(String(r.sale_amount ?? "0")) }, ]} rows={(listQ.data ?? []) as unknown as Record[]} empty={ setOpen(true)}>ثبت} />} /> setOpen(false)} title="خروج / فروش دارایی" size="lg">
createM.mutate(v))}> } /> setOpen(false)} pending={createM.isPending} />
); } 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>({ 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) => 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 ; if (listQ.error) return listQ.refetch()} />; const names = new Map((assetsQ.data ?? []).map((a) => [a.id, `${a.code} — ${a.name}`])); return (
setOpen(true)}> ارزش‌گذاری جدید } /> names.get(String(r.asset_id)) || String(r.asset_id) }, { key: "revaluation_date", header: "تاریخ", render: (r) => , }, { 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[]} empty={ setOpen(true)}>ثبت} />} /> setOpen(false)} title="ثبت ارزش‌گذاری" size="lg">
createM.mutate(v))}> } /> setOpen(false)} pending={createM.isPending} />
); } 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 ; if (assetsQ.error) return assetsQ.refetch()} />; return (
{String(r.status)} }, ]} rows={(assetsQ.data ?? []) as unknown as Record[]} empty={} />
); } /* -------------------- 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>({ 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) => 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 ; if (listQ.error) return listQ.refetch()} />; const names = new Map((employeesQ.data ?? []).map((e) => [e.id, `${e.code} — ${e.name}`])); return (
setOpen(true)}> قرارداد جدید } /> names.get(String(r.employee_id)) || String(r.employee_id) }, { key: "contract_type", header: "نوع" }, { key: "start_date", header: "شروع", render: (r) => }, { key: "base_salary", header: "حقوق پایه", render: (r) => formatMoney(String(r.base_salary)) }, { key: "is_active", header: "وضعیت", render: (r) => {r.is_active ? "فعال" : "غیرفعال"}, }, ]} rows={(listQ.data ?? []) as unknown as Record[]} empty={ setOpen(true)}>ثبت} />} /> setOpen(false)} title="قرارداد جدید" size="lg">
createM.mutate(v))}> } /> } /> setOpen(false)} pending={createM.isPending} />
); } 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>({ 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) => 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 ; if (listQ.error) return listQ.refetch()} />; return (
setOpen(true)}> قلم جدید } /> (r.is_taxable ? "بله" : "خیر"), }, ]} rows={(listQ.data ?? []) as unknown as Record[]} empty={ setOpen(true)}>ثبت} />} /> setOpen(false)} title="قلم حقوق جدید">
createM.mutate(v))}> setOpen(false)} pending={createM.isPending} />
); } export function PayrollStructuresPage() { return ; } 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 ; if (listQ.error) return listQ.refetch()} />; const names = new Map((employeesQ.data ?? []).map((e) => [e.id, e.name])); return (
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) => {String(r.status)} }, { key: "id", header: "عملیات", render: (r) => r.status !== "posted" ? ( ) : ( ارسال شده ), }, ]} rows={(listQ.data ?? []) as unknown as Record[]} empty={} />
); } export function PayrollCalculatePage() { const { tenantId } = useTenantId(); const qc = useQueryClient(); const schema = z.object({ payroll_period_id: req, employee_id: req }); const form = useForm>({ 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) => 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 ; if (employeesQ.error || periodsQ.error) return ( { employeesQ.refetch(); periodsQ.refetch(); }} /> ); return (
calcM.mutate(v))} >
{result ? (
ناخالص: {formatMoney(result.gross_salary)} — خالص: {formatMoney(result.net_salary)} — وضعیت: {result.status}
) : null}
); } /* -------------------- 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>({ resolver: zodResolver(reqSchema), defaultValues: { workflow_id: "", resource_type: "voucher", resource_id: "" }, }); const wfForm = useForm>({ 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) => 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) => 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 ; if (listQ.error) return listQ.refetch()} />; return (
} /> {String(r.status)} }, { key: "id", header: "عملیات", render: (r) => r.status === "pending" ? (
) : null, }, ]} rows={(listQ.data ?? []) as unknown as Record[]} empty={ setOpen(true)}>درخواست} />} /> setWfOpen(false)} title="گردش کار جدید">
createWf.mutate(v))}> setWfOpen(false)} pending={createWf.isPending} />
setOpen(false)} title="درخواست تأیید" size="lg">
createReq.mutate(v))}> setOpen(false)} pending={createReq.isPending} />
); } 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>({ 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) => 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 ; if (listQ.error) return listQ.refetch()} />; return (
setOpen(true)}> قاعده جدید } /> {r.is_active ? "بله" : "خیر"}, }, ]} rows={(listQ.data ?? []) as unknown as Record[]} empty={ setOpen(true)}>ثبت} />} /> setOpen(false)} title="قاعده SoD" size="lg">
createM.mutate(v))}>