Dev #109
@@ -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