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>
242 lines
7.7 KiB
TypeScript
242 lines
7.7 KiB
TypeScript
"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>
|
||
);
|
||
}
|