Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5423cd7138 | |||
| ff1eeecf58 | |||
| 5ffcd237e3 | |||
| a8c6b4cb5e |
@@ -42,18 +42,6 @@ public sealed class GrnsController : ApiControllerBase
|
||||
return Created($"/api/v1/grns/{dto.GrnId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Update a Draft GRN's header/lines.</summary>
|
||||
[HttpPut("{grnId:int}")]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<GrnDto>> Update(int grnId, [FromBody] CreateGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _grns.UpdateAsync(grnId, request, ct);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn).</summary>
|
||||
[HttpPost("{grnId:int}/confirm")]
|
||||
[ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -69,8 +69,6 @@ public sealed class CreateGrnRequest
|
||||
public int? VendorId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
|
||||
/// <summary>Optional document-level discount percentage (0–100). When supplied, per-line discounts are ignored.</summary>
|
||||
[Range(0, 100)] public decimal? TotalDiscountPct { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReleaseLineRequest
|
||||
|
||||
@@ -322,110 +322,6 @@ public sealed class GrnService : IGrnService
|
||||
return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected);
|
||||
}
|
||||
|
||||
public async Task<GrnDto> UpdateAsync(int grnId, CreateGrnRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query()
|
||||
.Include(g => g.Lines)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
if (grn.Status != GrnStatus.Draft)
|
||||
throw new ConflictException($"GRN {grnId} is {grn.Status} and can no longer be edited.");
|
||||
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
PurchaseOrder? po = null;
|
||||
int vendorId;
|
||||
if (request.PoId is not null)
|
||||
{
|
||||
po = await _pos.Query().AsNoTracking().Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == request.PoId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422);
|
||||
if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived))
|
||||
throw new ConflictException($"Purchase order {po.PoId} is {po.Status} and cannot be received against.");
|
||||
vendorId = po.VendorId;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (request.VendorId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422);
|
||||
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
vendorId = request.VendorId.Value;
|
||||
}
|
||||
|
||||
var lines = new List<GrnLine>();
|
||||
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
|
||||
|
||||
foreach (var input in request.Lines)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422);
|
||||
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
|
||||
|
||||
decimal unitCost;
|
||||
decimal? poUnitPrice = null;
|
||||
if (input.PoLineId is not null)
|
||||
{
|
||||
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is not on purchase order {request.PoId}.", 422);
|
||||
if (poLine.ItemId != input.ItemId)
|
||||
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
|
||||
|
||||
var openQty = poLine.Qty - poLine.QtyReceived;
|
||||
if (input.Qty > openQty * (1 + OverReceiptTolerance))
|
||||
throw new DomainException(ErrorCodes.OverReceiptTolerance,
|
||||
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
|
||||
|
||||
poUnitPrice = poLine.UnitPrice;
|
||||
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
|
||||
}
|
||||
else
|
||||
{
|
||||
unitCost = input.UnitCost;
|
||||
}
|
||||
|
||||
var netUnitCost = Math.Round(unitCost * (1 - input.DiscountPct / 100m), 6, MidpointRounding.AwayFromZero);
|
||||
var receivedValue = Math.Round(input.Qty * netUnitCost, 4, MidpointRounding.AwayFromZero);
|
||||
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
|
||||
|
||||
lines.Add(new GrnLine
|
||||
{
|
||||
PoLineId = input.PoLineId,
|
||||
ItemId = input.ItemId,
|
||||
UomId = input.UomId,
|
||||
BinId = input.BinId,
|
||||
Batch = batch,
|
||||
Qty = input.Qty,
|
||||
UnitCost = unitCost,
|
||||
PoUnitPrice = poUnitPrice,
|
||||
DiscountPct = input.DiscountPct,
|
||||
NetUnitCost = netUnitCost,
|
||||
VatPct = input.VatPct,
|
||||
VatAmount = vatAmount,
|
||||
ReceivedValue = receivedValue,
|
||||
LineTotal = receivedValue + vatAmount,
|
||||
HoldStatus = input.HoldStatus
|
||||
});
|
||||
}
|
||||
|
||||
grn.PoId = request.PoId;
|
||||
grn.VendorId = vendorId;
|
||||
grn.WarehouseId = request.WarehouseId;
|
||||
grn.Lines.Clear();
|
||||
foreach (var line in lines) grn.Lines.Add(line);
|
||||
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return Map(grn);
|
||||
}
|
||||
|
||||
private async Task<Batch?> ResolveBatchAsync(
|
||||
Item item, BatchInput? batch, Dictionary<(int, string), Batch> cache, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -13,9 +13,6 @@ public interface IGrnService
|
||||
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
|
||||
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Update a Draft GRN's header/lines; rejects if the GRN is no longer Draft.</summary>
|
||||
Task<GrnDto> UpdateAsync(int grnId, CreateGrnRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts, atomically.</summary>
|
||||
Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ExternalLink, Plus, X } from "lucide-react"
|
||||
import { Plus, X } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
@@ -44,11 +44,6 @@ function isBuilderItemType(name: string): boolean {
|
||||
return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
|
||||
}
|
||||
|
||||
/** Opens a master-data management page in a new tab so the in-progress form isn't lost. */
|
||||
function openInNewTab(path: string) {
|
||||
window.open(path, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
export default function NewItemPage() {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -285,20 +280,7 @@ export default function NewItemPage() {
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
onClick={() => openInNewTab("/dashboard/products/categories")}
|
||||
aria-label="Add category"
|
||||
title="Add category"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-base">Category</Label>
|
||||
<Select<number | null>
|
||||
value={categoryId}
|
||||
onValueChange={handleCategoryChange}
|
||||
@@ -321,22 +303,7 @@ export default function NewItemPage() {
|
||||
just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */}
|
||||
{config?.subcategoriesEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base">Subcategory (optional)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
onClick={() =>
|
||||
openInNewTab(categoryId ? `/dashboard/products/categories/${categoryId}` : "/dashboard/products/categories")
|
||||
}
|
||||
aria-label="Add subcategory"
|
||||
title="Add subcategory"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-base">Subcategory (optional)</Label>
|
||||
<Select<number | null>
|
||||
value={subCategoryId}
|
||||
onValueChange={setSubCategoryId}
|
||||
@@ -358,20 +325,7 @@ export default function NewItemPage() {
|
||||
)}
|
||||
{config?.brandsEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base">Brand (optional)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
onClick={() => openInNewTab("/dashboard/products/brands")}
|
||||
aria-label="Add brand"
|
||||
title="Add brand"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-base">Brand (optional)</Label>
|
||||
<Select<number | null>
|
||||
value={brandId}
|
||||
onValueChange={setBrandId}
|
||||
@@ -391,20 +345,7 @@ export default function NewItemPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
onClick={() => openInNewTab("/dashboard/warehouse")}
|
||||
aria-label="Add warehouse"
|
||||
title="Add warehouse"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Select<number | null>
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
@@ -423,20 +364,7 @@ export default function NewItemPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
onClick={() => openInNewTab("/dashboard/products/uoms")}
|
||||
aria-label="Add UOM"
|
||||
title="Add UOM"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Select<number | null>
|
||||
value={baseUomId}
|
||||
onValueChange={setBaseUomId}
|
||||
@@ -526,22 +454,11 @@ export default function NewItemPage() {
|
||||
item-type reference), so this section IS the enforcement. */}
|
||||
{config?.itemTypesEnabled && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check the item types that apply, then add their values to generate a SKU per combination.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openInNewTab("/dashboard/products/item-types")}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Manage item types
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check the item types that apply, then add their values to generate a SKU per combination.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
|
||||
@@ -130,15 +130,10 @@ export default function GrnDetailPage() {
|
||||
</div>
|
||||
|
||||
{grn.status === "Draft" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/dashboard/receiving/grn/new?grnId=${grn.grnId}`} className={cn(buttonVariants({ size: "lg", variant: "outline" }))}>
|
||||
Edit
|
||||
</Link>
|
||||
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
||||
<PackageCheck className="size-5" />
|
||||
{confirming ? "Confirming…" : "Confirm GRN"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
||||
<PackageCheck className="size-5" />
|
||||
{confirming ? "Confirming…" : "Confirm GRN"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||
|
||||
@@ -86,8 +86,6 @@ function emptyLine(): DraftLine {
|
||||
|
||||
export default function NewGrnPage() {
|
||||
const router = useRouter()
|
||||
const search = useSearchParams()
|
||||
const editingGrnId = Number(search?.get("grnId")) || null
|
||||
|
||||
const [mode, setMode] = useState<Mode>("po")
|
||||
|
||||
@@ -104,7 +102,6 @@ export default function NewGrnPage() {
|
||||
const [poId, setPoId] = useState<number | null>(null)
|
||||
const [poLoading, setPoLoading] = useState(false)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
const [totalDiscount, setTotalDiscount] = useState<string>("")
|
||||
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
@@ -130,37 +127,6 @@ export default function NewGrnPage() {
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
// If editing an existing draft GRN, load and populate the form.
|
||||
useEffect(() => {
|
||||
if (!editingGrnId) return
|
||||
grnsApi
|
||||
.get(editingGrnId)
|
||||
.then((g) => {
|
||||
setPoId(g.poId ?? null)
|
||||
setVendorId(g.vendorId ?? null)
|
||||
setWarehouseId(g.warehouseId)
|
||||
setLines(
|
||||
g.lines.map((ln) => ({
|
||||
key: newKey(),
|
||||
poLineId: ln.poLineId,
|
||||
itemId: ln.itemId,
|
||||
uomId: ln.uomId,
|
||||
binId: ln.binId,
|
||||
qty: String(ln.qty),
|
||||
unitCost: String(ln.unitCost),
|
||||
poUnitPrice: ln.poUnitPrice,
|
||||
discountPct: String(ln.discountPct),
|
||||
vatPct: String(ln.vatPct),
|
||||
holdStatus: ln.holdStatus,
|
||||
batchNo: "",
|
||||
expiryDate: "",
|
||||
serialNumbersText: "",
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [editingGrnId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!warehouseId) {
|
||||
setBins([])
|
||||
@@ -242,15 +208,6 @@ export default function NewGrnPage() {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
// When a document-level total discount is entered, clear any per-line discounts.
|
||||
function updateTotalDiscount(next: string) {
|
||||
setTotalDiscount(next)
|
||||
const pct = Number(next) || 0
|
||||
if (pct > 0) {
|
||||
setLines((prev) => prev.map((l) => ({ ...l, discountPct: "0" })))
|
||||
}
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
@@ -315,8 +272,7 @@ export default function NewGrnPage() {
|
||||
binId: l.binId,
|
||||
qty: Number(l.qty),
|
||||
unitCost: Number(l.unitCost),
|
||||
// Use document-level discount if supplied, otherwise per-line discount.
|
||||
discountPct: (Number(totalDiscount) || 0) > 0 ? (Number(totalDiscount) || 0) : Number(l.discountPct) || 0,
|
||||
discountPct: Number(l.discountPct) || 0,
|
||||
vatPct: Number(l.vatPct) || 0,
|
||||
holdStatus: l.holdStatus,
|
||||
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
|
||||
@@ -326,26 +282,13 @@ export default function NewGrnPage() {
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
let grn
|
||||
if (editingGrnId) {
|
||||
grn = await grnsApi.update(editingGrnId, {
|
||||
poId: mode === "po" ? poId : null,
|
||||
vendorId: mode === "direct" ? vendorId : null,
|
||||
warehouseId: warehouseId as number,
|
||||
lines: payloadLines,
|
||||
totalDiscount: Number(totalDiscount) || undefined,
|
||||
})
|
||||
toast.success("GRN updated", `${grn.docNo} is ready to confirm.`)
|
||||
} else {
|
||||
grn = await grnsApi.create({
|
||||
poId: mode === "po" ? poId : null,
|
||||
vendorId: mode === "direct" ? vendorId : null,
|
||||
warehouseId: warehouseId as number,
|
||||
lines: payloadLines,
|
||||
totalDiscount: Number(totalDiscount) || undefined,
|
||||
})
|
||||
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
|
||||
}
|
||||
const grn = await grnsApi.create({
|
||||
poId: mode === "po" ? poId : null,
|
||||
vendorId: mode === "direct" ? vendorId : null,
|
||||
warehouseId: warehouseId as number,
|
||||
lines: payloadLines,
|
||||
})
|
||||
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
|
||||
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
@@ -530,7 +473,7 @@ export default function NewGrnPage() {
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-md max-w-[80vw]" align="start">
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
@@ -614,11 +557,10 @@ export default function NewGrnPage() {
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={(Number(totalDiscount) || 0) > 0 ? totalDiscount : line.discountPct}
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
disabled={(Number(totalDiscount) || 0) > 0}
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
@@ -711,37 +653,12 @@ export default function NewGrnPage() {
|
||||
)}
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-4 text-base">
|
||||
<div className="flex items-center gap-3 justify-end">
|
||||
<label className="text-sm text-muted-foreground">Total discount %</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={totalDiscount}
|
||||
onChange={(e) => updateTotalDiscount(e.target.value)}
|
||||
className="w-28 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 justify-end">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{(() => {
|
||||
const totalReceived = lines.reduce((s, l) => s + computeLine(l).receivedValue, 0)
|
||||
const totalVat = lines.reduce((s, l) => s + computeLine(l).vatAmount, 0)
|
||||
const pct = Number(totalDiscount) || 0
|
||||
if (pct > 0) {
|
||||
const discountedBase = totalReceived * (1 - pct / 100)
|
||||
const discountedVat = lines.reduce((s, l) => s + computeLine(l).vatAmount * (1 - pct / 100), 0)
|
||||
return (discountedBase + discountedVat).toFixed(2)
|
||||
}
|
||||
return (totalReceived + totalVat).toFixed(2)
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -754,7 +671,7 @@ export default function NewGrnPage() {
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? (editingGrnId ? "Saving…" : "Creating…") : editingGrnId ? "Save Changes" : "Create GRN"}
|
||||
{submitting ? "Creating…" : "Create GRN"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,45 +129,47 @@
|
||||
--sidebar-ring: oklch(0.68 0.186 265.215);
|
||||
}
|
||||
|
||||
/* Vibrant — the "System" toggle option. Light gray background and sidebar
|
||||
throughout (no light content split), with one violet accent driving every
|
||||
interactive state. */
|
||||
/* Vibrant — the "System" toggle option. Light content area (background,
|
||||
cards, header, table panels) paired with a dark sidebar — the same split
|
||||
Linear/Vercel/Notion use in their light themes. One violet accent drives
|
||||
every interactive state; the sidebar keeps its own dark token family
|
||||
(applied via .sidebar-surface below) so it stays dark regardless. */
|
||||
.vibrant {
|
||||
--background: oklch(0.82 0 0);
|
||||
--foreground: oklch(0.2 0 0);
|
||||
--card: oklch(0.99 0 0);
|
||||
--card-foreground: oklch(0.2 0 0);
|
||||
--popover: oklch(0.99 0 0);
|
||||
--popover-foreground: oklch(0.2 0 0);
|
||||
--primary: oklch(0.5 0.2 275);
|
||||
--background: oklch(0.97 0.004 265);
|
||||
--foreground: oklch(0.2 0.02 265);
|
||||
--card: oklch(0.995 0.002 265);
|
||||
--card-foreground: oklch(0.2 0.02 265);
|
||||
--popover: oklch(0.995 0.002 265);
|
||||
--popover-foreground: oklch(0.2 0.02 265);
|
||||
--primary: oklch(0.55 0.2 275);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
--secondary: oklch(0.82 0.02 275);
|
||||
--secondary-foreground: oklch(0.35 0.15 275);
|
||||
--muted: oklch(0.85 0 0);
|
||||
--muted-foreground: oklch(0.45 0 0);
|
||||
--accent: oklch(0.55 0.13 210);
|
||||
--secondary: oklch(0.93 0.02 275);
|
||||
--secondary-foreground: oklch(0.32 0.15 275);
|
||||
--muted: oklch(0.94 0.006 265);
|
||||
--muted-foreground: oklch(0.48 0.02 265);
|
||||
--accent: oklch(0.55 0.14 210);
|
||||
--accent-foreground: oklch(0.98 0 0);
|
||||
--destructive: oklch(0.55 0.22 25);
|
||||
--border: oklch(0.78 0 0);
|
||||
--input: oklch(0.8 0 0);
|
||||
--ring: oklch(0.5 0.2 275);
|
||||
--success: oklch(0.5 0.15 142.495);
|
||||
--warning: oklch(0.65 0.15 72.031);
|
||||
--error: oklch(0.55 0.22 25);
|
||||
--info: oklch(0.55 0.13 210);
|
||||
--chart-1: oklch(0.5 0.2 275);
|
||||
--chart-2: oklch(0.55 0.13 210);
|
||||
--chart-3: oklch(0.5 0.15 142.495);
|
||||
--chart-4: oklch(0.65 0.15 72.031);
|
||||
--chart-5: oklch(0.55 0.22 25);
|
||||
--sidebar: oklch(0.82 0 0);
|
||||
--sidebar-foreground: oklch(0.2 0 0);
|
||||
--sidebar-primary: oklch(0.5 0.19 275);
|
||||
--destructive: oklch(0.58 0.22 25);
|
||||
--border: oklch(0.88 0.012 265);
|
||||
--input: oklch(0.92 0.01 265);
|
||||
--ring: oklch(0.55 0.2 275);
|
||||
--success: oklch(0.55 0.15 150);
|
||||
--warning: oklch(0.72 0.15 80);
|
||||
--error: oklch(0.58 0.22 25);
|
||||
--info: oklch(0.55 0.14 210);
|
||||
--chart-1: oklch(0.55 0.2 275);
|
||||
--chart-2: oklch(0.55 0.14 210);
|
||||
--chart-3: oklch(0.55 0.15 150);
|
||||
--chart-4: oklch(0.72 0.15 80);
|
||||
--chart-5: oklch(0.58 0.22 25);
|
||||
--sidebar: oklch(0.18 0.02 265);
|
||||
--sidebar-foreground: oklch(0.96 0.005 265);
|
||||
--sidebar-primary: oklch(0.64 0.19 275);
|
||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||
--sidebar-accent: oklch(0.75 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.2 0 0);
|
||||
--sidebar-border: oklch(0.7 0 0);
|
||||
--sidebar-ring: oklch(0.5 0.19 275);
|
||||
--sidebar-accent: oklch(0.28 0.03 265);
|
||||
--sidebar-accent-foreground: oklch(0.96 0.005 265);
|
||||
--sidebar-border: oklch(0.26 0.025 265);
|
||||
--sidebar-ring: oklch(0.64 0.19 275);
|
||||
}
|
||||
|
||||
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
|
||||
@@ -184,7 +186,7 @@
|
||||
--popover-foreground: var(--sidebar-foreground);
|
||||
--foreground: var(--sidebar-foreground);
|
||||
--muted: var(--sidebar-accent);
|
||||
--muted-foreground: oklch(0.4 0.015 265);
|
||||
--muted-foreground: oklch(0.72 0.015 265);
|
||||
--primary: var(--sidebar-primary);
|
||||
--primary-foreground: var(--sidebar-primary-foreground);
|
||||
--border: var(--sidebar-border);
|
||||
|
||||
@@ -120,7 +120,7 @@ const navItems: {
|
||||
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
||||
],
|
||||
},
|
||||
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{
|
||||
title: "Stock",
|
||||
code: "stock",
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon, SearchIcon } from "lucide-react"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
// Base UI's `Select.Value` renders the raw selected value (e.g. an id) unless the Root is
|
||||
// given an `items` map to resolve the label from — the popup items are unmounted when closed,
|
||||
@@ -61,50 +61,6 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Flattens a node's rendered text so a SelectItem can be matched against a search query. */
|
||||
function nodeToText(node: React.ReactNode): string {
|
||||
if (node === null || node === undefined || typeof node === "boolean") return ""
|
||||
if (typeof node === "string" || typeof node === "number") return String(node)
|
||||
if (Array.isArray(node)) return node.map(nodeToText).join(" ")
|
||||
if (React.isValidElement(node)) {
|
||||
return nodeToText((node.props as { children?: React.ReactNode }).children)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/** Walks the popup's children, dropping any SelectItem whose text doesn't match the query. */
|
||||
function filterSelectChildren(children: React.ReactNode, query: string): React.ReactNode {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return children
|
||||
|
||||
return React.Children.map(children, (child) => {
|
||||
if (!React.isValidElement(child)) return child
|
||||
if (child.type === SelectItem) {
|
||||
const text = nodeToText((child.props as { children?: React.ReactNode }).children).toLowerCase()
|
||||
return text.includes(q) ? child : null
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode }).children
|
||||
if (nested !== undefined) {
|
||||
return React.cloneElement(child, undefined, filterSelectChildren(nested, query))
|
||||
}
|
||||
return child
|
||||
})
|
||||
}
|
||||
|
||||
function countSelectItems(node: React.ReactNode): number {
|
||||
let count = 0
|
||||
React.Children.forEach(node, (child) => {
|
||||
if (!React.isValidElement(child)) return
|
||||
if (child.type === SelectItem) {
|
||||
count += 1
|
||||
return
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode }).children
|
||||
if (nested !== undefined) count += countSelectItems(nested)
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
@@ -140,27 +96,13 @@ function SelectContent({
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
// Aligning the popup to the selected item lets it float above the trigger (and above the
|
||||
// search box). A plain dropdown that always opens fully below the trigger is what the
|
||||
// search box needs to stay pinned to the top, so this defaults to off now.
|
||||
alignItemWithTrigger = false,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
const [query, setQuery] = React.useState("")
|
||||
const searchRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
// The popup remounts each time it opens, so this only ever fires once per open.
|
||||
React.useEffect(() => {
|
||||
searchRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const filteredChildren = React.useMemo(() => filterSelectChildren(children, query), [children, query])
|
||||
const noResults = query.trim().length > 0 && countSelectItems(filteredChildren) === 0
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
@@ -177,27 +119,8 @@ function SelectContent({
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<div data-slot="select-search" className="sticky top-0 z-10 bg-popover p-1.5 pb-1">
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation()
|
||||
}}
|
||||
placeholder="Search…"
|
||||
className="h-8 w-full rounded-md border border-input bg-transparent pr-2 pl-7 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{filteredChildren}</SelectPrimitive.List>
|
||||
{noResults && (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground">No results found.</div>
|
||||
)}
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
|
||||
@@ -43,10 +43,6 @@ export const grnsApi = {
|
||||
return apiRequest<Grn>("/grns", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
update(grnId: number, request: CreateGrnRequest): Promise<Grn> {
|
||||
return apiRequest<Grn>(`/grns/${grnId}`, { method: "PUT", body: request })
|
||||
},
|
||||
|
||||
/**
|
||||
* Posts the receipt. Pass a stable idempotencyKey per detail-page session so a retry
|
||||
* cannot double-post stock — unlike the old mock, the server genuinely dedupes on it.
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -49,8 +49,6 @@ export interface CreateGrnRequest {
|
||||
vendorId?: number | null
|
||||
warehouseId: number
|
||||
lines: CreateGrnLineInput[]
|
||||
/** Optional total document-level discount % (0–100). When set, per-line discounts are cleared. */
|
||||
totalDiscount?: number
|
||||
}
|
||||
|
||||
export interface GrnLine {
|
||||
|
||||
Reference in New Issue
Block a user