RHF was caching 0/0 via useMemo on in-place form arrays; MoneyInput now drives Controller. Voucher list exposes source filters for manual vs automatic GL docs. Co-authored-by: Cursor <cursoragent@cursor.com>
551 lines
22 KiB
TypeScript
551 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import type { ChangeEvent } from "react";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { Controller, useFieldArray, useForm, useWatch } 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<string, string> = {
|
||
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<typeof editSchema>;
|
||
|
||
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<EditForm>({
|
||
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 = useWatch({ control: editForm.control, name: "lines" });
|
||
const balance = sumVoucherLines(watchedEditLines);
|
||
|
||
if (!tenantId || voucherQ.isLoading) return <LoadingState />;
|
||
if (voucherQ.error) return <ErrorState message={voucherQ.error.message} onRetry={() => 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 (
|
||
<div>
|
||
<PageHeader
|
||
title={`سند ${v.voucher_number}`}
|
||
description={v.description || "جزئیات سند و اقدامات Posting Engine"}
|
||
actions={
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button type="button" variant="outline" onClick={() => router.push("/accounting/vouchers")}>
|
||
بازگشت
|
||
</Button>
|
||
{isDraft ? (
|
||
<Button type="button" variant="outline" onClick={() => setEditing((e) => !e)}>
|
||
{editing ? "لغو ویرایش" : "ویرایش"}
|
||
</Button>
|
||
) : null}
|
||
{v.status === "draft" || v.status === "validated" ? (
|
||
<Button type="button" variant="outline" disabled={validateM.isPending} onClick={() => validateM.mutate()}>
|
||
اعتبارسنجی
|
||
</Button>
|
||
) : null}
|
||
{v.status === "draft" || v.status === "validated" ? (
|
||
<Button type="button" onClick={() => setConfirm("post")}>
|
||
ثبت قطعی
|
||
</Button>
|
||
) : null}
|
||
{v.status === "posted" ? (
|
||
<>
|
||
<Button type="button" variant="outline" onClick={() => setConfirm("reverse")}>
|
||
فقط برگشت
|
||
</Button>
|
||
<Button type="button" onClick={() => setConfirm("reverse-edit")}>
|
||
برگشت و ویرایش
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
{v.status === "draft" || v.status === "validated" ? (
|
||
<Button type="button" variant="danger" onClick={() => setConfirm("cancel")}>
|
||
لغو / حذف
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
<div className="mb-6 grid gap-4 sm:grid-cols-4">
|
||
<Card>
|
||
<CardContent className="pt-5">
|
||
<p className="text-xs text-[var(--muted)]">وضعیت</p>
|
||
<Badge className="mt-2" tone={v.status === "posted" ? "success" : "default"}>
|
||
{STATUS_FA[v.status] ?? v.status}
|
||
</Badge>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-5">
|
||
<p className="text-xs text-[var(--muted)]">تاریخ</p>
|
||
<p className="mt-2 font-medium">
|
||
<JalaliDateText value={v.voucher_date} />
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-5">
|
||
<p className="text-xs text-[var(--muted)]">جمع بدهکار</p>
|
||
<p className="mt-2 font-medium">{formatMoney(v.total_debit)}</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-5">
|
||
<p className="text-xs text-[var(--muted)]">جمع بستانکار</p>
|
||
<p className="mt-2 font-medium">{formatMoney(v.total_credit)}</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{isDraft && editing ? (
|
||
<form className="space-y-4" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
|
||
<Card>
|
||
<CardContent className="grid gap-4 pt-6 sm:grid-cols-2">
|
||
<div>
|
||
<Label>تاریخ</Label>
|
||
<Controller
|
||
control={editForm.control}
|
||
name="voucher_date"
|
||
render={({ field }) => (
|
||
<DatePicker
|
||
className="mt-1"
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
onBlur={field.onBlur}
|
||
name={field.name}
|
||
/>
|
||
)}
|
||
/>
|
||
<FieldError>{editForm.formState.errors.voucher_date?.message}</FieldError>
|
||
</div>
|
||
<div>
|
||
<Label>شرح</Label>
|
||
<Input className="mt-1" {...editForm.register("description")} />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardContent className="space-y-3 pt-6">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="font-semibold text-secondary">ردیفهای سند</h2>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() =>
|
||
append({
|
||
account_id: "",
|
||
debit: "0",
|
||
credit: "0",
|
||
description: "",
|
||
cost_center_id: "",
|
||
project_id: "",
|
||
})
|
||
}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
ردیف
|
||
</Button>
|
||
</div>
|
||
{fields.map((field, index) => {
|
||
const lineErr = lineBothSidesError(
|
||
watchedEditLines?.[index]?.debit ?? "0",
|
||
watchedEditLines?.[index]?.credit ?? "0"
|
||
);
|
||
return (
|
||
<div
|
||
key={field.id}
|
||
className={`grid gap-2 rounded-xl border p-3 sm:grid-cols-12 ${
|
||
lineErr ? "border-red-300 bg-red-50/40" : "border-[var(--border)]"
|
||
}`}
|
||
>
|
||
<div className="sm:col-span-3">
|
||
<Label>حساب</Label>
|
||
<Select className="mt-1" {...editForm.register(`lines.${index}.account_id`)}>
|
||
<option value="">انتخاب</option>
|
||
{postable.map((a) => (
|
||
<option key={a.id} value={a.id}>
|
||
{a.code} — {a.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>بدهکار</Label>
|
||
<Controller
|
||
control={editForm.control}
|
||
name={`lines.${index}.debit`}
|
||
render={({ field }) => (
|
||
<MoneyInput
|
||
className="mt-1"
|
||
name={field.name}
|
||
ref={field.ref}
|
||
value={field.value ?? "0"}
|
||
onBlur={field.onBlur}
|
||
onChange={(e) => {
|
||
const next =
|
||
typeof e === "object" && e && "target" in e
|
||
? String((e as ChangeEvent<HTMLInputElement>).target.value)
|
||
: String(e ?? "0");
|
||
field.onChange(next);
|
||
}}
|
||
/>
|
||
)}
|
||
/>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>بستانکار</Label>
|
||
<Controller
|
||
control={editForm.control}
|
||
name={`lines.${index}.credit`}
|
||
render={({ field }) => (
|
||
<MoneyInput
|
||
className="mt-1"
|
||
name={field.name}
|
||
ref={field.ref}
|
||
value={field.value ?? "0"}
|
||
onBlur={field.onBlur}
|
||
onChange={(e) => {
|
||
const next =
|
||
typeof e === "object" && e && "target" in e
|
||
? String((e as ChangeEvent<HTMLInputElement>).target.value)
|
||
: String(e ?? "0");
|
||
field.onChange(next);
|
||
}}
|
||
/>
|
||
)}
|
||
/>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>مرکز هزینه</Label>
|
||
<Select className="mt-1" {...editForm.register(`lines.${index}.cost_center_id`)}>
|
||
<option value="">—</option>
|
||
{activeCostCenters.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.code} — {c.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>پروژه</Label>
|
||
<Select className="mt-1" {...editForm.register(`lines.${index}.project_id`)}>
|
||
<option value="">—</option>
|
||
{activeProjects.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.code} — {p.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
<div className="flex items-end sm:col-span-1">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={fields.length <= 2}
|
||
onClick={() => remove(index)}
|
||
aria-label="حذف ردیف"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
{lineErr ? (
|
||
<p className="sm:col-span-12 text-xs text-red-700">ردیف {index + 1}: {lineErr}</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
<FieldError>
|
||
{editForm.formState.errors.lines?.message || editForm.formState.errors.lines?.root?.message}
|
||
</FieldError>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div
|
||
className={`rounded-xl border p-3 text-sm ${
|
||
balance.isBalanced
|
||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||
: "border-red-200 bg-red-50 text-red-800"
|
||
}`}
|
||
>
|
||
<div className="flex flex-wrap gap-4">
|
||
<span>جمع بدهکار: {formatMoney(String(balance.debit))}</span>
|
||
<span>جمع بستانکار: {formatMoney(String(balance.credit))}</span>
|
||
<span>اختلاف: {formatMoney(String(Math.abs(balance.difference)))}</span>
|
||
<span className="font-semibold">
|
||
{balance.isBalanced
|
||
? "متوازن ✓"
|
||
: !balance.hasAmount
|
||
? "مبالغ ردیفها را وارد کنید"
|
||
: "نامتوازن — ذخیره مجاز نیست"}
|
||
</span>
|
||
</div>
|
||
{balance.hasAmount && !balance.isBalanced ? (
|
||
<p className="mt-2 text-xs">{unbalancedMessage(balance.debit, balance.credit)}</p>
|
||
) : null}
|
||
</div>
|
||
<div className="flex justify-end gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={updateM.isPending || !balance.isBalanced || (watchedEditLines ?? []).some((l) => !!lineBothSidesError(l.debit, l.credit))}>
|
||
ذخیره تغییرات
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<DataTable
|
||
columns={[
|
||
{ key: "line_number", header: "#" },
|
||
{
|
||
key: "account_id",
|
||
header: "حساب",
|
||
render: (r) => 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<string, unknown>[]}
|
||
/>
|
||
)}
|
||
|
||
<ConfirmDialog
|
||
open={confirm === "post"}
|
||
onClose={() => setConfirm(null)}
|
||
title="ثبت قطعی سند؟"
|
||
description="پس از ثبت، سند قابل ویرایش مستقیم نیست. در صورت اشتباه میتوانید «برگشت و ویرایش» کنید."
|
||
confirmLabel="ثبت قطعی"
|
||
loading={postM.isPending}
|
||
onConfirm={() => postM.mutate()}
|
||
/>
|
||
<ConfirmDialog
|
||
open={confirm === "reverse"}
|
||
onClose={() => setConfirm(null)}
|
||
title="فقط برگشت سند؟"
|
||
description="یک سند معکوس ثبت میشود و این سند به وضعیت برگشتی میرود. برای اصلاح محتوا از «برگشت و ویرایش» استفاده کنید."
|
||
confirmLabel="تأیید برگشت"
|
||
danger
|
||
loading={reverseM.isPending}
|
||
onConfirm={() => reverseM.mutate()}
|
||
/>
|
||
<ConfirmDialog
|
||
open={confirm === "reverse-edit"}
|
||
onClose={() => setConfirm(null)}
|
||
title="برگشت و باز کردن برای ویرایش؟"
|
||
description="سند قطعی برگشت میخورد، سند معکوس ثبت میشود، و یک پیشنویس جدید با همان ردیفها برای اصلاح باز میشود. این کار قابل بازگشت آسان نیست."
|
||
confirmLabel="برگشت و ویرایش"
|
||
danger
|
||
loading={reverseEditM.isPending}
|
||
onConfirm={() => reverseEditM.mutate()}
|
||
/>
|
||
<ConfirmDialog
|
||
open={confirm === "cancel"}
|
||
onClose={() => setConfirm(null)}
|
||
title="لغو / حذف سند پیشنویس؟"
|
||
description="سند پیشنویس لغو میشود و دیگر قابل ثبت قطعی نیست. اسناد قطعیشده قابل حذف فیزیکی نیستند."
|
||
confirmLabel="لغو سند"
|
||
danger
|
||
loading={cancelM.isPending}
|
||
onConfirm={() => cancelM.mutate()}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|