feat: add vendor optional fields and configuration hooks

- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation.
- Created useCustomerConfig hook to load optional customer field settings from Sales configuration.
- Developed useProcurementConfig hook to manage Procurement configuration flags.
- Introduced accessConfigApi for managing access configuration settings.
- Added openingBalancesApi for handling opening balances operations.
- Created procurementConfigApi for managing procurement configuration settings.
- Implemented nav-routes for permission-based routing.
- Developed PDF generation for purchase orders with dynamic vendor and item details.
- Added purchase-order tax helpers for tax calculations and formatting.
- Defined types for opening balances and related operations.
This commit is contained in:
Dhananjaya99
2026-10-18 12:46:31 +05:30
parent c27db06880
commit e7b1f270a5
202 changed files with 99906 additions and 1894 deletions
@@ -67,7 +67,8 @@ import { useAuth } from "@/components/auth/AuthProvider"
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
// so role-based filtering (via GET /auth/me's navCodes) can match entries here.
const navItems: {
// Exported: lib/nav-routes.ts derives the page -> permission map (the route guard) from this same list.
export const navItems: {
title: string
code: string
href: string
@@ -114,6 +115,7 @@ const navItems: {
icon: ClipboardList,
children: [
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
{ title: "PO Approval Queue", code: "procurement.po-approvals", href: "/dashboard/procurement/purchase-orders/approvals", icon: ClipboardCheck },
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
{ title: "Vendors", code: "procurement.vendors", href: "/dashboard/vendors", icon: Truck },
],
@@ -215,6 +217,7 @@ const navItems: {
{ title: "Expenses", code: "accounts.expenses", href: "/dashboard/accounts/expenses", icon: ReceiptText },
{ title: "Expense Setup", code: "accounts.expense-setup", href: "/dashboard/accounts/expense-setup", icon: Tags },
{ title: "Bank Deposits", code: "accounts.bank-deposits", href: "/dashboard/accounts/bank-deposits", icon: PiggyBank },
{ title: "Opening Balances", code: "accounts.opening-balances", href: "/dashboard/accounts/opening-balances", icon: Scale },
],
},
{
@@ -227,6 +230,7 @@ const navItems: {
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
{ title: "Approvals", code: "settings.approvals", href: "/dashboard/settings/approvals", icon: ClipboardCheck },
{ title: "Audit Log", code: "settings.audit-logs", href: "/dashboard/settings/audit-logs", icon: ScrollText },
{ title: "Configuration", code: "products.configuration", href: "/dashboard/settings/configuration", icon: SlidersHorizontal },
],
},
@@ -439,32 +443,19 @@ export function AppSidebar() {
// While /auth/me hasn't resolved yet, show nothing rather than briefly
// flashing the full menu to a restricted role. Once resolved, a nav item
// is visible if its own code is granted, or (for parents) if any child is.
//
// "procurement", "hrm", "sales", "production" and "stock" are exempted from that check
// (frontend-only): no role is currently seeded with NAV:procurement/NAV:hrm/NAV:production
// or their children server-side (stock's children specifically have no SubNavItem rows at
// all yet), which would hide the whole section for everyone. Remove each bypass once roles
// are granted the permission properly (Settings → Roles → Sidebar permissions) or a backend
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
// anything server-side.
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"])
// Every section now has seeded nav rows (HRM and Sales Returns were the last gaps), so there is no
// exemption list any more: what you see is exactly what your role grants (an administrator holds every code).
const visibleItems = loading
? []
: navItems
.filter(
(item) =>
bypassCodes.has(item.code) ||
navCodes.includes(item.code) ||
item.children?.some((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
)
.map((item) => ({
...item,
children: (bypassCodes.has(item.code)
? item.children
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
),
children: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))),
}))
// Close mobile menu on route change
@@ -49,6 +49,7 @@ const PROCUREMENT_TITLES: Record<string, string> = {
"/dashboard/procurement/rfqs/new": "New RFQ",
"/dashboard/procurement/purchase-orders": "Purchase Orders",
"/dashboard/procurement/purchase-orders/new": "New Purchase Order",
"/dashboard/procurement/purchase-orders/approvals": "PO Approval Queue",
"/dashboard/procurement/purchase-returns": "Purchase Returns",
"/dashboard/procurement/purchase-returns/new": "New Purchase Return",
}
@@ -0,0 +1,278 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { CheckCircle2, Landmark, Wallet } from "lucide-react"
import { openingBalancesApi } from "@/lib/api/opening-balances"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, todayIso } from "@/lib/format"
import { CashBankOpeningState, OpeningCashBankAccount } from "@/types/opening-balances"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/** Amounts with at most 2 decimals; blank means "nothing to enter for this account". */
function parseAmount(raw: string): { value: number | null; error?: string } {
const text = raw.trim()
if (text === "") return { value: null }
const n = Number(text)
if (!Number.isFinite(n)) return { value: null, error: "Enter a number" }
if (Math.round(n * 100) / 100 !== n) return { value: null, error: "At most 2 decimals" }
return { value: n }
}
function AccountIcon({ type }: { type: string }) {
const Icon = type === "Bank" ? Landmark : Wallet
return <Icon className="size-4 shrink-0 text-muted-foreground" />
}
/**
* Opening balances — cash and bank. The amounts become ONE journal in the General Ledger (a debit to each
* account, the offsetting credit to "Opening Balance Equity"), posted once. Once posted this panel shows the
* record instead of the form; later corrections are made with an ordinary journal entry.
*/
export function OpeningCashBankPanel() {
const [state, setState] = useState<CashBankOpeningState | null>(null)
const [error, setError] = useState<string | null>(null)
const [date, setDate] = useState(todayIso())
const [amounts, setAmounts] = useState<Record<string, string>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const [posting, setPosting] = useState(false)
function load() {
setError(null)
openingBalancesApi
.cashBank()
.then(setState)
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
// Accounts in another currency are shown but cannot take an amount: the journal is in the base currency.
const usable = (a: OpeningCashBankAccount) => !state || a.currencyCode.toUpperCase() === state.baseCurrency.toUpperCase()
const total = useMemo(() => {
let sum = 0
for (const a of state?.accounts ?? []) {
const { value } = parseAmount(amounts[a.glAccountCode] ?? "")
if (value !== null && usable(a)) sum += value
}
return Math.round(sum * 100) / 100
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [amounts, state])
function validate(): { lines: { glAccountCode: string; amount: number }[]; ok: boolean } {
const next: Record<string, string> = {}
const lines: { glAccountCode: string; amount: number }[] = []
for (const a of state?.accounts ?? []) {
if (!usable(a)) continue
const { value, error: err } = parseAmount(amounts[a.glAccountCode] ?? "")
if (err) next[a.glAccountCode] = err
else if (value !== null && value !== 0) lines.push({ glAccountCode: a.glAccountCode, amount: value })
}
if (!date) next.date = "Choose the opening date"
else if (date > todayIso()) next.date = "The opening date cannot be in the future"
if (lines.length === 0 && Object.keys(next).length === 0) next.lines = "Enter an amount for at least one account"
setErrors(next)
return { lines, ok: Object.keys(next).length === 0 }
}
async function post() {
const { lines, ok } = validate()
if (!ok) return
setPosting(true)
try {
const run = await openingBalancesApi.postCashBank({ cutoverDate: date, lines })
toast.success("Opening balances posted", `Journal ${run.glJournalNo ?? ""} — ${run.currency} ${formatAmount(run.totalAmount)}`)
load()
} catch (err) {
const code = (err as { code?: string })?.code
toast.error("Could not post the opening balances", errorMessage(err))
// Already done by someone else: show the posted record instead of an empty form.
if (code === "OPENING_BALANCE_ALREADY_POSTED") load()
} finally {
setPosting(false)
}
}
if (error) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!state) return <Skeleton className="h-96 w-full" />
const posted = state.posted
return (
<div className="flex flex-col gap-6">
<p className="text-base text-muted-foreground">
Enter what each cash and bank account held when you started using the system. It is posted once, as a single
General Ledger journal against <span className="font-medium text-foreground">Opening Balance Equity</span>.
</p>
{posted ? (
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 size-6 shrink-0 text-success" />
<div>
<h2 className="text-lg font-semibold text-foreground">Cash and bank opening balances are posted</h2>
<p className="text-sm text-muted-foreground">
As at {formatReportDate(posted.cutoverDate)} · journal <span className="font-medium text-foreground">{posted.glJournalNo ?? "—"}</span> ·
posted {new Date(posted.postedAt).toLocaleString()}. To change an amount, post a correcting journal entry in the General Ledger.
</p>
</div>
</div>
<div className="overflow-hidden rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Account</TableHead>
<TableHead>Code</TableHead>
<TableHead className="text-right">Opening balance ({posted.currency})</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{posted.lines.map((l) => (
<TableRow key={l.accountCode}>
<TableCell className="font-medium text-foreground">{l.accountName}</TableCell>
<TableCell className="text-muted-foreground">{l.accountCode}</TableCell>
<TableCell className="text-right">{formatAmount(l.amount)}</TableCell>
</TableRow>
))}
<TableRow className="bg-muted/30 font-semibold hover:bg-muted/30">
<TableCell colSpan={2}>Credited to Opening Balance Equity</TableCell>
<TableCell className="text-right">{formatAmount(posted.totalAmount)}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</section>
) : (
<>
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
<Field data-invalid={!!errors.date} className="sm:max-w-xs">
<FieldLabel htmlFor="opening-date" required>Opening date</FieldLabel>
<Input
id="opening-date"
type="date"
max={todayIso()}
value={date}
onChange={(e) => {
setDate(e.target.value)
setErrors((p) => ({ ...p, date: "" }))
}}
aria-invalid={!!errors.date}
className="h-11 text-base"
/>
<FieldError errors={[errors.date ? { message: errors.date } : undefined]} />
<FieldDescription>
Must fall in an open accounting period. Reports that start after this date show these amounts as opening
balances, so use the day you started — or the first day of the financial year.
</FieldDescription>
</Field>
</section>
{state.accounts.length === 0 ? (
<div className="rounded-xl border border-dashed p-8 text-center text-base text-muted-foreground">
There are no cash or bank accounts yet. Create them under Accounts → Cash / Bank Accounts first.
</div>
) : (
<div className="overflow-hidden rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Account</TableHead>
<TableHead className="hidden sm:table-cell">Code</TableHead>
<TableHead className="w-56 text-right">Opening balance ({state.baseCurrency})</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{state.accounts.map((a) => {
const ok = usable(a)
const err = errors[a.glAccountCode]
return (
<TableRow key={a.glAccountCode} className="align-top">
<TableCell>
<div className="flex items-center gap-2">
<AccountIcon type={a.accountType} />
<div>
<p className="font-medium text-foreground">{a.accountName}</p>
<p className="text-sm text-muted-foreground">
{a.accountType}
{a.bankName ? ` · ${a.bankName}` : ""}
{a.accountNumber ? ` · ${a.accountNumber}` : ""}
</p>
</div>
</div>
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">{a.glAccountCode}</TableCell>
<TableCell className="text-right">
{ok ? (
<>
<Input
type="number"
step="0.01"
inputMode="decimal"
value={amounts[a.glAccountCode] ?? ""}
onChange={(e) => {
setAmounts((p) => ({ ...p, [a.glAccountCode]: e.target.value }))
setErrors((p) => ({ ...p, [a.glAccountCode]: "", lines: "" }))
}}
placeholder="0.00"
aria-label={`Opening balance for ${a.accountName}`}
aria-invalid={!!err}
className="h-11 text-right text-base"
/>
<FieldError errors={[err ? { message: err } : undefined]} />
</>
) : (
<span className="text-sm text-muted-foreground">{a.currencyCode} account — not supported here</span>
)}
</TableCell>
</TableRow>
)
})}
<TableRow className="bg-muted/30 font-semibold hover:bg-muted/30">
<TableCell colSpan={2} className="hidden sm:table-cell">Credited to Opening Balance Equity</TableCell>
<TableCell className="sm:hidden">Opening Balance Equity (credit)</TableCell>
<TableCell className="text-right">{formatAmount(total)}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
{errors.lines && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{errors.lines}</div>
)}
<p className="text-sm text-muted-foreground">
A negative amount records an overdraft (a credit balance). Leave an account blank if it opened at zero.
</p>
<div className="flex justify-end">
<AlertDialog>
<AlertDialogTrigger render={<Button size="lg" disabled={posting || state.accounts.length === 0} />}>
{posting ? "Posting…" : "Post opening balances"}
</AlertDialogTrigger>
<AlertDialogContent
variant="warning"
title="Post the opening balances?"
description={`This posts one General Ledger journal dated ${date || "—"} totalling ${state.baseCurrency} ${formatAmount(total)}. It can only be done once; any later change needs a correcting journal entry.`}
confirmLabel="Post"
onConfirm={post}
/>
</AlertDialog>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,449 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { ClipboardPaste, Plus, Trash2 } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { openingBalancesApi } from "@/lib/api/opening-balances"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, todayIso } from "@/lib/format"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { OpeningStockRun, OpeningStockRunSummary } from "@/types/opening-balances"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Combobox, ComboboxOption } from "@/components/ui/combobox"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
interface DraftLine {
key: string
itemId: number | null
qty: string
unitCost: string
}
let keySeq = 0
const newLine = (patch: Partial<DraftLine> = {}): DraftLine => ({ key: `ol-${++keySeq}`, itemId: null, qty: "", unitCost: "", ...patch })
/** A positive number with at most `dp` decimals; null otherwise. */
function num(raw: string, dp: number, allowZero: boolean): number | null {
const text = raw.trim()
if (text === "") return null
const n = Number(text)
if (!Number.isFinite(n) || n < 0 || (!allowZero && n === 0)) return null
const f = 10 ** dp
return Math.round(n * f) / f === n ? n : null
}
/**
* Opening stock: what is already on the shelf when the system starts. One warehouse per run (load them one at a
* time); each line becomes a FIFO layer at the unit cost entered, and ONE journal debits Inventory against
* Opening Balance Equity. An item that already has stock movements in the warehouse is refused by the server.
* Only stocked, untracked items are offered — serial/batch items need per-unit identities.
*/
export function OpeningStockPanel() {
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [runs, setRuns] = useState<OpeningStockRunSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [date, setDate] = useState(todayIso())
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [lines, setLines] = useState<DraftLine[]>([newLine()])
const [errors, setErrors] = useState<Record<string, string>>({})
const [posting, setPosting] = useState(false)
const [pasteOpen, setPasteOpen] = useState(false)
const [pasteText, setPasteText] = useState("")
const [pasteNotes, setPasteNotes] = useState<string[]>([])
const [openRun, setOpenRun] = useState<OpeningStockRun | null>(null)
const [openRunLoading, setOpenRunLoading] = useState(false)
function loadRuns() {
openingBalancesApi.stockRuns().then(setRuns).catch((err) => setError(errorMessage(err)))
}
useEffect(() => {
loadRuns()
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
let cancelled = false
;(async () => {
const all: ItemListItem[] = []
for (let page = 1; page <= 25; page++) {
const res = await itemsApi.list({ status: "Active", page, pageSize: 200 })
all.push(...res.items)
if (page >= res.pagination.totalPages) break
}
if (!cancelled) setItems(all)
})().catch((err) => {
if (!cancelled) {
setItems([])
toast.error("Could not load items", errorMessage(err))
}
})
return () => {
cancelled = true
}
}, [])
// Only what the server accepts: stocked items with no serial/batch tracking.
const eligible = useMemo(() => (items ?? []).filter((i) => i.stockNature === "Stocked" && i.trackingMode === "None"), [items])
const options = useMemo<ComboboxOption<number>[]>(
() => eligible.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })).sort((a, b) => a.label.localeCompare(b.label)),
[eligible],
)
const total = useMemo(
() =>
Math.round(
lines.reduce((sum, l) => {
const q = num(l.qty, 4, false)
const c = num(l.unitCost, 4, true)
return sum + (q !== null && c !== null ? q * c : 0)
}, 0) * 10000,
) / 10000,
[lines],
)
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
setErrors((prev) => ({ ...prev, [key]: "", general: "" }))
}
/** "SKU qty cost" per row, tab- or comma-separated — what copying three columns out of a spreadsheet gives. */
function applyPaste() {
const notes: string[] = []
const next: DraftLine[] = []
const bySku = new Map(eligible.map((i) => [i.sku.trim().toLowerCase(), i]))
pasteText.split(/\r?\n/).forEach((row, idx) => {
if (!row.trim()) return
const cells = row.split(/\t|,/).map((c) => c.trim())
if (idx === 0 && Number.isNaN(Number(cells[1]))) return // a header row
const [sku, qty, cost] = cells
const item = bySku.get((sku ?? "").toLowerCase())
if (!item) notes.push(`Row ${idx + 1}: "${sku}" is not an active, stocked, untracked item — skipped.`)
else next.push(newLine({ itemId: item.itemId, qty: qty ?? "", unitCost: cost ?? "" }))
})
if (next.length > 0) setLines((prev) => [...prev.filter((l) => l.itemId !== null || l.qty || l.unitCost), ...next])
setPasteNotes(notes)
if (notes.length === 0) {
setPasteOpen(false)
setPasteText("")
}
toast.success("Rows added", `${next.length} line${next.length === 1 ? "" : "s"} imported${notes.length ? `, ${notes.length} skipped` : ""}.`)
}
function validate() {
const next: Record<string, string> = {}
const payload: { itemId: number; warehouseId: number; qty: number; unitCost: number }[] = []
const used = new Set<number>()
for (const l of lines) {
if (l.itemId === null && !l.qty && !l.unitCost) continue // an untouched blank row
const q = num(l.qty, 4, false)
const c = num(l.unitCost, 4, true)
if (l.itemId === null) next[l.key] = "Select an item"
else if (used.has(l.itemId)) next[l.key] = "This item is already on another line"
else if (q === null) next[l.key] = "Quantity must be more than 0 (up to 4 decimals)"
else if (c === null) next[l.key] = "Unit cost must be 0 or more (up to 4 decimals)"
else {
used.add(l.itemId)
payload.push({ itemId: l.itemId, warehouseId: warehouseId ?? 0, qty: q, unitCost: c })
}
}
if (warehouseId === null) next.warehouse = "Choose the warehouse"
if (!date) next.date = "Choose the opening date"
else if (date > todayIso()) next.date = "The opening date cannot be in the future"
if (payload.length === 0 && Object.keys(next).length === 0) next.general = "Add at least one item with a quantity"
setErrors(next)
return { payload, ok: Object.keys(next).length === 0 }
}
async function post() {
const { payload, ok } = validate()
if (!ok) return
setPosting(true)
try {
const run = await openingBalancesApi.postStock({ cutoverDate: date, lines: payload })
toast.success(
"Opening stock posted",
`${run.lineCount} line${run.lineCount === 1 ? "" : "s"} · ${formatAmount(run.totalValue)}${run.glJournalNo ? ` · ${run.glJournalNo}` : ""}`,
)
setLines([newLine()])
setOpenRun(run)
loadRuns()
} catch (err) {
toast.error("Could not post the opening stock", errorMessage(err))
} finally {
setPosting(false)
}
}
async function showRun(id: number) {
setOpenRunLoading(true)
try {
setOpenRun(await openingBalancesApi.stockRun(id))
} catch (err) {
toast.error("Could not load that run", errorMessage(err))
} finally {
setOpenRunLoading(false)
}
}
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (items === null || runs === null) return <Skeleton className="h-96 w-full" />
return (
<div className="flex flex-col gap-6">
<p className="text-base text-muted-foreground">
Enter the stock you already have on the shelf, one warehouse at a time. Each line becomes stock at the unit cost you give, and one
General Ledger journal debits Inventory against <span className="font-medium text-foreground">Opening Balance Equity</span>.
An item can be opened once per warehouse — after that, use a stock adjustment.
</p>
<section className="grid grid-cols-1 gap-4 rounded-2xl border bg-card p-5 shadow-sm sm:grid-cols-2">
<Field data-invalid={!!errors.date}>
<FieldLabel htmlFor="ostock-date" required>Opening date</FieldLabel>
<Input
id="ostock-date"
type="date"
max={todayIso()}
value={date}
onChange={(e) => {
setDate(e.target.value)
setErrors((p) => ({ ...p, date: "" }))
}}
aria-invalid={!!errors.date}
className="h-11 text-base"
/>
<FieldError errors={[errors.date ? { message: errors.date } : undefined]} />
</Field>
<Field data-invalid={!!errors.warehouse}>
<FieldLabel required>Warehouse</FieldLabel>
<Select<number | null>
value={warehouseId}
onValueChange={(v) => {
setWarehouseId(v)
setErrors((p) => ({ ...p, warehouse: "" }))
}}
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouse}>
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warehouse ? { message: errors.warehouse } : undefined]} />
<FieldDescription>Quantities are counted in each item&apos;s base unit (bottle, can, pack…).</FieldDescription>
</Field>
</section>
<section className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Items</h2>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => setPasteOpen((o) => !o)}>
<ClipboardPaste className="size-5" />
Paste from spreadsheet
</Button>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, newLine()])}>
<Plus className="size-5" />
Add line
</Button>
</div>
</div>
{pasteOpen && (
<div className="flex flex-col gap-2 rounded-xl border border-dashed p-4">
<p className="text-sm text-muted-foreground">
Paste rows of <span className="font-medium text-foreground">SKU, quantity, unit cost</span> (tab- or comma-separated — three columns
copied from a spreadsheet work). A header row is skipped.
</p>
<Textarea
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
placeholder={"CLA-500ML\t240\t95.50\nCLA-1L\t120\t160"}
rows={6}
className="font-mono text-sm"
/>
{pasteNotes.length > 0 && (
<ul className="list-disc pl-5 text-sm text-destructive">
{pasteNotes.slice(0, 8).map((n) => <li key={n}>{n}</li>)}
{pasteNotes.length > 8 && <li>…and {pasteNotes.length - 8} more.</li>}
</ul>
)}
<div className="flex gap-2">
<Button type="button" size="sm" onClick={applyPaste} disabled={!pasteText.trim()}>Add rows</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => { setPasteOpen(false); setPasteNotes([]) }}>Close</Button>
</div>
</div>
)}
<div className="overflow-x-auto rounded-xl border">
<Table className="min-w-[44rem] text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[40%]">Item</TableHead>
<TableHead className="w-32 text-right">Quantity</TableHead>
<TableHead className="w-36 text-right">Unit cost</TableHead>
<TableHead className="w-36 text-right">Value</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((l) => {
const q = num(l.qty, 4, false)
const c = num(l.unitCost, 4, true)
return (
<TableRow key={l.key} className="align-top">
<TableCell>
<Combobox<number>
items={options}
value={l.itemId}
onValueChange={(v) => updateLine(l.key, { itemId: v })}
placeholder="Type SKU or name"
emptyText="No matching stocked, untracked item."
className="h-11 text-base"
aria-invalid={!!errors[l.key]}
/>
<FieldError errors={[errors[l.key] ? { message: errors[l.key] } : undefined]} />
</TableCell>
<TableCell>
<Input
type="number" min="0" step="any" inputMode="decimal" value={l.qty}
onChange={(e) => updateLine(l.key, { qty: e.target.value })}
aria-label="Quantity" className="h-11 text-right text-base"
/>
</TableCell>
<TableCell>
<Input
type="number" min="0" step="any" inputMode="decimal" value={l.unitCost}
onChange={(e) => updateLine(l.key, { unitCost: e.target.value })}
aria-label="Unit cost" className="h-11 text-right text-base"
/>
</TableCell>
<TableCell className="pt-3.5 text-right text-muted-foreground">{q !== null && c !== null ? formatAmount(q * c) : "—"}</TableCell>
<TableCell>
<Button
type="button" variant="ghost" size="icon" aria-label="Remove line"
onClick={() => setLines((prev) => (prev.length > 1 ? prev.filter((x) => x.key !== l.key) : [newLine()]))}
>
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
<TableRow className="bg-muted/30 font-semibold hover:bg-muted/30">
<TableCell colSpan={3}>Debited to Inventory / credited to Opening Balance Equity</TableCell>
<TableCell className="text-right">{formatAmount(total)}</TableCell>
<TableCell />
</TableRow>
</TableBody>
</Table>
</div>
{errors.general && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{errors.general}</div>
)}
<div className="flex justify-end">
<AlertDialog>
<AlertDialogTrigger render={<Button size="lg" disabled={posting} />}>
{posting ? "Posting…" : "Post opening stock"}
</AlertDialogTrigger>
<AlertDialogContent
variant="warning"
title="Post this opening stock?"
description={`This adds the stock to ${warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse"} dated ${date || "—"} and posts one journal for ${formatAmount(total)}. An item can't be opened twice in a warehouse, so check the quantities and costs.`}
confirmLabel="Post"
onConfirm={post}
/>
</AlertDialog>
</div>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Posted runs</h2>
{runs.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing posted yet.</p>
) : (
<div className="overflow-hidden rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>As at</TableHead>
<TableHead>Journal</TableHead>
<TableHead className="text-right">Lines</TableHead>
<TableHead className="text-right">Value</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{runs.map((r) => (
<TableRow key={r.openingBalanceRunId}>
<TableCell>{formatReportDate(r.cutoverDate)}</TableCell>
<TableCell className="text-muted-foreground">{r.glJournalNo ?? "— (zero value)"}</TableCell>
<TableCell className="text-right">{r.lineCount}</TableCell>
<TableCell className="text-right">{formatAmount(r.totalValue)}</TableCell>
<TableCell className="text-right">
<Button type="button" variant="ghost" size="sm" disabled={openRunLoading} onClick={() => showRun(r.openingBalanceRunId)}>View</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{openRun && (
<div className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-base font-semibold text-foreground">
Run as at {formatReportDate(openRun.cutoverDate)} · {openRun.glJournalNo ?? "no journal"}
</p>
<Button type="button" variant="ghost" size="sm" onClick={() => setOpenRun(null)}>Close</Button>
</div>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Item</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit cost</TableHead>
<TableHead className="text-right">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{openRun.lines.map((l) => (
<TableRow key={`${l.itemId}-${l.warehouseId}`}>
<TableCell>{l.sku} — {l.itemName}</TableCell>
<TableCell>{l.warehouseCode}</TableCell>
<TableCell className="text-right">{l.qty}</TableCell>
<TableCell className="text-right">{formatAmount(l.unitCost)}</TableCell>
<TableCell className="text-right">{formatAmount(l.value)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</section>
</div>
)
}
@@ -0,0 +1,79 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Lock, RefreshCw, ShieldOff } from "lucide-react"
import { requiredCodesFor } from "@/lib/nav-routes"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
/**
* Two doors. First: the page is shown only to people who hold a role. Someone who signs in with a valid login but has no usable role
* (none assigned and no default role configured, or their role was deactivated) — or whose account is switched
* off — gets a plain explanation instead of a menu full of "Forbidden" screens. "Check again" re-reads their role
* from the server, so access an administrator just granted appears without signing out.
*
* Second: a person with a role still only opens the pages that role grants, even by typing the address
* (lib/nav-routes.ts maps each page to its permission). The API enforces the same permissions on every call.
*/
export function AccessGate({ children }: { children: React.ReactNode }) {
const { loading, hasRole, accountBlocked, refresh, can, roleName } = useAuth()
const pathname = usePathname() || "/"
const [checking, setChecking] = useState(false)
if (loading) return <>{children}</>
if (hasRole) {
const required = requiredCodesFor(pathname)
if (required === null || required.some((code) => can(code))) return <>{children}</>
return (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<ShieldOff className="size-7" />
</div>
<h1 className="text-2xl font-bold text-foreground">You don&apos;t have access to this page</h1>
<p className="max-w-md text-base text-muted-foreground">
{roleName ? `Your role (${roleName}) doesn't include this area.` : "Your role doesn't include this area."} If you need it,
ask an administrator to add it under Settings → Roles.
</p>
<Link href="/dashboard" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Back to the dashboard
</Link>
</div>
)
}
async function checkAgain() {
setChecking(true)
try {
await refresh()
} finally {
setChecking(false)
}
}
return (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-warning/10 text-warning">
<Lock className="size-7" />
</div>
<h1 className="text-2xl font-bold text-foreground">
{accountBlocked ? "Your account is switched off" : "Waiting for access"}
</h1>
<p className="max-w-md text-base text-muted-foreground">
{accountBlocked
? "An administrator has deactivated this account. Please contact them if you think this is a mistake."
: "You're signed in, but no role has been assigned to your account yet. Ask an administrator to give you one (Settings → Users), then check again."}
</p>
{!accountBlocked && (
<Button variant="outline" size="lg" onClick={checkAgain} disabled={checking}>
<RefreshCw className={checking ? "size-5 animate-spin" : "size-5"} />
{checking ? "Checking…" : "Check again"}
</Button>
)}
</div>
)
}
@@ -1,13 +1,15 @@
"use client"
// Authoritative session context: fetches GET /auth/me once per mount and exposes
// the current role + permitted sidebar nav codes. Replaces trusting the stale,
// client-only `roleId` cached by lib/auth-session.ts for anything access-related
// (that cache remains display-only, e.g. for the header's user name).
import { createContext, useContext, useEffect, useState } from "react"
// Authoritative session context: fetches GET /auth/me and exposes the current role and what it may do.
// The role lives in the ERP (users.RoleId) and is read on every request, so this is refetched whenever the
// window regains focus (and on demand via `refresh`) — a role or permission change made by an administrator
// shows up without signing out. Replaces trusting the stale, client-only `roleId` cached by
// lib/auth-session.ts for anything access-related (that cache remains display-only, e.g. the header's name).
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"
import { authApi } from "@/lib/api/auth"
import { MeResponse } from "@/types/rbac"
import { ApiError, FORBIDDEN_EVENT } from "@/lib/api-client"
import { MeResponse, RolePermissionAction } from "@/types/rbac"
interface ActiveBranch {
id: number | null
@@ -19,6 +21,9 @@ interface ActiveBranch {
* display/filter convenience — never trusted server-side. */
const ACTIVE_BRANCH_KEY = "erpcore.activeBranch"
/** Don't re-ask the server more often than this when the window keeps regaining focus. */
const FOCUS_REFRESH_MIN_MS = 15_000
function readStoredActiveBranch(): ActiveBranch | null {
if (typeof window === "undefined") return null
const raw = window.localStorage.getItem(ACTIVE_BRANCH_KEY)
@@ -31,12 +36,28 @@ function readStoredActiveBranch(): ActiveBranch | null {
}
}
/** The names of the actions a permission can grant (Approve, Delete, …). */
export type PermissionActionName = keyof typeof RolePermissionAction
interface AuthContextValue {
roleId: number | null
roleCode: string | null
roleName: string | null
navCodes: string[]
/** True for the Admin role — the only role that may pick any branch (others are locked to their own). */
/** True when the person has a usable role. False means "waiting for access": no role assigned and no default, or an inactive role. */
hasRole: boolean
/** True for the administrator role — the only role that may pick any branch (others are locked to their own). */
isAdmin: boolean
/**
* Whether the role may reach `navCode` (a module, or a screen within one) and — when `action` is given — holds that
* action on exactly that code. Administrators may do everything. Hide or disable UI with this; the server
* enforces the same rules, so this is a convenience, never the protection.
*/
can: (navCode: string, action?: PermissionActionName) => boolean
/** Re-reads the role and permissions from the server (after changing your own role, say). */
refresh: () => Promise<void>
/** True when the account itself is switched off (the server answered 403 to /auth/me). */
accountBlocked: boolean
/** The caller's own branch, via their linked Employee record — null if unlinked. */
branchId: number | null
branchName: string | null
@@ -67,10 +88,15 @@ interface AuthContextValue {
}
const AuthContext = createContext<AuthContextValue>({
roleId: null,
roleCode: null,
roleName: null,
navCodes: [],
hasRole: false,
isAdmin: false,
can: () => false,
refresh: async () => {},
accountBlocked: false,
branchId: null,
branchName: null,
activeBranchId: null,
@@ -81,27 +107,50 @@ const AuthContext = createContext<AuthContextValue>({
loading: true,
})
const NO_ACCESS: MeResponse = {
roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null,
branchingEnabled: true, warehouseBranchingEnabled: true, roleId: null, hasRole: false, permissions: {},
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [me, setMe] = useState<MeResponse | null>(null)
const [accountBlocked, setAccountBlocked] = useState(false)
const [activeBranch, setActiveBranchState] = useState<ActiveBranch | null>(null)
const lastFetch = useRef(0)
useEffect(() => {
let cancelled = false
authApi
.me()
.then((res) => {
if (!cancelled) setMe(res)
})
.catch(() => {
// Unauthenticated/unreachable — fall back to "no permissions" rather than
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true })
})
return () => {
cancelled = true
const load = useCallback(async () => {
lastFetch.current = Date.now()
try {
const res = await authApi.me()
setMe(res)
setAccountBlocked(false)
} catch (err) {
// 403 = the ERP account is switched off; anything else (unauthenticated/unreachable) falls back to
// "no permissions" rather than crashing the shell — proxy.ts already redirects unauthenticated users to /login.
setAccountBlocked(err instanceof ApiError && err.status === 403)
setMe((prev) => prev ?? NO_ACCESS)
}
}, [])
useEffect(() => {
void load()
}, [load])
// The role is read from the ERP on every request, so keep the UI in step with it: refetch when the person comes
// back to the tab (rate-limited), which picks up a role or permission change an administrator just made.
useEffect(() => {
function onFocus() {
if (Date.now() - lastFetch.current >= FOCUS_REFRESH_MIN_MS) void load()
}
// A 403 from any call means what this tab believes the role can do may be stale: re-read it (same rate limit).
window.addEventListener("focus", onFocus)
window.addEventListener(FORBIDDEN_EVENT, onFocus)
return () => {
window.removeEventListener("focus", onFocus)
window.removeEventListener(FORBIDDEN_EVENT, onFocus)
}
}, [load])
useEffect(() => {
if (me === null) return
// Non-admins are always pinned to their own branch — never their own past picks.
@@ -115,13 +164,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (typeof window !== "undefined") window.localStorage.setItem(ACTIVE_BRANCH_KEY, JSON.stringify(next))
}
const can = useCallback(
(navCode: string, action?: PermissionActionName): boolean => {
if (!me) return false
if (me.isAdmin) return true
const permissions = me.permissions ?? {}
if (action) {
const granted = permissions[navCode]
return granted !== undefined && (granted & RolePermissionAction[action]) !== 0
}
// Reaching a module: the code itself, or any screen inside it ("procurement" matches "procurement.vendors").
return navCode in permissions || Object.keys(permissions).some((c) => c.startsWith(navCode + "."))
},
[me],
)
return (
<AuthContext.Provider
value={{
roleId: me?.roleId ?? null,
roleCode: me?.roleCode ?? null,
roleName: me?.roleName ?? null,
navCodes: me?.navCodes ?? [],
hasRole: me?.hasRole ?? false,
isAdmin: me?.isAdmin ?? false,
can,
refresh: load,
accountBlocked,
branchId: me?.branchId ?? null,
branchName: me?.branchName ?? null,
activeBranchId: activeBranch?.id ?? null,
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"
import { useCustomerConfig } from "@/hooks/use-customer-config"
import { customersApi } from "@/lib/api/customers"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { Customer, CustomerType } from "@/types/customers"
@@ -28,6 +29,8 @@ export function EditCustomerDialog({
onClose: () => void
onSaved: (customer: Customer) => void
}) {
// A field switched off in Sales Configuration is hidden; its stored value is sent back unchanged.
const { fields } = useCustomerConfig()
const [customer, setCustomer] = useState<Customer | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
@@ -194,48 +197,70 @@ export function EditCustomerDialog({
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="ec-phone">Phone (optional)</FieldLabel>
<Input id="ec-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
</Field>
<Field>
<FieldLabel htmlFor="ec-email">Email (optional)</FieldLabel>
<Input id="ec-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
</Field>
</div>
<Field>
<FieldLabel htmlFor="ec-addr1">Address line 1 (optional)</FieldLabel>
<Input id="ec-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="ec-addr2">Address line 2 (optional)</FieldLabel>
<Input id="ec-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="ec-city">City (optional)</FieldLabel>
<Input id="ec-city" value={city} onChange={(e) => setCity(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="ec-country">Country (optional)</FieldLabel>
<Input id="ec-country" value={country} onChange={(e) => setCountry(e.target.value)} />
</Field>
</div>
{(fields.phone || fields.email) && (
<div className="grid grid-cols-2 gap-3">
{fields.phone && (
<Field>
<FieldLabel htmlFor="ec-phone">Phone (optional)</FieldLabel>
<Input id="ec-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
</Field>
)}
{fields.email && (
<Field>
<FieldLabel htmlFor="ec-email">Email (optional)</FieldLabel>
<Input id="ec-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
</Field>
)}
</div>
)}
{fields.address && (
<>
<Field>
<FieldLabel htmlFor="ec-addr1">Address line 1 (optional)</FieldLabel>
<Input id="ec-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="ec-addr2">Address line 2 (optional)</FieldLabel>
<Input id="ec-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
</Field>
</>
)}
{(fields.city || fields.country) && (
<div className="grid grid-cols-2 gap-3">
{fields.city && (
<Field>
<FieldLabel htmlFor="ec-city">City (optional)</FieldLabel>
<Input id="ec-city" value={city} onChange={(e) => setCity(e.target.value)} />
</Field>
)}
{fields.country && (
<Field>
<FieldLabel htmlFor="ec-country">Country (optional)</FieldLabel>
<Input id="ec-country" value={country} onChange={(e) => setCountry(e.target.value)} />
</Field>
)}
</div>
)}
<Field>
<FieldLabel htmlFor="ec-tax">Tax registration (optional)</FieldLabel>
<Input id="ec-tax" value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="ec-credit-limit">Credit limit (LKR)</FieldLabel>
<Input id="ec-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="ec-credit-days">Credit days</FieldLabel>
<Input id="ec-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
</Field>
</div>
{(fields.creditLimit || fields.creditDays) && (
<div className="grid grid-cols-2 gap-3">
{fields.creditLimit && (
<Field>
<FieldLabel htmlFor="ec-credit-limit">Credit limit (LKR)</FieldLabel>
<Input id="ec-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
</Field>
)}
{fields.creditDays && (
<Field>
<FieldLabel htmlFor="ec-credit-days">Credit days</FieldLabel>
<Input id="ec-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
</Field>
)}
</div>
)}
</FieldGroup>
)}
@@ -0,0 +1,395 @@
"use client"
import { useEffect, useState, type ReactNode } from "react"
import { useRouter } from "next/navigation"
import { ArrowDown, ArrowUp, ArrowUpDown, ShoppingCart, Truck } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import { PaginationMeta } from "@/types/common"
import { Branch } from "@/types/hrm"
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
import { useAuth } from "@/components/auth/AuthProvider"
import { PoStatusBadge } from "@/components/procurement/status-badges"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { DateRangeFilter, DateRangeValue, dateRangeBounds } from "@/components/ui/date-range-filter"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type StatusFilter = PurchaseOrderStatus | "All"
type SortKey = "created" | "vendor" | "total" | "status"
type SortOrder = "asc" | "desc"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All statuses" },
{ value: "Draft", label: "Draft" },
{ value: "PendingApproval", label: "Pending approval" },
{ value: "Approved", label: "Approved" },
{ value: "PartiallyReceived", label: "Partially received" },
{ value: "FullyReceived", label: "Fully received" },
{ value: "Closed", label: "Closed" },
{ value: "Rejected", label: "Rejected" },
{ value: "Cancelled", label: "Cancelled" },
]
function SortableHeader({ label, active, order, onClick }: { label: string; active: boolean; order: SortOrder; onClick: () => void }) {
return (
<button type="button" className="flex items-center gap-1 hover:text-foreground" onClick={onClick}>
{label}
{active ? (
order === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
) : (
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
</button>
)
}
export interface PoRowContext {
/** Re-reads the current page (after a delete, submit, approve, …). */
reload: () => void
}
/**
* The purchase-order table, shared by Purchase Orders and the Approval Queue so the two look and behave the
* same: a vendor-first row (icon, name, code · contact, address — the contact and address only when switched on
* in Procurement configuration), the created DATE, status, total, branch (admin + branching), and an Actions
* column the caller fills in. Filters: search (doc no., vendor name/code), status (unless `fixedStatus`),
* created-date range. Sorting and paging are server-side, so a sorted page is always a true slice.
*/
export function PurchaseOrderListView({
fixedStatus,
searchPlaceholder = "Search vendor or doc no…",
renderActions,
actionsHeader = "Actions",
emptyMessage,
onTotalChange,
}: {
/** Pins the list to one status (the approval queue); hides the status filter. */
fixedStatus?: PurchaseOrderStatus
searchPlaceholder?: string
/** Rendered in the Actions column (desktop) and the card footer (mobile). Clicks inside don't open the PO. */
renderActions: (po: PurchaseOrderSummary, ctx: PoRowContext) => ReactNode
actionsHeader?: string
emptyMessage?: ReactNode
/** Total rows matching the current filters, e.g. for a count badge in the page header. */
onTotalChange?: (total: number | null) => void
}) {
const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const { config: procConfig } = useProcurementConfig()
const showContact = procConfig.vendorContactMode !== "None"
const showAddress = procConfig.vendorAddressEnabled
const showBranch = isAdmin && branchingEnabled
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [dateRange, setDateRange] = useState<DateRangeValue>({ preset: "All" })
const [sortKey, setSortKey] = useState<SortKey>("created")
const [sortOrder, setSortOrder] = useState<SortOrder>("desc")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
const { from, to } = dateRangeBounds(dateRange)
// The effect depends on these primitives rather than the range object, which is a new object every render.
const customFrom = dateRange.custom?.from?.getTime()
const customTo = dateRange.custom?.to?.getTime()
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, dateRange.preset, customFrom, customTo, sortKey, sortOrder])
function load() {
setError(null)
purchaseOrdersApi
.list({
page,
pageSize,
q: query || undefined,
status: fixedStatus ?? (status === "All" ? undefined : status),
from,
to,
sort: `${sortOrder === "desc" ? "-" : ""}${sortKey}`,
})
.then((res) => {
setPos(res.items)
setPagination(res.pagination)
onTotalChange?.(res.pagination.totalItems)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
useEffect(load, [page, pageSize, query, status, fixedStatus, from, to, sortKey, sortOrder])
// Admins bypass branch scoping server-side and see every PO — the header's branch switcher is the only
// branch filter, applied client-side since /purchase-orders has no branchId query param.
useEffect(() => {
if (showBranch) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [showBranch])
function toggleSort(key: SortKey) {
if (key === sortKey) setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
else {
setSortKey(key)
// Dates and totals read best newest/largest first; names and statuses A→Z.
setSortOrder(key === "created" || key === "total" ? "desc" : "asc")
}
}
const branchName = (branchId: number) => branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
const visible = showBranch && activeBranchId ? (pos ?? []).filter((po) => po.branchId === activeBranchId) : pos
const hasFilters = query.length > 0 || (!fixedStatus && status !== "All") || dateRange.preset !== "All"
const ctx: PoRowContext = { reload: load }
const open = (poId: number) => router.push(`/dashboard/procurement/purchase-orders/${poId}`)
function contactOf(po: PurchaseOrderSummary) {
return (procConfig.vendorContactMode === "Mobile" ? po.vendor?.mobile : po.vendor?.email) || ""
}
/** Vendor name, then "code · contact" and (when enabled) the address — the same lines in the table and the cards. */
function vendorBlock(po: PurchaseOrderSummary) {
const sub = [po.vendor?.code, showContact ? contactOf(po) : ""].filter(Boolean).join(" · ")
return (
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Truck className="size-4 text-primary" />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{po.vendor?.name ?? `Vendor #${po.vendorId}`}</p>
{sub && <p className="truncate text-xs text-muted-foreground">{sub}</p>}
{showAddress && po.vendor?.address && (
<p className="max-w-64 truncate text-xs text-muted-foreground" title={po.vendor.address}>{po.vendor.address}</p>
)}
</div>
</div>
)
}
const totalCell = (po: PurchaseOrderSummary) => (
<>
<p className="font-medium text-foreground">{po.totals.currency} {formatAmount(po.totals.grandTotal)}</p>
{procConfig.poTaxEnabled && po.totals.tax > 0 && (
<p className="text-xs text-muted-foreground">incl. tax {formatAmount(po.totals.tax)}</p>
)}
</>
)
return (
<div className="flex flex-col gap-4">
<FilterBar
searchFirst
search={searchInput}
onSearchChange={setSearchInput}
searchPlaceholder={searchPlaceholder}
searchAriaLabel="Search purchase orders"
>
<div className="flex flex-wrap items-center gap-2">
{!fixedStatus && (
<Select<StatusFilter>
value={status}
onValueChange={(v) => setStatus(v ?? "All")}
items={STATUS_OPTIONS}
>
<SelectTrigger className="h-10! w-44 text-sm" aria-label="Status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
<DateRangeFilter value={dateRange} onChange={setDateRange} label="Created date" />
{hasFilters && (
<Button type="button" variant="ghost" size="sm" onClick={() => { setSearchInput(""); setStatus("All"); setDateRange({ preset: "All" }) }}>
Clear filters
</Button>
)}
</div>
</FilterBar>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && pos === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))}
</div>
)}
{!error && visible !== null && visible.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ShoppingCart className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No purchase orders match your search or filters." : (emptyMessage ?? "No purchase orders yet.")}
</p>
</div>
)}
{!error && visible !== null && visible.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{visible.map((po) => (
<div
key={po.poId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
open(po.poId)
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !(e.target as HTMLElement).closest("[data-no-row-nav]")) open(po.poId)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
{vendorBlock(po)}
<PoStatusBadge status={po.status} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Total</p>
{totalCell(po)}
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{formatReportDate(po.createdAt)}</p>
</div>
{showBranch && (
<div className="col-span-2">
<p className="text-muted-foreground">Branch</p>
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
{branchName(po.branchId)}
</Badge>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
{renderActions(po, ctx)}
</div>
</div>
))}
{pagination && pos !== null && pos.length < pagination.totalItems && (
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={() => { setShowMoreLoading(true); setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) }}
disabled={showMoreLoading}
>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table, styled like the Items table. */}
<div className="hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Vendor" active={sortKey === "vendor"} order={sortOrder} onClick={() => toggleSort("vendor")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Created" active={sortKey === "created"} order={sortOrder} onClick={() => toggleSort("created")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Grand total" active={sortKey === "total"} order={sortOrder} onClick={() => toggleSort("total")} />
</TableHead>
{showBranch && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-12 px-3 text-sm">{actionsHeader}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((po) => (
<TableRow
key={po.poId}
className="cursor-pointer align-top"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
open(po.poId)
}}
>
<TableCell className="px-3 py-3.5">{vendorBlock(po)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(po.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5"><PoStatusBadge status={po.status} /></TableCell>
<TableCell className="px-3 py-3.5">{totalCell(po)}</TableCell>
{showBranch && (
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
{branchName(po.branchId)}
</Badge>
</TableCell>
)}
<TableCell className={cn("px-3 py-3.5")} data-no-row-nav>
<div className="flex items-center gap-1">{renderActions(po, ctx)}</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)}
</div>
)
}
@@ -32,15 +32,26 @@ export function RfqStatusBadge({ status }: { status: RfqStatus }) {
function poClass(status: PurchaseOrderStatus) {
if (status === "Draft" || status === "PendingApproval") return "bg-warning/10 text-warning border-transparent"
if (status === "Cancelled") return "bg-destructive/10 text-destructive border-transparent"
if (status === "Cancelled" || status === "Rejected") return "bg-destructive/10 text-destructive border-transparent"
if (status === "Closed") return "bg-muted text-muted-foreground border-transparent"
return "bg-success/10 text-success border-transparent" // Approved / PartiallyReceived / FullyReceived
}
const poStatusLabel: Record<PurchaseOrderStatus, string> = {
Draft: "Draft",
PendingApproval: "Pending approval",
Approved: "Approved",
PartiallyReceived: "Partially received",
FullyReceived: "Fully received",
Closed: "Closed",
Cancelled: "Cancelled",
Rejected: "Rejected",
}
export function PoStatusBadge({ status }: { status: PurchaseOrderStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${poClass(status)}`}>
{status}
{poStatusLabel[status] ?? status}
</Badge>
)
}
@@ -0,0 +1,183 @@
"use client"
import { Fragment, useState } from "react"
import { Plus } from "lucide-react"
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit, warrantyPeriodGroups } from "@/lib/warranty"
import { ProductConfig, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"
/**
* Warranty choice for an existing item: Non-Warranty / Warranty plus a period. Same options as the New Item
* form — the default periods, the saved custom periods (when custom periods are on), and a one-off
* "Custom period" typed here that applies to this item only and is never added to the Warranty Periods master.
* The server validates the period again (ItemService.ValidateWarrantyPeriod).
*/
export function WarrantyFields({
config,
customMonths,
warranty,
months,
onChange,
error,
disabled,
}: {
config: ProductConfig
/** Active rows of the Warranty Periods master, in months. */
customMonths: number[]
warranty: Warranty
months: WarrantyPeriodMonths | null
onChange: (warranty: Warranty, months: WarrantyPeriodMonths | null) => void
error?: string
disabled?: boolean
}) {
const groups = warrantyPeriodGroups(config, customMonths)
// The item's own length when it is not one of the offered periods (a one-off custom period saved earlier).
const offered = [...groups.defaults, ...groups.custom]
const ownLength = months !== null && !offered.includes(months) ? months : null
const selectGroups = [
{ label: "Default periods", months: groups.defaults },
{ label: "Custom periods", months: groups.custom },
{ label: "This item only", months: ownLength !== null ? [ownLength] : [] },
].filter((g) => g.months.length > 0)
const [customOpen, setCustomOpen] = useState(false)
const [amount, setAmount] = useState("")
const [unit, setUnit] = useState<WarrantyInputUnit>("months")
const [customError, setCustomError] = useState<string | null>(null)
function closeCustom() {
setCustomOpen(false)
setAmount("")
setUnit("months")
setCustomError(null)
}
function applyCustom() {
const parsed = parseWarrantyInput(amount, unit, customMonths)
if (parsed.error !== undefined) {
setCustomError(parsed.error)
return
}
onChange("Warranty", parsed.months as WarrantyPeriodMonths)
closeCustom()
}
return (
<div className="flex flex-col gap-3">
<Label className="text-base">Warranty</Label>
<RadioGroup
value={warranty}
onValueChange={(v) => {
const next = v as Warranty
onChange(next, next === "NonWarranty" ? null : months)
}}
className="flex flex-col gap-3 sm:flex-row sm:gap-6"
>
<label className="flex items-center gap-2.5">
<RadioGroupItem value="NonWarranty" disabled={disabled} />
<span className="text-base font-medium">Non-Warranty</span>
</label>
<label className="flex items-center gap-2.5">
<RadioGroupItem value="Warranty" disabled={disabled} />
<span className="text-base font-medium">Warranty</span>
</label>
</RadioGroup>
{warranty === "Warranty" && (
<div className="flex flex-col gap-1.5">
<Label required>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={months}
onValueChange={(v) => v && onChange("Warranty", v)}
items={selectGroups.flatMap((g) => g.months).map((m) => ({ label: formatWarrantyPeriod(m), value: m as WarrantyPeriodMonths }))}
disabled={disabled}
>
<SelectTrigger className="h-11! w-full text-base sm:w-60" aria-invalid={!!error}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{selectGroups.map((group, i) => (
<Fragment key={group.label}>
{i > 0 && <SelectSeparator />}
<SelectGroup>
<SelectLabel>{group.label}</SelectLabel>
{group.months.map((m) => (
<SelectItem key={m} value={m} className="text-base">
{formatWarrantyPeriod(m)}
</SelectItem>
))}
</SelectGroup>
</Fragment>
))}
</SelectContent>
</Select>
<FieldError errors={[error ? { message: error } : undefined]} />
{config.customWarrantyPeriodsEnabled &&
(customOpen ? (
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-dashed p-3">
<p className="text-xs text-muted-foreground">
For this item only — it isn&apos;t added to Warranty Periods or offered on other items.
</p>
<div className="flex flex-wrap items-center gap-2">
<Input
type="number"
inputMode="numeric"
min={1}
step={1}
autoFocus
value={amount}
onChange={(e) => {
setAmount(e.target.value)
setCustomError(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
applyCustom()
} else if (e.key === "Escape") closeCustom()
}}
placeholder="e.g. 2"
aria-label="Warranty length"
aria-invalid={!!customError}
className="h-10 w-24 text-base"
/>
<Select<WarrantyInputUnit>
value={unit}
onValueChange={(v) => v && setUnit(v)}
items={[
{ label: "Months", value: "months" },
{ label: "Years", value: "years" },
]}
>
<SelectTrigger className="h-10! w-28 text-base" aria-label="Unit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="months" className="text-base">Months</SelectItem>
<SelectItem value="years" className="text-base">Years</SelectItem>
</SelectContent>
</Select>
<Button type="button" size="sm" onClick={applyCustom}>Use</Button>
<Button type="button" size="sm" variant="ghost" onClick={closeCustom}>Cancel</Button>
</div>
{customError && <p className="text-sm text-destructive">{customError}</p>}
</div>
) : (
<Button type="button" variant="link" size="sm" className="h-auto w-fit px-0" onClick={() => setCustomOpen(true)} disabled={disabled}>
<Plus className="size-3.5" />
Custom period
</Button>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,301 @@
"use client"
import { useEffect, useState } from "react"
import { grnsApi } from "@/lib/api/grns"
import { itemsApi } from "@/lib/api/items"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import {
A4_SHEET_CLASS,
ACCENT,
formatDate,
InvoicePreviewFrame,
money,
PaperSize,
printColorExact,
qty,
RECEIPT_SHEET_BASE_CLASS,
RECEIPT_SHEET_CLASS,
ReceiptRow,
ReceiptRule,
ReceiptWidth,
SectionTitle,
} from "@/components/invoice-template/InvoicePrintKit"
import { CompanyProfile } from "@/types/company-profile"
import { Grn } from "@/types/grn"
import { Vendor, Warehouse } from "@/types/master-data"
/*
* Printable goods-received note in the same three paper sizes as the invoices — A4, 80mm and 58mm receipt — sharing
* their preview frame, PDF export and print plumbing. A draft prints with a DRAFT marker so it can't be mistaken for a
* posted receipt.
*/
interface GrnDoc {
grn: Grn
vendor: Vendor | null
warehouse: Warehouse | null
poDocNo: string | null
company: CompanyProfile | null
showLogo: boolean
/** itemId -> "SKU — name" */
itemLabels: Map<number, { sku: string; name: string }>
}
const statusMark = (grn: Grn) => (grn.status === "Draft" ? "DRAFT" : null)
function A4Sheet({ grn, vendor, warehouse, poDocNo, company, showLogo, itemLabels }: GrnDoc) {
const subtotal = grn.lines.reduce((s, l) => s + l.receivedValue, 0)
const vat = grn.lines.reduce((s, l) => s + l.vatAmount, 0)
const total = grn.lines.reduce((s, l) => s + l.lineTotal, 0)
return (
<div className={A4_SHEET_CLASS} style={printColorExact}>
<div className="flex items-start justify-between gap-6">
<div>
<h1 className="text-4xl font-extrabold tracking-wide text-neutral-900">GOODS RECEIVED NOTE</h1>
<dl className="mt-5 grid grid-cols-[auto_1fr] gap-x-10 gap-y-1.5">
<dt className="text-xs font-bold text-neutral-900">GRN No.</dt>
<dd>{grn.docNo}</dd>
<dt className="text-xs font-bold text-neutral-900">Date</dt>
<dd>{formatDate(grn.postedAt ?? grn.createdAt)}</dd>
<dt className="text-xs font-bold text-neutral-900">Against</dt>
<dd>{poDocNo ? `PO ${poDocNo}` : grn.poId ? `PO #${grn.poId}` : "Direct receipt"}</dd>
{statusMark(grn) && (
<>
<dt className="text-xs font-bold text-neutral-900">Status</dt>
<dd className="font-semibold" style={{ color: ACCENT }}>{statusMark(grn)}</dd>
</>
)}
</dl>
</div>
{showLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
<img src={company!.logoUrl!} alt={company!.legalName} className="h-16 w-auto max-w-45 object-contain" />
)}
</div>
<div className="mt-5 mb-7">
<div className="h-1 bg-neutral-900" />
<div className="mt-0.5 h-0.5" style={{ backgroundColor: ACCENT }} />
</div>
<div className="grid grid-cols-2 gap-10">
<div>
<SectionTitle>Received from</SectionTitle>
<div className="leading-snug">
<p className="font-bold text-neutral-900">{vendor?.name ?? `Vendor #${grn.vendorId}`}</p>
{vendor?.code && <p>Vendor code: {vendor.code}</p>}
</div>
</div>
<div>
<SectionTitle>Received at</SectionTitle>
<div className="leading-snug">
{company && <p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>}
<p>{warehouse ? `${warehouse.code} — ${warehouse.name}` : `Warehouse #${grn.warehouseId}`}</p>
{warehouse?.address && <p>{warehouse.address}</p>}
</div>
</div>
</div>
<div className="mt-8">
<SectionTitle>Goods Received</SectionTitle>
<table className="w-full border-collapse">
<thead>
<tr className="border-y-2 border-neutral-900 text-xs font-bold text-neutral-900">
<th className="px-2 py-2.5 text-left">Item</th>
<th className="px-2 py-2.5 text-right">Qty</th>
<th className="px-2 py-2.5 text-right">Unit cost</th>
<th className="px-2 py-2.5 text-right">Disc %</th>
<th className="px-2 py-2.5 text-right">VAT</th>
<th className="px-2 py-2.5 text-right">Amount</th>
</tr>
</thead>
<tbody>
{grn.lines.map((l) => {
const item = itemLabels.get(l.itemId)
return (
<tr key={l.grnLineId} className="border-b border-neutral-300">
<td className="px-2 py-2.5">
<p className="font-medium text-neutral-900">{item?.name ?? `Item #${l.itemId}`}</p>
{item?.sku && <p className="text-xs text-neutral-500">{item.sku}</p>}
{l.batchNo && <p className="text-xs text-neutral-500">Batch {l.batchNo}</p>}
</td>
<td className="px-2 py-2.5 text-right tabular-nums">{qty(l.qty)}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{money(l.unitCost)}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{l.discountPct > 0 ? l.discountPct : "—"}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{l.vatAmount > 0 ? money(l.vatAmount) : "—"}</td>
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(l.lineTotal)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="mt-6 flex justify-end print:break-inside-avoid">
<div className="w-72 border border-neutral-900">
<div className="space-y-1.5 px-3.5 py-3 text-xs">
<div className="flex justify-between font-bold text-neutral-900"><span>Subtotal</span><span className="tabular-nums">{money(subtotal)}</span></div>
{vat > 0 && <div className="flex justify-between"><span>VAT</span><span className="tabular-nums">{money(vat)}</span></div>}
</div>
<div className="flex justify-between border-t border-neutral-300 bg-neutral-100 px-3.5 py-3 text-sm font-bold text-neutral-900">
<span>Total</span>
<span className="tabular-nums">{money(total)}</span>
</div>
</div>
</div>
<div className="mt-16 grid grid-cols-2 gap-16 text-xs text-neutral-700 print:break-inside-avoid">
<div className="border-t border-neutral-900 pt-1.5">Received by</div>
<div className="border-t border-neutral-900 pt-1.5">Delivered by</div>
</div>
</div>
)
}
/** 80mm / 58mm thermal layout: one centred column, no fills. */
function ReceiptSheet({ grn, vendor, warehouse, poDocNo, company, showLogo, itemLabels, width }: GrnDoc & { width: ReceiptWidth }) {
const subtotal = grn.lines.reduce((s, l) => s + l.receivedValue, 0)
const vat = grn.lines.reduce((s, l) => s + l.vatAmount, 0)
const total = grn.lines.reduce((s, l) => s + l.lineTotal, 0)
return (
<div className={cn(RECEIPT_SHEET_BASE_CLASS, RECEIPT_SHEET_CLASS[width])} style={printColorExact}>
<div className="text-center">
{showLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
<img src={company!.logoUrl!} alt={company!.legalName} className="mx-auto mb-1.5 h-10 w-auto max-w-[50mm] object-contain grayscale" />
)}
{company && <p className="text-sm font-bold uppercase">{company.tradeName || company.legalName}</p>}
<p className="mt-2 text-sm font-extrabold tracking-[0.15em]">GOODS RECEIVED</p>
{statusMark(grn) && <p className="font-bold">*** {statusMark(grn)} ***</p>}
</div>
<ReceiptRule />
<ReceiptRow label="GRN No." value={grn.docNo} />
<ReceiptRow label="Date" value={formatDate(grn.postedAt ?? grn.createdAt)} />
<ReceiptRow label="Against" value={poDocNo ? `PO ${poDocNo}` : grn.poId ? `PO #${grn.poId}` : "Direct"} />
<ReceiptRow label="Vendor" value={vendor?.name ?? `#${grn.vendorId}`} />
<ReceiptRow label="Warehouse" value={warehouse?.code ?? `#${grn.warehouseId}`} />
<ReceiptRule />
<div className="space-y-1.5">
{grn.lines.map((l) => {
const item = itemLabels.get(l.itemId)
return (
<div key={l.grnLineId}>
<p className="font-medium">{item?.name ?? `Item #${l.itemId}`}</p>
<div className="flex justify-between gap-2 tabular-nums">
<span>{qty(l.qty)} x {money(l.unitCost)}</span>
<span>{money(l.lineTotal)}</span>
</div>
</div>
)
})}
</div>
<ReceiptRule />
<ReceiptRow label="Subtotal" value={money(subtotal)} />
{vat > 0 && <ReceiptRow label="VAT" value={money(vat)} />}
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
<ReceiptRow label="TOTAL" value={money(total)} bold />
</div>
</div>
)
}
/** Loads the GRN plus the optional decoration around it (vendor, warehouse, PO, item names, company). Only the GRN is
* required — any other failure just leaves that block out. */
function useGrnDoc(grnId: number) {
const [doc, setDoc] = useState<GrnDoc | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const error = Number.isFinite(grnId) ? loadError : `Invalid GRN id '${grnId}'.`
useEffect(() => {
if (!Number.isFinite(grnId)) return
let cancelled = false
grnsApi
.get(grnId)
.then(async (grn) => {
const itemIds = [...new Set(grn.lines.map((l) => l.itemId))]
const [vendorRes, warehouse, po, companyRes, templateRes, items] = await Promise.all([
vendorsApi.get(grn.vendorId).catch(() => null),
warehousesApi.get(grn.warehouseId).catch(() => null),
grn.poId ? purchaseOrdersApi.get(grn.poId).catch(() => null) : Promise.resolve(null),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
Promise.all(itemIds.map((id) => itemsApi.get(id).then((r) => r.data).catch(() => null))),
])
if (cancelled) return
const company = companyRes.data
const itemLabels = new Map<number, { sku: string; name: string }>()
for (const item of items) if (item) itemLabels.set(item.itemId, { sku: item.sku, name: item.name })
setDoc({
grn,
vendor: vendorRes?.data ?? null,
warehouse,
poDocNo: po?.docNo ?? null,
company,
showLogo: templateRes.data?.logoPlacement !== "None" && !!company?.logoUrl,
itemLabels,
})
})
.catch((err) => {
if (!cancelled) setLoadError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [grnId])
return { doc, error }
}
function Sheet({ size, doc }: { size: PaperSize; doc: GrnDoc }) {
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
}
/** Popup print preview of a GRN — same frame as the invoice dialogs. Opens on A4 every time. */
export function GrnReceiptDialog({
grnId,
docNo,
open,
onOpenChange,
}: {
grnId: number | null
docNo?: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
<div className="border-b px-5 py-4 pr-12">
<DialogTitle className="text-lg font-semibold">Goods received note{docNo ? ` ${docNo}` : ""}</DialogTitle>
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
</div>
{open && grnId !== null && <GrnReceiptPreview grnId={grnId} className="flex-1" />}
</DialogContent>
</Dialog>
)
}
function GrnReceiptPreview({ grnId, className }: { grnId: number; className?: string }) {
const { doc, error } = useGrnDoc(grnId)
return (
<InvoicePreviewFrame
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
error={error}
docNo={doc?.grn.docNo ?? `grn-${grnId}`}
shareTitle={`GRN ${doc?.grn.docNo ?? ""}`.trim()}
sharePath={`/dashboard/receiving/grn/${grnId}`}
className={className}
/>
)
}
@@ -0,0 +1,83 @@
"use client"
import { CalendarDays } from "lucide-react"
import { DateRangePicker } from "@/components/ui/date-picker"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
export type DateRangePreset = "All" | "Today" | "7d" | "30d" | "Month" | "Custom"
export type CustomDateRange = { from: Date | undefined; to?: Date | undefined }
export interface DateRangeValue {
preset: DateRangePreset
custom?: CustomDateRange
}
const PRESETS: { value: DateRangePreset; label: string }[] = [
{ value: "All", label: "All time" },
{ value: "Today", label: "Today" },
{ value: "7d", label: "Last 7 days" },
{ value: "30d", label: "Last 30 days" },
{ value: "Month", label: "This month" },
{ value: "Custom", label: "Custom range" },
]
export function toIsoDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Inclusive `YYYY-MM-DD` bounds for a filter value; empty (no bounds) for "All time" or a custom range with no start yet. */
export function dateRangeBounds(value: DateRangeValue): { from?: string; to?: string } {
const today = new Date()
switch (value.preset) {
case "All":
return {}
case "Custom":
return value.custom?.from ? { from: toIsoDate(value.custom.from), to: toIsoDate(value.custom.to ?? value.custom.from) } : {}
case "Month":
return { from: toIsoDate(new Date(today.getFullYear(), today.getMonth(), 1)), to: toIsoDate(today) }
default: {
const days = value.preset === "Today" ? 1 : value.preset === "7d" ? 7 : 30
const from = new Date(today)
from.setDate(today.getDate() - (days - 1))
return { from: toIsoDate(from), to: toIsoDate(today) }
}
}
}
/** A date-range dropdown (presets, plus a from–to picker for "Custom range") for list-page filter bars. */
export function DateRangeFilter({
value,
onChange,
label = "Date range",
}: {
value: DateRangeValue
onChange: (next: DateRangeValue) => void
label?: string
}) {
return (
<>
<Select<DateRangePreset> value={value.preset} onValueChange={(v) => onChange({ ...value, preset: v ?? "All" })}>
<SelectTrigger className="h-10! w-40 text-sm" aria-label={label}>
<CalendarDays className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRESETS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
{value.preset === "Custom" && (
<DateRangePicker
value={value.custom}
onChange={(custom) => onChange({ ...value, custom })}
placeholder="Pick from – to dates"
className="sm:w-72"
/>
)}
</>
)
}
+36 -9
View File
@@ -6,6 +6,15 @@ import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { generateVendorCode } from "@/lib/vendor-code"
import { Vendor } from "@/types/master-data"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import {
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -40,6 +49,8 @@ export function EditVendorDialog({
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [currency, setCurrency] = useState("")
const { config: procConfig } = useProcurementConfig()
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
@@ -51,6 +62,7 @@ export function EditVendorDialog({
setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "")
setCurrency(data.currency)
setOptional({ mobile: data.mobile ?? "", email: data.email ?? "", address: data.address ?? "" })
}
function reload() {
@@ -85,6 +97,7 @@ export function EditVendorDialog({
if (!code.trim()) nextErrors.code = "Vendor code is required"
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required"
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not save vendor", Object.values(nextErrors)[0])
@@ -96,7 +109,7 @@ export function EditVendorDialog({
try {
const result = await vendorsApi.update(
vendorId,
{ code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId },
{ code, name, ...vendorTermsTaxPayload(procConfig, terms, taxReg), currency, ...vendorOptionalPayload(procConfig, optional), branchId: vendor.branchId },
etag
)
toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`)
@@ -145,14 +158,28 @@ export function EditVendorDialog({
<Input id="ev-code" value={code} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="ev-terms">Payment terms (optional)</FieldLabel>
<Input id="ev-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
<Field>
<FieldLabel htmlFor="ev-taxreg">Tax registration (optional)</FieldLabel>
<Input id="ev-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
{procConfig.vendorPaymentTermsEnabled && (
<Field>
<FieldLabel htmlFor="ev-terms">Payment terms (optional)</FieldLabel>
<Input id="ev-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
)}
{procConfig.vendorTaxRegEnabled && (
<Field>
<FieldLabel htmlFor="ev-taxreg">Tax registration (optional)</FieldLabel>
<Input id="ev-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
)}
<VendorOptionalFields
config={procConfig}
values={optional}
onChange={(patch) => {
setOptional((prev) => ({ ...prev, ...patch }))
clearErrors("mobile", "email")
}}
idPrefix="ev"
errors={errors}
/>
<Field data-invalid={!!errors.currency}>
<FieldLabel htmlFor="ev-currency" required>Currency</FieldLabel>
<Input
@@ -0,0 +1,117 @@
"use client"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { ProcurementConfig } from "@/types/procurement"
/** The optional vendor details; `""` means "not provided". */
export interface VendorOptionalValues {
mobile: string
email: string
address: string
}
export const EMPTY_VENDOR_OPTIONAL: VendorOptionalValues = { mobile: "", email: "", address: "" }
// Mirrors VendorService.ValidateOptionalFields on the server.
const MOBILE_PATTERN = /^\+?[0-9][0-9 \-()]{5,18}$/
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/** Format errors for the fields that are switched on. Blank is always fine — every field is optional. */
export function validateVendorOptional(config: ProcurementConfig, v: VendorOptionalValues): Record<string, string> {
const errors: Record<string, string> = {}
if (config.vendorContactMode === "Mobile" && v.mobile.trim() && !MOBILE_PATTERN.test(v.mobile.trim()))
errors.mobile = "Enter a valid mobile number (digits, optional leading +)"
if (config.vendorContactMode === "Email" && v.email.trim() && !EMAIL_PATTERN.test(v.email.trim()))
errors.email = "Enter a valid email address"
return errors
}
/**
* The request fields for the optional details. Only fields that are switched on are included, so a
* hidden field is never sent (the server answers 422 CONFIG_DISABLED to one) and a blank becomes null.
*/
export function vendorOptionalPayload(config: ProcurementConfig, v: VendorOptionalValues) {
return {
...(config.vendorContactMode === "Mobile" ? { mobile: v.mobile.trim() || null } : {}),
...(config.vendorContactMode === "Email" ? { email: v.email.trim() || null } : {}),
...(config.vendorAddressEnabled ? { address: v.address.trim() || null } : {}),
}
}
/** Payment terms / tax registration, included only while their switches are on (a blank becomes null). */
export function vendorTermsTaxPayload(config: ProcurementConfig, terms: string, taxReg: string) {
return {
...(config.vendorPaymentTermsEnabled ? { terms: terms.trim() || null } : {}),
...(config.vendorTaxRegEnabled ? { taxReg: taxReg.trim() || null } : {}),
}
}
/**
* Mobile or Email (whichever Procurement configuration selects) and the company address
* (when enabled). Renders nothing when both are off, so every vendor form can include it unconditionally.
*/
export function VendorOptionalFields({
config,
values,
onChange,
idPrefix,
errors = {},
}: {
config: ProcurementConfig
values: VendorOptionalValues
onChange: (patch: Partial<VendorOptionalValues>) => void
idPrefix: string
errors?: Record<string, string>
}) {
return (
<>
{config.vendorContactMode === "Mobile" && (
<Field data-invalid={!!errors.mobile}>
<FieldLabel htmlFor={`${idPrefix}-mobile`}>Mobile number (optional)</FieldLabel>
<Input
id={`${idPrefix}-mobile`}
type="tel"
inputMode="tel"
value={values.mobile}
onChange={(e) => onChange({ mobile: e.target.value })}
placeholder="+94 77 123 4567"
maxLength={20}
aria-invalid={!!errors.mobile}
/>
<FieldError errors={[errors.mobile ? { message: errors.mobile } : undefined]} />
</Field>
)}
{config.vendorContactMode === "Email" && (
<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor={`${idPrefix}-email`}>Email (optional)</FieldLabel>
<Input
id={`${idPrefix}-email`}
type="email"
inputMode="email"
value={values.email}
onChange={(e) => onChange({ email: e.target.value })}
placeholder="accounts@supplier.com"
maxLength={200}
aria-invalid={!!errors.email}
/>
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
</Field>
)}
{config.vendorAddressEnabled && (
<Field>
<FieldLabel htmlFor={`${idPrefix}-address`}>Company address (optional)</FieldLabel>
<Textarea
id={`${idPrefix}-address`}
value={values.address}
onChange={(e) => onChange({ address: e.target.value })}
placeholder="No. 12, Galle Road, Colombo 03"
maxLength={500}
rows={2}
/>
</Field>
)}
</>
)
}