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 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:17:22 +05:30
parent c41aed9a80
commit c30ff473f5
4 changed files with 357 additions and 1 deletions
@@ -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",
@@ -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 <code> 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<GlAccountConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
/** Pending edits, by role. Empty once saved or reloaded. */
const [draft, setDraft] = useState<Partial<Record<GlAccountRole, string | null>>>({})
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 (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">GL Accounts</h1>
<p className="text-base text-muted-foreground">
Choose which General Ledger account each posting role uses. Only postable accounts from
GL&apos;s chart of accounts can be selected.
</p>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-96 w-full" />}
{!error && config && (
<>
{config.glUnavailable && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
<div className="flex flex-col gap-1">
<span className="font-semibold">The General Ledger service could not be reached.</span>
<span className="text-sm">
The mapping below is what is stored, but it cannot be verified and cannot be changed
until GL responds.
</span>
</div>
</div>
)}
{!config.glUnavailable && broken.length > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
<div className="flex flex-col gap-1">
<span className="font-semibold">
{broken.length} mapped account{broken.length === 1 ? "" : "s"} do not exist in GL.
</span>
<span className="text-sm">
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(", ")}.
</span>
</div>
</div>
)}
{!config.glUnavailable && broken.length === 0 && unmapped.length === 0 && (
<div className="flex items-start gap-3 rounded-lg border border-primary/30 bg-primary/5 p-5 text-base">
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-primary" />
<span>Every role is mapped to an account that exists in GL and accepts postings.</span>
</div>
)}
<div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2">
<BookOpen className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Account mapping</h2>
</div>
<div className="border-t" />
{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 (
<div key={role} className="flex flex-col gap-2 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
<div className="flex flex-col gap-1 sm:max-w-sm">
<span className="text-base font-medium text-foreground">{meta.label}</span>
<span className="text-sm text-muted-foreground">{meta.description}</span>
</div>
<div className="flex flex-col items-start gap-1 sm:w-80 sm:shrink-0">
<Select
value={value ?? ""}
onValueChange={(v) => setDraft((d) => ({ ...d, [role]: v || null }))}
disabled={saving || config.glUnavailable}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Not mapped" />
</SelectTrigger>
<SelectContent>
{/* The stored code, when GL does not have it, is not in options — keep it
selectable so the broken state is visible rather than looking unset. */}
{missing && value && (
<SelectItem value={value}>{value} — not in GL</SelectItem>
)}
{config.options.map((o) => (
<SelectItem key={o.accountCode} value={o.accountCode}>
{o.accountCode} — {o.accountName}
</SelectItem>
))}
</SelectContent>
</Select>
{missing && value && (
<Badge variant="destructive">Not in GL&apos;s chart of accounts</Badge>
)}
{!missing && !value && <span className="text-sm text-muted-foreground">Postings using this role will fail.</span>}
{!missing && value && mapping?.accountName && (
<span className="text-sm text-muted-foreground">{mapping.accountName}</span>
)}
</div>
</div>
)
})}
<div className="border-t" />
<div className="flex items-center justify-end gap-3">
{dirty && (
<Button variant="outline" onClick={load} disabled={saving}>
Discard changes
</Button>
)}
<Button onClick={save} disabled={saving || !dirty || config.glUnavailable}>
{saving ? "Saving…" : "Save mapping"}
</Button>
</div>
</div>
</>
)}
</div>
)
}
@@ -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<ApiResult<GlAccountConfig>> {
return apiRequestWithETag<GlAccountConfig>("/gl-account-config")
},
update(request: UpdateGlAccountConfigRequest, ifMatch: string): Promise<ApiResult<GlAccountConfig>> {
return apiRequestWithETag<GlAccountConfig>("/gl-account-config", { method: "PUT", body: request, ifMatch })
},
}
@@ -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<GlAccountRole, keyof UpdateGlAccountConfigRequest> = {
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<GlAccountRole, { label: string; description: string }> = {
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",
]