feat: add General Ledger integration and financial reporting features

- Implemented General Ledger client component for managing GL integration.
- Created a dedicated page for General Ledger configuration.
- Developed Finance Report client component for generating various financial reports.
- Added report blocks for structured display of financial data.
- Introduced API endpoints for fetching GL accounts and financial reports.
- Updated navigation to include links for GL integration and financial reports.
- Enhanced permissions to manage GL and view financial reports.
- Updated appsettings to include General Ledger API configuration.
This commit is contained in:
2026-10-20 18:54:30 +05:30
parent cc33ecdd54
commit ca81e9bc42
32 changed files with 8786 additions and 134 deletions
@@ -0,0 +1,205 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { toast } from '@/components/ui/toast'
import { usePermission } from '@/hooks/use-permission'
import { getApiError } from '@/lib/api/index'
import { financeReports, type FileFormat, type GlAccountOption, type FinanceReportParams, type FinanceReportType } from '@/lib/api/finance-reports'
import { toBlocks, type Block } from './report-blocks'
type Filter = 'asOf' | 'range' | 'account' | 'budget'
const REPORTS: { type: FinanceReportType; label: string; filters: Filter[]; hint: string }[] = [
{ type: 'TrialBalance', label: 'Trial Balance', filters: ['asOf'], hint: 'Debit and credit balance of every ledger account as of a date.' },
{ type: 'GeneralLedger', label: 'General Ledger', filters: ['range', 'account'], hint: 'Every posted transaction line with the account debited or credited, grouped by account.' },
{ type: 'ProfitAndLoss', label: 'Profit & Loss', filters: ['range'], hint: 'Income and expenses for a period.' },
{ type: 'BalanceSheet', label: 'Balance Sheet', filters: ['asOf'], hint: 'Assets, liabilities and equity as of a date.' },
{ type: 'CashFlow', label: 'Cash Flow', filters: ['range'], hint: 'Cash movement for a period (indirect method).' },
{ type: 'BudgetVsActual', label: 'Budget vs Actual', filters: ['budget'], hint: 'Budget lines against posted actuals. Budgets are created in the General Ledger service.' },
{ type: 'TaxSummary', label: 'Tax Summary', filters: ['range'], hint: 'Income tax computation for a period.' },
]
const ALL_ACCOUNTS = 'all'
const today = () => new Date().toISOString().slice(0, 10)
const yearStart = () => `${new Date().getFullYear()}-01-01`
export function FinanceReportClient() {
const allowed = usePermission('finance-reports.view')
const [type, setType] = useState<FinanceReportType>('TrialBalance')
const [asOfDate, setAsOfDate] = useState(today())
const [periodStart, setPeriodStart] = useState(yearStart())
const [periodEnd, setPeriodEnd] = useState(today())
const [accountCode, setAccountCode] = useState(ALL_ACCOUNTS)
const [accounts, setAccounts] = useState<GlAccountOption[]>([])
const [budgetId, setBudgetId] = useState('')
const [blocks, setBlocks] = useState<Block[] | null>(null)
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState<FileFormat | null>(null)
useEffect(() => {
if (!allowed) return
financeReports.accounts().then(setAccounts).catch(() => setAccounts([]))
}, [allowed])
const report = REPORTS.find(r => r.type === type)!
function params(): FinanceReportParams {
const p: FinanceReportParams = {}
if (report.filters.includes('asOf')) p.asOfDate = asOfDate
if (report.filters.includes('range')) { p.periodStart = periodStart; p.periodEnd = periodEnd }
if (report.filters.includes('account') && accountCode !== ALL_ACCOUNTS) p.accountCode = accountCode
if (report.filters.includes('budget')) p.budgetId = Number(budgetId) || undefined
return p
}
function changeType(next: FinanceReportType) {
setType(next)
setBlocks(null)
}
async function run() {
setLoading(true)
try {
const data = await financeReports.get(type, params())
setBlocks(toBlocks(type, data))
} catch (err) {
setBlocks(null)
toast.add({ title: 'Could not load report', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}
async function download(format: FileFormat) {
setBusy(format)
try {
await financeReports.download(type, params(), format)
} catch (err) {
toast.add({ title: 'Download failed', description: getApiError(err), type: 'error' })
} finally {
setBusy(null)
}
}
if (!allowed) {
return <p className="text-sm text-muted-foreground">You do not have permission to view financial reports.</p>
}
return (
<>
<div>
<h1 className="text-2xl font-semibold text-foreground">Financial Reports</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Produced by the General Ledger from sales, purchases and payments. Cash, bank and cheque balances are mirrored from the Payments module.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{REPORTS.map(r => (
<Button key={r.type} size="sm" variant={type === r.type ? 'default' : 'outline'} onClick={() => changeType(r.type)}>
{r.label}
</Button>
))}
</div>
<p className="text-sm text-muted-foreground">{report.hint}</p>
<div className="flex flex-wrap items-center gap-2">
{report.filters.includes('asOf') && (
<Input className="w-44" type="date" value={asOfDate} onChange={e => setAsOfDate(e.target.value)} aria-label="As of date" />
)}
{report.filters.includes('range') && (
<>
<Input className="w-44" type="date" value={periodStart} onChange={e => setPeriodStart(e.target.value)} aria-label="From" />
<Input className="w-44" type="date" value={periodEnd} onChange={e => setPeriodEnd(e.target.value)} aria-label="To" />
</>
)}
{report.filters.includes('account') && (
<Select value={accountCode} onValueChange={v => setAccountCode(v ?? ALL_ACCOUNTS)}>
<SelectTrigger className="w-72"><SelectValue placeholder="Account" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL_ACCOUNTS}>All accounts</SelectItem>
{accounts.map(a => <SelectItem key={a.accountCode} value={a.accountCode}>{a.accountCode} — {a.accountName}</SelectItem>)}
</SelectContent>
</Select>
)}
{report.filters.includes('budget') && (
<Input className="w-40" type="number" placeholder="Budget id" value={budgetId} onChange={e => setBudgetId(e.target.value)} />
)}
<Button size="sm" onClick={run} disabled={loading}>{loading ? 'Loading…' : 'Run report'}</Button>
<div className="ml-auto flex items-center gap-2">
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => download('Pdf')}>
{busy === 'Pdf' ? 'Downloading…' : 'Download PDF'}
</Button>
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => download('Csv')}>
{busy === 'Csv' ? 'Downloading…' : 'Download CSV'}
</Button>
</div>
</div>
{blocks === null ? (
<p className="text-sm text-muted-foreground">Choose the filters and run the report.</p>
) : blocks.length === 0 ? (
<p className="text-sm text-muted-foreground">No data for this selection.</p>
) : (
<div className="space-y-4">
{blocks.map((b, i) => <BlockTable key={i} block={b} />)}
</div>
)}
</>
)
}
function BlockTable({ block }: { block: Block }) {
const numeric = new Set(block.numericCols)
return (
<Card className="rounded-3xl border-border/60">
<CardContent className="space-y-2">
{block.title && <h2 className="text-base font-semibold text-foreground">{block.title}</h2>}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{block.headers.map((h, c) => (
<TableHead key={c} className={numeric.has(c) ? 'text-right' : undefined}>{h}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{block.rows.length === 0 ? (
<TableRow>
<TableCell colSpan={block.headers.length} className="text-muted-foreground">No entries.</TableCell>
</TableRow>
) : (
block.rows.map((row, r) => (
<TableRow key={r}>
{row.map((cell, c) => (
<TableCell key={c} className={numeric.has(c) ? 'text-right tabular-nums' : undefined}>{cell}</TableCell>
))}
</TableRow>
))
)}
</TableBody>
{block.footer && (
<TableFooter>
<TableRow>
{block.footer.map((cell, c) => (
<TableCell key={c} className={`font-semibold ${numeric.has(c) ? 'text-right tabular-nums' : ''}`}>{cell}</TableCell>
))}
</TableRow>
</TableFooter>
)}
</Table>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,153 @@
import type { FinanceReportType } from '@/lib/api/finance-reports'
/** A table the report page renders; every GL report is converted into one or more of these. */
export interface Block {
title?: string
headers: string[]
rows: string[][]
footer?: string[]
numericCols: number[]
}
type Obj = Record<string, unknown>
const money = (v: unknown) =>
Number(v ?? 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const text = (v: unknown) => (v == null ? '' : String(v))
const sum = (rows: Obj[], key: string) => rows.reduce((t, r) => t + Number(r[key] ?? 0), 0)
const humanize = (key: string) =>
key.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase()).replace(/\bApit\b/, 'APIT').replace(/\bWht\b/, 'WHT')
function summary(title: string | undefined, entries: [string, unknown][]): Block {
return { title, headers: ['', 'Amount'], rows: entries.map(([k, v]) => [k, money(v)]), numericCols: [1] }
}
function section(title: string, s: Obj | undefined, amountKey: string, totalLabel: string): Block | null {
const lines = (s?.lines as Obj[] | undefined) ?? []
if (lines.length === 0 && !Number(s?.total ?? 0)) return null
return {
title,
headers: ['Code', 'Account', 'Amount'],
rows: lines.map(l => [text(l.accountCode), text(l.accountName), money(l[amountKey])]),
footer: ['', totalLabel, money(s?.total)],
numericCols: [2],
}
}
const compact = (blocks: (Block | null)[]) => blocks.filter((b): b is Block => b !== null)
export function toBlocks(type: FinanceReportType, data: unknown): Block[] {
switch (type) {
case 'TrialBalance': {
const rows = (data as Obj[]) ?? []
return [{
headers: ['Code', 'Account', 'Debit', 'Credit'],
rows: rows.map(r => [text(r.accountCode), text(r.accountName), money(r.debit), money(r.credit)]),
footer: ['', 'Total', money(sum(rows, 'debit')), money(sum(rows, 'credit'))],
numericCols: [2, 3],
}]
}
case 'GeneralLedger': {
const rows = (data as Obj[]) ?? []
return [{
headers: ['Date', 'Journal', 'Account', 'Narration', 'Debit', 'Credit'],
rows: rows.map(r => [
text(r.entryDate), text(r.journalNo), `${text(r.accountCode)} ${text(r.accountName)}`, text(r.narration),
r.debitAmount ? money(r.debitAmount) : '', r.creditAmount ? money(r.creditAmount) : '',
]),
footer: ['', '', '', 'Total', money(sum(rows, 'debitAmount')), money(sum(rows, 'creditAmount'))],
numericCols: [4, 5],
}]
}
case 'ProfitAndLoss': {
const d = (data as Obj) ?? {}
return compact([
section('Sales', d.sales as Obj, 'amount', 'Total sales'),
section('Cost of sales', d.costOfSales as Obj, 'amount', 'Total cost of sales'),
summary(undefined, [['Gross profit', d.grossProfit]]),
section('Other income', d.otherIncome as Obj, 'amount', 'Total other income'),
section('Distribution expenses', d.distributionExpenses as Obj, 'amount', 'Total'),
section('Administration expenses', d.administrationExpenses as Obj, 'amount', 'Total'),
section('Other expenses', d.otherExpenses as Obj, 'amount', 'Total'),
section('Financial expenses', d.financialExpenses as Obj, 'amount', 'Total'),
section('Unclassified accounts', d.unclassified as Obj, 'amount', 'Total'),
summary(undefined, [['Net profit for the period', d.netProfitForPeriod]]),
])
}
case 'BalanceSheet': {
const d = (data as Obj) ?? {}
return compact([
section('Non-current assets', d.nonCurrentAssets as Obj, 'balance', 'Total'),
section('Current assets', d.currentAssets as Obj, 'balance', 'Total'),
section('Unclassified assets', d.unclassifiedAssets as Obj, 'balance', 'Total'),
summary(undefined, [['Total assets', d.totalAssets]]),
section('Equity', d.equity as Obj, 'balance', 'Total equity'),
section('Non-current liabilities', d.nonCurrentLiabilities as Obj, 'balance', 'Total'),
section('Current liabilities', d.currentLiabilities as Obj, 'balance', 'Total'),
section('Unclassified liabilities', d.unclassifiedLiabilities as Obj, 'balance', 'Total'),
summary(undefined, [['Total equity and liabilities', d.totalEquityAndLiabilities]]),
])
}
case 'CashFlow': {
const d = (data as Obj) ?? {}
const op = (d.operatingActivities as Obj) ?? {}
const adjustments = (op.nonCashAdjustments as Obj[]) ?? []
const changes = (op.workingCapitalChanges as Obj[]) ?? []
const lines = (a: unknown) => ((a as Obj)?.lines as Obj[]) ?? []
return [
{
title: 'Operating activities',
headers: ['Item', 'Amount'],
rows: [
['Profit for the period', money(op.profitForPeriod)],
...adjustments.map(a => [text(a.description), money(a.amount)]),
...changes.map(c => [`${text(c.accountName)} (${text(c.direction)})`, money(c.changeAmount)]),
],
footer: ['Net cash from operating activities', money(op.netCashFromOperatingActivities)],
numericCols: [1],
},
{
title: 'Investing activities',
headers: ['Item', 'Amount'],
rows: lines(d.investingActivities).map(l => [text(l.accountName), money(l.amount)]),
footer: ['Net cash from investing activities', money((d.investingActivities as Obj)?.netCashFromInvestingActivities)],
numericCols: [1],
},
{
title: 'Financing activities',
headers: ['Item', 'Amount'],
rows: lines(d.financingActivities).map(l => [text(l.accountName), money(l.amount)]),
footer: ['Net cash from financing activities', money((d.financingActivities as Obj)?.netCashFromFinancingActivities)],
numericCols: [1],
},
summary(undefined, [
['Net increase / decrease in cash', d.netIncreaseDecreaseInCash],
['Opening cash balance', d.openingCashBalance],
['Closing cash balance', d.closingCashBalance],
]),
]
}
case 'BudgetVsActual': {
const rows = (data as Obj[]) ?? []
return [{
headers: ['Account', 'Period', 'Budgeted', 'Actual', 'Variance'],
rows: rows.map(r => [`${text(r.accountCode)} ${text(r.accountName)}`, text(r.periodId), money(r.budgetedAmount), money(r.actualAmount), money(r.variance)]),
numericCols: [2, 3, 4],
}]
}
case 'TaxSummary': {
const d = (data as Obj) ?? {}
const entries = Object.entries(d).filter(([k]) => k !== 'periodStart' && k !== 'periodEnd')
return [summary(`Income tax computation (${text(d.periodStart)} to ${text(d.periodEnd)})`, entries.map(([k, v]) => [humanize(k), v]))]
}
}
}
@@ -0,0 +1,5 @@
import { FinanceReportClient } from './_components/finance-report-client'
export default function FinanceReportPage() {
return <FinanceReportClient />
}