TorbatYar/frontend/app/accounting/reports/page.tsx
Mortezakoohjani 12c8615615 Ship enterprise Accounting FE/API with CRUD parity and production wiring.
Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 15:26:43 +03:30

236 lines
7.8 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 } from "react";
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,
} from "@/components/ds";
type ReportKind = "trial-balance" | "balance-sheet" | "income-statement";
const LABELS: Record<ReportKind, string> = {
"trial-balance": "تراز آزمایشی",
"balance-sheet": "ترازنامه",
"income-statement": "سود و زیان",
};
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);
}
export default function ReportsPage() {
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);
}
// Download client-side JSON snapshot for immediate UX
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()} />;
}
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="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>
);
}