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

220 lines
7.9 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 } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { RefreshCw } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
Select,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Card,
CardContent,
StatCard,
} from "@/components/ds";
export default function LedgerPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-periods"],
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
enabled: !!tenantId,
});
const defaultPeriod = useMemo(() => {
const periods = periodsQ.data ?? [];
return periods.find((p) => p.is_current)?.id ?? periods[0]?.id ?? "";
}, [periodsQ.data]);
const [periodId, setPeriodId] = useState("");
const activePeriodId = periodId || defaultPeriod;
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
});
const balancesQ = useQuery({
queryKey: ["accounting", tenantId, "ledger-balances", activePeriodId],
queryFn: () => accountingApi.ledger.balances(tenantId!, activePeriodId),
enabled: !!tenantId && !!activePeriodId,
});
const trialQ = useQuery({
queryKey: ["accounting", tenantId, "trial-balance", activePeriodId],
queryFn: () => accountingApi.ledger.trialBalance(tenantId!, activePeriodId),
enabled: !!tenantId && !!activePeriodId,
});
const recalcM = useMutation({
mutationFn: () => accountingApi.ledger.recalculate(tenantId!, activePeriodId),
onSuccess: async (res) => {
toast.success(`${res.recalculated_accounts} حساب محاسبه مجدد شد`);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ledger-balances", activePeriodId] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "trial-balance", activePeriodId] });
},
onError: (e: Error) => toast.error(e.message),
});
const accountMap = useMemo(
() => new Map((accountsQ.data ?? []).map((a) => [a.id, `${a.code}${a.name}`])),
[accountsQ.data]
);
const rows = useMemo(() => {
const balances = balancesQ.data ?? [];
return [...balances]
.map((b) => ({
...b,
account_label: accountMap.get(b.account_id) ?? b.account_id,
}))
.sort((a, b) => a.account_label.localeCompare(b.account_label, "fa"));
}, [balancesQ.data, accountMap]);
if (!tenantId || periodsQ.isLoading) return <LoadingState />;
if (periodsQ.error) {
return <ErrorState message={periodsQ.error.message} onRetry={() => periodsQ.refetch()} />;
}
const periodName =
periodsQ.data?.find((p) => p.id === activePeriodId)?.name ?? "—";
return (
<div>
<PageHeader
title="دفتر کل"
description="مانده حساب‌ها، تراز آزمایشی و محاسبه مجدد — API واقعی Ledger."
actions={
<Button
type="button"
variant="outline"
disabled={!activePeriodId || recalcM.isPending}
onClick={() => recalcM.mutate()}
>
<RefreshCw className="h-4 w-4" />
محاسبه مجدد
</Button>
}
/>
<div className="mb-6 flex flex-wrap items-end gap-4">
<div className="min-w-[220px]">
<label className="mb-1.5 block text-sm font-medium text-secondary">دوره مالی</label>
<Select value={activePeriodId} 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>
</div>
{activePeriodId ? (
<p className="text-sm text-[var(--muted)]">
دوره انتخابشده: <span className="font-medium text-secondary">{periodName}</span>
</p>
) : null}
</div>
{!activePeriodId ? (
<EmptyState
title="دوره مالی انتخاب نشده"
description="ابتدا در بخش سال و دوره مالی یک دوره ایجاد کنید."
/>
) : (
<>
<div className="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{trialQ.isLoading ? (
<LoadingState />
) : trialQ.error ? (
<ErrorState message={trialQ.error.message} onRetry={() => trialQ.refetch()} />
) : trialQ.data ? (
<>
<StatCard label="جمع بدهکار" value={formatMoney(trialQ.data.total_debit)} />
<StatCard label="جمع بستانکار" value={formatMoney(trialQ.data.total_credit)} />
<StatCard label="اختلاف" value={formatMoney(trialQ.data.difference)} />
<Card>
<CardContent className="pt-5">
<p className="text-xs text-[var(--muted)]">تراز آزمایشی</p>
<Badge
className="mt-2"
tone={trialQ.data.is_balanced ? "success" : "danger"}
>
{trialQ.data.is_balanced ? "متوازن" : "نامتوازن"}
</Badge>
</CardContent>
</Card>
</>
) : null}
</div>
{balancesQ.isLoading ? (
<LoadingState />
) : balancesQ.error ? (
<ErrorState message={balancesQ.error.message} onRetry={() => balancesQ.refetch()} />
) : (
<DataTable
columns={[
{
key: "account_label",
header: "حساب",
render: (r) => String(r.account_label ?? r.account_id),
},
{
key: "opening_balance",
header: "افتتاحیه",
render: (r) => formatMoney(String(r.opening_balance)),
},
{
key: "debit_total",
header: "گردش بدهکار",
render: (r) => formatMoney(String(r.debit_total)),
},
{
key: "credit_total",
header: "گردش بستانکار",
render: (r) => formatMoney(String(r.credit_total)),
},
{
key: "closing_balance",
header: "اختتامیه",
render: (r) => formatMoney(String(r.closing_balance)),
},
{
key: "current_balance",
header: "مانده جاری",
render: (r) => formatMoney(String(r.current_balance)),
},
]}
rows={rows as unknown as Record<string, unknown>[]}
empty={
<EmptyState
title="مانده‌ای ثبت نشده"
description="پس از ثبت اسناد، محاسبه مجدد را اجرا کنید."
action={
<Button type="button" onClick={() => recalcM.mutate()} disabled={recalcM.isPending}>
محاسبه مجدد
</Button>
}
/>
}
/>
)}
</>
)}
</div>
);
}