Compare commits

..

4 Commits

Author SHA1 Message Date
Sasanka bb6d939059 feat(grn): add update functionality for draft GRNs and support document-level discounts
- Implemented Update method in GrnsController to allow editing of draft GRNs.
- Enhanced CreateGrnRequest to include an optional totalDiscount property.
- Updated GrnService to handle GRN updates, including validation and line item processing.
- Modified NewGrnPage to support editing existing GRNs and applying document-level discounts.
- Improved UI in NewItemPage and GrnDetailPage for better user experience.
- Added search functionality to Select component for improved item selection.
2026-08-11 11:00:31 +05:30
ImanThiyanga 8e9974b735 Merge pull request 'fix: update toast messages and improve UI elements in purchase orders and stock pages' (#34) from ui-fixers-7/8 into Dev
Reviewed-on: #34
2026-08-07 05:17:04 +00:00
Sasanka c4e016c460 fix: update toast messages and improve UI elements in purchase orders and stock pages 2026-08-07 10:46:23 +05:30
ImanThiyanga 7e8418685c Merge pull request 'fix sales issues' (#33) from fix--Sales_issues into Dev
Reviewed-on: #33
2026-08-07 05:13:02 +00:00
21 changed files with 747 additions and 394 deletions
@@ -42,6 +42,18 @@ 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)]
+2
View File
@@ -69,6 +69,8 @@ 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 (0100). When supplied, per-line discounts are ignored.</summary>
[Range(0, 100)] public decimal? TotalDiscountPct { get; set; }
}
public sealed class ReleaseLineRequest
+104
View File
@@ -322,6 +322,110 @@ 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)
{
@@ -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);
@@ -13,6 +13,9 @@ 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);
@@ -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">
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Plus, X } from "lucide-react"
import { ExternalLink, Plus, X } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
@@ -44,6 +44,11 @@ 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()
@@ -280,7 +285,20 @@ export default function NewItemPage() {
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<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>
<Select<number | null>
value={categoryId}
onValueChange={handleCategoryChange}
@@ -303,7 +321,22 @@ export default function NewItemPage() {
just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */}
{config?.subcategoriesEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label>
<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>
<Select<number | null>
value={subCategoryId}
onValueChange={setSubCategoryId}
@@ -325,7 +358,20 @@ export default function NewItemPage() {
)}
{config?.brandsEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<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>
<Select<number | null>
value={brandId}
onValueChange={setBrandId}
@@ -345,7 +391,20 @@ export default function NewItemPage() {
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<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>
<Select<number | null>
value={warehouseId}
onValueChange={setWarehouseId}
@@ -364,7 +423,20 @@ export default function NewItemPage() {
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label>
<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>
<Select<number | null>
value={baseUomId}
onValueChange={setBaseUomId}
@@ -454,11 +526,22 @@ 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>
<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 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>
<div className="flex flex-wrap items-center gap-4">
@@ -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}
@@ -130,10 +130,15 @@ export default function GrnDetailPage() {
</div>
{grn.status === "Draft" && (
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
<PackageCheck className="size-5" />
{confirming ? "Confirming…" : "Confirm GRN"}
</Button>
<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>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
@@ -86,6 +86,8 @@ 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")
@@ -102,6 +104,7 @@ 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>>>({})
@@ -127,6 +130,37 @@ 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([])
@@ -208,6 +242,15 @@ 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))
}
@@ -272,7 +315,8 @@ export default function NewGrnPage() {
binId: l.binId,
qty: Number(l.qty),
unitCost: Number(l.unitCost),
discountPct: Number(l.discountPct) || 0,
// Use document-level discount if supplied, otherwise per-line discount.
discountPct: (Number(totalDiscount) || 0) > 0 ? (Number(totalDiscount) || 0) : Number(l.discountPct) || 0,
vatPct: Number(l.vatPct) || 0,
holdStatus: l.holdStatus,
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
@@ -282,13 +326,26 @@ export default function NewGrnPage() {
setSubmitting(true)
try {
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.`)
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.`)
}
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
} catch (err) {
setSubmitError(errorMessage(err))
@@ -440,225 +497,251 @@ 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 className="w-md max-w-[80vw]" align="start">
{(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={(Number(totalDiscount) || 0) > 0 ? totalDiscount : 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>
<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">
<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 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>
@@ -671,7 +754,7 @@ export default function NewGrnPage() {
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create GRN"}
{submitting ? (editingGrnId ? "Saving…" : "Creating…") : editingGrnId ? "Save Changes" : "Create GRN"}
</Button>
</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>
)
}
+36 -38
View File
@@ -129,47 +129,45 @@
--sidebar-ring: oklch(0.68 0.186 265.215);
}
/* 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 — the "System" toggle option. Light gray background and sidebar
throughout (no light content split), with one violet accent driving every
interactive state. */
.vibrant {
--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);
--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);
--primary-foreground: oklch(0.98 0 0);
--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);
--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);
--accent-foreground: oklch(0.98 0 0);
--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);
--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);
--sidebar-primary-foreground: oklch(0.98 0 0);
--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);
--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);
}
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
@@ -186,7 +184,7 @@
--popover-foreground: var(--sidebar-foreground);
--foreground: var(--sidebar-foreground);
--muted: var(--sidebar-accent);
--muted-foreground: oklch(0.72 0.015 265);
--muted-foreground: oklch(0.4 0.015 265);
--primary: var(--sidebar-primary);
--primary-foreground: var(--sidebar-primary-foreground);
--border: var(--sidebar-border);
@@ -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,
@@ -115,8 +120,27 @@ const navItems: {
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
],
},
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, 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
+80 -3
View File
@@ -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 } from "lucide-react"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon, SearchIcon } 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,6 +61,50 @@ 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",
@@ -96,13 +140,27 @@ function SelectContent({
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
// 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,
...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
@@ -119,8 +177,27 @@ 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>{children}</SelectPrimitive.List>
<SelectPrimitive.List>{filteredChildren}</SelectPrimitive.List>
{noResults && (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">No results found.</div>
)}
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
+4
View File
@@ -43,6 +43,10 @@ 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.
+2
View File
@@ -49,6 +49,8 @@ export interface CreateGrnRequest {
vendorId?: number | null
warehouseId: number
lines: CreateGrnLineInput[]
/** Optional total document-level discount % (0100). When set, per-line discounts are cleared. */
totalDiscount?: number
}
export interface GrnLine {