TorbatYar/frontend/app/accounting/vouchers/page.tsx
Mortezakoohjani 8caed2cfd2 Fix live voucher balance totals and show system-posted vouchers in the list.
RHF was caching 0/0 via useMemo on in-place form arrays; MoneyInput now drives Controller. Voucher list exposes source filters for manual vs automatic GL docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:22:40 +03:30

242 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 { Suspense, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
JalaliDateText,
Pagination,
Select,
} from "@/components/ds";
const STATUS_FA: Record<string, string> = {
draft: "پیش‌نویس",
validated: "اعتبارسنجی‌شده",
posted: "ثبت قطعی",
reversed: "برگشتی",
cancelled: "لغو شده",
};
const SOURCE_FA: Record<string, string> = {
manual: "دستی",
sales: "فروش (اتومات)",
purchase: "خرید (اتومات)",
treasury: "خزانه (اتومات)",
payroll: "حقوق (اتومات)",
assets: "دارایی (اتومات)",
};
function sourceLabel(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "manual") return "دستی";
return SOURCE_FA[s] ?? `${s} (سیستمی)`;
}
function isAutoSource(raw: unknown): boolean {
const s = String(raw ?? "").trim();
return !!s && s !== "manual";
}
const STATUS_TITLES: Record<string, string> = {
draft: "اسناد پیش‌نویس",
reversed: "اسناد برگشتی",
posted: "اسناد ثبت‌قطع‌شده",
};
function VouchersList() {
const { tenantId } = useTenantId();
const sp = useSearchParams();
const router = useRouter();
const statusFilter = sp.get("status") || "";
const sourceFilter = sp.get("source") || "";
const [page, setPage] = useState(1);
const pageSize = 100;
const listQ = useQuery({
queryKey: ["accounting", tenantId, "vouchers", page, pageSize, statusFilter, sourceFilter],
queryFn: () =>
accountingApi.vouchers.list(tenantId!, page, pageSize, {
status: statusFilter || undefined,
source: sourceFilter || undefined,
}),
enabled: !!tenantId,
});
const rows = useMemo(() => listQ.data ?? [], [listQ.data]);
const setSource = (source: string) => {
const params = new URLSearchParams();
if (statusFilter) params.set("status", statusFilter);
if (source) params.set("source", source);
const q = params.toString();
router.push(q ? `/accounting/vouchers?${q}` : "/accounting/vouchers");
setPage(1);
};
if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
const title =
sourceFilter === "auto"
? "اسناد سیستمی / اتومات"
: sourceFilter === "manual"
? "اسناد دستی"
: STATUS_TITLES[statusFilter] || "لیست اسناد حسابداری";
return (
<div>
<PageHeader
title={title}
description="همه اسناد دفتر کل — دستی و اتومات (فروش، خرید، خزانه و …) از طریق Posting Engine."
actions={
<Link href="/accounting/vouchers/new">
<Button>
<Plus className="h-4 w-4" />
سند جدید
</Button>
</Link>
}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={!sourceFilter ? "default" : "outline"}
onClick={() => setSource("")}
>
همه منابع
</Button>
<Button
type="button"
size="sm"
variant={sourceFilter === "auto" ? "default" : "outline"}
onClick={() => setSource("auto")}
>
سیستمی / اتومات
</Button>
<Button
type="button"
size="sm"
variant={sourceFilter === "manual" ? "default" : "outline"}
onClick={() => setSource("manual")}
>
دستی
</Button>
</div>
<Select
className="w-auto min-w-[10rem]"
value={
["sales", "purchase", "treasury", "payroll", "assets"].includes(sourceFilter)
? sourceFilter
: ""
}
onChange={(e) => setSource(e.target.value)}
>
<option value="">ماژول خاص</option>
<option value="sales">فروش</option>
<option value="purchase">خرید</option>
<option value="treasury">خزانه</option>
<option value="payroll">حقوق</option>
<option value="assets">دارایی</option>
</Select>
</div>
<DataTable
columns={[
{
key: "voucher_number",
header: "شماره",
render: (r) => (
<Link className="font-medium text-primary hover:underline" href={`/accounting/vouchers/${r.id}`}>
{String(r.voucher_number)}
</Link>
),
},
{
key: "voucher_date",
header: "تاریخ",
render: (r) => <JalaliDateText value={String(r.voucher_date ?? "")} />,
},
{
key: "source_module",
header: "منبع",
render: (r) => (
<Badge tone={isAutoSource(r.source_module) ? "primary" : "default"}>
{sourceLabel(r.source_module)}
</Badge>
),
},
{
key: "status",
header: "وضعیت",
render: (r) => (
<Badge tone={r.status === "posted" ? "success" : r.status === "draft" ? "default" : "warning"}>
{STATUS_FA[String(r.status)] ?? String(r.status)}
</Badge>
),
},
{ key: "total_debit", header: "بدهکار", render: (r) => formatMoney(String(r.total_debit)) },
{ key: "total_credit", header: "بستانکار", render: (r) => formatMoney(String(r.total_credit)) },
{
key: "description",
header: "شرح",
render: (r) => {
const ref = r.reference_number ? String(r.reference_number) : "";
const desc = r.description ? String(r.description) : "";
if (desc && ref) return `${desc} (مرجع: ${ref})`;
return desc || ref || "—";
},
},
]}
rows={rows as unknown as Record<string, unknown>[]}
empty={
<EmptyState
title={
sourceFilter === "auto"
? "هنوز سند اتومات ثبت نشده — ثبت حسابداری فاکتور/رسید/خزانه یا روشن‌کردن اسناد اتوماتیک"
: "سندی وجود ندارد"
}
action={
<Link href="/accounting/vouchers/new">
<Button>ایجاد سند دستی</Button>
</Link>
}
/>
}
/>
<Pagination
page={page}
pageSize={pageSize}
total={
(listQ.data?.length ?? 0) === pageSize
? page * pageSize + 1
: (page - 1) * pageSize + Math.max(listQ.data?.length ?? 0, 1)
}
onPageChange={setPage}
/>
</div>
);
}
export default function VouchersPage() {
return (
<Suspense fallback={<LoadingState />}>
<VouchersList />
</Suspense>
);
}