"use client"; import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { Controller, useFieldArray, 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 { Plus, Trash2 } from "lucide-react"; import { accountingApi } from "@/lib/accounting-api"; import { useTenantId } from "@/hooks/useTenantId"; import { formatMoney, toRialInteger } from "@/lib/utils"; import { lineBothSidesError, sumVoucherLines, unbalancedMessage } from "@/lib/voucher-balance"; import { PageHeader, Button, Badge, DataTable, LoadingState, ErrorState, ConfirmDialog, Card, CardContent, JalaliDateText, DatePicker, Input, Label, FieldError, Select, MoneyInput, } from "@/components/ds"; const STATUS_FA: Record = { draft: "پیش‌نویس", validated: "اعتبارسنجی‌شده", posted: "ثبت قطعی", reversed: "برگشتی", cancelled: "لغو شده", }; const lineSchema = z.object({ account_id: z.string().uuid("حساب معتبر انتخاب کنید"), debit: z.string().min(1), credit: z.string().min(1), description: z.string().optional(), cost_center_id: z.string().optional(), project_id: z.string().optional(), }); const editSchema = z.object({ voucher_date: z.string().min(1), description: z.string().optional(), lines: z.array(lineSchema).min(2, "حداقل دو ردیف لازم است"), }); type EditForm = z.infer; export default function VoucherDetailPage() { const params = useParams<{ id: string }>(); const id = params.id; const { tenantId } = useTenantId(); const qc = useQueryClient(); const router = useRouter(); const [confirm, setConfirm] = useState<"post" | "reverse" | "reverse-edit" | "cancel" | null>( null ); const [editing, setEditing] = useState(false); const voucherQ = useQuery({ queryKey: ["accounting", tenantId, "voucher", id], queryFn: () => accountingApi.vouchers.get(tenantId!, id), enabled: !!tenantId && !!id, }); const accountsQ = useQuery({ queryKey: ["accounting", tenantId, "accounts"], queryFn: () => accountingApi.accounts.list(tenantId!), enabled: !!tenantId, }); const costCentersQ = useQuery({ queryKey: ["accounting", tenantId, "cost-centers"], queryFn: () => accountingApi.costCenters.list(tenantId!), enabled: !!tenantId, }); const projectsQ = useQuery({ queryKey: ["accounting", tenantId, "projects"], queryFn: () => accountingApi.projects.list(tenantId!), enabled: !!tenantId, }); const editForm = useForm({ resolver: zodResolver(editSchema), defaultValues: { voucher_date: "", description: "", lines: [] }, }); const { fields, append, remove } = useFieldArray({ control: editForm.control, name: "lines" }); useEffect(() => { const v = voucherQ.data; if (!v || v.status !== "draft") return; editForm.reset({ voucher_date: v.voucher_date, description: v.description ?? "", lines: v.lines.map((l) => ({ account_id: l.account_id, debit: toRialInteger(l.debit) || "0", credit: toRialInteger(l.credit) || "0", description: l.description ?? "", cost_center_id: l.cost_center_id ?? "", project_id: l.project_id ?? "", })), }); }, [voucherQ.data, editForm]); const invalidate = async () => { await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "voucher", id] }); await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] }); }; const updateM = useMutation({ mutationFn: (data: EditForm) => { const bal = sumVoucherLines(data.lines); if (!bal.isBalanced) throw new Error(unbalancedMessage(bal.debit, bal.credit)); for (let i = 0; i < data.lines.length; i++) { const err = lineBothSidesError(data.lines[i].debit, data.lines[i].credit); if (err) throw new Error(`ردیف ${i + 1}: ${err}`); } return accountingApi.vouchers.update(tenantId!, id, { voucher_date: data.voucher_date, description: data.description || undefined, lines: data.lines.map((l) => ({ account_id: l.account_id, debit: toRialInteger(l.debit) || "0", credit: toRialInteger(l.credit) || "0", description: l.description || undefined, cost_center_id: l.cost_center_id || null, project_id: l.project_id || null, })), }); }, onSuccess: async () => { toast.success("سند به‌روز شد"); setEditing(false); await invalidate(); }, onError: (e: Error) => toast.error(e.message), }); const validateM = useMutation({ mutationFn: () => accountingApi.vouchers.validate(tenantId!, id), onSuccess: async () => { toast.success("سند اعتبارسنجی شد"); await invalidate(); }, onError: (e: Error) => toast.error(e.message), }); const postM = useMutation({ mutationFn: () => accountingApi.vouchers.post(tenantId!, id, "manual"), onSuccess: async () => { toast.success("سند ثبت قطعی شد"); setConfirm(null); await invalidate(); }, onError: (e: Error) => toast.error(e.message), }); const reverseM = useMutation({ mutationFn: () => accountingApi.vouchers.reverse(tenantId!, id), onSuccess: async () => { toast.success("سند برگشت خورد (سند معکوس ثبت شد)"); setConfirm(null); await invalidate(); }, onError: (e: Error) => toast.error(e.message), }); const reverseEditM = useMutation({ mutationFn: () => accountingApi.vouchers.reverseAndEdit(tenantId!, id), onSuccess: async (draft) => { toast.success("سند برگشت خورد؛ پیش‌نویس اصلاحی باز شد"); setConfirm(null); await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] }); router.push(`/accounting/vouchers/${draft.id}`); }, onError: (e: Error) => toast.error(e.message), }); const cancelM = useMutation({ mutationFn: () => accountingApi.vouchers.cancel(tenantId!, id), onSuccess: async () => { toast.success("سند لغو شد"); setConfirm(null); await invalidate(); }, onError: (e: Error) => toast.error(e.message), }); const watchedEditLines = editForm.watch("lines"); const balance = useMemo(() => sumVoucherLines(watchedEditLines), [watchedEditLines]); if (!tenantId || voucherQ.isLoading) return ; if (voucherQ.error) return voucherQ.refetch()} />; const v = voucherQ.data!; const accountMap = new Map((accountsQ.data ?? []).map((a) => [a.id, `${a.code} — ${a.name}`])); const postable = (accountsQ.data ?? []).filter((a) => a.is_postable); const activeCostCenters = (costCentersQ.data ?? []).filter((c) => c.is_active); const activeProjects = (projectsQ.data ?? []).filter((p) => p.is_active); const isDraft = v.status === "draft"; return (
{isDraft ? ( ) : null} {v.status === "draft" || v.status === "validated" ? ( ) : null} {v.status === "draft" || v.status === "validated" ? ( ) : null} {v.status === "posted" ? ( <> ) : null} {v.status === "draft" || v.status === "validated" ? ( ) : null}
} />

وضعیت

{STATUS_FA[v.status] ?? v.status}

تاریخ

جمع بدهکار

{formatMoney(v.total_debit)}

جمع بستانکار

{formatMoney(v.total_credit)}

{isDraft && editing ? (
updateM.mutate(d))}>
( )} /> {editForm.formState.errors.voucher_date?.message}

ردیف‌های سند

{fields.map((field, index) => { const lineErr = lineBothSidesError( watchedEditLines?.[index]?.debit ?? "0", watchedEditLines?.[index]?.credit ?? "0" ); return (
{lineErr ? (

ردیف {index + 1}: {lineErr}

) : null}
); })} {editForm.formState.errors.lines?.message || editForm.formState.errors.lines?.root?.message}
جمع بدهکار: {formatMoney(String(balance.debit))} جمع بستانکار: {formatMoney(String(balance.credit))} اختلاف: {formatMoney(String(Math.abs(balance.difference)))} {balance.isBalanced ? "متوازن ✓" : "نامتوازن — ذخیره مجاز نیست"}
{!balance.isBalanced ? (

{unbalancedMessage(balance.debit, balance.credit)}

) : null}
) : ( accountMap.get(String(r.account_id)) || String(r.account_id), }, { key: "debit", header: "بدهکار", render: (r) => formatMoney(String(r.debit)) }, { key: "credit", header: "بستانکار", render: (r) => formatMoney(String(r.credit)) }, { key: "cost_center_id", header: "مرکز هزینه", render: (r) => { const cid = r.cost_center_id ? String(r.cost_center_id) : ""; if (!cid) return "—"; const cc = (costCentersQ.data ?? []).find((c) => c.id === cid); return cc ? `${cc.code} — ${cc.name}` : "—"; }, }, { key: "description", header: "شرح" }, ]} rows={(v.lines ?? []) as unknown as Record[]} /> )} setConfirm(null)} title="ثبت قطعی سند؟" description="پس از ثبت، سند قابل ویرایش مستقیم نیست. در صورت اشتباه می‌توانید «برگشت و ویرایش» کنید." confirmLabel="ثبت قطعی" loading={postM.isPending} onConfirm={() => postM.mutate()} /> setConfirm(null)} title="فقط برگشت سند؟" description="یک سند معکوس ثبت می‌شود و این سند به وضعیت برگشتی می‌رود. برای اصلاح محتوا از «برگشت و ویرایش» استفاده کنید." confirmLabel="تأیید برگشت" danger loading={reverseM.isPending} onConfirm={() => reverseM.mutate()} /> setConfirm(null)} title="برگشت و باز کردن برای ویرایش؟" description="سند قطعی برگشت می‌خورد، سند معکوس ثبت می‌شود، و یک پیش‌نویس جدید با همان ردیف‌ها برای اصلاح باز می‌شود. این کار قابل بازگشت آسان نیست." confirmLabel="برگشت و ویرایش" danger loading={reverseEditM.isPending} onConfirm={() => reverseEditM.mutate()} /> setConfirm(null)} title="لغو / حذف سند پیش‌نویس؟" description="سند پیش‌نویس لغو می‌شود و دیگر قابل ثبت قطعی نیست. اسناد قطعی‌شده قابل حذف فیزیکی نیستند." confirmLabel="لغو سند" danger loading={cancelM.isPending} onConfirm={() => cancelM.mutate()} /> ); }