feat: Implement new General Ledger frontend section with comprehensive report screens and cash/bank account management
- Added a new Ledgers sidebar section for statutory-format financial reports and cash/bank-account management. - Introduced dedicated GL client for API interactions, handling response envelopes and error management. - Developed report screens for Trial Balance, Balance Sheet, General Ledger, Profit & Loss, Cash Flow, Budget vs Actual, and a new Tax Report. - Implemented CSV download functionality alongside existing PDF downloads for all report screens. - Separated Cash and Bank accounts into distinct tables/endpoints, with updated create forms and unified list view. - Created a new Accounts section for Cheque Management, moving Cash/Bank Accounts from the Ledgers section. - Updated RBAC navigation to include new permissions and sub-navigation items for the added features. - Ensured compliance with GL's updated API contract, including renaming fields and adjusting response shapes. - Addressed various bugs and presentation issues, enhancing user experience across the new module.
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// Client for the external General Ledger service, reached through ERPCore's generic
|
||||
// reverse-proxy at /api/v1/gl/* (docs/12-GENERAL-LEDGER-INTEGRATION.md). Deliberately NOT
|
||||
// built on lib/api-client.ts's apiRequest/apiRequestWithETag: those assume ERPCore's own
|
||||
// RFC 7807 ProblemDetails error shape and a bare-DTO success body. GL wraps every response
|
||||
// (success AND error) in its own `{ statusCode, success, message, data }` envelope instead,
|
||||
// and — a documented GL quirk — success bodies are camelCase while error bodies are
|
||||
// PascalCase, so this module unwraps both forms itself rather than trusting one casing.
|
||||
import {
|
||||
CashAccountType,
|
||||
CashAndBankAccountDto,
|
||||
CashBankAccountType,
|
||||
CreateBankAccountRequest,
|
||||
CreateCashAccountRequest,
|
||||
CreateCashOrBankAccountResponse,
|
||||
GlAccountListResult,
|
||||
GlBudget,
|
||||
GlFilePayload,
|
||||
ReportOutputFormat,
|
||||
ReportType,
|
||||
TrialBalanceRow,
|
||||
BalanceSheetResponse,
|
||||
GeneralLedgerRow,
|
||||
ProfitAndLossResponse,
|
||||
CashFlowResponse,
|
||||
BudgetVsActualRow,
|
||||
TaxSummaryResponse,
|
||||
TaxSummaryParams,
|
||||
GlPagedResult,
|
||||
ChequeBook,
|
||||
ChequeBookStatus,
|
||||
ChequePage,
|
||||
CreateChequeBookRequest,
|
||||
IssueChequePageRequest,
|
||||
UpdateChequePageStatusRequest,
|
||||
ReceivedCheque,
|
||||
ReceivedChequeStatus,
|
||||
ReceivedFromType,
|
||||
CreateReceivedChequeRequest,
|
||||
UpdateReceivedChequeStatusRequest,
|
||||
} from "@/types/general-ledger"
|
||||
|
||||
const GL_BASE = "/api/v1/gl"
|
||||
|
||||
/** Duck-type compatible with lib/error-map.ts's ApiErrorLike — `detail` carries GL's own message. */
|
||||
export class GlApiError extends Error {
|
||||
status: number
|
||||
detail: string
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.detail = message
|
||||
}
|
||||
}
|
||||
|
||||
interface GlEnvelope<T> {
|
||||
statusCode?: number
|
||||
StatusCode?: number
|
||||
success?: boolean
|
||||
Success?: boolean
|
||||
message?: string
|
||||
Message?: string
|
||||
data?: T
|
||||
Data?: T
|
||||
// Surfaces only when the proxy itself fails before reaching GL (e.g. ERPCore's own
|
||||
// 503 GL_SERVICE_UNAVAILABLE ProblemDetails) rather than GL's own envelope.
|
||||
title?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
type GlQueryValue = string | number | undefined
|
||||
|
||||
async function glRequest<T>(
|
||||
path: string,
|
||||
options: { method?: string; query?: Record<string, GlQueryValue>; body?: unknown } = {}
|
||||
): Promise<T> {
|
||||
const { method = "GET", query, body } = options
|
||||
const search = new URLSearchParams()
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === "") continue
|
||||
search.set(key, String(value))
|
||||
}
|
||||
}
|
||||
const qs = search.toString()
|
||||
|
||||
const response = await fetch(`${GL_BASE}${path}${qs ? `?${qs}` : ""}`, {
|
||||
method,
|
||||
credentials: "include", // the proxy is ErpAccess-gated, same as every other v1 endpoint
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
let envelope: GlEnvelope<T> | null = null
|
||||
try {
|
||||
envelope = (await response.json()) as GlEnvelope<T>
|
||||
} catch {
|
||||
// Non-JSON body — e.g. an unreachable proxy hop. Falls through to the generic message below.
|
||||
}
|
||||
|
||||
const success = envelope?.success ?? envelope?.Success ?? false
|
||||
if (!response.ok || !success) {
|
||||
const message =
|
||||
envelope?.message ?? envelope?.Message ?? envelope?.detail ?? envelope?.title ??
|
||||
response.statusText ?? "General Ledger service request failed"
|
||||
throw new GlApiError(response.status, message)
|
||||
}
|
||||
|
||||
return (envelope?.data ?? envelope?.Data) as T
|
||||
}
|
||||
|
||||
/** Decodes a base64 payload and triggers a browser download — no server round-trip needed. */
|
||||
function downloadBase64File(base64: string, fileName: string, contentType: string) {
|
||||
const byteChars = atob(base64)
|
||||
const byteNumbers = new Array(byteChars.length)
|
||||
for (let i = 0; i < byteChars.length; i++) byteNumbers[i] = byteChars.charCodeAt(i)
|
||||
const blob = new Blob([new Uint8Array(byteNumbers)], { type: contentType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/** Shared by downloadPdf/downloadCsv — same report call, only `outputFormat` differs. */
|
||||
async function downloadReportFile(
|
||||
reportType: ReportType,
|
||||
outputFormat: ReportOutputFormat.Pdf | ReportOutputFormat.Csv,
|
||||
params: Record<string, GlQueryValue>
|
||||
): Promise<void> {
|
||||
const payload = await glRequest<GlFilePayload>("/reports", {
|
||||
query: { reportType, outputFormat, ...params },
|
||||
})
|
||||
downloadBase64File(payload.contentBase64, payload.fileName, payload.contentType)
|
||||
}
|
||||
|
||||
export const reportsApi = {
|
||||
trialBalance(asOfDate: string) {
|
||||
return glRequest<TrialBalanceRow[]>("/reports", {
|
||||
query: { reportType: ReportType.TrialBalance, outputFormat: ReportOutputFormat.Json, asOfDate },
|
||||
})
|
||||
},
|
||||
|
||||
/** Confirmed classified-statement shape (2026-07-31 rework) — see types/general-ledger.ts's BalanceSheetResponse note. */
|
||||
balanceSheet(asOfDate: string) {
|
||||
return glRequest<BalanceSheetResponse>("/reports", {
|
||||
query: { reportType: ReportType.BalanceSheet, outputFormat: ReportOutputFormat.Json, asOfDate },
|
||||
})
|
||||
},
|
||||
|
||||
// GL's `accountCode` param is optional (renamed from `accountId` in GL's 2026-07-22 revision,
|
||||
// CLAUDE.md Rule 8.2 on GL's side — behavior unchanged): omitted, this returns the true General
|
||||
// Ledger — every postable account's own transactions together, each with its own running
|
||||
// balance (resets per account), sorted by accountCode then entryDate. Supplying accountCode
|
||||
// switches to "Account Ledger" mode (one account + its descendants, one running balance) — not
|
||||
// used by this page; add it back with an accountCode param if a single-account view is needed later.
|
||||
generalLedger(periodStart: string, periodEnd: string) {
|
||||
return glRequest<GeneralLedgerRow[]>("/reports", {
|
||||
query: { reportType: ReportType.GeneralLedger, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd },
|
||||
})
|
||||
},
|
||||
|
||||
/** Nested-sections shape as of the 2026-07-22 rework — see types/general-ledger.ts's ProfitAndLossResponse note. */
|
||||
profitAndLoss(periodStart: string, periodEnd: string) {
|
||||
return glRequest<ProfitAndLossResponse>("/reports", {
|
||||
query: { reportType: ReportType.ProfitAndLoss, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd },
|
||||
})
|
||||
},
|
||||
|
||||
/** Confirmed structured-statement shape (2026-07-22 rework) — see types/general-ledger.ts's CashFlowResponse note; everything nests under `operatingActivities`. */
|
||||
cashFlow(periodStart: string, periodEnd: string) {
|
||||
return glRequest<CashFlowResponse>("/reports", {
|
||||
query: { reportType: ReportType.CashFlow, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd },
|
||||
})
|
||||
},
|
||||
|
||||
budgetVsActual(budgetId: number) {
|
||||
return glRequest<BudgetVsActualRow[]>("/reports", {
|
||||
query: { reportType: ReportType.BudgetVsActual, outputFormat: ReportOutputFormat.Json, budgetId },
|
||||
})
|
||||
},
|
||||
|
||||
/** Income Tax Computation — new report (2026-07-22). Optional params get no client-side default; an untouched field sends nothing, letting GL's own server-side defaulting be the single source of truth. */
|
||||
taxSummary(params: TaxSummaryParams) {
|
||||
return glRequest<TaxSummaryResponse>("/reports", {
|
||||
query: { reportType: ReportType.TaxSummary, outputFormat: ReportOutputFormat.Json, ...params },
|
||||
})
|
||||
},
|
||||
|
||||
/** Same report call as the Json variants above, only `outputFormat` differs — the PDF bytes come from the same endpoint. */
|
||||
downloadPdf(reportType: ReportType, params: Record<string, GlQueryValue>): Promise<void> {
|
||||
return downloadReportFile(reportType, ReportOutputFormat.Pdf, params)
|
||||
},
|
||||
|
||||
/** Same mechanism as downloadPdf, `outputFormat=Csv` — GL's bytes are downloaded unmodified, not reshaped/reformatted client-side. */
|
||||
downloadCsv(reportType: ReportType, params: Record<string, GlQueryValue>): Promise<void> {
|
||||
return downloadReportFile(reportType, ReportOutputFormat.Csv, params)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart of Accounts — used by the Cash/Bank Accounts list page to resolve each row's `glAccountId`
|
||||
* into a readable account code/name (retrofit 2026-07-31: the create form no longer needs this at
|
||||
* all, since `glAccountCode` was removed from the create request — the GL account is auto-created).
|
||||
* The General Ledger **report** page deliberately does NOT use this: it always calls the report in
|
||||
* full-ledger mode (no `accountCode`), so every account's code/name shown come from the report's own
|
||||
* rows (`GeneralLedgerRow.accountCode`/`accountName`), not a separate `/accounts` call — see
|
||||
* docs/21-GENERAL-LEDGER-FRONTEND.md.
|
||||
*/
|
||||
export const glAccountsApi = {
|
||||
list(): Promise<GlAccountListResult> {
|
||||
return glRequest<GlAccountListResult>("/accounts")
|
||||
},
|
||||
}
|
||||
|
||||
/** Used only to populate the Budget vs Actual report's budget picker. */
|
||||
export const glBudgetsApi = {
|
||||
list(): Promise<GlBudget[]> {
|
||||
return glRequest<GlBudget[]>("/budgets")
|
||||
},
|
||||
}
|
||||
|
||||
export const bankAccountsApi = {
|
||||
/** GL's own server-side union of both tables (2026-07-22 rework) — `accountType` narrows which table(s) contribute rows; client-side filters still layer on top. */
|
||||
list(accountType?: CashBankAccountType | "Both"): Promise<CashAndBankAccountDto[]> {
|
||||
return glRequest<CashAndBankAccountDto[]>("/bank-accounts", { query: { accountType } })
|
||||
},
|
||||
|
||||
createBank(request: CreateBankAccountRequest): Promise<CreateCashOrBankAccountResponse> {
|
||||
return glRequest<CreateCashOrBankAccountResponse>("/bank-accounts", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
createCash(request: CreateCashAccountRequest): Promise<CreateCashOrBankAccountResponse> {
|
||||
return glRequest<CreateCashOrBankAccountResponse>("/cash-accounts", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
// No get()/update(): GL exposes no GET/PUT by id for either bank_account or cash_account today
|
||||
// (see docs/21-GENERAL-LEDGER-FRONTEND.md's "Known gap — edit").
|
||||
}
|
||||
|
||||
/** Feeds the Cash/Bank create form's Cash Account Type picker; a name with no match creates a new type on the fly server-side (nothing to pre-create from this list). */
|
||||
export const cashAccountTypesApi = {
|
||||
list(): Promise<CashAccountType[]> {
|
||||
return glRequest<CashAccountType[]>("/cash-account-types")
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheque Books/Pages — cheques issued from this company's own cheque books (Cheque Management
|
||||
* module, added to GL 2026-07-30). `chequeBookNo` is the identifying value GL uses in its own
|
||||
* routes, not a numeric id. No `list()`/`get()` for pages standalone — a book's pages are always
|
||||
* read via `get(chequeBookNo, true)`'s `pages[]`, which is the only place this frontend needs them.
|
||||
*/
|
||||
export const chequeBooksApi = {
|
||||
list(params?: {
|
||||
bankAccountId?: number
|
||||
branchId?: number
|
||||
status?: ChequeBookStatus
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<GlPagedResult<ChequeBook>> {
|
||||
return glRequest<GlPagedResult<ChequeBook>>("/cheque-books", { query: { ...params } })
|
||||
},
|
||||
|
||||
/** `expandPages` maps to GL's `?expand=pages` — omit it for just the book's own fields. */
|
||||
get(chequeBookNo: string, expandPages = false): Promise<ChequeBook> {
|
||||
return glRequest<ChequeBook>(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, {
|
||||
query: expandPages ? { expand: "pages" } : undefined,
|
||||
})
|
||||
},
|
||||
|
||||
/** Auto-generates every leaf (`totalLeaves` `ChequePage` rows, all `Unused`) in the same call — the response's `pages[]` already has them. */
|
||||
create(request: CreateChequeBookRequest): Promise<ChequeBook> {
|
||||
return glRequest<ChequeBook>("/cheque-books", { method: "POST", body: request })
|
||||
},
|
||||
}
|
||||
|
||||
export const chequePagesApi = {
|
||||
issue(chequeNo: string, request: IssueChequePageRequest): Promise<ChequePage> {
|
||||
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, {
|
||||
method: "PUT",
|
||||
body: request,
|
||||
})
|
||||
},
|
||||
|
||||
/** `Clear`/`Bounce`/`Cancel`/`Void` — only valid from certain `issueStatus` values, see `types/general-ledger.ts`'s `ChequePageStatusAction`. */
|
||||
updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise<ChequePage> {
|
||||
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, {
|
||||
method: "PUT",
|
||||
body: request,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/** Received Cheques — cheques received from customers/suppliers/others, deliberately unlinked to any `ChequeBook`. */
|
||||
export const receivedChequesApi = {
|
||||
list(params?: {
|
||||
companyId?: number
|
||||
branchId?: number
|
||||
status?: ReceivedChequeStatus
|
||||
receivedFromType?: ReceivedFromType
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<GlPagedResult<ReceivedCheque>> {
|
||||
return glRequest<GlPagedResult<ReceivedCheque>>("/received-cheques", { query: { ...params } })
|
||||
},
|
||||
|
||||
create(request: CreateReceivedChequeRequest): Promise<ReceivedCheque> {
|
||||
return glRequest<ReceivedCheque>("/received-cheques", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** `Deposit`/`Clear`/`Return`/`Cancel` — only valid from certain statuses, see `types/general-ledger.ts`'s `ReceivedChequeStatusAction`. */
|
||||
updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise<ReceivedCheque> {
|
||||
return glRequest<ReceivedCheque>(`/received-cheques/${id}/status`, { method: "PUT", body: request })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Formatting helpers for statutory-style financial reports (docs/21-GENERAL-LEDGER-FRONTEND.md) —
|
||||
// comma-grouped thousands, fixed 2 decimals, negatives in parentheses (standard financial-statement
|
||||
// convention), rather than the plain `.toFixed(2)` used by the inventory-side stock screens.
|
||||
|
||||
const AMOUNT_FORMATTER = new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
|
||||
/** `1234.5` -> "1,234.50"; `-1234.5` -> "(1,234.50)"; `0`/`null`/`undefined` -> the given fallback. */
|
||||
export function formatAmount(value: number | null | undefined, zeroDash = false): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return "—"
|
||||
if (zeroDash && value === 0) return "—"
|
||||
const formatted = AMOUNT_FORMATTER.format(Math.abs(value))
|
||||
return value < 0 ? `(${formatted})` : formatted
|
||||
}
|
||||
|
||||
/** `"2026-07-01"` / an ISO timestamp -> "01 Jul 2026" for report headers and tables. */
|
||||
export function formatReportDate(value: string | null | undefined): string {
|
||||
if (!value) return "—"
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
|
||||
}
|
||||
|
||||
/** Today's date as `YYYY-MM-DD`, for default report filter values. */
|
||||
export function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** The first day of the current month as `YYYY-MM-DD`, for default period-start filter values. */
|
||||
export function startOfMonthIso(): string {
|
||||
const now = new Date()
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Client-side UX validation only (docs/20-FRONTEND.md §3.1) — required fields the browser
|
||||
// already knows about. Everything else is server-authoritative and surfaced via the GL
|
||||
// service's own error message (lib/error-map.ts).
|
||||
|
||||
export function validateBankAccountForm(input: { accountName: string }): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.accountName.trim()) errors.accountName = "Account name is required"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateChequeBookForm(input: {
|
||||
branchId: string
|
||||
bankAccountId: string
|
||||
chequeBookNo: string
|
||||
startChequeNo: string
|
||||
endChequeNo: string
|
||||
totalLeaves: string
|
||||
receivedDate: string
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.branchId.trim()) errors.branchId = "Branch ID is required"
|
||||
if (!input.bankAccountId) errors.bankAccountId = "Select a bank account"
|
||||
if (!input.chequeBookNo.trim()) errors.chequeBookNo = "Cheque book number is required"
|
||||
if (!input.startChequeNo.trim()) errors.startChequeNo = "Start cheque number is required"
|
||||
if (!input.endChequeNo.trim()) errors.endChequeNo = "End cheque number is required"
|
||||
if (!input.totalLeaves.trim()) errors.totalLeaves = "Total leaves is required"
|
||||
if (!input.receivedDate) errors.receivedDate = "Received date is required"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateIssueChequeForm(input: { payeeName: string; issueDate: string; amount: string }): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.payeeName.trim()) errors.payeeName = "Payee name is required"
|
||||
if (!input.issueDate) errors.issueDate = "Issue date is required"
|
||||
if (!input.amount.trim() || Number(input.amount) <= 0) errors.amount = "Amount must be greater than 0"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateReceivedChequeForm(input: {
|
||||
companyId: string
|
||||
receivedFromName: string
|
||||
chequeNo: string
|
||||
chequeDate: string
|
||||
amount: string
|
||||
receivedDate: string
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.companyId.trim()) errors.companyId = "Company ID is required"
|
||||
if (!input.receivedFromName.trim()) errors.receivedFromName = "Received-from name is required"
|
||||
if (!input.chequeNo.trim()) errors.chequeNo = "Cheque number is required"
|
||||
if (!input.chequeDate) errors.chequeDate = "Cheque date is required"
|
||||
if (!input.amount.trim() || Number(input.amount) <= 0) errors.amount = "Amount must be greater than 0"
|
||||
if (!input.receivedDate) errors.receivedDate = "Received date is required"
|
||||
return errors
|
||||
}
|
||||
Reference in New Issue
Block a user