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 />
|
||||
}
|
||||
+205
@@ -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 />
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user