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>
268 lines
9.1 KiB
TypeScript
268 lines
9.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|