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:
+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 />
|
||||
}
|
||||
Reference in New Issue
Block a user