TorbatYar/frontend/app/accounting/vouchers/new/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

344 lines
14 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 } from "react";
import { Controller, useFieldArray, useForm } 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),
});
const watchedLines = form.watch("lines");
const balance = useMemo(() => sumVoucherLines(watchedLines), [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>
<MoneyInput className="mt-1" {...form.register(`lines.${index}.debit`)} />
</div>
<div className="sm:col-span-2">
<Label>بستانکار</Label>
<MoneyInput className="mt-1" {...form.register(`lines.${index}.credit`)} />
</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 ? "متوازن ✓" : "نامتوازن — ثبت مجاز نیست"}</span>
</div>
{!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>
);
}