TorbatYar/frontend/app/accounting/vouchers/new/page.tsx
Mortezakoohjani 7953e47f8b Ship accounting UX: Rial integers, auto doc numbers, reverse-and-edit, real reports, customers nav.
Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 15:52:06 +03:30

302 lines
11 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 { 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 {
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) =>
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),
});
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) => (
<div
key={field.id}
className="grid gap-2 rounded-xl border border-[var(--border)] p-3 sm:grid-cols-12"
>
<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>
</div>
))}
<FieldError>{form.formState.errors.lines?.message || form.formState.errors.lines?.root?.message}</FieldError>
</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}>
ذخیره پیشنویس
</Button>
</div>
</form>
</div>
);
}