From bb6d939059d1f5ec037bb1164a0bd2d193729dc7 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Tue, 11 Aug 2026 11:00:31 +0530 Subject: [PATCH] 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. --- Backend/ERPCore/Controllers/GrnsController.cs | 12 ++ Backend/ERPCore/Dtos/Grn/GrnDtos.cs | 2 + Backend/ERPCore/Services/GrnService.cs | 104 +++++++++++++++ .../Services/Interfaces/IBundleSaleService.cs | 3 +- .../Services/Interfaces/IGrnService.cs | 3 + .../app/dashboard/products/new/page.tsx | 105 ++++++++++++++-- .../app/dashboard/receiving/grn/[id]/page.tsx | 13 +- .../app/dashboard/receiving/grn/new/page.tsx | 119 +++++++++++++++--- Frontend/erp-system/app/globals.css | 74 ++++++----- .../components/Layouts/AppSidebar.tsx | 2 +- Frontend/erp-system/components/ui/select.tsx | 83 +++++++++++- Frontend/erp-system/lib/api/grns.ts | 4 + Frontend/erp-system/types/grn.ts | 2 + 13 files changed, 450 insertions(+), 76 deletions(-) diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs index 3ad1f5a..cbbf888 100644 --- a/Backend/ERPCore/Controllers/GrnsController.cs +++ b/Backend/ERPCore/Controllers/GrnsController.cs @@ -42,6 +42,18 @@ public sealed class GrnsController : ApiControllerBase return Created($"/api/v1/grns/{dto.GrnId}", dto); } + /// Update a Draft GRN's header/lines. + [HttpPut("{grnId:int}")] + [ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Update(int grnId, [FromBody] CreateGrnRequest request, CancellationToken ct) + { + var dto = await _grns.UpdateAsync(grnId, request, ct); + return Ok(dto); + } + /// Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn). [HttpPost("{grnId:int}/confirm")] [ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs index aec88c2..b7a89f3 100644 --- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -69,6 +69,8 @@ public sealed class CreateGrnRequest public int? VendorId { get; set; } [Required] public int WarehouseId { get; set; } [Required, MinLength(1)] public List Lines { get; set; } = new(); + /// Optional document-level discount percentage (0–100). When supplied, per-line discounts are ignored. + [Range(0, 100)] public decimal? TotalDiscountPct { get; set; } } public sealed class ReleaseLineRequest diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 41b57ad..825b7b1 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -322,6 +322,110 @@ public sealed class GrnService : IGrnService return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected); } + public async Task 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(); + 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 ResolveBatchAsync( Item item, BatchInput? batch, Dictionary<(int, string), Batch> cache, CancellationToken ct) { diff --git a/Backend/ERPCore/Services/Interfaces/IBundleSaleService.cs b/Backend/ERPCore/Services/Interfaces/IBundleSaleService.cs index b67ee2f..2753889 100644 --- a/Backend/ERPCore/Services/Interfaces/IBundleSaleService.cs +++ b/Backend/ERPCore/Services/Interfaces/IBundleSaleService.cs @@ -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> ListTemplatesAsync(PageQuery query, CancellationToken ct = default); Task GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default); - Task> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default); + Task> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default); Task GetAsync(int bundleSaleId, CancellationToken ct = default); Task CheckPostingAsync(int bundleSaleId, CancellationToken ct = default); Task CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IGrnService.cs b/Backend/ERPCore/Services/Interfaces/IGrnService.cs index a683c26..0988119 100644 --- a/Backend/ERPCore/Services/Interfaces/IGrnService.cs +++ b/Backend/ERPCore/Services/Interfaces/IGrnService.cs @@ -13,6 +13,9 @@ public interface IGrnService Task GetAsync(int grnId, CancellationToken ct = default); Task CreateAsync(CreateGrnRequest request, CancellationToken ct = default); + /// Update a Draft GRN's header/lines; rejects if the GRN is no longer Draft. + Task UpdateAsync(int grnId, CreateGrnRequest request, CancellationToken ct = default); + /// Confirm: create FIFO layers + inbound ledger + update PO receipts, atomically. Task ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default); diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index fa24983..2d431ad 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -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() { <>
- +
+ + +
value={categoryId} onValueChange={handleCategoryChange} @@ -303,7 +321,22 @@ export default function NewItemPage() { just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */} {config?.subcategoriesEnabled && (
- +
+ + +
value={subCategoryId} onValueChange={setSubCategoryId} @@ -325,7 +358,20 @@ export default function NewItemPage() { )} {config?.brandsEnabled && (
- +
+ + +
value={brandId} onValueChange={setBrandId} @@ -345,7 +391,20 @@ export default function NewItemPage() {
)}
- +
+ + +
value={warehouseId} onValueChange={setWarehouseId} @@ -364,7 +423,20 @@ export default function NewItemPage() {
- +
+ + +
value={baseUomId} onValueChange={setBaseUomId} @@ -454,11 +526,22 @@ export default function NewItemPage() { item-type reference), so this section IS the enforcement. */} {config?.itemTypesEnabled && (
-
-

Item types

-

- Check the item types that apply, then add their values to generate a SKU per combination. -

+
+
+

Item types

+

+ Check the item types that apply, then add their values to generate a SKU per combination. +

+
+
diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx index 4d66b46..5b053e4 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -130,10 +130,15 @@ export default function GrnDetailPage() {
{grn.status === "Draft" && ( - +
+ + Edit + + +
)}
diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index fa9ff39..3adadf2 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -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("po") @@ -102,6 +104,7 @@ export default function NewGrnPage() { const [poId, setPoId] = useState(null) const [poLoading, setPoLoading] = useState(false) const [lines, setLines] = useState([emptyLine()]) + const [totalDiscount, setTotalDiscount] = useState("") const [headerError, setHeaderError] = useState(null) const [lineErrors, setLineErrors] = useState>>({}) @@ -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)) @@ -473,7 +530,7 @@ export default function NewGrnPage() { - + {(items ?? []).map((i) => ( {i.sku} — {i.name} @@ -557,10 +614,11 @@ export default function NewGrnPage() { min="0" max="100" step="any" - value={line.discountPct} + 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} /> @@ -653,12 +711,37 @@ export default function NewGrnPage() { )} {!poLoading && lines.length > 0 && ( -
- Document total (incl. VAT) - - {lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)} - -
+
+
+ + updateTotalDiscount(e.target.value)} + className="w-28 text-sm" + /> +
+ +
+ Document total (incl. VAT) + + {(() => { + 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) + })()} + +
+
)}
@@ -671,7 +754,7 @@ export default function NewGrnPage() { Cancel
diff --git a/Frontend/erp-system/app/globals.css b/Frontend/erp-system/app/globals.css index b251b5b..4618f11 100644 --- a/Frontend/erp-system/app/globals.css +++ b/Frontend/erp-system/app/globals.css @@ -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); diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index a1fada5..4e991e3 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -120,7 +120,7 @@ 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: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, { title: "Stock", code: "stock", diff --git a/Frontend/erp-system/components/ui/select.tsx b/Frontend/erp-system/components/ui/select.tsx index 4b598ed..75272f8 100644 --- a/Frontend/erp-system/components/ui/select.tsx +++ b/Frontend/erp-system/components/ui/select.tsx @@ -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(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 ( +
+
+ + 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" + /> +
+
- {children} + {filteredChildren} + {noResults && ( +
No results found.
+ )}
diff --git a/Frontend/erp-system/lib/api/grns.ts b/Frontend/erp-system/lib/api/grns.ts index 9a7cba0..0cad66f 100644 --- a/Frontend/erp-system/lib/api/grns.ts +++ b/Frontend/erp-system/lib/api/grns.ts @@ -43,6 +43,10 @@ export const grnsApi = { return apiRequest("/grns", { method: "POST", body: request }) }, + update(grnId: number, request: CreateGrnRequest): Promise { + return apiRequest(`/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. diff --git a/Frontend/erp-system/types/grn.ts b/Frontend/erp-system/types/grn.ts index 904b217..254009a 100644 --- a/Frontend/erp-system/types/grn.ts +++ b/Frontend/erp-system/types/grn.ts @@ -49,6 +49,8 @@ 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 {