TorbatYar/frontend/app/accounting/vouchers/[id]/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

420 lines
16 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, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus, Trash2 } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
Badge,
DataTable,
LoadingState,
ErrorState,
ConfirmDialog,
Card,
CardContent,
JalaliDateText,
DatePicker,
Input,
Label,
FieldError,
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 editSchema = z.object({
voucher_date: z.string().min(1),
description: z.string().optional(),
lines: z.array(lineSchema).min(2, "حداقل دو ردیف لازم است"),
});
type EditForm = z.infer<typeof editSchema>;
export default function VoucherDetailPage() {
const params = useParams<{ id: string }>();
const id = params.id;
const { tenantId } = useTenantId();
const qc = useQueryClient();
const router = useRouter();
const [confirm, setConfirm] = useState<"post" | "reverse" | "cancel" | null>(null);
const [editing, setEditing] = useState(false);
const voucherQ = useQuery({
queryKey: ["accounting", tenantId, "voucher", id],
queryFn: () => accountingApi.vouchers.get(tenantId!, id),
enabled: !!tenantId && !!id,
});
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 editForm = useForm<EditForm>({
resolver: zodResolver(editSchema),
defaultValues: { voucher_date: "", description: "", lines: [] },
});
const { fields, append, remove } = useFieldArray({ control: editForm.control, name: "lines" });
useEffect(() => {
const v = voucherQ.data;
if (!v || v.status !== "draft") return;
editForm.reset({
voucher_date: v.voucher_date,
description: v.description ?? "",
lines: v.lines.map((l) => ({
account_id: l.account_id,
debit: l.debit,
credit: l.credit,
description: l.description ?? "",
cost_center_id: l.cost_center_id ?? "",
project_id: l.project_id ?? "",
})),
});
}, [voucherQ.data, editForm]);
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "voucher", id] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
};
const updateM = useMutation({
mutationFn: (data: EditForm) =>
accountingApi.vouchers.update(tenantId!, id, {
voucher_date: data.voucher_date,
description: data.description || undefined,
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: async () => {
toast.success("سند به‌روز شد");
setEditing(false);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const validateM = useMutation({
mutationFn: () => accountingApi.vouchers.validate(tenantId!, id),
onSuccess: async () => {
toast.success("سند اعتبارسنجی شد");
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const postM = useMutation({
mutationFn: () => accountingApi.vouchers.post(tenantId!, id, "manual"),
onSuccess: async () => {
toast.success("سند ثبت قطعی شد");
setConfirm(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const reverseM = useMutation({
mutationFn: () => accountingApi.vouchers.reverse(tenantId!, id),
onSuccess: async () => {
toast.success("سند برگشت خورد");
setConfirm(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const cancelM = useMutation({
mutationFn: () => accountingApi.vouchers.cancel(tenantId!, id),
onSuccess: async () => {
toast.success("سند لغو شد");
setConfirm(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || voucherQ.isLoading) return <LoadingState />;
if (voucherQ.error) return <ErrorState message={voucherQ.error.message} onRetry={() => voucherQ.refetch()} />;
const v = voucherQ.data!;
const accountMap = new Map((accountsQ.data ?? []).map((a) => [a.id, `${a.code}${a.name}`]));
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 isDraft = v.status === "draft";
return (
<div>
<PageHeader
title={`سند ${v.voucher_number}`}
description={v.description || "جزئیات سند و اقدامات Posting Engine"}
actions={
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => router.push("/accounting/vouchers")}>
بازگشت
</Button>
{isDraft ? (
<Button type="button" variant="outline" onClick={() => setEditing((e) => !e)}>
{editing ? "لغو ویرایش" : "ویرایش"}
</Button>
) : null}
{v.status === "draft" || v.status === "validated" ? (
<Button type="button" variant="outline" disabled={validateM.isPending} onClick={() => validateM.mutate()}>
اعتبارسنجی
</Button>
) : null}
{v.status === "draft" || v.status === "validated" ? (
<Button type="button" onClick={() => setConfirm("post")}>
ثبت قطعی
</Button>
) : null}
{v.status === "posted" ? (
<Button type="button" variant="outline" onClick={() => setConfirm("reverse")}>
برگشت
</Button>
) : null}
{v.status === "draft" || v.status === "validated" ? (
<Button type="button" variant="danger" onClick={() => setConfirm("cancel")}>
لغو
</Button>
) : null}
</div>
}
/>
<div className="mb-6 grid gap-4 sm:grid-cols-4">
<Card>
<CardContent className="pt-5">
<p className="text-xs text-[var(--muted)]">وضعیت</p>
<Badge className="mt-2" tone={v.status === "posted" ? "success" : "default"}>
{v.status}
</Badge>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5">
<p className="text-xs text-[var(--muted)]">تاریخ</p>
<p className="mt-2 font-medium">
<JalaliDateText value={v.voucher_date} />
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5">
<p className="text-xs text-[var(--muted)]">جمع بدهکار</p>
<p className="mt-2 font-medium">{formatMoney(v.total_debit)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5">
<p className="text-xs text-[var(--muted)]">جمع بستانکار</p>
<p className="mt-2 font-medium">{formatMoney(v.total_credit)}</p>
</CardContent>
</Card>
</div>
{isDraft && editing ? (
<form className="space-y-4" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
<Card>
<CardContent className="grid gap-4 pt-6 sm:grid-cols-2">
<div>
<Label>تاریخ</Label>
<Controller
control={editForm.control}
name="voucher_date"
render={({ field }) => (
<DatePicker
className="mt-1"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
<FieldError>{editForm.formState.errors.voucher_date?.message}</FieldError>
</div>
<div>
<Label>شرح</Label>
<Input className="mt-1" {...editForm.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" {...editForm.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" {...editForm.register(`lines.${index}.debit`)} />
</div>
<div className="sm:col-span-2">
<Label>بستانکار</Label>
<MoneyInput className="mt-1" {...editForm.register(`lines.${index}.credit`)} />
</div>
<div className="sm:col-span-2">
<Label>مرکز هزینه</Label>
<Select className="mt-1" {...editForm.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" {...editForm.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="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>
{editForm.formState.errors.lines?.message || editForm.formState.errors.lines?.root?.message}
</FieldError>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
انصراف
</Button>
<Button type="submit" disabled={updateM.isPending}>
ذخیره تغییرات
</Button>
</div>
</form>
) : (
<DataTable
columns={[
{ key: "line_number", header: "#" },
{
key: "account_id",
header: "حساب",
render: (r) => accountMap.get(String(r.account_id)) || String(r.account_id),
},
{ key: "debit", header: "بدهکار", render: (r) => formatMoney(String(r.debit)) },
{ key: "credit", header: "بستانکار", render: (r) => formatMoney(String(r.credit)) },
{ key: "description", header: "شرح" },
]}
rows={(v.lines ?? []) as unknown as Record<string, unknown>[]}
/>
)}
<ConfirmDialog
open={confirm === "post"}
onClose={() => setConfirm(null)}
title="ثبت قطعی سند؟"
description="پس از ثبت، Journal Entry فقط از طریق Posting Engine ایجاد می‌شود و قابل ویرایش مستقیم نیست."
confirmLabel="ثبت قطعی"
loading={postM.isPending}
onConfirm={() => postM.mutate()}
/>
<ConfirmDialog
open={confirm === "reverse"}
onClose={() => setConfirm(null)}
title="برگشت سند؟"
description="یک سند معکوس از طریق Posting Engine ایجاد می‌شود."
confirmLabel="برگشت"
loading={reverseM.isPending}
onConfirm={() => reverseM.mutate()}
/>
<ConfirmDialog
open={confirm === "cancel"}
onClose={() => setConfirm(null)}
title="لغو سند؟"
description="سند پیش‌نویس لغو می‌شود و دیگر قابل ثبت نیست."
confirmLabel="لغو سند"
danger
loading={cancelM.isPending}
onConfirm={() => cancelM.mutate()}
/>
</div>
);
}