From c30ff473f551c97645edfda7d80589206bba2360 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Wed, 7 Oct 2026 13:17:22 +0530 Subject: [PATCH] Add the GL Accounts settings screen Pickers are populated from GL's own chart of accounts, postable and active accounts only, so a user can only choose a code the server will accept. A stored code GL does not have is shown flagged and still selected rather than appearing blank, which is the state 11 of the 12 mappings are in until GL's chart is seeded - the broken mapping stays visible instead of looking merely unset. Options come from /gl-account-config rather than the GL proxy: that proxy is gated on the `ledgers` nav permission while this screen is on `settings`, and one endpoint keeps the picker and the server-side validation working from the same list. Gated on the existing `settings` key so no role_permissions edit is needed. Saving is disabled outright while GL is unreachable, matching the server, which refuses to store a mapping it could not verify. Co-Authored-By: Claude Opus 5 --- .../dashboard/settings/configuration/page.tsx | 8 +- .../dashboard/settings/gl-accounts/page.tsx | 217 ++++++++++++++++++ .../erp-system/lib/api/gl-account-config.ts | 20 ++ .../erp-system/types/gl-account-config.ts | 113 +++++++++ 4 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 Frontend/erp-system/app/dashboard/settings/gl-accounts/page.tsx create mode 100644 Frontend/erp-system/lib/api/gl-account-config.ts create mode 100644 Frontend/erp-system/types/gl-account-config.ts diff --git a/Frontend/erp-system/app/dashboard/settings/configuration/page.tsx b/Frontend/erp-system/app/dashboard/settings/configuration/page.tsx index 5742979..14bbb97 100644 --- a/Frontend/erp-system/app/dashboard/settings/configuration/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/configuration/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link" -import { Building2, LayoutTemplate, MapPin, PackageCheck, ShoppingCart, SlidersHorizontal } from "lucide-react" +import { BookOpen, Building2, LayoutTemplate, MapPin, PackageCheck, ShoppingCart, SlidersHorizontal } from "lucide-react" const cards = [ { @@ -32,6 +32,12 @@ const cards = [ icon: PackageCheck, description: "Warehouse selection and the default receiving warehouse", }, + { + title: "GL Accounts", + href: "/dashboard/settings/gl-accounts", + icon: BookOpen, + description: "Which General Ledger account each posting role uses", + }, { title: "Invoice Template", href: "/dashboard/settings/invoice-template", diff --git a/Frontend/erp-system/app/dashboard/settings/gl-accounts/page.tsx b/Frontend/erp-system/app/dashboard/settings/gl-accounts/page.tsx new file mode 100644 index 0000000..2c5a18c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/settings/gl-accounts/page.tsx @@ -0,0 +1,217 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { AlertTriangle, BookOpen, CheckCircle2 } from "lucide-react" + +import { glAccountConfigApi } from "@/lib/api/gl-account-config" +import { errorMessage } from "@/lib/error-map" +import { + GL_ROLE_FIELD, + GL_ROLE_LABELS, + GL_ROLE_ORDER, + GlAccountConfig, + GlAccountRole, + UpdateGlAccountConfigRequest, +} from "@/types/gl-account-config" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +/** + * GL Accounts — which General Ledger account each accounting role posts to. + * + * This used to live in the backend's appsettings.json as per-module `Gl*Code` keys, which could + * only be corrected by a redeploy and were only ever checked at startup. On 2026-10-07 eleven of + * the twelve configured codes turned out never to have existed in GL, so every GRN, adjustment, + * day-end and invoice posting was failing with "Account not found". + * + * The picker is populated from GL's own chart of accounts (postable, active accounts only) and the + * server re-validates on save, so a mapping that GL cannot post to cannot be stored. A stored code + * that GL does not have is shown flagged rather than blank — the broken mapping stays visible + * instead of silently appearing empty. + */ +export default function GlAccountsPage() { + const [config, setConfig] = useState(null) + const [etag, setEtag] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + /** Pending edits, by role. Empty once saved or reloaded. */ + const [draft, setDraft] = useState>>({}) + + function load() { + setError(null) + glAccountConfigApi + .get() + .then((res) => { + setConfig(res.data) + setEtag(res.etag) + setDraft({}) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, []) + + const current = useMemo(() => { + const byRole = new Map(config?.mappings.map((m) => [m.role, m]) ?? []) + return (role: GlAccountRole) => ({ + mapping: byRole.get(role), + value: role in draft ? draft[role] ?? null : byRole.get(role)?.accountCode ?? null, + }) + }, [config, draft]) + + const dirty = Object.keys(draft).length > 0 + const broken = config?.mappings.filter((m) => m.accountCode && !m.existsInGl) ?? [] + const unmapped = config?.mappings.filter((m) => !m.accountCode) ?? [] + + async function save() { + if (!config) return + setSaving(true) + try { + // PUT is a full replacement: start from what is stored, then layer the pending edits. + const body = GL_ROLE_ORDER.reduce((acc, role) => { + acc[GL_ROLE_FIELD[role]] = current(role).value + return acc + }, {} as UpdateGlAccountConfigRequest) + + const res = await glAccountConfigApi.update(body, etag ?? "") + setConfig(res.data) + setEtag(res.etag) + setDraft({}) + toast.success("GL accounts saved", "Postings will use the new mapping immediately.") + } catch (err) { + toast.error("Could not save GL accounts", errorMessage(err)) + } finally { + setSaving(false) + } + } + + return ( +
+
+

GL Accounts

+

+ Choose which General Ledger account each posting role uses. Only postable accounts from + GL's chart of accounts can be selected. +

+
+ + {error && ( +
{error}
+ )} + + {!error && !config && } + + {!error && config && ( + <> + {config.glUnavailable && ( +
+ +
+ The General Ledger service could not be reached. + + The mapping below is what is stored, but it cannot be verified and cannot be changed + until GL responds. + +
+
+ )} + + {!config.glUnavailable && broken.length > 0 && ( +
+ +
+ + {broken.length} mapped account{broken.length === 1 ? "" : "s"} do not exist in GL. + + + Every flow that posts to {broken.length === 1 ? "it" : "them"} will fail. Either create + the account{broken.length === 1 ? "" : "s"} in GL, or pick{" "} + {broken.length === 1 ? "a replacement" : "replacements"} below:{" "} + {broken.map((m) => m.accountCode).join(", ")}. + +
+
+ )} + + {!config.glUnavailable && broken.length === 0 && unmapped.length === 0 && ( +
+ + Every role is mapped to an account that exists in GL and accepts postings. +
+ )} + +
+
+ +

Account mapping

+
+
+ + {GL_ROLE_ORDER.map((role) => { + const { mapping, value } = current(role) + const meta = GL_ROLE_LABELS[role] + const missing = Boolean(value) && !config.options.some((o) => o.accountCode === value) + + return ( +
+
+ {meta.label} + {meta.description} +
+ +
+ + + {missing && value && ( + Not in GL's chart of accounts + )} + {!missing && !value && Postings using this role will fail.} + {!missing && value && mapping?.accountName && ( + {mapping.accountName} + )} +
+
+ ) + })} + +
+
+ {dirty && ( + + )} + +
+
+ + )} +
+ ) +} diff --git a/Frontend/erp-system/lib/api/gl-account-config.ts b/Frontend/erp-system/lib/api/gl-account-config.ts new file mode 100644 index 0000000..e7e8351 --- /dev/null +++ b/Frontend/erp-system/lib/api/gl-account-config.ts @@ -0,0 +1,20 @@ +// Role → GL account mapping. Singleton, ETag-guarded, same shape as branch-config/grn-config. +// +// Note this is ERPCore's own endpoint, not the GL proxy in lib/api/general-ledger.ts: it speaks +// ProblemDetails and ETags like the rest of the app. It also serves the selectable accounts +// itself rather than the page calling the GL proxy, for two reasons — the GL proxy is gated on the +// `ledgers` nav permission while this screen is on `settings`, and serving both from one endpoint +// guarantees the picker offers exactly the accounts the server will accept. +import { apiRequestWithETag } from "@/lib/api-client" +import { ApiResult } from "@/types/common" +import { GlAccountConfig, UpdateGlAccountConfigRequest } from "@/types/gl-account-config" + +export const glAccountConfigApi = { + get(): Promise> { + return apiRequestWithETag("/gl-account-config") + }, + + update(request: UpdateGlAccountConfigRequest, ifMatch: string): Promise> { + return apiRequestWithETag("/gl-account-config", { method: "PUT", body: request, ifMatch }) + }, +} diff --git a/Frontend/erp-system/types/gl-account-config.ts b/Frontend/erp-system/types/gl-account-config.ts new file mode 100644 index 0000000..d11c703 --- /dev/null +++ b/Frontend/erp-system/types/gl-account-config.ts @@ -0,0 +1,113 @@ +// Role → GL account mapping (ERPCore's own `/gl-account-config`, not a GL passthrough — so the +// ordinary ProblemDetails/ETag conventions apply here, unlike types/general-ledger.ts). +// +// This replaced the per-module `Gl*Code` appsettings keys. Those pinned GL's account *codes* in +// backend config while GL generates codes itself, so nothing kept the two in step — on 2026-10-07 +// eleven of the twelve configured codes turned out never to have existed in GL. The server now +// validates every code against GL's live chart of accounts on save, and serves the selectable +// accounts from this same endpoint so the picker can only offer codes that will pass validation. + +/** Keep in step with the backend `GlAccountRole` enum — the server sends these as strings. */ +export type GlAccountRole = + | "Inventory" + | "VatRecoverable" + | "AccountsReceivable" + | "Clearing" + | "TaxPayable" + | "AccountsPayable" + | "SalesRevenue" + | "SalesDiscount" + | "InventoryGain" + | "Cogs" + | "InventoryLoss" + | "Cash" + +export interface GlAccountOption { + accountCode: string + accountName: string + accountTypeId: number + isPostable: boolean + isActive: boolean +} + +export interface GlAccountMapping { + role: GlAccountRole + accountCode: string | null + accountName: string | null + /** False when the stored code is not in GL's chart of accounts — shown flagged, not blank. */ + existsInGl: boolean + isPostable: boolean +} + +export interface GlAccountConfig { + mappings: GlAccountMapping[] + options: GlAccountOption[] + /** GL could not be reached: mappings still render, `options` is empty, and saving is refused. */ + glUnavailable: boolean + updatedAt: string | null + updatedBy: number | null +} + +/** Full replacement — every role is sent on every save; null clears one. */ +export interface UpdateGlAccountConfigRequest { + inventoryAccountCode: string | null + vatRecoverableAccountCode: string | null + accountsReceivableCode: string | null + clearingAccountCode: string | null + taxPayableAccountCode: string | null + accountsPayableCode: string | null + salesRevenueAccountCode: string | null + salesDiscountAccountCode: string | null + inventoryGainAccountCode: string | null + cogsAccountCode: string | null + inventoryLossAccountCode: string | null + cashAccountCode: string | null +} + +/** Request-field name per role, so the page can build the PUT body from the mapping list. */ +export const GL_ROLE_FIELD: Record = { + Inventory: "inventoryAccountCode", + VatRecoverable: "vatRecoverableAccountCode", + AccountsReceivable: "accountsReceivableCode", + Clearing: "clearingAccountCode", + TaxPayable: "taxPayableAccountCode", + AccountsPayable: "accountsPayableCode", + SalesRevenue: "salesRevenueAccountCode", + SalesDiscount: "salesDiscountAccountCode", + InventoryGain: "inventoryGainAccountCode", + Cogs: "cogsAccountCode", + InventoryLoss: "inventoryLossAccountCode", + Cash: "cashAccountCode", +} + +/** What each role means in accounting terms, and which flows post to it. */ +export const GL_ROLE_LABELS: Record = { + Inventory: { label: "Inventory", description: "Stock on hand — GRN, adjustments, day-end, FIFO costing" }, + VatRecoverable: { label: "VAT Recoverable", description: "Recoverable input VAT on purchases — GRN" }, + AccountsReceivable: { label: "Accounts Receivable", description: "Trade debtors — sales invoices, day-end" }, + Clearing: { label: "Goods Received Not Invoiced", description: "GRN ↔ purchase-invoice clearing account" }, + TaxPayable: { label: "Tax Payable", description: "Output tax collected on sales — day-end" }, + AccountsPayable: { label: "Accounts Payable", description: "Trade creditors — purchase invoices and payments" }, + SalesRevenue: { label: "Sales Revenue", description: "Sales income — day-end, invoice posting" }, + SalesDiscount: { label: "Sales Discount", description: "Discounts given (contra-revenue) — day-end" }, + InventoryGain: { label: "Inventory Gain", description: "Stock written up by an adjustment or count" }, + Cogs: { label: "Cost of Goods Sold", description: "Cost of sales — day-end, sales returns" }, + InventoryLoss: { label: "Inventory Loss", description: "Stock written off by an adjustment or count" }, + Cash: { label: "Cash", description: "Cash takings at day-end close" }, +} + +/** Display order: assets, liabilities, income, expense — the order an accountant reads them in. */ +export const GL_ROLE_ORDER: GlAccountRole[] = [ + "Inventory", + "VatRecoverable", + "AccountsReceivable", + "Cash", + "Clearing", + "TaxPayable", + "AccountsPayable", + "SalesRevenue", + "SalesDiscount", + "InventoryGain", + "Cogs", + "InventoryLoss", +]