sales & grn fixers

This commit is contained in:
2026-10-04 15:31:59 +05:30
parent cea433e320
commit 3d3dbbbb59
52 changed files with 20761 additions and 482 deletions
@@ -109,7 +109,8 @@ export default function CustomersPage() {
useEffect(load, [query, status, customerType, page, pageSize])
useEffect(() => {
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
// Every code in use, the hidden cash customer's included, so a generated code never collides.
customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
}, [])
// Admin-only branch picker — non-admins never see this control, their own employee
@@ -190,19 +190,6 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
{vendor?.code && <div className="text-sm text-muted-foreground">Code: {vendor.code}</div>}
{invoice.vendorSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
</div>
{layout.arrangement !== "highlight" && (
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div>
<div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
<div className="text-muted-foreground">VAT</div>
<div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
<div className="text-muted-foreground">Grand total</div>
<div className="text-right font-semibold">{formatAmount(invoice.totals.grandTotal)}</div>
</div>
</div>
)}
</div>
<div className="overflow-x-auto border-y">
@@ -234,6 +221,21 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
</Table>
</div>
{/* Totals sit under the GRN lines, right-aligned, like an invoice footer. */}
{layout.arrangement !== "highlight" && (
<div className="ml-auto mt-4 w-full max-w-xs">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div>
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.subtotal)}</div>
<div className="text-muted-foreground">VAT</div>
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.vatTotal)}</div>
<div className="border-t pt-2 text-muted-foreground">Grand total</div>
<div className="border-t pt-2 text-right font-semibold tabular-nums">{formatAmount(invoice.totals.grandTotal)}</div>
</div>
</div>
)}
<InvoicePrintFooter company={company} template={template} />
<div className="mt-6 border-t pt-5">
@@ -37,6 +37,7 @@ import { toast } from "@/components/ui/toast"
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"
function docLink(sourceDocType: string, sourceDocId: number): string | null {
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
@@ -419,6 +420,16 @@ export default function ItemDetailPage() {
dialog closes so this copy picks up what changed there. */}
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
<ItemReorderPanel
key={item.itemId}
itemId={item.itemId}
stockNature={item.stockNature}
settings={item.reorder}
warehouses={warehouses}
uomName={uomName(item.baseUomId)}
onSaved={(settings) => setItem((prev) => (prev ? { ...prev, reorder: settings } : prev))}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
@@ -8,11 +8,13 @@ import {
ArrowDown,
ArrowUp,
ArrowUpDown,
CircleDollarSign,
Download,
Eye,
ListFilter,
ListTree,
Package,
PackageX,
Pencil,
Plus,
} from "lucide-react"
@@ -25,6 +27,7 @@ import { errorMessage } from "@/lib/error-map"
import { downloadCsv } from "@/lib/csv-export"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory, TrackingMode } from "@/types/master-data"
@@ -36,6 +39,7 @@ import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type StatusFilter = EntityStatus | "All"
@@ -123,6 +127,8 @@ export default function ItemsPage() {
const [config, setConfig] = useState<ProductConfig | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [itemStats, setItemStats] = useState<{ total: number; lowStock: number; outOfStock: number } | null>(null)
const dashboardStats = useDashboardStats()
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
@@ -211,6 +217,29 @@ export default function ItemsPage() {
setLoadingMore(true)
setPage((p) => p + 1)
}
// Catalogue-wide counts for the stat cards — independent of the list filters. Each is a
// 1-row overview call read for its total, so "Low/Out of stock" match the Stock level filter.
useEffect(() => {
let cancelled = false
Promise.all([
itemsApi.overview({ page: 1, pageSize: 1 }),
itemsApi.overview({ page: 1, pageSize: 1, stock: "LowStock" }),
itemsApi.overview({ page: 1, pageSize: 1, stock: "OutOfStock" }),
])
.then(([total, low, out]) => {
if (cancelled) return
setItemStats({
total: total.pagination.totalItems,
lowStock: low.pagination.totalItems,
outOfStock: out.pagination.totalItems,
})
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
@@ -331,6 +360,24 @@ export default function ItemsPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{itemStats ? (
<>
<StatCard label="Total items" value={itemStats.total} icon={Package} />
<StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} />
<StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} />
</>
) : (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
)}
{/* Passed as a string so StatCard shows the full 2dp amount, not its compact "1.2M" form. */}
{dashboardStats ? (
<StatCard label="Stock value" value={formatMoney(dashboardStats.stockValuationTotal)} icon={CircleDollarSign} />
) : (
<Skeleton className="h-22.5 rounded-xl" />
)}
</div>
{/* Same filter bar as the Sales list, but search on the left and every filter
(category, status, "Advanced" popover) grouped on the right. */}
<FilterBar
@@ -1,9 +1,9 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
import { ScanInput } from "@/components/scanning/ScanInput"
import { ApiError } from "@/lib/api-client"
@@ -14,11 +14,12 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { grnConfigApi } from "@/lib/api/grn-config"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
import { cn } from "@/lib/utils"
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
import { CreateGrnLineInput, GrnConfig, HoldStatus } from "@/types/grn"
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
@@ -30,6 +31,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
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"
@@ -197,10 +199,21 @@ export default function NewGrnPage() {
const [loadError, setLoadError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [grnConfig, setGrnConfig] = useState<GrnConfig | null>(null)
// A saved default receiving warehouse (GRN Configuration) pins every GRN to it — the picker is
// replaced by a locked field, and the server forces it too.
const allowWarehouseSelection = !grnConfig?.defaultWarehouseId && (grnConfig?.allowWarehouseSelection ?? true)
// GRN Configuration "Allow discounts" / "Allow VAT" off: those inputs are hidden and every line is
// treated (and sent) as 0% — the server rejects anything else.
const allowDiscounts = grnConfig?.allowDiscounts ?? true
const allowVat = grnConfig?.allowVat ?? true
function effectiveLine(l: DraftLine): DraftLine {
return { ...l, discountPct: allowDiscounts ? l.discountPct : "0", vatPct: allowVat ? l.vatPct : "0" }
}
const [vendorId, setVendorId] = useState<number | null>(null)
const [poId, setPoId] = useState<number | null>(null)
const [poLoading, setPoLoading] = useState(false)
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
const [lines, setLines] = useState<DraftLine[]>([])
const [lineTab, setLineTab] = useState<LineTab>("lines")
// Whole-GRN discount/VAT: entering either one here applies that same rate to every
@@ -240,8 +253,12 @@ export default function NewGrnPage() {
uomsApi.list(),
vendorsApi.list({ pageSize: 200, status: "Active" }),
purchaseOrdersApi.list({ pageSize: 200 }),
// Missing/failed config just means "pick a warehouse" — never block the form on it.
grnConfigApi.get().then((r) => r.data).catch(() => null),
])
.then(([wh, it, uo, ve, po]) => {
.then(([wh, it, uo, ve, po, config]) => {
setGrnConfig(config)
if (config?.defaultWarehouseId) setWarehouseId(config.defaultWarehouseId)
setWarehouses(wh.items)
setItems(it.items)
setUoms(uo.items)
@@ -281,7 +298,7 @@ export default function NewGrnPage() {
setMode(next)
setPoId(null)
setVendorId(null)
setLines([emptyLine()])
setLines([])
setHeaderDiscountPct("")
setHeaderVatPct("")
setHeaderError(null)
@@ -291,7 +308,7 @@ export default function NewGrnPage() {
async function handleSelectPo(nextPoId: number | null) {
setPoId(nextPoId)
if (!nextPoId) {
setLines([emptyLine()])
setLines([])
return
}
@@ -305,7 +322,8 @@ export default function NewGrnPage() {
setLines([])
return
}
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
// A pinned default receiving warehouse is never replaced by the PO's warehouse.
if (allowWarehouseSelection) setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
setLines(
openLines.map((l): DraftLine => {
const draft: DraftLine = {
@@ -357,40 +375,7 @@ export default function NewGrnPage() {
try {
const result = await itemsApi.scan(code)
const scannedBatchNo = result.matchedBy === "Batch" ? (result.batchNo ?? "") : ""
setLines((prev) => {
const existingIdx = prev.findIndex(
(l) => l.itemId === result.itemId && l.batchNo.trim() === scannedBatchNo.trim()
)
if (existingIdx >= 0) {
const next = [...prev]
const merged: DraftLine = {
...next[existingIdx],
qty: String((Number(next[existingIdx].qty) || 0) + 1),
batchNo: scannedBatchNo || next[existingIdx].batchNo,
}
next[existingIdx] = { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
return next
}
// Fill the first still-blank row (itemId never picked) instead of piling on a new
// one below it — the untouched starter row, or one left empty after its item was
// cleared, would otherwise sit there forever while every scan adds another line.
const blankIdx = prev.findIndex((l) => l.itemId === null)
const draft: DraftLine = {
...emptyLine(),
itemId: result.itemId,
qty: "1",
batchNo: scannedBatchNo,
discountPct: headerDiscountPct.trim() || "0",
vatPct: headerVatPct.trim() || "0",
}
const filled = { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
if (blankIdx >= 0) {
const next = [...prev]
next[blankIdx] = { ...filled, key: next[blankIdx].key }
return next
}
return [...prev, filled]
})
addItemLine(result.itemId, scannedBatchNo)
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setScanError(`No item or lot found for "${code}"`)
@@ -402,6 +387,45 @@ export default function NewGrnPage() {
}
}
// Shared by the scan box and the "Add item" search: bumps qty on an existing line for the same
// item + batch, else fills the first blank row or appends a new line for it.
function addItemLine(itemId: number, batchNo = "") {
setLines((prev) => {
const existingIdx = prev.findIndex(
(l) => l.itemId === itemId && l.batchNo.trim() === batchNo.trim()
)
if (existingIdx >= 0) {
const next = [...prev]
const merged: DraftLine = {
...next[existingIdx],
qty: String((Number(next[existingIdx].qty) || 0) + 1),
batchNo: batchNo || next[existingIdx].batchNo,
}
next[existingIdx] = { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
return next
}
// Fill the first still-blank row (itemId never picked) instead of piling on a new
// one below it — the untouched starter row, or one left empty after its item was
// cleared, would otherwise sit there forever while every scan adds another line.
const blankIdx = prev.findIndex((l) => l.itemId === null)
const draft: DraftLine = {
...emptyLine(),
itemId,
qty: "1",
batchNo,
discountPct: headerDiscountPct.trim() || "0",
vatPct: headerVatPct.trim() || "0",
}
const filled = { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
if (blankIdx >= 0) {
const next = [...prev]
next[blankIdx] = { ...filled, key: next[blankIdx].key }
return next
}
return [...prev, filled]
})
}
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) =>
prev.map((l) => {
@@ -420,6 +444,24 @@ export default function NewGrnPage() {
return items?.find((i) => i.itemId === itemId) ?? null
}
const itemOptions = useMemo<SuggestOption[]>(
() =>
(items ?? []).map((i) => {
const uom = (uoms ?? []).find((u) => u.uomId === i.baseUomId)?.name
const detail = [uom, i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null, i.taxRateName ? `Tax: ${i.taxRateName}` : null]
.filter(Boolean)
.join(" · ")
return {
value: String(i.itemId),
label: `${i.sku} — ${i.name}`,
detail: detail || undefined,
badge: i.warranty === "Warranty" ? "Warranty" : undefined,
badgeTone: "muted",
}
}),
[items, uoms],
)
function setWarrantyNumberAt(lineKey: string, index: number, value: string) {
setLines((prev) =>
prev.map((l) => {
@@ -531,8 +573,8 @@ export default function NewGrnPage() {
itemId: line.itemId,
qty: line.qty,
unitCost: line.unitCost,
discountPct: line.discountPct,
vatPct: line.vatPct,
discountPct: allowDiscounts ? line.discountPct : "0",
vatPct: allowVat ? line.vatPct : "0",
warranty: item?.warranty ?? null,
warrantyNumbers: line.warrantyNumbers,
})
@@ -557,8 +599,8 @@ export default function NewGrnPage() {
binId: l.binId,
qty: Number(l.qty),
unitCost: Number(l.unitCost),
discountPct: Number(l.discountPct) || 0,
vatPct: Number(l.vatPct) || 0,
discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0,
vatPct: allowVat ? Number(l.vatPct) || 0 : 0,
holdStatus: l.holdStatus,
warrantyNumbers:
item?.warranty === "Warranty"
@@ -690,18 +732,28 @@ export default function NewGrnPage() {
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{visibleWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
{allowWarehouseSelection ? (
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{visibleWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
// Pinned to the default receiving warehouse — shown, not editable (same as New Sale).
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{(() => {
const w = visibleWarehouses.find((x) => x.warehouseId === warehouseId)
return w ? `${w.code} — ${w.name}` : (grnConfig?.defaultWarehouseName ?? "No default warehouse set")
})()}
</div>
)}
</div>
</div>
@@ -709,37 +761,43 @@ export default function NewGrnPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
)}
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
<div className="flex flex-1 flex-col gap-2">
<Label className="text-base">Whole-GRN discount %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
value={headerDiscountPct}
onChange={(e) => applyHeaderDiscountPct(e.target.value)}
placeholder="Applies to every line"
className="h-11 text-base"
/>
{(allowDiscounts || allowVat) && (
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
{allowDiscounts && (
<div className="flex flex-1 flex-col gap-2">
<Label className="text-base">Whole-GRN discount %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
value={headerDiscountPct}
onChange={(e) => applyHeaderDiscountPct(e.target.value)}
placeholder="Applies to every line"
className="h-11 text-base"
/>
</div>
)}
{allowVat && (
<div className="flex flex-1 flex-col gap-2">
<Label className="text-base">Whole-GRN VAT %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
value={headerVatPct}
onChange={(e) => applyHeaderVatPct(e.target.value)}
placeholder="Applies to every line"
className="h-11 text-base"
/>
</div>
)}
<p className="text-sm text-muted-foreground sm:max-w-64">
Setting a rate here applies it to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
</p>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label className="text-base">Whole-GRN VAT %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
value={headerVatPct}
onChange={(e) => applyHeaderVatPct(e.target.value)}
placeholder="Applies to every line"
className="h-11 text-base"
/>
</div>
<p className="text-sm text-muted-foreground sm:max-w-64">
Setting either one applies that rate to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
</p>
</div>
)}
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-input p-4">
<ScanInput
@@ -758,30 +816,11 @@ export default function NewGrnPage() {
<h2 className="text-base font-semibold text-foreground">Lines</h2>
{mode === "po" && (
<p className="text-sm text-muted-foreground">
PO lines are prefilled. Use “Add line” to receive an item that isn’t on the PO.
PO lines are prefilled. Search for an item below to also receive one that isn’t on the PO.
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Off-PO items are allowed on a PO-based GRN — the server treats a line with
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
<Button
type="button"
variant="outline"
onClick={() =>
setLines((prev) => [
...prev,
{
...emptyLine(),
discountPct: headerDiscountPct.trim() || "0",
vatPct: headerVatPct.trim() || "0",
},
])
}
>
<Plus className="size-5" />
Add line
</Button>
{/* Create a brand-new item in a separate tab, then refresh to pick it up. */}
<Button
type="button"
@@ -833,14 +872,35 @@ export default function NewGrnPage() {
{poLoading && <Skeleton className="h-24 w-full" />}
{!poLoading && lineTab === "lines" && lines.length > 0 && (
{!poLoading && lineTab === "lines" && (
<>
{/* Mobile has no table, so the "Add item" box sits above the cards; on desktop it is
the last row of the table, under the Item column (same layout as Sales). */}
<SuggestInput
options={itemOptions}
onSelect={(v) => addItemLine(Number(v))}
// A barcode typed/scanned here (no suggestion matches it) is looked up as a scan.
onEnterWithoutMatch={handleGrnScan}
browsable
keepOpenOnSelect
placeholder="Add item — type SKU or name…"
emptyText="No items match."
className="w-full md:hidden"
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
aria-label="Add item"
/>
{lines.length === 0 && (
<div className="rounded-lg border border-dashed py-10 text-center text-sm text-muted-foreground md:hidden">
No items yet — start typing in &quot;Add item&quot; above, or scan a barcode.
</div>
)}
{/* Mobile: stacked editable cards. */}
<div className="flex flex-col gap-3 md:hidden">
{lines.map((line) => {
const item = itemFor(line.itemId)
const errors = lineErrors[line.key] ?? {}
const c = computeLine(line)
const c = computeLine(effectiveLine(line))
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start gap-2">
@@ -853,19 +913,7 @@ export default function NewGrnPage() {
</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.sku} — {i.name}
{i.warranty === "Warranty" ? " (Warranty)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
{item?.warranty === "Warranty" && (
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
)}
@@ -949,38 +997,44 @@ export default function NewGrnPage() {
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Disc %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
inputMode="decimal"
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
disabled={headerDiscountPct.trim() !== ""}
className="h-11 text-sm"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</div>
<div>
<Label className="text-sm">VAT %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
inputMode="decimal"
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
disabled={headerVatPct.trim() !== ""}
className="h-11 text-sm"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
</div>
{allowDiscounts && (
<div>
<Label className="text-sm">Disc %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
inputMode="decimal"
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
disabled={headerDiscountPct.trim() !== ""}
className="h-11 text-sm"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</div>
)}
{allowVat && (
<div>
<Label className="text-sm">VAT %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
inputMode="decimal"
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
disabled={headerVatPct.trim() !== ""}
className="h-11 text-sm"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
</div>
)}
</div>
<div>
@@ -1021,14 +1075,14 @@ export default function NewGrnPage() {
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-80 min-w-80 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Batch no</TableHead>
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>
{allowDiscounts && <TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>}
{allowVat && <TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>}
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-6 px-1 text-sm">Action</TableHead>
@@ -1051,19 +1105,7 @@ export default function NewGrnPage() {
</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.sku} — {i.name}
{i.warranty === "Warranty" ? " (Warranty)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
{item?.warranty === "Warranty" && (
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
)}
@@ -1131,39 +1173,45 @@ export default function NewGrnPage() {
</p>
)}
</TableCell>
{allowDiscounts && (
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="any"
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
disabled={headerDiscountPct.trim() !== ""}
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
className="h-11 text-sm"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</TableCell>
<Input
type="number"
min="0"
max="100"
step="any"
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
disabled={headerDiscountPct.trim() !== ""}
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
className="h-11 text-sm"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</TableCell>
)}
{allowVat && (
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="any"
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
disabled={headerVatPct.trim() !== ""}
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
className="h-11 text-sm"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
</TableCell>
<Input
type="number"
min="0"
max="100"
step="any"
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
disabled={headerVatPct.trim() !== ""}
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
className="h-11 text-sm"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
</TableCell>
)}
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
{(() => {
const c = computeLine(line)
const c = computeLine(effectiveLine(line))
return (
<div className="flex h-11 flex-col justify-center">
<span>{c.lineTotal.toFixed(2)}</span>
@@ -1224,6 +1272,24 @@ export default function NewGrnPage() {
</TableRow>
)
})}
<TableRow className="hover:bg-transparent">
<TableCell className="px-3 py-3 align-top">
<SuggestInput
options={itemOptions}
onSelect={(v) => addItemLine(Number(v))}
onEnterWithoutMatch={handleGrnScan}
browsable
keepOpenOnSelect
placeholder="Search item — SKU or name…"
emptyText="No items match."
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
aria-label="Add item"
/>
</TableCell>
<TableCell colSpan={9 + (allowDiscounts ? 1 : 0) + (allowVat ? 1 : 0)} className="px-3 py-3 align-middle text-sm text-muted-foreground">
{lines.length === 0 && "No items yet — search for an item, or scan a barcode."}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
@@ -1350,9 +1416,9 @@ export default function NewGrnPage() {
{!poLoading && lineTab === "lines" && lines.length > 0 && (
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
<span className="text-muted-foreground">Document total (incl. VAT)</span>
<span className="text-muted-foreground">Document total{allowVat ? " (incl. VAT)" : ""}</span>
<span className="font-semibold tabular-nums">
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
{lines.reduce((sum, l) => sum + computeLine(effectiveLine(l)).lineTotal, 0).toFixed(2)}
</span>
</div>
)}
@@ -16,7 +16,7 @@ import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { salesConfigApi } from "@/lib/api/sales-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { CompanyProfile } from "@/types/company-profile"
@@ -103,7 +103,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
}
Promise.all([
customersApi.list({ pageSize: 200 }),
// Includes the cash customer: a cash sale's draft must still show (and keep) it in the picker.
customersApi.list({ pageSize: 200, includeCashCustomer: true }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
@@ -232,7 +233,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
}
function resolveLineUnitPrice(line: Line): number | null {
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
if (line.unitPrice !== null && line.unitPrice !== undefined) return roundPrice(Number(line.unitPrice))
return getSuggestedUnitPrice(items, line.itemId)
}
@@ -386,11 +387,10 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div>
)}
<div className={`grid gap-6 py-5 ${layout.arrangement === "highlight" ? "md:grid-cols-2" : "md:grid-cols-3"}`}>
<div className="grid gap-6 py-5 md:grid-cols-2">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</div>
<div className="mt-2 font-semibold text-foreground">{invoice.customerSnapshotName}</div>
<div className="text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
{customer?.displayName ? <div className="text-sm text-muted-foreground">Registered name: {customer.displayName}</div> : null}
{invoice.customerSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
</div>
@@ -399,23 +399,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
</div>
{layout.arrangement !== "highlight" && (
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div>
<div className="text-right font-medium">{money.format(invoice.totals.subtotal)}</div>
<div className="text-muted-foreground">Discount</div>
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
<div className="text-muted-foreground">Free qty</div>
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
<div className="text-muted-foreground">Tax</div>
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
<div className="text-muted-foreground">Net payable</div>
<div className="text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
</div>
</div>
)}
</div>
<div className="overflow-x-auto border-y">
@@ -452,6 +435,28 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</table>
</div>
{/* Totals sit under the lines, right-aligned — where an invoice's totals are read. The
"highlight" layout shows Net payable as a banner up top instead. */}
{layout.arrangement !== "highlight" && (
<div className="flex justify-end py-5">
<div className="w-full max-w-xs">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div>
<div className="text-right font-medium">{money.format(invoice.totals.subtotal)}</div>
<div className="text-muted-foreground">Discount</div>
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
<div className="text-muted-foreground">Free qty</div>
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
<div className="text-muted-foreground">Tax</div>
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
<div className="border-t pt-2 text-muted-foreground">Net payable</div>
<div className="border-t pt-2 text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
</div>
</div>
</div>
)}
{invoice.lines.some((line) => line.freeQty > 0) ? (
<div className="py-5">
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Free issue summary</div>
@@ -610,7 +615,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
min="0"
step="0.01"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
<div className="mt-1 text-[11px] text-muted-foreground">
@@ -12,16 +12,17 @@ import { FieldError } 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 } from "@/components/ui/suggest-input"
import { SuggestBadgeTone, SuggestInput } from "@/components/ui/suggest-input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { ApiError } from "@/lib/api-client"
import { salesApi } from "@/lib/api/sales"
import { salesConfigApi } from "@/lib/api/sales-config"
import { stockApi } from "@/lib/api/stock"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
@@ -110,6 +111,8 @@ const lkr = new Intl.NumberFormat("en-LK", {
minimumFractionDigits: 2,
})
const qtyFormat = new Intl.NumberFormat("en-LK", { maximumFractionDigits: 3 })
function NewSalesInvoiceContent() {
const router = useRouter()
const searchParams = useSearchParams()
@@ -160,6 +163,17 @@ function NewSalesInvoiceContent() {
() => customers.filter((c) => c.customerId !== salesConfig?.cashCustomerId),
[customers, salesConfig],
)
// The dropdown rows carry extra customer details, so pass explicit trigger labels — otherwise
// the Select wrapper derives them from the rows and the closed trigger shows the details too.
const customerSelectItems = useMemo(() => {
const items = regularCustomers.map((c) => ({
value: String(c.customerId),
label: `${c.customerCode} — ${c.displayName ?? c.name}`,
}))
if (cashCustomerId !== null)
items.unshift({ value: String(cashCustomerId), label: salesConfig?.cashCustomerName ?? "Cash Customer" })
return items
}, [regularCustomers, cashCustomerId, salesConfig])
function selectCustomer(nextCustomerId: number | null) {
setCustomerId(nextCustomerId)
@@ -358,7 +372,7 @@ function NewSalesInvoiceContent() {
let price = resolvedPrices[key]
if (price === undefined) {
try {
price = (await salesApi.getSuggestedPrice(itemId, warehouseId)).unitPrice
price = roundPrice((await salesApi.getSuggestedPrice(itemId, warehouseId)).unitPrice)
} catch {
return
}
@@ -436,15 +450,78 @@ function NewSalesInvoiceContent() {
}
}
// Issuable stock per item at the sale's warehouse, for the "Add item" dropdown. Reloaded when the
// warehouse changes; null until loaded (or with no warehouse), shown as "Stock: —" then. Falls back
// to the stock enquiry list (all pages) if the sales stock-summary endpoint isn't reachable.
const [stockByItem, setStockByItem] = useState<Map<number, number> | null>(null)
useEffect(() => {
if (!warehouseId) return
let cancelled = false
async function load(whId: number): Promise<Map<number, number>> {
try {
const rows = await salesApi.getStockSummary(whId)
return new Map(rows.map((r) => [r.itemId, r.available]))
} catch {
// Older backend without stock-summary: try the stock enquiry list (needs the "stock"
// permission), else ask the per-item sales endpoint for each stocked item.
const map = new Map<number, number>()
try {
for (let page = 1; ; page++) {
const res = await stockApi.onHandList({ warehouseId: whId, page, pageSize: 200 })
res.items.forEach((r) => map.set(r.itemId, r.available))
if (res.items.length < 200) break
}
return map
} catch {
const queue = items.filter((i) => i.stockNature === "Stocked").map((i) => i.itemId)
const worker = async () => {
for (let id = queue.shift(); id !== undefined && !cancelled; id = queue.shift()) {
const a = await salesApi.getStockAvailability(id, whId).catch(() => null)
if (a) map.set(id, a.available)
}
}
await Promise.all(Array.from({ length: 6 }, worker))
return map
}
}
}
load(warehouseId)
.then((map) => !cancelled && setStockByItem(map))
.catch(() => {})
return () => {
cancelled = true
setStockByItem(null)
}
}, [warehouseId, items])
// The "Add item" box doubles as bundle search: item values are prefixed "item:" and bundle
// template values "bundle:" so one handler can tell them apart on select.
// template values "bundle:" so one handler can tell them apart on select. Each item row carries a
// second line with its available stock and key details (UOM, batch/serial tracking, warranty, tax).
const itemOptions = useMemo(
() =>
items.map((i) => {
const price = getSuggestedUnitPrice(items, i.itemId)
return { value: `item:${i.itemId}`, label: `${i.sku} — ${i.name}`, hint: price !== null ? lkr.format(price) : undefined }
const stocked = i.stockNature === "Stocked"
const qty = stockByItem ? (stockByItem.get(i.itemId) ?? 0) : null
const uom = uoms.find((u) => u.uomId === i.baseUomId)?.name
const detail = [
uom,
i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null,
i.warranty === "Warranty" && i.warrantyPeriodMonths ? `${i.warrantyPeriodMonths}m warranty` : null,
i.taxRateName ? `Tax: ${i.taxRateName}` : null,
]
.filter(Boolean)
.join(" · ")
return {
value: `item:${i.itemId}`,
label: `${i.sku} — ${i.name}`,
hint: price !== null ? lkr.format(price) : undefined,
detail: detail || undefined,
badge: !stocked ? "Non-stock" : qty === null ? "Stock: —" : `Stock: ${qtyFormat.format(qty)}`,
badgeTone: (!stocked || qty === null ? "muted" : qty > 0 ? "success" : "danger") as SuggestBadgeTone,
}
}),
[items],
[items, uoms, stockByItem],
)
const bundleTemplateOptions = useMemo(
@@ -612,7 +689,7 @@ function NewSalesInvoiceContent() {
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: allowFreeItems ? Number(line.freeQty) : 0,
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
unitPrice: line.unitPrice === null ? null : roundPrice(Number(line.unitPrice)),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: allowDiscounts ? Number(line.discountPct) : 0,
@@ -661,7 +738,11 @@ function NewSalesInvoiceContent() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Customer</Label>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => selectCustomer(v ? Number(v) : null)}>
<Select
value={customerId ? String(customerId) : ""}
onValueChange={(v) => selectCustomer(v ? Number(v) : null)}
items={customerSelectItems}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select customer" />
</SelectTrigger>
@@ -671,11 +752,17 @@ function NewSalesInvoiceContent() {
{salesConfig?.cashCustomerName ?? "Cash Customer"}
</SelectItem>
)}
{regularCustomers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
{c.customerCode} — {c.displayName ?? c.name}
</SelectItem>
))}
{regularCustomers.map((c) => {
const details = [c.customerType, c.phone, c.email, c.city].filter(Boolean).join(" · ")
return (
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
<div className="flex flex-col">
<span>{c.customerCode} — {c.displayName ?? c.name}</span>
{details && <span className="text-sm text-muted-foreground">{details}</span>}
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
@@ -927,10 +1014,10 @@ function NewSalesInvoiceContent() {
<Input
type="number"
min="0"
step="1"
step="0.01"
inputMode="decimal"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
{...navProps(line.key, "price")}
className="h-11 text-sm tabular-nums"
/>
@@ -1085,9 +1172,9 @@ function NewSalesInvoiceContent() {
<Input
type="number"
min="0"
step="1"
step="0.01"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
{...navProps(line.key, "price")}
className="h-11 text-right text-sm tabular-nums"
/>
@@ -2,16 +2,32 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, Tag } from "lucide-react"
import {
AlertTriangle,
ArrowDown,
ArrowUp,
ArrowUpDown,
CalendarDays,
CheckCircle2,
CircleDollarSign,
FileText,
ListFilter,
Loader2,
Plus,
Printer,
Receipt,
Tag,
} from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { salesConfigApi } from "@/lib/api/sales-config"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Field, FieldLabel } from "@/components/ui/field"
@@ -21,6 +37,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 { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { PaginationMeta } from "@/types/common"
@@ -30,6 +47,22 @@ import { Warehouse } from "@/types/master-data"
import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All"
type SortKey = "invoiceNo" | "customer" | "date" | "due" | "net" | "paid" | "remaining"
type SortOrder = "asc" | "desc"
/** Same clickable sort header as the Items table. */
function SortableHeader({ label, active, order, onClick, alignRight }: { label: string; active: boolean; order: SortOrder; onClick: () => void; alignRight?: boolean }) {
return (
<button type="button" className={cn("flex items-center gap-1 hover:text-foreground", alignRight && "ml-auto")} 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>
)
}
function formatAmount(n: number) {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -69,8 +102,23 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
{ value: "Cancelled", label: "Cancelled" },
]
/** Past its due date (the "Due" column, `invoiceDate`) with money still owed — Draft and Cancelled
* invoices are never overdue. Compared by calendar day, so an invoice due today is not overdue. */
function isOverdue(row: SalesInvoiceSummary): boolean {
if (row.status === "Draft" || row.status === "Cancelled" || row.totals.balanceAmount <= 0) return false
const due = new Date(row.invoiceDate)
const today = new Date()
today.setHours(0, 0, 0, 0)
due.setHours(0, 0, 0, 0)
return due < today
}
/** Money still owed on a live invoice — shown in red. A cancelled invoice owes nothing. */
function hasRemaining(row: SalesInvoiceSummary): boolean {
return row.status !== "Cancelled" && row.totals.balanceAmount > 0
}
export default function SalesInvoicesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
@@ -89,9 +137,23 @@ export default function SalesInvoicesPage() {
const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false)
const [printInvoice, setPrintInvoice] = useState<SalesInvoiceSummary | null>(null)
// The invoice open in the pop-up view (row click, invoice no. or the print icon). Read through
// `rows` so its buttons follow payments/confirms; viewRefresh reloads the sheet after those.
const [viewInvoice, setViewInvoice] = useState<SalesInvoiceSummary | null>(null)
const [viewRefresh, setViewRefresh] = useState(0)
const viewRow = viewInvoice ? (rows?.find((r) => r.salesInvoiceId === viewInvoice.salesInvoiceId) ?? viewInvoice) : null
// Multi-invoice payment: ticked rows (kept across pages), all for one customer. The generic cash
// customer's invoices can't be ticked — they're paid one at a time.
const [selected, setSelected] = useState<Map<number, SalesInvoiceSummary>>(new Map())
const [bulkPayOpen, setBulkPayOpen] = useState(false)
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
// While invoices are ticked the list is narrowed to their customer (overriding the Customer
// filter); clearing the selection falls back to whatever filter was chosen.
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
const listCustomerId = selectedCustomerId ?? customerId
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
@@ -104,11 +166,13 @@ export default function SalesInvoicesPage() {
useEffect(() => {
setPage(1)
setPageSize(rowsPerPage)
}, [query, status, dateRange, activeBranchId, customerId, warehouseId, rowsPerPage])
}, [query, status, dateRange, activeBranchId, listCustomerId, warehouseId, rowsPerPage])
useEffect(() => {
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
// A failed read only loosens the UI guard — the server rejects cash-customer bulk payments anyway.
salesConfigApi.get().then((r) => setCashCustomerId(r.data.cashCustomerId)).catch(() => setCashCustomerId(null))
}, [])
useEffect(() => {
@@ -119,7 +183,7 @@ export default function SalesInvoicesPage() {
pageSize,
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
customerId,
customerId: listCustomerId,
warehouseId,
...dateRangeBounds(dateRange),
})
@@ -129,7 +193,7 @@ export default function SalesInvoicesPage() {
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, customerId, warehouseId])
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, listCustomerId, warehouseId])
function handleShowMore() {
setShowMoreLoading(true)
@@ -142,6 +206,7 @@ export default function SalesInvoicesPage() {
const posted = await salesApi.postInvoice(row.salesInvoiceId)
setRows((prev) => prev?.map((r) => (r.salesInvoiceId === row.salesInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
toast.success("Invoice confirmed", posted.invoiceNo)
setViewRefresh((n) => n + 1)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
} finally {
@@ -150,6 +215,7 @@ export default function SalesInvoicesPage() {
}
function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) {
setViewRefresh((n) => n + 1)
setRows(
(prev) =>
prev?.map((r) =>
@@ -162,6 +228,77 @@ export default function SalesInvoicesPage() {
: r
) ?? prev
)
// Keep a ticked invoice's balance current, and untick it once nothing is left to pay.
setSelected((prev) => {
const sel = prev.get(payment.salesInvoiceId)
if (!sel) return prev
const next = new Map(prev)
const balanceAmount = sel.totals.balanceAmount - payment.amount
if (balanceAmount <= 0) next.delete(payment.salesInvoiceId)
else next.set(payment.salesInvoiceId, { ...sel, totals: { ...sel.totals, paidAmount: sel.totals.paidAmount + payment.amount, balanceAmount } })
return next
})
}
function handleBulkConfirmed(salesInvoiceId: number) {
const asPosted = (r: SalesInvoiceSummary) => (r.salesInvoiceId === salesInvoiceId ? { ...r, status: "Posted" as const } : r)
setRows((prev) => prev?.map(asPosted) ?? prev)
setSelected((prev) => {
const sel = prev.get(salesInvoiceId)
return sel ? new Map(prev).set(salesInvoiceId, asPosted(sel)) : prev
})
}
function handleBulkPaid(payments: SalesInvoicePayment[]) {
payments.forEach((p) => handlePaid(p, "Posted"))
// Partly-paid ones would stay ticked via handlePaid; a finished payment clears the whole selection.
setSelected(new Map())
}
const selectedBalance = [...selected.values()].reduce((sum, r) => sum + r.totals.balanceAmount, 0)
/** Null when the row can be ticked, else why not (shown as the checkbox's tooltip). */
function notSelectableReason(row: SalesInvoiceSummary): string | null {
if (selected.has(row.salesInvoiceId)) return null
if (row.status === "Cancelled") return "Cancelled invoices can't be paid"
if (row.totals.balanceAmount <= 0) return "Already fully paid"
if (cashCustomerId !== null && row.customerId === cashCustomerId) return "Cash customer invoices are paid one at a time"
if (selectedCustomerId !== null && row.customerId !== selectedCustomerId) return "Select invoices of one customer only"
return null
}
function toggleSelected(row: SalesInvoiceSummary) {
setSelected((prev) => {
const next = new Map(prev)
if (next.has(row.salesInvoiceId)) next.delete(row.salesInvoiceId)
else next.set(row.salesInvoiceId, row)
return next
})
}
function renderSelectBox(row: SalesInvoiceSummary) {
const reason = notSelectableReason(row)
return (
<Checkbox
checked={selected.has(row.salesInvoiceId)}
disabled={reason !== null}
title={reason ?? "Select to pay together"}
aria-label={`Select ${row.invoiceNo}`}
onCheckedChange={() => toggleSelected(row)}
/>
)
}
// Desktop table sorting (Items-table style) — over the loaded page, client-side; null keeps the
// server's order.
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
function toggleSort(key: SortKey) {
if (key === sortKey) setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
else {
setSortKey(key)
setSortOrder("asc")
}
}
const visibleRows = useMemo(
@@ -171,14 +308,43 @@ export default function SalesInvoicesPage() {
) ?? [],
[rows, query]
)
const sortedRows = useMemo(() => {
if (!sortKey) return visibleRows
const value = (r: SalesInvoiceSummary): string | number => {
switch (sortKey) {
case "invoiceNo": return r.invoiceNo
case "customer": return r.customerSnapshotName
case "date": return new Date(r.createdAt).getTime()
case "due": return new Date(r.invoiceDate).getTime()
case "net": return r.totals.grandTotal
case "paid": return r.totals.paidAmount
case "remaining": return r.status === "Cancelled" ? 0 : r.totals.balanceAmount
}
}
return [...visibleRows].sort((a, b) => {
const va = value(a), vb = value(b)
const cmp = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb))
return sortOrder === "asc" ? cmp : -cmp
})
}, [visibleRows, sortKey, sortOrder])
// Header "select all": ticks every tickable row on screen, but only when they're all one customer
// (e.g. once a selection has narrowed the list) — invoices of different customers never mix.
const tickable = sortedRows.filter((r) => notSelectableReason(r) === null)
const canSelectAll = tickable.length > 0 && new Set(tickable.map((r) => r.customerId)).size === 1
const allTicked = tickable.length > 0 && tickable.every((r) => selected.has(r.salesInvoiceId))
function toggleAll(checked: boolean) {
setSelected((prev) => {
const next = new Map(prev)
tickable.forEach((r) => (checked ? next.set(r.salesInvoiceId, r) : next.delete(r.salesInvoiceId)))
return next
})
}
const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
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)
const printRange = dateRangeBounds(dateRange)
const printHref =
`/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` +
(printRange.from && printRange.to ? `&from=${printRange.from}&to=${printRange.to}` : "")
return (
<div className="flex flex-col gap-6">
@@ -188,15 +354,6 @@ export default function SalesInvoicesPage() {
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Link
href={printHref}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
>
<Printer className="size-5" />
Print batch
</Link>
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
New Sale
@@ -235,7 +392,11 @@ export default function SalesInvoicesPage() {
<>
<Field>
<FieldLabel htmlFor="inv-filter-customer">Customer</FieldLabel>
<Select value={customerId ? String(customerId) : "All"} onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}>
<Select
value={listCustomerId ? String(listCustomerId) : "All"}
onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}
disabled={selectedCustomerId !== null}
>
<SelectTrigger id="inv-filter-customer"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All customers</SelectItem>
@@ -291,6 +452,28 @@ export default function SalesInvoicesPage() {
</div>
</FilterBar>
{selected.size > 0 && (
<div className="flex flex-col gap-2 border-b bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm">
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
<span className="font-semibold tabular-nums">{formatAmount(selectedBalance)}</span>
<span className="block text-xs text-muted-foreground">
Showing only this customer&apos;s invoices — clear the selection to see all.
</span>
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setSelected(new Map())}>
Clear
</Button>
<Button type="button" size="sm" onClick={() => setBulkPayOpen(true)}>
<CircleDollarSign className="size-4" />
Pay selected
</Button>
</div>
</div>
)}
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && rows === null && (
@@ -319,17 +502,22 @@ export default function SalesInvoicesPage() {
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
setViewInvoice(row)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
if (e.key === "Enter") setViewInvoice(row)
}}
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">
<div className="flex min-w-0 items-start gap-3">
<span className="pt-1" data-no-row-nav>
{renderSelectBox(row)}
</span>
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<SalesInvoiceStatusBadge status={row.status} />
@@ -343,13 +531,12 @@ export default function SalesInvoicesPage() {
<div>
<p className="text-muted-foreground">Date / Due</p>
<p className="font-medium text-foreground">
{new Date(row.createdAt).toLocaleDateString()} / {new Date(row.invoiceDate).toLocaleDateString()}
{new Date(row.createdAt).toLocaleDateString()} /{" "}
<span className={cn(isOverdue(row) && "text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
</span>
</p>
</div>
<div>
<p className="text-muted-foreground">Lines</p>
<p className="font-medium text-foreground">{row.totals.freeQtyTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Gross / Discount</p>
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}</p>
@@ -358,36 +545,54 @@ export default function SalesInvoicesPage() {
<p className="text-muted-foreground">Net</p>
<p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Paid / Remaining</p>
<p className="font-medium text-foreground">
{row.totals.paidAmount.toFixed(2)} /{" "}
<span className={cn(hasRemaining(row) && "font-semibold text-destructive")}>
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
</span>
</p>
</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="sm" onClick={() => setPrintInvoice(row)}>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setViewInvoice(row)}
aria-label={`Print ${row.invoiceNo}`}
title="Print"
>
<Printer className="size-4" />
Print
</Button>
{row.status === "Draft" && (
<Button
type="button"
variant="outline"
size="sm"
size="icon"
disabled={confirmingId === row.salesInvoiceId}
onClick={() => handleConfirm(row)}
aria-label={`Confirm ${row.invoiceNo}`}
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
variant="outline"
size="icon"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
aria-label={`Pay ${row.invoiceNo}`}
title="Pay"
>
<CircleDollarSign className="size-4" />
Pay
</Button>
)}
</div>
@@ -402,45 +607,83 @@ export default function SalesInvoicesPage() {
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<div className="mx-4 my-4 hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Invoice</TableHead>
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
<TableHead className="h-12 px-4 text-sm">Due</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Lines</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Gross</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Discount</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Net</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
<TableHead className="h-12 w-10 px-3">
<Checkbox
checked={canSelectAll && allTicked}
disabled={!canSelectAll}
onCheckedChange={(c) => toggleAll(c === true)}
aria-label="Select all"
title={canSelectAll ? "Select all" : "Select an invoice first — all must be one customer"}
/>
</TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Invoice" active={sortKey === "invoiceNo"} order={sortOrder} onClick={() => toggleSort("invoiceNo")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Customer" active={sortKey === "customer"} order={sortOrder} onClick={() => toggleSort("customer")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Date" active={sortKey === "date"} order={sortOrder} onClick={() => toggleSort("date")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Due" active={sortKey === "due"} order={sortOrder} onClick={() => toggleSort("due")} /></TableHead>
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Net" active={sortKey === "net"} order={sortOrder} onClick={() => toggleSort("net")} alignRight /></TableHead>
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Paid" active={sortKey === "paid"} order={sortOrder} onClick={() => toggleSort("paid")} alignRight /></TableHead>
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Remaining" active={sortKey === "remaining"} order={sortOrder} onClick={() => toggleSort("remaining")} alignRight /></TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
{sortedRows.map((row) => (
<TableRow
key={row.salesInvoiceId}
className="cursor-pointer hover:bg-muted/40"
className="cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
setViewInvoice(row)
}}
>
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
{row.invoiceNo}
</Link>
<TableCell className="px-3 py-3.5" data-no-row-nav>
{renderSelectBox(row)}
</TableCell>
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<TableCell className="px-3 py-3.5 font-medium">
<div className="flex items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Receipt className="size-4 text-primary" />
</div>
<div>
<Link
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
className="hover:underline"
data-no-row-nav
onClick={(e) => {
// Plain click opens the pop-up; Ctrl/⌘-click still opens the full page in a new tab.
if (e.ctrlKey || e.metaKey || e.shiftKey) return
e.preventDefault()
setViewInvoice(row)
}}
>
{row.invoiceNo}
</Link>
<p className="text-xs font-normal text-muted-foreground">{row.invoiceType}</p>
</div>
</div>
</TableCell>
<TableCell className="px-3 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
<p className="font-semibold">{row.totals.grandTotal.toFixed(2)}</p>
<p className="text-xs text-muted-foreground">
Gross {row.totals.subtotal.toFixed(2)}
{row.totals.discountTotal > 0 && ` · Disc ${row.totals.discountTotal.toFixed(2)}`}
</p>
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
<TableCell className={cn("px-3 py-3.5 text-right tabular-nums", hasRemaining(row) && "font-semibold text-destructive")}>
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex flex-col items-start gap-1">
<SalesInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
@@ -448,13 +691,13 @@ export default function SalesInvoicesPage() {
)}
</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
<div className="flex items-center justify-end gap-1">
<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={() => setPrintInvoice(row)}
onClick={() => setViewInvoice(row)}
aria-label={`Print ${row.invoiceNo}`}
title="Print"
>
@@ -463,26 +706,29 @@ export default function SalesInvoicesPage() {
{row.status === "Draft" && (
<Button
type="button"
variant="outline"
size="sm"
variant="ghost"
size="icon-sm"
disabled={confirmingId === row.salesInvoiceId}
onClick={() => handleConfirm(row)}
aria-label={`Confirm ${row.invoiceNo}`}
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
variant="ghost"
size="icon-sm"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
aria-label={`Pay ${row.invoiceNo}`}
title="Pay"
>
<CircleDollarSign className="size-4" />
Pay
</Button>
)}
</div>
@@ -494,7 +740,7 @@ export default function SalesInvoicesPage() {
</div>
{pagination && (
<div className="hidden border-t px-4 py-4 md:block">
<div className="hidden px-4 pb-4 md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
@@ -508,11 +754,53 @@ export default function SalesInvoicesPage() {
</div>
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
<SalesInvoiceBulkPaymentDialog
invoices={[...selected.values()]}
open={bulkPayOpen}
onOpenChange={setBulkPayOpen}
onConfirmed={handleBulkConfirmed}
onPaid={handleBulkPaid}
/>
<SalesInvoicePrintDialog
invoiceId={printInvoice?.salesInvoiceId ?? null}
invoiceNo={printInvoice?.invoiceNo}
open={printInvoice !== null}
onOpenChange={(open) => { if (!open) setPrintInvoice(null) }}
invoiceId={viewRow?.salesInvoiceId ?? null}
invoiceNo={viewRow?.invoiceNo}
open={viewRow !== null}
onOpenChange={(open) => { if (!open) setViewInvoice(null) }}
refreshKey={viewRefresh}
actions={
viewRow && (
<>
{viewRow.status === "Draft" && (
<Button
type="button"
variant="outline"
size="icon"
disabled={confirmingId === viewRow.salesInvoiceId}
onClick={() => handleConfirm(viewRow)}
aria-label={`Confirm ${viewRow.invoiceNo}`}
title="Confirm"
>
{confirmingId === viewRow.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
{viewRow.status !== "Cancelled" && viewRow.totals.balanceAmount > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
setPayDialogInvoice(viewRow)
setPayDialogOpen(true)
}}
aria-label={`Pay ${viewRow.invoiceNo}`}
title="Pay"
>
<CircleDollarSign className="size-4" />
</Button>
)}
</>
)
}
/>
</div>
)
@@ -67,7 +67,7 @@ export default function SalesReturnsListPage() {
useEffect(load, [page, pageSize, query])
useEffect(() => {
Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
Promise.all([customersApi.list({ pageSize: 200, includeCashCustomer: true }), warehousesApi.list(), reasonCodesApi.list("Return")])
.then(([c, w, rc]) => {
setCustomers(c.items)
setWarehouses(w.items)
@@ -1,5 +1,5 @@
import Link from "next/link"
import { Building2, LayoutTemplate, MapPin, ShoppingCart, SlidersHorizontal } from "lucide-react"
import { Building2, LayoutTemplate, MapPin, PackageCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
const cards = [
{
@@ -26,6 +26,12 @@ const cards = [
icon: ShoppingCart,
description: "Allow cash customer sales or require picking a customer",
},
{
title: "GRN Configuration",
href: "/dashboard/settings/grn-configuration",
icon: PackageCheck,
description: "Warehouse selection and the default receiving warehouse",
},
{
title: "Invoice Template",
href: "/dashboard/settings/invoice-template",
@@ -0,0 +1,235 @@
"use client"
import { useEffect, useState } from "react"
import { Settings } from "lucide-react"
import { grnConfigApi } from "@/lib/api/grn-config"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { Warehouse } from "@/types/master-data"
import { GrnConfig, UpdateGrnConfigRequest } from "@/types/grn"
import { Button } from "@/components/ui/button"
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"
/**
* GRN Configuration — singleton goods-receipt options (same pattern as Sales Configuration).
* "Allow warehouse selection" + default receiving warehouse: a saved default pins New GRN to that
* one warehouse (server-forced too); with none saved, the picker shows unless selection is off.
* "Allow discounts" / "Allow VAT": off hides those columns (and whole-GRN rates) on New GRN and
* receives every line at 0% — the server rejects anything else.
*/
export default function GrnConfigurationPage() {
const [config, setConfig] = useState<GrnConfig | 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)
grnConfigApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
useEffect(() => {
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
}, [])
const savedDefault = warehouses.find((w) => w.warehouseId === config?.defaultWarehouseId)
const savedDefaultInList = savedDefault !== undefined
const savedDefaultLabel = savedDefault
? `${savedDefault.code} — ${savedDefault.name}`
: (config?.defaultWarehouseName ?? `Warehouse #${config?.defaultWarehouseId}`)
// PUT is a full replacement — send every flag, overriding just the one changed.
async function saveFlag(patch: Partial<UpdateGrnConfigRequest>, message: string) {
if (!config) return
setSaving(true)
try {
const res = await grnConfigApi.update(
{
allowWarehouseSelection: config.allowWarehouseSelection,
defaultWarehouseId: config.defaultWarehouseId,
allowDiscounts: config.allowDiscounts,
allowVat: config.allowVat,
...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 className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">GRN Configuration</h1>
<p className="text-base text-muted-foreground">Choose how goods are received in this deployment.</p>
</div>
</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">Warehouse</h2>
</div>
<div className="border-t" />
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.allowWarehouseSelection}
onCheckedChange={(next) => {
// Turning selection off needs somewhere for receipts to go — the server rejects it too.
if (!next && !config.defaultWarehouseId) {
toast.error("Pick a default warehouse first", "Choose the default receiving warehouse below, then turn this off.")
return
}
saveFlag({ allowWarehouseSelection: next }, `Warehouse selection ${next ? "enabled" : "disabled"}.`)
}}
disabled={saving}
aria-label="Allow warehouse selection"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Allow warehouse selection</span>
<span className="text-sm text-muted-foreground">
On: with no default warehouse saved below, New GRN shows a Warehouse picker.
Off: a default warehouse must be saved. Whenever a default is saved, New GRN
shows only that warehouse.
</span>
</div>
</div>
<div className="flex flex-col gap-2 py-2 sm:max-w-md">
<span className="text-base font-medium text-foreground">Default receiving warehouse</span>
<Select
value={config.defaultWarehouseId ? String(config.defaultWarehouseId) : ""}
onValueChange={(v) => {
if (!v) return
const picked = warehouses.find((w) => w.warehouseId === Number(v))
saveFlag({ defaultWarehouseId: Number(v) }, `Default receiving warehouse: ${picked?.name ?? v}.`)
}}
disabled={saving}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={config.defaultWarehouseName ?? "Select warehouse"} />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
{/* The warehouse list can be branch-scoped — keep the saved default selectable
(and labelled in the trigger) even when it isn't in this user's list. */}
{config.defaultWarehouseId && !savedDefaultInList && (
<SelectItem value={String(config.defaultWarehouseId)} className="text-base">
{config.defaultWarehouseName ?? `Warehouse #${config.defaultWarehouseId}`}
</SelectItem>
)}
</SelectContent>
</Select>
{config.defaultWarehouseId ? (
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-foreground">
New GRN shows only: <span className="font-medium">{savedDefaultLabel}</span>
</span>
{/* Clearing brings the picker back — only possible while selection is allowed. */}
{config.allowWarehouseSelection && (
<Button
type="button"
variant="outline"
size="sm"
disabled={saving}
onClick={() => saveFlag({ defaultWarehouseId: null }, "Default receiving warehouse cleared.")}
>
Clear
</Button>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">
Not set — New GRN lets the user pick a warehouse. Required to turn warehouse
selection off.
</span>
)}
</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>
</div>
<div className="border-t" />
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.allowDiscounts}
onCheckedChange={(next) => saveFlag({ allowDiscounts: next }, `Discounts ${next ? "allowed" : "not allowed"}.`)}
disabled={saving}
aria-label="Allow discounts"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Allow discounts</span>
<span className="text-sm text-muted-foreground">
On: GRN lines show a Disc % column, plus a whole-GRN discount % that applies to
every line. Off: both are hidden and goods are received without discounts.
</span>
</div>
</div>
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.allowVat}
onCheckedChange={(next) => saveFlag({ allowVat: next }, `VAT ${next ? "allowed" : "not allowed"}.`)}
disabled={saving}
aria-label="Allow VAT"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Allow VAT</span>
<span className="text-sm text-muted-foreground">
On: GRN lines show a VAT % column (pre-filled from the item&apos;s tax rate), plus a
whole-GRN VAT %. Off: both are hidden and every line is received at 0% VAT, even
for items that have a tax rate.
</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>
)
}