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
@@ -0,0 +1,49 @@
"use client"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { OpeningCashBankPanel } from "@/components/accounts/OpeningCashBankPanel"
import { OpeningStockPanel } from "@/components/accounts/OpeningStockPanel"
const TABS = [
{ key: "cash", label: "Cash & bank" },
{ key: "stock", label: "Stock" },
] as const
/**
* Opening balances — what the business already had when it started using the system. Each tab posts one
* balanced General Ledger journal against "Opening Balance Equity" (cash and bank once; stock a warehouse at a time).
*/
export default function OpeningBalancesPage() {
const [tab, setTab] = useState<(typeof TABS)[number]["key"]>("cash")
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Opening Balances</h1>
<p className="text-base text-muted-foreground">Bring in the cash, bank and stock you already had on the day you started.</p>
</div>
<div role="tablist" aria-label="Opening balance type" className="flex w-fit gap-1 rounded-xl bg-muted p-1">
{TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
onClick={() => setTab(t.key)}
className={cn(
"rounded-lg px-4 py-2 text-base font-medium transition-colors",
tab === t.key ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
))}
</div>
{tab === "cash" ? <OpeningCashBankPanel /> : <OpeningStockPanel />}
</div>
)
}
@@ -1,5 +1,5 @@
import Link from "next/link"
import { BookText, Inbox, PiggyBank, Receipt, Tags, Wallet, type LucideIcon } from "lucide-react"
import { BookText, Inbox, PiggyBank, Receipt, Scale, Tags, Wallet, type LucideIcon } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -40,6 +40,12 @@ const areas: { title: string; description: string; href: string; icon: LucideIco
href: "/dashboard/accounts/bank-deposits",
icon: PiggyBank,
},
{
title: "Opening Balances",
description: "Enter what each cash and bank account held when you started — posted once as a single ledger journal.",
href: "/dashboard/accounts/opening-balances",
icon: Scale,
},
]
export default function AccountsHubPage() {
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Pencil, Receipt, Save, Users } from "lucide-react"
import { useCustomerConfig } from "@/hooks/use-customer-config"
import { customersApi } from "@/lib/api/customers"
import { salesApi } from "@/lib/api/sales"
import { errorMessage, fieldErrors } from "@/lib/error-map"
@@ -61,6 +62,8 @@ export default function CustomerDetailPage() {
const [city, setCity] = useState("")
const [country, setCountry] = useState("")
const [taxRegistrationNo, setTaxRegistrationNo] = useState("")
// A field switched off in Sales Configuration is hidden; its stored value is sent back unchanged.
const { fields } = useCustomerConfig()
const [creditLimit, setCreditLimit] = useState("0")
const [creditDays, setCreditDays] = useState("0")
@@ -502,42 +505,58 @@ export default function CustomerDetailPage() {
<Label className="text-base">Display name (optional)</Label>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Phone</Label>
<Input value={phone} onChange={(e) => setPhone(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Email</Label>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Address line 1</Label>
<Input value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Address line 2</Label>
<Input value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">City</Label>
<Input value={city} onChange={(e) => setCity(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Country</Label>
<Input value={country} onChange={(e) => setCountry(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
{fields.phone && (
<div className="flex flex-col gap-2">
<Label className="text-base">Phone</Label>
<Input value={phone} onChange={(e) => setPhone(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
)}
{fields.email && (
<div className="flex flex-col gap-2">
<Label className="text-base">Email</Label>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
)}
{fields.address && (
<>
<div className="flex flex-col gap-2">
<Label className="text-base">Address line 1</Label>
<Input value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Address line 2</Label>
<Input value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
</>
)}
{fields.city && (
<div className="flex flex-col gap-2">
<Label className="text-base">City</Label>
<Input value={city} onChange={(e) => setCity(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
)}
{fields.country && (
<div className="flex flex-col gap-2">
<Label className="text-base">Country</Label>
<Input value={country} onChange={(e) => setCountry(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Tax registration</Label>
<Input value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Credit limit (LKR)</Label>
<Input type="number" min="0" step="0.01" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Credit days</Label>
<Input type="number" min="0" step="1" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
{fields.creditLimit && (
<div className="flex flex-col gap-2">
<Label className="text-base">Credit limit (LKR)</Label>
<Input type="number" min="0" step="0.01" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
)}
{fields.creditDays && (
<div className="flex flex-col gap-2">
<Label className="text-base">Credit days</Label>
<Input type="number" min="0" step="1" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
@@ -35,6 +35,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { EditCustomerDialog } from "@/components/customers/EditCustomerDialog"
import { useCustomerConfig } from "@/hooks/use-customer-config"
type StatusFilter = EntityStatus | "All"
type TypeFilter = CustomerType | "All"
@@ -63,6 +64,8 @@ export default function CustomersPage() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
// Which optional customer fields Sales Configuration has on: the create dialog, the columns and the cards follow it.
const { fields } = useCustomerConfig()
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [newCustomerType, setNewCustomerType] = useState<CustomerType>("B2C")
@@ -169,14 +172,15 @@ export default function CustomersPage() {
customerCode: generatedCode,
customerType: newCustomerType,
name,
phone: phone || null,
email: email || null,
addressLine1: addressLine1 || null,
addressLine2: addressLine2 || null,
city: city || null,
country: country || null,
creditLimit: Number(creditLimit) || 0,
creditDays: Number(creditDays) || 0,
// Only what is switched on — the server refuses a value for a field that is off.
phone: fields.phone ? phone || null : null,
email: fields.email ? email || null : null,
addressLine1: fields.address ? addressLine1 || null : null,
addressLine2: fields.address ? addressLine2 || null : null,
city: fields.city ? city || null : null,
country: fields.country ? country || null : null,
creditLimit: fields.creditLimit ? Number(creditLimit) || 0 : 0,
creditDays: fields.creditDays ? Number(creditDays) || 0 : 0,
branchId: effectiveBranchId ?? 0,
})
toast.success("Customer created", `${result.data.customerCode} — ${result.data.name}`)
@@ -265,44 +269,66 @@ export default function CustomersPage() {
</SelectContent>
</Select>
</Field>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
<Input id="c-phone" type="tel" inputMode="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
</Field>
<Field>
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
<Input id="c-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
</Field>
</div>
<Field>
<FieldLabel htmlFor="c-addr1">Address line 1 (optional)</FieldLabel>
<Input id="c-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} placeholder="No. 12, Galle Road" />
</Field>
<Field>
<FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel>
<Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" />
</Field>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="c-city">City (optional)</FieldLabel>
<Input id="c-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Colombo" />
</Field>
<Field>
<FieldLabel htmlFor="c-country">Country (optional)</FieldLabel>
<Input id="c-country" value={country} onChange={(e) => setCountry(e.target.value)} placeholder="Sri Lanka" />
</Field>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
<Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="c-credit-days">Credit days</FieldLabel>
<Input id="c-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
</Field>
</div>
{(fields.phone || fields.email) && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{fields.phone && (
<Field>
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
<Input id="c-phone" type="tel" inputMode="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
</Field>
)}
{fields.email && (
<Field>
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
<Input id="c-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
</Field>
)}
</div>
)}
{fields.address && (
<>
<Field>
<FieldLabel htmlFor="c-addr1">Address line 1 (optional)</FieldLabel>
<Input id="c-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} placeholder="No. 12, Galle Road" />
</Field>
<Field>
<FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel>
<Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" />
</Field>
</>
)}
{(fields.city || fields.country) && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{fields.city && (
<Field>
<FieldLabel htmlFor="c-city">City (optional)</FieldLabel>
<Input id="c-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Colombo" />
</Field>
)}
{fields.country && (
<Field>
<FieldLabel htmlFor="c-country">Country (optional)</FieldLabel>
<Input id="c-country" value={country} onChange={(e) => setCountry(e.target.value)} placeholder="Sri Lanka" />
</Field>
)}
</div>
)}
{(fields.creditLimit || fields.creditDays) && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{fields.creditLimit && (
<Field>
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
<Input id="c-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="c-credit-days">Credit days</FieldLabel>
<Input id="c-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
</Field>
)}
</div>
)}
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="c-branch" required>Branch</FieldLabel>
@@ -435,14 +461,18 @@ export default function CustomersPage() {
<p className="text-muted-foreground">Type</p>
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center text-xs">{c.customerType}</Badge>
</div>
<div>
<p className="text-muted-foreground">Phone</p>
<p className="font-medium text-foreground">{c.phone ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Credit limit</p>
<p className="font-medium text-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
</div>
{fields.phone && (
<div>
<p className="text-muted-foreground">Phone</p>
<p className="font-medium text-foreground">{c.phone ?? "—"}</p>
</div>
)}
{fields.creditLimit && (
<div>
<p className="text-muted-foreground">Credit limit</p>
<p className="font-medium text-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Last paid</p>
<p className="font-medium text-foreground">{c.lastPaymentDate ? formatReportDate(c.lastPaymentDate) : "—"}</p>
@@ -512,8 +542,8 @@ export default function CustomersPage() {
<TableHead className="w-32">Code</TableHead>
<TableHead className="w-48">Name</TableHead>
<TableHead className="w-28">Type</TableHead>
<TableHead className="w-36">Phone</TableHead>
<TableHead className="w-36">Credit limit</TableHead>
{fields.phone && <TableHead className="w-36">Phone</TableHead>}
{fields.creditLimit && <TableHead className="w-36">Credit limit</TableHead>}
<TableHead className="w-32">Last paid</TableHead>
<TableHead className="w-36 text-right">Remaining payment</TableHead>
<TableHead className="w-28 text-right">Unpaid invoices</TableHead>
@@ -537,8 +567,10 @@ export default function CustomersPage() {
<TableCell>
<Badge variant="outline">{c.customerType}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
{fields.phone && <TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>}
{fields.creditLimit && (
<TableCell className="text-sm text-muted-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
)}
<TableCell className="text-sm text-muted-foreground">{c.lastPaymentDate ? formatReportDate(c.lastPaymentDate) : "—"}</TableCell>
<TableCell className={cn("text-right text-sm tabular-nums", outstanding(c) > 0 ? "font-semibold text-foreground" : "text-muted-foreground")}>
{outstanding(c) > 0 ? `LKR ${formatAmount(outstanding(c))}` : "—"}
@@ -55,7 +55,10 @@ function EmployeeDetailPageContent() {
const params = useParams<{ id: string }>()
const searchParams = useSearchParams()
const employeeId = Number(params.id)
const { isAdmin, branchId: myBranchId, branchingEnabled } = useAuth()
const { isAdmin, branchId: myBranchId, branchingEnabled, can } = useAuth()
// Bank details, salary structures and loans are pay data: they need the Payroll permission on top of Employees.
const canSeePay = can("hrm.payroll")
const visibleTabs = TABS.filter((t) => canSeePay || (t !== "Bank Details" && t !== "Salary & Loans"))
const [tab, setTab] = useState<Tab>("Overview")
const [employee, setEmployee] = useState<EmployeeDetail | null>(null)
@@ -331,7 +334,7 @@ function EmployeeDetailPageContent() {
</div>
<div className="flex w-full gap-1 overflow-x-auto rounded-xl bg-muted p-1">
{TABS.map((t) => (
{visibleTabs.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
+2 -1
View File
@@ -3,6 +3,7 @@ import { Header } from "@/components/Layouts/Header"
import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs"
import { Toaster } from "@/components/ui/toast"
import { AuthProvider } from "@/components/auth/AuthProvider"
import { AccessGate } from "@/components/auth/AccessGate"
export default function DashboardLayout({
children,
@@ -20,7 +21,7 @@ export default function DashboardLayout({
<Breadcrumbs />
<div className="rounded-xl bg-card border border-border shadow-sm print:rounded-none print:border-0 print:shadow-none">
<div className="p-6 print:p-0">
{children}
<AccessGate>{children}</AccessGate>
</div>
</div>
</div>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, Ban, Check, Plus, Save, Send, Trash2, X } from "lucide-react"
import { AlertTriangle, Ban, Check, Download, Plus, Save, Send, Trash2, X } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { useAuth } from "@/components/auth/AuthProvider"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -13,6 +13,10 @@ import { baseUomLabel } from "@/lib/uom-label"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
import { validatePoLine } from "@/lib/validations/procurement"
import { formatAmount } from "@/lib/format"
import { lineTaxAmount, pctText, pctToRate, rateToPct } from "@/lib/po-tax"
import { downloadPurchaseOrderPdfById } from "@/lib/pdf/purchase-order-download"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import { cn } from "@/lib/utils"
import { CreatePoLineInput, PurchaseOrder } from "@/types/procurement"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
@@ -41,7 +45,10 @@ interface DraftLine {
warehouseId: number | null
qty: string
unitPrice: string
tax: string
/** Tax as a percent (18 = 18%) — converted to the API's 0..1 rate on save. */
taxPct: string
/** The server's stored amount; used for the read-only view (the editable view computes it live). */
taxAmount: number
qtyReceived: number
}
@@ -55,7 +62,11 @@ export default function PurchaseOrderDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const poId = Number(params.id)
const { isAdmin } = useAuth()
const { can } = useAuth()
const canApprove = can("procurement", "Approve")
const { config: procConfig } = useProcurementConfig()
const showTax = procConfig.poTaxEnabled
const [downloadingPdf, setDownloadingPdf] = useState(false)
const [po, setPo] = useState<PurchaseOrder | null>(null)
const [etag, setEtag] = useState<string | null>(null)
@@ -89,7 +100,8 @@ export default function PurchaseOrderDetailPage() {
warehouseId: l.warehouseId,
qty: String(l.qty),
unitPrice: String(l.unitPrice),
tax: String(l.tax),
taxPct: pctText(rateToPct(l.tax)),
taxAmount: l.taxAmount ?? 0,
qtyReceived: l.qtyReceived,
}))
}
@@ -131,6 +143,24 @@ export default function PurchaseOrderDetailPage() {
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
}
/** Picking an item prefills its tax rate (when PO tax is on) — still editable per line. */
function handleItemChange(key: string, itemId: number | null) {
const item = itemFor(itemId)
updateLine(key, { itemId, ...(showTax ? { taxPct: pctText(item?.taxRatePercentage ?? 0) } : {}) })
}
async function handleDownloadPdf() {
if (!po) return
setDownloadingPdf(true)
try {
await downloadPurchaseOrderPdfById(po.poId, procConfig)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setDownloadingPdf(false)
}
}
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
@@ -154,8 +184,13 @@ export default function PurchaseOrderDetailPage() {
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
tax: line.tax,
tax: "0",
})
if (showTax) {
const pct = Number(line.taxPct)
if (line.taxPct.trim() === "" || Number.isNaN(pct) || pct < 0) errors.tax = "Tax % cannot be negative"
else if (pct > 100) errors.tax = "Tax cannot exceed 100%"
}
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
}
setLineErrors(nextLineErrors)
@@ -169,7 +204,7 @@ export default function PurchaseOrderDetailPage() {
warehouseId: l.warehouseId as number,
qty: Number(l.qty),
unitPrice: Number(l.unitPrice),
tax: Number(l.tax),
tax: showTax ? pctToRate(Number(l.taxPct)) : 0,
}))
setSaving(true)
@@ -200,9 +235,8 @@ export default function PurchaseOrderDetailPage() {
setSubmitting(true)
try {
const updated = await purchaseOrdersApi.submit(po.poId)
setPo(updated)
setLines(toDraftLines(updated))
toast.success("Submitted for approval", `${updated.docNo} — now ${updated.status} and locked for editing.`)
router.push("/dashboard/procurement/purchase-orders")
} catch (err) {
setSaveError(errorMessage(err))
toast.error("Could not submit purchase order", errorMessage(err))
@@ -217,9 +251,8 @@ export default function PurchaseOrderDetailPage() {
setApproving(true)
try {
const updated = await purchaseOrdersApi.approve(po.poId)
setPo(updated)
setLines(toDraftLines(updated))
toast.success("Purchase order approved", updated.docNo)
router.push("/dashboard/procurement/purchase-orders")
} catch (err) {
setSaveError(errorMessage(err))
toast.error("Could not approve purchase order", errorMessage(err))
@@ -234,11 +267,10 @@ export default function PurchaseOrderDetailPage() {
setRejecting(true)
try {
const updated = await purchaseOrdersApi.reject(po.poId, { reason: rejectReason.trim() || undefined })
setPo(updated)
setLines(toDraftLines(updated))
setShowRejectForm(false)
setRejectReason("")
toast.success("Purchase order rejected", `${updated.docNo} — back to Draft for revision.`)
toast.success("Purchase order rejected", `${updated.docNo} — marked Rejected - revise and resubmit it.`)
router.push("/dashboard/procurement/purchase-orders")
} catch (err) {
setSaveError(errorMessage(err))
toast.error("Could not reject purchase order", errorMessage(err))
@@ -271,9 +303,9 @@ export default function PurchaseOrderDetailPage() {
setCancelling(true)
try {
const updated = await purchaseOrdersApi.cancel(po.poId, { reason: cancelReason.trim() })
setPo(updated)
setShowCancelForm(false)
toast.success("Purchase order cancelled", updated.docNo)
router.push("/dashboard/procurement/purchase-orders")
} catch (err) {
toast.error("Could not cancel purchase order", errorMessage(err))
} finally {
@@ -306,6 +338,16 @@ export default function PurchaseOrderDetailPage() {
// A submitted-but-still-open PO (issued to the vendor) is cancellable with a reason;
// a Draft is deleted instead, and closed/cancelled POs are terminal.
const cancellable = po.status === "Approved" || po.status === "PartiallyReceived"
// Editing recomputes from the lines as typed; otherwise the server's totals are the truth.
const totals = editable
? lines.reduce(
(acc, l) => ({
subTotal: acc.subTotal + (Number(l.qty) || 0) * (Number(l.unitPrice) || 0),
tax: acc.tax + (showTax ? lineTaxAmount(Number(l.qty), Number(l.unitPrice), Number(l.taxPct)) : 0),
}),
{ subTotal: 0, tax: 0 },
)
: { subTotal: po.totals.subTotal, tax: po.totals.tax }
return (
<div className="flex flex-col gap-6">
@@ -317,13 +359,22 @@ export default function PurchaseOrderDetailPage() {
<PoStatusBadge status={po.status} />
</div>
<p className="text-base text-muted-foreground">
Vendor {vendorCode(po.vendorId)} {po.requisitionId ? `— from Requisition #${po.requisitionId}` : ""} — {po.totals.currency} {po.totals.grandTotal.toFixed(2)}
Vendor {po.vendor ? `${po.vendor.name} (${po.vendor.code})` : vendorCode(po.vendorId)} {po.requisitionId ? `— from Requisition #${po.requisitionId}` : ""} — {po.totals.currency} {po.totals.grandTotal.toFixed(2)}
</p>
{(po.vendor?.mobile || po.vendor?.email || po.vendor?.address) && (
<p className="text-sm text-muted-foreground">
{[po.vendor?.mobile, po.vendor?.email, po.vendor?.address].filter(Boolean).join(" · ")}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
{po.status === "Draft" && (
<Button variant="outline" size="lg" className="w-full sm:w-auto" onClick={handleDownloadPdf} disabled={downloadingPdf}>
<Download className="size-5" />
{downloadingPdf ? "Preparing…" : "Download PDF"}
</Button>
{(po.status === "Draft" || po.status === "Rejected") && (
<>
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Send className="size-5" />
@@ -346,7 +397,7 @@ export default function PurchaseOrderDetailPage() {
</AlertDialog>
</>
)}
{po.status === "PendingApproval" && isAdmin && (
{po.status === "PendingApproval" && canApprove && (
<>
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
<Check className="size-5" />
@@ -398,7 +449,7 @@ export default function PurchaseOrderDetailPage() {
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Reject {po.docNo}?</DialogTitle>
<DialogDescription>Sends it back to Draft so the requester can revise and resubmit.</DialogDescription>
<DialogDescription>Marks it Rejected so the requester can revise and resubmit it.</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor="po-reject-reason">Reason (optional)</FieldLabel>
@@ -441,7 +492,7 @@ export default function PurchaseOrderDetailPage() {
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
{editable && (
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", taxPct: "0", taxAmount: 0, qtyReceived: 0 }])}>
<Plus className="size-5" />
Add line
</Button>
@@ -472,8 +523,11 @@ export default function PurchaseOrderDetailPage() {
<p className="font-medium text-foreground">{line.qty} / {line.qtyReceived}</p>
</div>
<div>
<p className="text-muted-foreground">Unit price / Tax</p>
<p className="font-medium text-foreground">{Number(line.unitPrice).toFixed(2)} / {(Number(line.tax) * 100).toFixed(0)}%</p>
<p className="text-muted-foreground">{showTax ? "Unit price / Tax" : "Unit price"}</p>
<p className="font-medium text-foreground">
{Number(line.unitPrice).toFixed(2)}
{showTax ? ` / ${line.taxPct}% (${formatAmount(line.taxAmount)})` : ""}
</p>
</div>
</div>
</div>
@@ -485,7 +539,7 @@ export default function PurchaseOrderDetailPage() {
<div className="flex items-start gap-2">
<div className="flex-1">
<FieldLabel className="text-sm">Item</FieldLabel>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<Select<number | null> value={line.itemId} onValueChange={(v) => handleItemChange(line.key, v)}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
@@ -569,20 +623,31 @@ export default function PurchaseOrderDetailPage() {
</div>
</div>
<div>
<FieldLabel className="text-sm">Tax</FieldLabel>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={line.tax}
aria-invalid={!!errors.tax}
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
className="h-11 w-full text-base sm:w-32"
/>
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
</div>
{showTax && (
<div className="grid grid-cols-2 gap-3">
<div>
<FieldLabel className="text-sm">Tax %</FieldLabel>
<Input
type="number"
min="0"
max="100"
step="any"
inputMode="decimal"
value={line.taxPct}
aria-invalid={!!errors.tax}
onChange={(e) => updateLine(line.key, { taxPct: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
</div>
<div>
<FieldLabel className="text-sm">Tax amount</FieldLabel>
<div className="flex h-11 items-center text-base text-muted-foreground">
{formatAmount(lineTaxAmount(Number(line.qty), Number(line.unitPrice), Number(line.taxPct)))}
</div>
</div>
</div>
)}
</div>
)
})}
@@ -599,7 +664,8 @@ export default function PurchaseOrderDetailPage() {
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
<TableHead className="h-12 w-20 px-3 text-sm">Tax</TableHead>
{showTax && <TableHead className="h-12 w-24 px-3 text-sm">Tax %</TableHead>}
{showTax && <TableHead className="h-12 w-28 px-3 text-sm">Tax amount</TableHead>}
{editable && <TableHead className="h-12 w-10 px-3" />}
</TableRow>
</TableHeader>
@@ -616,14 +682,15 @@ export default function PurchaseOrderDetailPage() {
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">{line.qtyReceived}</TableCell>
<TableCell className="px-3 py-3.5">{Number(line.unitPrice).toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{(Number(line.tax) * 100).toFixed(0)}%</TableCell>
{showTax && <TableCell className="px-3 py-3.5">{line.taxPct}%</TableCell>}
{showTax && <TableCell className="px-3 py-3.5">{formatAmount(line.taxAmount)}</TableCell>}
</TableRow>
)
}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<Select<number | null> value={line.itemId} onValueChange={(v) => handleItemChange(line.key, v)}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
@@ -682,18 +749,26 @@ export default function PurchaseOrderDetailPage() {
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="0.01"
value={line.tax}
aria-invalid={!!errors.tax}
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
</TableCell>
{showTax && (
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="any"
value={line.taxPct}
aria-invalid={!!errors.tax}
onChange={(e) => updateLine(line.key, { taxPct: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
</TableCell>
)}
{showTax && (
<TableCell className="px-3 py-3.5 align-top">
{formatAmount(lineTaxAmount(Number(line.qty), Number(line.unitPrice), Number(line.taxPct)))}
</TableCell>
)}
<TableCell className="px-3 py-3 align-top">
<Button
type="button"
@@ -713,6 +788,23 @@ export default function PurchaseOrderDetailPage() {
</TableBody>
</Table>
</div>
<div className="ml-auto flex w-full flex-col gap-1.5 rounded-xl border p-4 text-base sm:w-80">
<div className="flex justify-between">
<span className="text-muted-foreground">Sub total</span>
<span className="font-medium text-foreground">{formatAmount(totals.subTotal)}</span>
</div>
{showTax && (
<div className="flex justify-between">
<span className="text-muted-foreground">Tax</span>
<span className="font-medium text-foreground">{formatAmount(totals.tax)}</span>
</div>
)}
<div className="flex justify-between border-t pt-1.5">
<span className="font-semibold text-foreground">Total ({po.totals.currency})</span>
<span className="font-semibold text-foreground">{formatAmount(totals.subTotal + totals.tax)}</span>
</div>
</div>
</div>
{editable && (
@@ -1,16 +1,13 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Check, CheckCircle2, Clock, ShieldAlert, X } from "lucide-react"
import { Check, CheckCircle2, Eye, ShieldAlert, X } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PurchaseOrderSummary } from "@/types/procurement"
import { Vendor } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -23,51 +20,31 @@ import {
} from "@/components/ui/dialog"
import { Field, 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"
import { PoStatusBadge } from "@/components/procurement/status-badges"
import { PurchaseOrderListView } from "@/components/procurement/PurchaseOrderListView"
/**
* Approvals Queue — every PO currently PendingApproval, with Approve/Reject actions
* right here instead of having to open each PO individually. Approve/Reject are
* server-gated to Admins who aren't the PO's own creator (FR-PROC-04); this screen
* still shows the queue to non-admins (read-only) so anyone can see what's waiting.
* Approval Queue — every PO currently PendingApproval, in the same table as Purchase Orders (vendor details,
* created date, search and date filter) with Approve / Reject right on each row instead of opening each PO.
* Approve/Reject need the Approve permission on Procurement (an administrator always has it), and nobody can approve
* their own PO (FR-PROC-04). Anyone else still sees the queue read-only, so everyone can see what's waiting.
*/
export default function PurchaseOrderApprovalsPage() {
const router = useRouter()
const { isAdmin } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [error, setError] = useState<string | null>(null)
const { can } = useAuth()
const canApprove = can("procurement", "Approve")
const [total, setTotal] = useState<number | null>(null)
const [approvingId, setApprovingId] = useState<number | null>(null)
const [rejectTarget, setRejectTarget] = useState<PurchaseOrderSummary | null>(null)
const [rejectTarget, setRejectTarget] = useState<{ po: PurchaseOrderSummary; reload: () => void } | null>(null)
const [rejectReason, setRejectReason] = useState("")
const [rejectBusy, setRejectBusy] = useState(false)
function load() {
setError(null)
purchaseOrdersApi
.list({ status: "PendingApproval", pageSize: 200 })
.then((res) => setPos(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
}, [])
const vendorsById = useMemo(() => new Map(vendors.map((v) => [v.vendorId, v])), [vendors])
async function handleApprove(po: PurchaseOrderSummary) {
async function handleApprove(po: PurchaseOrderSummary, reload: () => void) {
setApprovingId(po.poId)
try {
const updated = await purchaseOrdersApi.approve(po.poId)
toast.success("Purchase order approved", updated.docNo)
setPos((prev) => prev?.filter((p) => p.poId !== po.poId) ?? prev)
reload()
} catch (err) {
toast.error("Could not approve purchase order", errorMessage(err))
} finally {
@@ -75,19 +52,16 @@ export default function PurchaseOrderApprovalsPage() {
}
}
function openReject(po: PurchaseOrderSummary) {
setRejectTarget(po)
setRejectReason("")
}
async function confirmReject() {
if (!rejectTarget) return
const { po, reload } = rejectTarget
setRejectBusy(true)
try {
const updated = await purchaseOrdersApi.reject(rejectTarget.poId, { reason: rejectReason.trim() || undefined })
toast.success("Purchase order rejected", `${updated.docNo} — back to Draft for revision.`)
setPos((prev) => prev?.filter((p) => p.poId !== rejectTarget.poId) ?? prev)
const updated = await purchaseOrdersApi.reject(po.poId, { reason: rejectReason.trim() || undefined })
toast.success("Purchase order rejected", `${updated.docNo} — marked Rejected - revise and resubmit it.`)
setRejectTarget(null)
setRejectReason("")
reload()
} catch (err) {
toast.error("Could not reject purchase order", errorMessage(err))
} finally {
@@ -95,18 +69,14 @@ export default function PurchaseOrderApprovalsPage() {
}
}
const loading = pos === null
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Approvals Queue</h1>
{pos && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{pos.length}
</span>
<h1 className="text-2xl font-bold text-foreground">Approval Queue</h1>
{total !== null && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">{total}</span>
)}
</div>
<p className="text-base text-muted-foreground">Purchase orders awaiting approval (FR-PROC-04).</p>
@@ -116,108 +86,65 @@ export default function PurchaseOrderApprovalsPage() {
</Link>
</div>
{!isAdmin && (
{!canApprove && (
<div className="flex items-center gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning">
<ShieldAlert className="size-5 shrink-0" />
You can see the queue, but only an Admin can approve or reject — and not their own PO.
You can see the queue, but your role does not have the Approve permission on Procurement — and nobody can approve their own PO.
</div>
)}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{loading && !error && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!loading && !error && pos.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<CheckCircle2 className="size-12 text-success" />
<p className="text-base text-muted-foreground">Nothing is waiting on approval right now.</p>
</div>
)}
{!loading && !error && pos.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
<TableHead className="h-12 px-3 text-sm">Submitted</TableHead>
<TableHead className="h-12 w-56 px-3 text-sm">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pos.map((po) => {
const vendor = vendorsById.get(po.vendorId)
return (
<TableRow
key={po.poId}
className="align-top cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{vendor?.name ?? vendor?.code ?? `#${po.vendorId}`}</TableCell>
<TableCell className="px-3 py-3.5">
<PoStatusBadge status={po.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
<div className="flex items-center gap-1.5">
<Clock className="size-3.5" />
{new Date(po.createdAt).toLocaleString()}
</div>
</TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
variant="success"
disabled={!isAdmin || approvingId === po.poId}
onClick={() => handleApprove(po)}
>
<Check className="size-4" />
{approvingId === po.poId ? "Approving…" : "Approve"}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
disabled={!isAdmin}
onClick={() => openReject(po)}
>
<X className="size-4" />
Reject
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
<PurchaseOrderListView
fixedStatus="PendingApproval"
actionsHeader="Action"
onTotalChange={setTotal}
emptyMessage={
<span className="flex flex-col items-center gap-3">
<CheckCircle2 className="size-10 text-success" />
Nothing is waiting on approval right now.
</span>
}
renderActions={(po, { reload }) => (
<>
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${po.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
<Button
type="button"
size="sm"
variant="success"
disabled={!canApprove || approvingId === po.poId}
onClick={() => handleApprove(po, reload)}
>
<Check className="size-4" />
{approvingId === po.poId ? "Approving…" : "Approve"}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
disabled={!canApprove}
onClick={() => {
setRejectReason("")
setRejectTarget({ po, reload })
}}
>
<X className="size-4" />
Reject
</Button>
</>
)}
/>
<Dialog open={!!rejectTarget} onOpenChange={(v) => { if (!v) setRejectTarget(null) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Reject {rejectTarget?.docNo}?</DialogTitle>
<DialogDescription>Sends it back to Draft so the requester can revise and resubmit.</DialogDescription>
<DialogTitle>Reject {rejectTarget?.po.docNo}?</DialogTitle>
<DialogDescription>Marks it Rejected so the requester can revise and resubmit it.</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor="po-reject-reason">Reason (optional)</FieldLabel>
@@ -1,9 +1,9 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ExternalLink, Plus, Trash2 } from "lucide-react"
import { ChevronDown, ExternalLink, Plus, Trash2 } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { requisitionsApi } from "@/lib/api/requisitions"
@@ -16,9 +16,20 @@ import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { validatePoLine } from "@/lib/validations/procurement"
import { formatAmount } from "@/lib/format"
import { lineTaxAmount, pctText, pctToRate } from "@/lib/po-tax"
import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import {
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
@@ -36,6 +47,7 @@ import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SuggestInput, SuggestOption } from "@/components/ui/suggest-input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
@@ -46,7 +58,10 @@ interface DraftLine {
warehouseId: number | null
qty: string
unitPrice: string
tax: string
/** Tax as a percent (18 = 18%) — prefilled from the item's tax rate, editable. Only used while PO tax is on. */
taxPct: string
/** True once the user types a tax %, so a later item change or reload of items never overwrites it. */
taxTouched?: boolean
}
let keySeq = 0
@@ -55,12 +70,55 @@ function newKey() {
return `poline-${keySeq}`
}
// Tax is still not entered at PO creation — it's captured at GRN receipt (with discount/VAT
// there) and stays off this form, though it remains on the payload since the backend line
// DTO still requires it. Unit price *is* entered here; a PO prefilled from an RFQ starts
// from its negotiated price (below) but stays editable.
// Tax is entered here only while Procurement configuration has "Apply tax on purchase orders" on:
// each line then starts from its item's tax rate and stays editable. With it off the field is hidden
// and every line is sent at 0 (the server rejects anything else). Unit price *is* entered here; a PO
// prefilled from an RFQ starts from its negotiated price (below) but stays editable.
function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
return { key: newKey(), itemId: null, warehouseId: null, qty: "", unitPrice: "0", taxPct: "0" }
}
function ItemSearchPicker({
item,
options,
onSelect,
}: {
item: ItemListItem | null
options: SuggestOption[]
onSelect: (itemId: number) => void
}) {
const [searching, setSearching] = useState(!item)
if (item && !searching) {
return (
<Button
type="button"
variant="outline"
className="h-11 w-full justify-between gap-2 text-left text-base font-normal"
onClick={() => setSearching(true)}
title={`${item.sku} — ${item.name}`}
>
<span className="truncate">{item.sku} — {item.name}</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
</Button>
)
}
return (
<SuggestInput
options={options}
onSelect={(value) => {
onSelect(Number(value))
setSearching(false)
}}
browsable
autoFocus
placeholder="Search item — SKU or name…"
emptyText="No items match."
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
aria-label="Search item"
/>
)
}
function NewPurchaseOrderContent() {
@@ -93,6 +151,8 @@ function NewPurchaseOrderContent() {
const [vTerms, setVTerms] = useState("")
const [vTaxReg, setVTaxReg] = useState("")
const [vCurrency, setVCurrency] = useState("LKR")
const { config: procConfig } = useProcurementConfig()
const [vOptional, setVOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [vErrors, setVErrors] = useState<Record<string, string>>({})
const [vSubmitting, setVSubmitting] = useState(false)
@@ -132,6 +192,7 @@ function NewPurchaseOrderContent() {
setVTerms("")
setVTaxReg("")
setVCurrency("LKR")
setVOptional(EMPTY_VENDOR_OPTIONAL)
setVErrors({})
}
@@ -139,6 +200,7 @@ function NewPurchaseOrderContent() {
const nextErrors: Record<string, string> = {}
if (!vName.trim()) nextErrors.name = "Vendor name is required"
if (!vCurrency.trim()) nextErrors.currency = "Currency is required"
Object.assign(nextErrors, validateVendorOptional(procConfig, vOptional))
setVErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
@@ -147,9 +209,9 @@ function NewPurchaseOrderContent() {
const result = await vendorsApi.create({
code: generatedVendorCode,
name: vName,
terms: vTerms || null,
taxReg: vTaxReg || null,
...vendorTermsTaxPayload(procConfig, vTerms, vTaxReg),
currency: vCurrency,
...vendorOptionalPayload(procConfig, vOptional),
})
await loadVendors()
setVendorId(result.data.vendorId)
@@ -178,7 +240,7 @@ function NewPurchaseOrderContent() {
warehouseId: null,
qty: String(l.qty),
unitPrice: "0",
tax: "0",
taxPct: "0",
})
)
)
@@ -203,7 +265,7 @@ function NewPurchaseOrderContent() {
warehouseId: null,
qty: String(l.qty),
unitPrice: cell ? String(cell.unitPrice) : "0",
tax: "0",
taxPct: "0",
}
})
)
@@ -244,6 +306,32 @@ function NewPurchaseOrderContent() {
return items?.find((i) => i.itemId === itemId) ?? null
}
// PO tax on: every line the user has not typed a tax % into follows its item's tax rate. Keyed on the
// item ids (not on `lines`), so typing in a line never re-triggers it. Covers picking an item, a
// requisition/RFQ prefill and the item list finishing its load.
const showTax = procConfig.poTaxEnabled
const lineItemIds = lines.map((l) => l.itemId ?? 0).join(",")
useEffect(() => {
if (!showTax || !items) return
setLines((prev) =>
prev.map((l) => (l.taxTouched ? l : { ...l, taxPct: pctText(items.find((i) => i.itemId === l.itemId)?.taxRatePercentage ?? 0) })),
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showTax, items, lineItemIds])
const itemOptions = useMemo<SuggestOption[]>(
() =>
(items ?? []).map((i) => {
const uom = (uoms ?? []).find((u) => u.uomId === i.baseUomId)?.name
return {
value: String(i.itemId),
label: `${i.sku} — ${i.name}`,
detail: uom ? `UOM: ${uom}` : undefined,
}
}),
[items, uoms]
)
async function handleSubmit(saveAsDraft: boolean) {
setHeaderError(null)
setSubmitError(null)
@@ -268,8 +356,13 @@ function NewPurchaseOrderContent() {
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
tax: line.tax,
tax: "0",
})
if (showTax) {
const pct = Number(line.taxPct)
if (line.taxPct.trim() === "" || Number.isNaN(pct) || pct < 0) errors.tax = "Tax % cannot be negative"
else if (pct > 100) errors.tax = "Tax cannot exceed 100%"
}
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
}
setLineErrors(nextLineErrors)
@@ -283,7 +376,7 @@ function NewPurchaseOrderContent() {
warehouseId: l.warehouseId as number,
qty: Number(l.qty),
unitPrice: Number(l.unitPrice),
tax: Number(l.tax),
tax: showTax ? pctToRate(Number(l.taxPct)) : 0,
}))
setSubmitting(true)
@@ -297,9 +390,9 @@ function NewPurchaseOrderContent() {
})
toast.success(
"Purchase order created",
saveAsDraft ? `${po.docNo} — saved as draft.` : `${po.docNo} — auto-approved (FR-PROC-04).`
saveAsDraft ? `${po.docNo} — saved as draft.` : `${po.docNo} — submitted for approval.`
)
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
router.push("/dashboard/procurement/purchase-orders")
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create purchase order", errorMessage(err))
@@ -310,12 +403,17 @@ function NewPurchaseOrderContent() {
const loading = !items || !uoms || !warehouses || !vendors || prefillLoading
const subTotal = lines.reduce((sum, l) => sum + (Number(l.qty) || 0) * (Number(l.unitPrice) || 0), 0)
const taxTotal = showTax
? lines.reduce((sum, l) => sum + lineTaxAmount(Number(l.qty), Number(l.unitPrice), Number(l.taxPct)), 0)
: 0
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">New Purchase Order</h1>
<p className="text-base text-muted-foreground">Auto-approved on creation; freely editable while open (FR-PROC-03..05).</p>
<p className="text-base text-muted-foreground">Save as a draft to keep editing, or create and submit it for approval (FR-PROC-03..05).</p>
</div>
</div>
@@ -369,14 +467,25 @@ function NewPurchaseOrderContent() {
<FieldLabel htmlFor="po-v-code">Code (auto-generated)</FieldLabel>
<Input id="po-v-code" value={generatedVendorCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
<Field>
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
</Field>
<Field>
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
{procConfig.vendorPaymentTermsEnabled && (
<Field>
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
</Field>
)}
{procConfig.vendorTaxRegEnabled && (
<Field>
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
)}
<VendorOptionalFields
config={procConfig}
values={vOptional}
onChange={(patch) => setVOptional((prev) => ({ ...prev, ...patch }))}
idPrefix="po-v"
errors={vErrors}
/>
<Field data-invalid={!!vErrors.currency}>
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
@@ -468,22 +577,13 @@ function NewPurchaseOrderContent() {
</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger
className="h-11! w-full text-base"
aria-invalid={!!errors.itemId}
title={item ? `${item.sku} — ${item.name}` : undefined}
>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div aria-invalid={!!errors.itemId}>
<ItemSearchPicker
item={item}
options={itemOptions}
onSelect={(itemId) => updateLine(line.key, { itemId })}
/>
</div>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
@@ -555,6 +655,32 @@ function NewPurchaseOrderContent() {
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</div>
</div>
{showTax && (
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Tax %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
inputMode="decimal"
value={line.taxPct}
aria-invalid={!!errors.tax}
onChange={(e) => updateLine(line.key, { taxPct: e.target.value, taxTouched: true })}
className="h-11 text-base"
/>
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
</div>
<div>
<Label className="text-sm">Tax amount</Label>
<div className="flex h-11 items-center text-base text-muted-foreground">
{formatAmount(lineTaxAmount(Number(line.qty), Number(line.unitPrice), Number(line.taxPct)))}
</div>
</div>
</div>
)}
</div>
)
})}
@@ -570,6 +696,8 @@ function NewPurchaseOrderContent() {
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
{showTax && <TableHead className="h-12 w-24 px-3 text-sm">Tax %</TableHead>}
{showTax && <TableHead className="h-12 w-28 px-3 text-sm">Tax amount</TableHead>}
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
@@ -584,22 +712,13 @@ function NewPurchaseOrderContent() {
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger
className="h-11! w-full text-base"
aria-invalid={!!errors.itemId}
title={item ? `${item.sku} — ${item.name}` : undefined}
>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div aria-invalid={!!errors.itemId}>
<ItemSearchPicker
item={item}
options={itemOptions}
onSelect={(itemId) => updateLine(line.key, { itemId })}
/>
</div>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
@@ -648,6 +767,26 @@ function NewPurchaseOrderContent() {
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</TableCell>
{showTax && (
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="any"
value={line.taxPct}
aria-invalid={!!errors.tax}
onChange={(e) => updateLine(line.key, { taxPct: e.target.value, taxTouched: true })}
className="h-11 text-base"
/>
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
</TableCell>
)}
{showTax && (
<TableCell className="px-3 py-3.5 align-top">
{formatAmount(lineTaxAmount(Number(line.qty), Number(line.unitPrice), Number(line.taxPct)))}
</TableCell>
)}
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
@@ -663,6 +802,25 @@ function NewPurchaseOrderContent() {
)}
</div>
{lines.length > 0 && (
<div className="ml-auto flex w-full flex-col gap-1.5 rounded-xl border p-4 text-base sm:w-80">
<div className="flex justify-between">
<span className="text-muted-foreground">Sub total</span>
<span className="font-medium text-foreground">{formatAmount(subTotal)}</span>
</div>
{showTax && (
<div className="flex justify-between">
<span className="text-muted-foreground">Tax</span>
<span className="font-medium text-foreground">{formatAmount(taxTotal)}</span>
</div>
)}
<div className="flex justify-between border-t pt-1.5">
<span className="font-semibold text-foreground">Total</span>
<span className="font-semibold text-foreground">{formatAmount(subTotal + taxTotal)}</span>
</div>
</div>
)}
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
@@ -1,111 +1,35 @@
"use client"
import { useEffect, useState } from "react"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
import { Clock, Download, Eye, Pencil, Plus, Send, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
import { Vendor } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { PaginationMeta } from "@/types/common"
import { downloadPurchaseOrderPdfById } from "@/lib/pdf/purchase-order-download"
import { PurchaseOrderSummary } from "@/types/procurement"
import { cn } from "@/lib/utils"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { useAuth } from "@/components/auth/AuthProvider"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { KpiRow } from "@/components/ui/kpi-row"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All"
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
import { PurchaseOrderListView } from "@/components/procurement/PurchaseOrderListView"
export default function PurchaseOrdersListPage() {
const router = useRouter()
const stats = useDashboardStats()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
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 [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const { config: procConfig } = useProcurementConfig()
const [deletingId, setDeletingId] = useState<number | null>(null)
const [submittingId, setSubmittingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [pdfId, setPdfId] = useState<number | null>(null)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status])
function load() {
setError(null)
purchaseOrdersApi
.list({ page, pageSize, q: query || undefined, status: status === "All" ? undefined : status })
.then((res) => {
setPos(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query, status])
useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
}, [])
// Admins bypass branch scoping server-side and see every PO unfiltered — the header's
// branch switcher is the only filter available here, applied client-side since
// /purchase-orders has no branchId query param (docs/BRANCH_ISOLATION_PLAN.md §Frontend impact).
useEffect(() => {
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled])
function vendorCode(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
}
function branchName(branchId: number) {
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
}
const visiblePos = isAdmin && branchingEnabled && activeBranchId ? (pos ?? []).filter((po) => po.branchId === activeBranchId) : pos
async function handleDelete(po: PurchaseOrderSummary) {
async function handleDelete(po: PurchaseOrderSummary, reload: () => void) {
setDeletingId(po.poId)
try {
await purchaseOrdersApi.remove(po.poId)
toast.success("Draft deleted", po.docNo)
load()
reload()
} catch (err) {
toast.error("Could not delete purchase order", errorMessage(err))
} finally {
@@ -113,12 +37,12 @@ export default function PurchaseOrdersListPage() {
}
}
async function handleSubmit(po: PurchaseOrderSummary) {
async function handleSubmit(po: PurchaseOrderSummary, reload: () => void) {
setSubmittingId(po.poId)
try {
const updated = await purchaseOrdersApi.submit(po.poId)
toast.success("Submitted for approval", `${updated.docNo} — now ${updated.status}.`)
load()
reload()
} catch (err) {
toast.error("Could not submit purchase order", errorMessage(err))
} finally {
@@ -126,7 +50,16 @@ export default function PurchaseOrdersListPage() {
}
}
const hasFilters = query.length > 0 || status !== "All"
async function handleDownloadPdf(po: PurchaseOrderSummary) {
setPdfId(po.poId)
try {
await downloadPurchaseOrderPdfById(po.poId, procConfig)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setPdfId(null)
}
}
return (
<div className="flex flex-col gap-6">
@@ -152,320 +85,96 @@ export default function PurchaseOrdersListPage() {
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no., vendor…"
className="h-12 w-full flex-1 basis-0 text-base"
aria-label="Search purchase orders"
/>
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
<SelectTrigger className="h-12! w-full sm:w-56 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
<SelectItem value="PendingApproval" className="text-base">Pending approval</SelectItem>
<SelectItem value="Approved" className="text-base">Approved</SelectItem>
<SelectItem value="PartiallyReceived" className="text-base">Partially received</SelectItem>
<SelectItem value="FullyReceived" className="text-base">Fully received</SelectItem>
<SelectItem value="Closed" className="text-base">Closed</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
{hasFilters && (
<Button type="button" variant="ghost" onClick={() => { setSearchInput(""); setStatus("All") }}>
Clear filters
</Button>
)}
</div>
{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 && visiblePos !== null && visiblePos.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/filter." : "No purchase orders yet."}</p>
</div>
)}
{!error && visiblePos !== null && visiblePos.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{visiblePos.map((po) => {
const editable = isPoEditable(po.status)
return (
<div
key={po.poId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
<PurchaseOrderListView
renderActions={(po, { reload }) => {
const editable = isPoEditable(po.status)
return (
<>
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${po.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
{editable && (
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit draft ${po.docNo}`}
title="Edit draft"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{po.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
</div>
<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">Grand total</p>
<p className="font-medium text-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(po.createdAt).toLocaleDateString()}</p>
</div>
{isAdmin && branchingEnabled && (
<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>
{editable && (
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="Edit draft"
title="Edit draft"
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Submit for approval"
title="Submit for approval"
disabled={submittingId === po.poId || deletingId === po.poId}
className="text-success hover:bg-success/10 hover:text-success"
/>
}
>
<Send className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="success"
title={`Submit ${po.docNo} for approval?`}
description="It will be locked for editing while it awaits an Admin's approval."
confirmLabel="Submit"
onConfirm={() => handleSubmit(po)}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Delete draft"
title="Delete draft"
disabled={deletingId === po.poId || submittingId === po.poId}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete draft ${po.docNo}?`}
description="This cannot be undone."
confirmLabel="Delete"
onConfirm={() => handleDelete(po)}
/>
</AlertDialog>
</div>
)}
</div>
)
})}
{pagination && pos !== null && pos.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
<Pencil className="size-4" />
</Link>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Download PDF for ${po.docNo}`}
title="Download PDF"
disabled={pdfId === po.poId}
onClick={() => handleDownloadPdf(po)}
>
<Download className="size-4" />
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Doc No</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Status</TableHead>
<TableHead>Grand total</TableHead>
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
<TableHead>Created</TableHead>
<TableHead className="w-44 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visiblePos.map((po) => {
const editable = isPoEditable(po.status)
return (
<TableRow
key={po.poId}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
>
<TableCell>
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-semibold text-foreground hover:underline">
{po.docNo}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</TableCell>
<TableCell>
<PoStatusBadge status={po.status} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
{isAdmin && branchingEnabled && (
<TableCell>
<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="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="View purchase order"
title="View"
>
<Eye className="size-4" />
</Link>
{editable && (
<>
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="Edit draft"
title="Edit draft"
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Submit for approval"
title="Submit for approval"
disabled={submittingId === po.poId || deletingId === po.poId}
className="text-success hover:bg-success/10 hover:text-success"
/>
}
>
<Send className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="success"
title={`Submit ${po.docNo} for approval?`}
description="It will be locked for editing while it awaits an Admin's approval."
confirmLabel="Submit"
onConfirm={() => handleSubmit(po)}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Delete draft"
title="Delete draft"
disabled={deletingId === po.poId || submittingId === po.poId}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete draft ${po.docNo}?`}
description="This cannot be undone."
confirmLabel="Delete"
onConfirm={() => handleDelete(po)}
/>
</AlertDialog>
</>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)}
{editable && (
<>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Submit for approval"
title="Submit for approval"
disabled={submittingId === po.poId || deletingId === po.poId}
className="text-success hover:bg-success/10 hover:text-success"
/>
}
>
<Send className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="success"
title={`Submit ${po.docNo} for approval?`}
description="It will be locked for editing while it awaits an Admin's approval."
confirmLabel="Submit"
onConfirm={() => handleSubmit(po, reload)}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Delete draft"
title="Delete draft"
disabled={deletingId === po.poId || submittingId === po.poId}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete draft ${po.docNo}?`}
description="This cannot be undone."
confirmLabel="Delete"
onConfirm={() => handleDelete(po, reload)}
/>
</AlertDialog>
</>
)}
</>
)
}}
/>
</div>
)
}
@@ -0,0 +1,7 @@
"use client"
import ItemDetailRoute from "../page"
export default function EditItemRoute() {
return <ItemDetailRoute />
}
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation"
import { useParams, usePathname, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
@@ -9,6 +9,7 @@ import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { productConfig } from "@/lib/api/product-config"
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
import { uomsApi } from "@/lib/api/uoms"
import { taxRatesApi } from "@/lib/api/tax-rates"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -16,7 +17,7 @@ import { stockApi } from "@/lib/api/stock"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, ItemReorderSetting, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { Item, ItemReorderSetting, MeasureUnit, ProductConfig, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { LedgerEntry } from "@/types/stock"
import { ItemPriceHistory } from "@/types/approvals"
@@ -28,7 +29,6 @@ const SALES_DOC_TYPES = new Set(["SI", "SSL", "BND"])
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
@@ -40,6 +40,7 @@ import LineChart from "@/components/ui/line-chart"
import BarChart from "@/components/ui/bar-chart"
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
import { WarrantyFields } from "@/components/products/WarrantyFields"
/**
* The Edit dialog's simple low-stock alert, read back from the item's reorder rules: one level and
@@ -88,7 +89,9 @@ function StatChip({ icon: Icon, label, value }: { icon: typeof Package; label: s
)
}
export default function ItemDetailPage() {
function ItemDetailPage() {
const router = useRouter()
const editMode = usePathname().endsWith("/edit")
const params = useParams<{ id: string }>()
const itemId = Number(params.id)
@@ -120,7 +123,7 @@ export default function ItemDetailPage() {
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
// Not editable here — carried through unchanged so a save doesn't silently reset it.
// Warranty: edited in the Warranty section below (options follow Product configuration).
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
const [taxRateId, setTaxRateId] = useState<number | null>(null)
@@ -145,8 +148,11 @@ export default function ItemDetailPage() {
const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [imagesEnabled, setImagesEnabled] = useState(false)
// Product configuration drives which edit fields exist: selling prices and warranty only when switched on.
const [config, setConfig] = useState<ProductConfig | null>(null)
const imagesEnabled = config?.itemImagesEnabled ?? false
// Active rows of the Warranty Periods master (months), offered when custom warranty periods are on.
const [customWarrantyMonths, setCustomWarrantyMonths] = useState<number[]>([])
const [imagesVersion, setImagesVersion] = useState(0)
function applyItem(data: Item) {
@@ -199,7 +205,8 @@ export default function ItemDetailPage() {
setTaxRates(tr.items)
})
.catch(() => {})
productConfig().then((cfg) => setImagesEnabled(cfg.itemImagesEnabled)).catch(() => {})
productConfig().then(setConfig).catch(() => {})
warrantyPeriodsApi.list({ status: "Active" }).then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months))).catch(() => {})
// Locked unless Settings → Approvals turned price approval off; the server enforces it either way.
approvalsApi.getConfig().then((c) => setPricesLocked(c.priceAdjustmentLevels > 0)).catch(() => {})
priceAdjustmentsApi.itemHistory(itemId).then(setPriceHistory).catch(() => setPriceHistory([]))
@@ -283,25 +290,17 @@ export default function ItemDetailPage() {
)
const currentStockQty = stockTrend.length > 0 ? stockTrend[stockTrend.length - 1].qty : 0
/** Opens the edit dialog fresh from the last-loaded item, discarding any unsaved
* edits left over from a previous cancelled session. */
function openEdit() {
if (item) applyItem(item)
setErrors({})
setSaveError(null)
setEditOpen(true)
}
function closeEdit() {
if (item) applyItem(item)
setEditOpen(false)
setImagesVersion((v) => v + 1)
router.push(`/dashboard/products/${itemId}/edit`)
}
async function handleSave() {
if (!item || !etag) return
setSaveError(null)
const nextErrors: Record<string, string> = validateItemForm({ sku, name, categoryId, baseUomId, contentQty, contentUnit })
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period"
}
if (lowStockAt.trim()) {
const point = Number(lowStockAt)
const qty = Number(reorderQty)
@@ -350,16 +349,15 @@ export default function ItemDetailPage() {
}
applyItem(saved)
setEtag(result.etag)
setEditOpen(false)
setImagesVersion((v) => v + 1)
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
if (editMode) router.push(`/dashboard/products/${item.itemId}`)
} catch (err) {
const code = (err as { code?: string })?.code
if (code === "CONCURRENCY_CONFLICT") {
setConflict(true)
setSaveError(errorMessage(err))
setSaving(false)
setEditOpen(false)
return
}
const fe = fieldErrors(err)
@@ -417,6 +415,177 @@ export default function ItemDetailPage() {
)
}
if (editMode) {
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<Link
href={`/dashboard/products/${item.itemId}`}
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
Back to item
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Edit {item.sku}</h1>
<p className="text-base text-muted-foreground">Update this item&apos;s basic info, pricing and classification.</p>
</div>
{conflict && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
<div className="flex flex-col gap-2">
<p>{saveError ?? "This item was changed by someone else."} Reload before retrying.</p>
<Button size="sm" variant="outline" onClick={load}>Reload</Button>
</div>
</div>
)}
{saveError && !conflict && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)}
<div className="flex flex-col gap-6 rounded-xl border bg-card p-5 sm:p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label>
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Barcode (optional)</Label>
<Input value={barcode} onChange={(e) => setBarcode(e.target.value)} placeholder="Scan or type a barcode" aria-invalid={!!errors.barcode} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.barcode ? { message: errors.barcode } : undefined]} />
<p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
{config?.sellingPricesEnabled && (
<>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling min price</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMinPrice} onChange={(e) => setSellingMinPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling max price</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMaxPrice} onChange={(e) => setSellingMaxPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling flat price (fixed value)</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingFlatPrice} onChange={(e) => setSellingFlatPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
{pricesLocked && (
<p className="text-sm text-muted-foreground">
Selling prices need approval (Settings → Approvals).{" "}
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
Request a price change
</Link>
</p>
)}
</div>
</>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Tax rate</Label>
<Select<string> value={taxRateId === null ? NO_TAX_RATE : String(taxRateId)} onValueChange={(v) => setTaxRateId(!v || v === NO_TAX_RATE ? null : Number(v))} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="No default (0%)" /></SelectTrigger>
<SelectContent>
<SelectItem value={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem>
{taxRates.map((t) => <SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">{t.name} ({t.percentage}%)</SelectItem>)}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Sales/purchase lines for this item default to this rate — see Products → Tax Rates.</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict} items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}><SelectValue placeholder="Select category" /></SelectTrigger>
<SelectContent>{categories.map((c) => <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">{c.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label>
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict} items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}><SelectValue placeholder="Select base UOM" /></SelectTrigger>
<SelectContent>{uoms.map((u) => <SelectItem key={u.uomId} value={u.uomId} className="text-base">{u.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Content size</Label>
<div className="flex gap-2">
<Input type="number" min="0" step="any" value={contentQty} disabled={conflict} onChange={(e) => setContentQty(e.target.value)} placeholder="e.g. 500" aria-invalid={!!errors.contentQty} className="h-12! text-base" />
<Select<MeasureUnit | null> value={contentUnit} onValueChange={setContentUnit} disabled={conflict} items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}>
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}><SelectValue placeholder="Unit" /></SelectTrigger>
<SelectContent>{CONTENT_UNITS.map((u) => <SelectItem key={u} value={u} className="text-base">{u.toLowerCase()}</SelectItem>)}</SelectContent>
</Select>
</div>
<FieldError errors={[errors.contentQty ? { message: errors.contentQty } : undefined, errors.contentUnit ? { message: errors.contentUnit } : undefined]} />
<p className="text-sm text-muted-foreground">
{item.contentBaseQty !== null && item.contentBaseUnit ? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.` : "No content size — leave blank for items with nothing measurable to hold."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem>
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem>
<SelectItem value="Service" className="text-base">Service</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Low stock alert (optional)</Label>
<div className="flex gap-2">
<Input type="number" min="0" step="any" inputMode="decimal" value={lowStockAt} onChange={(e) => setLowStockAt(e.target.value)} placeholder="Alert when stock ≤" aria-label="Alert when stock is at or below" aria-invalid={!!errors.lowStockAt} className="h-12! text-base" disabled={conflict} />
<Input type="number" min="0" step="any" inputMode="decimal" value={reorderQty} onChange={(e) => setReorderQty(e.target.value)} placeholder="Reorder qty" aria-label="Reorder quantity" aria-invalid={!!errors.reorderQty} className="h-12! text-base" disabled={conflict} />
</div>
<FieldError errors={[errors.lowStockAt ? { message: errors.lowStockAt } : undefined, errors.reorderQty ? { message: errors.reorderQty } : undefined]} />
<p className="text-sm text-muted-foreground">
{alertIsCustom ? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level." : lowStockAt.trim() ? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.` : "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId} disabled={conflict} items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}>
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="Every warehouse" /></SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
{warehouses.map((w) => <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.code} — {w.name}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock.</p>
</div>
</div>
{config?.warrantyPeriodsEnabled && (
<WarrantyFields
config={config}
customMonths={customWarrantyMonths}
warranty={warranty}
months={warrantyPeriodMonths}
onChange={(w, m) => {
setWarranty(w)
setWarrantyPeriodMonths(m)
setErrors((prev) => ({ ...prev, warrantyPeriodMonths: "" }))
}}
error={errors.warrantyPeriodMonths || undefined}
disabled={conflict}
/>
)}
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" size="lg" onClick={() => router.push(`/dashboard/products/${item.itemId}`)} disabled={saving}>Cancel</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
</div>
)
}
return (
<div className="flex flex-col gap-6">
<Link
@@ -748,292 +917,7 @@ export default function ItemDetailPage() {
</div>
</div>
<Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Edit {item.sku}</DialogTitle>
<DialogDescription>Update this item&apos;s basic info, pricing and classification.</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label>
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Barcode (optional)</Label>
<Input
value={barcode}
onChange={(e) => setBarcode(e.target.value)}
placeholder="Scan or type a barcode"
aria-invalid={!!errors.barcode}
className="h-12 text-base"
disabled={conflict}
/>
<FieldError errors={[errors.barcode ? { message: errors.barcode } : undefined]} />
<p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling min price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMinPrice}
onChange={(e) => setSellingMinPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict || pricesLocked}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling max price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMaxPrice}
onChange={(e) => setSellingMaxPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict || pricesLocked}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingFlatPrice}
onChange={(e) => setSellingFlatPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict || pricesLocked}
/>
{pricesLocked && (
<p className="text-sm text-muted-foreground">
Selling prices need approval.{" "}
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
Request a price change
</Link>
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax rate</Label>
<Select<string>
value={taxRateId === null ? NO_TAX_RATE : String(taxRateId)}
onValueChange={(v) => setTaxRateId(!v || v === NO_TAX_RATE ? null : Number(v))}
disabled={conflict}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="No default (0%)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem>
{taxRates.map((t) => (
<SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">
{t.name} ({t.percentage}%)
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Sales/purchase lines for this item default to this rate — see Products → Tax Rates.
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null>
value={categoryId}
onValueChange={setCategoryId}
disabled={conflict}
items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}
>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label>
<Select<number | null>
value={baseUomId}
onValueChange={setBaseUomId}
disabled={conflict}
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}>
<SelectValue placeholder="Select base UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
</div>
{/* Content size (FR-MD-02): how much one pack holds. Optional, and independent of the
base UOM above — stock is counted in packs either way. */}
<div className="flex flex-col gap-2">
<Label className="text-base">Content size</Label>
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
value={contentQty}
disabled={conflict}
onChange={(e) => setContentQty(e.target.value)}
placeholder="e.g. 500"
aria-invalid={!!errors.contentQty}
className="h-12! text-base"
/>
<Select<MeasureUnit | null>
value={contentUnit}
onValueChange={setContentUnit}
disabled={conflict}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}>
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{u.toLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FieldError
errors={[
errors.contentQty ? { message: errors.contentQty } : undefined,
errors.contentUnit ? { message: errors.contentUnit } : undefined,
]}
/>
{/* The stored value, so the ×1000 normalisation is never a surprise. */}
<p className="text-sm text-muted-foreground">
{item.contentBaseQty !== null && item.contentBaseUnit
? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.`
: "No content size — leave blank for items with nothing measurable to hold."}
</p>
</div>
<div className="flex flex-col gap-2">
{/* "Item type" now means a Color/Size dimension master — this field is the
stock-nature one it used to be confused with (docs/11 §8). */}
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem>
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem>
<SelectItem value="Service" className="text-base">Service</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Low stock alert (optional)</Label>
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={lowStockAt}
onChange={(e) => setLowStockAt(e.target.value)}
placeholder="Alert when stock ≤"
aria-label="Alert when stock is at or below"
aria-invalid={!!errors.lowStockAt}
className="h-12! text-base"
disabled={conflict}
/>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={reorderQty}
onChange={(e) => setReorderQty(e.target.value)}
placeholder="Reorder qty"
aria-label="Reorder quantity"
aria-invalid={!!errors.reorderQty}
className="h-12! text-base"
disabled={conflict}
/>
</div>
<FieldError
errors={[
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
errors.reorderQty ? { message: errors.reorderQty } : undefined,
]}
/>
<p className="text-sm text-muted-foreground">
{alertIsCustom
? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level."
: lowStockAt.trim()
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.`
: "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null>
value={warehouseId}
onValueChange={setWarehouseId}
disabled={conflict}
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Every warehouse" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock.</p>
</div>
</div>
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
<DialogFooter>
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Cancel
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<p className="text-sm text-muted-foreground">
{uomName(item.baseUomId)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03).
@@ -1042,3 +926,7 @@ export default function ItemDetailPage() {
</div>
)
}
export default function ItemDetailRoute() {
return <ItemDetailPage />
}
@@ -208,7 +208,7 @@ export default function NewItemPage() {
brandsApi.list({ pageSize: 200, status: "Active" }),
itemTypesApi.list({ pageSize: 200, status: "Active" }),
productConfig(),
uomsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200, status: "Active" }),
warehousesApi.list({ pageSize: 200 }),
])
.then(([cat, br, types, cfg, uoms, wh]) => {
@@ -579,7 +579,7 @@ export default function ItemsPage() {
</div>
<Link
href={`/dashboard/products/${item.itemId}`}
href={`/dashboard/products/${item.itemId}/edit`}
data-no-row-nav
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "self-end")}
aria-label={`Edit ${item.sku}`}
@@ -689,7 +689,7 @@ export default function ItemsPage() {
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/products/${item.itemId}`}
href={`/dashboard/products/${item.itemId}/edit`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${item.sku}`}
title="Edit"
@@ -3,9 +3,9 @@
import { Suspense, useEffect, useState } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { ArrowLeft, ArrowRight, Send } from "lucide-react"
import { ArrowLeft, ArrowRight, Check, Send } from "lucide-react"
import { priceAdjustmentsApi } from "@/lib/api/approvals"
import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
@@ -57,6 +57,12 @@ function NewPriceAdjustmentForm() {
const [reason, setReason] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
// Settings → Approvals: 0 means no approval — the change is typed, confirmed and applied at once.
const [approvalLevels, setApprovalLevels] = useState<number | null>(null)
const appliesImmediately = approvalLevels === 0
useEffect(() => {
approvalsApi.getConfig().then((c) => setApprovalLevels(c.priceAdjustmentLevels)).catch(() => setApprovalLevels(null))
}, [])
// The item's latest GRN purchase prices, to price against (null while loading).
const [purchases, setPurchases] = useState<ItemPurchasePrice[] | null>(null)
@@ -322,14 +328,20 @@ function NewPriceAdjustmentForm() {
</Field>
</Step>
{/* 5. Send for approval */}
{/* 5. Send for approval, or confirm and apply when approval is switched off */}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">Approvers are notified. The price changes only after approval.</p>
<p className="text-sm text-muted-foreground">
{appliesImmediately
? "Approval is switched off in Settings → Approvals, so the new price is applied as soon as you confirm."
: "Approvers are notified. The price changes only after approval."}
</p>
<div className="flex gap-2">
<Link href="/dashboard/products/price-adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Cancel</Link>
<Button size="lg" onClick={submit} disabled={!item || saving}>
<Send className="size-4" />
{saving ? "Sending…" : "Send for approval"}
{appliesImmediately ? <Check className="size-4" /> : <Send className="size-4" />}
{saving
? appliesImmediately ? "Applying…" : "Sending…"
: appliesImmediately ? "Confirm and apply" : "Send for approval"}
</Button>
</div>
</div>
@@ -236,6 +236,8 @@ export default function NewGrnPage() {
}
const [vendorId, setVendorId] = useState<number | null>(null)
const [poId, setPoId] = useState<number | null>(null)
// A GRN against a purchase order belongs to that order's branch - fixed when the PO was raised - so it is never asked again.
const [poBranchId, setPoBranchId] = useState<number | null>(null)
const [poLoading, setPoLoading] = useState(false)
const [lines, setLines] = useState<DraftLine[]>([])
const [lineTab, setLineTab] = useState<LineTab>("lines")
@@ -265,7 +267,7 @@ export default function NewGrnPage() {
const [headerError, setHeaderError] = useState<string | null>(null)
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [submitting, setSubmitting] = useState<"draft" | "confirm" | null>(null)
const [refreshingItems, setRefreshingItems] = useState(false)
const [scanError, setScanError] = useState<string | null>(null)
const [scanning, setScanning] = useState(false)
@@ -318,7 +320,11 @@ export default function NewGrnPage() {
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
// The branch this GRN is being raised for — no longer used to filter warehouses
// (warehouses are shared infrastructure across branches), only to scope the document itself.
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
const effectiveBranchId = !branchingEnabled
? null
: mode === "po"
? poBranchId
: isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId
const visibleWarehouses = warehouses ?? []
// Each keeps its inline message (it marks the field) and mirrors it as a toast so no error goes unseen.
@@ -334,7 +340,7 @@ export default function NewGrnPage() {
function failSubmit(message: string) {
setSubmitError(message)
toast.error("Could not create GRN", message)
toast.error("Could not save the GRN", message)
}
function failWarranty(lineKey: string, message: string) {
@@ -345,6 +351,7 @@ export default function NewGrnPage() {
function switchMode(next: Mode) {
setMode(next)
setPoId(null)
setPoBranchId(null)
setVendorId(null)
setLines([])
setHeaderDiscountPct("")
@@ -355,6 +362,7 @@ export default function NewGrnPage() {
async function handleSelectPo(nextPoId: number | null) {
setPoId(nextPoId)
setPoBranchId(null)
if (!nextPoId) {
setLines([])
return
@@ -364,6 +372,7 @@ export default function NewGrnPage() {
setHeaderError(null)
try {
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
setPoBranchId(po.branchId)
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
if (openLines.length === 0) {
failHeader("This purchase order has no open (unreceived) lines.")
@@ -647,7 +656,7 @@ export default function NewGrnPage() {
}
}
async function handleSubmit() {
async function handleSubmit(confirmNow: boolean) {
setSubmitError(null)
setHeaderError(null)
@@ -733,7 +742,7 @@ export default function NewGrnPage() {
}
})
setSubmitting(true)
setSubmitting(confirmNow ? "confirm" : "draft")
try {
const grn = await grnsApi.create({
poId: mode === "po" ? poId : null,
@@ -742,13 +751,26 @@ export default function NewGrnPage() {
branchId: effectiveBranchId ?? 0,
lines: payloadLines,
})
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
if (!confirmNow) {
toast.success("GRN saved as draft", `${grn.docNo} is waiting to be confirmed.`)
router.push("/dashboard/receiving/grn")
return
}
// Confirming posts stock and the ledger entry. If that part fails the GRN already exists as a draft, so say so
// and land on the table, where it can be confirmed again once the cause is fixed.
try {
const result = await grnsApi.confirm(grn.grnId, `grn-new-${grn.grnId}`)
toast.success("GRN confirmed", `${grn.docNo} — ${result.createdLayers.length} stock layer(s) posted. Journal ${result.glJournalNo}.`)
} catch (err) {
toast.error(`${grn.docNo} saved as draft, but could not be confirmed`, errorMessage(err))
}
router.push("/dashboard/receiving/grn")
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create GRN", errorMessage(err))
} finally {
setSubmitting(false)
setSubmitting(null)
}
}
@@ -759,7 +781,7 @@ export default function NewGrnPage() {
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">New GRN</h1>
<p className="text-base text-muted-foreground">Receive goods against a purchase order, or record a direct receipt (FR-GRN-01/02).</p>
<p className="text-base text-muted-foreground">Receive goods against a purchase order, or record a direct receipt (FR-GRN-01/02). Save it as a draft, or confirm it right away to post the stock.</p>
</div>
</div>
@@ -829,7 +851,18 @@ export default function NewGrnPage() {
</div>
)}
{branchingEnabled && isAdmin && (
{branchingEnabled && mode === "po" && (
<div className="flex flex-col gap-2">
<Label className="text-base">Branch</Label>
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{poBranchId
? (branches.find((b) => b.branchId === poBranchId)?.name ?? (poBranchId === activeBranchId ? activeBranchName : null) ?? `Branch #${poBranchId}`)
: "Taken from the purchase order"}
</div>
</div>
)}
{branchingEnabled && isAdmin && mode === "direct" && (
<div className="flex flex-col gap-2">
<Label className="text-base">Branch</Label>
{showBranchPicker ? (
@@ -1638,8 +1671,11 @@ export default function NewGrnPage() {
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</Link>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create GRN"}
<Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => handleSubmit(false)} disabled={submitting !== null}>
{submitting === "draft" ? "Saving…" : "Save as draft"}
</Button>
<Button size="lg" type="button" variant="success" className="w-full sm:w-auto" onClick={() => handleSubmit(true)} disabled={submitting !== null}>
{submitting === "confirm" ? "Confirming…" : "Confirm now"}
</Button>
</div>
)}
@@ -1,77 +1,115 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { ArrowDown, ArrowUp, ArrowUpDown, Check, Clock, Eye, PackageSearch, Plus, Printer, Truck } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { GrnStatus, GrnSummary } from "@/types/grn"
import { Vendor, Warehouse } from "@/types/master-data"
import { PaginationMeta } from "@/types/common"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { PaginationMeta } from "@/types/common"
import { GrnStatus, GrnSummary } from "@/types/grn"
import { Branch } from "@/types/hrm"
import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { GrnReceiptDialog } from "@/components/receiving/GrnReceiptPrint"
import { GrnStatusBadge } from "@/components/receiving/status-badges"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
import { Input } from "@/components/ui/input"
import { DateRangeFilter, DateRangeValue, dateRangeBounds } from "@/components/ui/date-range-filter"
import { FilterBar } from "@/components/ui/filter-bar"
import { KpiRow } from "@/components/ui/kpi-row"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { GrnStatusBadge } from "@/components/receiving/status-badges"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
type StatusFilter = GrnStatus | "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: "Confirmed", label: "Confirmed" },
{ value: "Closed", label: "Closed" },
]
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>
)
}
/**
* Goods Received Notes — the same table as Purchase Orders: a vendor-first row (name, code · contact, address — the
* contact and address only when switched on in Procurement configuration), created DATE, status, total, branch (admin
* with branching on), and actions. A Draft can be approved (confirmed) right from its row; any GRN can print its receipt.
*/
export default function GrnListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
const { config: procConfig } = useProcurementConfig()
const showContact = procConfig.vendorContactMode !== "None"
const showAddress = procConfig.vendorAddressEnabled
const showBranch = isAdmin && branchingEnabled
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
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 [approvingId, setApprovingId] = useState<number | null>(null)
const [receipt, setReceipt] = useState<GrnSummary | null>(null)
// One idempotency key per GRN per page session, so a double click or a retry can't post the stock twice.
const approveKeys = useRef(new Map<number, string>())
const approveSeq = useRef(0)
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(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
warehousesApi.list({ pageSize: 200 }).then((res) => setWarehouses(res.items)).catch(() => {})
}, [])
function vendorName(id: number) {
const vendor = vendors.find((v) => v.vendorId === id)
return vendor ? `${vendor.code} — ${vendor.name}` : `#${id}`
}
function warehouseName(id: number) {
const warehouse = warehouses.find((w) => w.warehouseId === id)
return warehouse ? `${warehouse.code} — ${warehouse.name}` : `#${id}`
}
useEffect(() => {
if (showBranch) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [showBranch])
// Debounce the search box so typing doesn't refetch on every keystroke.
useEffect(() => {
@@ -80,9 +118,7 @@ export default function GrnListPage() {
}, [searchInput])
// Any filter change starts back at page 1.
useEffect(() => {
setPage(1)
}, [query, status, activeBranchId])
useEffect(() => setPage(1), [query, status, activeBranchId, dateRange.preset, customFrom, customTo, sortKey, sortOrder])
function load() {
setError(null)
@@ -93,6 +129,9 @@ export default function GrnListPage() {
q: query || undefined,
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
from,
to,
sort: `${sortOrder === "desc" ? "-" : ""}${sortKey}`,
})
.then((res) => {
setGrns(res.items)
@@ -105,14 +144,142 @@ export default function GrnListPage() {
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
useEffect(load, [page, pageSize, query, status, activeBranchId, branchingEnabled, from, to, sortKey, sortOrder])
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")
}
}
useEffect(load, [page, pageSize, query, status, activeBranchId, branchingEnabled])
async function handleApprove(grn: GrnSummary) {
let key = approveKeys.current.get(grn.grnId)
if (!key) {
approveSeq.current += 1
key = `grn-list-${grn.grnId}-${approveSeq.current}`
approveKeys.current.set(grn.grnId, key)
}
setApprovingId(grn.grnId)
try {
const result = await grnsApi.confirm(grn.grnId, key)
toast.success("GRN approved", `${grn.docNo} — ${result.createdLayers.length} stock layer(s) posted. Journal ${result.glJournalNo}.`)
load()
} catch (err) {
// A failed attempt rolled back, so a later click is a fresh attempt.
approveKeys.current.delete(grn.grnId)
toast.error("Could not approve GRN", errorMessage(err))
} finally {
setApprovingId(null)
}
}
const hasFilters = query.length > 0 || status !== "All"
const warehouseName = (id: number) => {
const w = warehouses.find((x) => x.warehouseId === id)
return w ? `${w.code} — ${w.name}` : `#${id}`
}
const branchName = (id: number) => branches.find((b) => b.branchId === id)?.name ?? `#${id}`
const hasFilters = query.length > 0 || status !== "All" || dateRange.preset !== "All"
const open = (grnId: number) => router.push(`/dashboard/receiving/grn/${grnId}`)
function clearFilters() {
setSearchInput("")
setStatus("All")
setDateRange({ preset: "All" })
}
/** Vendor name, then "code · contact" and (when enabled) the address — the same lines in the table and the cards. */
function vendorBlock(grn: GrnSummary) {
const contact = procConfig.vendorContactMode === "Mobile" ? grn.vendor?.mobile : grn.vendor?.email
const sub = [grn.vendor?.code, showContact ? contact : ""].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">{grn.vendor?.name ?? `Vendor #${grn.vendorId}`}</p>
{sub && <p className="truncate text-xs text-muted-foreground">{sub}</p>}
{showAddress && grn.vendor?.address && (
<p className="max-w-64 truncate text-xs text-muted-foreground" title={grn.vendor.address}>{grn.vendor.address}</p>
)}
</div>
</div>
)
}
/** Where the goods came from: "PO-… · Warehouse" under the GRN number. */
const docBlock = (grn: GrnSummary) => (
<div>
<p className="font-semibold text-foreground">{grn.docNo}</p>
<p className="text-xs text-muted-foreground" data-no-row-nav>
{grn.poId ? (
<Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
{grn.poDocNo ?? `PO ${grn.poId}`}
</Link>
) : (
"Direct receipt"
)}
{" · "}
{warehouseName(grn.warehouseId)}
</p>
</div>
)
const invoiceCell = (grn: GrnSummary) =>
grn.status !== "Confirmed" ? (
<span className="text-muted-foreground">—</span>
) : grn.purchaseInvoiceId ? (
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
</Link>
) : (
<span className="text-muted-foreground">Not invoiced</span>
)
/** View · Receipt · Approve (Draft only). Clicks inside don't open the row. */
function actions(grn: GrnSummary) {
return (
<>
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${grn.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Receipt for ${grn.docNo}`}
title="Receipt"
onClick={() => setReceipt(grn)}
>
<Printer className="size-4" />
</Button>
{grn.status === "Draft" && (
<AlertDialog>
<AlertDialogTrigger render={<Button type="button" size="sm" variant="success" disabled={approvingId === grn.grnId} />}>
<Check className="size-4" />
{approvingId === grn.grnId ? "Approving…" : "Approve"}
</AlertDialogTrigger>
<AlertDialogContent
title={`Approve ${grn.docNo}?`}
description="This confirms the receipt: stock is added to the warehouse and the ledger entry is posted. A confirmed GRN can't be edited."
confirmLabel="Approve"
onConfirm={() => handleApprove(grn)}
/>
</AlertDialog>
)}
</>
)
}
const totalCell = (grn: GrnSummary) => <p className="font-medium text-foreground">{formatAmount(grn.totalValue)}</p>
return (
<div className="flex flex-col gap-6">
@@ -131,43 +298,32 @@ export default function GrnListPage() {
<KpiRow tiles={stats ? [{ label: "Pending GRNs", value: stats.pendingGrns, icon: Clock }] : null} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no., PO, vendor, warehouse…"
className="h-14 w-full pl-11 text-base"
aria-label="Search GRNs"
/>
<FilterBar
searchFirst
search={searchInput}
onSearchChange={setSearchInput}
searchPlaceholder="Search GRN no., PO, vendor…"
searchAriaLabel="Search GRNs"
>
<div className="flex flex-wrap items-center gap-2">
<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={clearFilters}>
Clear filters
</Button>
)}
</div>
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
<SelectItem value="Confirmed" className="text-base">Confirmed</SelectItem>
<SelectItem value="Closed" className="text-base">Closed</SelectItem>
</SelectContent>
</Select>
{hasFilters && (
<Button
type="button"
variant="ghost"
onClick={() => {
setSearchInput("")
setStatus("All")
}}
>
Clear filters
</Button>
)}
</div>
</FilterBar>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">
@@ -199,18 +355,10 @@ export default function GrnListPage() {
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<PackageSearch className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No GRNs match your search/filter." : "No GRNs yet. Create one from an approved purchase order."}
{hasFilters ? "No GRNs match your search or filters." : "No GRNs yet. Create one from an approved purchase order."}
</p>
{hasFilters ? (
<Button
type="button"
variant="outline"
size="lg"
onClick={() => {
setSearchInput("")
setStatus("All")
}}
>
<Button type="button" variant="outline" size="lg" onClick={clearFilters}>
Clear filters
</Button>
) : (
@@ -233,146 +381,116 @@ export default function GrnListPage() {
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
open(grn.grnId)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/receiving/grn/${grn.grnId}`)
if (e.key === "Enter" && !(e.target as HTMLElement).closest("[data-no-row-nav]")) open(grn.grnId)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{grn.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</p>
</div>
{vendorBlock(grn)}
<GrnStatusBadge status={grn.status} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">PO</p>
<p className="font-medium text-foreground" data-no-row-nav>
{grn.poId ? (
<Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
PO {String(grn.poId).padStart(2, "0")}
</Link>
) : (
"Direct"
)}
</p>
<div className="col-span-2">
<p className="text-muted-foreground">GRN</p>
{docBlock(grn)}
</div>
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{warehouseName(grn.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Invoice</p>
<p className="font-medium text-foreground" data-no-row-nav>
{grn.status !== "Confirmed" ? (
"—"
) : grn.purchaseInvoiceId ? (
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
</Link>
) : (
"Not invoiced"
)}
</p>
<p className="text-muted-foreground">Total</p>
{totalCell(grn)}
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(grn.createdAt).toLocaleDateString()}</p>
<p className="font-medium text-foreground">{formatReportDate(grn.createdAt)}</p>
</div>
<div>
<p className="text-muted-foreground">Invoice</p>
<p className="font-medium text-foreground" data-no-row-nav>{invoiceCell(grn)}</p>
</div>
{showBranch && (
<div>
<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(grn.branchId)}
</Badge>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
{actions(grn)}
</div>
</div>
))}
{pagination && grns.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
<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. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-40">Doc No</TableHead>
<TableHead>PO</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Status</TableHead>
<TableHead>Invoice</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-20 text-center">Actions</TableHead>
{/* Desktop: table, styled like the Purchase Orders 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">GRN</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="Total" active={sortKey === "total"} order={sortOrder} onClick={() => toggleSort("total")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
{showBranch && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{grns.map((grn) => (
<TableRow
key={grn.grnId}
className="cursor-pointer align-top"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
open(grn.grnId)
}}
>
<TableCell className="px-3 py-3.5">{vendorBlock(grn)}</TableCell>
<TableCell className="px-3 py-3.5">{docBlock(grn)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(grn.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5"><GrnStatusBadge status={grn.status} /></TableCell>
<TableCell className="px-3 py-3.5">{totalCell(grn)}</TableCell>
<TableCell className="px-3 py-3.5 text-sm" data-no-row-nav>{invoiceCell(grn)}</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(grn.branchId)}
</Badge>
</TableCell>
)}
<TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">{actions(grn)}</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{grns.map((grn) => {
return (
<TableRow
key={grn.grnId}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
}}
>
<TableCell>
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className="font-semibold text-foreground hover:underline"
>
{grn.docNo}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground" data-no-row-nav>
{grn.poId ? (
<Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
PO {String(grn.poId).padStart(2, "0")}
</Link>
) : (
<span>Direct</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{warehouseName(grn.warehouseId)}</TableCell>
<TableCell>
<GrnStatusBadge status={grn.status} />
</TableCell>
<TableCell data-no-row-nav className="text-sm text-muted-foreground">
{grn.status !== "Confirmed" ? (
<span>—</span>
) : grn.purchaseInvoiceId ? (
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
</Link>
) : (
<span>Not invoiced</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(grn.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${grn.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
))}
</TableBody>
</Table>
</div>
{pagination && (
@@ -387,6 +505,13 @@ export default function GrnListPage() {
)}
</>
)}
<GrnReceiptDialog
grnId={receipt?.grnId ?? null}
docNo={receipt?.docNo}
open={receipt !== null}
onOpenChange={(o) => { if (!o) setReceipt(null) }}
/>
</div>
)
}
@@ -1,7 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import { Suspense, useEffect, useState } from "react"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Hourglass, Wallet } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
@@ -73,6 +74,17 @@ const paymentFilters: { value: PaymentFilter; label: string }[] = [
]
export default function ApprovedInvoicesPage() {
// useSearchParams needs a Suspense boundary above it.
return (
<Suspense>
<ApprovedInvoices />
</Suspense>
)
}
function ApprovedInvoices() {
// The Sales list links here with ?q=<invoice no.> so the invoice is already searched for.
const initialQuery = useSearchParams().get("q")?.trim() ?? ""
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
// Approved (Posted) sales invoices with their own filters: a cashier ("All" shows every
@@ -82,8 +94,8 @@ export default function ApprovedInvoicesPage() {
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("All")
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All")
const [salesSearchInput, setSalesSearchInput] = useState("")
const [salesQuery, setSalesQuery] = useState("")
const [salesSearchInput, setSalesSearchInput] = useState(initialQuery)
const [salesQuery, setSalesQuery] = useState(initialQuery)
// Server-side paging — every filter above is applied by the API, so pages and counts are exact.
const [salesPage, setSalesPage] = useState(1)
const [salesPageSize, setSalesPageSize] = useState(SALES_PAGE_SIZE_OPTIONS[1])
@@ -11,10 +11,12 @@ import {
CheckCircle2,
CircleDollarSign,
FileText,
ExternalLink,
Loader2,
Plus,
Receipt,
Tag,
Wallet,
} from "lucide-react"
import { salesApi } from "@/lib/api/sales"
@@ -33,9 +35,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { PaginationMeta } from "@/types/common"
import { SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data"
import { cn } from "@/lib/utils"
@@ -61,6 +64,15 @@ function formatAmount(n: number) {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
type StatusFilter = "All" | SalesInvoiceStatus
const statusFilters: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All sales" },
{ value: "Draft", label: "Draft (pending)" },
{ value: "Posted", label: "Approved" },
{ value: "Cancelled", label: "Cancelled" },
]
type DateRange = "All" | "Today" | "7d" | "30d" | "Custom"
type CustomRange = { from: Date | undefined; to?: Date | undefined }
@@ -125,8 +137,13 @@ export default function SalesInvoicesPage() {
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [confirmingId, setConfirmingId] = useState<number | null>(null)
// This screen lists drafts only; the row, its invoice no. and the invoice icon all open the
// invoice page, where a draft is editable.
const [statusFilter, setStatusFilter] = useState<StatusFilter>("All")
// Bumped after an approval or a payment, so the page is re-read from the server.
const [reloadKey, setReloadKey] = useState(0)
const [payInvoice, setPayInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payOpen, setPayOpen] = useState(false)
// This screen lists every sale (drafts, approved, cancelled); the row and its invoice no. open the
// invoice page, where a draft is editable and an approved sale is read-only with its payments.
const router = useRouter()
function openDraft(row: SalesInvoiceSummary) {
@@ -151,7 +168,7 @@ export default function SalesInvoicesPage() {
setPage(1)
setPageSize(rowsPerPage)
setSelected(new Set())
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage])
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage, statusFilter])
useEffect(() => {
// Includes the cash customer so its (walk-in) invoices can be filtered too.
@@ -165,8 +182,9 @@ export default function SalesInvoicesPage() {
.listInvoices({
page,
pageSize,
// Drafts only — approved invoices are worked (and paid) from the Day End screen.
status: "Draft",
// Every sale by default; approved ones can be paid here or worked from Approved Invoices.
status: statusFilter === "All" ? undefined : statusFilter,
q: query || undefined,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
customerId,
warehouseId,
@@ -179,7 +197,7 @@ export default function SalesInvoicesPage() {
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, dateFrom, dateTo, activeBranchId, branchingEnabled, customerId, warehouseId])
}, [page, pageSize, dateFrom, dateTo, activeBranchId, branchingEnabled, customerId, warehouseId, statusFilter, query, reloadKey])
function handleShowMore() {
setShowMoreLoading(true)
@@ -190,13 +208,13 @@ export default function SalesInvoicesPage() {
setConfirmingId(row.salesInvoiceId)
try {
const posted = await salesApi.postInvoice(row.salesInvoiceId)
// No longer a draft, so it leaves this list — it's now on Day End.
setRows((prev) => prev?.filter((r) => r.salesInvoiceId !== row.salesInvoiceId) ?? prev)
// Stays in the list, now Approved (and payable): re-read it from the server.
setSelected((prev) => {
const next = new Set(prev)
next.delete(row.salesInvoiceId)
return next
})
setReloadKey((k) => k + 1)
toast.success("Invoice confirmed", posted.invoiceNo)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
@@ -221,9 +239,9 @@ export default function SalesInvoicesPage() {
failed.push(`${row?.invoiceNo ?? id}: ${errorMessage(err)}`)
}
}
setRows((prev) => prev?.filter((r) => !approved.includes(r.salesInvoiceId)) ?? prev)
setSelected((prev) => new Set([...prev].filter((id) => !approved.includes(id))))
setBulkApproving(false)
if (approved.length > 0) setReloadKey((k) => k + 1)
if (approved.length > 0) toast.success("Invoices approved", `${approved.length} invoice(s) approved.`)
if (failed.length > 0) toast.error(`${failed.length} invoice(s) could not be approved`, failed.join("\n"))
}
@@ -249,13 +267,8 @@ export default function SalesInvoicesPage() {
}
}
const visibleRows = useMemo(
() =>
rows?.filter((row) =>
`${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
) ?? [],
[rows, query]
)
// Search, status and dates are applied by the API, so the loaded page is shown as it comes.
const visibleRows = useMemo(() => rows ?? [], [rows])
const sortedRows = useMemo(() => {
if (!sortKey) return visibleRows
const value = (r: SalesInvoiceSummary): string | number => {
@@ -276,12 +289,13 @@ export default function SalesInvoicesPage() {
})
}, [visibleRows, sortKey, sortOrder])
// Header "select all" ticks every draft on screen (the loaded page, after search).
const allTicked = sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.salesInvoiceId))
// Header "select all" ticks every draft on screen (approved and cancelled sales can't be approved again).
const draftRows = sortedRows.filter((r) => r.status === "Draft")
const allTicked = draftRows.length > 0 && draftRows.every((r) => selected.has(r.salesInvoiceId))
function toggleAll(checked: boolean) {
setSelected((prev) => {
const next = new Set(prev)
sortedRows.forEach((r) => (checked ? next.add(r.salesInvoiceId) : next.delete(r.salesInvoiceId)))
draftRows.forEach((r) => (checked ? next.add(r.salesInvoiceId) : next.delete(r.salesInvoiceId)))
return next
})
}
@@ -290,14 +304,71 @@ export default function SalesInvoicesPage() {
return (
<Checkbox
checked={selected.has(row.salesInvoiceId)}
disabled={bulkApproving}
disabled={bulkApproving || row.status !== "Draft"}
aria-label={`Select ${row.invoiceNo}`}
onCheckedChange={() => toggleSelected(row.salesInvoiceId)}
/>
)
}
const hasFilters = dateFrom !== undefined || query.length > 0 || customerId !== undefined || warehouseId !== undefined
const hasFilters = dateFrom !== undefined || query.length > 0 || customerId !== undefined || warehouseId !== undefined || statusFilter !== "All"
/** Open · Approve (drafts) · Pay and "Open in Approved Invoices" (approved) — the same on cards and table rows. */
function renderRowActions(row: SalesInvoiceSummary, buttonVariant: "outline" | "ghost", size: "icon" | "icon-sm") {
const confirming = confirmingId === row.salesInvoiceId
return (
<>
<Button
type="button"
variant={buttonVariant}
size={size}
onClick={() => openDraft(row)}
aria-label={row.status === "Draft" ? `Open ${row.invoiceNo} to edit` : `View ${row.invoiceNo}`}
title={row.status === "Draft" ? "Open draft to edit" : "View"}
>
<FileText className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
type="button"
variant={buttonVariant}
size={size}
disabled={confirming}
onClick={() => handleConfirm(row)}
aria-label={`Confirm ${row.invoiceNo}`}
title={confirming ? "Confirming…" : "Confirm"}
>
{confirming ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
{row.status === "Posted" && row.totals.balanceAmount > 0 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setPayInvoice(row)
setPayOpen(true)
}}
aria-label={`Pay ${row.invoiceNo}`}
>
<Wallet className="size-4" />
Pay
</Button>
)}
{row.status === "Posted" && (
<Link
href={`/dashboard/sales/day-end?q=${encodeURIComponent(row.invoiceNo)}`}
className={cn(buttonVariants({ variant: buttonVariant, size }))}
aria-label={`Open ${row.invoiceNo} in Approved Invoices`}
title="Open in Approved Invoices"
>
<ExternalLink className="size-4" />
</Link>
)}
</>
)
}
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
@@ -307,9 +378,13 @@ export default function SalesInvoicesPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
<p className="text-base text-muted-foreground">Every sale — pending drafts and approved invoices — with filters, approval and payment.</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Link href="/dashboard/sales/day-end" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
<CheckCircle2 className="size-5" />
Approved invoices
</Link>
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
New Sale
@@ -317,13 +392,13 @@ export default function SalesInvoicesPage() {
</div>
</div>
{/* Same order as the table: how many drafts, then Gross → Discount → Net. Draft count is every
matching draft; the amounts are over the invoices currently listed (filters + loaded page),
{/* Same order as the table: how many sales, then Gross → Discount → Net. The count is every
matching sale; the amounts are over the invoices currently listed (filters + loaded page),
passed as strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{rows !== null ? (
<>
<StatCard label="Draft invoices" value={pagination?.totalItems ?? rows.length} icon={FileText} />
<StatCard label="Sales" value={pagination?.totalItems ?? rows.length} icon={FileText} />
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
<StatCard label="Discount total" value={formatAmount(discountTotal)} icon={Tag} />
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
@@ -371,6 +446,21 @@ export default function SalesInvoicesPage() {
}
>
<div className="flex flex-wrap items-center gap-2">
<Select<StatusFilter>
value={statusFilter}
onValueChange={(v) => setStatusFilter(v ?? "All")}
items={statusFilters.map((s) => ({ label: s.label, value: s.value }))}
>
<SelectTrigger className="h-10! w-44 text-sm" aria-label="Status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusFilters.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select<DateRange>
value={dateRange}
onValueChange={(v) => setDateRange(v ?? "All")}
@@ -496,30 +586,8 @@ export default function SalesInvoicesPage() {
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => openDraft(row)}
aria-label={`Open ${row.invoiceNo} to edit`}
title="Open draft to edit"
>
<FileText className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
type="button"
variant="outline"
size="icon"
disabled={confirmingId === row.salesInvoiceId}
onClick={() => handleConfirm(row)}
aria-label={`Confirm ${row.invoiceNo}`}
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
>
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
{renderRowActions(row, "outline", "icon")}
</div>
</div>
))}
@@ -603,31 +671,7 @@ export default function SalesInvoicesPage() {
</div>
</TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => openDraft(row)}
aria-label={`Open ${row.invoiceNo} to edit`}
title="Open draft to edit"
>
<FileText className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={confirmingId === row.salesInvoiceId}
onClick={() => handleConfirm(row)}
aria-label={`Confirm ${row.invoiceNo}`}
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
>
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
</div>
<div className="flex items-center gap-1">{renderRowActions(row, "ghost", "icon-sm")}</div>
</TableCell>
</TableRow>
))}
@@ -648,6 +692,14 @@ export default function SalesInvoicesPage() {
</>
)}
</div>
{/* After a payment the page is re-read, so Paid / Remaining and the badges come from the server. */}
<SalesInvoicePaymentDialog
invoice={payInvoice}
open={payOpen}
onOpenChange={setPayOpen}
onPaid={() => setReloadKey((k) => k + 1)}
/>
</div>
)
}
@@ -0,0 +1,123 @@
"use client"
import { useEffect, useState } from "react"
import { ShieldCheck } from "lucide-react"
import { AccessConfig, accessConfigApi } from "@/lib/api/access-config"
import { rolesApi } from "@/lib/api/roles"
import { errorMessage } from "@/lib/error-map"
import { Role } from "@/types/rbac"
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"
const NONE = "__none__"
/**
* Settings → Access — the default role. Every first sign-in, and every person with no role, receives it. Keep it
* least-privilege (the seeded "No access" role has no permissions at all): whoever can register a login gets it.
* The administrator role is never offered, and the server refuses it.
*/
export default function AccessSettingsPage() {
const [config, setConfig] = useState<AccessConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [roles, setRoles] = useState<Role[]>([])
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
function load() {
setError(null)
accessConfigApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
useEffect(() => {
rolesApi.list({ pageSize: 200, status: "Active" }).then((r) => setRoles(r.items.filter((x) => !x.isAdmin))).catch(() => setRoles([]))
}, [])
async function save(value: string) {
const roleId = value === NONE ? null : Number(value)
setSaving(true)
try {
const res = await accessConfigApi.update(roleId, etag ?? "")
setConfig(res.data)
setEtag(res.etag)
toast.success("Default role saved", res.data.defaultRoleName ? `New sign-ins get “${res.data.defaultRoleName}”.` : "New sign-ins get no role.")
} catch (err) {
toast.error("Could not save", errorMessage(err))
load()
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Access</h1>
<p className="text-base text-muted-foreground">What a person can do when they have no role of their own.</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-40 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2">
<ShieldCheck className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Default role</h2>
</div>
<div className="border-t" />
<div className="flex flex-col gap-2 py-2 sm:max-w-md">
<Select<string>
value={config.defaultRoleId ? String(config.defaultRoleId) : NONE}
onValueChange={(v) => v && save(v)}
items={[{ label: "No default role", value: NONE }, ...roles.map((r) => ({ label: r.name, value: String(r.roleId) }))]}
disabled={saving}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE} className="text-base">No default role</SelectItem>
{roles.map((r) => (
<SelectItem key={r.roleId} value={String(r.roleId)} className="text-base">{r.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Given to anyone who signs in for the first time, and to anyone without a role. Choose a role with few or no permissions —
an administrator then assigns the real one under Settings → Users. With no default, such people see “Waiting for access”.
</p>
</div>
{config.defaultRoleId && (
<div>
<Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => save(NONE)}>
Clear default
</Button>
</div>
)}
{config.updatedAt && (
<p className="pt-2 text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p>
)}
</div>
)}
</div>
)
}
@@ -4,6 +4,8 @@ import { useEffect, useState } from "react"
import { Save } from "lucide-react"
import { approvalsApi } from "@/lib/api/approvals"
import { rolesApi } from "@/lib/api/roles"
import { Role } from "@/types/rbac"
import { errorMessage } from "@/lib/error-map"
import { ApprovalConfig } from "@/types/approvals"
@@ -17,7 +19,7 @@ import { toast } from "@/components/ui/toast"
const levelOptions = [
{ value: "2", label: "Two approvals — two different people" },
{ value: "1", label: "One approval" },
{ value: "0", label: "No approval — applies immediately" },
{ value: "0", label: "No approval — type, confirm and it applies immediately" },
]
/** Settings → Approvals (docs/DUAL_APPROVAL_PLAN.md §7): how many approvals stock and price adjustments need. */
@@ -28,7 +30,8 @@ export default function ApprovalSettingsPage() {
const [stockThreshold, setStockThreshold] = useState("0")
const [priceLevels, setPriceLevels] = useState("2")
const [priceThreshold, setPriceThreshold] = useState("0")
const [roles, setRoles] = useState("")
const [level2Roles, setLevel2Roles] = useState<number[]>([])
const [allRoles, setAllRoles] = useState<Role[]>([])
const [saving, setSaving] = useState(false)
function apply(c: ApprovalConfig) {
@@ -37,11 +40,12 @@ export default function ApprovalSettingsPage() {
setStockThreshold(String(c.stockAdjustmentLevel2ThresholdValue))
setPriceLevels(String(c.priceAdjustmentLevels))
setPriceThreshold(String(c.priceAdjustmentLevel2ThresholdPercent))
setRoles(c.level2RoleCodes.join(", "))
setLevel2Roles(c.level2RoleIds)
}
useEffect(() => {
approvalsApi.getConfig().then(apply).catch((err) => setError(errorMessage(err)))
rolesApi.list({ pageSize: 200, status: "Active" }).then((r) => setAllRoles(r.items)).catch(() => setAllRoles([]))
}, [])
async function save() {
@@ -58,7 +62,7 @@ export default function ApprovalSettingsPage() {
stockAdjustmentLevel2ThresholdValue: sv,
priceAdjustmentLevels: Number(priceLevels),
priceAdjustmentLevel2ThresholdPercent: pv,
level2RoleCodes: roles.split(",").map((r) => r.trim()).filter(Boolean),
level2RoleIds: level2Roles,
})
apply(saved)
toast.success("Approval settings saved", "They apply to requests made from now on.")
@@ -104,7 +108,10 @@ export default function ApprovalSettingsPage() {
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
<h2 className="text-lg font-semibold text-foreground">Price adjustments</h2>
<p className="text-sm text-muted-foreground">Selling-price changes. While this is not “No approval”, prices can’t be edited directly on an item.</p>
<p className="text-sm text-muted-foreground">
Selling-price changes. With “No approval” the new price is typed on the item (or the price-change form), confirmed and applied at once.
With one or two approvals, prices can’t be edited directly on an item — they go through a price change request.
</p>
<Field>
<FieldLabel>Approvals required</FieldLabel>
<Select<string> value={priceLevels} onValueChange={(v) => v && setPriceLevels(v)}>
@@ -124,11 +131,29 @@ export default function ApprovalSettingsPage() {
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
<Field>
<FieldLabel htmlFor="level2-roles">Who can give the second approval (role codes)</FieldLabel>
<Input id="level2-roles" value={roles} onChange={(e) => setRoles(e.target.value)} placeholder="e.g. Manager, Accountant — leave empty for anyone with the Approve permission" className="h-11 text-base" />
<FieldLabel>Who can give the second approval</FieldLabel>
<div className="flex flex-wrap gap-2">
{allRoles.filter((r) => !r.isAdmin).map((r) => {
const on = level2Roles.includes(r.roleId)
return (
<button
key={r.roleId}
type="button"
aria-pressed={on}
onClick={() => setLevel2Roles((prev) => (on ? prev.filter((id) => id !== r.roleId) : [...prev, r.roleId]))}
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
on ? "border-primary bg-primary/10 text-primary" : "border-input text-muted-foreground hover:text-foreground"
}`}
>
{r.name}
</button>
)
})}
</div>
<FieldDescription>
Comma-separated. Anyone giving an approval also needs the Approve permission on the screen (Settings → Roles).
Make sure at least two people per branch can approve, or requests will wait forever.
Pick the roles allowed to give the second approval; pick none to allow anyone with the Approve permission (administrators always can).
Anyone giving an approval also needs the Approve permission on the screen (Settings → Roles). Make sure at least two people can approve,
or requests will wait forever.
</FieldDescription>
</Field>
</section>
@@ -0,0 +1,139 @@
"use client"
import { useEffect, useState } from "react"
import { ScrollText } from "lucide-react"
import { auditLogsApi } from "@/lib/api/audit-logs"
import { errorMessage } from "@/lib/error-map"
import { PaginationMeta } from "@/types/common"
import { AuditLog } from "@/types/audit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE = 20
/**
* Settings → Audit Log — who created, changed or deleted what (and who signed in). Read-only; the API requires the
* Audit Log permission, because the trail includes pay and personal-data changes.
*/
export default function AuditLogsPage() {
const [rows, setRows] = useState<AuditLog[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [page, setPage] = useState(1)
const [entityType, setEntityType] = useState("")
const [userId, setUserId] = useState("")
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
auditLogsApi
.list({
page,
pageSize: PAGE_SIZE,
entityType: entityType.trim() || undefined,
userId: userId.trim() ? Number(userId) : undefined,
from: from || undefined,
to: to || undefined,
})
.then((res) => {
if (cancelled) return
setRows(res.items)
setPagination(res.pagination)
setError(null)
})
.catch((err) => !cancelled && setError(errorMessage(err)))
.finally(() => !cancelled && setLoading(false))
return () => {
cancelled = true
}
}, [page, entityType, userId, from, to])
// Any filter change goes back to the first page.
function onFilter(setter: (v: string) => void) {
return (e: React.ChangeEvent<HTMLInputElement>) => {
setLoading(true)
setter(e.target.value)
setPage(1)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2.5">
<ScrollText className="size-6 text-primary" />
<div>
<h1 className="text-2xl font-bold text-foreground">Audit Log</h1>
<p className="text-base text-muted-foreground">Who created, changed or deleted what, and when.</p>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Input placeholder="Record type (e.g. PurchaseOrder)" value={entityType} onChange={onFilter(setEntityType)} className="h-11" />
<Input placeholder="User id" inputMode="numeric" value={userId} onChange={onFilter(setUserId)} className="h-11" />
<Input type="date" aria-label="From" value={from} onChange={onFilter(setFrom)} className="h-11" />
<Input type="date" aria-label="To" value={to} onChange={onFilter(setTo)} className="h-11" />
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && (
<div className="overflow-x-auto rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Record</TableHead>
<TableHead>Changes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading &&
Array.from({ length: 6 }).map((_, i) => (
<TableRow key={i}>
<TableCell colSpan={5}><Skeleton className="h-6 w-full" /></TableCell>
</TableRow>
))}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">Nothing recorded for these filters.</TableCell>
</TableRow>
)}
{!loading &&
rows.map((r) => (
<TableRow key={r.auditId}>
<TableCell className="whitespace-nowrap">{new Date(r.createdAt).toLocaleString()}</TableCell>
<TableCell>#{r.userId}</TableCell>
<TableCell>{r.action}</TableCell>
<TableCell className="whitespace-nowrap">{r.entityType} #{r.entityId}</TableCell>
<TableCell className="max-w-md truncate font-mono text-xs text-muted-foreground" title={JSON.stringify(r.changeSet)}>
{JSON.stringify(r.changeSet)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages} · {pagination.totalItems} entries
</span>
<div className="flex gap-2">
<Button variant="outline" disabled={page <= 1} onClick={() => { setLoading(true); setPage((p) => p - 1) }}>Previous</Button>
<Button variant="outline" disabled={page >= pagination.totalPages} onClick={() => { setLoading(true); setPage((p) => p + 1) }}>Next</Button>
</div>
</div>
)}
</div>
)
}
@@ -1,5 +1,5 @@
import Link from "next/link"
import { BookOpen, Building2, LayoutTemplate, MapPin, PackageCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
import { BookOpen, Building2, ClipboardList, LayoutTemplate, MapPin, PackageCheck, ShieldCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
const cards = [
{
@@ -32,6 +32,18 @@ const cards = [
icon: PackageCheck,
description: "Warehouse selection and the default receiving warehouse",
},
{
title: "Procurement Configuration",
href: "/dashboard/settings/procurement-configuration",
icon: ClipboardList,
description: "Vendor contact and address fields, and tax on purchase orders",
},
{
title: "Access",
href: "/dashboard/settings/access",
icon: ShieldCheck,
description: "The default role for people who sign in without one",
},
{
title: "GL Accounts",
href: "/dashboard/settings/gl-accounts",
@@ -0,0 +1,213 @@
"use client"
import { useEffect, useState } from "react"
import { Settings } from "lucide-react"
import { procurementConfigApi } from "@/lib/api/procurement-config"
import { errorMessage } from "@/lib/error-map"
import { ProcurementConfig, UpdateProcurementConfigRequest, VendorContactMode } from "@/types/procurement"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
const CONTACT_LABEL: Record<VendorContactMode, string> = {
None: "Off — no contact field",
Mobile: "Mobile number",
Email: "Email address",
}
/**
* Procurement Configuration — singleton options for vendors and purchase orders (same pattern as
* GRN Configuration). Every flag is enforced server-side as well: a value sent for a switched-off
* field is rejected with 422 CONFIG_DISABLED, so turning something off here hides it everywhere.
*/
export default function ProcurementConfigurationPage() {
const [config, setConfig] = useState<ProcurementConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
function load() {
setError(null)
procurementConfigApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
// PUT is a full replacement — send every flag, overriding just the one changed.
async function saveFlag(patch: Partial<UpdateProcurementConfigRequest>, message: string) {
if (!config) return
setSaving(true)
try {
const res = await procurementConfigApi.update(
{
vendorContactMode: config.vendorContactMode,
vendorAddressEnabled: config.vendorAddressEnabled,
vendorPaymentTermsEnabled: config.vendorPaymentTermsEnabled,
vendorTaxRegEnabled: config.vendorTaxRegEnabled,
poTaxEnabled: config.poTaxEnabled,
...patch,
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
toast.success("Configuration saved", message)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load()
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Procurement Configuration</h1>
<p className="text-base text-muted-foreground">Choose which optional details vendors and purchase orders carry. Contact, address, payment terms and tax registration are all optional fields you can switch on or off.</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-40 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2">
<Settings className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Vendors</h2>
</div>
<div className="border-t" />
<div className="flex flex-col gap-2 py-2 sm:max-w-md">
<span className="text-base font-medium text-foreground">Vendor contact</span>
<Select<VendorContactMode>
value={config.vendorContactMode}
onValueChange={(v) => {
if (!v || v === config.vendorContactMode) return
saveFlag({ vendorContactMode: v }, `Vendor contact: ${CONTACT_LABEL[v]}.`)
}}
items={(Object.keys(CONTACT_LABEL) as VendorContactMode[]).map((m) => ({ label: CONTACT_LABEL[m], value: m }))}
disabled={saving}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(CONTACT_LABEL) as VendorContactMode[]).map((m) => (
<SelectItem key={m} value={m} className="text-base">
{CONTACT_LABEL[m]}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
Pick one contact detail to record for every vendor. It appears on the vendor forms, the
purchase-order table and the purchase-order PDF. Details already saved are kept if you
switch it off.
</span>
</div>
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.vendorAddressEnabled}
onCheckedChange={(next) =>
saveFlag({ vendorAddressEnabled: next }, `Vendor address ${next ? "enabled" : "disabled"}.`)
}
disabled={saving}
aria-label="Vendor company address"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Vendor company address</span>
<span className="text-sm text-muted-foreground">
On: vendors can have an optional company address, shown on the purchase-order table and
PDF. Off: the address field is hidden.
</span>
</div>
</div>
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.vendorPaymentTermsEnabled}
onCheckedChange={(next) =>
saveFlag({ vendorPaymentTermsEnabled: next }, `Vendor payment terms ${next ? "enabled" : "disabled"}.`)
}
disabled={saving}
aria-label="Vendor payment terms"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Vendor payment terms</span>
<span className="text-sm text-muted-foreground">
On: vendors can have optional payment terms (e.g. NET30). Off: the field is hidden and can&apos;t be set;
values already saved are kept.
</span>
</div>
</div>
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.vendorTaxRegEnabled}
onCheckedChange={(next) =>
saveFlag({ vendorTaxRegEnabled: next }, `Vendor tax registration ${next ? "enabled" : "disabled"}.`)
}
disabled={saving}
aria-label="Vendor tax registration"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Vendor tax registration</span>
<span className="text-sm text-muted-foreground">
On: vendors can have an optional tax registration number. Off: the field is hidden and can&apos;t be set;
numbers already saved are kept (and still copied onto purchase invoices).
</span>
</div>
</div>
<div className="flex items-center gap-2 pt-4">
<Settings className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Purchase orders</h2>
</div>
<div className="border-t" />
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.poTaxEnabled}
onCheckedChange={(next) => saveFlag({ poTaxEnabled: next }, `PO tax ${next ? "enabled" : "disabled"}.`)}
disabled={saving}
aria-label="Apply tax on purchase orders"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Apply tax on purchase orders</span>
<span className="text-sm text-muted-foreground">
On: each PO line gets a tax rate (pre-filled from the item&apos;s tax rate, editable per line)
and a tax amount, included in the PO total and PDF. Off: tax is hidden everywhere on
purchase orders and every line is at 0%.
</span>
</div>
</div>
{config.updatedAt && (
<p className="pt-2 text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p>
)}
</div>
)}
</div>
)
}
@@ -15,6 +15,21 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
/** The optional customer fields, in the order the customer form shows them. */
const customerFieldSwitches: {
key: "customerPhoneEnabled" | "customerEmailEnabled" | "customerAddressEnabled" | "customerCityEnabled" | "customerCountryEnabled" | "customerCreditLimitEnabled" | "customerCreditDaysEnabled"
label: string
help: string
}[] = [
{ key: "customerPhoneEnabled", label: "Phone", help: "On: customers have an optional phone number. Off: the field is hidden." },
{ key: "customerEmailEnabled", label: "Email", help: "On: customers have an optional email address. Off: the field is hidden." },
{ key: "customerAddressEnabled", label: "Address", help: "On: customers have optional address lines 1 and 2. Off: both are hidden." },
{ key: "customerCityEnabled", label: "City", help: "On: customers have an optional city. Off: the field is hidden." },
{ key: "customerCountryEnabled", label: "Country", help: "On: customers have an optional country. Off: the field is hidden." },
{ key: "customerCreditLimitEnabled", label: "Credit limit", help: "On: customers have a credit limit (LKR). Off: the field and its column are hidden." },
{ key: "customerCreditDaysEnabled", label: "Credit days (age)", help: "On: customers have credit days — how long an invoice may stay unpaid. Off: the field is hidden." },
]
/**
* Sales Configuration — singleton sales toggles. "Allow cash customer": on, New Sale offers a
* generic "Cash Customer" (and defaults to it) alongside the customer list; off, a real customer
@@ -67,6 +82,13 @@ export default function SalesConfigurationPage() {
allowOverStockSale: config.allowOverStockSale,
allowWarehouseSelection: config.allowWarehouseSelection,
defaultWarehouseId: config.defaultWarehouseId,
customerPhoneEnabled: config.customerPhoneEnabled,
customerEmailEnabled: config.customerEmailEnabled,
customerAddressEnabled: config.customerAddressEnabled,
customerCityEnabled: config.customerCityEnabled,
customerCountryEnabled: config.customerCountryEnabled,
customerCreditLimitEnabled: config.customerCreditLimitEnabled,
customerCreditDaysEnabled: config.customerCreditDaysEnabled,
...patch,
},
etag ?? "",
@@ -125,6 +147,32 @@ export default function SalesConfigurationPage() {
</div>
</div>
<div className="flex items-center gap-2 pt-4">
<Settings className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Customer fields</h2>
</div>
<div className="border-t" />
<p className="text-sm text-muted-foreground">
Choose which optional details a customer carries. A field that is off is hidden on the New customer, Edit
customer and profile screens, and new values for it are refused; details already saved are kept.
</p>
{customerFieldSwitches.map((f) => (
<div key={f.key} className="flex items-start gap-4 py-2">
<Switch
checked={config[f.key]}
onCheckedChange={(next) => saveFlag({ [f.key]: next }, `${f.label} ${next ? "enabled" : "disabled"} for customers.`)}
disabled={saving}
aria-label={f.label}
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">{f.label}</span>
<span className="text-sm text-muted-foreground">{f.help}</span>
</div>
</div>
))}
<div className="flex items-center gap-2 pt-4">
<Settings className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Item lines</h2>
@@ -7,6 +7,10 @@ import { Clock, IdCard, KeyRound, Laptop, Pencil, ShieldCheck } from "lucide-rea
import { auditLogsApi } from "@/lib/api/audit-logs"
import { usersApi } from "@/lib/api/users"
import { rolesApi } from "@/lib/api/roles"
import { useAuth } from "@/components/auth/AuthProvider"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Role } from "@/types/rbac"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { AuditLog, LoginActivity } from "@/types/audit"
@@ -48,12 +52,42 @@ export default function UserDetailPage() {
const [sendingResetLink, setSendingResetLink] = useState(false)
// Role assignment: the role is the ERP's own (users.RoleId); a change applies on the person's very next request.
const { refresh: refreshMyAccess } = useAuth()
const [roles, setRoles] = useState<Role[]>([])
// The role chosen in the dropdown; until the person picks one it shows their current role.
const [pickedRoleIdState, setPickedRoleId] = useState<number | null | undefined>(undefined)
const pickedRoleId = pickedRoleIdState === undefined ? (user?.roleId ?? null) : pickedRoleIdState
const [savingRole, setSavingRole] = useState(false)
useEffect(() => {
if (!Number.isFinite(userId)) return
setLoadError(null)
usersApi.get(userId).then(setUser).catch((err) => setLoadError(errorMessage(err)))
}, [userId])
useEffect(() => {
rolesApi.list({ pageSize: 200, status: "Active" }).then((r) => setRoles(r.items)).catch(() => setRoles([]))
}, [])
async function handleSaveRole() {
if (!user || pickedRoleId === null || pickedRoleId === user.roleId) return
setSavingRole(true)
try {
const updated = await usersApi.updateRole(user.userId, { roleId: pickedRoleId })
setUser(updated)
setPickedRoleId(undefined)
toast.success("Role changed", `${updated.displayName} is now ${updated.roleName}. It applies on their next request.`)
// It may be your own role (or a role you hold changed): re-read what you can do.
void refreshMyAccess()
} catch (err) {
toast.error("Could not change the role", errorMessage(err))
setPickedRoleId(undefined)
} finally {
setSavingRole(false)
}
}
useEffect(() => {
if (!Number.isFinite(userId)) return
auditLogsApi
@@ -238,10 +272,32 @@ export default function UserDetailPage() {
<p className="text-sm text-muted-foreground">Controls what this user can see and do.</p>
</div>
</div>
<div className="mt-5 flex flex-col gap-1 sm:max-w-sm">
<div className="mt-5 flex flex-col gap-2 sm:max-w-sm">
<span className="text-sm text-muted-foreground">Role</span>
<span className="text-sm text-foreground">{user.roleName ?? "—"}</span>
{user.roleCode && <p className="text-xs text-muted-foreground">Role code: {user.roleCode}</p>}
<div className="flex gap-2">
<Select<number | null>
value={pickedRoleId}
onValueChange={setPickedRoleId}
items={roles.map((r) => ({ label: r.name, value: r.roleId }))}
disabled={savingRole}
>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder={user.roleName ?? "No role"} />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.roleId} value={r.roleId} className="text-base">{r.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" className="h-11 shrink-0" onClick={handleSaveRole} disabled={savingRole || pickedRoleId === null || pickedRoleId === user.roleId}>
{savingRole ? "Saving…" : "Change role"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{user.roleCode ? `Current role code: ${user.roleCode}. ` : "No role assigned yet. "}
The change applies on this person&apos;s very next click — they don&apos;t need to sign in again.
</p>
</div>
</section>
@@ -71,7 +71,7 @@ export default function UsersPage() {
Promise.all([
usersApi.list({ page: 1, pageSize: 1 }),
usersApi.list({ page: 1, pageSize: 1, status: "Active" }),
usersApi.list({ page: 1, pageSize: 1, roleCode: "Admin" }),
usersApi.list({ page: 1, pageSize: 1, isAdmin: true }),
])
.then(([totalRes, activeRes, adminsRes]) =>
setStats({ total: totalRes.pagination.totalItems, active: activeRes.pagination.totalItems, admins: adminsRes.pagination.totalItems })
+37 -8
View File
@@ -11,6 +11,15 @@ import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
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 { GrnSummary } from "@/types/grn"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -41,6 +50,8 @@ export default function VendorDetailPage() {
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 [conflict, setConflict] = useState(false)
@@ -87,6 +98,7 @@ export default function VendorDetailPage() {
setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "")
setCurrency(data.currency)
setOptional({ mobile: data.mobile ?? "", email: data.email ?? "", address: data.address ?? "" })
}
function load() {
@@ -169,13 +181,14 @@ export default function VendorDetailPage() {
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 || !etag) return
if (!vendor) return
setSaving(true)
try {
const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId }, etag)
const result = await vendorsApi.update(vendorId, { code, name, ...vendorTermsTaxPayload(procConfig, terms, taxReg), currency, ...vendorOptionalPayload(procConfig, optional), branchId: vendor.branchId }, etag)
setVendor(result.data)
setEtag(result.etag)
setEditOpen(false)
@@ -461,13 +474,29 @@ export default function VendorDetailPage() {
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Payment terms</Label>
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax registration</Label>
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
{procConfig.vendorPaymentTermsEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Payment terms</Label>
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} />
</div>
)}
{procConfig.vendorTaxRegEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Tax registration</Label>
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
)}
<div className="flex flex-col gap-4 sm:col-span-2">
<VendorOptionalFields
config={procConfig}
values={optional}
onChange={(patch) => {
setOptional((prev) => ({ ...prev, ...patch }))
clearErrors("mobile", "email")
}}
idPrefix="vd"
errors={errors}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base" required>Currency</Label>
+46 -16
View File
@@ -34,6 +34,15 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { EditVendorDialog } from "@/components/vendors/EditVendorDialog"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import {
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
type StatusFilter = EntityStatus | "All"
@@ -62,6 +71,8 @@ export default function VendorsPage() {
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [currency, setCurrency] = useState("LKR")
const { config: procConfig } = useProcurementConfig()
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
@@ -129,6 +140,7 @@ export default function VendorsPage() {
setTerms("")
setTaxReg("")
setCurrency("LKR")
setOptional(EMPTY_VENDOR_OPTIONAL)
setBranchFilterId(null)
setErrors({})
}
@@ -148,12 +160,13 @@ export default function VendorsPage() {
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: effectiveBranchId ?? 0 })
const result = await vendorsApi.create({ code: generatedCode, name, ...vendorTermsTaxPayload(procConfig, terms, taxReg), currency, ...vendorOptionalPayload(procConfig, optional), branchId: effectiveBranchId ?? 0 })
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setOpen(false)
resetForm()
@@ -202,7 +215,7 @@ export default function VendorsPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Vendors</h1>
<p className="text-base text-muted-foreground">Supplier master data — code, terms, tax registration, currency (FR-MD-06).</p>
<p className="text-base text-muted-foreground">Supplier master data — code, currency and the optional details switched on in Procurement Configuration (FR-MD-06).</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
@@ -229,14 +242,29 @@ export default function VendorsPage() {
<FieldLabel htmlFor="v-code">Code (auto-generated)</FieldLabel>
<Input id="v-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
<Field>
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
<Field>
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
{procConfig.vendorPaymentTermsEnabled && (
<Field>
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
)}
{procConfig.vendorTaxRegEnabled && (
<Field>
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
)}
<VendorOptionalFields
config={procConfig}
values={optional}
onChange={(patch) => {
setOptional((prev) => ({ ...prev, ...patch }))
clearError("mobile")
clearError("email")
}}
idPrefix="v"
errors={errors}
/>
<Field data-invalid={!!errors.currency}>
<FieldLabel htmlFor="v-currency" required>Currency</FieldLabel>
<Input id="v-currency" value={currency} onChange={(e) => { setCurrency(e.target.value); clearError("currency") }} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
@@ -359,10 +387,12 @@ export default function VendorsPage() {
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Terms</p>
<p className="font-medium text-foreground">{v.terms ?? "—"}</p>
</div>
{procConfig.vendorPaymentTermsEnabled && (
<div>
<p className="text-muted-foreground">Terms</p>
<p className="font-medium text-foreground">{v.terms ?? "—"}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Currency</p>
<p className="font-medium text-foreground">{v.currency}</p>
@@ -429,7 +459,7 @@ export default function VendorsPage() {
<TableRow className="hover:bg-transparent">
<TableHead className="w-32">Code</TableHead>
<TableHead>Name</TableHead>
<TableHead>Terms</TableHead>
{procConfig.vendorPaymentTermsEnabled && <TableHead>Terms</TableHead>}
<TableHead>Currency</TableHead>
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
<TableHead>Status</TableHead>
@@ -449,7 +479,7 @@ export default function VendorsPage() {
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{v.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{v.terms ?? "—"}</TableCell>
{procConfig.vendorPaymentTermsEnabled && <TableCell className="text-sm text-muted-foreground">{v.terms ?? "—"}</TableCell>}
<TableCell className="text-sm text-muted-foreground">{v.currency}</TableCell>
{isAdmin && branchingEnabled && (
<TableCell>
@@ -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>
)}
</>
)
}
@@ -0,0 +1,63 @@
"use client"
import { useEffect, useState } from "react"
import { salesConfigApi } from "@/lib/api/sales-config"
/** Which optional customer fields Settings -> Sales Configuration has switched on. */
export interface CustomerFieldConfig {
phone: boolean
email: boolean
address: boolean
city: boolean
country: boolean
creditLimit: boolean
creditDays: boolean
}
/**
* Everything on — the default before this became configurable, and what a screen shows until the real switches arrive
* (or if they can't be read). The server refuses a value for a field that is off, so a wrong guess is caught there.
*/
export const CUSTOMER_FIELDS_ON: CustomerFieldConfig = {
phone: true,
email: true,
address: true,
city: true,
country: true,
creditLimit: true,
creditDays: true,
}
/** Loads the optional-customer-field switches from the Sales configuration. */
export function useCustomerConfig(): { fields: CustomerFieldConfig; loaded: boolean } {
const [fields, setFields] = useState<CustomerFieldConfig>(CUSTOMER_FIELDS_ON)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
let active = true
salesConfigApi
.get()
.then(({ data }) => {
if (!active) return
setFields({
phone: data.customerPhoneEnabled,
email: data.customerEmailEnabled,
address: data.customerAddressEnabled,
city: data.customerCityEnabled,
country: data.customerCountryEnabled,
creditLimit: data.customerCreditLimitEnabled,
creditDays: data.customerCreditDaysEnabled,
})
})
.catch(() => {})
.finally(() => {
if (active) setLoaded(true)
})
return () => {
active = false
}
}, [])
return { fields, loaded }
}
@@ -0,0 +1,43 @@
"use client"
import { useEffect, useState } from "react"
import { procurementConfig } from "@/lib/api/procurement-config"
import { ProcurementConfig } from "@/types/procurement"
/** Everything off — what a screen assumes until the real flags arrive (or if they cannot be read). */
export const PROCUREMENT_CONFIG_OFF: ProcurementConfig = {
vendorContactMode: "None",
vendorAddressEnabled: false,
vendorPaymentTermsEnabled: false,
vendorTaxRegEnabled: false,
poTaxEnabled: false,
updatedAt: null,
updatedBy: null,
}
/**
* Loads the Procurement configuration flags. A failed read leaves every optional field hidden
* rather than blocking the screen — the server rejects gated values on its own anyway.
*/
export function useProcurementConfig(): { config: ProcurementConfig; loaded: boolean } {
const [config, setConfig] = useState<ProcurementConfig>(PROCUREMENT_CONFIG_OFF)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
let active = true
procurementConfig()
.then((c) => {
if (active) setConfig(c)
})
.catch(() => {})
.finally(() => {
if (active) setLoaded(true)
})
return () => {
active = false
}
}, [])
return { config, loaded }
}
+20 -2
View File
@@ -12,6 +12,9 @@ import { ApiResult, ProblemDetails } from "@/types/common"
const API_BASE = "/api/v1"
/** Fired on window whenever the API answers 403, so the auth context can re-read the person's permissions. */
export const FORBIDDEN_EVENT = "erp:forbidden"
/** Normalized RFC 7807 error (docs/11-BACKEND-PHASE1.md §1.8) thrown on any non-2xx response. */
export class ApiError extends Error {
status: number
@@ -36,7 +39,7 @@ export interface RequestOptions extends Omit<RequestInit, "body"> {
ifMatch?: string
/** Sent as Idempotency-Key for transactional POSTs (e.g. GRN confirm, docs/11 §1.6). */
idempotencyKey?: string
/** Sent as X-XSRF-TOKEN. Only the [ValidateCsrf] actions on AuthController need it (docs/11 §2.0). */
/** Sent as X-XSRF-TOKEN. Now sent automatically on every POST/PUT/PATCH/DELETE (the API checks all of them); kept so a caller can force it. */
csrf?: boolean
}
@@ -78,7 +81,11 @@ function redirectToLogin() {
async function rawRequest(path: string, options: RequestOptions = {}, isRetry = false): Promise<Response> {
const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options
const csrfToken = csrf ? readCsrfToken() : null
// The API double-submit-checks every state-changing request that rides on the session cookie, so send the token
// on all of them (it is just echoing a cookie of our own origin).
const method = (rest.method ?? "GET").toUpperCase()
const stateChanging = method !== "GET" && method !== "HEAD" && method !== "OPTIONS"
const csrfToken = csrf || stateChanging ? readCsrfToken() : null
// Multipart uploads (attendance files, staff documents) pass a FormData body — the
// browser sets its own Content-Type (with boundary), and it must never be JSON-encoded.
const isFormData = typeof FormData !== "undefined" && body instanceof FormData
@@ -105,7 +112,18 @@ async function rawRequest(path: string, options: RequestOptions = {}, isRetry =
redirectToLogin()
}
// A session older than the CSRF check has no XSRF-TOKEN cookie (or it rotated): renewing the session re-issues it.
// Peek at a clone so the normal error path below still reads the body.
if (response.status === 403 && !isRetry && !path.startsWith("/auth/")) {
const peek = await response.clone().json().catch(() => null) as ProblemDetails | null
if (peek?.code === "CSRF_TOKEN_MISMATCH" && (await refreshSession())) return rawRequest(path, options, true)
}
if (!response.ok) {
if (response.status === 403 && typeof window !== "undefined") {
// The role may have changed under this tab: let the auth context re-read what the person can still do.
window.dispatchEvent(new CustomEvent(FORBIDDEN_EVENT))
}
let problem: ProblemDetails
try {
problem = (await response.json()) as ProblemDetails
@@ -0,0 +1,21 @@
// Access configuration. Singleton - no id. The default role is what a first sign-in (or a person with no role) receives.
import { apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common"
export interface AccessConfig {
defaultRoleId: number | null
defaultRoleName: string | null
updatedAt: string | null
updatedBy: number | null
}
export const accessConfigApi = {
get(): Promise<ApiResult<AccessConfig>> {
return apiRequestWithETag<AccessConfig>("/access-config")
},
/** `null` clears the default (people without a role then have no permissions at all). The administrator role is refused. */
update(defaultRoleId: number | null, ifMatch: string): Promise<ApiResult<AccessConfig>> {
return apiRequestWithETag<AccessConfig>("/access-config", { method: "PUT", body: { defaultRoleId }, ifMatch })
},
}
+5
View File
@@ -27,6 +27,11 @@ export interface ListGrnsParams {
vendorId?: number
warehouseId?: number
branchId?: number
/** Created on or after this day (YYYY-MM-DD). */
from?: string
/** Created on or before this day (YYYY-MM-DD). */
to?: string
/** `created` (default, newest first) | `vendor` | `total` | `status`; a leading `-` flips the direction. */
sort?: string
}
@@ -0,0 +1,40 @@
// Opening balances. post() creates ONE journal in the General Ledger (a debit per cash/bank account, the
// offsetting credit to "Opening Balance Equity") and can be done only once: a second call is 409
// OPENING_BALANCE_ALREADY_POSTED.
import { apiRequest } from "@/lib/api-client"
import {
CashBankOpeningState,
OpeningBalanceRun,
OpeningStockRun,
OpeningStockRunSummary,
PostCashBankOpeningRequest,
PostOpeningStockRequest,
} from "@/types/opening-balances"
export const openingBalancesApi = {
/** The cash/bank accounts to enter amounts for — or the posted record, once it has been done. */
cashBank(): Promise<CashBankOpeningState> {
return apiRequest<CashBankOpeningState>("/opening-balances/cash-bank")
},
postCashBank(request: PostCashBankOpeningRequest): Promise<OpeningBalanceRun> {
return apiRequest<OpeningBalanceRun>("/opening-balances/cash-bank", { method: "POST", body: request })
},
/** Opening-stock runs posted so far, newest first. */
stockRuns(): Promise<OpeningStockRunSummary[]> {
return apiRequest<OpeningStockRunSummary[]>("/opening-balances/stock")
},
stockRun(runId: number): Promise<OpeningStockRun> {
return apiRequest<OpeningStockRun>(`/opening-balances/stock/${runId}`)
},
/**
* Creates a FIFO layer per line and ONE journal (Dr Inventory / Cr Opening Balance Equity). 409 when an item
* already has stock movements in that warehouse; untracked, stocked items only.
*/
postStock(request: PostOpeningStockRequest): Promise<OpeningStockRun> {
return apiRequest<OpeningStockRun>("/opening-balances/stock", { method: "POST", body: request })
},
}
@@ -0,0 +1,22 @@
// Procurement configuration. Singleton - no id. Controls the optional vendor contact / address
// fields and whether purchase orders carry tax. The server enforces every flag too: a value sent
// for a switched-off field returns 422 CONFIG_DISABLED, so the UI hides what is off.
import { apiRequest, apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common"
import { ProcurementConfig, UpdateProcurementConfigRequest } from "@/types/procurement"
export const procurementConfigApi = {
get(): Promise<ApiResult<ProcurementConfig>> {
return apiRequestWithETag<ProcurementConfig>("/procurement-config")
},
/** Full replacement — every flag is required. */
update(request: UpdateProcurementConfigRequest, ifMatch: string): Promise<ApiResult<ProcurementConfig>> {
return apiRequestWithETag<ProcurementConfig>("/procurement-config", { method: "PUT", body: request, ifMatch })
},
}
/** Read-only helper for screens that only need the flags. */
export function procurementConfig(): Promise<ProcurementConfig> {
return apiRequest<ProcurementConfig>("/procurement-config")
}
@@ -18,13 +18,18 @@ export interface ListPurchaseOrdersParams {
q?: string
status?: PurchaseOrderStatus
vendorId?: number
/** Created on or after this day (YYYY-MM-DD). */
from?: string
/** Created on or before this day (YYYY-MM-DD). */
to?: string
/** `created` (default, newest first) | `vendor` | `total` | `status`; a leading `-` flips the direction. */
sort?: string
}
/** Editable/deletable only while Draft (FR-PROC-05, revised — submitting locks the PO).
/** Editable/deletable only while Draft or Rejected (FR-PROC-05, revised — submitting locks the PO; a rejected one is reopened for revision).
* The server is authoritative (409 PO_NOT_EDITABLE otherwise); this only drives UI affordances. */
export function isPoEditable(status: PurchaseOrderStatus): boolean {
return status === "Draft"
return status === "Draft" || status === "Rejected"
}
export const purchaseOrdersApi = {
+2
View File
@@ -21,6 +21,8 @@ export interface ListUsersParams {
q?: string
status?: EntityStatus
roleCode?: string
/** true = only administrators (users whose role is the administrator role, whatever it is called). */
isAdmin?: boolean
}
export const usersApi = {
+42
View File
@@ -0,0 +1,42 @@
// Which permission opens which page: the route guard in components/auth/AccessGate.tsx uses this so a role
// can't open a screen by typing its address (the sidebar only hides the link). Derived from the sidebar's own
// list, so a page and its menu entry can never disagree; the API enforces the same codes on every request, so this
// is a courtesy that replaces a wall of "Forbidden" toasts with one clear message — never the protection.
import { navItems } from "@/components/Layouts/AppSidebar"
interface RouteRule {
prefix: string
/** Any one of these opens the page. */
codes: string[]
}
/** Pages with no menu entry of their own, but that belong to a permission anyway. */
const EXTRA_RULES: RouteRule[] = [
{ prefix: "/dashboard/settings/audit-logs", codes: ["settings.audit-logs"] },
]
function buildRules(): RouteRule[] {
const rules: RouteRule[] = [...EXTRA_RULES]
for (const item of navItems) {
// The landing page and Help are open to everyone with a role (they hold no data of their own).
if (item.code !== "dashboard" && item.code !== "help") rules.push({ prefix: item.href, codes: [item.code] })
for (const child of item.children ?? []) {
rules.push({ prefix: child.href, codes: [child.code, ...(child.altCodes ?? [])] })
}
}
// Longest prefix first, so "/dashboard/procurement/purchase-orders/approvals" beats ".../purchase-orders".
return rules.sort((a, b) => b.prefix.length - a.prefix.length)
}
let cached: RouteRule[] | null = null
/**
* The permission codes that open `pathname` (any one suffices), or null when the page is unguarded
* (the landing page, Help, and anything not in the menu: those are protected by the API alone).
*/
export function requiredCodesFor(pathname: string): string[] | null {
cached ??= buildRules()
const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname
const rule = cached.find((r) => path === r.prefix || path.startsWith(r.prefix + "/"))
return rule ? rule.codes : null
}
@@ -0,0 +1,36 @@
// Gathers everything a purchase-order PDF needs for ONE PO id and downloads it. Shared by the PO
// table's row action and the PO detail page, so both produce the same document. The list endpoint
// carries no lines, so the PO is always re-read in full here.
import { companyProfile } from "@/lib/api/company-profile"
import { itemsApi } from "@/lib/api/items"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { downloadPurchaseOrderPdf } from "@/lib/pdf/purchase-order-pdf"
import { ItemListItem } from "@/types/master-data"
import { ProcurementConfig } from "@/types/procurement"
export async function downloadPurchaseOrderPdfById(poId: number, config: ProcurementConfig): Promise<void> {
const [po, itemPage, uomPage, warehousePage, company] = await Promise.all([
purchaseOrdersApi.get(poId),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
companyProfile().catch(() => null),
])
// The item list is capped, so any line item that is not in it is fetched on its own.
const items: ItemListItem[] = [...itemPage.items]
const missing = [...new Set(po.lines.map((l) => l.itemId))].filter((id) => !items.some((i) => i.itemId === id))
const fetched = await Promise.all(missing.map((id) => itemsApi.get(id).then((r) => r.data).catch(() => null)))
for (const item of fetched) if (item) items.push(item as unknown as ItemListItem)
await downloadPurchaseOrderPdf({
po,
items,
uoms: uomPage.items,
warehouses: warehousePage.items,
config,
company,
})
}
@@ -0,0 +1,259 @@
// Purchase-order PDF — built in the browser with jsPDF (the backend has no PDF library), in the same
// hand-drawn style as lib/pdf/day-end-report-pdf.ts (no jspdf-autotable dependency). One PDF per PO,
// with the vendor block, the item lines and the totals. The vendor contact / address and the tax
// columns appear only when Procurement configuration has them switched on.
import type { jsPDF as JsPdf } from "jspdf"
import { formatAmount, formatReportDate } from "@/lib/format"
import { rateToPct } from "@/lib/po-tax"
import { baseUomLabel } from "@/lib/uom-label"
import { CompanyProfile } from "@/types/company-profile"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ProcurementConfig, PurchaseOrder } from "@/types/procurement"
const PAGE = { width: 210, height: 297, margin: 14 }
const BOTTOM = PAGE.height - 16
const CONTENT_W = PAGE.width - PAGE.margin * 2
type Column = { header: string; width: number; align?: "left" | "right" }
export interface PurchaseOrderPdfInput {
po: PurchaseOrder
items: readonly ItemListItem[]
uoms: readonly Uom[]
warehouses: readonly Warehouse[]
config: ProcurementConfig
company: CompanyProfile | null
}
/** Cut `text` with "..." so it fits `maxWidth` mm at the current font. */
function fit(doc: JsPdf, text: string, maxWidth: number): string {
if (doc.getTextWidth(text) <= maxWidth) return text
let cut = text
while (cut.length > 1 && doc.getTextWidth(`${cut}...`) > maxWidth) cut = cut.slice(0, -1)
return `${cut}...`
}
function statusLabel(status: string): string {
return status.replace(/([a-z])([A-Z])/g, "$1 $2")
}
/** Company block (left) and document title block (right); returns the y where content starts. */
function header(doc: JsPdf, po: PurchaseOrder, company: CompanyProfile | null): number {
const name = company?.tradeName?.trim() || company?.legalName?.trim() || ""
doc.setFont("helvetica", "bold")
doc.setFontSize(15)
doc.text(name || "Purchase Order", PAGE.margin, 18)
doc.setFont("helvetica", "normal")
doc.setFontSize(9)
doc.setTextColor(90)
let y = 23
const companyLines = [
company?.legalName && company.legalName !== name ? company.legalName : null,
[company?.addressLine1, company?.addressLine2].filter(Boolean).join(", "),
[company?.city, company?.country].filter(Boolean).join(", "),
[company?.phone, company?.email].filter(Boolean).join(" | "),
company?.taxRegistrationNo ? `Tax reg. ${company.taxRegistrationNo}` : null,
].filter((l): l is string => !!l && l.trim().length > 0)
for (const line of companyLines) {
doc.text(fit(doc, line, 105), PAGE.margin, y)
y += 4.2
}
doc.setTextColor(0)
doc.setFont("helvetica", "bold")
doc.setFontSize(18)
doc.text("PURCHASE ORDER", PAGE.width - PAGE.margin, 18, { align: "right" })
doc.setFontSize(10.5)
doc.text(po.docNo, PAGE.width - PAGE.margin, 25, { align: "right" })
doc.setFont("helvetica", "normal")
doc.setFontSize(9)
doc.setTextColor(90)
doc.text(`Status: ${statusLabel(po.status)}`, PAGE.width - PAGE.margin, 30, { align: "right" })
doc.text(`Date: ${formatReportDate(po.createdAt)}`, PAGE.width - PAGE.margin, 34.5, { align: "right" })
doc.setTextColor(0)
const bottom = Math.max(y, 38) + 2
doc.setDrawColor(200)
doc.line(PAGE.margin, bottom, PAGE.width - PAGE.margin, bottom)
return bottom + 7
}
/** "Vendor" box: name, code, and the contact / address the configuration enables. */
function vendorBlock(doc: JsPdf, y: number, po: PurchaseOrder, config: ProcurementConfig): number {
const v = po.vendor
doc.setFont("helvetica", "bold")
doc.setFontSize(10)
doc.text("Vendor", PAGE.margin, y)
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
y += 5.5
if (!v) {
doc.text(`Vendor #${po.vendorId}`, PAGE.margin, y)
return y + 8
}
doc.setFont("helvetica", "bold")
doc.text(fit(doc, v.name, CONTENT_W), PAGE.margin, y)
doc.setFont("helvetica", "normal")
y += 5
doc.setFontSize(9)
doc.setTextColor(70)
const rows: string[] = [`Code: ${v.code}`]
if (config.vendorContactMode === "Mobile" && v.mobile) rows.push(`Mobile: ${v.mobile}`)
if (config.vendorContactMode === "Email" && v.email) rows.push(`Email: ${v.email}`)
for (const row of rows) {
doc.text(fit(doc, row, CONTENT_W), PAGE.margin, y)
y += 4.4
}
if (config.vendorAddressEnabled && v.address) {
const wrapped = doc.splitTextToSize(`Address: ${v.address}`, CONTENT_W) as string[]
for (const row of wrapped) {
doc.text(row, PAGE.margin, y)
y += 4.4
}
}
doc.setTextColor(0)
return y + 4
}
/** A table that breaks across pages, repeating its header. */
function table(doc: JsPdf, y: number, columns: Column[], rows: string[][]): number {
const rowH = 6.5
const drawRow = (top: number, cells: string[]) => {
doc.setFontSize(8.5)
let x = PAGE.margin
columns.forEach((col, i) => {
const text = fit(doc, cells[i] ?? "", col.width - 3)
if (col.align === "right") doc.text(text, x + col.width - 1.5, top + 4.5, { align: "right" })
else doc.text(text, x + 1.5, top + 4.5)
x += col.width
})
}
const drawHeader = (top: number) => {
doc.setFillColor(238, 238, 236)
doc.rect(PAGE.margin, top, CONTENT_W, rowH, "F")
doc.setFont("helvetica", "bold")
drawRow(top, columns.map((c) => c.header))
doc.setFont("helvetica", "normal")
return top + rowH
}
let top = drawHeader(y)
rows.forEach((cells, idx) => {
if (top + rowH > BOTTOM) {
doc.addPage()
top = drawHeader(20)
}
if (idx % 2 === 1) {
doc.setFillColor(248, 248, 246)
doc.rect(PAGE.margin, top, CONTENT_W, rowH, "F")
}
drawRow(top, cells)
top += rowH
})
doc.setDrawColor(200)
doc.line(PAGE.margin, top, PAGE.width - PAGE.margin, top)
return top + 5
}
function totalsBlock(doc: JsPdf, y: number, po: PurchaseOrder, showTax: boolean): number {
const rows: [string, string, boolean][] = [["Sub total", formatAmount(po.totals.subTotal), false]]
if (showTax) rows.push(["Tax", formatAmount(po.totals.tax), false])
rows.push([`Total (${po.totals.currency})`, formatAmount(po.totals.grandTotal), true])
const needed = rows.length * 6 + 4
if (y + needed > BOTTOM) {
doc.addPage()
y = 20
}
const labelX = PAGE.width - PAGE.margin - 70
const valueX = PAGE.width - PAGE.margin - 1.5
for (const [label, value, strong] of rows) {
if (strong) {
doc.setDrawColor(150)
doc.line(labelX, y - 3.8, PAGE.width - PAGE.margin, y - 3.8)
}
doc.setFont("helvetica", strong ? "bold" : "normal")
doc.setFontSize(strong ? 10.5 : 9.5)
doc.text(label, labelX, y)
doc.text(value, valueX, y, { align: "right" })
y += 6
}
doc.setFont("helvetica", "normal")
return y
}
function footer(doc: JsPdf, docNo: string) {
const pages = doc.getNumberOfPages()
for (let i = 1; i <= pages; i++) {
doc.setPage(i)
doc.setFontSize(8)
doc.setTextColor(120)
doc.text(`${docNo} — generated ${new Date().toLocaleString()}`, PAGE.margin, PAGE.height - 8)
doc.text(`Page ${i} of ${pages}`, PAGE.width - PAGE.margin, PAGE.height - 8, { align: "right" })
doc.setTextColor(0)
}
}
/** Builds the PDF and triggers the browser download as `<doc no>.pdf`. */
export async function downloadPurchaseOrderPdf(input: PurchaseOrderPdfInput): Promise<void> {
const { po, items, uoms, warehouses, config, company } = input
const showTax = config.poTaxEnabled
const { jsPDF } = await import("jspdf")
const doc = new jsPDF({ unit: "mm", format: "a4" })
let y = header(doc, po, company)
y = vendorBlock(doc, y, po, config)
const columns: Column[] = showTax
? [
{ header: "#", width: 7 },
{ header: "SKU", width: 24 },
{ header: "Item", width: 40 },
{ header: "UOM", width: 14 },
{ header: "Warehouse", width: 15 },
{ header: "Qty", width: 13, align: "right" },
{ header: "Unit price", width: 19, align: "right" },
{ header: "Tax %", width: 11, align: "right" },
{ header: "Tax amt", width: 16, align: "right" },
{ header: "Line total", width: 23, align: "right" },
]
: [
{ header: "#", width: 7 },
{ header: "SKU", width: 26 },
{ header: "Item", width: 56 },
{ header: "UOM", width: 16 },
{ header: "Warehouse", width: 17 },
{ header: "Qty", width: 14, align: "right" },
{ header: "Unit price", width: 20, align: "right" },
{ header: "Line total", width: 26, align: "right" },
]
const rows = po.lines.map((line, i) => {
const item = items.find((it) => it.itemId === line.itemId)
const warehouse = warehouses.find((w) => w.warehouseId === line.warehouseId)
const net = line.qty * line.unitPrice
const base = [
String(i + 1),
item?.sku ?? `#${line.itemId}`,
item?.name ?? `Item #${line.itemId}`,
baseUomLabel(items, uoms, line.itemId),
warehouse?.code ?? `#${line.warehouseId}`,
String(line.qty),
formatAmount(line.unitPrice),
]
return showTax
? [...base, `${rateToPct(line.tax)}%`, formatAmount(line.taxAmount ?? 0), formatAmount(net + (line.taxAmount ?? 0))]
: [...base, formatAmount(net)]
})
y = table(doc, y, columns, rows)
totalsBlock(doc, y, po, showTax)
footer(doc, po.docNo)
doc.save(`${po.docNo}.pdf`)
}
+33
View File
@@ -0,0 +1,33 @@
// Purchase-order tax helpers. The UI works in percent (18 for 18%) because that is how an item's
// tax rate is stored (`taxRatePercentage`) and how people say it; the PO API carries the rate as a
// fraction 0..1 (0.18) and a stored amount. The server recomputes and stores the amount itself, so
// these figures are for display and live totals only.
/** 18 -> 0.18. Four decimals, the precision the API stores. */
export function pctToRate(pct: number): number {
if (!Number.isFinite(pct) || pct <= 0) return 0
return Math.round(pct * 100) / 10000
}
/** 0.18 -> 18. */
export function rateToPct(rate: number): number {
if (!Number.isFinite(rate) || rate <= 0) return 0
return Math.round(rate * 10000) / 100
}
/**
* Tax amount for a line: qty x unit price x pct%, rounded half-up to 2 dp like the server
* (`PurchaseOrderService.TaxAmountFor`). The epsilon keeps 1.005-style products from rounding down.
*/
export function lineTaxAmount(qty: number, unitPrice: number, pct: number): number {
const q = Number(qty)
const p = Number(unitPrice)
const t = Number(pct)
if (![q, p, t].every(Number.isFinite) || q <= 0 || p < 0 || t <= 0) return 0
return Math.round(q * p * t + 1e-9) / 100
}
/** The text of a percent for an input box: 18 -> "18", 8.25 -> "8.25", 0 -> "0". */
export function pctText(pct: number | null | undefined): string {
return pct === null || pct === undefined || !Number.isFinite(pct) ? "0" : String(pct)
}
@@ -46,6 +46,7 @@ export function validatePoLine(input: {
if (input.unitPrice === "" || Number.isNaN(unitPrice) || unitPrice < 0) errors.unitPrice = "Unit price cannot be negative"
const tax = Number(input.tax)
if (input.tax === "" || Number.isNaN(tax) || tax < 0) errors.tax = "Tax rate cannot be negative"
else if (tax > 1) errors.tax = "Tax cannot exceed 100%"
return errors
}
+2 -2
View File
@@ -46,8 +46,8 @@ export interface ApprovalConfig {
priceAdjustmentLevels: number
/** With 2 levels: price changes smaller than this % need one approval. 0 = always two. */
priceAdjustmentLevel2ThresholdPercent: number
/** Only these roles may give the second approval; empty = anyone with the Approve action. */
level2RoleCodes: string[]
/** Only these roles (ids) may give the second approval; empty = anyone with the Approve action. */
level2RoleIds: number[]
updatedAt: string | null
}
+7
View File
@@ -1,3 +1,5 @@
import { PoVendor } from "@/types/procurement"
// Goods Receipt Note types (docs/11-BACKEND-PHASE1.md §4; FR-GRN-01..08).
// Mirrors the live backend DTOs. GET /grns and GET /grns/{id} both exist as of 2026-07-16.
// Serial numbers (FR-GRN-04) are captured per unit for Serial-tracked items — see
@@ -13,6 +15,7 @@ export type PurchaseOrderStatus =
| "FullyReceived"
| "Closed"
| "Cancelled"
| "Rejected"
export interface BatchInput {
batchNo: string
@@ -141,6 +144,10 @@ export interface GrnSummary {
totalValue: number
purchaseInvoiceId: number | null
purchaseInvoiceNo: string | null
/** The purchase order's document number, when this GRN is against one. */
poDocNo?: string | null
/** Vendor block; mobile / email / address are null unless the matching Procurement configuration switch is on. */
vendor?: PoVendor | null
}
/** docs/11 §4.2 confirm response. */
+8
View File
@@ -221,6 +221,10 @@ export interface Vendor {
terms: string | null
taxReg: string | null
currency: string
/** Optional contact details — recorded only while Procurement configuration enables them. */
mobile?: string | null
email?: string | null
address?: string | null
status: EntityStatus
/** Resolved server-side via `IBranchConfigService.ResolveBranchIdAsync` — never posted by the form. */
branchId: number
@@ -234,6 +238,10 @@ export interface CreateVendorRequest {
terms?: string | null
taxReg?: string | null
currency: string
/** Sent only while the matching Procurement configuration flag is on (else 422 CONFIG_DISABLED). */
mobile?: string | null
email?: string | null
address?: string | null
/** Required when branching is enabled — resolved client-side from the logged-in user's
* own branch, or picked by an Admin viewing "All Branches" (see `useAuth()`). */
branchId?: number
@@ -0,0 +1,78 @@
// Opening balances (cash and bank). One balanced journal in the General Ledger, posted once.
export interface OpeningCashBankAccount {
accountType: "Cash" | "Bank" | string
accountName: string
bankName: string | null
accountNumber: string | null
glAccountCode: string
currencyCode: string
/** Filled only once the opening balance has been posted. */
amount: number | null
}
export interface OpeningBalanceLine {
accountCode: string
accountName: string
amount: number
}
/** The record of the one-time posting. */
export interface OpeningBalanceRun {
openingBalanceRunId: number
kind: string
/** YYYY-MM-DD — the journal's entry date. */
cutoverDate: string
glJournalNo: string | null
/** Net amount credited to Opening Balance Equity. */
totalAmount: number
currency: string
postedAt: string
postedBy: number
lines: OpeningBalanceLine[]
}
export interface CashBankOpeningState {
posted: OpeningBalanceRun | null
accounts: OpeningCashBankAccount[]
baseCurrency: string
}
export interface PostCashBankOpeningRequest {
cutoverDate: string
lines: { glAccountCode: string; amount: number }[]
}
// ── Opening stock ────────────────────────────────────────────────────────────────────────
export interface OpeningStockLine {
itemId: number
sku: string
itemName: string
warehouseId: number
warehouseCode: string
/** Base-UOM quantity. */
qty: number
unitCost: number
value: number
}
export interface OpeningStockRunSummary {
openingBalanceRunId: number
/** YYYY-MM-DD */
cutoverDate: string
glJournalNo: string | null
totalValue: number
lineCount: number
postedAt: string
postedBy: number
}
export interface OpeningStockRun extends OpeningStockRunSummary {
lines: OpeningStockLine[]
}
export interface PostOpeningStockRequest {
cutoverDate: string
lines: { itemId: number; warehouseId: number; qty: number; unitCost: number }[]
}
+40
View File
@@ -141,6 +141,7 @@ export type PurchaseOrderStatus =
| "FullyReceived"
| "Closed"
| "Cancelled"
| "Rejected"
export interface PoLine {
poLineId: number
@@ -151,6 +152,18 @@ export interface PoLine {
/** Rate, 0..1 (e.g. 0.18), not an amount. */
tax: number
qtyReceived: number
/** Stored tax amount for the line (0 while PO tax is switched off). */
taxAmount: number
}
/** Vendor block on a PO. mobile/email/address are null unless Procurement configuration enables them. */
export interface PoVendor {
vendorId: number
code: string
name: string
mobile: string | null
email: string | null
address: string | null
}
export interface PoTotals {
@@ -177,6 +190,7 @@ export interface PurchaseOrder {
updatedAt: string | null
totals: PoTotals
lines: PoLine[]
vendor?: PoVendor | null
}
export interface PurchaseOrderSummary {
@@ -189,6 +203,7 @@ export interface PurchaseOrderSummary {
createdBy: number
createdAt: string
totals: PoTotals
vendor?: PoVendor | null
}
export interface CreatePoLineInput {
@@ -276,3 +291,28 @@ export interface CreatePurchaseReturnRequest {
reasonCodeId: number
lines: CreatePurchaseReturnLineInput[]
}
// ── Procurement configuration (Settings → Configuration → Procurement) ─────────────────────
/** Which single contact detail vendors carry. */
export type VendorContactMode = "None" | "Mobile" | "Email"
export interface ProcurementConfig {
vendorContactMode: VendorContactMode
vendorAddressEnabled: boolean
/** Optional payment terms / tax registration on vendors (both on by default). */
vendorPaymentTermsEnabled: boolean
vendorTaxRegEnabled: boolean
/** When on, PO lines carry a tax rate (prefilled from the item) and a tax amount. */
poTaxEnabled: boolean
updatedAt: string | null
updatedBy: number | null
}
/** Full replacement — every flag is required. */
export interface UpdateProcurementConfigRequest {
vendorContactMode: VendorContactMode
vendorAddressEnabled: boolean
vendorPaymentTermsEnabled: boolean
vendorTaxRegEnabled: boolean
poTaxEnabled: boolean
}
+8
View File
@@ -9,6 +9,8 @@ export interface Role {
status: EntityStatus
createdAt: string
updatedAt: string | null
/** The administrator role: its holders pass every permission check. Set by the system, never through the API. */
isAdmin?: boolean
}
export interface CreateRoleRequest {
@@ -77,4 +79,10 @@ export interface MeResponse {
branchName: string | null
branchingEnabled: boolean
warehouseBranchingEnabled: boolean
/** The ERP role the person holds right now (read from the ERP, so a change applies on the next request). */
roleId?: number | null
/** False when the person has no usable role (none assigned and no default, or the role is inactive). */
hasRole?: boolean
/** Nav code -> granted actions bitmask (see RolePermissionAction). Administrators hold every code with all actions. */
permissions?: Record<string, number>
}
+15
View File
@@ -97,6 +97,14 @@ export interface SalesConfig {
defaultWarehouseName: string | null
updatedAt: string | null
updatedBy: number | null
/** Optional customer fields. Each is on by default; when off the customer forms hide it and the server refuses a new value. */
customerPhoneEnabled: boolean
customerEmailEnabled: boolean
customerAddressEnabled: boolean
customerCityEnabled: boolean
customerCountryEnabled: boolean
customerCreditLimitEnabled: boolean
customerCreditDaysEnabled: boolean
}
export interface UpdateSalesConfigRequest {
@@ -107,6 +115,13 @@ export interface UpdateSalesConfigRequest {
allowWarehouseSelection: boolean
/** Required when `allowWarehouseSelection` is false. */
defaultWarehouseId: number | null
customerPhoneEnabled: boolean
customerEmailEnabled: boolean
customerAddressEnabled: boolean
customerCityEnabled: boolean
customerCountryEnabled: boolean
customerCreditLimitEnabled: boolean
customerCreditDaysEnabled: boolean
}
/** Issuable stock for one item at one warehouse. `isStocked` false = non-stock item, never short. */