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
This commit is contained in:
339
app/(app)/accounting/reports/page.tsx
Normal file
339
app/(app)/accounting/reports/page.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
"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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user