Files
fiberops-web/app/(app)/accounting/reports/page.tsx
Nemo (Claude Code) b719d87e1b feat: Accounting web module — COA, journals, expenses, accounts, reports + nav (FIBEROPS-234-239)
- Chart of Accounts (FIBEROPS-234): type-filtered table, add/seed/toggle-active
- Journal Entries (FIBEROPS-235): double-entry form with debit=credit validation, read-only view modal
- Expenses (FIBEROPS-236): date-range filtered table, record expense against expense accounts
- Company Accounts + Transfers (FIBEROPS-237): balance cards, add account modal, transfer modal, transfers table
- Financial Reports (FIBEROPS-238): Trial Balance, P&L, Balance Sheet, Cash Flow with date pickers
- Sidebar nav entry (FIBEROPS-239): Accounting link with BookOpen icon, placed between Reports and Settings
- AccountingNav horizontal sub-nav component shared across all accounting pages
- New types: Account, JournalEntry, Expense, CompanyAccount, Transfer and report types added to src/types/index.ts
2026-03-31 23:35:29 +00:00

340 lines
15 KiB
TypeScript

"use client";
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 &amp; 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>
);
}