TorbatYar/frontend/app/accounting/reports/page.tsx
Mortezakoohjani 7d66932da8 Complete Accounting sidebar modules with ops CRUD, BFF proxy, and treasury APIs.
Wire nested nav pages for phases 5.1-5.11 to real list/create endpoints, fix COA import connectivity via same-origin proxy, and add operational migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:10:06 +03:30

268 lines
9.1 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 { 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 { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
import {
PageHeader,
Button,
LoadingState,
ErrorState,
Card,
CardContent,
CardHeader,
CardTitle,
Badge,
Select,
FormField,
DataTable,
EmptyState,
} from "@/components/ds";
type ReportKind = "trial-balance" | "balance-sheet" | "income-statement";
const LABELS: Record<ReportKind, string> = {
"trial-balance": "تراز آزمایشی",
"balance-sheet": "ترازنامه",
"income-statement": "سود و زیان",
};
const OPS_REPORTS: Record<string, { title: string; module: string; docType: string }> = {
"cash-flow": { title: "جریان وجوه نقد", module: "reports", docType: "cash_flow" },
equity: { title: "تغییرات حقوق صاحبان سهام", module: "reports", docType: "equity" },
subsidiary: { title: "گزارش معین", module: "reports", docType: "subsidiary" },
detail: { title: "گزارش تفصیلی", module: "reports", docType: "detail" },
analytical: { title: "گزارش تحلیلی", module: "reports", docType: "analytical" },
};
const FIELD_LABELS: Record<string, string> = {
total_debit: "جمع بدهکار",
total_credit: "جمع بستانکار",
difference: "اختلاف",
is_balanced: "متوازن",
total_assets: "جمع دارایی‌ها",
account_count: "تعداد حساب",
total_revenue: "جمع درآمد",
total_expense: "جمع هزینه",
net: "خالص",
};
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 === "net" || key.includes("amount")) {
return formatMoney(String(value));
}
return String(value);
}
function EngineReports({ initialKind }: { initialKind?: string }) {
const { tenantId } = useTenantId();
const [periodId, setPeriodId] = useState("");
const [result, setResult] = useState<{ kind: ReportKind; 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: ReportKind) => {
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 ReportKind)
: 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(LABELS) as ReportKind[]).map((kind) => (
<Button
key={kind}
type="button"
variant={highlight === kind ? "default" : "outline"}
disabled={runM.isPending || !periodId}
onClick={() => runM.mutate(kind)}
>
{LABELS[kind]}
</Button>
))}
</div>
</CardContent>
</Card>
{result ? (
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center gap-2">
{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 ReportsRouter() {
const sp = useSearchParams();
const type = sp.get("type") || "";
const ops = OPS_REPORTS[type];
if (ops) {
return <BusinessDocsPage title={ops.title} module={ops.module} docType={ops.docType} />;
}
return <EngineReports initialKind={type || undefined} />;
}
export default function ReportsPage() {
return (
<Suspense fallback={<LoadingState />}>
<ReportsRouter />
</Suspense>
);
}