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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user