From f02c89b3cb4d928164468b68fc983e350ef1146a Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Tue, 21 Jul 2026 10:08:32 +0530 Subject: [PATCH 1/4] feat(procurement): enhance purchase order and GRN functionalities - Updated purchase order descriptions for clarity on draft and submission processes. - Implemented submit and delete functionalities for draft purchase orders, allowing users to manage their orders more effectively. - Added discount and VAT fields to GRN lines, enabling better cost tracking and reporting. - Enhanced validation for GRN lines to ensure discount and VAT percentages are within acceptable ranges. - Updated API to support new functionalities, including submitting and deleting purchase orders. - Improved UI components for better user experience in managing purchase orders and GRNs. - Documented changes in security and backend phase documentation to reflect new processes and requirements. --- .../Controllers/PurchaseOrdersController.cs | 19 ++++ Backend/ERPCore/Domain/Entities/GrnLine.cs | 33 ++++++- Backend/ERPCore/Domain/Entities/PoLine.cs | 2 +- Backend/ERPCore/Dtos/Grn/GrnDtos.cs | 15 ++- .../Dtos/Procurement/PurchaseOrderDtos.cs | 7 ++ .../Configurations/GrnConfiguration.cs | 6 ++ .../Configurations/PermissionConfiguration.cs | 6 +- .../Configurations/SubNavItemConfiguration.cs | 7 +- .../Migrations/ErpDbContextModelSnapshot.cs | 88 +++++++++++++++++ Backend/ERPCore/Services/GrnService.cs | 30 +++++- .../Interfaces/IPurchaseOrderService.cs | 6 ++ .../ERPCore/Services/PurchaseOrderService.cs | 42 ++++++++- Backend/PROGRESS.md | 14 ++- Frontend/PROGRESS.md | 12 ++- .../app/dashboard/procurement/page.tsx | 2 +- .../procurement/purchase-orders/[id]/page.tsx | 78 ++++++++++++--- .../procurement/purchase-orders/new/page.tsx | 15 ++- .../app/dashboard/receiving/grn/[id]/page.tsx | 43 ++++++++- .../app/dashboard/receiving/grn/new/page.tsx | 94 ++++++++++++++++++- .../components/Layouts/AppSidebar.tsx | 16 +++- Frontend/erp-system/components/ui/select.tsx | 35 ++++++- .../erp-system/lib/api/purchase-orders.ts | 16 +++- Frontend/erp-system/lib/validations/grn.ts | 10 ++ Frontend/erp-system/types/grn.ts | 22 +++++ Frontend/erp-system/types/procurement.ts | 6 +- docs/02-SECURITY.md | 6 +- docs/10-BACKEND-PHASE1.md | 4 +- docs/11-BACKEND-PHASE1.md | 26 +++-- 28 files changed, 594 insertions(+), 66 deletions(-) diff --git a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs index a1bf9b9..ff9dc71 100644 --- a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs +++ b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs @@ -57,6 +57,25 @@ public sealed class PurchaseOrdersController : ApiControllerBase return Ok(result.Value); } + /// Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. + [HttpPost("{poId:int}/submit")] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Submit(int poId, CancellationToken ct) + => Ok(await _pos.SubmitAsync(poId, ct)); + + /// Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise). + [HttpDelete("{poId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int poId, CancellationToken ct) + { + await _pos.DeleteAsync(poId, ct); + return NoContent(); + } + /// Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled. [HttpPost("{poId:int}/approve")] [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs index 5805127..f6981d4 100644 --- a/Backend/ERPCore/Domain/Entities/GrnLine.cs +++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs @@ -3,9 +3,13 @@ using ERPCore.Domain.Enums; namespace ERPCore.Domain.Entities; /// -/// GRN line (FR-GRN-04..08). is the PO-derived cost for -/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost -/// for direct receipts. = qty × unitCost. +/// GRN line (FR-GRN-04..08). is the gross cost received at: +/// entered on the line, defaulting to the PO price when omitted (a per-receipt price +/// override is now permitted — see docs/02-SECURITY C.3, revised). +/// snapshots the PO price at receipt so the variance survives later PO edits. +/// = unitCost after trade discount — this is what the FIFO layer +/// costs at (VAT never enters stock value; it is recoverable input tax). +/// = qty × netUnitCost (after discount, before VAT). /// gates issuability. Model: docs/10 Part C.3. /// public class GrnLine @@ -31,7 +35,30 @@ public class GrnLine public Batch? Batch { get; set; } public decimal Qty { get; set; } + + /// Gross unit cost received at (entered, or PO price when omitted). public decimal UnitCost { get; set; } + + /// Snapshot of the PO line price at receipt; null for direct receipts. + public decimal? PoUnitPrice { get; set; } + + /// Trade discount percentage (0–100), entered. + public decimal DiscountPct { get; set; } + + /// UnitCost × (1 − DiscountPct/100) — the inventory (FIFO layer) cost. + public decimal NetUnitCost { get; set; } + + /// VAT percentage (0–100), entered. Recoverable — does not affect stock value. + public decimal VatPct { get; set; } + + /// Qty × NetUnitCost × VatPct/100. + public decimal VatAmount { get; set; } + + /// Qty × NetUnitCost (after discount, before VAT). public decimal ReceivedValue { get; set; } + + /// Qty × NetUnitCost + VatAmount — payable to the vendor. + public decimal LineTotal { get; set; } + public HoldStatus HoldStatus { get; set; } = HoldStatus.Available; } diff --git a/Backend/ERPCore/Domain/Entities/PoLine.cs b/Backend/ERPCore/Domain/Entities/PoLine.cs index 1f38169..0a5f087 100644 --- a/Backend/ERPCore/Domain/Entities/PoLine.cs +++ b/Backend/ERPCore/Domain/Entities/PoLine.cs @@ -22,7 +22,7 @@ public class PoLine public Warehouse? Warehouse { get; set; } public decimal Qty { get; set; } - public decimal UnitPrice { get; set; } + public decimal UnitPrice { get; set; }// public decimal Tax { get; set; } public decimal QtyReceived { get; set; } } diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs index 9051017..aec88c2 100644 --- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -7,7 +7,10 @@ namespace ERPCore.Dtos.Grn; public sealed record GrnLineDto( int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId, - decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId); + decimal Qty, decimal UnitCost, decimal? PoUnitPrice, + decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount, + decimal ReceivedValue, decimal LineTotal, decimal PriceVariance, + HoldStatus HoldStatus, int? BatchId); public sealed record GrnDto( int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, @@ -44,8 +47,16 @@ public sealed class CreateGrnLineInput [Required] public int UomId { get; set; } public int? BinId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } - /// Used only for direct (no-PO) receipts; ignored when is set. + /// + /// Gross unit cost. Required for direct (no-PO) receipts. For a PO line it is an optional + /// per-receipt price override — when 0/omitted the PO line price is used; when supplied it + /// wins and a variance is recorded against the PO snapshot (docs/02-SECURITY C.3, revised). + /// [Range(0, double.MaxValue)] public decimal UnitCost { get; set; } + /// Trade discount percentage (0–100). Reduces the inventory cost. + [Range(0, 100)] public decimal DiscountPct { get; set; } + /// VAT percentage (0–100). Recoverable — does not affect stock value. + [Range(0, 100)] public decimal VatPct { get; set; } [EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available; public BatchInput? Batch { get; set; } } diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs index c70e9a7..d062179 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs @@ -37,6 +37,13 @@ public sealed class CreatePurchaseOrderRequest [Required] public int VendorId { get; set; } public int? RequisitionId { get; set; } [Required, MinLength(1)] public List Lines { get; set; } = new(); + + /// + /// When true the PO is created in Draft (editable/deletable, not yet issued). + /// When false (default) it auto-approves on creation, preserving the Requisition→PO + /// and RFQ→PO flows unchanged (docs/11 §3.3). + /// + public bool SaveAsDraft { get; set; } } public sealed class UpdatePurchaseOrderRequest diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs index 087ea19..0c47198 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs @@ -37,7 +37,13 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration builder.Property(l => l.Qty).HasPrecision(18, 4); builder.Property(l => l.UnitCost).HasPrecision(18, 6); + builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6); + builder.Property(l => l.DiscountPct).HasPrecision(9, 4); + builder.Property(l => l.NetUnitCost).HasPrecision(18, 6); + builder.Property(l => l.VatPct).HasPrecision(9, 4); + builder.Property(l => l.VatAmount).HasPrecision(18, 4); builder.Property(l => l.ReceivedValue).HasPrecision(18, 4); + builder.Property(l => l.LineTotal).HasPrecision(18, 4); builder.Property(l => l.HoldStatus).HasConversion().HasMaxLength(20).IsRequired(); builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade); diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs index 35b2ede..065f5b8 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs @@ -41,7 +41,11 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration("BinId") .HasColumnType("integer"); + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + b.Property("GrnId") .HasColumnType("integer"); @@ -288,9 +292,21 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("ItemId") .HasColumnType("integer"); + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("NetUnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + b.Property("PoLineId") .HasColumnType("integer"); + b.Property("PoUnitPrice") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + b.Property("Qty") .HasPrecision(18, 4) .HasColumnType("numeric(18,4)"); @@ -306,6 +322,14 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("UomId") .HasColumnType("integer"); + b.Property("VatAmount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("VatPct") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + b.HasKey("GrnLineId"); b.HasIndex("BatchId"); @@ -828,6 +852,30 @@ namespace ERPCore.Infra.Persistence.Migrations PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 + }, + new + { + PermissionId = 19, + Code = "NAV:procurement.requisitions", + SubNavItemId = 9 + }, + new + { + PermissionId = 20, + Code = "NAV:procurement.rfqs", + SubNavItemId = 10 + }, + new + { + PermissionId = 21, + Code = "NAV:procurement.purchase-orders", + SubNavItemId = 11 + }, + new + { + PermissionId = 22, + Code = "NAV:procurement.purchase-returns", + SubNavItemId = 12 }); }); @@ -1916,6 +1964,46 @@ namespace ERPCore.Infra.Persistence.Migrations NavItemId = 9, SortOrder = 2, Status = "Active" + }, + new + { + SubNavItemId = 9, + Code = "procurement.requisitions", + Href = "/dashboard/procurement/requisitions", + Label = "Requisitions", + NavItemId = 4, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 10, + Code = "procurement.rfqs", + Href = "/dashboard/procurement/rfqs", + Label = "RFQs", + NavItemId = 4, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 11, + Code = "procurement.purchase-orders", + Href = "/dashboard/procurement/purchase-orders", + Label = "Purchase Orders", + NavItemId = 4, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 12, + Code = "procurement.purchase-returns", + Href = "/dashboard/procurement/purchase-returns", + Label = "Purchase Returns", + NavItemId = 4, + SortOrder = 4, + Status = "Active" }); }); diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 8af5783..3da76b1 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -138,8 +138,11 @@ public sealed class GrnService : IGrnService 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); - // Cost: PO-derived for PO lines (client cost ignored, C.3); entered for direct. + // Cost: for a PO line, the PO price is used unless an override is entered (then it + // wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3, + // revised). Direct receipts always use the entered cost. decimal unitCost; + decimal? poUnitPrice = null; if (input.PoLineId is not null) { var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId) @@ -152,13 +155,19 @@ public sealed class GrnService : IGrnService throw new DomainException(ErrorCodes.OverReceiptTolerance, $"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422); - unitCost = poLine.UnitPrice; + poUnitPrice = poLine.UnitPrice; + unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice; } else { unitCost = input.UnitCost; } + // Derived figures are always computed server-side, never accepted from the client. + 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 @@ -170,7 +179,13 @@ public sealed class GrnService : IGrnService Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted Qty = input.Qty, UnitCost = unitCost, - ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero), + PoUnitPrice = poUnitPrice, + DiscountPct = input.DiscountPct, + NetUnitCost = netUnitCost, + VatPct = input.VatPct, + VatAmount = vatAmount, + ReceivedValue = receivedValue, + LineTotal = receivedValue + vatAmount, HoldStatus = input.HoldStatus }); } @@ -220,7 +235,9 @@ public sealed class GrnService : IGrnService foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId)) { var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token); - var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.UnitCost, token); + // FIFO layer costs at the after-discount net price; VAT is recoverable and never + // enters stock value (docs/10 FR-GRN-06, revised). + var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token); var layer = await _fifo.CreateInboundLayerAsync( line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, @@ -380,5 +397,8 @@ public sealed class GrnService : IGrnService private static GrnDto Map(Grn g) => new( g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto( - l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.ReceivedValue, l.HoldStatus, l.BatchId)).ToList()); + l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice, + l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal, + l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero), + l.HoldStatus, l.BatchId)).ToList()); } diff --git a/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs index 898588b..a93bca2 100644 --- a/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs +++ b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs @@ -16,4 +16,10 @@ public interface IPurchaseOrderService Task> UpdateAsync(int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default); Task ApproveAsync(int poId, CancellationToken ct = default); Task CancelAsync(int poId, string? reason, CancellationToken ct = default); + + /// Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. + Task SubmitAsync(int poId, CancellationToken ct = default); + + /// Delete a PO — permitted only while Draft, else 409 PO_NOT_EDITABLE. + Task DeleteAsync(int poId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/PurchaseOrderService.cs b/Backend/ERPCore/Services/PurchaseOrderService.cs index cae9d35..25c114e 100644 --- a/Backend/ERPCore/Services/PurchaseOrderService.cs +++ b/Backend/ERPCore/Services/PurchaseOrderService.cs @@ -92,9 +92,10 @@ public sealed class PurchaseOrderService : IPurchaseOrderService DocNo = docNo, VendorId = request.VendorId, RequisitionId = request.RequisitionId, - // Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04). + // Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04), + // unless the caller explicitly saves a Draft (editable/deletable until submitted). ApprovalRequired = false, - Status = PurchaseOrderStatus.Approved, + Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved, CreatedBy = actor, CreatedAt = DateTime.UtcNow, Lines = request.Lines.Select(ToLine).ToList() @@ -182,8 +183,41 @@ public sealed class PurchaseOrderService : IPurchaseOrderService return Map(po); } - private static bool IsEditable(PurchaseOrderStatus status) => status is not ( - PurchaseOrderStatus.FullyReceived or PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled); + public async Task SubmitAsync(int poId, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + if (po.Status != PurchaseOrderStatus.Draft) + throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409); + + // Phase 1: no value gate, so a submitted draft goes straight to Approved (FR-PROC-04). + po.Status = PurchaseOrderStatus.Approved; + po.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + + return Map(po); + } + + public async Task DeleteAsync(int poId, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + if (po.Status != PurchaseOrderStatus.Draft) + throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be deleted; only a Draft can be deleted.", 409); + + _pos.Remove(po); + await _uow.SaveChangesAsync(ct); + } + + // FR-PROC-05 (revised): a PO is editable/deletable only while Draft. Submitting locks it. + // Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05. + private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft; private static PoLine ToLine(CreatePoLineInput l) => new() { diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index b9baf5f..4cbffea 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -55,7 +55,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** > Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired. - [x] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get) - [x] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix) -- [x] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel +- [x] Purchase Order: create (auto-approve **or `saveAsDraft`**, `approvalRequired` flag), edit **Draft-only** (If-Match), **submit** (Draft→Approved), **delete** (Draft-only), approve (no-op), cancel — see the 2026-07-20 entry (FR-PROC-05 revised: draft-lock supersedes edit-while-open) - [x] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.) > **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match. @@ -74,7 +74,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 3. Goods Receipt > Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate. -- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **PO-derived server-side** (client `999` verified ignored → PO price used, 02-SECURITY C.3); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. +- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **defaults to the PO price but is now overridable per line** (variance recorded vs `poUnitPrice` snapshot — 02-SECURITY C.3 revised 2026-07-20; the old "client cost ignored" block is gone); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. **Discount/VAT added** — see the 2026-07-20 entry. - [x] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred. - [x] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4). @@ -111,6 +111,16 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## Done +### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (migration `AddGrnPricingAndPoDraft`) +- **PO draft lifecycle (FR-PROC-05 revised).** `CreatePurchaseOrderRequest.SaveAsDraft` (default `false` → auto-approve unchanged; `true` → `Draft`). New `POST /purchase-orders/{id}/submit` (Draft→Approved, else `409 PO_NOT_EDITABLE`) and `DELETE /purchase-orders/{id}` (Draft-only, else 409). `IsEditable` narrowed from "not FullyReceived/Closed/Cancelled" to **`Draft` only** — so `PUT` now 409s on any submitted PO. **Option B ("freely edit while open") is superseded**; docs/10 FR-PROC-05, docs/11 §3.3 updated. ⚠️ **Every pre-existing PO is `Approved` and therefore now uneditable/undeletable** — intended, not a regression. No schema change (reuses the existing `Draft` enum value). +- **GRN discount + VAT + price override.** `GrnLine` gained `PoUnitPrice`(nullable snapshot), `DiscountPct`, `NetUnitCost`, `VatPct`, `VatAmount`, `LineTotal`. All derived figures **server-computed**, never client-supplied. FIFO layer + ledger now cost at **`NetUnitCost`** (after discount) — VAT is recoverable and never enters stock value (docs/10 FR-GRN-06 revised). For a PO line, `unitCost` defaults to the PO price but an entered override wins and a **variance** is recorded against `PoUnitPrice` (02-SECURITY C.3 revised — the "client cost ignored, decision locked" control is deliberately loosened; the variance trail + audit log are the compensating control). Multi-GRN-per-PO at differing prices (the 20/50/30 case) already worked via `openQty`/`QtyReceived` and is untouched. +- **Migration** `AddGrnPricingAndPoDraft` — hand-added a data backfill (`UPDATE grn_lines SET NetUnitCost = UnitCost, LineTotal = ReceivedValue`) so existing GRN lines stay consistent with their already-posted FIFO layers; `PoUnitPrice` left NULL for historical rows (no retroactive variance). `Down()` drops the six columns cleanly. +- **Verified:** `dotnet build` clean (0/0); migration **Up and Down** exercised against the live DB (rollback to `AddRolesNavPermissions` then re-apply — both `Done`). **Runtime end-to-end PASSED — 22/22 assertions** (Node script, register→cookie session): PO draft→edit→submit→edit/delete-locked (409), draft delete (204→404), plain create still auto-approves; **costing proof** (100 @10, 10% disc, 18% VAT → net 9.00, receivedValue 900, VAT 162, lineTotal 1062, **FIFO layer @9.00, valuation 900 — VAT absent from stock**); multi-GRN 20@10/50@11/30@12 → variances +50/+60, PO FullyReceived, blended valuation 2010. + +### 2026-07-20 (2) — Procurement sidebar submenu (migration `AddProcurementSubNav`) +- The sidebar submenu is driven by seeded `SubNavItem` rows + `GET /auth/me` navCodes; only Products/Settings had children, so **Purchase Orders had no sidebar section**. Added 4 `SubNavItem`s (ids 9–12, `NavItemId 4`) + 4 `Permission`s (ids 19–22) for Requisitions/RFQs/Purchase Orders/Purchase Returns via `AddProcurementSubNav`. The migration also grants the 4 to any role already holding the parent `NAV:procurement` (raw SQL, `ON CONFLICT DO NOTHING`); `Down()` removes the grants then the rows. +- **Found:** the `Admin` role (`RoleId 2`) was never granted `NAV:procurement` at all (nor Vendors), so its whole Procurement branch was hidden — granted the parent + 4 children directly. **Verified:** `/auth/me` for Admin returns `procurement` + all 4 children; frontend `tsc`/`eslint` clean. + ### 2026-07-09 — Bootstrap verified + Master Data (§1) implemented - Bootstrap scaffolding confirmed against 00-CORE §5 (solution, packages, `Program.cs` wiring, UoW, generic repo, `ICurrentUser`, ProblemDetails handler). Added enum-as-string JSON (`JsonStringEnumConverter`) and registered the 5 master-data services. - Domain: 3 enums (`ItemType`, `TrackingMode`, `EntityStatus`) + 8 entities (Category, Uom, UomConversion, Item, ItemReorder, Vendor, Warehouse, Bin) with one `IEntityTypeConfiguration` each; FKs `Restrict` (masters deactivate, not cascade-delete), unique indexes (SKU, vendor/warehouse code, uom name, bin code per-warehouse), decimal precision, `xmin` concurrency token on Item/Vendor. diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 859af32..1b76f10 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -39,12 +39,12 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 3. Procurement screens - [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01 - [~] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02 -- [~] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07 +- [~] Purchase Order (`.../purchase-orders` list, `/new` create — **Save as draft** or **Create & submit** (auto-approve), prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: **Draft** is editable (ETag/If-Match) with **Submit** + **Delete**; a submitted/open PO is read-only with **Cancel** (with reason)) — FR-PROC-03..07. **2026-07-20:** rewired to the draft lifecycle — `isPoEditable` is now `Draft`-only, `submit`/`remove` added to `lib/api/purchase-orders.ts`, `saveAsDraft` on the create request. See the 2026-07-20 entry. - [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page ## 4. Receiving screens - [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail -- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode` +- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry. - [~] GRN confirm (`app/dashboard/receiving/grn/[id]/page.tsx`) — renders returned `createdLayers`/`ledgerRefs`/`poStatus` as a confirmation panel (20-FRONTEND §4); sends a stable `Idempotency-Key` per detail-page session - [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed` - Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn` @@ -90,6 +90,14 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## Done +### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (backend + frontend, same pass) +- **PO draft lifecycle.** `lib/api/purchase-orders.ts`: `isPoEditable` narrowed to `status === "Draft"` (was the three-status exclusion); added `submit(poId)` and `remove(poId)`. `types/procurement.ts`: `saveAsDraft?` on `CreatePurchaseOrderRequest`; `UpdatePurchaseOrderRequest` now `Omit`s it. `/new`: the single "Create PO" button split into **Save as draft** / **Create & submit**. `/[id]`: **Draft** shows the editable line grid + **Submit** + **Delete draft**; an issued-but-open PO (`Approved`/`PartiallyReceived`) is read-only with **Cancel PO** (with reason, disabled once receipts exist); closed/cancelled are terminal. The old Cancel button was gated on `isPoEditable` — that would have shown Cancel only for Draft, so the affordances were re-split into `editable` (Draft) vs `cancellable` (Approved/PartiallyReceived). +- **GRN discount/VAT/variance.** `types/grn.ts`: `discountPct`/`vatPct` on `CreateGrnLineInput`; `poUnitPrice`/`discountPct`/`netUnitCost`/`vatPct`/`vatAmount`/`lineTotal`/`priceVariance` on `GrnLine`. `/new`: per-line Disc %/VAT % inputs, a client mirror of the server arithmetic (`computeLine`, display-only — server stays authoritative) driving a live Line total + document total, and a PO-price variance hint when the entered unit cost differs from the prefilled PO price. `/[id]`: renders the full cost breakdown (unit cost + variance, disc %, net cost, received value, VAT, line total) and a stock-value/VAT/document-total footer. `lib/validations/grn.ts`: 0–100 range checks on the two percentages. +- **Deliberately not touched:** the item picker already showed `sku — name` (the request's stated need); multi-GRN-per-PO and adding a non-PO item to a PO GRN already worked. Vendor stays PO-derived (non-selectable) for a PO-based GRN — selecting a different vendor than the PO's would be wrong. +- **Select trigger showed the id, not the label (global fix).** Base UI's `Select.Value` renders the raw selected value unless the `Select.Root` is given an `items` map — the popup items unmount when closed, so their text isn't available to the trigger (confirmed in `@base-ui/react`'s `resolveSelectedLabel`, which `find`s `items` by value and only falls back to stringifying the value when none is supplied). Fixed once in the shared wrapper (`components/ui/select.tsx`): `Select` now walks its own `SelectItem` children and derives the `items` array automatically, so all ~60 `` call sites across 26 files show the selected label without any per-site change. `tsc`/`eslint` clean; verified against Base UI's label-resolution source. +- **Procurement sidebar submenu.** The sidebar builds submenus from backend-seeded `SubNavItem` rows filtered by `GET /auth/me`'s `navCodes`; only Products and Settings had children, so Purchase Orders had no sidebar section (only reachable via the Procurement hub card). Added a `children` array to the Procurement nav entry (`components/Layouts/AppSidebar.tsx`) — Requisitions, RFQs, Purchase Orders, Purchase Returns — matching new backend sub-nav codes. Also found the Admin role (`RoleId 2`) was never granted `NAV:procurement` at all, so the whole Procurement branch was hidden for it; granted the parent + 4 children. **Verified:** `/auth/me` for Admin now returns all five procurement codes → submenu renders. Stale PO hub-card copy ("freely editable while open") updated to the draft/submit wording. +- **Verified:** `tsc --noEmit` clean; `eslint` unchanged from baseline (7 pre-existing `set-state-in-effect` on the PO/GRN screens before and after — 0 new issues, confirmed by stashing and re-counting). Runtime browser verification is the next step in this pass. + ### 2026-07-17 — connected to the real API (mock-data.ts deleted) **The app now talks to ERPCore.** Every `lib/api/*.ts` module calls the backend; `lib/api/mock-data.ts` is gone. This is the pass the 2026-07-15 note anticipated. diff --git a/Frontend/erp-system/app/dashboard/procurement/page.tsx b/Frontend/erp-system/app/dashboard/procurement/page.tsx index dd9f1ad..cc3a0c4 100644 --- a/Frontend/erp-system/app/dashboard/procurement/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/page.tsx @@ -18,7 +18,7 @@ const areas: { title: string; description: string; href: string; icon: LucideIco }, { title: "Purchase Orders", - description: "Auto-approved on creation, freely editable while open, cancellable before receipt.", + description: "Save as draft (editable/deletable) or submit to lock; cancel an issued PO before receipt.", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart, }, diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx index a205486..cf3db2a 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" import Link from "next/link" -import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Trash2 } from "lucide-react" +import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { warehousesApi } from "@/lib/api/warehouses" @@ -65,6 +65,8 @@ export default function PurchaseOrderDetailPage() { const [showCancelForm, setShowCancelForm] = useState(false) const [cancelReason, setCancelReason] = useState("") const [cancelling, setCancelling] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [deleting, setDeleting] = useState(false) function toDraftLines(order: PurchaseOrder): DraftLine[] { return order.lines.map((l) => ({ @@ -185,6 +187,39 @@ export default function PurchaseOrderDetailPage() { } } + async function handleSubmitPo() { + if (!po) return + setSaveError(null) + setSubmitting(true) + try { + const updated = await purchaseOrdersApi.submit(po.poId) + setPo(updated) + setLines(toDraftLines(updated)) + toast.success("Purchase order submitted", `${updated.docNo} — now ${updated.status} and locked for editing.`) + } catch (err) { + setSaveError(errorMessage(err)) + toast.error("Could not submit purchase order", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function handleDelete() { + if (!po) return + if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return + setSaveError(null) + setDeleting(true) + try { + await purchaseOrdersApi.remove(po.poId) + toast.success("Draft deleted", po.docNo) + router.push("/dashboard/procurement/purchase-orders") + } catch (err) { + setSaveError(errorMessage(err)) + toast.error("Could not delete purchase order", errorMessage(err)) + setDeleting(false) + } + } + async function handleCancel() { if (!po) return if (!cancelReason.trim()) { @@ -226,6 +261,9 @@ export default function PurchaseOrderDetailPage() { const editable = isPoEditable(po.status) && !conflict const hasReceipts = po.lines.some((l) => l.qtyReceived > 0) + // A submitted-but-still-open PO (issued to the vendor) is cancellable with a reason; + // a Draft is deleted instead, and closed/cancelled POs are terminal. + const cancellable = po.status === "Approved" || po.status === "PartiallyReceived" return (
@@ -245,18 +283,32 @@ export default function PurchaseOrderDetailPage() {
- {isPoEditable(po.status) && !showCancelForm && ( - - )} +
+ {po.status === "Draft" && ( + <> + + + + )} + {cancellable && !showCancelForm && ( + + )} +
{showCancelForm && ( diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx index c7e6592..ae5672e 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -151,7 +151,7 @@ function NewPurchaseOrderContent() { return items?.find((i) => i.itemId === itemId) ?? null } - async function handleSubmit() { + async function handleSubmit(saveAsDraft: boolean) { setHeaderError(null) setSubmitError(null) @@ -197,8 +197,12 @@ function NewPurchaseOrderContent() { vendorId, requisitionId: requisitionId ?? (rfqId ? undefined : null), lines: payloadLines, + saveAsDraft, }) - toast.success("Purchase order created", `${po.docNo} — auto-approved (FR-PROC-04).`) + toast.success( + "Purchase order created", + saveAsDraft ? `${po.docNo} — saved as draft.` : `${po.docNo} — auto-approved (FR-PROC-04).` + ) router.push(`/dashboard/procurement/purchase-orders/${po.poId}`) } catch (err) { setSubmitError(errorMessage(err)) @@ -394,8 +398,11 @@ function NewPurchaseOrderContent() { Cancel - + 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 4928aef..cab3278 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -158,6 +158,7 @@ export default function GrnDetailPage() { )} +
@@ -165,8 +166,12 @@ export default function GrnDetailPage() { UOM Bin Qty - Unit cost - Received value + Unit cost + Disc % + Net cost + Received value + VAT + Line total Hold status {grn.status === "Confirmed" && Actions} @@ -180,8 +185,22 @@ export default function GrnDetailPage() { {uomFor(line.uomId)} {binFor(line.binId)} {line.qty} - {line.unitCost.toFixed(2)} - {line.receivedValue.toFixed(2)} + + {line.unitCost.toFixed(2)} + {line.poUnitPrice !== null && line.priceVariance !== 0 && ( + + PO {line.poUnitPrice.toFixed(2)} · var {line.priceVariance > 0 ? "+" : ""}{line.priceVariance.toFixed(2)} + + )} + + {line.discountPct.toFixed(2)} + {line.netUnitCost.toFixed(2)} + {line.receivedValue.toFixed(2)} + + {line.vatAmount.toFixed(2)} + {line.vatPct.toFixed(2)}% + + {line.lineTotal.toFixed(2)} @@ -228,6 +247,22 @@ export default function GrnDetailPage() { })}
+
+ +
+
+ Stock value (excl. VAT) + {grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)} +
+
+ VAT + {grn.lines.reduce((s, l) => s + l.vatAmount, 0).toFixed(2)} +
+
+ Document total + {grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)} +
+
) } 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 b230d3a..99ce3ef 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -37,12 +37,28 @@ interface DraftLine { binId: number | null qty: string unitCost: string + /** PO line price when prefilled from a PO; drives the variance hint. */ + poUnitPrice: number | null + discountPct: string + vatPct: string holdStatus: HoldStatus batchNo: string expiryDate: string serialNumbersText: string } +/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */ +function computeLine(l: DraftLine) { + const qty = Number(l.qty) || 0 + const gross = Number(l.unitCost) || 0 + const disc = Number(l.discountPct) || 0 + const vat = Number(l.vatPct) || 0 + const netUnitCost = gross * (1 - disc / 100) + const receivedValue = qty * netUnitCost + const vatAmount = receivedValue * (vat / 100) + return { netUnitCost, receivedValue, vatAmount, lineTotal: receivedValue + vatAmount } +} + let keySeq = 0 function newKey() { keySeq += 1 @@ -58,6 +74,9 @@ function emptyLine(): DraftLine { binId: null, qty: "", unitCost: "", + poUnitPrice: null, + discountPct: "0", + vatPct: "0", holdStatus: "Available", batchNo: "", expiryDate: "", @@ -152,6 +171,9 @@ export default function NewGrnPage() { binId: null, qty: String(l.qty - l.qtyReceived), unitCost: String(l.unitPrice), + poUnitPrice: l.unitPrice, + discountPct: "0", + vatPct: "0", holdStatus: "Available", batchNo: "", expiryDate: "", @@ -211,6 +233,8 @@ export default function NewGrnPage() { uomId: line.uomId, qty: line.qty, unitCost: line.unitCost, + discountPct: line.discountPct, + vatPct: line.vatPct, trackingMode: itemFor(line.itemId)?.trackingMode ?? null, batchNo: line.batchNo, serialNumbersText: line.serialNumbersText, @@ -232,6 +256,8 @@ export default function NewGrnPage() { binId: l.binId, qty: Number(l.qty), unitCost: Number(l.unitCost), + discountPct: Number(l.discountPct) || 0, + vatPct: Number(l.vatPct) || 0, holdStatus: l.holdStatus, batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null, serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null, @@ -371,16 +397,20 @@ export default function NewGrnPage() { {poLoading && } {!poLoading && lines.length > 0 && ( +
Item - UOM - Bin - Qty + UOM + Bin + Qty Unit cost - Hold status - Batch / Serial + Disc % + VAT % + Line total + Hold status + Batch / Serial @@ -473,6 +503,50 @@ export default function NewGrnPage() { className="h-11 text-base" /> + {line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && ( +

+ PO price {line.poUnitPrice.toFixed(2)} — variance recorded +

+ )} + + + updateLine(line.key, { discountPct: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { vatPct: e.target.value })} + className="h-11 text-base" + /> + + + + {(() => { + const c = computeLine(line) + return ( +
+ {c.lineTotal.toFixed(2)} + + net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)} + +
+ ) + })()}
@@ -533,6 +607,16 @@ export default function NewGrnPage() { })}
+
+ )} + + {!poLoading && lines.length > 0 && ( +
+ Document total (incl. VAT) + + {lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)} + +
)} diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index fee71f5..ca61060 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -8,12 +8,14 @@ import { Building2, ChevronRight, ClipboardList, + FileText, HelpCircle, LayoutGrid, ListTree, Menu, Package, PackageCheck, + PackageX, Ruler, Settings, ShieldCheck, @@ -59,7 +61,19 @@ const navItems: { ], }, { title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true }, - { title: "Procurement", code: "procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true }, + { + title: "Procurement", + code: "procurement", + href: "/dashboard/procurement", + icon: ClipboardList, + chevron: true, + children: [ + { title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList }, + { title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText }, + { title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart }, + { title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX }, + ], + }, { title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, { title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true }, { title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true }, diff --git a/Frontend/erp-system/components/ui/select.tsx b/Frontend/erp-system/components/ui/select.tsx index e8021f5..852c14f 100644 --- a/Frontend/erp-system/components/ui/select.tsx +++ b/Frontend/erp-system/components/ui/select.tsx @@ -6,7 +6,40 @@ import { Select as SelectPrimitive } from "@base-ui/react/select" import { cn } from "@/lib/utils" import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" -const Select = SelectPrimitive.Root +// 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, +// so their text isn't otherwise available. Rather than pass `items` at all ~60 call sites, +// this wrapper walks its own `SelectItem` children and derives that map automatically, so the +// trigger shows the selected item's label instead of its value. +function collectItems( + children: React.ReactNode, + acc: { value: unknown; label: React.ReactNode }[] +) { + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) return + if (child.type === SelectItem) { + const p = child.props as { value?: unknown; children?: React.ReactNode } + acc.push({ value: p.value, label: p.children }) + return + } + const nested = (child.props as { children?: React.ReactNode }).children + if (nested) collectItems(nested, acc) + }) +} + +function Select( + props: SelectPrimitive.Root.Props +) { + const { items, children } = props + const derivedItems = React.useMemo(() => { + if (items) return items + const acc: { value: unknown; label: React.ReactNode }[] = [] + collectItems(children, acc) + return acc.length ? (acc as ReadonlyArray<{ value: Value; label: React.ReactNode }>) : undefined + }, [items, children]) + + return +} function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { return ( diff --git a/Frontend/erp-system/lib/api/purchase-orders.ts b/Frontend/erp-system/lib/api/purchase-orders.ts index 29f7199..0ddb5e3 100644 --- a/Frontend/erp-system/lib/api/purchase-orders.ts +++ b/Frontend/erp-system/lib/api/purchase-orders.ts @@ -20,10 +20,10 @@ export interface ListPurchaseOrdersParams { sort?: string } -/** Editable while open (FR-PROC-05, Option B). The server is authoritative — it returns - * 409 PO_NOT_EDITABLE regardless — this only drives UI affordances. */ +/** Editable/deletable only while Draft (FR-PROC-05, revised — submitting locks the PO). + * The server is authoritative (409 PO_NOT_EDITABLE otherwise); this only drives UI affordances. */ export function isPoEditable(status: PurchaseOrderStatus): boolean { - return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled" + return status === "Draft" } export const purchaseOrdersApi = { @@ -54,6 +54,16 @@ export const purchaseOrdersApi = { return apiRequest(`/purchase-orders/${poId}/approve`, { method: "POST" }) }, + /** Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. */ + submit(poId: number): Promise { + return apiRequest(`/purchase-orders/${poId}/submit`, { method: "POST" }) + }, + + /** Delete a Draft PO. 409 PO_NOT_EDITABLE once submitted. */ + remove(poId: number): Promise { + return apiRequest(`/purchase-orders/${poId}`, { method: "DELETE" }) + }, + /** 409 if any receipt exists against the PO. */ cancel(poId: number, request: CancelPurchaseOrderRequest): Promise { return apiRequest(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request }) diff --git a/Frontend/erp-system/lib/validations/grn.ts b/Frontend/erp-system/lib/validations/grn.ts index dadc3ee..6d4debb 100644 --- a/Frontend/erp-system/lib/validations/grn.ts +++ b/Frontend/erp-system/lib/validations/grn.ts @@ -16,6 +16,8 @@ export function validateLine(input: { uomId: number | null qty: string unitCost: string + discountPct: string + vatPct: string trackingMode: TrackingMode | null batchNo: string serialNumbersText: string @@ -31,6 +33,14 @@ export function validateLine(input: { const unitCost = Number(input.unitCost) if (input.unitCost === "" || Number.isNaN(unitCost) || unitCost < 0) errors.unitCost = "Unit cost cannot be negative" + const discountPct = Number(input.discountPct) + if (input.discountPct !== "" && (Number.isNaN(discountPct) || discountPct < 0 || discountPct > 100)) + errors.discountPct = "Discount must be 0–100%" + + const vatPct = Number(input.vatPct) + if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100)) + errors.vatPct = "VAT must be 0–100%" + if (input.trackingMode === "Batch" && !input.batchNo.trim()) { errors.batchNo = "Batch number is required for this item" } diff --git a/Frontend/erp-system/types/grn.ts b/Frontend/erp-system/types/grn.ts index 4647d72..904b217 100644 --- a/Frontend/erp-system/types/grn.ts +++ b/Frontend/erp-system/types/grn.ts @@ -29,7 +29,16 @@ export interface CreateGrnLineInput { uomId: number binId?: number | null qty: number + /** + * Gross unit cost. For a PO line it is an optional per-receipt override — 0/omitted uses + * the PO price; a value wins and the server records a variance (docs/02-SECURITY C.3, + * revised). Required (> 0) for a direct receipt. + */ unitCost: number + /** Trade discount % (0–100). Reduces inventory cost. */ + discountPct?: number + /** VAT % (0–100). Recoverable — does not affect stock value. */ + vatPct?: number holdStatus: HoldStatus batch?: BatchInput | null } @@ -49,8 +58,21 @@ export interface GrnLine { uomId: number binId: number | null qty: number + /** Gross unit cost received at. */ unitCost: number + /** PO price snapshot at receipt; null for direct receipts. */ + poUnitPrice: number | null + discountPct: number + /** After-discount cost — what the FIFO layer is valued at. */ + netUnitCost: number + vatPct: number + vatAmount: number + /** qty × netUnitCost (after discount, before VAT). */ receivedValue: number + /** qty × netUnitCost + vatAmount — payable to vendor. */ + lineTotal: number + /** (unitCost − poUnitPrice) × qty; 0 for direct receipts. */ + priceVariance: number holdStatus: HoldStatus batchId: number | null } diff --git a/Frontend/erp-system/types/procurement.ts b/Frontend/erp-system/types/procurement.ts index 9b21cd1..5d95563 100644 --- a/Frontend/erp-system/types/procurement.ts +++ b/Frontend/erp-system/types/procurement.ts @@ -189,10 +189,12 @@ export interface CreatePurchaseOrderRequest { vendorId: number requisitionId?: number | null lines: CreatePoLineInput[] + /** When true the PO is created as an editable/deletable Draft; false (default) auto-approves. */ + saveAsDraft?: boolean } -/** PUT /purchase-orders/{poId} — edit-while-open, same line shape as create (FR-PROC-05, Option B). */ -export type UpdatePurchaseOrderRequest = CreatePurchaseOrderRequest +/** PUT /purchase-orders/{poId} — edit a Draft only (FR-PROC-05, revised); same line shape as create. */ +export type UpdatePurchaseOrderRequest = Omit export interface CancelPurchaseOrderRequest { reason?: string | null diff --git a/docs/02-SECURITY.md b/docs/02-SECURITY.md index f8c166d..3c80f7c 100644 --- a/docs/02-SECURITY.md +++ b/docs/02-SECURITY.md @@ -83,9 +83,9 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin - [ ] Note in review: **AR-01/AR-02/AR-03** apply to these endpoints ### C.3 GRN -- [ ] `unitCost` **derived from the PO line server-side**; any client-supplied cost is ignored *(decision locked)* -- [ ] `receivedValue` computed server-side (qty × PO-line cost), not accepted from client -- [ ] Direct GRN (no PO) is the exception where cost is entered → extra scrutiny + review flag + audit (**AR-04**) +- [ ] `unitCost` **defaults to the PO line price**; a per-line override **is now permitted** *(decision revised 2026-07-20 — was "locked, client cost ignored")*. When an override is entered it is used, and the PO price is snapshotted (`poUnitPrice`) so a **`priceVariance` is recorded** against it for review. Rationale: one PO legitimately spans batches received at different prices; the variance trail (plus the audit log) is the compensating control that replaces the old hard block. +- [ ] **Derived figures stay server-computed** — `netUnitCost`/`receivedValue`/`vatAmount`/`lineTotal` are never accepted from the client, so the client cannot inflate stock value except by an *auditable* unit-cost override. Discount reduces inventory cost; **VAT is recoverable and never enters stock value**. +- [ ] Direct GRN (no PO) remains the higher-scrutiny path where cost is entered with no PO to compare against → review flag + audit (**AR-04**) - [ ] Over-receipt tolerance enforced server-side → `OVER_RECEIPT_TOLERANCE` - [ ] On-hold stock is not issuable (FR-WH-07); expired batch blocked diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index 5f4ca3f..6e65db5 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -137,7 +137,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users | FR-PROC-02 | Optional **RFQ**: issue to vendors, record quotations for comparison. | S | | FR-PROC-03 | Generate **PO** from PR/RFQ or directly (item, UOM, qty, price, tax, delivery date, warehouse). | M | | FR-PROC-04 | **[Phase 1: auto-approve]** Auto-approve PO on creation (status `Approved`). Config flag `approvalRequired` (default off) gates a future approval workflow (authorization matrix); when on, PO cannot issue until approved. `PendingApproval` state + approval fields retained in schema (no migration to enable). | M | -| FR-PROC-05 | **[Phase 1: Option B — edit-while-open]** PO may be **freely edited while open** (not fully received/closed); changes take effect immediately with an audit entry. Versioned amendments deferred; schema must not preclude adding a version field later. | S | +| FR-PROC-05 | **[Phase 1: Option B *superseded* 2026-07-20 — draft-lock]** A PO is **editable and deletable only while `Draft`**; **submitting locks it** (Draft → Approved) and no further edit/delete/add-line is allowed — an issued PO is corrected by Cancel-with-reason (blocked once receipts exist) or a reversing document, never edited. Create takes `saveAsDraft` (default `false` → auto-approve, preserving the Requisition→PO / RFQ→PO flows). *Why the reversal:* Option B ("freely edit while open") let an already-issued, vendor-facing PO change silently after the fact; the draft/submit boundary makes "issued to vendor" a real, immutable commitment. Versioned amendments still deferred; schema unchanged (reuses the existing `Draft` enum value). | S | | FR-PROC-06 | PO lifecycle: Draft → (PendingApproval →) Approved → PartiallyReceived → FullyReceived → Closed/Cancelled. Phase 1 bypasses PendingApproval via auto-approve. | M | | FR-PROC-07 | Support **partial receipt**; PO stays open until fully received or manually closed. | M | | FR-PROC-08 | Support **Purchase Return** referencing original GRN/PO line; generates outbound movement. | M | @@ -151,7 +151,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users | FR-GRN-03 | Support **over/under-receipt tolerances** (per item or global); warn or block beyond tolerance. | S | | FR-GRN-04 | Capture **batch + expiry** and/or **serial numbers** for tracked items on receipt. | M | | FR-GRN-05 | Allow receipt into **inspection/quarantine hold** (not issuable) pending QC, before QC module exists. | M | -| FR-GRN-06 | On confirm, each line **creates a FIFO cost layer** at unit cost (PO price + attributable charges; landed cost per §B.1.2.1) and posts an inbound ledger entry. | M | +| FR-GRN-06 | On confirm, each line **creates a FIFO cost layer** at the **after-discount net unit cost** (`unitCost × (1 − discountPct/100)`) and posts an inbound ledger entry. **VAT never enters stock value** — it is recoverable input tax (revised 2026-07-20). PO price is the default unit cost; a per-line override is permitted and recorded as a variance (see 02-SECURITY C.3, revised). | M | | FR-GRN-07 | Record **received value per line** and PO reference for downstream matching. | M | | FR-GRN-08 | Assign received stock to a **bin/location** (putaway). | S | diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index 6fad489..1bf4b16 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -457,14 +457,15 @@ Query: `q`, `status` (`Open|Closed`), + paging. → list envelope of `GET /rfqs/{rfqId}/comparison` → vendor-by-line price matrix. ### 3.3 Purchase Orders -> **Phase 1:** `approvalRequired` defaults `false` → PO **auto-approved on creation**. Approve endpoint exists but is a no-op unless enabled (FR-PROC-04). PO **freely editable while open** (Option B, FR-PROC-05). +> **Phase 1:** `approvalRequired` defaults `false`. Create takes **`saveAsDraft`** (default `false` → **auto-approved on creation**; `true` → `Draft`). Approve endpoint exists but is a no-op unless enabled (FR-PROC-04). **A PO is editable/deletable only while `Draft`; submitting locks it** (FR-PROC-05, revised 2026-07-20 — Option B "freely edit while open" superseded). #### `POST /purchase-orders` ```json -{ "vendorId": 5, "requisitionId": 210, +{ "vendorId": 5, "requisitionId": 210, "saveAsDraft": false, "lines": [ { "itemId": 1001, "uomId": 1, "warehouseId": 1, "qty": 5000, "unitPrice": 12.50, "tax": 0.18 }, { "itemId": 1002, "uomId": 1, "warehouseId": 1, "qty": 8000, "unitPrice": 6.20, "tax": 0.18 } ] } ``` +`saveAsDraft` optional (default `false`). When `true` the response `status` is `Draft`. **201 Created** — `Location: /api/v1/purchase-orders/342` ```json { "poId": 342, "docNo": "PO-2026-00342", "vendorId": 5, "requisitionId": 210, @@ -477,7 +478,11 @@ Query: `q`, `status` (`Open|Closed`), + paging. → list envelope of `GET /purchase-orders?status=Approved&vendorId=5` → list envelope of PO summaries. #### `PUT /purchase-orders/{poId}` -Edit while open (not FullyReceived/Closed/Cancelled); requires `If-Match`. → **200 OK** updated resource; `409 PO_NOT_EDITABLE` if closed. +Edit a **Draft only**; requires `If-Match`. → **200 OK** updated resource; `409 PO_NOT_EDITABLE` once submitted (any non-Draft status). + +#### `POST /purchase-orders/{poId}/submit` → **200 OK** — `Draft → Approved`. `409 PO_NOT_EDITABLE` if not Draft. + +#### `DELETE /purchase-orders/{poId}` → **204 No Content** — permitted **only while Draft**; `409 PO_NOT_EDITABLE` once submitted. #### `POST /purchase-orders/{poId}/approve` → **200 OK** (no-op in Phase 1; transitions PendingApproval→Approved when enabled). @@ -524,15 +529,24 @@ Against a PO (lines default from open PO lines) or direct (`poId: null`, by perm ```json { "poId": 342, "warehouseId": 1, "lines": [ { "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, "qty": 5000, - "unitCost": 12.50, "holdStatus": "OnHold", + "unitCost": 12.50, "discountPct": 10, "vatPct": 18, "holdStatus": "OnHold", "batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } } ] } ``` -**201 Created** — status `Draft` +`discountPct`/`vatPct` optional (default 0, range 0–100). `unitCost` on a **PO line** is an optional +override: 0/omitted uses the PO price; a value wins and a variance is recorded (02-SECURITY C.3, revised). +On a direct receipt `unitCost` is required. +**201 Created** — status `Draft`. All derived figures are **server-computed**: +`netUnitCost = unitCost × (1 − discountPct/100)`, `receivedValue = qty × netUnitCost` (after discount, +**before** VAT — this is the stock value), `vatAmount = receivedValue × vatPct/100`, +`lineTotal = receivedValue + vatAmount`, `priceVariance = (unitCost − poUnitPrice) × qty`. ```json { "grnId": 780, "docNo": "GRN-2026-00780", "poId": 342, "vendorId": 5, "warehouseId": 1, "status": "Draft", "createdBy": 17, "lines": [ { "grnLineId": 1300, "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, - "qty": 5000, "unitCost": 12.50, "receivedValue": 62500.00, "holdStatus": "OnHold", "batchId": 410 } ] } + "qty": 5000, "unitCost": 12.50, "poUnitPrice": 12.50, "discountPct": 10.0, + "netUnitCost": 11.25, "vatPct": 18.0, "vatAmount": 10125.00, + "receivedValue": 56250.00, "lineTotal": 66375.00, "priceVariance": 0.00, + "holdStatus": "OnHold", "batchId": 410 } ] } ``` `422 OVER_RECEIPT_TOLERANCE` if qty exceeds open PO qty beyond tolerance. From 9158cd8c8293fbc6a482520eb0bafe162126a24a Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Tue, 21 Jul 2026 11:55:40 +0530 Subject: [PATCH 2/4] ui fixes --- .../procurement/purchase-orders/new/page.tsx | 40 +--- .../components/Layouts/AppSidebar.tsx | 177 ++++++++++++------ 2 files changed, 125 insertions(+), 92 deletions(-) diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx index ae5672e..411799d 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -43,8 +43,12 @@ 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). function emptyLine(): DraftLine { - return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18" } + return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" } } function NewPurchaseOrderContent() { @@ -98,8 +102,8 @@ function NewPurchaseOrderContent() { uomId: null, warehouseId: null, qty: String(l.qty), - unitPrice: "", - tax: "0.18", + unitPrice: "0", + tax: "0", }) ) ) @@ -124,8 +128,8 @@ function NewPurchaseOrderContent() { uomId: null, warehouseId: null, qty: String(l.qty), - unitPrice: cell ? String(cell.unitPrice) : "", - tax: "0.18", + unitPrice: cell ? String(cell.unitPrice) : "0", + tax: "0", } }) ) @@ -278,8 +282,6 @@ function NewPurchaseOrderContent() { UOM Warehouse Qty - Unit price - Tax @@ -352,30 +354,6 @@ function NewPurchaseOrderContent() { /> - - updateLine(line.key, { unitPrice: e.target.value })} - className="h-11 text-base" - /> - - - - updateLine(line.key, { tax: e.target.value })} - className="h-11 text-base" - /> - - - {/* Nav items */} -
    + {/* Nav items — scrolls internally when it overflows, without a visible + scrollbar so the rounded panel stays clean. */} +
      {items.map((item) => { const isActive = item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href) + const hasChildren = !!item.children?.length && !iconOnly + const isOpen = !!expanded[item.code] return (
    • - - - {(!collapsed || isMobile) && ( - <> - {item.title} - {item.chevron && !item.children && !isActive && ( - - )} - - )} - + + + {!iconOnly && ( + <> + {item.title} + {item.chevron && !hasChildren && ( + + )} + + )} + - {item.children && (!collapsed || isMobile) && ( -
        - {(() => { - // Longest-matching href wins so a shared prefix (e.g. "Item" and - // "Category" both live under /dashboard/products) doesn't light up - // more than one sub-item at once. - const activeChild = [...item.children] - .filter((c) => pathname === c.href || pathname.startsWith(`${c.href}/`)) - .sort((a, b) => b.href.length - a.href.length)[0] - return item.children.map((child) => { - const childActive = child.href === activeChild?.href - return ( -
      • - - - {child.title} - -
      • - ) - }) - })()} -
      + {hasChildren && ( + + )} + + + {hasChildren && ( +
      +
      +
        + {(() => { + // Longest-matching href wins so a shared prefix (e.g. "Item" and + // "Category" both live under /dashboard/products) doesn't light up + // more than one sub-item at once. + const activeChild = [...item.children!] + .filter((c) => pathname === c.href || pathname.startsWith(`${c.href}/`)) + .sort((a, b) => b.href.length - a.href.length)[0] + return item.children!.map((child) => { + const childActive = child.href === activeChild?.href + return ( +
      • + + + {child.title} + +
      • + ) + }) + })()} +
      +
      +
      )}
    • ) })}
    -
    +
    From 03bc85b78823beec2491d34fde9c7bc38f81440b Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Tue, 21 Jul 2026 10:08:32 +0530 Subject: [PATCH 3/4] feat(procurement): enhance purchase order and GRN functionalities - Updated purchase order descriptions for clarity on draft and submission processes. - Implemented submit and delete functionalities for draft purchase orders, allowing users to manage their orders more effectively. - Added discount and VAT fields to GRN lines, enabling better cost tracking and reporting. - Enhanced validation for GRN lines to ensure discount and VAT percentages are within acceptable ranges. - Updated API to support new functionalities, including submitting and deleting purchase orders. - Improved UI components for better user experience in managing purchase orders and GRNs. - Documented changes in security and backend phase documentation to reflect new processes and requirements. --- .../Controllers/PurchaseOrdersController.cs | 19 ++++ Backend/ERPCore/Domain/Entities/GrnLine.cs | 33 ++++++- Backend/ERPCore/Domain/Entities/PoLine.cs | 2 +- Backend/ERPCore/Dtos/Grn/GrnDtos.cs | 15 ++- .../Dtos/Procurement/PurchaseOrderDtos.cs | 7 ++ .../Configurations/GrnConfiguration.cs | 6 ++ .../Configurations/PermissionConfiguration.cs | 6 +- .../Configurations/SubNavItemConfiguration.cs | 7 +- .../Migrations/ErpDbContextModelSnapshot.cs | 88 +++++++++++++++++ Backend/ERPCore/Services/GrnService.cs | 30 +++++- .../Interfaces/IPurchaseOrderService.cs | 6 ++ .../ERPCore/Services/PurchaseOrderService.cs | 42 ++++++++- Backend/PROGRESS.md | 14 ++- Frontend/PROGRESS.md | 12 ++- .../app/dashboard/procurement/page.tsx | 2 +- .../procurement/purchase-orders/[id]/page.tsx | 78 ++++++++++++--- .../procurement/purchase-orders/new/page.tsx | 15 ++- .../app/dashboard/receiving/grn/[id]/page.tsx | 43 ++++++++- .../app/dashboard/receiving/grn/new/page.tsx | 94 ++++++++++++++++++- .../components/Layouts/AppSidebar.tsx | 16 +++- Frontend/erp-system/components/ui/select.tsx | 35 ++++++- .../erp-system/lib/api/purchase-orders.ts | 16 +++- Frontend/erp-system/lib/validations/grn.ts | 10 ++ Frontend/erp-system/types/grn.ts | 22 +++++ Frontend/erp-system/types/procurement.ts | 6 +- docs/02-SECURITY.md | 6 +- docs/10-BACKEND-PHASE1.md | 4 +- docs/11-BACKEND-PHASE1.md | 26 +++-- 28 files changed, 594 insertions(+), 66 deletions(-) diff --git a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs index a1bf9b9..ff9dc71 100644 --- a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs +++ b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs @@ -57,6 +57,25 @@ public sealed class PurchaseOrdersController : ApiControllerBase return Ok(result.Value); } + /// Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. + [HttpPost("{poId:int}/submit")] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Submit(int poId, CancellationToken ct) + => Ok(await _pos.SubmitAsync(poId, ct)); + + /// Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise). + [HttpDelete("{poId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int poId, CancellationToken ct) + { + await _pos.DeleteAsync(poId, ct); + return NoContent(); + } + /// Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled. [HttpPost("{poId:int}/approve")] [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs index 5805127..f6981d4 100644 --- a/Backend/ERPCore/Domain/Entities/GrnLine.cs +++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs @@ -3,9 +3,13 @@ using ERPCore.Domain.Enums; namespace ERPCore.Domain.Entities; /// -/// GRN line (FR-GRN-04..08). is the PO-derived cost for -/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost -/// for direct receipts. = qty × unitCost. +/// GRN line (FR-GRN-04..08). is the gross cost received at: +/// entered on the line, defaulting to the PO price when omitted (a per-receipt price +/// override is now permitted — see docs/02-SECURITY C.3, revised). +/// snapshots the PO price at receipt so the variance survives later PO edits. +/// = unitCost after trade discount — this is what the FIFO layer +/// costs at (VAT never enters stock value; it is recoverable input tax). +/// = qty × netUnitCost (after discount, before VAT). /// gates issuability. Model: docs/10 Part C.3. /// public class GrnLine @@ -31,7 +35,30 @@ public class GrnLine public Batch? Batch { get; set; } public decimal Qty { get; set; } + + /// Gross unit cost received at (entered, or PO price when omitted). public decimal UnitCost { get; set; } + + /// Snapshot of the PO line price at receipt; null for direct receipts. + public decimal? PoUnitPrice { get; set; } + + /// Trade discount percentage (0–100), entered. + public decimal DiscountPct { get; set; } + + /// UnitCost × (1 − DiscountPct/100) — the inventory (FIFO layer) cost. + public decimal NetUnitCost { get; set; } + + /// VAT percentage (0–100), entered. Recoverable — does not affect stock value. + public decimal VatPct { get; set; } + + /// Qty × NetUnitCost × VatPct/100. + public decimal VatAmount { get; set; } + + /// Qty × NetUnitCost (after discount, before VAT). public decimal ReceivedValue { get; set; } + + /// Qty × NetUnitCost + VatAmount — payable to the vendor. + public decimal LineTotal { get; set; } + public HoldStatus HoldStatus { get; set; } = HoldStatus.Available; } diff --git a/Backend/ERPCore/Domain/Entities/PoLine.cs b/Backend/ERPCore/Domain/Entities/PoLine.cs index 1f38169..0a5f087 100644 --- a/Backend/ERPCore/Domain/Entities/PoLine.cs +++ b/Backend/ERPCore/Domain/Entities/PoLine.cs @@ -22,7 +22,7 @@ public class PoLine public Warehouse? Warehouse { get; set; } public decimal Qty { get; set; } - public decimal UnitPrice { get; set; } + public decimal UnitPrice { get; set; }// public decimal Tax { get; set; } public decimal QtyReceived { get; set; } } diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs index 9051017..aec88c2 100644 --- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -7,7 +7,10 @@ namespace ERPCore.Dtos.Grn; public sealed record GrnLineDto( int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId, - decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId); + decimal Qty, decimal UnitCost, decimal? PoUnitPrice, + decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount, + decimal ReceivedValue, decimal LineTotal, decimal PriceVariance, + HoldStatus HoldStatus, int? BatchId); public sealed record GrnDto( int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, @@ -44,8 +47,16 @@ public sealed class CreateGrnLineInput [Required] public int UomId { get; set; } public int? BinId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } - /// Used only for direct (no-PO) receipts; ignored when is set. + /// + /// Gross unit cost. Required for direct (no-PO) receipts. For a PO line it is an optional + /// per-receipt price override — when 0/omitted the PO line price is used; when supplied it + /// wins and a variance is recorded against the PO snapshot (docs/02-SECURITY C.3, revised). + /// [Range(0, double.MaxValue)] public decimal UnitCost { get; set; } + /// Trade discount percentage (0–100). Reduces the inventory cost. + [Range(0, 100)] public decimal DiscountPct { get; set; } + /// VAT percentage (0–100). Recoverable — does not affect stock value. + [Range(0, 100)] public decimal VatPct { get; set; } [EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available; public BatchInput? Batch { get; set; } } diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs index c70e9a7..d062179 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs @@ -37,6 +37,13 @@ public sealed class CreatePurchaseOrderRequest [Required] public int VendorId { get; set; } public int? RequisitionId { get; set; } [Required, MinLength(1)] public List Lines { get; set; } = new(); + + /// + /// When true the PO is created in Draft (editable/deletable, not yet issued). + /// When false (default) it auto-approves on creation, preserving the Requisition→PO + /// and RFQ→PO flows unchanged (docs/11 §3.3). + /// + public bool SaveAsDraft { get; set; } } public sealed class UpdatePurchaseOrderRequest diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs index 087ea19..0c47198 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs @@ -37,7 +37,13 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration builder.Property(l => l.Qty).HasPrecision(18, 4); builder.Property(l => l.UnitCost).HasPrecision(18, 6); + builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6); + builder.Property(l => l.DiscountPct).HasPrecision(9, 4); + builder.Property(l => l.NetUnitCost).HasPrecision(18, 6); + builder.Property(l => l.VatPct).HasPrecision(9, 4); + builder.Property(l => l.VatAmount).HasPrecision(18, 4); builder.Property(l => l.ReceivedValue).HasPrecision(18, 4); + builder.Property(l => l.LineTotal).HasPrecision(18, 4); builder.Property(l => l.HoldStatus).HasConversion().HasMaxLength(20).IsRequired(); builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade); diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs index 35b2ede..065f5b8 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs @@ -41,7 +41,11 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration("BinId") .HasColumnType("integer"); + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + b.Property("GrnId") .HasColumnType("integer"); @@ -288,9 +292,21 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("ItemId") .HasColumnType("integer"); + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("NetUnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + b.Property("PoLineId") .HasColumnType("integer"); + b.Property("PoUnitPrice") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + b.Property("Qty") .HasPrecision(18, 4) .HasColumnType("numeric(18,4)"); @@ -306,6 +322,14 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("UomId") .HasColumnType("integer"); + b.Property("VatAmount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("VatPct") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + b.HasKey("GrnLineId"); b.HasIndex("BatchId"); @@ -828,6 +852,30 @@ namespace ERPCore.Infra.Persistence.Migrations PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 + }, + new + { + PermissionId = 19, + Code = "NAV:procurement.requisitions", + SubNavItemId = 9 + }, + new + { + PermissionId = 20, + Code = "NAV:procurement.rfqs", + SubNavItemId = 10 + }, + new + { + PermissionId = 21, + Code = "NAV:procurement.purchase-orders", + SubNavItemId = 11 + }, + new + { + PermissionId = 22, + Code = "NAV:procurement.purchase-returns", + SubNavItemId = 12 }); }); @@ -1916,6 +1964,46 @@ namespace ERPCore.Infra.Persistence.Migrations NavItemId = 9, SortOrder = 2, Status = "Active" + }, + new + { + SubNavItemId = 9, + Code = "procurement.requisitions", + Href = "/dashboard/procurement/requisitions", + Label = "Requisitions", + NavItemId = 4, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 10, + Code = "procurement.rfqs", + Href = "/dashboard/procurement/rfqs", + Label = "RFQs", + NavItemId = 4, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 11, + Code = "procurement.purchase-orders", + Href = "/dashboard/procurement/purchase-orders", + Label = "Purchase Orders", + NavItemId = 4, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 12, + Code = "procurement.purchase-returns", + Href = "/dashboard/procurement/purchase-returns", + Label = "Purchase Returns", + NavItemId = 4, + SortOrder = 4, + Status = "Active" }); }); diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 8af5783..3da76b1 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -138,8 +138,11 @@ public sealed class GrnService : IGrnService 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); - // Cost: PO-derived for PO lines (client cost ignored, C.3); entered for direct. + // Cost: for a PO line, the PO price is used unless an override is entered (then it + // wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3, + // revised). Direct receipts always use the entered cost. decimal unitCost; + decimal? poUnitPrice = null; if (input.PoLineId is not null) { var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId) @@ -152,13 +155,19 @@ public sealed class GrnService : IGrnService throw new DomainException(ErrorCodes.OverReceiptTolerance, $"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422); - unitCost = poLine.UnitPrice; + poUnitPrice = poLine.UnitPrice; + unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice; } else { unitCost = input.UnitCost; } + // Derived figures are always computed server-side, never accepted from the client. + 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 @@ -170,7 +179,13 @@ public sealed class GrnService : IGrnService Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted Qty = input.Qty, UnitCost = unitCost, - ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero), + PoUnitPrice = poUnitPrice, + DiscountPct = input.DiscountPct, + NetUnitCost = netUnitCost, + VatPct = input.VatPct, + VatAmount = vatAmount, + ReceivedValue = receivedValue, + LineTotal = receivedValue + vatAmount, HoldStatus = input.HoldStatus }); } @@ -220,7 +235,9 @@ public sealed class GrnService : IGrnService foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId)) { var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token); - var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.UnitCost, token); + // FIFO layer costs at the after-discount net price; VAT is recoverable and never + // enters stock value (docs/10 FR-GRN-06, revised). + var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token); var layer = await _fifo.CreateInboundLayerAsync( line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, @@ -380,5 +397,8 @@ public sealed class GrnService : IGrnService private static GrnDto Map(Grn g) => new( g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto( - l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.ReceivedValue, l.HoldStatus, l.BatchId)).ToList()); + l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice, + l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal, + l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero), + l.HoldStatus, l.BatchId)).ToList()); } diff --git a/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs index 898588b..a93bca2 100644 --- a/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs +++ b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs @@ -16,4 +16,10 @@ public interface IPurchaseOrderService Task> UpdateAsync(int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default); Task ApproveAsync(int poId, CancellationToken ct = default); Task CancelAsync(int poId, string? reason, CancellationToken ct = default); + + /// Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. + Task SubmitAsync(int poId, CancellationToken ct = default); + + /// Delete a PO — permitted only while Draft, else 409 PO_NOT_EDITABLE. + Task DeleteAsync(int poId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/PurchaseOrderService.cs b/Backend/ERPCore/Services/PurchaseOrderService.cs index cae9d35..25c114e 100644 --- a/Backend/ERPCore/Services/PurchaseOrderService.cs +++ b/Backend/ERPCore/Services/PurchaseOrderService.cs @@ -92,9 +92,10 @@ public sealed class PurchaseOrderService : IPurchaseOrderService DocNo = docNo, VendorId = request.VendorId, RequisitionId = request.RequisitionId, - // Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04). + // Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04), + // unless the caller explicitly saves a Draft (editable/deletable until submitted). ApprovalRequired = false, - Status = PurchaseOrderStatus.Approved, + Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved, CreatedBy = actor, CreatedAt = DateTime.UtcNow, Lines = request.Lines.Select(ToLine).ToList() @@ -182,8 +183,41 @@ public sealed class PurchaseOrderService : IPurchaseOrderService return Map(po); } - private static bool IsEditable(PurchaseOrderStatus status) => status is not ( - PurchaseOrderStatus.FullyReceived or PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled); + public async Task SubmitAsync(int poId, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + if (po.Status != PurchaseOrderStatus.Draft) + throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409); + + // Phase 1: no value gate, so a submitted draft goes straight to Approved (FR-PROC-04). + po.Status = PurchaseOrderStatus.Approved; + po.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + + return Map(po); + } + + public async Task DeleteAsync(int poId, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + if (po.Status != PurchaseOrderStatus.Draft) + throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be deleted; only a Draft can be deleted.", 409); + + _pos.Remove(po); + await _uow.SaveChangesAsync(ct); + } + + // FR-PROC-05 (revised): a PO is editable/deletable only while Draft. Submitting locks it. + // Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05. + private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft; private static PoLine ToLine(CreatePoLineInput l) => new() { diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index b9baf5f..4cbffea 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -55,7 +55,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** > Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired. - [x] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get) - [x] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix) -- [x] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel +- [x] Purchase Order: create (auto-approve **or `saveAsDraft`**, `approvalRequired` flag), edit **Draft-only** (If-Match), **submit** (Draft→Approved), **delete** (Draft-only), approve (no-op), cancel — see the 2026-07-20 entry (FR-PROC-05 revised: draft-lock supersedes edit-while-open) - [x] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.) > **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match. @@ -74,7 +74,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 3. Goods Receipt > Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate. -- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **PO-derived server-side** (client `999` verified ignored → PO price used, 02-SECURITY C.3); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. +- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **defaults to the PO price but is now overridable per line** (variance recorded vs `poUnitPrice` snapshot — 02-SECURITY C.3 revised 2026-07-20; the old "client cost ignored" block is gone); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. **Discount/VAT added** — see the 2026-07-20 entry. - [x] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred. - [x] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4). @@ -111,6 +111,16 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## Done +### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (migration `AddGrnPricingAndPoDraft`) +- **PO draft lifecycle (FR-PROC-05 revised).** `CreatePurchaseOrderRequest.SaveAsDraft` (default `false` → auto-approve unchanged; `true` → `Draft`). New `POST /purchase-orders/{id}/submit` (Draft→Approved, else `409 PO_NOT_EDITABLE`) and `DELETE /purchase-orders/{id}` (Draft-only, else 409). `IsEditable` narrowed from "not FullyReceived/Closed/Cancelled" to **`Draft` only** — so `PUT` now 409s on any submitted PO. **Option B ("freely edit while open") is superseded**; docs/10 FR-PROC-05, docs/11 §3.3 updated. ⚠️ **Every pre-existing PO is `Approved` and therefore now uneditable/undeletable** — intended, not a regression. No schema change (reuses the existing `Draft` enum value). +- **GRN discount + VAT + price override.** `GrnLine` gained `PoUnitPrice`(nullable snapshot), `DiscountPct`, `NetUnitCost`, `VatPct`, `VatAmount`, `LineTotal`. All derived figures **server-computed**, never client-supplied. FIFO layer + ledger now cost at **`NetUnitCost`** (after discount) — VAT is recoverable and never enters stock value (docs/10 FR-GRN-06 revised). For a PO line, `unitCost` defaults to the PO price but an entered override wins and a **variance** is recorded against `PoUnitPrice` (02-SECURITY C.3 revised — the "client cost ignored, decision locked" control is deliberately loosened; the variance trail + audit log are the compensating control). Multi-GRN-per-PO at differing prices (the 20/50/30 case) already worked via `openQty`/`QtyReceived` and is untouched. +- **Migration** `AddGrnPricingAndPoDraft` — hand-added a data backfill (`UPDATE grn_lines SET NetUnitCost = UnitCost, LineTotal = ReceivedValue`) so existing GRN lines stay consistent with their already-posted FIFO layers; `PoUnitPrice` left NULL for historical rows (no retroactive variance). `Down()` drops the six columns cleanly. +- **Verified:** `dotnet build` clean (0/0); migration **Up and Down** exercised against the live DB (rollback to `AddRolesNavPermissions` then re-apply — both `Done`). **Runtime end-to-end PASSED — 22/22 assertions** (Node script, register→cookie session): PO draft→edit→submit→edit/delete-locked (409), draft delete (204→404), plain create still auto-approves; **costing proof** (100 @10, 10% disc, 18% VAT → net 9.00, receivedValue 900, VAT 162, lineTotal 1062, **FIFO layer @9.00, valuation 900 — VAT absent from stock**); multi-GRN 20@10/50@11/30@12 → variances +50/+60, PO FullyReceived, blended valuation 2010. + +### 2026-07-20 (2) — Procurement sidebar submenu (migration `AddProcurementSubNav`) +- The sidebar submenu is driven by seeded `SubNavItem` rows + `GET /auth/me` navCodes; only Products/Settings had children, so **Purchase Orders had no sidebar section**. Added 4 `SubNavItem`s (ids 9–12, `NavItemId 4`) + 4 `Permission`s (ids 19–22) for Requisitions/RFQs/Purchase Orders/Purchase Returns via `AddProcurementSubNav`. The migration also grants the 4 to any role already holding the parent `NAV:procurement` (raw SQL, `ON CONFLICT DO NOTHING`); `Down()` removes the grants then the rows. +- **Found:** the `Admin` role (`RoleId 2`) was never granted `NAV:procurement` at all (nor Vendors), so its whole Procurement branch was hidden — granted the parent + 4 children directly. **Verified:** `/auth/me` for Admin returns `procurement` + all 4 children; frontend `tsc`/`eslint` clean. + ### 2026-07-09 — Bootstrap verified + Master Data (§1) implemented - Bootstrap scaffolding confirmed against 00-CORE §5 (solution, packages, `Program.cs` wiring, UoW, generic repo, `ICurrentUser`, ProblemDetails handler). Added enum-as-string JSON (`JsonStringEnumConverter`) and registered the 5 master-data services. - Domain: 3 enums (`ItemType`, `TrackingMode`, `EntityStatus`) + 8 entities (Category, Uom, UomConversion, Item, ItemReorder, Vendor, Warehouse, Bin) with one `IEntityTypeConfiguration` each; FKs `Restrict` (masters deactivate, not cascade-delete), unique indexes (SKU, vendor/warehouse code, uom name, bin code per-warehouse), decimal precision, `xmin` concurrency token on Item/Vendor. diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 859af32..1b76f10 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -39,12 +39,12 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 3. Procurement screens - [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01 - [~] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02 -- [~] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07 +- [~] Purchase Order (`.../purchase-orders` list, `/new` create — **Save as draft** or **Create & submit** (auto-approve), prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: **Draft** is editable (ETag/If-Match) with **Submit** + **Delete**; a submitted/open PO is read-only with **Cancel** (with reason)) — FR-PROC-03..07. **2026-07-20:** rewired to the draft lifecycle — `isPoEditable` is now `Draft`-only, `submit`/`remove` added to `lib/api/purchase-orders.ts`, `saveAsDraft` on the create request. See the 2026-07-20 entry. - [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page ## 4. Receiving screens - [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail -- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode` +- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry. - [~] GRN confirm (`app/dashboard/receiving/grn/[id]/page.tsx`) — renders returned `createdLayers`/`ledgerRefs`/`poStatus` as a confirmation panel (20-FRONTEND §4); sends a stable `Idempotency-Key` per detail-page session - [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed` - Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn` @@ -90,6 +90,14 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## Done +### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (backend + frontend, same pass) +- **PO draft lifecycle.** `lib/api/purchase-orders.ts`: `isPoEditable` narrowed to `status === "Draft"` (was the three-status exclusion); added `submit(poId)` and `remove(poId)`. `types/procurement.ts`: `saveAsDraft?` on `CreatePurchaseOrderRequest`; `UpdatePurchaseOrderRequest` now `Omit`s it. `/new`: the single "Create PO" button split into **Save as draft** / **Create & submit**. `/[id]`: **Draft** shows the editable line grid + **Submit** + **Delete draft**; an issued-but-open PO (`Approved`/`PartiallyReceived`) is read-only with **Cancel PO** (with reason, disabled once receipts exist); closed/cancelled are terminal. The old Cancel button was gated on `isPoEditable` — that would have shown Cancel only for Draft, so the affordances were re-split into `editable` (Draft) vs `cancellable` (Approved/PartiallyReceived). +- **GRN discount/VAT/variance.** `types/grn.ts`: `discountPct`/`vatPct` on `CreateGrnLineInput`; `poUnitPrice`/`discountPct`/`netUnitCost`/`vatPct`/`vatAmount`/`lineTotal`/`priceVariance` on `GrnLine`. `/new`: per-line Disc %/VAT % inputs, a client mirror of the server arithmetic (`computeLine`, display-only — server stays authoritative) driving a live Line total + document total, and a PO-price variance hint when the entered unit cost differs from the prefilled PO price. `/[id]`: renders the full cost breakdown (unit cost + variance, disc %, net cost, received value, VAT, line total) and a stock-value/VAT/document-total footer. `lib/validations/grn.ts`: 0–100 range checks on the two percentages. +- **Deliberately not touched:** the item picker already showed `sku — name` (the request's stated need); multi-GRN-per-PO and adding a non-PO item to a PO GRN already worked. Vendor stays PO-derived (non-selectable) for a PO-based GRN — selecting a different vendor than the PO's would be wrong. +- **Select trigger showed the id, not the label (global fix).** Base UI's `Select.Value` renders the raw selected value unless the `Select.Root` is given an `items` map — the popup items unmount when closed, so their text isn't available to the trigger (confirmed in `@base-ui/react`'s `resolveSelectedLabel`, which `find`s `items` by value and only falls back to stringifying the value when none is supplied). Fixed once in the shared wrapper (`components/ui/select.tsx`): `Select` now walks its own `SelectItem` children and derives the `items` array automatically, so all ~60 `` call sites across 26 files show the selected label without any per-site change. `tsc`/`eslint` clean; verified against Base UI's label-resolution source. +- **Procurement sidebar submenu.** The sidebar builds submenus from backend-seeded `SubNavItem` rows filtered by `GET /auth/me`'s `navCodes`; only Products and Settings had children, so Purchase Orders had no sidebar section (only reachable via the Procurement hub card). Added a `children` array to the Procurement nav entry (`components/Layouts/AppSidebar.tsx`) — Requisitions, RFQs, Purchase Orders, Purchase Returns — matching new backend sub-nav codes. Also found the Admin role (`RoleId 2`) was never granted `NAV:procurement` at all, so the whole Procurement branch was hidden for it; granted the parent + 4 children. **Verified:** `/auth/me` for Admin now returns all five procurement codes → submenu renders. Stale PO hub-card copy ("freely editable while open") updated to the draft/submit wording. +- **Verified:** `tsc --noEmit` clean; `eslint` unchanged from baseline (7 pre-existing `set-state-in-effect` on the PO/GRN screens before and after — 0 new issues, confirmed by stashing and re-counting). Runtime browser verification is the next step in this pass. + ### 2026-07-17 — connected to the real API (mock-data.ts deleted) **The app now talks to ERPCore.** Every `lib/api/*.ts` module calls the backend; `lib/api/mock-data.ts` is gone. This is the pass the 2026-07-15 note anticipated. diff --git a/Frontend/erp-system/app/dashboard/procurement/page.tsx b/Frontend/erp-system/app/dashboard/procurement/page.tsx index dd9f1ad..cc3a0c4 100644 --- a/Frontend/erp-system/app/dashboard/procurement/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/page.tsx @@ -18,7 +18,7 @@ const areas: { title: string; description: string; href: string; icon: LucideIco }, { title: "Purchase Orders", - description: "Auto-approved on creation, freely editable while open, cancellable before receipt.", + description: "Save as draft (editable/deletable) or submit to lock; cancel an issued PO before receipt.", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart, }, diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx index a205486..cf3db2a 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" import Link from "next/link" -import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Trash2 } from "lucide-react" +import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { warehousesApi } from "@/lib/api/warehouses" @@ -65,6 +65,8 @@ export default function PurchaseOrderDetailPage() { const [showCancelForm, setShowCancelForm] = useState(false) const [cancelReason, setCancelReason] = useState("") const [cancelling, setCancelling] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [deleting, setDeleting] = useState(false) function toDraftLines(order: PurchaseOrder): DraftLine[] { return order.lines.map((l) => ({ @@ -185,6 +187,39 @@ export default function PurchaseOrderDetailPage() { } } + async function handleSubmitPo() { + if (!po) return + setSaveError(null) + setSubmitting(true) + try { + const updated = await purchaseOrdersApi.submit(po.poId) + setPo(updated) + setLines(toDraftLines(updated)) + toast.success("Purchase order submitted", `${updated.docNo} — now ${updated.status} and locked for editing.`) + } catch (err) { + setSaveError(errorMessage(err)) + toast.error("Could not submit purchase order", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function handleDelete() { + if (!po) return + if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return + setSaveError(null) + setDeleting(true) + try { + await purchaseOrdersApi.remove(po.poId) + toast.success("Draft deleted", po.docNo) + router.push("/dashboard/procurement/purchase-orders") + } catch (err) { + setSaveError(errorMessage(err)) + toast.error("Could not delete purchase order", errorMessage(err)) + setDeleting(false) + } + } + async function handleCancel() { if (!po) return if (!cancelReason.trim()) { @@ -226,6 +261,9 @@ export default function PurchaseOrderDetailPage() { const editable = isPoEditable(po.status) && !conflict const hasReceipts = po.lines.some((l) => l.qtyReceived > 0) + // A submitted-but-still-open PO (issued to the vendor) is cancellable with a reason; + // a Draft is deleted instead, and closed/cancelled POs are terminal. + const cancellable = po.status === "Approved" || po.status === "PartiallyReceived" return (
    @@ -245,18 +283,32 @@ export default function PurchaseOrderDetailPage() {
    - {isPoEditable(po.status) && !showCancelForm && ( - - )} +
    + {po.status === "Draft" && ( + <> + + + + )} + {cancellable && !showCancelForm && ( + + )} +
    {showCancelForm && ( diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx index c7e6592..ae5672e 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -151,7 +151,7 @@ function NewPurchaseOrderContent() { return items?.find((i) => i.itemId === itemId) ?? null } - async function handleSubmit() { + async function handleSubmit(saveAsDraft: boolean) { setHeaderError(null) setSubmitError(null) @@ -197,8 +197,12 @@ function NewPurchaseOrderContent() { vendorId, requisitionId: requisitionId ?? (rfqId ? undefined : null), lines: payloadLines, + saveAsDraft, }) - toast.success("Purchase order created", `${po.docNo} — auto-approved (FR-PROC-04).`) + toast.success( + "Purchase order created", + saveAsDraft ? `${po.docNo} — saved as draft.` : `${po.docNo} — auto-approved (FR-PROC-04).` + ) router.push(`/dashboard/procurement/purchase-orders/${po.poId}`) } catch (err) { setSubmitError(errorMessage(err)) @@ -394,8 +398,11 @@ function NewPurchaseOrderContent() { Cancel - +
    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 4928aef..cab3278 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -158,6 +158,7 @@ export default function GrnDetailPage() { )} +
    @@ -165,8 +166,12 @@ export default function GrnDetailPage() { UOM Bin Qty - Unit cost - Received value + Unit cost + Disc % + Net cost + Received value + VAT + Line total Hold status {grn.status === "Confirmed" && Actions} @@ -180,8 +185,22 @@ export default function GrnDetailPage() { {uomFor(line.uomId)} {binFor(line.binId)} {line.qty} - {line.unitCost.toFixed(2)} - {line.receivedValue.toFixed(2)} + + {line.unitCost.toFixed(2)} + {line.poUnitPrice !== null && line.priceVariance !== 0 && ( + + PO {line.poUnitPrice.toFixed(2)} · var {line.priceVariance > 0 ? "+" : ""}{line.priceVariance.toFixed(2)} + + )} + + {line.discountPct.toFixed(2)} + {line.netUnitCost.toFixed(2)} + {line.receivedValue.toFixed(2)} + + {line.vatAmount.toFixed(2)} + {line.vatPct.toFixed(2)}% + + {line.lineTotal.toFixed(2)} @@ -228,6 +247,22 @@ export default function GrnDetailPage() { })}
    +
    + +
    +
    + Stock value (excl. VAT) + {grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)} +
    +
    + VAT + {grn.lines.reduce((s, l) => s + l.vatAmount, 0).toFixed(2)} +
    +
    + Document total + {grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)} +
    +
    ) } 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 b230d3a..99ce3ef 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -37,12 +37,28 @@ interface DraftLine { binId: number | null qty: string unitCost: string + /** PO line price when prefilled from a PO; drives the variance hint. */ + poUnitPrice: number | null + discountPct: string + vatPct: string holdStatus: HoldStatus batchNo: string expiryDate: string serialNumbersText: string } +/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */ +function computeLine(l: DraftLine) { + const qty = Number(l.qty) || 0 + const gross = Number(l.unitCost) || 0 + const disc = Number(l.discountPct) || 0 + const vat = Number(l.vatPct) || 0 + const netUnitCost = gross * (1 - disc / 100) + const receivedValue = qty * netUnitCost + const vatAmount = receivedValue * (vat / 100) + return { netUnitCost, receivedValue, vatAmount, lineTotal: receivedValue + vatAmount } +} + let keySeq = 0 function newKey() { keySeq += 1 @@ -58,6 +74,9 @@ function emptyLine(): DraftLine { binId: null, qty: "", unitCost: "", + poUnitPrice: null, + discountPct: "0", + vatPct: "0", holdStatus: "Available", batchNo: "", expiryDate: "", @@ -152,6 +171,9 @@ export default function NewGrnPage() { binId: null, qty: String(l.qty - l.qtyReceived), unitCost: String(l.unitPrice), + poUnitPrice: l.unitPrice, + discountPct: "0", + vatPct: "0", holdStatus: "Available", batchNo: "", expiryDate: "", @@ -211,6 +233,8 @@ export default function NewGrnPage() { uomId: line.uomId, qty: line.qty, unitCost: line.unitCost, + discountPct: line.discountPct, + vatPct: line.vatPct, trackingMode: itemFor(line.itemId)?.trackingMode ?? null, batchNo: line.batchNo, serialNumbersText: line.serialNumbersText, @@ -232,6 +256,8 @@ export default function NewGrnPage() { binId: l.binId, qty: Number(l.qty), unitCost: Number(l.unitCost), + discountPct: Number(l.discountPct) || 0, + vatPct: Number(l.vatPct) || 0, holdStatus: l.holdStatus, batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null, serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null, @@ -371,16 +397,20 @@ export default function NewGrnPage() { {poLoading && } {!poLoading && lines.length > 0 && ( +
    Item - UOM - Bin - Qty + UOM + Bin + Qty Unit cost - Hold status - Batch / Serial + Disc % + VAT % + Line total + Hold status + Batch / Serial @@ -473,6 +503,50 @@ export default function NewGrnPage() { className="h-11 text-base" /> + {line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && ( +

    + PO price {line.poUnitPrice.toFixed(2)} — variance recorded +

    + )} + + + updateLine(line.key, { discountPct: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { vatPct: e.target.value })} + className="h-11 text-base" + /> + + + + {(() => { + const c = computeLine(line) + return ( +
    + {c.lineTotal.toFixed(2)} + + net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)} + +
    + ) + })()}
    @@ -533,6 +607,16 @@ export default function NewGrnPage() { })}
    +
    + )} + + {!poLoading && lines.length > 0 && ( +
    + Document total (incl. VAT) + + {lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)} + +
    )} diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index c612b7e..9f75b23 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -8,12 +8,14 @@ import { Building2, ChevronRight, ClipboardList, + FileText, HelpCircle, LayoutGrid, ListTree, Menu, Package, PackageCheck, + PackageX, Ruler, Settings, ShieldCheck, @@ -56,7 +58,19 @@ const navItems: { ], }, { title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true }, - { title: "Procurement", code: "procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true }, + { + title: "Procurement", + code: "procurement", + href: "/dashboard/procurement", + icon: ClipboardList, + chevron: true, + children: [ + { title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList }, + { title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText }, + { title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart }, + { title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX }, + ], + }, { title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, { title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true }, { title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true }, diff --git a/Frontend/erp-system/components/ui/select.tsx b/Frontend/erp-system/components/ui/select.tsx index e8021f5..852c14f 100644 --- a/Frontend/erp-system/components/ui/select.tsx +++ b/Frontend/erp-system/components/ui/select.tsx @@ -6,7 +6,40 @@ import { Select as SelectPrimitive } from "@base-ui/react/select" import { cn } from "@/lib/utils" import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" -const Select = SelectPrimitive.Root +// 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, +// so their text isn't otherwise available. Rather than pass `items` at all ~60 call sites, +// this wrapper walks its own `SelectItem` children and derives that map automatically, so the +// trigger shows the selected item's label instead of its value. +function collectItems( + children: React.ReactNode, + acc: { value: unknown; label: React.ReactNode }[] +) { + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) return + if (child.type === SelectItem) { + const p = child.props as { value?: unknown; children?: React.ReactNode } + acc.push({ value: p.value, label: p.children }) + return + } + const nested = (child.props as { children?: React.ReactNode }).children + if (nested) collectItems(nested, acc) + }) +} + +function Select( + props: SelectPrimitive.Root.Props +) { + const { items, children } = props + const derivedItems = React.useMemo(() => { + if (items) return items + const acc: { value: unknown; label: React.ReactNode }[] = [] + collectItems(children, acc) + return acc.length ? (acc as ReadonlyArray<{ value: Value; label: React.ReactNode }>) : undefined + }, [items, children]) + + return +} function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { return ( diff --git a/Frontend/erp-system/lib/api/purchase-orders.ts b/Frontend/erp-system/lib/api/purchase-orders.ts index 29f7199..0ddb5e3 100644 --- a/Frontend/erp-system/lib/api/purchase-orders.ts +++ b/Frontend/erp-system/lib/api/purchase-orders.ts @@ -20,10 +20,10 @@ export interface ListPurchaseOrdersParams { sort?: string } -/** Editable while open (FR-PROC-05, Option B). The server is authoritative — it returns - * 409 PO_NOT_EDITABLE regardless — this only drives UI affordances. */ +/** Editable/deletable only while Draft (FR-PROC-05, revised — submitting locks the PO). + * The server is authoritative (409 PO_NOT_EDITABLE otherwise); this only drives UI affordances. */ export function isPoEditable(status: PurchaseOrderStatus): boolean { - return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled" + return status === "Draft" } export const purchaseOrdersApi = { @@ -54,6 +54,16 @@ export const purchaseOrdersApi = { return apiRequest(`/purchase-orders/${poId}/approve`, { method: "POST" }) }, + /** Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. */ + submit(poId: number): Promise { + return apiRequest(`/purchase-orders/${poId}/submit`, { method: "POST" }) + }, + + /** Delete a Draft PO. 409 PO_NOT_EDITABLE once submitted. */ + remove(poId: number): Promise { + return apiRequest(`/purchase-orders/${poId}`, { method: "DELETE" }) + }, + /** 409 if any receipt exists against the PO. */ cancel(poId: number, request: CancelPurchaseOrderRequest): Promise { return apiRequest(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request }) diff --git a/Frontend/erp-system/lib/validations/grn.ts b/Frontend/erp-system/lib/validations/grn.ts index dadc3ee..6d4debb 100644 --- a/Frontend/erp-system/lib/validations/grn.ts +++ b/Frontend/erp-system/lib/validations/grn.ts @@ -16,6 +16,8 @@ export function validateLine(input: { uomId: number | null qty: string unitCost: string + discountPct: string + vatPct: string trackingMode: TrackingMode | null batchNo: string serialNumbersText: string @@ -31,6 +33,14 @@ export function validateLine(input: { const unitCost = Number(input.unitCost) if (input.unitCost === "" || Number.isNaN(unitCost) || unitCost < 0) errors.unitCost = "Unit cost cannot be negative" + const discountPct = Number(input.discountPct) + if (input.discountPct !== "" && (Number.isNaN(discountPct) || discountPct < 0 || discountPct > 100)) + errors.discountPct = "Discount must be 0–100%" + + const vatPct = Number(input.vatPct) + if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100)) + errors.vatPct = "VAT must be 0–100%" + if (input.trackingMode === "Batch" && !input.batchNo.trim()) { errors.batchNo = "Batch number is required for this item" } diff --git a/Frontend/erp-system/types/grn.ts b/Frontend/erp-system/types/grn.ts index 4647d72..904b217 100644 --- a/Frontend/erp-system/types/grn.ts +++ b/Frontend/erp-system/types/grn.ts @@ -29,7 +29,16 @@ export interface CreateGrnLineInput { uomId: number binId?: number | null qty: number + /** + * Gross unit cost. For a PO line it is an optional per-receipt override — 0/omitted uses + * the PO price; a value wins and the server records a variance (docs/02-SECURITY C.3, + * revised). Required (> 0) for a direct receipt. + */ unitCost: number + /** Trade discount % (0–100). Reduces inventory cost. */ + discountPct?: number + /** VAT % (0–100). Recoverable — does not affect stock value. */ + vatPct?: number holdStatus: HoldStatus batch?: BatchInput | null } @@ -49,8 +58,21 @@ export interface GrnLine { uomId: number binId: number | null qty: number + /** Gross unit cost received at. */ unitCost: number + /** PO price snapshot at receipt; null for direct receipts. */ + poUnitPrice: number | null + discountPct: number + /** After-discount cost — what the FIFO layer is valued at. */ + netUnitCost: number + vatPct: number + vatAmount: number + /** qty × netUnitCost (after discount, before VAT). */ receivedValue: number + /** qty × netUnitCost + vatAmount — payable to vendor. */ + lineTotal: number + /** (unitCost − poUnitPrice) × qty; 0 for direct receipts. */ + priceVariance: number holdStatus: HoldStatus batchId: number | null } diff --git a/Frontend/erp-system/types/procurement.ts b/Frontend/erp-system/types/procurement.ts index 9b21cd1..5d95563 100644 --- a/Frontend/erp-system/types/procurement.ts +++ b/Frontend/erp-system/types/procurement.ts @@ -189,10 +189,12 @@ export interface CreatePurchaseOrderRequest { vendorId: number requisitionId?: number | null lines: CreatePoLineInput[] + /** When true the PO is created as an editable/deletable Draft; false (default) auto-approves. */ + saveAsDraft?: boolean } -/** PUT /purchase-orders/{poId} — edit-while-open, same line shape as create (FR-PROC-05, Option B). */ -export type UpdatePurchaseOrderRequest = CreatePurchaseOrderRequest +/** PUT /purchase-orders/{poId} — edit a Draft only (FR-PROC-05, revised); same line shape as create. */ +export type UpdatePurchaseOrderRequest = Omit export interface CancelPurchaseOrderRequest { reason?: string | null diff --git a/docs/02-SECURITY.md b/docs/02-SECURITY.md index f8c166d..3c80f7c 100644 --- a/docs/02-SECURITY.md +++ b/docs/02-SECURITY.md @@ -83,9 +83,9 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin - [ ] Note in review: **AR-01/AR-02/AR-03** apply to these endpoints ### C.3 GRN -- [ ] `unitCost` **derived from the PO line server-side**; any client-supplied cost is ignored *(decision locked)* -- [ ] `receivedValue` computed server-side (qty × PO-line cost), not accepted from client -- [ ] Direct GRN (no PO) is the exception where cost is entered → extra scrutiny + review flag + audit (**AR-04**) +- [ ] `unitCost` **defaults to the PO line price**; a per-line override **is now permitted** *(decision revised 2026-07-20 — was "locked, client cost ignored")*. When an override is entered it is used, and the PO price is snapshotted (`poUnitPrice`) so a **`priceVariance` is recorded** against it for review. Rationale: one PO legitimately spans batches received at different prices; the variance trail (plus the audit log) is the compensating control that replaces the old hard block. +- [ ] **Derived figures stay server-computed** — `netUnitCost`/`receivedValue`/`vatAmount`/`lineTotal` are never accepted from the client, so the client cannot inflate stock value except by an *auditable* unit-cost override. Discount reduces inventory cost; **VAT is recoverable and never enters stock value**. +- [ ] Direct GRN (no PO) remains the higher-scrutiny path where cost is entered with no PO to compare against → review flag + audit (**AR-04**) - [ ] Over-receipt tolerance enforced server-side → `OVER_RECEIPT_TOLERANCE` - [ ] On-hold stock is not issuable (FR-WH-07); expired batch blocked diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index 5f4ca3f..6e65db5 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -137,7 +137,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users | FR-PROC-02 | Optional **RFQ**: issue to vendors, record quotations for comparison. | S | | FR-PROC-03 | Generate **PO** from PR/RFQ or directly (item, UOM, qty, price, tax, delivery date, warehouse). | M | | FR-PROC-04 | **[Phase 1: auto-approve]** Auto-approve PO on creation (status `Approved`). Config flag `approvalRequired` (default off) gates a future approval workflow (authorization matrix); when on, PO cannot issue until approved. `PendingApproval` state + approval fields retained in schema (no migration to enable). | M | -| FR-PROC-05 | **[Phase 1: Option B — edit-while-open]** PO may be **freely edited while open** (not fully received/closed); changes take effect immediately with an audit entry. Versioned amendments deferred; schema must not preclude adding a version field later. | S | +| FR-PROC-05 | **[Phase 1: Option B *superseded* 2026-07-20 — draft-lock]** A PO is **editable and deletable only while `Draft`**; **submitting locks it** (Draft → Approved) and no further edit/delete/add-line is allowed — an issued PO is corrected by Cancel-with-reason (blocked once receipts exist) or a reversing document, never edited. Create takes `saveAsDraft` (default `false` → auto-approve, preserving the Requisition→PO / RFQ→PO flows). *Why the reversal:* Option B ("freely edit while open") let an already-issued, vendor-facing PO change silently after the fact; the draft/submit boundary makes "issued to vendor" a real, immutable commitment. Versioned amendments still deferred; schema unchanged (reuses the existing `Draft` enum value). | S | | FR-PROC-06 | PO lifecycle: Draft → (PendingApproval →) Approved → PartiallyReceived → FullyReceived → Closed/Cancelled. Phase 1 bypasses PendingApproval via auto-approve. | M | | FR-PROC-07 | Support **partial receipt**; PO stays open until fully received or manually closed. | M | | FR-PROC-08 | Support **Purchase Return** referencing original GRN/PO line; generates outbound movement. | M | @@ -151,7 +151,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users | FR-GRN-03 | Support **over/under-receipt tolerances** (per item or global); warn or block beyond tolerance. | S | | FR-GRN-04 | Capture **batch + expiry** and/or **serial numbers** for tracked items on receipt. | M | | FR-GRN-05 | Allow receipt into **inspection/quarantine hold** (not issuable) pending QC, before QC module exists. | M | -| FR-GRN-06 | On confirm, each line **creates a FIFO cost layer** at unit cost (PO price + attributable charges; landed cost per §B.1.2.1) and posts an inbound ledger entry. | M | +| FR-GRN-06 | On confirm, each line **creates a FIFO cost layer** at the **after-discount net unit cost** (`unitCost × (1 − discountPct/100)`) and posts an inbound ledger entry. **VAT never enters stock value** — it is recoverable input tax (revised 2026-07-20). PO price is the default unit cost; a per-line override is permitted and recorded as a variance (see 02-SECURITY C.3, revised). | M | | FR-GRN-07 | Record **received value per line** and PO reference for downstream matching. | M | | FR-GRN-08 | Assign received stock to a **bin/location** (putaway). | S | diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index 6fad489..1bf4b16 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -457,14 +457,15 @@ Query: `q`, `status` (`Open|Closed`), + paging. → list envelope of `GET /rfqs/{rfqId}/comparison` → vendor-by-line price matrix. ### 3.3 Purchase Orders -> **Phase 1:** `approvalRequired` defaults `false` → PO **auto-approved on creation**. Approve endpoint exists but is a no-op unless enabled (FR-PROC-04). PO **freely editable while open** (Option B, FR-PROC-05). +> **Phase 1:** `approvalRequired` defaults `false`. Create takes **`saveAsDraft`** (default `false` → **auto-approved on creation**; `true` → `Draft`). Approve endpoint exists but is a no-op unless enabled (FR-PROC-04). **A PO is editable/deletable only while `Draft`; submitting locks it** (FR-PROC-05, revised 2026-07-20 — Option B "freely edit while open" superseded). #### `POST /purchase-orders` ```json -{ "vendorId": 5, "requisitionId": 210, +{ "vendorId": 5, "requisitionId": 210, "saveAsDraft": false, "lines": [ { "itemId": 1001, "uomId": 1, "warehouseId": 1, "qty": 5000, "unitPrice": 12.50, "tax": 0.18 }, { "itemId": 1002, "uomId": 1, "warehouseId": 1, "qty": 8000, "unitPrice": 6.20, "tax": 0.18 } ] } ``` +`saveAsDraft` optional (default `false`). When `true` the response `status` is `Draft`. **201 Created** — `Location: /api/v1/purchase-orders/342` ```json { "poId": 342, "docNo": "PO-2026-00342", "vendorId": 5, "requisitionId": 210, @@ -477,7 +478,11 @@ Query: `q`, `status` (`Open|Closed`), + paging. → list envelope of `GET /purchase-orders?status=Approved&vendorId=5` → list envelope of PO summaries. #### `PUT /purchase-orders/{poId}` -Edit while open (not FullyReceived/Closed/Cancelled); requires `If-Match`. → **200 OK** updated resource; `409 PO_NOT_EDITABLE` if closed. +Edit a **Draft only**; requires `If-Match`. → **200 OK** updated resource; `409 PO_NOT_EDITABLE` once submitted (any non-Draft status). + +#### `POST /purchase-orders/{poId}/submit` → **200 OK** — `Draft → Approved`. `409 PO_NOT_EDITABLE` if not Draft. + +#### `DELETE /purchase-orders/{poId}` → **204 No Content** — permitted **only while Draft**; `409 PO_NOT_EDITABLE` once submitted. #### `POST /purchase-orders/{poId}/approve` → **200 OK** (no-op in Phase 1; transitions PendingApproval→Approved when enabled). @@ -524,15 +529,24 @@ Against a PO (lines default from open PO lines) or direct (`poId: null`, by perm ```json { "poId": 342, "warehouseId": 1, "lines": [ { "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, "qty": 5000, - "unitCost": 12.50, "holdStatus": "OnHold", + "unitCost": 12.50, "discountPct": 10, "vatPct": 18, "holdStatus": "OnHold", "batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } } ] } ``` -**201 Created** — status `Draft` +`discountPct`/`vatPct` optional (default 0, range 0–100). `unitCost` on a **PO line** is an optional +override: 0/omitted uses the PO price; a value wins and a variance is recorded (02-SECURITY C.3, revised). +On a direct receipt `unitCost` is required. +**201 Created** — status `Draft`. All derived figures are **server-computed**: +`netUnitCost = unitCost × (1 − discountPct/100)`, `receivedValue = qty × netUnitCost` (after discount, +**before** VAT — this is the stock value), `vatAmount = receivedValue × vatPct/100`, +`lineTotal = receivedValue + vatAmount`, `priceVariance = (unitCost − poUnitPrice) × qty`. ```json { "grnId": 780, "docNo": "GRN-2026-00780", "poId": 342, "vendorId": 5, "warehouseId": 1, "status": "Draft", "createdBy": 17, "lines": [ { "grnLineId": 1300, "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, - "qty": 5000, "unitCost": 12.50, "receivedValue": 62500.00, "holdStatus": "OnHold", "batchId": 410 } ] } + "qty": 5000, "unitCost": 12.50, "poUnitPrice": 12.50, "discountPct": 10.0, + "netUnitCost": 11.25, "vatPct": 18.0, "vatAmount": 10125.00, + "receivedValue": 56250.00, "lineTotal": 66375.00, "priceVariance": 0.00, + "holdStatus": "OnHold", "batchId": 410 } ] } ``` `422 OVER_RECEIPT_TOLERANCE` if qty exceeds open PO qty beyond tolerance. From 4108062416a40138cb99e83d678cd6d3971101c0 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Tue, 21 Jul 2026 11:55:40 +0530 Subject: [PATCH 4/4] ui fixes --- .../procurement/purchase-orders/new/page.tsx | 40 +--- .../components/Layouts/AppSidebar.tsx | 177 ++++++++++++------ 2 files changed, 125 insertions(+), 92 deletions(-) diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx index ae5672e..411799d 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -43,8 +43,12 @@ 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). function emptyLine(): DraftLine { - return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18" } + return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" } } function NewPurchaseOrderContent() { @@ -98,8 +102,8 @@ function NewPurchaseOrderContent() { uomId: null, warehouseId: null, qty: String(l.qty), - unitPrice: "", - tax: "0.18", + unitPrice: "0", + tax: "0", }) ) ) @@ -124,8 +128,8 @@ function NewPurchaseOrderContent() { uomId: null, warehouseId: null, qty: String(l.qty), - unitPrice: cell ? String(cell.unitPrice) : "", - tax: "0.18", + unitPrice: cell ? String(cell.unitPrice) : "0", + tax: "0", } }) ) @@ -278,8 +282,6 @@ function NewPurchaseOrderContent() { UOM Warehouse Qty - Unit price - Tax @@ -352,30 +354,6 @@ function NewPurchaseOrderContent() { /> - - updateLine(line.key, { unitPrice: e.target.value })} - className="h-11 text-base" - /> - - - - updateLine(line.key, { tax: e.target.value })} - className="h-11 text-base" - /> - - - {/* Nav items */} -
      + {/* Nav items — scrolls internally when it overflows, without a visible + scrollbar so the rounded panel stays clean. */} +
        {items.map((item) => { const isActive = item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href) + const hasChildren = !!item.children?.length && !iconOnly + const isOpen = !!expanded[item.code] return (
      • - - - {(!collapsed || isMobile) && ( - <> - {item.title} - {item.chevron && !item.children && !isActive && ( - - )} - - )} - + + + {!iconOnly && ( + <> + {item.title} + {item.chevron && !hasChildren && ( + + )} + + )} + - {item.children && (!collapsed || isMobile) && ( -
          - {(() => { - // Longest-matching href wins so a shared prefix (e.g. "Item" and - // "Category" both live under /dashboard/products) doesn't light up - // more than one sub-item at once. - const activeChild = [...item.children] - .filter((c) => pathname === c.href || pathname.startsWith(`${c.href}/`)) - .sort((a, b) => b.href.length - a.href.length)[0] - return item.children.map((child) => { - const childActive = child.href === activeChild?.href - return ( -
        • - - - {child.title} - -
        • - ) - }) - })()} -
        + {hasChildren && ( + + )} + + + {hasChildren && ( +
        +
        +
          + {(() => { + // Longest-matching href wins so a shared prefix (e.g. "Item" and + // "Category" both live under /dashboard/products) doesn't light up + // more than one sub-item at once. + const activeChild = [...item.children!] + .filter((c) => pathname === c.href || pathname.startsWith(`${c.href}/`)) + .sort((a, b) => b.href.length - a.href.length)[0] + return item.children!.map((child) => { + const childActive = child.href === activeChild?.href + return ( +
        • + + + {child.title} + +
        • + ) + }) + })()} +
        +
        +
        )}
      • ) })}
      -
      +