TorbatYar/frontend/app/accounting/vouchers/new/page.tsx
Mortezakoohjani 8caed2cfd2 Fix live voucher balance totals and show system-posted vouchers in the list.
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>
2026-07-26 18:22:40 +03:30

390 lines
15 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 } from "react";
import type { ChangeEvent } from "react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Plus, Trash2 } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { todayIso } from "@/lib/jalali";
import { formatMoney } from "@/lib/utils";
import { lineBothSidesError, sumVoucherLines, unbalancedMessage } from "@/lib/voucher-balance";
import {
PageHeader,
Button,
Input,
Label,
FieldError,
LoadingState,
ErrorState,
Card,
CardContent,
DatePicker,
Select,
MoneyInput,
} from "@/components/ds";
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 schema = z.object({
fiscal_period_id: z.string().uuid("دوره مالی الزامی است"),
voucher_number: z.string().optional(),
voucher_date: z.string().min(1),
description: z.string().optional(),
lines: z.array(lineSchema).min(2, "حداقل دو ردیف لازم است"),
});
type FormValues = z.infer<typeof schema>;
export default function NewVoucherPage() {
const { tenantId } = useTenantId();
const router = useRouter();
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
fiscal_period_id: "",
voucher_number: "",
voucher_date: todayIso(),
description: "",
lines: [
{ account_id: "", debit: "0", credit: "0", description: "", cost_center_id: "", project_id: "" },
{ account_id: "", debit: "0", credit: "0", description: "", cost_center_id: "", project_id: "" },
],
},
});
const { fields, append, remove } = useFieldArray({ control: form.control, name: "lines" });
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-periods"],
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
enabled: !!tenantId,
});
const nextNumberQ = useQuery({
queryKey: ["accounting", tenantId, "seq", "voucher"],
queryFn: () => accountingApi.setup.peekNumber(tenantId!, "voucher"),
enabled: !!tenantId,
});
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,
});
useEffect(() => {
if (nextNumberQ.data?.preview && !form.getValues("voucher_number")) {
form.setValue("voucher_number", nextNumberQ.data.preview);
}
}, [nextNumberQ.data, form]);
const createM = useMutation({
mutationFn: (data: FormValues) => {
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.create(tenantId!, {
fiscal_period_id: data.fiscal_period_id,
voucher_number: data.voucher_number?.trim() || undefined,
voucher_date: data.voucher_date,
description: data.description || undefined,
source_module: "manual",
lines: data.lines.map((l) => ({
account_id: l.account_id,
debit: l.debit || "0",
credit: l.credit || "0",
description: l.description || undefined,
cost_center_id: l.cost_center_id || null,
project_id: l.project_id || null,
})),
});
},
onSuccess: (v) => {
toast.success("سند ایجاد شد");
router.push(`/accounting/vouchers/${v.id}`);
},
onError: (e: Error) => toast.error(e.message),
});
// useWatch (not useMemo on watch) — RHF mutates lines in place; memo would freeze at 0/0.
const watchedLines = useWatch({ control: form.control, name: "lines" });
const balance = sumVoucherLines(watchedLines);
if (!tenantId || periodsQ.isLoading || accountsQ.isLoading) return <LoadingState />;
if (periodsQ.error || accountsQ.error) {
return (
<ErrorState
message={(periodsQ.error || accountsQ.error)?.message || "خطا"}
onRetry={() => {
void periodsQ.refetch();
void accountsQ.refetch();
}}
/>
);
}
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 openPeriods = (periodsQ.data ?? []).filter((p) => p.status === "open" || p.status === "locked");
return (
<div>
<PageHeader
title="سند حسابداری جدید"
description="ایجاد سند پیش‌نویس — ثبت قطعی فقط از طریق Posting Engine."
/>
<form className="space-y-6" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<Card>
<CardContent className="grid gap-4 pt-6 sm:grid-cols-2">
<div>
<Label>دوره مالی</Label>
<select
className="mt-1 w-full rounded-xl border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm"
{...form.register("fiscal_period_id")}
>
<option value="">انتخاب دوره</option>
{(periodsQ.data ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.status})
</option>
))}
</select>
<FieldError>{form.formState.errors.fiscal_period_id?.message}</FieldError>
{!openPeriods.length ? (
<p className="mt-1 text-xs text-amber-600">ابتدا یک دوره مالی باز ایجاد کنید.</p>
) : null}
</div>
<div>
<Label>شماره سند</Label>
<Input className="mt-1" placeholder="خودکار" {...form.register("voucher_number")} />
<p className="mt-1 text-xs text-[var(--muted)]">
پیشنهاد سیستم: {nextNumberQ.data?.preview ?? "…"} خالی بگذارید تا هنگام ذخیره تخصیص داده شود.
</p>
<FieldError>{form.formState.errors.voucher_number?.message}</FieldError>
</div>
<div>
<Label>تاریخ</Label>
<Controller
control={form.control}
name="voucher_date"
render={({ field }) => (
<DatePicker
className="mt-1"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
<FieldError>{form.formState.errors.voucher_date?.message}</FieldError>
</div>
<div>
<Label>شرح</Label>
<Input className="mt-1" {...form.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(
watchedLines?.[index]?.debit ?? "0",
watchedLines?.[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" {...form.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={form.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={form.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" {...form.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" {...form.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="sm:col-span-2">
<Label>شرح</Label>
<Input className="mt-1" {...form.register(`lines.${index}.description`)} />
</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>{form.formState.errors.lines?.message || form.formState.errors.lines?.root?.message}</FieldError>
<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>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.push("/accounting/vouchers")}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending || !balance.isBalanced || (watchedLines ?? []).some((l) => !!lineBothSidesError(l.debit, l.credit))}>
ذخیره پیشنویس
</Button>
</div>
</form>
</div>
);
}