TorbatYar/frontend/app/accounting/vouchers/new/page.tsx
Mortezakoohjani 12c8615615 Ship enterprise Accounting FE/API with CRUD parity and production wiring.
Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs.

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

287 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 { 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().min(1, "شماره سند الزامی است"),
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: `V-${Date.now().toString().slice(-8)}`,
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 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 createM = useMutation({
mutationFn: (data: FormValues) =>
accountingApi.vouchers.create(tenantId!, {
fiscal_period_id: data.fiscal_period_id,
voucher_number: data.voucher_number,
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" {...form.register("voucher_number")} />
<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>
);
}