343 lines
15 KiB
TypeScript
343 lines
15 KiB
TypeScript
"use client";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
|
|
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { RefreshCw } from "lucide-react";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { AccountingNav } from "@/components/accounting/AccountingNav";
|
|
import { formatCurrency } from "@/lib/utils";
|
|
import api from "@/lib/api";
|
|
import type {
|
|
TrialBalanceLine,
|
|
ProfitLossReport,
|
|
BalanceSheetReport,
|
|
CashFlowReport,
|
|
} from "@/types/index";
|
|
|
|
type ReportTab = "trial-balance" | "profit-loss" | "balance-sheet" | "cash-flow";
|
|
|
|
const TABS: { key: ReportTab; label: string }[] = [
|
|
{ key: "trial-balance", label: "Trial Balance" },
|
|
{ key: "profit-loss", label: "Profit & Loss" },
|
|
{ key: "balance-sheet", label: "Balance Sheet" },
|
|
{ key: "cash-flow", label: "Cash Flow" },
|
|
];
|
|
|
|
// ─── Trial Balance ─────────────────────────────────────────────────────────────
|
|
|
|
function TrialBalance({ dateFrom, dateTo }: { dateFrom: string; dateTo: string }) {
|
|
const { data, isLoading } = useQuery<TrialBalanceLine[]>({
|
|
queryKey: ["report-trial-balance", dateFrom, dateTo],
|
|
queryFn: async () => {
|
|
const res = await api.get(`/api/v1/reports/trial-balance?dateFrom=${dateFrom}&dateTo=${dateTo}`);
|
|
const d = res.data;
|
|
if (Array.isArray(d)) return d;
|
|
return (d as { data?: TrialBalanceLine[] }).data ?? d as TrialBalanceLine[];
|
|
},
|
|
});
|
|
|
|
const lines = data ?? [];
|
|
const totalDebit = lines.reduce((s, l) => s + Number(l.debit), 0);
|
|
const totalCredit = lines.reduce((s, l) => s + Number(l.credit), 0);
|
|
|
|
if (isLoading) return <div className="h-64 animate-pulse bg-gray-100 rounded-xl" />;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader><CardTitle>Trial Balance</CardTitle></CardHeader>
|
|
<CardContent className="p-0">
|
|
{lines.length === 0 ? (
|
|
<p className="text-sm text-gray-400 py-8 text-center">No data for this period</p>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-gray-50 border-b">
|
|
<tr>
|
|
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500">Code</th>
|
|
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500">Account</th>
|
|
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500">Debit</th>
|
|
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500">Credit</th>
|
|
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500">Balance</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lines.map((l, i) => (
|
|
<tr key={i} className="border-t hover:bg-gray-50">
|
|
<td className="px-4 py-2.5 font-mono text-xs text-gray-500">{l.code}</td>
|
|
<td className="px-4 py-2.5 font-medium">{l.name}</td>
|
|
<td className="px-4 py-2.5 text-right font-mono text-sm">
|
|
{Number(l.debit) > 0 ? formatCurrency(Number(l.debit)) : "—"}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right font-mono text-sm">
|
|
{Number(l.credit) > 0 ? formatCurrency(Number(l.credit)) : "—"}
|
|
</td>
|
|
<td className={`px-4 py-2.5 text-right font-mono text-sm font-semibold ${Number(l.balance) < 0 ? "text-red-600" : "text-gray-800"}`}>
|
|
{formatCurrency(Math.abs(Number(l.balance)))}
|
|
{Number(l.balance) < 0 && " Cr"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot className="border-t bg-gray-50 font-semibold">
|
|
<tr>
|
|
<td colSpan={2} className="px-4 py-2.5 text-sm">Totals</td>
|
|
<td className="px-4 py-2.5 text-right font-mono text-sm">{formatCurrency(totalDebit)}</td>
|
|
<td className="px-4 py-2.5 text-right font-mono text-sm">{formatCurrency(totalCredit)}</td>
|
|
<td className="px-4 py-2.5 text-right">
|
|
{Math.abs(totalDebit - totalCredit) < 0.01 ? (
|
|
<span className="text-green-600 text-xs">✓ Balanced</span>
|
|
) : (
|
|
<span className="text-red-500 text-xs">Off by {formatCurrency(Math.abs(totalDebit - totalCredit))}</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── P&L ──────────────────────────────────────────────────────────────────────
|
|
|
|
function ProfitLoss({ dateFrom, dateTo }: { dateFrom: string; dateTo: string }) {
|
|
const { data, isLoading } = useQuery<ProfitLossReport>({
|
|
queryKey: ["report-profit-loss", dateFrom, dateTo],
|
|
queryFn: async () => {
|
|
const res = await api.get(`/api/v1/reports/profit-loss?dateFrom=${dateFrom}&dateTo=${dateTo}`);
|
|
return res.data as ProfitLossReport;
|
|
},
|
|
});
|
|
|
|
if (isLoading) return <div className="h-64 animate-pulse bg-gray-100 rounded-xl" />;
|
|
if (!data) return <p className="text-sm text-gray-400 py-8 text-center">No data for this period</p>;
|
|
|
|
const netIncome = Number(data.netIncome ?? (Number(data.totalRevenue) - Number(data.totalExpenses)));
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader><CardTitle>Profit & Loss Statement</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-6">
|
|
{/* Revenue */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-2 uppercase tracking-wide">Revenue</h3>
|
|
<div className="space-y-1">
|
|
{(data.revenue ?? []).map((r, i) => (
|
|
<div key={i} className="flex justify-between text-sm py-1 border-b border-gray-50">
|
|
<span className="text-gray-700">{r.name}</span>
|
|
<span className="font-mono font-medium">{formatCurrency(Number(r.amount))}</span>
|
|
</div>
|
|
))}
|
|
<div className="flex justify-between text-sm font-bold py-1 border-t">
|
|
<span>Total Revenue</span>
|
|
<span className="text-green-700">{formatCurrency(Number(data.totalRevenue))}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expenses */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-2 uppercase tracking-wide">Expenses</h3>
|
|
<div className="space-y-1">
|
|
{(data.expenses ?? []).map((e, i) => (
|
|
<div key={i} className="flex justify-between text-sm py-1 border-b border-gray-50">
|
|
<span className="text-gray-700">{e.name}</span>
|
|
<span className="font-mono font-medium">{formatCurrency(Number(e.amount))}</span>
|
|
</div>
|
|
))}
|
|
<div className="flex justify-between text-sm font-bold py-1 border-t">
|
|
<span>Total Expenses</span>
|
|
<span className="text-red-600">{formatCurrency(Number(data.totalExpenses))}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Net Income */}
|
|
<div className={`flex justify-between text-base font-bold p-3 rounded-lg ${netIncome >= 0 ? "bg-green-50 text-green-800" : "bg-red-50 text-red-800"}`}>
|
|
<span>Net Income</span>
|
|
<span>{formatCurrency(netIncome)}</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── Balance Sheet ─────────────────────────────────────────────────────────────
|
|
|
|
function BalanceSheet({ asOf }: { asOf: string }) {
|
|
const { data, isLoading } = useQuery<BalanceSheetReport>({
|
|
queryKey: ["report-balance-sheet", asOf],
|
|
queryFn: async () => {
|
|
const res = await api.get(`/api/v1/reports/balance-sheet?asOf=${asOf}`);
|
|
return res.data as BalanceSheetReport;
|
|
},
|
|
});
|
|
|
|
if (isLoading) return <div className="h-64 animate-pulse bg-gray-100 rounded-xl" />;
|
|
if (!data) return <p className="text-sm text-gray-400 py-8 text-center">No data</p>;
|
|
|
|
function Section({ title, items, total, color }: { title: string; items: Array<{ name: string; amount: number }>; total: number; color: string }) {
|
|
return (
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-2 uppercase tracking-wide">{title}</h3>
|
|
<div className="space-y-1">
|
|
{items.map((item, i) => (
|
|
<div key={i} className="flex justify-between text-sm py-1 border-b border-gray-50">
|
|
<span className="text-gray-700">{item.name}</span>
|
|
<span className="font-mono">{formatCurrency(Number(item.amount))}</span>
|
|
</div>
|
|
))}
|
|
<div className={`flex justify-between text-sm font-bold py-1 border-t ${color}`}>
|
|
<span>Total {title}</span>
|
|
<span>{formatCurrency(Number(total))}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader><CardTitle>Balance Sheet</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
<Section title="Assets" items={data.assets ?? []} total={data.totalAssets} color="text-blue-700" />
|
|
<div className="space-y-6">
|
|
<Section title="Liabilities" items={data.liabilities ?? []} total={data.totalLiabilities} color="text-red-600" />
|
|
<Section title="Equity" items={data.equity ?? []} total={data.totalEquity} color="text-purple-700" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-6 p-3 rounded-lg bg-gray-50 flex justify-between text-sm font-bold">
|
|
<span>Total Liabilities + Equity</span>
|
|
<span>{formatCurrency(Number(data.totalLiabilities) + Number(data.totalEquity))}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── Cash Flow ─────────────────────────────────────────────────────────────────
|
|
|
|
function CashFlow({ dateFrom, dateTo }: { dateFrom: string; dateTo: string }) {
|
|
const { data, isLoading } = useQuery<CashFlowReport>({
|
|
queryKey: ["report-cash-flow", dateFrom, dateTo],
|
|
queryFn: async () => {
|
|
const res = await api.get(`/api/v1/reports/cash-flow?dateFrom=${dateFrom}&dateTo=${dateTo}`);
|
|
return res.data as CashFlowReport;
|
|
},
|
|
});
|
|
|
|
if (isLoading) return <div className="h-64 animate-pulse bg-gray-100 rounded-xl" />;
|
|
if (!data) return <p className="text-sm text-gray-400 py-8 text-center">No data for this period</p>;
|
|
|
|
function CashSection({ title, items, net }: { title: string; items: Array<{ name: string; amount: number }>; net: number }) {
|
|
return (
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-2 uppercase tracking-wide">{title}</h3>
|
|
<div className="space-y-1">
|
|
{items.map((item, i) => (
|
|
<div key={i} className="flex justify-between text-sm py-1 border-b border-gray-50">
|
|
<span className="text-gray-700">{item.name}</span>
|
|
<span className={`font-mono ${Number(item.amount) < 0 ? "text-red-600" : ""}`}>
|
|
{formatCurrency(Number(item.amount))}
|
|
</span>
|
|
</div>
|
|
))}
|
|
<div className="flex justify-between text-sm font-bold py-1 border-t">
|
|
<span>Net {title}</span>
|
|
<span className={Number(net) >= 0 ? "text-green-700" : "text-red-600"}>
|
|
{formatCurrency(Number(net))}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader><CardTitle>Cash Flow Statement</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-6">
|
|
<CashSection title="Operating Activities" items={data.operating ?? []} net={data.netOperating} />
|
|
<CashSection title="Investing Activities" items={data.investing ?? []} net={data.netInvesting} />
|
|
<CashSection title="Financing Activities" items={data.financing ?? []} net={data.netFinancing} />
|
|
<div className={`flex justify-between text-base font-bold p-3 rounded-lg ${Number(data.netCashFlow) >= 0 ? "bg-green-50 text-green-800" : "bg-red-50 text-red-800"}`}>
|
|
<span>Net Cash Flow</span>
|
|
<span>{formatCurrency(Number(data.netCashFlow))}</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ─────────────────────────────────────────────────────────────────
|
|
|
|
export default function AccountingReportsPage() {
|
|
const today = new Date().toISOString().split("T")[0];
|
|
const firstOfYear = `${new Date().getFullYear()}-01-01`;
|
|
|
|
const [activeTab, setActiveTab] = useState<ReportTab>("trial-balance");
|
|
const [dateFrom, setDateFrom] = useState(firstOfYear);
|
|
const [dateTo, setDateTo] = useState(today);
|
|
const [asOf, setAsOf] = useState(today);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-start justify-between flex-wrap gap-3">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Accounting</h1>
|
|
<p className="text-sm text-gray-500">Financial statements and accounting reports</p>
|
|
</div>
|
|
{activeTab !== "balance-sheet" ? (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<label className="text-gray-500">From</label>
|
|
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
|
|
className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
|
<label className="text-gray-500">To</label>
|
|
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
|
className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<label className="text-gray-500">As of</label>
|
|
<input type="date" value={asOf} onChange={(e) => setAsOf(e.target.value)}
|
|
className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<AccountingNav />
|
|
|
|
{/* Report tabs */}
|
|
<div className="flex gap-1 flex-wrap">
|
|
{TABS.map(({ key, label }) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => setActiveTab(key)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
activeTab === key
|
|
? "bg-blue-100 text-blue-700"
|
|
: "text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeTab === "trial-balance" && <TrialBalance dateFrom={dateFrom} dateTo={dateTo} />}
|
|
{activeTab === "profit-loss" && <ProfitLoss dateFrom={dateFrom} dateTo={dateTo} />}
|
|
{activeTab === "balance-sheet" && <BalanceSheet asOf={asOf} />}
|
|
{activeTab === "cash-flow" && <CashFlow dateFrom={dateFrom} dateTo={dateTo} />}
|
|
</div>
|
|
);
|
|
}
|