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>
344 lines
14 KiB
TypeScript
344 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|