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", +]