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 />
}
@@ -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 />
}
+2
View File
@@ -134,6 +134,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ title: 'Bank Accounts', href: '/payments/bank-accounts', icon: BankIcon, description: 'Cash and bank accounts used for payments and deposits' },
{ title: 'Cheque Books', href: '/payments/cheque-books', icon: ClipboardListIcon, description: 'Cheque books issued from the company’s own supply' },
{ title: 'Received Cheques', href: '/payments/received-cheques', icon: DollarSquareIcon, description: 'Cheques received from customers, suppliers, or others' },
{ title: 'GL Integration', href: '/configuration/general-ledger', icon: Settings01Icon, description: 'Set up the General Ledger link and retry failed postings', permission: 'gl.manage' },
],
},
{
@@ -179,6 +180,7 @@ export const NAV_GROUPS: NavGroup[] = [
items: [
{ title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' },
{ title: 'Stock Report', href: '/reports/stock', icon: Package01Icon, description: 'Opening, in, out and closing stock, low stock and expiry', permission: 'warehouses.view' },
{ title: 'Financial Reports', href: '/reports/finance', icon: DollarSquareIcon, description: 'Trial balance, ledger, profit & loss, balance sheet and cash flow from the General Ledger', permission: 'finance-reports.view' },
],
},
]
+8
View File
@@ -107,6 +107,14 @@ export const PERMISSION_CATEGORIES: PermissionCategory[] = [
'roles.view',
],
},
{
id: 'finance',
label: 'Finance',
codes: [
'finance-reports.view',
'gl.manage',
],
},
{
id: 'system',
label: 'System',
+110
View File
@@ -0,0 +1,110 @@
import { api } from '@/lib/api'
export type FinanceReportType =
| 'TrialBalance'
| 'GeneralLedger'
| 'ProfitAndLoss'
| 'BalanceSheet'
| 'CashFlow'
| 'BudgetVsActual'
| 'TaxSummary'
export interface FinanceReportParams {
asOfDate?: string
periodStart?: string
periodEnd?: string
accountCode?: string
budgetId?: number
}
export type FileFormat = 'Pdf' | 'Csv'
const cleanParams = (params: FinanceReportParams) =>
Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined && v !== ''))
// Reports come from the General Ledger service through the Distribution backend (/reports/finance/{type}).
export interface GlAccountOption {
accountCode: string
accountName: string
}
export const financeReports = {
/** Active GL accounts for the account dropdown. */
accounts: () => api.get<GlAccountOption[]>('/reports/finance/accounts').then(r => r.data),
get: (type: FinanceReportType, params: FinanceReportParams) =>
api.get<unknown>(`/reports/finance/${type}`, { params: cleanParams(params) }).then(r => r.data),
/** Downloads the GL-rendered PDF or CSV and hands it to the browser. */
async download(type: FinanceReportType, params: FinanceReportParams, format: FileFormat) {
const res = await api.get<Blob>(`/reports/finance/${type}`, {
params: { ...cleanParams(params), outputFormat: format },
responseType: 'blob',
})
const disposition = String(res.headers['content-disposition'] ?? '')
const name = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)?.[1] ?? `${type}.${format.toLowerCase()}`
const url = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = url
a.download = decodeURIComponent(name)
a.click()
URL.revokeObjectURL(url)
},
}
export interface GlStatus {
glReachable: boolean
setupDone: boolean
openingBalancePosted: boolean
closedYears: number[]
mapping: Record<string, string | null>
posted: number
failed: number
}
export interface GlPostingRow {
id: string
event: string
sourceId: string
reference: string
status: 'Pending' | 'Posted' | 'Failed'
glJournalNo: string | null
error: string | null
attempts: number
lastAttemptAt: string | null
createdAt: string
}
export interface OpeningBalanceLine {
/** A key such as 'Inventory' or 'ShareCapital', or 'BankAccount' together with bankAccountId. */
account: string
bankAccountId?: string
/** The account's normal balance (>= 0). Debit/credit side is decided by the backend. */
amount: number
}
export interface YearEndPreview {
year: number
startDate: string
endDate: string
totalIncome: number
totalExpenses: number
netProfit: number
closable: boolean
reason: string | null
alreadyClosed: boolean
}
export const generalLedger = {
yearEndPreview: (year: number) =>
api.get<YearEndPreview>('/gl/year-end/preview', { params: { year } }).then(r => r.data),
closeYear: (year: number) =>
api.post<{ year: number; glJournalNo: string | null; netProfit: number; periodsLocked: number }>('/gl/year-end/close', { year }).then(r => r.data),
postOpeningBalances: (date: string, narration: string, lines: OpeningBalanceLine[]) =>
api.post<{ status: string; glJournalNo: string | null }>('/gl/opening-balances', { date, narration, lines }).then(r => r.data),
status: () => api.get<GlStatus>('/gl/status').then(r => r.data),
setup: (year?: number) => api.post<{ done: boolean; actions: string[] }>('/gl/setup', { year }).then(r => r.data),
postings: (status?: GlPostingRow['status']) =>
api.get<GlPostingRow[]>('/gl/postings', { params: { status } }).then(r => r.data),
retry: () => api.post<{ posted: number }>('/gl/postings/retry').then(r => r.data),
}
+1
View File
@@ -9,6 +9,7 @@ export { purchaseOrders, grn, purchaseReturns } from './procurement'
export { rides, loadingSheets, deliveryTasks } from './operations'
export { stock } from './stock'
export { productReports, stockReports, dashboardReports } from './reports'
export { financeReports, generalLedger } from './finance-reports'
export { shopPos, invoices, payments } from './sales'
export { saleReturns, claims, supplierClaims } from './returns-claims'
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config'