TorbatYar/frontend/app/accounting/reports/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

664 lines
23 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 { useMemo, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
LoadingState,
ErrorState,
Card,
CardContent,
CardHeader,
CardTitle,
Badge,
Select,
FormField,
DataTable,
EmptyState,
StatCard,
} from "@/components/ds";
type EngineKind = "trial-balance" | "balance-sheet" | "income-statement";
type LedgerReportKind =
| "subsidiary"
| "detail"
| "analytical"
| "cash-flow"
| "equity";
const ENGINE_LABELS: Record<EngineKind, string> = {
"trial-balance": "تراز آزمایشی",
"balance-sheet": "ترازنامه",
"income-statement": "سود و زیان",
};
const LEDGER_REPORT_META: Record<
LedgerReportKind,
{ title: string; description: string; needsAccount?: boolean }
> = {
subsidiary: {
title: "گزارش معین",
description: "مانده و گردش حساب‌های سطح معین بر اساس دفتر و اسناد ثبت‌شده.",
needsAccount: true,
},
detail: {
title: "گزارش تفصیلی",
description: "مانده و گردش حساب‌های سطح تفصیلی بر اساس دفتر و اسناد ثبت‌شده.",
needsAccount: true,
},
analytical: {
title: "گزارش تحلیلی",
description: "تجمیع مانده‌ها بر اساس نوع و دسته حساب از داده واقعی دفتر.",
},
"cash-flow": {
title: "جریان وجوه نقد",
description: "گردش حساب‌های نقدی/بانکی از مانده و اسناد ثبت‌شده.",
},
equity: {
title: "تغییرات حقوق صاحبان سهام",
description: "مانده و گردش حساب‌های حقوق صاحبان سهام در دوره انتخابی.",
},
};
const FIELD_LABELS: Record<string, string> = {
total_debit: "جمع بدهکار",
total_credit: "جمع بستانکار",
difference: "اختلاف",
is_balanced: "متوازن",
total_assets: "جمع دارایی‌ها",
account_count: "تعداد حساب",
total_revenue: "جمع درآمد",
total_expense: "جمع هزینه",
net: "خالص",
net_cash_change: "تغییر خالص نقد",
inflows: "ورودی‌ها",
outflows: "خروجی‌ها",
};
type BalanceRow = {
account_id: string;
account_code: string;
account_name: string;
account_type?: string;
account_category?: string;
level?: number;
opening_balance: string;
debit_total: string;
credit_total: string;
closing_balance: string;
};
type StatementLine = {
id: string;
entry_date: string;
description?: string | null;
debit: string;
credit: string;
running_balance: string;
entry_number?: string;
};
type LedgerReportData = {
mode?: string;
level_mode?: string;
account?: { id: string; code: string; name: string; level: number };
rows?: BalanceRow[];
lines?: StatementLine[];
totals?: Record<string, string | number>;
by_type?: Array<Record<string, string | number>>;
by_category?: Array<Record<string, string | number>>;
net_cash_change?: string;
inflows?: string;
outflows?: string;
};
function parseReportData(data: unknown): Record<string, unknown> | null {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data) as Record<string, unknown>;
} catch {
return { raw: data };
}
}
if (typeof data === "object" && !Array.isArray(data)) {
return data as Record<string, unknown>;
}
return { value: data };
}
function formatCellValue(key: string, value: unknown): React.ReactNode {
if (value === null || value === undefined) return "—";
if (typeof value === "boolean") return value ? "بله" : "خیر";
if (
key.includes("total") ||
key.includes("difference") ||
key.includes("balance") ||
key === "net" ||
key.includes("amount") ||
key.includes("inflow") ||
key.includes("outflow") ||
key.includes("change")
) {
return formatMoney(String(value));
}
return String(value);
}
const MONEY_COLS = [
{
key: "opening_balance",
header: "مانده اول دوره",
render: (r: Record<string, unknown>) => formatMoney(String(r.opening_balance ?? "0")),
},
{
key: "debit_total",
header: "بدهکار",
render: (r: Record<string, unknown>) => formatMoney(String(r.debit_total ?? "0")),
},
{
key: "credit_total",
header: "بستانکار",
render: (r: Record<string, unknown>) => formatMoney(String(r.credit_total ?? "0")),
},
{
key: "closing_balance",
header: "مانده پایان",
render: (r: Record<string, unknown>) => formatMoney(String(r.closing_balance ?? "0")),
},
];
function EngineReports({ initialKind }: { initialKind?: string }) {
const { tenantId } = useTenantId();
const [periodId, setPeriodId] = useState("");
const [result, setResult] = useState<{
kind: EngineKind;
payload: { id: string; data: unknown };
} | null>(null);
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-periods"],
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
enabled: !!tenantId,
});
const runM = useMutation({
mutationFn: async (kind: EngineKind) => {
if (!periodId) throw new Error("دوره مالی را انتخاب کنید");
if (kind === "trial-balance") {
const payload = await accountingApi.reporting.trialBalance(tenantId!, periodId);
return { kind, payload };
}
if (kind === "balance-sheet") {
const payload = await accountingApi.reporting.balanceSheet(tenantId!, periodId);
return { kind, payload };
}
const payload = await accountingApi.reporting.incomeStatement(tenantId!, periodId);
return { kind, payload };
},
onSuccess: (data) => {
setResult(data);
toast.success("گزارش تولید شد");
},
onError: (e: Error) => toast.error(e.message),
});
const exportM = useMutation({
mutationFn: (format: "json" | "csv") =>
accountingApi.reporting.export(tenantId!, result!.payload.id, format),
onSuccess: (data) => {
toast.success(`خروجی ${data.format} ثبت شد`);
if (data.file_path) toast.message(data.file_path);
if (result) {
const blob = new Blob([JSON.stringify(result.payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `report-${result.payload.id}.json`;
a.click();
URL.revokeObjectURL(url);
}
},
onError: (e: Error) => toast.error(e.message),
});
const parsed = useMemo(
() => (result ? parseReportData(result.payload.data) : null),
[result]
);
const tableRows = useMemo(() => {
if (!parsed) return [];
return Object.entries(parsed).map(([key, value]) => ({
id: key,
field: FIELD_LABELS[key] ?? key,
value,
rawKey: key,
}));
}, [parsed]);
if (!tenantId || periodsQ.isLoading) return <LoadingState />;
if (periodsQ.error) {
return <ErrorState message={periodsQ.error.message} onRetry={() => periodsQ.refetch()} />;
}
const highlight =
initialKind === "trial-balance" ||
initialKind === "balance-sheet" ||
initialKind === "income-statement"
? (initialKind as EngineKind)
: null;
return (
<div>
<PageHeader
title="گزارش‌های مالی"
description="تراز آزمایشی، ترازنامه و سود و زیان — از Report Engine."
/>
<Card className="mb-6">
<CardContent className="grid gap-4 pt-6 sm:grid-cols-[1fr_auto] sm:items-end">
<FormField label="دوره مالی">
<Select value={periodId} onChange={(e) => setPeriodId(e.target.value)}>
<option value="">انتخاب دوره</option>
{(periodsQ.data ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Select>
</FormField>
<div className="flex flex-wrap gap-2">
{(Object.keys(ENGINE_LABELS) as EngineKind[]).map((kind) => (
<Button
key={kind}
type="button"
variant={highlight === kind ? "default" : "outline"}
disabled={runM.isPending || !periodId}
onClick={() => runM.mutate(kind)}
>
{ENGINE_LABELS[kind]}
</Button>
))}
</div>
</CardContent>
</Card>
{result ? (
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center gap-2">
{ENGINE_LABELS[result.kind]}
<Badge tone="primary">زنده</Badge>
<span className="text-xs font-normal text-[var(--muted)]" dir="ltr">
ID: {result.payload.id}
</span>
<Button
type="button"
size="sm"
variant="outline"
className="ms-auto"
disabled={exportM.isPending}
onClick={() => exportM.mutate("json")}
>
خروجی / Export
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{parsed && tableRows.length > 0 ? (
<DataTable
columns={[
{ key: "field", header: "عنوان" },
{
key: "value",
header: "مقدار",
render: (r) => formatCellValue(String(r.rawKey), r.value),
},
]}
rows={tableRows as unknown as Record<string, unknown>[]}
/>
) : (
<EmptyState title="داده‌ای برای نمایش نیست" />
)}
<details className="rounded-xl border border-[var(--border)] bg-[var(--surface-muted)] p-3">
<summary className="cursor-pointer text-sm font-medium text-secondary">JSON خام</summary>
<pre
className="mt-3 max-h-[320px] overflow-auto text-xs leading-relaxed text-secondary"
dir="ltr"
>
{JSON.stringify(result.payload, null, 2)}
</pre>
</details>
</CardContent>
</Card>
) : (
<p className="text-sm text-[var(--muted)]">یک دوره انتخاب کنید و گزارش را اجرا کنید.</p>
)}
</div>
);
}
function LedgerReports({ kind }: { kind: LedgerReportKind }) {
const meta = LEDGER_REPORT_META[kind];
const { tenantId } = useTenantId();
const [periodId, setPeriodId] = useState("");
const [accountId, setAccountId] = useState("");
const [result, setResult] = useState<{ id: string; data: LedgerReportData } | null>(null);
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-periods"],
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts", "report-picker", kind],
queryFn: () => accountingApi.accounts.list(tenantId!, 1, 500),
enabled: !!tenantId && !!meta.needsAccount,
});
const accountOptions = useMemo(() => {
const accounts = accountsQ.data ?? [];
if (kind === "subsidiary") {
const level3 = accounts.filter((a) => a.level === 3);
if (level3.length) return level3;
return accounts.filter((a) => a.is_postable && !a.is_group && a.level <= 3);
}
if (kind === "detail") {
const detail = accounts.filter((a) => a.level >= 4);
if (detail.length) return detail;
return accounts.filter((a) => a.is_postable && !a.is_group);
}
return accounts.filter((a) => a.is_postable && !a.is_group);
}, [accountsQ.data, kind]);
const runM = useMutation({
mutationFn: async () => {
if (!periodId) throw new Error("دوره مالی را انتخاب کنید");
const account = accountId || undefined;
if (kind === "subsidiary") {
return accountingApi.reporting.subsidiaryLedger(tenantId!, periodId, account);
}
if (kind === "detail") {
return accountingApi.reporting.detailLedger(tenantId!, periodId, account);
}
if (kind === "analytical") {
return accountingApi.reporting.analytical(tenantId!, periodId);
}
if (kind === "cash-flow") {
return accountingApi.reporting.cashFlow(tenantId!, periodId);
}
return accountingApi.reporting.equityStatement(tenantId!, periodId);
},
onSuccess: (payload) => {
const data = (parseReportData(payload.data) ?? {}) as LedgerReportData;
setResult({ id: payload.id, data });
toast.success("گزارش از داده دفتر تولید شد");
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || periodsQ.isLoading) return <LoadingState />;
if (periodsQ.error) {
return <ErrorState message={periodsQ.error.message} onRetry={() => periodsQ.refetch()} />;
}
const rows = result?.data.rows ?? [];
const lines = result?.data.lines ?? [];
const totals = result?.data.totals ?? {};
const byType = result?.data.by_type ?? [];
const byCategory = result?.data.by_category ?? [];
return (
<div>
<PageHeader title={meta.title} description={meta.description} />
<Card className="mb-6">
<CardContent className="grid gap-4 pt-6 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_auto] lg:items-end">
<FormField label="دوره مالی">
<Select value={periodId} onChange={(e) => setPeriodId(e.target.value)}>
<option value="">انتخاب دوره</option>
{(periodsQ.data ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.is_current ? "(جاری)" : ""}
</option>
))}
</Select>
</FormField>
{meta.needsAccount ? (
<FormField label="حساب (اختیاری — برای گردش جزئیات)">
<Select value={accountId} onChange={(e) => setAccountId(e.target.value)}>
<option value="">همه حسابهای این سطح</option>
{accountOptions.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
) : (
<div />
)}
<Button
type="button"
disabled={runM.isPending || !periodId}
onClick={() => runM.mutate()}
>
{runM.isPending ? "در حال تولید…" : "اجرای گزارش"}
</Button>
</CardContent>
</Card>
{result ? (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="مانده اول دوره"
value={formatMoney(String(totals.opening_balance ?? "0"))}
/>
<StatCard label="جمع بدهکار" value={formatMoney(String(totals.debit_total ?? "0"))} />
<StatCard label="جمع بستانکار" value={formatMoney(String(totals.credit_total ?? "0"))} />
<StatCard
label="مانده پایان"
value={formatMoney(String(totals.closing_balance ?? "0"))}
/>
</div>
{kind === "cash-flow" ? (
<div className="grid gap-4 sm:grid-cols-3">
<StatCard
label="ورودی‌ها"
value={formatMoney(String(result.data.inflows ?? totals.debit_total ?? "0"))}
/>
<StatCard
label="خروجی‌ها"
value={formatMoney(String(result.data.outflows ?? totals.credit_total ?? "0"))}
/>
<StatCard
label="تغییر خالص نقد"
value={formatMoney(String(result.data.net_cash_change ?? "0"))}
/>
</div>
) : null}
{result.data.account ? (
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
گردش حساب
<Badge tone="primary">
{result.data.account.code} {result.data.account.name}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{lines.length ? (
<DataTable
columns={[
{ key: "entry_date", header: "تاریخ" },
{
key: "entry_number",
header: "شماره",
render: (r) => String(r.entry_number ?? "—"),
},
{
key: "description",
header: "شرح",
render: (r) => String(r.description ?? "—"),
},
{
key: "debit",
header: "بدهکار",
render: (r) => formatMoney(String(r.debit ?? "0")),
},
{
key: "credit",
header: "بستانکار",
render: (r) => formatMoney(String(r.credit ?? "0")),
},
{
key: "running_balance",
header: "مانده جاری",
render: (r) => formatMoney(String(r.running_balance ?? "0")),
},
]}
rows={lines as unknown as Record<string, unknown>[]}
/>
) : (
<EmptyState
title="گردشی برای این حساب در دوره نیست"
description="اسناد ثبت‌شده (Posted) روی این حساب در این دوره یافت نشد."
/>
)}
</CardContent>
</Card>
) : null}
{kind === "analytical" ? (
<>
<Card>
<CardHeader>
<CardTitle>بر اساس نوع حساب</CardTitle>
</CardHeader>
<CardContent>
{byType.length ? (
<DataTable
columns={[
{ key: "key", header: "نوع" },
{
key: "account_count",
header: "تعداد",
render: (r) => String(r.account_count ?? 0),
},
...MONEY_COLS,
]}
rows={byType as unknown as Record<string, unknown>[]}
/>
) : (
<EmptyState title="داده‌ای نیست" />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>بر اساس دسته حساب</CardTitle>
</CardHeader>
<CardContent>
{byCategory.length ? (
<DataTable
columns={[
{ key: "key", header: "دسته" },
{
key: "account_count",
header: "تعداد",
render: (r) => String(r.account_count ?? 0),
},
...MONEY_COLS,
]}
rows={byCategory as unknown as Record<string, unknown>[]}
/>
) : (
<EmptyState title="داده‌ای نیست" />
)}
</CardContent>
</Card>
</>
) : null}
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center gap-2">
{result.data.mode === "statement" ? "مانده حساب انتخاب‌شده" : "خلاصه مانده حساب‌ها"}
<Badge tone="primary">زنده از دفتر</Badge>
<span className="text-xs font-normal text-[var(--muted)]" dir="ltr">
ID: {result.id}
</span>
</CardTitle>
</CardHeader>
<CardContent>
{rows.length ? (
<DataTable
columns={[
{
key: "account_code",
header: "کد",
render: (r) => (
<span dir="ltr">{String(r.account_code ?? "")}</span>
),
},
{ key: "account_name", header: "نام حساب" },
{
key: "level",
header: "سطح",
render: (r) => String(r.level ?? "—"),
},
...MONEY_COLS,
]}
rows={rows as unknown as Record<string, unknown>[]}
/>
) : (
<EmptyState
title="حسابی برای نمایش نیست"
description="حساب‌های این سطح را در درخت حساب‌ها تعریف کنید و اسناد را ثبت (Post) کنید."
/>
)}
</CardContent>
</Card>
</div>
) : (
<p className="text-sm text-[var(--muted)]">
دوره مالی را انتخاب کنید و گزارش را اجرا کنید. داده از مانده دفتر و اسناد ثبتشده خوانده میشود نه فرم ساختگی.
</p>
)}
</div>
);
}
function ReportsRouter() {
const sp = useSearchParams();
const type = sp.get("type") || "";
if (type in LEDGER_REPORT_META) {
return <LedgerReports kind={type as LedgerReportKind} />;
}
return <EngineReports initialKind={type || undefined} />;
}
export default function ReportsPage() {
return (
<Suspense fallback={<LoadingState />}>
<ReportsRouter />
</Suspense>
);
}