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:
@@ -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'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'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",
|
||||
]
|
||||
Reference in New Issue
Block a user