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>