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:
+350
@@ -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's sales and expenses into Retained Earnings with one closing entry and locks the year'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 />
|
||||
}
|
||||
Reference in New Issue
Block a user