Dev #109

Merged
ImanThiyanga merged 10 commits from Dev into production 2026-10-14 11:16:44 +05:30
4 changed files with 357 additions and 1 deletions
Showing only changes of commit c30ff473f5 - Show all commits
@@ -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",
]