TorbatYar/frontend/app/accounting/vouchers/[id]/page.tsx
Mortezakoohjani d92e1df332 Add automatic GL posting policy with inline voucher balance validation.
Enable tenant-controlled auto-post for sales/purchase/treasury via Posting Engine, block unbalanced voucher lines on FE and BE, and expose international posting controls in settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 17:55:37 +03:30

506 lines
20 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";
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<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 = editForm.watch("lines");
const balance = useMemo(() => sumVoucherLines(watchedEditLines), [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>
<MoneyInput className="mt-1" {...editForm.register(`lines.${index}.debit`)} />
</div>
<div className="sm:col-span-2">
<Label>بستانکار</Label>
<MoneyInput className="mt-1" {...editForm.register(`lines.${index}.credit`)} />
</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 ? "متوازن ✓" : "نامتوازن — ذخیره مجاز نیست"}</span>
</div>
{!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>
);
}