Compare commits

...

7 Commits

23 changed files with 751 additions and 546 deletions
@@ -1,4 +1,5 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
@@ -8,7 +9,7 @@ public interface IBundleSaleService
{
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
@@ -195,11 +195,9 @@ export default function PurchaseOrderDetailPage() {
setPo(updated)
setLines(toDraftLines(updated))
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
} catch (err) {
setSaveError(errorMessage(err))
toast.error("Could not approve purchase order", errorMessage(err))
toast.error("Could not approve purchase order", errorMessage(err))
} finally {
setSubmitting(false)
}
@@ -286,7 +284,7 @@ export default function PurchaseOrderDetailPage() {
<>
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Check className="size-5" />
{submitting ? "Approving" : "Approve"}
{submitting ? "Approving" : "Approve"}
</Button>
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
<Trash2 className="size-5" />
@@ -52,10 +52,10 @@ function newKey() {
return `poline-${keySeq}`
}
// Unit price and tax are no longer entered at PO creation — pricing is captured at GRN
// receipt (with discount/VAT there). They default to 0 here and stay off the form, but
// remain on the payload because the backend line DTO still requires them; a PO prefilled
// from an RFQ keeps its negotiated price (below).
// 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.
function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
}
@@ -306,7 +306,7 @@ function NewPurchaseOrderContent() {
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div className="flex flex-col gap-2 sm:col-span-2">
<div className="flex flex-col gap-2 sm:col-span-1">
<Label className="text-base">Vendor</Label>
<div className="flex items-center gap-2">
<Select<number | null>
@@ -406,13 +406,14 @@ function NewPurchaseOrderContent() {
{lines.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-40 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
<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>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
@@ -424,11 +425,15 @@ function NewPurchaseOrderContent() {
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{requisitionId || rfqId ? (
<div className="flex h-11 items-center text-base">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</div>
<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}>
<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>
@@ -485,6 +490,18 @@ function NewPurchaseOrderContent() {
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.unitPrice}
aria-invalid={!!errors.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</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" />
@@ -188,7 +188,7 @@ export default function ItemDetailPage() {
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
<Badge
variant="outline"
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive")}
>
{item.status}
</Badge>
@@ -257,7 +257,15 @@ export default function BrandsPage() {
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={b.status === "Active" ? "default" : "secondary"}>{b.status}</Badge>
<Badge
variant="outline"
className={cn(
"border-transparent",
b.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{b.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
@@ -7,6 +7,7 @@ import { Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, SubCategory } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
@@ -181,7 +182,15 @@ export default function CategorySubCategoriesPage() {
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={s.status === "Active" ? "default" : "secondary"}>{s.status}</Badge>
<Badge
variant="outline"
className={cn(
"border-transparent",
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{s.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
@@ -256,7 +256,15 @@ export default function CategoriesPage() {
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={c.status === "Active" ? "default" : "secondary"}>{c.status}</Badge>
<Badge
variant="outline"
className={cn(
"border-transparent",
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
@@ -240,7 +240,7 @@ export default function ItemsPage() {
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
@@ -440,220 +440,220 @@ export default function NewGrnPage() {
{!poLoading && lines.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-96 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-20 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
<TableHead className="h-12 w-24 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-44 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const item = itemFor(line.itemId)
const errors = lineErrors[line.key] ?? {}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
{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}>
<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>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
</div>
) : (
<>
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
</>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{bins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
{b.code}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.unitCost}
aria-invalid={!!errors.unitCost}
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
<p className="mt-1 text-xs text-warning">
PO price {line.poUnitPrice.toFixed(2)} variance recorded
</p>
)}
</TableCell>
<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 })}
className="h-11 text-base"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</TableCell>
<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 })}
className="h-11 text-base"
/>
<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)
return (
<div className="flex h-11 flex-col justify-center">
<span>{c.lineTotal.toFixed(2)}</span>
<span className="text-xs text-muted-foreground">
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
</span>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 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">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
<TableHead className="h-12 w-24 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-44 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const item = itemFor(line.itemId)
const errors = lineErrors[line.key] ?? {}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}
</div>
)
})()}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<HoldStatus>
value={line.holdStatus}
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Available" className="text-base">Available</SelectItem>
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
{item?.trackingMode === "Batch" && (
<div className="flex flex-col gap-1.5">
<Input
placeholder="Batch no."
value={line.batchNo}
aria-invalid={!!errors.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
className="h-9 text-sm"
/>
<Input
type="date"
value={line.expiryDate}
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
className="h-9 text-sm"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
</div>
)}
{item?.trackingMode === "Serial" && (
<div className="flex flex-col gap-1.5">
<textarea
placeholder="One serial per line"
value={line.serialNumbersText}
aria-invalid={!!errors.serialNumbers}
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
</div>
)}
{(!item || item.trackingMode === "None") && (
<span className="text-sm text-muted-foreground"></span>
)}
</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" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
) : (
<>
<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}>
<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>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
</div>
) : (
<>
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
</>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{bins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
{b.code}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.unitCost}
aria-invalid={!!errors.unitCost}
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
<p className="mt-1 text-xs text-warning">
PO price {line.poUnitPrice.toFixed(2)} variance recorded
</p>
)}
</TableCell>
<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 })}
className="h-11 text-base"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</TableCell>
<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 })}
className="h-11 text-base"
/>
<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)
return (
<div className="flex h-11 flex-col justify-center">
<span>{c.lineTotal.toFixed(2)}</span>
<span className="text-xs text-muted-foreground">
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
</span>
</div>
)
})()}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<HoldStatus>
value={line.holdStatus}
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Available" className="text-base">Available</SelectItem>
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
{item?.trackingMode === "Batch" && (
<div className="flex flex-col gap-1.5">
<Input
placeholder="Batch no."
value={line.batchNo}
aria-invalid={!!errors.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
className="h-9 text-sm"
/>
<Input
type="date"
value={line.expiryDate}
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
className="h-9 text-sm"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
</div>
)}
{item?.trackingMode === "Serial" && (
<div className="flex flex-col gap-1.5">
<textarea
placeholder="One serial per line"
value={line.serialNumbersText}
aria-invalid={!!errors.serialNumbers}
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
</div>
)}
{(!item || item.trackingMode === "None") && (
<span className="text-sm text-muted-foreground"></span>
)}
</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" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
{!poLoading && lines.length > 0 && (
<div className="flex justify-end gap-6 pr-12 text-base">
<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="font-semibold tabular-nums">
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
@@ -15,7 +15,9 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatUomName } from "@/lib/format-uom"
import { cn } from "@/lib/utils"
import { validateBundleSale } from "@/lib/sales-validation"
import { BundleSale, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -138,7 +140,20 @@ export default function BundleSaleDetailPage() {
}
async function saveBundle() {
if (!bundle || !customerId || !warehouseId || !cashierUserId || !templateId) return
if (!bundle) return
const validationError = validateBundleSale({
customerId,
warehouseId,
cashierUserId,
templateId,
bundleName,
bundlePrice,
lines,
})
if (validationError) {
setError(validationError)
return
}
setBusy(true)
setError(null)
try {
@@ -393,7 +408,7 @@ export default function BundleSaleDetailPage() {
<SelectContent>
{uoms.map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
{formatUomName(uom.name)}
</SelectItem>
))}
</SelectContent>
@@ -18,8 +18,10 @@ import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatUomName } from "@/lib/format-uom"
import { cn } from "@/lib/utils"
import { toast } from "@/components/ui/toast"
import { validateBundleSale } from "@/lib/sales-validation"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
@@ -54,7 +56,7 @@ function NewBundleSaleContent() {
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [templateId, setTemplateId] = useState<number | null>(templateFromQuery ? Number(templateFromQuery) : null)
const [bundleName, setBundleName] = useState("Demo Bundle")
const [bundleName, setBundleName] = useState("")
const [bundlePrice, setBundlePrice] = useState<number>(0)
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
const [lines, setLines] = useState<EditableLine[]>([])
@@ -78,10 +80,10 @@ function NewBundleSaleContent() {
setWarehouses(whRes.items)
setUsers(userRes.items)
setTemplates(templateRes.items)
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
setTemplateId((current) => current ?? templateRes.items[0]?.bundleSaleTemplateId ?? null)
setCustomerId(null)
setWarehouseId(null)
setCashierUserId(null)
setTemplateId((current) => current ?? null)
})
.catch((err) => setSubmitError(errorMessage(err)))
.finally(() => setLoading(false))
@@ -122,12 +124,21 @@ function NewBundleSaleContent() {
}
async function submit() {
if (!customerId || !warehouseId || !cashierUserId || !templateId || !template) {
setSubmitError("Select customer, warehouse, cashier, and bundle template.")
const validationError = validateBundleSale({
customerId,
warehouseId,
cashierUserId,
templateId,
bundleName,
bundlePrice,
lines,
})
if (validationError) {
setSubmitError(validationError)
return
}
if (lines.length === 0) {
setSubmitError("Add at least one bundle component line.")
if (!template) {
setSubmitError("Load a bundle template before saving.")
return
}
setSaving(true)
@@ -189,28 +200,28 @@ function NewBundleSaleContent() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
<SelectTrigger><SelectValue placeholder="Select customer" /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
<SelectTrigger><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))}>
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
<SelectTrigger><SelectValue placeholder="Select cashier" /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))}>
<Select value={templateId ? String(templateId) : ""} onValueChange={(v) => setTemplateId(v ? Number(v) : null)}>
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
</Select>
@@ -256,7 +267,7 @@ function NewBundleSaleContent() {
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, {
@@ -278,14 +289,14 @@ function NewBundleSaleContent() {
</Select>
</TableCell>
<TableCell className="min-w-40">
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: v ? Number(v) : 0 })} disabled>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
{formatUomName(uom.name)}
</SelectItem>
))}
</SelectContent>
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, FileText, Plus, Printer, Search } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
@@ -10,6 +10,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
@@ -23,7 +24,6 @@ import { cn } from "@/lib/utils"
type StatusFilter = BundleSaleStatus | "All"
const PAGE_SIZE = 10
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
function statusClass(status: BundleSaleStatus) {
switch (status) {
@@ -46,7 +46,6 @@ export default function BundleSalesPage() {
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [showFilters, setShowFilters] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
@@ -119,88 +118,74 @@ export default function BundleSalesPage() {
</div>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t}
onClick={() => setStatus(t)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t}
</button>
))}
<Card>
<CardHeader className="border-b">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div className="relative sm:col-span-2 xl:col-span-2">
<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 bundle, code, or customer"
className="h-14 w-full pl-11 text-base"
aria-label="Search bundles"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full text-base">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
<SelectItem value="Posted" className="text-base">Posted</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-14! w-full text-base">
<SelectValue placeholder="All customers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-base">All customers</SelectItem>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
{c.customerCode} - {c.displayName ?? c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-14! w-full text-base">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-base">All warehouses</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Filter by bundle, code, or customer"
className="h-12 w-full lg:max-w-sm"
/>
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
<Filter className="size-4" />
Advanced
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setCustomerId(null)
setWarehouseId(null)
setStatus("All")
setSearchInput("")
setQuery("")
}}
>
Reset filters
</Button>
</div>
</div>
{showFilters && (
<div className="grid gap-4 border-b px-4 py-4 md:grid-cols-3">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Customer</div>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All customers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All customers</SelectItem>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Warehouse</div>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All warehouses</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
setCustomerId(null)
setWarehouseId(null)
setStatus("All")
setSearchInput("")
setQuery("")
}}
>
Reset
</Button>
</div>
</div>
)}
</CardHeader>
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
@@ -221,6 +206,7 @@ export default function BundleSalesPage() {
{!error && rows !== null && visibleRows.length > 0 && (
<>
<CardContent className="px-0">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
@@ -266,6 +252,7 @@ export default function BundleSalesPage() {
</TableBody>
</Table>
</div>
</CardContent>
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-2">
@@ -302,7 +289,7 @@ export default function BundleSalesPage() {
)}
</>
)}
</div>
</Card>
</div>
)
}
@@ -6,9 +6,12 @@ import { ArrowLeft, Pencil, Plus, Save, Trash2, X } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatUomName } from "@/lib/format-uom"
import { cn } from "@/lib/utils"
import { validateSalesDocument } from "@/lib/sales-validation"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -88,7 +91,7 @@ export default function NewFreeIssuePage() {
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
itemName: item?.name ?? firstLine?.description ?? "—",
itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`,
uomName: uom?.name ?? `UOM ${firstLine?.uomId ?? 0}`,
uomName: uom?.name ? formatUomName(uom.name) : `UOM ${firstLine?.uomId ?? 0}`,
qty: firstLine?.qty ?? 0,
freeQty: firstLine?.freeQty ?? 0,
} satisfies FreeIssueRow
@@ -111,17 +114,10 @@ export default function NewFreeIssuePage() {
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
setLines([
{
...blankLine("line-1"),
itemId: itemRes.items[0]?.itemId ?? 0,
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
warehouseId: whRes.items[0]?.warehouseId ?? 0,
},
])
setCustomerId(null)
setWarehouseId(null)
setCashierUserId(null)
setLines([blankLine("line-1")])
await refreshRows()
})
.catch((err) => setError(errorMessage(err)))
@@ -132,12 +128,18 @@ export default function NewFreeIssuePage() {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function updateHeaderWarehouse(nextWarehouseId: number | null) {
setWarehouseId(nextWarehouseId)
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
setEditingLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
}
function updateEditingLine(key: string, patch: Partial<Line>) {
setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
}
function removeLine(key: string) {
@@ -146,20 +148,25 @@ export default function NewFreeIssuePage() {
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0, warehouseId: warehouseId ?? 0 })
}
function selectEditingItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0, warehouseId: warehouseId ?? 0 })
}
async function submit() {
const activeLines = editingRowId ? editingLines : lines
if (!customerId || !warehouseId || !cashierUserId) return setError("Select customer, warehouse, and cashier.")
if (activeLines.some((line) => !line.itemId)) return setError("Select an item for every line.")
if (activeLines.some((line) => !line.uomId)) return setError("Select a valid UOM for every line.")
if (activeLines.some((line) => !line.warehouseId)) return setError("Select a warehouse for every line.")
const validationError = validateSalesDocument({
customerId,
warehouseId,
cashierUserId,
requireCashierUser: true,
lines: activeLines,
lineLabel: "free issue line",
})
if (validationError) return setError(validationError)
setSaving(true)
setError(null)
@@ -198,6 +205,11 @@ export default function NewFreeIssuePage() {
toast.success("Free issue created", created.data.slipNo)
}
if (!editingRowId) {
setCustomerId(null)
setWarehouseId(null)
setCashierUserId(null)
}
setLines([blankLine("line-1")])
await refreshRows()
} catch (err) {
@@ -281,6 +293,57 @@ export default function NewFreeIssuePage() {
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<h2 className="text-sm font-semibold">{editingRowId ? "Free issue header" : "Create free issue header"}</h2>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Customer</Label>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select customer" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select cashier" />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.userId} value={String(u.userId)}>
{u.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</section>
{editingRowId ? (
<section className="rounded-2xl border border-sky-200 bg-sky-50 shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between border-b border-sky-200 px-4 py-3">
@@ -327,7 +390,7 @@ export default function NewFreeIssuePage() {
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
{formatUomName(u.name)}
</SelectItem>
))}
</SelectContent>
@@ -392,7 +455,7 @@ export default function NewFreeIssuePage() {
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
{formatUomName(u.name)}
</SelectItem>
))}
</SelectContent>
@@ -11,8 +11,10 @@ import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { findUomLabel, formatUomName } from "@/lib/format-uom"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { validateSalesDocument } from "@/lib/sales-validation"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
@@ -171,9 +173,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
}
async function save() {
if (!customerId || !warehouseId || !etag) return
if (lines.some((line) => !line.itemId || !line.uomId || !line.warehouseId)) {
setError("Select item, UOM and warehouse for every line.")
if (!etag) return
const validationError = validateSalesDocument({
customerId,
warehouseId,
lines,
lineLabel: "invoice line",
})
if (validationError) {
setError(validationError)
return
}
@@ -353,7 +361,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</td>
<td className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</td>
<td className="px-4 py-3">{findUomLabel(uoms, line.uomId)}</td>
<td className="px-4 py-3 text-right">{line.qty.toFixed(0)}</td>
<td className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</td>
<td className="px-4 py-3 text-right">{money.format(line.unitPrice)}</td>
@@ -505,7 +513,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<option value="">UOM</option>
{uoms.map((u) => (
<option key={u.uomId} value={String(u.uomId)}>
{u.name}
{formatUomName(u.name)}
</option>
))}
</select>
@@ -10,6 +10,7 @@ import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { findUomLabel } from "@/lib/format-uom"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -143,7 +144,7 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</TableCell>
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell>{findUomLabel(uoms, line.uomId)}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
@@ -11,8 +11,10 @@ import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatUomName } from "@/lib/format-uom"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { validateSalesDocument } from "@/lib/sales-validation"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -85,18 +87,9 @@ export default function NewSalesInvoicePage() {
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? null
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(defaultWarehouseId)
setLines([
{
...blankLine("line-1"),
itemId: itemRes.items[0]?.itemId ?? 0,
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
warehouseId: defaultWarehouseId ?? 0,
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
},
])
setCustomerId(null)
setWarehouseId(null)
setLines([blankLine("line-1")])
setActiveFocSchemes(
freeIssueRes.items.flatMap((issue) => {
if (!issue.itemId) return []
@@ -132,6 +125,7 @@ export default function NewSalesInvoicePage() {
updateLine(key, {
itemId,
uomId: item?.baseUomId ?? 0,
warehouseId: warehouseId ?? 0,
unitPrice: getSuggestedUnitPrice(items, itemId),
})
}
@@ -178,10 +172,13 @@ export default function NewSalesInvoicePage() {
const payableTotal = netTotal + taxTotal
async function submit() {
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
if (lines.some((line) => !line.uomId)) return setSubmitError("Select a valid UOM for every line.")
const validationError = validateSalesDocument({
customerId,
warehouseId,
lines,
lineLabel: "invoice line",
})
if (validationError) return setSubmitError(validationError)
const payload: CreateSalesInvoiceRequest = {
customerId,
@@ -365,7 +362,7 @@ export default function NewSalesInvoicePage() {
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
{formatUomName(u.name)}
</SelectItem>
))}
</SelectContent>
@@ -2,13 +2,15 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, FileText, Plus, Printer, Search } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { PaginationMeta } from "@/types/common"
@@ -18,7 +20,6 @@ import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All"
const PAGE_SIZE = 10
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
function statusClass(status: SalesInvoiceStatus) {
switch (status) {
@@ -95,36 +96,32 @@ export default function SalesInvoicesPage() {
</div>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t}
onClick={() => setStatus(t)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t}
</button>
))}
<Card>
<CardHeader className="border-b">
<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 customer or invoice number"
className="h-14 w-full pl-11 text-base"
aria-label="Search invoices"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
<SelectItem value="Posted" className="text-base">Posted</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Filter by customer or invoice number"
className="h-12 w-full lg:max-w-sm"
/>
<Button variant="outline" size="sm" className="lg:ml-auto">
<Filter className="size-4" />
Advanced
</Button>
</div>
</div>
</CardHeader>
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
@@ -145,6 +142,7 @@ export default function SalesInvoicesPage() {
{!error && rows !== null && visibleRows.length > 0 && (
<>
<CardContent className="px-0">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
@@ -195,6 +193,7 @@ export default function SalesInvoicesPage() {
</TableBody>
</Table>
</div>
</CardContent>
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-3">
@@ -233,7 +232,7 @@ export default function SalesInvoicesPage() {
)}
</>
)}
</div>
</Card>
</div>
)
}
@@ -1,7 +1,8 @@
import Link from "next/link"
import { FileBarChart, FileText, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
import { FileText, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { cn } from "@/lib/utils"
const sections = [
@@ -34,36 +35,38 @@ const sections = [
export default function SalesHubPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
<ReceiptText className="size-4" />
Sales
</div>
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
<p className="text-base text-muted-foreground">
Invoices, slips, and free issues in one place.
</p>
<p className="text-base text-muted-foreground">Invoices, slips, and free issues in one place.</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{sections.map((section) => {
const Icon = section.icon
return (
<Link
key={section.href}
href={section.href}
className="group rounded-2xl border bg-card p-5 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
className="block focus-visible:outline-none"
>
<div className="mb-4 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Icon className="size-5" />
</div>
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{section.description}</p>
<div className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "mt-4 px-0 text-primary")}>
Open
</div>
<Card className="h-full transition-all duration-200 hover:-translate-y-0.5 hover:ring-primary/30 hover:shadow-lg">
<CardHeader>
<div className="mb-2 flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Icon className="size-5" />
</div>
<CardTitle className="text-lg font-semibold">{section.title}</CardTitle>
<CardDescription className="text-base">{section.description}</CardDescription>
</CardHeader>
<CardContent>
<div className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "px-0 text-primary")}>Open</div>
</CardContent>
</Card>
</Link>
)
})}
@@ -2,13 +2,15 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Filter, Package2, Plus, Printer } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, Package2, Plus, Printer, Search } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
@@ -18,7 +20,6 @@ import { SalesSlipStatus, SalesSlipSummary } from "@/types/sales"
type StatusFilter = SalesSlipStatus | "All"
const PAGE_SIZE = 10
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
function statusClass(status: SalesSlipStatus) {
switch (status) {
@@ -95,36 +96,32 @@ export default function SalesSlipsPage() {
</div>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t}
onClick={() => setStatus(t)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t}
</button>
))}
<Card>
<CardHeader className="border-b">
<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 customer or slip number"
className="h-14 w-full pl-11 text-base"
aria-label="Search slips"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
<SelectItem value="Posted" className="text-base">Posted</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Filter by customer or slip number"
className="h-12 w-full lg:max-w-sm"
/>
<Button variant="outline" size="sm" className="lg:ml-auto">
<Filter className="size-4" />
Advanced
</Button>
</div>
</div>
</CardHeader>
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
@@ -145,6 +142,7 @@ export default function SalesSlipsPage() {
{!error && rows !== null && visibleRows.length > 0 && (
<>
<CardContent className="px-0">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
@@ -191,6 +189,7 @@ export default function SalesSlipsPage() {
</TableBody>
</Table>
</div>
</CardContent>
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-3">
@@ -229,7 +228,7 @@ export default function SalesSlipsPage() {
)}
</>
)}
</div>
</Card>
</div>
)
}
@@ -1,69 +1,3 @@
import Link from "next/link"
import {
AlertOctagon,
AlertTriangle,
ArrowLeftRight,
BadgeDollarSign,
ClipboardList,
PackageSearch,
ScrollText,
SlidersHorizontal,
type LucideIcon,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
{
title: "Stock Enquiry",
description: "On-hand, available, on-hold, and in-transit quantities by item and warehouse.",
href: "/dashboard/stock/enquiry",
icon: PackageSearch,
},
{
title: "Stock Ledger",
description: "The immutable, append-only movement journal — every in/out with running balance.",
href: "/dashboard/stock/ledger",
icon: ScrollText,
},
{
title: "Valuation",
description: "FIFO cost-layer breakdown and total stock value by item and warehouse.",
href: "/dashboard/stock/valuation",
icon: BadgeDollarSign,
},
{
title: "Transfers",
description: "Move stock between warehouses: create, dispatch, and receive (in-transit).",
href: "/dashboard/stock/transfers",
icon: ArrowLeftRight,
},
{
title: "Adjustments",
description: "Increase, decrease, or write off stock with a mandatory reason code.",
href: "/dashboard/stock/adjustments",
icon: SlidersHorizontal,
},
{
title: "Counts",
description: "Cycle or full physical counts — snapshot, enter counts, post variance.",
href: "/dashboard/stock/counts",
icon: ClipboardList,
},
{
title: "Reorder Alerts",
description: "Items at or below their reorder point, with a one-click requisition.",
href: "/dashboard/stock/reorder-alerts",
icon: AlertTriangle,
},
{
title: "Wastage",
description: "Damage, theft/loss, and expiry write-offs — reason-coded adjustments with a totals report.",
href: "/dashboard/stock/wastage",
icon: AlertOctagon,
},
]
export default function StockHubPage() {
return (
<div className="flex flex-col gap-6">
@@ -73,26 +7,6 @@ export default function StockHubPage() {
FIFO-costed stock across multiple warehouses (FR-STK-01..14).
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{areas.map((area) => (
<Link key={area.href} href={area.href}>
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<area.icon className="size-5" />
</div>
<CardTitle className="text-lg">{area.title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-base text-muted-foreground">{area.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}
@@ -4,6 +4,9 @@ import { useEffect, useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
AlertOctagon,
AlertTriangle,
ArrowLeftRight,
Banknote,
BadgeDollarSign,
BookOpen,
@@ -29,6 +32,7 @@ import {
Menu,
Package,
PackageCheck,
PackageSearch,
PackageX,
PlayCircle,
PieChart,
@@ -36,6 +40,7 @@ import {
ReceiptText,
Ruler,
Scale,
ScrollText,
Settings,
ShieldCheck,
ShoppingCart,
@@ -116,7 +121,26 @@ const navItems: {
],
},
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
{
title: "Stock",
code: "stock",
href: "/dashboard/stock",
// Clicking "Stock" itself lands on Stock Ledger — the hub page underneath has nothing on
// it (its card grid was removed once the sidebar grew these sub-items), same as Procurement.
landingHref: "/dashboard/stock/ledger",
icon: Warehouse,
chevron: true,
children: [
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
{ title: "Counts", code: "stock.counts", href: "/dashboard/stock/counts", icon: ClipboardList },
{ title: "Reorder Alerts", code: "stock.reorder-alerts", href: "/dashboard/stock/reorder-alerts", icon: AlertTriangle },
{ title: "Wastage", code: "stock.wastage", href: "/dashboard/stock/wastage", icon: AlertOctagon },
],
},
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
{
@@ -402,14 +426,15 @@ export function AppSidebar() {
// flashing the full menu to a restricted role. Once resolved, a nav item
// is visible if its own code is granted, or (for parents) if any child is.
//
// "procurement", "hrm", "sales" and "production" are exempted from that check (frontend-only): no
// role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children
// server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted
// the permission properly (Settings → Roles → Sidebar permissions) or a backend seed
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
// "procurement", "hrm", "sales", "production" and "stock" are exempted from that check
// (frontend-only): no role is currently seeded with NAV:procurement/NAV:hrm/NAV:production
// or their children server-side (stock's children specifically have no SubNavItem rows at
// all yet), which would hide the whole section for everyone. Remove each bypass once roles
// are granted the permission properly (Settings → Roles → Sidebar permissions) or a backend
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
// anything server-side.
const bypassCodes = new Set(["procurement", "sales", "hrm", "production"])
const bypassCodes = new Set(["procurement", "sales", "hrm", "production", "stock"])
const visibleItems = loading
? []
: navItems
+41
View File
@@ -0,0 +1,41 @@
const UOM_LABELS: Record<string, string> = {
BAG: "Bag",
BOX: "Box",
BTL: "Bottle",
CAN: "Can",
CM: "Centimeter",
CTN: "Carton",
DOZ: "Dozen",
EA: "Each",
G: "Gram",
KG: "Kilogram",
L: "Litre",
M: "Meter",
ML: "Millilitre",
MM: "Millimeter",
PACK: "Pack",
PCS: "Pieces",
PK: "Pack",
PKT: "Packet",
ROLL: "Roll",
SET: "Set",
}
export function formatUomName(name: string | null | undefined): string {
const normalized = name?.trim()
if (!normalized) return ""
const mapped = UOM_LABELS[normalized.toUpperCase()]
if (mapped) return mapped
if (/^[A-Z0-9/_-]+$/.test(normalized)) {
return normalized.charAt(0) + normalized.slice(1).toLowerCase()
}
return normalized
}
export function findUomLabel(uoms: Array<{ uomId: number; name: string }>, uomId: number): string {
const name = uoms.find((uom) => uom.uomId === uomId)?.name
return name ? formatUomName(name) : `#${uomId}`
}
+101
View File
@@ -0,0 +1,101 @@
type SalesEditableLine = {
itemId: number
uomId: number
warehouseId: number
qty: number
freeQty: number
unitPrice?: number | null
discountPct?: number
taxPct?: number
}
type SalesDocumentValidationInput = {
customerId: number | null
warehouseId: number | null
cashierUserId?: number | null
requireCashierUser?: boolean
lines: SalesEditableLine[]
lineLabel?: string
}
type BundleEditableLine = {
itemId: number
uomId: number
qty: number
unitPrice: number
}
type BundleValidationInput = {
customerId: number | null
warehouseId: number | null
cashierUserId: number | null
templateId: number | null
bundleName: string
bundlePrice: number
lines: BundleEditableLine[]
}
function invalidNumber(value: number | null | undefined) {
return value === null || value === undefined || !Number.isFinite(value)
}
export function validateSalesDocument(input: SalesDocumentValidationInput): string | null {
const {
customerId,
warehouseId,
cashierUserId,
requireCashierUser = false,
lines,
lineLabel = "line",
} = input
if (!customerId) return "Select a customer."
if (!warehouseId) return "Select a warehouse."
if (requireCashierUser && !cashierUserId) return "Select a cashier."
if (lines.length === 0) return `Add at least one ${lineLabel}.`
for (const [index, line] of lines.entries()) {
const row = index + 1
if (!line.itemId) return `Select an item for ${lineLabel} ${row}.`
if (!line.uomId) return `Select a valid UOM for ${lineLabel} ${row}.`
if (!line.warehouseId) return `Select a warehouse for ${lineLabel} ${row}.`
if (warehouseId && line.warehouseId !== warehouseId) {
return `${lineLabel[0]?.toUpperCase() ?? "L"}${lineLabel.slice(1)} ${row} warehouse must match the selected header warehouse.`
}
if (invalidNumber(line.qty) || line.qty <= 0) return `Enter a quantity greater than zero for ${lineLabel} ${row}.`
if (invalidNumber(line.freeQty) || line.freeQty < 0) return `Enter a valid free quantity for ${lineLabel} ${row}.`
if (line.unitPrice !== null && line.unitPrice !== undefined && (invalidNumber(line.unitPrice) || line.unitPrice < 0)) {
return `Enter a valid unit price for ${lineLabel} ${row}.`
}
if (line.discountPct !== undefined && (invalidNumber(line.discountPct) || line.discountPct < 0 || line.discountPct > 100)) {
return `Enter a discount percentage between 0 and 100 for ${lineLabel} ${row}.`
}
if (line.taxPct !== undefined && (invalidNumber(line.taxPct) || line.taxPct < 0 || line.taxPct > 100)) {
return `Enter a tax percentage between 0 and 100 for ${lineLabel} ${row}.`
}
}
return null
}
export function validateBundleSale(input: BundleValidationInput): string | null {
const { customerId, warehouseId, cashierUserId, templateId, bundleName, bundlePrice, lines } = input
if (!customerId) return "Select a customer."
if (!warehouseId) return "Select a warehouse."
if (!cashierUserId) return "Select a cashier."
if (!templateId) return "Select a bundle template."
if (!bundleName.trim()) return "Enter a bundle name."
if (invalidNumber(bundlePrice) || bundlePrice < 0) return "Enter a valid bundle price."
if (lines.length === 0) return "Add at least one bundle component line."
for (const [index, line] of lines.entries()) {
const row = index + 1
if (!line.itemId) return `Select an item for component line ${row}.`
if (!line.uomId) return `Select a valid UOM for component line ${row}.`
if (invalidNumber(line.qty) || line.qty <= 0) return `Enter a quantity greater than zero for component line ${row}.`
if (invalidNumber(line.unitPrice) || line.unitPrice < 0) return `Enter a valid unit price for component line ${row}.`
}
return null
}