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>
220 lines
7.9 KiB
TypeScript
220 lines
7.9 KiB
TypeScript
"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>
|
||
);
|
||
}
|