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:
2026-07-31 17:57:55 +05:30
parent 76484c7268
commit 22657f0910
49 changed files with 5707 additions and 27 deletions
@@ -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 })
},
}