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>
)
}