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,350 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, 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 { bankAccounts } from '@/lib/api/bank-accounts'
import { generalLedger, type GlPostingRow, type GlStatus, type YearEndPreview } from '@/lib/api/finance-reports'
import type { BankAccountResponse } from '@/types/payments'
const ACCOUNT_LABELS: Record<string, string> = {
'Gl.Account.CashEquivalents': 'Cash and cash equivalents',
'Gl.Account.CashInHand': 'Cash in hand',
'Gl.Account.BankAccounts': 'Bank accounts',
'Gl.Account.ChequesInHand': 'Cheques in hand',
'Gl.Account.Receivable': 'Trade receivables',
'Gl.Account.Inventory': 'Inventory',
'Gl.Account.InputVat': 'Input VAT',
'Gl.Account.InputNbt': 'Input NBT',
'Gl.Account.Payable': 'Trade payables',
'Gl.Account.ChequesIssued': 'Cheques issued (uncleared)',
'Gl.Account.Equity': "Owner's equity (header)",
'Gl.Account.ShareCapital': "Share capital / owner's capital",
'Gl.Account.RetainedEarnings': 'Retained earnings',
'Gl.Account.Drawings': 'Drawings and dividends',
'Gl.Account.GeneralReserve': 'General reserve',
'Gl.Account.RevaluationReserve': 'Revaluation reserve',
'Gl.Account.Sales': 'Sales revenue',
}
// Opening balance form rows. Amounts are each account's normal balance and default to 0 (zero rows are skipped).
const OPENING_ROWS: { key: string; label: string; group: 'Assets' | 'Liabilities' | 'Equity' }[] = [
{ key: 'ChequesInHand', label: 'Cheques in hand', group: 'Assets' },
{ key: 'Receivable', label: 'Trade receivables', group: 'Assets' },
{ key: 'Inventory', label: 'Inventory', group: 'Assets' },
{ key: 'InputVat', label: 'Input VAT', group: 'Assets' },
{ key: 'InputNbt', label: 'Input NBT', group: 'Assets' },
{ key: 'Payable', label: 'Trade payables', group: 'Liabilities' },
{ key: 'ChequesIssued', label: 'Cheques issued (uncleared)', group: 'Liabilities' },
{ key: 'ShareCapital', label: "Share capital / owner's capital", group: 'Equity' },
{ key: 'RetainedEarnings', label: 'Retained earnings', group: 'Equity' },
{ key: 'GeneralReserve', label: 'General reserve', group: 'Equity' },
{ key: 'RevaluationReserve', label: 'Revaluation reserve', group: 'Equity' },
{ key: 'Drawings', label: 'Drawings and dividends (reduces equity)', group: 'Equity' },
]
const toNum = (v: string) => (v.trim() === '' ? 0 : Number(v))
const fmt = (v: number) => v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
async function fetchState() {
const status = await generalLedger.status()
const waiting = status.failed > 0
? [...(await generalLedger.postings('Failed')), ...(await generalLedger.postings('Pending'))]
: []
return { status, waiting }
}
export function GeneralLedgerClient() {
const allowed = usePermission('gl.manage')
const [status, setStatus] = useState<GlStatus | null>(null)
const [failed, setFailed] = useState<GlPostingRow[]>([])
const [busy, setBusy] = useState<'setup' | 'retry' | 'opening' | 'preview' | 'close' | null>(null)
const [closeYear, setCloseYear] = useState(new Date().getFullYear() - 1)
const [preview, setPreview] = useState<YearEndPreview | null>(null)
const [cashBank, setCashBank] = useState<BankAccountResponse[]>([])
const [amounts, setAmounts] = useState<Record<string, string>>({})
const [openingDate, setOpeningDate] = useState(`${new Date().getFullYear()}-01-01`)
const apply = useCallback((d: { status: GlStatus; waiting: GlPostingRow[] }) => {
setStatus(d.status)
setFailed(d.waiting)
}, [])
const reload = useCallback(
() => fetchState().then(apply).catch(err => toast.add({ title: 'Could not load General Ledger status', description: getApiError(err), type: 'error' })),
[apply],
)
useEffect(() => {
if (!allowed) return
fetchState()
.then(apply)
.catch(err => toast.add({ title: 'Could not load General Ledger status', description: getApiError(err), type: 'error' }))
}, [allowed, apply])
useEffect(() => {
if (allowed) bankAccounts.list().then(setCashBank).catch(() => setCashBank([]))
}, [allowed])
const rowsTotal = (group: 'Assets' | 'Liabilities' | 'Equity') =>
OPENING_ROWS.filter(r => r.group === group && r.key !== 'Drawings').reduce((t, r) => t + toNum(amounts[r.key] ?? ''), 0)
const bankTotal = cashBank.reduce((t, b) => t + toNum(amounts[`bank:${b.accountId}`] ?? ''), 0)
// Drawings is debit-side (reduces equity), so it sits with the assets side of the balance check.
const debitsTotal = rowsTotal('Assets') + bankTotal + toNum(amounts.Drawings ?? '')
const creditsTotal = rowsTotal('Liabilities') + rowsTotal('Equity')
const difference = debitsTotal - creditsTotal
async function loadPreview() {
setBusy('preview')
try {
setPreview(await generalLedger.yearEndPreview(closeYear))
} catch (err) {
setPreview(null)
toast.add({ title: 'Could not load the year-end preview', description: getApiError(err), type: 'error' })
} finally {
setBusy(null)
}
}
async function runClose() {
setBusy('close')
try {
const result = await generalLedger.closeYear(closeYear)
toast.add({
title: `${closeYear} closed`,
description: `Journal ${result.glJournalNo ?? ''} posted, ${result.periodsLocked} periods locked`,
type: 'success',
})
setPreview(null)
await reload()
} catch (err) {
toast.add({ title: 'Could not close the year', description: getApiError(err), type: 'error' })
} finally {
setBusy(null)
}
}
async function postOpening() {
setBusy('opening')
try {
const lines = [
...cashBank.map(b => ({ account: 'BankAccount', bankAccountId: b.accountId, amount: toNum(amounts[`bank:${b.accountId}`] ?? '') })),
...OPENING_ROWS.map(r => ({ account: r.key, amount: toNum(amounts[r.key] ?? '') })),
]
const result = await generalLedger.postOpeningBalances(openingDate, 'Opening balances', lines)
toast.add({ title: 'Opening balances posted', description: result.glJournalNo ? `Journal ${result.glJournalNo}` : undefined, type: 'success' })
setAmounts({})
await reload()
} catch (err) {
toast.add({ title: 'Could not post opening balances', description: getApiError(err), type: 'error' })
} finally {
setBusy(null)
}
}
async function runSetup() {
setBusy('setup')
try {
const result = await generalLedger.setup()
toast.add({
title: 'General Ledger set up',
description: result.actions.length ? `${result.actions.length} change(s) applied.` : 'Nothing to change — already up to date.',
type: 'success',
})
await reload()
} catch (err) {
toast.add({ title: 'Setup failed', description: getApiError(err), type: 'error' })
} finally {
setBusy(null)
}
}
async function retry() {
setBusy('retry')
try {
const result = await generalLedger.retry()
toast.add({ title: 'Retry finished', description: `${result.posted} posting(s) sent to the General Ledger.`, type: 'success' })
await reload()
} catch (err) {
toast.add({ title: 'Retry 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 manage the General Ledger link.</p>
}
return (
<>
<div>
<h1 className="text-2xl font-semibold text-foreground">General Ledger Integration</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Sales, goods receipts, payments and cheque movements are mirrored into the General Ledger so financial reports can be produced.
Sales, goods receipts and payments made before setup are not posted.
</p>
</div>
{status && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{([
['General Ledger service', status.glReachable ? 'Reachable' : 'Unreachable'],
['Setup', status.setupDone ? 'Done' : 'Not run'],
['Posted entries', String(status.posted)],
['Waiting / failed', String(status.failed)],
] as const).map(([label, value]) => (
<Card key={label} className="rounded-3xl border-border/60">
<CardContent className="space-y-1">
<CardDescription>{label}</CardDescription>
<CardTitle className="text-xl">{value}</CardTitle>
</CardContent>
</Card>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" onClick={runSetup} disabled={busy !== null}>
{busy === 'setup' ? 'Running…' : status?.setupDone ? 'Re-run setup' : 'Run setup'}
</Button>
<Button size="sm" variant="outline" onClick={retry} disabled={busy !== null || !status || status.failed === 0}>
{busy === 'retry' ? 'Retrying…' : 'Retry failed postings'}
</Button>
</div>
{status?.setupDone && (
<Card className="rounded-3xl border-border/60">
<CardContent className="space-y-3">
<h2 className="text-base font-semibold text-foreground">Close financial year</h2>
<p className="text-sm text-muted-foreground">
Moves the year&apos;s sales and expenses into Retained Earnings with one closing entry and locks the year&apos;s periods.
Anything dated in that year afterwards will be rejected by the General Ledger. Reports for the year still show its own figures.
</p>
<div className="flex flex-wrap items-center gap-2">
<Input className="w-32" type="number" value={closeYear} onChange={e => { setCloseYear(Number(e.target.value)); setPreview(null) }} />
<Button size="sm" variant="outline" onClick={loadPreview} disabled={busy !== null}>
{busy === 'preview' ? 'Checking…' : 'Preview'}
</Button>
</div>
{preview && (
<div className="space-y-2 text-sm">
<p>
Income {fmt(preview.totalIncome)} · Expenses {fmt(preview.totalExpenses)} · Net {preview.netProfit >= 0 ? 'profit' : 'loss'} {fmt(Math.abs(preview.netProfit))}
</p>
{preview.reason && <p className="text-amber-600">{preview.reason}</p>}
<Button size="sm" onClick={runClose} disabled={busy !== null || !preview.closable}>
{busy === 'close' ? 'Closing…' : `Close ${preview.year}`}
</Button>
</div>
)}
{status && status.closedYears.length > 0 && (
<p className="text-sm text-muted-foreground">Closed years: {status.closedYears.join(', ')}</p>
)}
</CardContent>
</Card>
)}
{status?.setupDone && !status.openingBalancePosted && (
<div className="rounded-3xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-foreground">
<p className="font-semibold">Opening balances have not been entered.</p>
<p className="text-muted-foreground mt-1">
The ledger and every financial report start from zero. If the business already has cash, bank balances, stock, receivables,
payables or capital, enter them below (all amounts default to 0). Equity accounts such as Share Capital only appear in the
Balance Sheet once they hold a balance.
</p>
</div>
)}
{status?.setupDone && (
<Card className="rounded-3xl border-border/60">
<CardContent className="space-y-3">
<h2 className="text-base font-semibold text-foreground">Opening balances</h2>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">As of</span>
<Input className="w-44" type="date" value={openingDate} onChange={e => setOpeningDate(e.target.value)} />
</div>
{(['Assets', 'Liabilities', 'Equity'] as const).map(group => (
<div key={group} className="space-y-1">
<p className="text-sm font-medium text-foreground">{group}</p>
<div className="grid gap-2 md:grid-cols-2">
{group === 'Assets' && cashBank.map(b => (
<label key={b.accountId} className="flex items-center justify-between gap-2 text-sm">
<span>{b.accountType === 'Bank' && b.bankName ? `${b.bankName} - ${b.accountName}` : b.accountName}</span>
<Input className="w-40 text-right" type="number" min="0" placeholder="0" value={amounts[`bank:${b.accountId}`] ?? ''}
onChange={e => setAmounts(a => ({ ...a, [`bank:${b.accountId}`]: e.target.value }))} />
</label>
))}
{OPENING_ROWS.filter(r => r.group === group).map(r => (
<label key={r.key} className="flex items-center justify-between gap-2 text-sm">
<span>{r.label}</span>
<Input className="w-40 text-right" type="number" min="0" placeholder="0" value={amounts[r.key] ?? ''}
onChange={e => setAmounts(a => ({ ...a, [r.key]: e.target.value }))} />
</label>
))}
</div>
</div>
))}
<p className={`text-sm ${difference === 0 ? 'text-muted-foreground' : 'text-amber-600'}`}>
Debit side (assets, drawings) {fmt(debitsTotal)} · Credit side (liabilities, equity) {fmt(creditsTotal)}
{difference !== 0 && ` · difference ${fmt(Math.abs(difference))} (adjust share capital or retained earnings)`}
</p>
<Button size="sm" onClick={postOpening} disabled={busy !== null || (debitsTotal === 0 && creditsTotal === 0) || difference !== 0}>
{busy === 'opening' ? 'Posting…' : 'Post opening balances'}
</Button>
</CardContent>
</Card>
)}
{status?.setupDone && (
<Card className="rounded-3xl border-border/60">
<CardContent className="space-y-2">
<h2 className="text-base font-semibold text-foreground">Account mapping</h2>
<Table>
<TableHeader>
<TableRow><TableHead>Purpose</TableHead><TableHead>GL account code</TableHead></TableRow>
</TableHeader>
<TableBody>
{Object.entries(status.mapping).map(([key, code]) => (
<TableRow key={key}>
<TableCell>{ACCOUNT_LABELS[key] ?? key}</TableCell>
<TableCell>{code ?? '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{failed.length > 0 && (
<Card className="rounded-3xl border-border/60">
<CardContent className="space-y-2">
<h2 className="text-base font-semibold text-foreground">Postings waiting to be sent</h2>
<Table>
<TableHeader>
<TableRow><TableHead>Event</TableHead><TableHead>Reference</TableHead><TableHead>Attempts</TableHead><TableHead>Error</TableHead></TableRow>
</TableHeader>
<TableBody>
{failed.map(p => (
<TableRow key={p.id}>
<TableCell>{p.event}</TableCell>
<TableCell className="font-mono text-xs">{p.sourceId}</TableCell>
<TableCell>{p.attempts}</TableCell>
<TableCell className="text-red-600">{p.error ?? '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</>
)
}
@@ -0,0 +1,5 @@
import { GeneralLedgerClient } from './_components/general-ledger-client'
export default function GeneralLedgerPage() {
return <GeneralLedgerClient />
}