From 62a5d857de91604c470134a13c23eb57225151f3 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Fri, 17 Jul 2026 14:27:51 +0530 Subject: [PATCH] Complete all for Items --- Backend/ERPCore/Controllers/AuthController.cs | 33 +- Backend/ERPCore/Controllers/GrnsController.cs | 10 + .../Controllers/PurchaseReturnsController.cs | 18 + .../Controllers/RequisitionsController.cs | 6 +- Backend/ERPCore/Controllers/RfqsController.cs | 9 + .../Controllers/StockAdjustmentsController.cs | 18 + .../ERPCore/Controllers/StockController.cs | 18 +- .../Controllers/StockCountsController.cs | 9 + .../Controllers/StockTransfersController.cs | 10 + Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs | 6 +- Backend/ERPCore/Dtos/Grn/GrnDtos.cs | 5 + Backend/ERPCore/Dtos/Items/ItemDtos.cs | 11 +- .../Dtos/Procurement/PurchaseReturnDtos.cs | 7 +- .../Dtos/Procurement/RequisitionDtos.cs | 2 +- Backend/ERPCore/Dtos/Procurement/RfqDtos.cs | 4 + Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs | 5 + Backend/ERPCore/Dtos/Stock/CountDtos.cs | 8 +- Backend/ERPCore/Dtos/Stock/TransferDtos.cs | 7 +- Backend/ERPCore/Services/AdjustmentService.cs | 62 +- .../ERPCore/Services/Auth/AuthUserService.cs | 5 +- Backend/ERPCore/Services/CountService.cs | 27 +- Backend/ERPCore/Services/GrnService.cs | 27 + .../Services/Interfaces/IAdjustmentService.cs | 6 + .../Services/Interfaces/ICountService.cs | 5 + .../Services/Interfaces/IGrnService.cs | 5 + .../Interfaces/IPurchaseReturnService.cs | 6 + .../Interfaces/IRequisitionService.cs | 4 +- .../Services/Interfaces/IRfqService.cs | 4 + .../Services/Interfaces/IStockService.cs | 7 +- .../Services/Interfaces/ITransferService.cs | 5 + Backend/ERPCore/Services/ItemService.cs | 6 + .../ERPCore/Services/PurchaseReturnService.cs | 60 +- .../ERPCore/Services/RequisitionService.cs | 7 +- Backend/ERPCore/Services/RfqService.cs | 26 + .../ERPCore/Services/Stock/StockService.cs | 73 +- Backend/ERPCore/Services/TransferService.cs | 28 +- Backend/PROGRESS.md | 20 +- Frontend/PROGRESS.md | 61 +- Frontend/erp-system/.env.local.example | 7 + Frontend/erp-system/.gitignore | 2 + .../procurement/purchase-orders/new/page.tsx | 4 +- .../dashboard/procurement/rfqs/[id]/page.tsx | 32 +- .../dashboard/procurement/rfqs/new/page.tsx | 6 + .../app/dashboard/procurement/rfqs/page.tsx | 27 +- .../app/dashboard/products/[id]/page.tsx | 20 +- .../app/dashboard/products/brands/page.tsx | 55 +- .../products/categories/[id]/page.tsx | 233 +++++ .../dashboard/products/categories/page.tsx | 62 +- .../{variants => item-types}/page.tsx | 134 +-- .../app/dashboard/products/new/page.tsx | 316 ++++--- .../app/dashboard/products/page.tsx | 2 +- .../app/dashboard/products/settings/page.tsx | 168 ++++ .../receiving/grn/[id]/edit/page.tsx | 444 ---------- .../app/dashboard/receiving/grn/[id]/page.tsx | 2 +- .../app/dashboard/receiving/grn/new/page.tsx | 2 +- .../app/dashboard/receiving/grn/page.tsx | 63 +- .../app/dashboard/stock/enquiry/page.tsx | 4 +- .../dashboard/stock/reorder-alerts/page.tsx | 4 +- .../dashboard/stock/transfers/new/page.tsx | 4 +- .../app/dashboard/stock/wastage/new/page.tsx | 7 +- .../app/dashboard/stock/wastage/page.tsx | 5 +- .../app/dashboard/warehouse/[id]/page.tsx | 2 +- .../app/dashboard/warehouse/page.tsx | 2 +- Frontend/erp-system/app/login/page.tsx | 26 +- .../components/Layouts/AppSidebar.tsx | 6 +- .../erp-system/components/Layouts/Header.tsx | 49 +- Frontend/erp-system/components/ui/switch.tsx | 32 + Frontend/erp-system/lib/api-client.ts | 109 +++ Frontend/erp-system/lib/api/auth.ts | 32 + Frontend/erp-system/lib/api/brands.ts | 67 +- Frontend/erp-system/lib/api/categories.ts | 110 ++- Frontend/erp-system/lib/api/grns.ts | 210 +---- Frontend/erp-system/lib/api/item-types.ts | 41 + Frontend/erp-system/lib/api/items.ts | 148 +--- Frontend/erp-system/lib/api/mock-data.ts | 833 ------------------ Frontend/erp-system/lib/api/product-config.ts | 25 + .../erp-system/lib/api/purchase-orders.ts | 137 +-- .../erp-system/lib/api/purchase-returns.ts | 97 +- Frontend/erp-system/lib/api/reason-codes.ts | 18 +- Frontend/erp-system/lib/api/requisitions.ts | 59 +- Frontend/erp-system/lib/api/rfqs.ts | 91 +- .../erp-system/lib/api/stock-adjustments.ts | 118 +-- Frontend/erp-system/lib/api/stock-counts.ts | 145 +-- .../erp-system/lib/api/stock-transfers.ts | 160 +--- Frontend/erp-system/lib/api/stock.ts | 147 +--- Frontend/erp-system/lib/api/uoms.ts | 28 +- Frontend/erp-system/lib/api/variants.ts | 49 -- Frontend/erp-system/lib/api/vendors.ts | 104 +-- Frontend/erp-system/lib/api/warehouses.ts | 59 +- Frontend/erp-system/lib/api/wastage.ts | 122 +-- Frontend/erp-system/lib/auth-session.ts | 44 + Frontend/erp-system/lib/error-map.ts | 12 +- Frontend/erp-system/lib/validations.ts | 17 +- .../erp-system/lib/validations/master-data.ts | 5 +- Frontend/erp-system/next.config.ts | 27 + Frontend/erp-system/proxy.ts | 28 + Frontend/erp-system/types/auth.ts | 46 + Frontend/erp-system/types/grn.ts | 26 +- Frontend/erp-system/types/master-data.ts | 135 ++- Frontend/erp-system/types/procurement.ts | 51 +- Frontend/erp-system/types/stock.ts | 53 +- docs/11-BACKEND-PHASE1.md | 62 +- docs/20-FRONTEND.md | 34 +- 103 files changed, 2540 insertions(+), 3259 deletions(-) create mode 100644 Frontend/erp-system/.env.local.example create mode 100644 Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx rename Frontend/erp-system/app/dashboard/products/{variants => item-types}/page.tsx (53%) create mode 100644 Frontend/erp-system/app/dashboard/products/settings/page.tsx delete mode 100644 Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx create mode 100644 Frontend/erp-system/components/ui/switch.tsx create mode 100644 Frontend/erp-system/lib/api-client.ts create mode 100644 Frontend/erp-system/lib/api/auth.ts create mode 100644 Frontend/erp-system/lib/api/item-types.ts delete mode 100644 Frontend/erp-system/lib/api/mock-data.ts create mode 100644 Frontend/erp-system/lib/api/product-config.ts delete mode 100644 Frontend/erp-system/lib/api/variants.ts create mode 100644 Frontend/erp-system/lib/auth-session.ts create mode 100644 Frontend/erp-system/next.config.ts create mode 100644 Frontend/erp-system/proxy.ts create mode 100644 Frontend/erp-system/types/auth.ts diff --git a/Backend/ERPCore/Controllers/AuthController.cs b/Backend/ERPCore/Controllers/AuthController.cs index 5900a4a..a855e6c 100644 --- a/Backend/ERPCore/Controllers/AuthController.cs +++ b/Backend/ERPCore/Controllers/AuthController.cs @@ -125,16 +125,45 @@ public sealed class AuthController : ControllerBase public async Task> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct) => Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct)); + /// + /// Ends the session: revokes it upstream where possible, and always clears our cookies. + /// + /// userId is optional because callers usually cannot supply it — AuthHex returns + /// user.userId: null in its own login/register response, so a browser has no id + /// to send. It is resolved from the session token's UserId claim instead. + /// + /// + /// The cookies are cleared even if the upstream revoke fails or no user can be + /// resolved: a logout that leaves the caller holding a live session cookie is worse + /// than one that leaves a stale session server-side (which lapses on its own). + /// + /// [HttpPost("logout")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task Logout([FromBody] LogoutRequest request, CancellationToken ct) + public async Task Logout([FromBody] LogoutRequest? request, CancellationToken ct) { - await _users.LogoutUserAsync(request, ct); + var userId = request?.UserId ?? ResolveTokenUserId(); + if (userId is not null) + { + try + { + await _users.LogoutUserAsync(new LogoutRequest { UserId = userId.Value }, ct); + } + catch (DomainException) + { + // Upstream unreachable or already-revoked — fall through and clear anyway. + } + } + AuthCookieWriter.ClearSession(Response); return NoContent(); } + /// AuthHex's identity claim, present when the request carried a valid session. + private Guid? ResolveTokenUserId() + => Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null; + [HttpPut("me")] [ValidateCsrf] [ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs index 20ba722..3ad1f5a 100644 --- a/Backend/ERPCore/Controllers/GrnsController.cs +++ b/Backend/ERPCore/Controllers/GrnsController.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Grn; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +14,14 @@ public sealed class GrnsController : ApiControllerBase public GrnsController(IGrnService grns) => _grns = grns; + /// List GRNs, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId, + [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct) + => Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct)); + [HttpGet("{grnId:int}")] [ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs index 9f59a56..40ce9a2 100644 --- a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs +++ b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs @@ -1,3 +1,4 @@ +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +13,23 @@ public sealed class PurchaseReturnsController : ApiControllerBase public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns; + /// List posted returns, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct) + => Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct)); + + /// Get one return with its lines and the ledger entries it posted. + [HttpGet("{returnId:int}")] + [ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int returnId, CancellationToken ct) + { + var dto = await _returns.GetAsync(returnId, ct); + return dto is null ? NotFound() : Ok(dto); + } + /// Create + auto-post a return (outbound movement). 409 if return exceeds available stock. [HttpPost] [ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)] diff --git a/Backend/ERPCore/Controllers/RequisitionsController.cs b/Backend/ERPCore/Controllers/RequisitionsController.cs index ab849b9..e16d2e1 100644 --- a/Backend/ERPCore/Controllers/RequisitionsController.cs +++ b/Backend/ERPCore/Controllers/RequisitionsController.cs @@ -1,3 +1,4 @@ +using ERPCore.Domain.Enums; using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Services.Interfaces; @@ -15,8 +16,9 @@ public sealed class RequisitionsController : ApiControllerBase [HttpGet] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] - public async Task>> List([FromQuery] PageQuery query, CancellationToken ct) - => Ok(await _requisitions.ListAsync(query, ct)); + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct) + => Ok(await _requisitions.ListAsync(query, status, ct)); [HttpGet("{requisitionId:int}")] [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Controllers/RfqsController.cs b/Backend/ERPCore/Controllers/RfqsController.cs index 7cc0e1f..c917479 100644 --- a/Backend/ERPCore/Controllers/RfqsController.cs +++ b/Backend/ERPCore/Controllers/RfqsController.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +14,13 @@ public sealed class RfqsController : ApiControllerBase public RfqsController(IRfqService rfqs) => _rfqs = rfqs; + /// List RFQs, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct) + => Ok(await _rfqs.ListAsync(query, status, ct)); + [HttpGet("{rfqId:int}")] [ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs index 7952ba6..51b2544 100644 --- a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs +++ b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs @@ -1,3 +1,4 @@ +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +13,23 @@ public sealed class StockAdjustmentsController : ApiControllerBase public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments; + /// List posted adjustments, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct) + => Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct)); + + /// Get one adjustment with its lines and the ledger entries it posted. + [HttpGet("{adjustmentId:int}")] + [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int adjustmentId, CancellationToken ct) + { + var dto = await _adjustments.GetAsync(adjustmentId, ct); + return dto is null ? NotFound() : Ok(dto); + } + /// Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes). [HttpPost] [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)] diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs index 652942a..3189268 100644 --- a/Backend/ERPCore/Controllers/StockController.cs +++ b/Backend/ERPCore/Controllers/StockController.cs @@ -24,12 +24,26 @@ public sealed class StockController : ApiControllerBase public async Task> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct) => Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct)); + /// On-hand across every stocked (item, warehouse) pair; both filters optional. + [HttpGet("on-hand/list")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> OnHandList( + [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct)); + + /// + /// Immutable movement history. sourceDocType/sourceDocId answer "what did + /// this document post?" — the ledger's document reference is polymorphic, so there is + /// no FK to navigate instead (docs/10 C.9). + /// [HttpGet("ledger")] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] public async Task>> Ledger( [FromQuery] int? itemId, [FromQuery] int? warehouseId, - [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct) - => Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct)); + [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, + [FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId, + [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct)); [HttpGet("valuation")] [ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Controllers/StockCountsController.cs b/Backend/ERPCore/Controllers/StockCountsController.cs index 008ca79..aa8b477 100644 --- a/Backend/ERPCore/Controllers/StockCountsController.cs +++ b/Backend/ERPCore/Controllers/StockCountsController.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +14,13 @@ public sealed class StockCountsController : ApiControllerBase public StockCountsController(ICountService counts) => _counts = counts; + /// List counts, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct) + => Ok(await _counts.ListAsync(query, status, warehouseId, ct)); + [HttpGet("{countId:int}")] [ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/Backend/ERPCore/Controllers/StockTransfersController.cs b/Backend/ERPCore/Controllers/StockTransfersController.cs index fd5f52b..49d1a3f 100644 --- a/Backend/ERPCore/Controllers/StockTransfersController.cs +++ b/Backend/ERPCore/Controllers/StockTransfersController.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +14,14 @@ public sealed class StockTransfersController : ApiControllerBase public StockTransfersController(ITransferService transfers) => _transfers = transfers; + /// List transfers, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] TransferStatus? status, + [FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct) + => Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct)); + [HttpGet("{transferId:int}")] [ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs index f75c193..4b541a2 100644 --- a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs +++ b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs @@ -175,5 +175,9 @@ public sealed class TwoFaStatusResponse public sealed class LogoutRequest { - [Required] public Guid UserId { get; set; } + /// + /// Optional: AuthHex returns no userId on login, so browsers cannot supply one. + /// When omitted, the controller resolves it from the session token's UserId claim. + /// + public Guid? UserId { get; set; } } diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs index bdcff2b..9051017 100644 --- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -13,6 +13,11 @@ public sealed record GrnDto( int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList Lines); +/// Row shape for GET /grns — line count instead of the lines themselves. +public sealed record GrnSummaryDto( + int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, + int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount); + public sealed record CreatedLayerDto( int LayerId, int ItemId, int WarehouseId, int? BatchId, decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate); diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs index 7720597..adddcfa 100644 --- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs +++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs @@ -14,12 +14,21 @@ public sealed record ItemListItemDto( /// A single per-warehouse reorder policy row. public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty); -/// Full item resource for GET /items/{id} and create/update responses. +/// +/// Full item resource for GET /items/{id} and create/update responses. +/// +/// is embedded because they are otherwise unreadable: they can +/// only be written via PUT /items/{id}/uom-conversions, which returns them, but no +/// endpoint reads them back — so a detail screen could never show current state before +/// editing. Mirrors how is already inlined. +/// +/// public sealed record ItemDetailDto( int ItemId, string Sku, string Name, string? Description, int CategoryId, int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode, string? TaxClass, EntityStatus Status, IReadOnlyList Reorder, + IReadOnlyList Conversions, DateTime CreatedAt, DateTime? UpdatedAt); /// UOM conversion row (docs/11 §2.2). diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs index f9deb75..5854fbb 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs @@ -9,7 +9,12 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int public sealed record PurchaseReturnDto( int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status, - int CreatedBy, IReadOnlyList Lines, IReadOnlyList LedgerRefs); + int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs); + +/// Row shape for GET /purchase-returns — no lines/ledgerRefs (those need a per-row query). +public sealed record PurchaseReturnSummaryDto( + int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status, + int CreatedBy, DateTime CreatedAt, int LineCount); // Requests ---------------------------------------------------------------------- diff --git a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs index 63b5e0a..ef8cae1 100644 --- a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs @@ -12,7 +12,7 @@ public sealed record RequisitionDto( DateTime CreatedAt, IReadOnlyList Lines); public sealed record RequisitionSummaryDto( - int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt); + int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, int LineCount); // Requests — server sets docNo, status, requestedBy (audit actor), timestamps ---- diff --git a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs index d54ab90..f9a5635 100644 --- a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs @@ -10,6 +10,10 @@ public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty); public sealed record RfqDto( int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList Lines); +/// Row shape for GET /rfqs — line/quotation counts instead of the lines themselves. +public sealed record RfqSummaryDto( + int RfqId, string DocNo, int RequisitionId, RfqStatus Status, int LineCount, int QuotationCount); + public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays); public sealed record VendorQuotationDto( diff --git a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs index a8e9696..d7b97ce 100644 --- a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs @@ -11,6 +11,11 @@ public sealed record AdjustmentDto( int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs); +/// Row shape for GET /stock-adjustments — no lines/ledgerRefs (those need a per-row query). +public sealed record AdjustmentSummaryDto( + int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status, + int CreatedBy, DateTime CreatedAt, int LineCount); + // Requests ---------------------------------------------------------------------- public sealed class CreateAdjustmentLineInput diff --git a/Backend/ERPCore/Dtos/Stock/CountDtos.cs b/Backend/ERPCore/Dtos/Stock/CountDtos.cs index 44a67ec..a75a262 100644 --- a/Backend/ERPCore/Dtos/Stock/CountDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/CountDtos.cs @@ -8,7 +8,13 @@ namespace ERPCore.Dtos.Stock; public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance); public sealed record CountDto( - int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList Lines); + int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, + int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines); + +/// Row shape for GET /stock-counts — line count instead of the lines themselves. +public sealed record CountSummaryDto( + int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, + int CreatedBy, DateTime CreatedAt, int LineCount); public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList LedgerRefs); diff --git a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs index dd91237..5d4d1d7 100644 --- a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs @@ -10,7 +10,12 @@ public sealed record TransferLineDto( public sealed record TransferDto( int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, - TransferStatus Status, IReadOnlyList Lines); + TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines); + +/// Row shape for GET /stock-transfers — line count instead of the lines themselves. +public sealed record TransferSummaryDto( + int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, + TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount); public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost); diff --git a/Backend/ERPCore/Services/AdjustmentService.cs b/Backend/ERPCore/Services/AdjustmentService.cs index 53604f7..8983eec 100644 --- a/Backend/ERPCore/Services/AdjustmentService.cs +++ b/Backend/ERPCore/Services/AdjustmentService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -23,6 +24,7 @@ public sealed class AdjustmentService : IAdjustmentService private readonly IRepository _warehouses; private readonly IRepository _items; private readonly IRepository _reasonCodes; + private readonly IRepository _ledger; private readonly IStockMutator _mutator; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; @@ -30,19 +32,62 @@ public sealed class AdjustmentService : IAdjustmentService public AdjustmentService( IRepository adjustments, IRepository warehouses, IRepository items, - IRepository reasonCodes, IStockMutator mutator, INumberSequenceService numbers, - ICurrentUser currentUser, IUnitOfWork uow) + IRepository reasonCodes, IRepository ledger, IStockMutator mutator, + INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) { _adjustments = adjustments; _warehouses = warehouses; _items = items; _reasonCodes = reasonCodes; + _ledger = ledger; _mutator = mutator; _numbers = numbers; _currentUser = currentUser; _uow = uow; } + public async Task> ListAsync( + PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default) + { + var q = _adjustments.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%")); + } + if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId); + if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(a => a.AdjustmentId) + .Skip(query.Skip).Take(query.PageSize) + .Select(a => new AdjustmentSummaryDto( + a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, + a.CreatedBy, a.CreatedAt, a.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetAsync(int adjustmentId, CancellationToken ct = default) + { + var adjustment = await _adjustments.Query().AsNoTracking() + .Include(a => a.Lines) + .FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct); + if (adjustment is null) return null; + + // The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow, + // so the refs this adjustment posted are recovered by source-doc lookup. + var ledgerRefs = await _ledger.Query().AsNoTracking() + .Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == adjustmentId) + .OrderBy(l => l.LedgerId) + .Select(l => l.LedgerId) + .ToListAsync(ct); + + return ToDto(adjustment, ledgerRefs); + } + public async Task CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default) { if (request.ReasonCodeId is null) @@ -94,11 +139,12 @@ public sealed class AdjustmentService : IAdjustmentService return (entity, refs); }, ct); - return new AdjustmentDto( - adjustment.AdjustmentId, adjustment.DocNo, adjustment.WarehouseId, adjustment.ReasonCodeId, - adjustment.Status, adjustment.CreatedBy, adjustment.CreatedAt, - adjustment.Lines.OrderBy(l => l.AdjLineId) - .Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(), - ledgerRefs.Select(l => l.LedgerId).ToList()); + return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList()); } + + private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList ledgerRefs) => new( + a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt, + a.Lines.OrderBy(l => l.AdjLineId) + .Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(), + ledgerRefs); } diff --git a/Backend/ERPCore/Services/Auth/AuthUserService.cs b/Backend/ERPCore/Services/Auth/AuthUserService.cs index 277d493..a6ce0de 100644 --- a/Backend/ERPCore/Services/Auth/AuthUserService.cs +++ b/Backend/ERPCore/Services/Auth/AuthUserService.cs @@ -55,8 +55,11 @@ public sealed class AuthUserService : IAuthUserService public Task VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default) => _authHex.VerifyPasswordAsync(request, bearerToken, ct); + /// The controller resolves the id (from body or token claim) before calling here. public Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default) - => _authHex.LogoutUserAsync(request.UserId, ct); + => request.UserId is null + ? Task.CompletedTask + : _authHex.LogoutUserAsync(request.UserId.Value, ct); public Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default) => _authHex.UpdateUserAsync(request, bearerToken, ct); diff --git a/Backend/ERPCore/Services/CountService.cs b/Backend/ERPCore/Services/CountService.cs index 1c62ecb..6b26bcf 100644 --- a/Backend/ERPCore/Services/CountService.cs +++ b/Backend/ERPCore/Services/CountService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -49,6 +50,30 @@ public sealed class CountService : ICountService _uow = uow; } + public async Task> ListAsync( + PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default) + { + var q = _counts.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(c => EF.Functions.ILike(c.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(c => c.Status == status); + if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(c => c.CountId) + .Skip(query.Skip).Take(query.PageSize) + .Select(c => new CountSummaryDto( + c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, + c.CreatedBy, c.CreatedAt, c.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task GetAsync(int countId, CancellationToken ct = default) { var count = await _counts.Query().AsNoTracking().Include(c => c.Lines) @@ -175,7 +200,7 @@ public sealed class CountService : ICountService } private static CountDto Map(StockCount c) => new( - c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, + c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt, c.Lines.OrderBy(l => l.CountLineId) .Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList()); } diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 891163f..8af5783 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Grn; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -65,6 +66,32 @@ public sealed class GrnService : IGrnService _uow = uow; } + public async Task> ListAsync( + PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default) + { + var q = _grns.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(g => EF.Functions.ILike(g.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(g => g.Status == status); + if (poId is not null) q = q.Where(g => g.PoId == poId); + if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId); + if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(g => g.GrnId) + .Skip(query.Skip).Take(query.PageSize) + .Select(g => new GrnSummaryDto( + g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, + g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task GetAsync(int grnId, CancellationToken ct = default) { var grn = await _grns.Query().AsNoTracking() diff --git a/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs b/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs index c2f3c29..0b535e1 100644 --- a/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs +++ b/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs @@ -1,3 +1,4 @@ +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; namespace ERPCore.Services.Interfaces; @@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces; /// Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5). public interface IAdjustmentService { + Task> ListAsync( + PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default); + + Task GetAsync(int adjustmentId, CancellationToken ct = default); + Task CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/ICountService.cs b/Backend/ERPCore/Services/Interfaces/ICountService.cs index 667796e..514e751 100644 --- a/Backend/ERPCore/Services/Interfaces/ICountService.cs +++ b/Backend/ERPCore/Services/Interfaces/ICountService.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; namespace ERPCore.Services.Interfaces; @@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces; /// Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08). public interface ICountService { + Task> ListAsync( + PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default); + Task GetAsync(int countId, CancellationToken ct = default); Task CreateAsync(CreateCountRequest request, CancellationToken ct = default); Task EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IGrnService.cs b/Backend/ERPCore/Services/Interfaces/IGrnService.cs index 92c0dc7..a683c26 100644 --- a/Backend/ERPCore/Services/Interfaces/IGrnService.cs +++ b/Backend/ERPCore/Services/Interfaces/IGrnService.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Grn; namespace ERPCore.Services.Interfaces; @@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces; /// Goods-receipt business logic (docs/11 §4; FR-GRN-01..08). public interface IGrnService { + Task> ListAsync( + PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default); + Task GetAsync(int grnId, CancellationToken ct = default); Task CreateAsync(CreateGrnRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs b/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs index 8dc20b7..6543a4c 100644 --- a/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs +++ b/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs @@ -1,3 +1,4 @@ +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; namespace ERPCore.Services.Interfaces; @@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces; /// Purchase-return business logic (docs/11 §3.4; FR-PROC-08). public interface IPurchaseReturnService { + Task> ListAsync( + PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default); + + Task GetAsync(int returnId, CancellationToken ct = default); + Task CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs b/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs index 309645d..640b3a0 100644 --- a/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs +++ b/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs @@ -1,3 +1,4 @@ +using ERPCore.Domain.Enums; using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; @@ -6,7 +7,8 @@ namespace ERPCore.Services.Interfaces; /// Purchase-requisition business logic (docs/11 §3.1). public interface IRequisitionService { - Task> ListAsync(PageQuery query, CancellationToken ct = default); + Task> ListAsync( + PageQuery query, RequisitionStatus? status, CancellationToken ct = default); Task GetAsync(int requisitionId, CancellationToken ct = default); Task CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default); Task SubmitAsync(int requisitionId, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IRfqService.cs b/Backend/ERPCore/Services/Interfaces/IRfqService.cs index 9074dd1..83c42f5 100644 --- a/Backend/ERPCore/Services/Interfaces/IRfqService.cs +++ b/Backend/ERPCore/Services/Interfaces/IRfqService.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; namespace ERPCore.Services.Interfaces; @@ -5,6 +7,8 @@ namespace ERPCore.Services.Interfaces; /// RFQ & vendor-quotation business logic (docs/11 §3.2). public interface IRfqService { + Task> ListAsync(PageQuery query, RfqStatus? status, CancellationToken ct = default); + Task GetAsync(int rfqId, CancellationToken ct = default); Task CreateAsync(CreateRfqRequest request, CancellationToken ct = default); Task AddQuotationAsync(int rfqId, CreateQuotationRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IStockService.cs b/Backend/ERPCore/Services/Interfaces/IStockService.cs index 3fdaf78..32a720c 100644 --- a/Backend/ERPCore/Services/Interfaces/IStockService.cs +++ b/Backend/ERPCore/Services/Interfaces/IStockService.cs @@ -8,8 +8,13 @@ public interface IStockService { Task GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default); + /// On-hand for every (item, warehouse) pair holding stock — backs the enquiry list. + Task> GetOnHandListAsync( + int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default); + Task> GetLedgerAsync( - int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default); + int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, + string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default); Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/ITransferService.cs b/Backend/ERPCore/Services/Interfaces/ITransferService.cs index 44707e2..8afeda9 100644 --- a/Backend/ERPCore/Services/Interfaces/ITransferService.cs +++ b/Backend/ERPCore/Services/Interfaces/ITransferService.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; namespace ERPCore.Services.Interfaces; @@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces; /// Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06). public interface ITransferService { + Task> ListAsync( + PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default); + Task GetAsync(int transferId, CancellationToken ct = default); Task CreateAsync(CreateTransferRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index 051d4e0..c28808c 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -89,6 +89,7 @@ public sealed class ItemService : IItemService { var item = await _items.Query().AsNoTracking() .Include(i => i.ReorderSettings) + .Include(i => i.UomConversions) .FirstOrDefaultAsync(i => i.ItemId == itemId, ct); return item is null ? null : new ETagged(ToDetail(item), item.RowVersion); @@ -131,6 +132,7 @@ public sealed class ItemService : IItemService { var item = await _items.Query() .Include(i => i.ReorderSettings) + .Include(i => i.UomConversions) .FirstOrDefaultAsync(i => i.ItemId == itemId, ct) ?? throw new NotFoundException($"Item {itemId} was not found."); @@ -351,5 +353,9 @@ public sealed class ItemService : IItemService .OrderBy(r => r.WarehouseId) .Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty)) .ToList(), + i.UomConversions + .OrderBy(c => c.ConversionId) + .Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor)) + .ToList(), i.CreatedAt, i.UpdatedAt); } diff --git a/Backend/ERPCore/Services/PurchaseReturnService.cs b/Backend/ERPCore/Services/PurchaseReturnService.cs index 4e0dd11..da6ed71 100644 --- a/Backend/ERPCore/Services/PurchaseReturnService.cs +++ b/Backend/ERPCore/Services/PurchaseReturnService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -25,6 +26,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService private readonly IRepository _items; private readonly IRepository _reasonCodes; private readonly IRepository _grnLines; + private readonly IRepository _ledger; private readonly IStockMutator _mutator; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; @@ -33,7 +35,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService public PurchaseReturnService( IRepository returns, IRepository vendors, IRepository warehouses, IRepository items, IRepository reasonCodes, IRepository grnLines, - IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) + IRepository ledger, IStockMutator mutator, INumberSequenceService numbers, + ICurrentUser currentUser, IUnitOfWork uow) { _returns = returns; _vendors = vendors; @@ -41,12 +44,54 @@ public sealed class PurchaseReturnService : IPurchaseReturnService _items = items; _reasonCodes = reasonCodes; _grnLines = grnLines; + _ledger = ledger; _mutator = mutator; _numbers = numbers; _currentUser = currentUser; _uow = uow; } + public async Task> ListAsync( + PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default) + { + var q = _returns.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); + } + if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId); + if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.ReturnId) + .Skip(query.Skip).Take(query.PageSize) + .Select(r => new PurchaseReturnSummaryDto( + r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, + r.CreatedBy, r.CreatedAt, r.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetAsync(int returnId, CancellationToken ct = default) + { + var ret = await _returns.Query().AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.ReturnId == returnId, ct); + if (ret is null) return null; + + // Polymorphic ledger reference (docs/10 C.9) — recovered by source-doc lookup. + var ledgerRefs = await _ledger.Query().AsNoTracking() + .Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId) + .OrderBy(l => l.LedgerId) + .Select(l => l.LedgerId) + .ToListAsync(ct); + + return ToDto(ret, ledgerRefs); + } + public async Task CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default) { if (request.ReasonCodeId is null) @@ -105,11 +150,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService }, ct); // Map ledger ids after commit so they are populated. - return new PurchaseReturnDto( - entity.ReturnId, entity.DocNo, entity.VendorId, entity.WarehouseId, entity.ReasonCodeId, entity.Status, - entity.CreatedBy, - entity.Lines.OrderBy(l => l.ReturnLineId) - .Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(), - ledgerEntries.Select(r => r.LedgerId).ToList()); + return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList()); } + + private static PurchaseReturnDto ToDto(PurchaseReturn r, IReadOnlyList ledgerRefs) => new( + r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt, + r.Lines.OrderBy(l => l.ReturnLineId) + .Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(), + ledgerRefs); } diff --git a/Backend/ERPCore/Services/RequisitionService.cs b/Backend/ERPCore/Services/RequisitionService.cs index 8390609..d8086a6 100644 --- a/Backend/ERPCore/Services/RequisitionService.cs +++ b/Backend/ERPCore/Services/RequisitionService.cs @@ -31,7 +31,8 @@ public sealed class RequisitionService : IRequisitionService _uow = uow; } - public async Task> ListAsync(PageQuery query, CancellationToken ct = default) + public async Task> ListAsync( + PageQuery query, RequisitionStatus? status, CancellationToken ct = default) { var q = _requisitions.Query().AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Q)) @@ -39,11 +40,13 @@ public sealed class RequisitionService : IRequisitionService var term = query.Q.Trim(); q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); } + if (status is not null) q = q.Where(r => r.Status == status); var total = await q.CountAsync(ct); var rows = await q.OrderByDescending(r => r.RequisitionId) .Skip(query.Skip).Take(query.PageSize) - .Select(r => new RequisitionSummaryDto(r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt)) + .Select(r => new RequisitionSummaryDto( + r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt, r.Lines.Count)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); diff --git a/Backend/ERPCore/Services/RfqService.cs b/Backend/ERPCore/Services/RfqService.cs index b73db6e..2072565 100644 --- a/Backend/ERPCore/Services/RfqService.cs +++ b/Backend/ERPCore/Services/RfqService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; @@ -34,6 +35,31 @@ public sealed class RfqService : IRfqService _uow = uow; } + public async Task> ListAsync( + PageQuery query, RfqStatus? status, CancellationToken ct = default) + { + var q = _rfqs.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(r => r.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.RfqId) + .Skip(query.Skip).Take(query.PageSize) + .Select(r => new RfqSummaryDto( + r.RfqId, r.DocNo, r.RequisitionId, r.Status, + r.Lines.Count, + // Correlated subquery: there is no Rfq.Quotations navigation to count. + _quotations.Query().Count(qt => qt.RfqId == r.RfqId))) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task GetAsync(int rfqId, CancellationToken ct = default) { var rfq = await _rfqs.Query().AsNoTracking() diff --git a/Backend/ERPCore/Services/Stock/StockService.cs b/Backend/ERPCore/Services/Stock/StockService.cs index 2eaedab..1d9e5fb 100644 --- a/Backend/ERPCore/Services/Stock/StockService.cs +++ b/Backend/ERPCore/Services/Stock/StockService.cs @@ -50,14 +50,85 @@ public sealed class StockService : IStockService return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow); } + /// + /// On-hand across every (item, warehouse) pair that holds stock — backs the Stock + /// Enquiry list. Deliberately set-based: four grouped queries regardless of page size, + /// rather than calling per row (which would be N+1). + /// Pairs are sourced from StockLayer, so an item that never had a receipt in a + /// warehouse simply does not appear. + /// + public async Task> GetOnHandListAsync( + int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default) + { + var layers = _layers.Query().AsNoTracking(); + if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId); + if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId); + + var grouped = layers + .GroupBy(l => new { l.ItemId, l.WarehouseId }) + .Select(g => new { g.Key.ItemId, g.Key.WarehouseId, OnHand = g.Sum(x => x.QtyRemaining) }); + + var total = await grouped.CountAsync(ct); + var page = await grouped + .OrderBy(x => x.ItemId).ThenBy(x => x.WarehouseId) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + if (page.Count == 0) + return PagedResponse.Create([], query.Page, query.PageSize, total); + + // Filtering by the page's ids gives a superset (the cross-product of both lists); + // the join below narrows it back to the actual pairs. + var itemIds = page.Select(p => p.ItemId).Distinct().ToList(); + var warehouseIds = page.Select(p => p.WarehouseId).Distinct().ToList(); + + var onHold = (await _layers.Query().AsNoTracking() + .Where(l => itemIds.Contains(l.ItemId) && warehouseIds.Contains(l.WarehouseId) + && l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold) + .GroupBy(l => new { l.ItemId, l.WarehouseId }) + .Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) }) + .ToListAsync(ct)) + .ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty); + + var inTransit = (await _transferLines.Query().AsNoTracking() + .Where(l => itemIds.Contains(l.ItemId) + && l.Transfer != null + && warehouseIds.Contains(l.Transfer.SrcWarehouseId) + && l.Transfer.Status == TransferStatus.InTransit) + .GroupBy(l => new { l.ItemId, WarehouseId = l.Transfer!.SrcWarehouseId }) + .Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.Qty - x.QtyReceived) }) + .ToListAsync(ct)) + .ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty); + + var asOf = DateTime.UtcNow; + var rows = page.Select(p => + { + var key = (p.ItemId, p.WarehouseId); + var hold = onHold.GetValueOrDefault(key); + var transit = inTransit.GetValueOrDefault(key); + const decimal reserved = 0m; + // Same formula as GetOnHandAsync: in-transit is reported, not re-subtracted. + return new StockOnHandDto( + p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf); + }).ToList(); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task> GetLedgerAsync( - int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default) + int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, + string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default) { var q = _ledger.Query().AsNoTracking(); if (itemId is not null) q = q.Where(l => l.ItemId == itemId); if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId); if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue)); if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue)); + // Source-doc filter: the ledger references its originating document polymorphically + // (docs/10 C.9), so this is the only way to ask "what did document X post?" — + // needed by any screen that reports on a document's costed movements. + if (!string.IsNullOrWhiteSpace(sourceDocType)) q = q.Where(l => l.SourceDocType == sourceDocType); + if (sourceDocId is not null) q = q.Where(l => l.SourceDocId == sourceDocId); var total = await q.CountAsync(ct); var rows = await q.OrderByDescending(l => l.LedgerId) diff --git a/Backend/ERPCore/Services/TransferService.cs b/Backend/ERPCore/Services/TransferService.cs index 6429811..a3b4ce1 100644 --- a/Backend/ERPCore/Services/TransferService.cs +++ b/Backend/ERPCore/Services/TransferService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -40,6 +41,31 @@ public sealed class TransferService : ITransferService _uow = uow; } + public async Task> ListAsync( + PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default) + { + var q = _transfers.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(t => EF.Functions.ILike(t.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(t => t.Status == status); + if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId); + if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(t => t.TransferId) + .Skip(query.Skip).Take(query.PageSize) + .Select(t => new TransferSummaryDto( + t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, + t.CreatedBy, t.CreatedAt, t.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task GetAsync(int transferId, CancellationToken ct = default) { var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines) @@ -190,7 +216,7 @@ public sealed class TransferService : ITransferService } private static TransferDto Map(StockTransfer t) => new( - t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, + t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, t.CreatedBy, t.CreatedAt, t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto( l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList()); } diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index e1c80ae..b9baf5f 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -59,7 +59,18 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [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. -> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly. +> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly. **Consequence surfaced 2026-07-17:** no UI can show "invited but not yet quoted" — the RFQ screens now report quotations received instead. Persisting the invite list would need a new table. + +> ### 2026-07-17 — read endpoints added so the frontend could be connected +> The frontend rewire (see `Frontend/PROGRESS.md`) needed reads that did not exist. `stock-adjustments` and `purchase-returns` had **no GET at all** — a UI could not re-display a record it had just created. +> - **New:** `GET /grns`, `GET /rfqs`, `GET /stock-transfers`, `GET /stock-counts`, `GET /stock-adjustments` **+ `/{id}`**, `GET /purchase-returns` **+ `/{id}`**, `GET /stock/on-hand/list`. All follow `ItemService.ListAsync` (ILike on `q`, filters, `PagedResponse.Create`) with matching `*SummaryDto`s carrying a `lineCount`. +> - **`GET /stock/on-hand/list`** is deliberately set-based — four grouped queries regardless of page size — rather than calling `GetOnHandAsync` per row (N+1). It replaces a client-side loop the mock used to do. +> - **`GET /stock/ledger` gained `sourceDocType`/`sourceDocId`.** The ledger's document reference is polymorphic with no FK to follow, so this is the only way to ask "what did document X post?". Needed by the wastage report to cost its lines; also useful for any document's movement history. +> - **`ItemDetailDto` gained `conversions`** (+ `.Include(i => i.UomConversions)`): they could only be *written* (`PUT /items/{id}/uom-conversions` returns them; nothing read them back), so the item detail screen could never show current state before editing. Closes a deviation `Frontend/PROGRESS.md` had flagged. +> - **DTOs gained fields the entities already had** and the UI needed: `createdBy`/`createdAt` on transfers + counts, `createdAt` on purchase returns, `lineCount` + a `status` filter on requisitions. Cheaper and more honest than deleting working columns from the screens. +> - **Bug fixed — `POST /auth/logout` made `userId` optional.** AuthHex returns `user.userId: null` on login, so a browser could never supply the id the endpoint required; the call was skipped and the session cookies survived, making logout cosmetic. The controller now resolves the id from the token's `UserId` claim and **always** clears the cookies, even if the upstream revoke fails. Found by driving the real logout in a browser. +> - **Verified:** `dotnet build` clean; every new endpoint returns a correct `PagedResponse` against a live cookie session; `conversions` round-trips; `CONFIG_DISABLED` (422), `CONCURRENCY_CONFLICT` (412) and the cross-FK 422 (*"Subcategory 5 belongs to category 10, not 11"*) all confirmed through the browser. Test data removed afterwards. +> - **Not done — serial numbers (FR-GRN-04, priority M):** `CreateGrnLineInput` carries `batch` but has no serial field, so serials cannot be captured on receipt as the requirement mandates. The frontend does **not** collect them rather than silently discarding them. `SERIAL`/`StockLayer.serial_id` already exist in the model, so this is a service+DTO gap, not a schema one. Recorded in docs/11 §4. ## 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. @@ -173,8 +184,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Not done this pass (tracked as follow-ups, not silently skipped):** CORS (needed once a browser frontend calls cross-origin — `docs/02-SECURITY.md §B.2` left unticked), rate limiting on the anonymous endpoints (`docs/02-SECURITY.md` AR-08), and the frontend wiring itself (`lib/api/auth.ts` + wiring `app/login/**`'s mock pages to these endpoints) — deliberately out of scope per user decision. - **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register` → `200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN` → `403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed. - **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing. -- **Login/refresh/logout left unverified live** — `loginUser` currently fails with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` on AuthHex **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — this is a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. Blocked on an AuthHex-side fix; re-run the register→login→cookie→refresh→logout pass once that's resolved. - - **ROOT CAUSE FOUND (2026-07-16), in `ERP_Auth_Service` — two independent bugs, both one-liners. Not fixed here: different project, outside this repo's scope.** Re-confirmed the failure against a user registered this session (`smoketest_admin`/`SMOKE001`), by username *and* email, with *and* without `userTypeId`. - 1. **The password is never stored.** `Services/UserManager/UserManagerService.cs:97` computes `var PasswordHash = PasswordHasher.Hash(...)`, but the assignment in the `new User { … }` initializer at **line 116 is commented out** (`//PasswordHash = PasswordHash`). Every registered user lands in MySQL with a null `PasswordHash`, so `loginUser`'s `if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...))` (line 230) always throws `"Invalid credentials"`. Uncommenting line 116 should fix login outright. Note existing users are unrecoverable — their hashes were never written — so they need re-registration or a password reset (`ChangeUserPassword`/`UpdateUser` do persist the hash correctly, and `UpdateUser` even handles the null-hash case at line 836). +- **✅ RESOLVED 2026-07-17 — login works.** The AuthHex fix below was applied (`ERP_Auth_Service`, uncommenting the `PasswordHash` assignment) and verified: `POST /api/v1/auth/login` now returns **200 + `Set-Cookie: erp_at`** for a freshly-registered user, where it previously returned `500 "Invalid credentials"`. This unblocked the §1–§5 live verification that had been pending for two sessions. **Users registered before the fix have a null hash and can never log in** — they must be re-registered (the session's `smoketest_admin` among them). +- **Historical:** `loginUser` used to fail with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. + - **ROOT CAUSE (2026-07-16), in `ERP_Auth_Service` — two independent bugs, both one-liners.** Bug 1 fixed 2026-07-17; bug 2 left alone (out of scope, and email login is what the UI uses). Re-confirmed the failure against a user registered this session (`smoketest_admin`/`SMOKE001`), by username *and* email, with *and* without `userTypeId`. + 1. **The password was never stored — FIXED 2026-07-17.** `Services/UserManager/UserManagerService.cs:97` computes `var PasswordHash = PasswordHasher.Hash(...)`, but the assignment in the `new User { … }` initializer at **line 116 was commented out** (`//PasswordHash = PasswordHash`). Every registered user landed in MySQL with a null `PasswordHash`, so `loginUser`'s `if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...))` (line 230) always threw `"Invalid credentials"`. **Uncommenting that line fixed login outright** — verified end-to-end. Pre-fix users are unrecoverable (their hashes were never written) and need re-registration or a password reset (`ChangeUserPassword`/`UpdateUser` do persist the hash correctly, and `UpdateUser` even handles the null-hash case at line 836). 2. **Username is not a valid login identifier.** `Repos/UserManageRepository.cs:46` `GetUserByIdentifierAndType` matches only `Email`/`MobileNumber`/`Nic` — **not `UserName`** — and ignores its `userTypeId` argument entirely (that filtering sits commented out at lines 56–65, so the "AndType" half of the method name is currently a lie). Even with bug 1 fixed, `identifier: ""` will not resolve a user; only email/mobile/NIC will. - **Workaround meanwhile: `POST /api/v1/auth/register` issues a working `erp_at` session cookie directly**, which authenticates every v1 controller via the handler's cookie fallback. That is how this session's Master-Data smoke test (§1) was run — no login needed. diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 709ce4a..859af32 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -5,20 +5,23 @@ Spec: `docs/20-FRONTEND.md` (flows + rules) · `docs/11-BACKEND-PHASE1.md` (API Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation. ## 0. Foundation -- [x] `NEXT_PUBLIC_API_BASE_URL` wired (`.env.local` / `.env.local.example`, alongside the existing `NEXT_PUBLIC_AUTH_API_BASE_URL`) — currently unused now that the fetch client is gone (see 2026-07-15 note below) -- [ ] Typed API client / fetch wrapper — **removed 2026-07-15** (`lib/api-client.ts` + `lib/auth-token.ts` deleted per explicit user request: "delete all api connections"). No live code path in the frontend makes an HTTP call to any backend anymore — every screen is 100% mock data (`lib/api/mock-data.ts`). -- [x] Shared TS types mirroring API DTOs (`types/common.ts`, `types/master-data.ts`, `types/procurement.ts`, `types/grn.ts`) — built only for the subset GRN needs (Item/Warehouse/Bin/Vendor/Uom/PurchaseOrder/Grn); `types/common.ts` now also carries `ApiResult` (moved here 2026-07-15 when `lib/api-client.ts` was deleted, since it's a plain data envelope, not fetch-specific) +- [x] **Transport: same-origin Next `rewrites()` proxy** (`next.config.ts`, `/api/*` → `BACKEND_ORIGIN`, default `http://localhost:5224`). `BACKEND_ORIGIN` in `.env.local` / `.env.local.example` — **not** `NEXT_PUBLIC_*`; the browser never sees the backend URL. Chosen over backend CORS because it makes the API same-origin, so CORS and the Secure/SameSite=Strict session cookies stop being problems at all. (`.gitignore`'s `.env*` was silently swallowing the example file — added a `!.env.local.example` negation.) +- [x] **Typed API client rebuilt** (`lib/api-client.ts`, 2026-07-17) — recovered the pre-deletion version from git (`0e4bcf1^`) and adapted: relative `/api/v1` base, **`credentials: "include"`** (never present before), `ApiResult`/`ProblemDetails` imported from `@/types/common` rather than redeclared, `readCsrfToken()` for the eight `[ValidateCsrf]` auth actions. `ApiError`, `apiRequest`, `apiRequestWithETag`, `buildQuery`, `ifMatch`/`idempotencyKey` all carried over. +- [x] **Route guard** (`proxy.ts` — Next 16's rename of `middleware.ts`; the old name still works but warns). Redirects `/dashboard/*` to `/login?next=…` when the `erp_at` cookie is absent. **Presence check only** — the cookie is httpOnly and the JWT is RS256, so the edge cannot validate it; the API stays the authority. +- [x] **Auth** (`lib/api/auth.ts`, `lib/auth-session.ts`, `types/auth.ts`) — real login/logout. No token is stored: the session is httpOnly cookies. `lib/auth-session.ts` caches the user *profile* in localStorage for the Header, because there is no `GET /auth/me` and the user object only arrives in the login response. It is display data, not a credential. +- [x] Shared TS types mirroring API DTOs (`types/{common,master-data,procurement,grn,stock,auth}.ts`) — **reconciled field-by-field against the live schemas 2026-07-17**; see the entry below for what had drifted. - [~] Client validation helpers (`lib/validations/grn.ts`) — **deviation**: uses `zod` (already a project dependency, used by `lib/validations.ts`/login), not hand-rolled, to stay consistent with the codebase's existing pattern rather than introduce a second validation approach. UX-only; server remains authoritative (docs/20-FRONTEND.md §3) -- [x] `code → message` map (`lib/error-map.ts`) — `errorMessage`/`fieldErrors` duck-type any `{ code, detail, errors }`-shaped rejection (the `ApiError` class they used to check via `instanceof` no longer exists); this also fixed a latent bug where the mock layer's plain `Error`-plus-`.code` rejects never matched the old `instanceof ApiError` check, so `CODE_MESSAGES` silently never applied to any mock error - -> **Scope note:** this foundation was built alongside the GRN feature and only covers the endpoints GRN consumes (items, warehouses/bins, vendors, uoms, purchase-orders, grns). Other master-data/procurement API methods still need their own `lib/api/*.ts` files when those screens are built. +- [x] `code → message` map (`lib/error-map.ts`) — `errorMessage`/`fieldErrors` duck-type any `{ code, detail, errors }`-shaped rejection. **Fixed 2026-07-17:** generic framework codes (`conflict`/`not_found`/`validation_error`) were shadowing the server's specific `detail`, so a duplicate brand showed "This action conflicts with the record's current state." instead of "A brand named 'bosch' already exists." Generic codes now lose to `detail`; specific domain codes still win. +> **⚠️ The 2026-07-15 note below is HISTORY, not current state.** The fetch infrastructure was rebuilt on 2026-07-17 and `lib/api/mock-data.ts` is deleted — see "2026-07-17 — connected to the real API" at the bottom of this file. Its parting advice (re-verify the RFQ contract; expect to rebuild the client from scratch) was followed and proved correct. +> > **2026-07-15 — fetch infrastructure deleted outright (not just reverted to mock).** Following an earlier same-week pass that wired every `lib/api/*.ts` module to real `fetch` calls (then reverted via `git revert --no-commit` at the user's request — see `Backend`-adjacent history if relevant later), the user asked to go further and delete the underlying connection mechanism entirely, not just leave it unused. Deleted `lib/api-client.ts` (`apiRequest`/`apiRequestWithETag`/`buildQuery`/`ApiError`) and `lib/auth-token.ts` (bearer-token storage) as files. Follow-on fixes this required: (1) `ApiResult` — used by `items.ts`/`purchase-orders.ts`/`vendors.ts` for their mock ETag pattern — moved into `types/common.ts`; (2) `lib/error-map.ts` rewritten to duck-type instead of `instanceof ApiError`; (3) three detail pages (`vendors/[id]`, `products/[id]`, `procurement/purchase-orders/[id]`) had their `err instanceof ApiError ? err.code : (err as {code?:string})?.code` conflict-detection simplified to the duck-typed form only. Also stripped the now-dangling commented-out "real implementation" blocks (`// import { apiRequest... } from "@/lib/api-client"` etc.) from all 15 `lib/api/*.ts` files, since they referenced a now-deleted module. `tsc --noEmit`/`eslint` clean (same pre-existing `login/page.tsx` error and established `set-state-in-effect` pattern only — confirmed unchanged by this pass). > > **If real backend integration is attempted again**, note two things found during the reverted pass: the RFQ backend contract had drifted from this file's assumed shapes (`Backend/ERPCore/Dtos/Procurement/RfqDtos.cs`/`RfqService.cs` — no persisted invited-vendor list, `requisitionId` required on create, different comparison DTO field names) — re-verify against the actual backend rather than trusting old assumptions; and a typed fetch client + ETag/error-normalization layer will need to be rebuilt from scratch since `lib/api-client.ts`/`lib/auth-token.ts` no longer exist. ## 1. Auth -- [~] Login screen — UI built (`app/login`); not yet wired to `POST /auth/login` / token storage +- [x] Login screen — **wired 2026-07-17** to `POST /auth/login`. Previously it `console.log`'d the plaintext password and pushed to `/dashboard` unconditionally; any schema-valid input "logged in". Now: real call, session cookies, server errors surfaced, `?next=` honoured (same-origin paths only — an absolute URL there would be an open redirect). +- [x] Route guard (`proxy.ts`) + real logout in `components/Layouts/Header.tsx` — the Header no longer hardcodes `john52martinez@gmail.com`, and "Log out" is a real `POST /auth/logout` rather than a ``. - [~] Forgot password — add email screen — UI built (`app/login/forgot`); not yet wired to API - [~] Forgot password — verify OTP screen — UI built (`app/login/forgot/otp`); not yet wired to API - [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API @@ -46,7 +49,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] 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` -> **`[~]` not `[x]`, by design:** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend exists yet** (this was frontend-only work; see the deviation below). +> **⚠️ The two notes below are HISTORY (2026-07-13).** The GRN backend exists and these screens call it as of 2026-07-17; `GET /grns` + `GET /grns/{id}` are real, and GRN edit/delete were removed because the API has no `PUT`/`DELETE`. The FIFO engine they describe as living in `mock-data.ts` is deleted — the server owns it. +> +> **`[~]` not `[x]`, by design (at the time):** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend existed yet** (this was frontend-only work; see the deviation below). > > **UI-only / mock-data mode (temporary):** `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` currently return **in-memory sample data** (`lib/api/mock-data.ts`) instead of calling the real API, so the three screens are fully browsable/demoable (list → create against a mocked PO or as a direct receipt → detail → confirm → release/reject) without a running backend. Each `lib/api/*.ts` file keeps the real `fetch`-based implementation **commented out directly above** the mock block — switch back by deleting the mock block, uncommenting the real block, and deleting `lib/api/mock-data.ts` once the GRN backend exists. `npm run dev` + `tsc --noEmit` + `eslint` are clean (aside from the pre-existing, unrelated `app/login/page.tsx` resolver-typing error and the two `react-hooks/set-state-in-effect` warnings shared with `hooks/use-mobile.ts`). > @@ -64,7 +69,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Wastage (`.../stock/wastage` report, `/new` record) — **not a documented endpoint or SRS document type**: damage/theft-loss/expiry write-offs are modeled as Stock Adjustments with a loss-type reason code (FR-STK-07); `lib/api/wastage.ts` is a frontend-only lens over `stockAdjustmentsApi` + the shared mock ledger (filters to reason codes `DMG`/`LOSS`/`EXPWO`, flattens adjustment lines to per-item wastage records, sums matching outbound ledger entries for cost). No new backend concept — confirmed with the user before building (asked whether "Wastage" meant this vs. a distinct document type). - Sidebar: added "Stock" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/stock`; top-bar titles mapped per route (`components/Layouts/Header.tsx`) -> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4). +> **⚠️ HISTORY (2026-07-13).** The Stock Core backend exists and these screens call it as of 2026-07-17; the list endpoints assumed here (`GET /stock-transfers`, `/stock-adjustments`, `/stock-counts`, on-hand list) were all added for real. The in-memory Stock Core described below is deleted. +> +> **`[~]` not `[x]`, by design (at the time) — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4). > > **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience that iterates known item/warehouse pairs and calls the (documented) on-hand computation per pair; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions). > @@ -83,6 +90,40 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## Done +### 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. + +**Transport + auth** +- Same-origin **Next `rewrites()` proxy** rather than backend CORS (see §0). The backend has no CORS and now needs none. +- Rebuilt `lib/api-client.ts` from `git show 0e4bcf1^`; added `credentials: "include"`. +- New `proxy.ts` route guard, `lib/api/auth.ts`, `lib/auth-session.ts`, `types/auth.ts`. Login/logout are real. +- **Fixed the long-standing `app/login/page.tsx` resolver type error** — `lib/validations.ts` used `z.preprocess`, which widens the schema's *input* type to `unknown`, so `zodResolver` produced a `Resolver<{email: unknown}>` that could not satisfy `useForm`. Form fields always yield strings (RHF defaults them to `""`), so the null-coercion it guarded against cannot happen. **`tsc --noEmit` is now fully clean** — the first time in this file's history. + +**Two real bugs found by driving the browser (both fixed, both invisible to unit-level checks)** +1. **Logout didn't log you out.** AuthHex returns `user.userId: null` on login, so the Header could not supply the `userId` that `POST /auth/logout` required; the call was skipped and `erp_at` survived. Fixed backend-side (`userId` optional, resolved from the token claim, cookies always cleared). Verified: cookies now `[]` after logout. +2. **Generic error codes shadowed the server's message.** `errorMessage()` checked `CODE_MESSAGES[code]` before `detail`, so a duplicate brand showed "This action conflicts with the record's current state." instead of "A brand named 'bosch' already exists." Generic codes (`conflict`/`not_found`/`validation_error`) now lose to `detail`. + +**Contract drift reconciled** (types were rewritten field-by-field against the live OpenAPI, not assumed): +- `itemType` → `stockNature`; `ItemType` is now the Color/Size master. `variants.ts` → `item-types.ts`; the screen moved to `/dashboard/products/item-types`. +- `RfqComparison` was `{lines[].cells[]}` in this app but `{rows[].quotes[]}` on the server, and cells carry `quotationId`. `Rfq` has no `vendorIds`/`createdAt`; `StockTransfer`/`StockCount` had `createdBy`/`createdAt` the DTOs never returned (added server-side rather than dropping the columns); `ReasonCodeContext` had `"CountVariance"` where the server says `"Count"`; `EnterCounts` returns the whole `CountDto`, not `{lines}`; `PostCountResponse.adjustmentId` is nullable; `createReorderRequisition` returns a full `Requisition`, not `{qty}`. +- `remove()` → `updateStatus(id, "Inactive")` on brands/categories/item-types, each with a Status column and Deactivate/Activate (no `DELETE` exists — FR-MD-08). +- New `app/dashboard/products/categories/[id]` for subcategories (their own resource now); new `app/dashboard/products/settings` for Product Configuration (added the shadcn `switch` primitive via the CLI). + +**Features deliberately removed rather than left lying** +- **`initialQty`** and the builder's Quantity column — no such field on the Item contract and no initial-receipt flow; stock arrives via a GRN. It never worked under the mock either. +- **GRN edit/delete** + the `grn/[id]/edit` route — the API has no `PUT`/`DELETE` for a GRN (FR-X-05). +- **RFQ "vendors invited"** — not persisted server-side. The screens show quotations received; the quote form offers any active vendor instead of "invited but pending". +- **Serial capture on GRN** — `CreateGrnLineInput` has no serial field despite FR-GRN-04 (priority M). Not collected rather than silently discarded. Flagged in `Backend/PROGRESS.md` + docs/11 §4. + +**Fixed while rewiring:** the builder hardcoded `baseUomId: 1`, which only worked because the mock seeded that id — against a real DB it is a 422 or, worse, the wrong unit. It now adopts the first real UOM and says so when none exists. The per-row create loop still has no transaction, but the error now reports how many items landed before the failure instead of implying none did. + +**Verified end-to-end in a real browser (Playwright), not just typechecked** — 17/17 then 9/9 on a recheck: guard redirect + `?next=` round-trip; login → cookies (`erp_at` httpOnly) → real user in Header; brand created via the UI; **duplicate → server 409 with its own message**; product-config screen reads the singleton; item-types shows the seeded Color/Size; logout clears cookies. Plus, through the page's own session: **cross-FK guard 422** (*"Subcategory 5 belongs to category 10, not 11"*), item created with **both** `categoryId` and `subCategoryId` + `brandId`, `conversions` present on the detail, **`CONFIG_DISABLED` 422** with the same item succeeding without the gated field and pre-existing items still readable, and a stale `If-Match` → **412 `CONCURRENCY_CONFLICT`**. Test data was removed afterwards; the dev DB is back to empty masters. + +> **The DB is near-empty and that is now visible.** The mock silently supplied warehouses, UOMs, reason codes and sample POs. Screens are blank until that data is created — correct behaviour, not a regression, but a dev seed would make the app pleasant to open. `lib/api/mock-data.ts`'s FIFO engine (`receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) is gone with it: **the browser no longer does inventory maths** — the server does. +> +> **Not yet exercised against real data:** GRN confirm → Stock Enquiry/Ledger/Valuation, transfers, counts and the wastage report. They compile and are wired, but proving the FIFO chain needs a warehouse + PO + receipt seeded first. That is the highest-value next verification. + ### 2026-07-13 — GRN screens + frontend foundation (frontend-only; no backend changes) - Foundation: `lib/api-client.ts`, `lib/error-map.ts`, `lib/auth-token.ts`, `types/{common,master-data,procurement,grn}.ts`, `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` — scoped to what the GRN flow needs, not the full API surface. - Added the shadcn `select` primitive (`npx shadcn add select`) — wasn't in `components/ui/` yet; needed for PO/vendor/warehouse/bin/item/hold-status pickers. @@ -123,7 +164,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Wiring, not just new screens:** `stockApi.createReorderRequisition` (Stock → Reorder Alerts, built in an earlier session) previously fabricated a response with no backing record; it now pushes a real row into `mockRequisitions`, so a reorder-triggered requisition genuinely shows up in the new Requisitions list — same "connect the mock modules together" posture as GRN confirm → Stock Core. - Screens: Requisition (list/new/detail+Submit), RFQ (list/new with checkbox vendor multi-invite reusing the Stock Count's `Checkbox` pattern/new with quotation-recording form + vendor-by-line comparison table/"Create PO from vendor"), Purchase Order (list/new — accepts `?requisitionId=` or `?rfqId=&vendorId=` to prefill lines and pricing/detail with inline edit-while-open using the vendor `[id]` page's ETag+412-conflict-banner pattern, plus a Cancel-with-reason flow client-disabled when any line has receipts), Purchase Return (list/new — pick a Confirmed/Closed GRN, checkbox+qty its lines; also reachable via a new "Create Return" button next to `Rejected` lines on the GRN detail page, matching the SRS flow diagram's Reject→Purchase Return step). New `components/procurement/status-badges.tsx` (same fixed-width badge convention as Receiving/Stock) and `lib/validations/procurement.ts` (zod-free hand-rolled, matching the GRN validation file's style, not its `zod` deviation). - Sidebar: added "Procurement" nav entry between Vendors and Receiving; header title mappings added for all new routes. -- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend exists (`Backend/PROGRESS.md` §2 unchanged). +- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend existed at the time (`Backend/PROGRESS.md` §2 unchanged). **Superseded 2026-07-17** — the Procurement backend exists and these screens now call it; several assumed shapes turned out wrong (see the 2026-07-17 entry). - Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from the same established `set-state-in-effect` pattern used throughout this session (confirmed it also fires on the pre-existing `grn/page.tsx`/`vendors/page.tsx`/`hooks/use-mobile.ts` — not a regression); `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure blocks the full build, unrelated). All 12 new routes plus the GRN-detail "Create Return" link (including three query-param-prefilled variants) confirmed rendering 200 with no error boundary via SSR against the dev server. ### 2026-07-13 — Master Data screens: Items, UOM, Categories, Reorder settings (frontend-only; no backend changes) diff --git a/Frontend/erp-system/.env.local.example b/Frontend/erp-system/.env.local.example new file mode 100644 index 0000000..7c31eb9 --- /dev/null +++ b/Frontend/erp-system/.env.local.example @@ -0,0 +1,7 @@ +# Origin of the ERPCore backend. Used ONLY by the Next rewrite proxy in next.config.ts +# (server-side), so it is intentionally not NEXT_PUBLIC_* — the browser never sees it and +# only ever calls this Next server at same-origin /api/v1. +# +# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the +# proxy will refuse. +BACKEND_ORIGIN=http://localhost:5224 diff --git a/Frontend/erp-system/.gitignore b/Frontend/erp-system/.gitignore index 5ef6a52..2b411dd 100644 --- a/Frontend/erp-system/.gitignore +++ b/Frontend/erp-system/.gitignore @@ -32,6 +32,8 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# ...except the template, which carries no secrets and documents what to set. +!.env.local.example # vercel .vercel 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 06d58ae..c7e6592 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 @@ -115,7 +115,9 @@ function NewPurchaseOrderContent() { setVendorId(rfqVendorId) setLines( rfq.lines.map((l): DraftLine => { - const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId) + const cell = comparison.rows + .find((row) => row.itemId === l.itemId) + ?.quotes.find((q) => q.vendorId === rfqVendorId) return { key: newKey(), itemId: l.itemId, diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx index a60d775..ea3de47 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx @@ -69,11 +69,22 @@ export default function RfqDetailPage() { const quotedVendorIds = useMemo(() => { const set = new Set() - for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId) + for (const row of comparison?.rows ?? []) for (const quote of row.quotes) set.add(quote.vendorId) return set }, [comparison]) - const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds]) + /** + * Vendors still available to quote. + * + * This used to be "invited but not yet quoted", but the invited list does not survive: + * `POST /rfqs` validates `vendorIds` and then discards them — there is no RFQ↔vendor + * link in the model (docs/11 §3.2). So any active vendor may be quoted here, and the + * comparison's columns come from who actually quoted rather than who was asked. + */ + const pendingVendors = useMemo( + () => vendors.filter((v) => v.status === "Active" && !quotedVendorIds.has(v.vendorId)).map((v) => v.vendorId), + [vendors, quotedVendorIds], + ) function itemFor(itemId: number) { return items.find((i) => i.itemId === itemId) @@ -155,9 +166,9 @@ export default function RfqDetailPage() {

{rfq.docNo}

+ {/* No "Invited: …" — the invited-vendor list is not persisted (docs/11 §3.2). */}

- {rfq.requisitionId ? `From Requisition #${rfq.requisitionId} — ` : ""} - Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")} + {rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}

@@ -191,7 +202,7 @@ export default function RfqDetailPage() {

Vendor comparison

- {comparison.lines.every((l) => l.cells.length === 0) ? ( + {comparison.rows.every((r) => r.quotes.length === 0) ? (

No quotations recorded yet.

) : (
@@ -199,19 +210,20 @@ export default function RfqDetailPage() { Item - {rfq.vendorIds.map((vid) => ( + {/* Columns are the vendors that actually quoted — the server computes this. */} + {comparison.vendorIds.map((vid) => ( {vendorFor(vid)?.code ?? `#${vid}`} ))} - {comparison.lines.map((line) => { + {comparison.rows.map((line) => { const item = itemFor(line.itemId) return ( {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} - {rfq.vendorIds.map((vid) => { - const cell = line.cells.find((c) => c.vendorId === vid) + {comparison.vendorIds.map((vid) => { + const cell = line.quotes.find((c) => c.vendorId === vid) return ( {cell ? ( @@ -256,7 +268,7 @@ export default function RfqDetailPage() { value={quoteVendorId} onValueChange={selectQuoteVendor}> - + {pendingVendors.map((vid) => ( diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx index ee05136..cfb8bcb 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx @@ -99,6 +99,12 @@ function NewRfqContent() { setHeaderError(null) setSubmitError(null) + // The server requires a requisition — an RFQ is always raised against one + // (docs/11 §3.2). Catch it here rather than letting the POST 400. + if (requisitionId === null) { + setHeaderError("Select the requisition this RFQ is raised against.") + return + } if (vendorIds.size === 0) { setHeaderError("Select at least one vendor to invite.") return diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx index 6cc1f9c..b4d9e79 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx @@ -5,10 +5,8 @@ import Link from "next/link" import { FileText, Plus } from "lucide-react" import { rfqsApi } from "@/lib/api/rfqs" -import { vendorsApi } from "@/lib/api/vendors" import { errorMessage } from "@/lib/error-map" import { RfqSummary } from "@/types/procurement" -import { Vendor } from "@/types/master-data" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" @@ -17,22 +15,17 @@ import { RfqStatusBadge } from "@/components/procurement/status-badges" export default function RfqsListPage() { const [rfqs, setRfqs] = useState(null) - const [vendors, setVendors] = useState([]) const [error, setError] = useState(null) + // Vendors are no longer fetched here: the "invited vendors" column is gone because that + // list is not persisted (docs/11 §3.2), so there is nothing to resolve names for. useEffect(() => { - Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })]) - .then(([r, v]) => { - setRfqs(r.items) - setVendors(v.items) - }) + rfqsApi + .list() + .then((r) => setRfqs(r.items)) .catch((err) => setError(errorMessage(err))) }, []) - function vendorNames(vendorIds: number[]) { - return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ") - } - return (
@@ -75,9 +68,11 @@ export default function RfqsListPage() { Doc No Requisition - Vendors invited + {/* "Vendors invited" is gone: the invite list is validated on create but not + persisted (docs/11 §3.2). Quotations received is the fact that survives. */} + Lines + Quotations Status - Created @@ -89,11 +84,11 @@ export default function RfqsListPage() { {r.requisitionId ? `#${r.requisitionId}` : } - {vendorNames(r.vendorIds)} + {r.lineCount} + {r.quotationCount} - {new Date(r.createdAt).toLocaleString()} ))} diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index 94cd29d..bc2eeff 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -13,7 +13,7 @@ import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage, fieldErrors } from "@/lib/error-map" import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data" +import { Item, ItemReorderSetting, StockNature, TrackingMode, UomConversion } from "@/types/master-data" import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" @@ -63,9 +63,13 @@ export default function ItemDetailPage() { const [name, setName] = useState("") const [description, setDescription] = useState("") const [categoryId, setCategoryId] = useState(null) + // Carried through edits so a save doesn't silently drop the item's subcategory/brand. + // Not editable here — they are chosen on the create screen's builder. + const [subCategoryId, setSubCategoryId] = useState(null) + const [brandId, setBrandId] = useState(null) const [baseUomId, setBaseUomId] = useState(null) const [defaultVendorId, setDefaultVendorId] = useState(null) - const [itemType, setItemType] = useState("Stocked") + const [stockNature, setStockNature] = useState("Stocked") const [trackingMode, setTrackingMode] = useState("None") const [taxClass, setTaxClass] = useState("") @@ -93,9 +97,11 @@ export default function ItemDetailPage() { setName(data.name) setDescription(data.description ?? "") setCategoryId(data.categoryId) + setSubCategoryId(data.subCategoryId) + setBrandId(data.brandId) setBaseUomId(data.baseUomId) setDefaultVendorId(data.defaultVendorId) - setItemType(data.itemType) + setStockNature(data.stockNature) setTrackingMode(data.trackingMode) setTaxClass(data.taxClass ?? "") setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) }))) @@ -139,7 +145,7 @@ export default function ItemDetailPage() { try { const result = await itemsApi.update( item.itemId, - { sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null }, + { sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, taxClass: taxClass || null }, etag ) applyItem(result.data) @@ -389,8 +395,10 @@ export default function ItemDetailPage() { setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
- - value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}> + {/* "Item type" now means a Color/Size dimension master — this field is the + stock-nature one it used to be confused with (docs/11 §8). */} + + value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}> diff --git a/Frontend/erp-system/app/dashboard/products/brands/page.tsx b/Frontend/erp-system/app/dashboard/products/brands/page.tsx index df1d92f..0ad8c32 100644 --- a/Frontend/erp-system/app/dashboard/products/brands/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/brands/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag, Trash2 } from "lucide-react" +import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" import { brandsApi } from "@/lib/api/brands" import { errorMessage } from "@/lib/error-map" @@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common" import { Brand } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" @@ -54,7 +55,7 @@ export default function BrandsPage() { function load() { setError(null) brandsApi - .list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE }) + .list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE }) .then((res) => { setBrands(res.items) setPagination(res.pagination) @@ -87,10 +88,16 @@ export default function BrandsPage() { setSubmitting(true) try { - const brand = editing - ? await brandsApi.update(editing.brandId, { name }) - : await brandsApi.create({ name }) - toast.success(editing ? "Brand updated" : "Brand created", brand.name) + let result + if (editing) { + // The list response carries no ETag, so re-read to get a fresh If-Match token + // rather than guessing one. A concurrent edit surfaces as 412 from the server. + const current = await brandsApi.get(editing.brandId) + result = await brandsApi.update(editing.brandId, { name }, current.etag ?? "") + } else { + result = await brandsApi.create({ name }) + } + toast.success(editing ? "Brand updated" : "Brand created", result.data.name) setOpen(false) setName("") setEditing(null) @@ -104,14 +111,16 @@ export default function BrandsPage() { } } - async function handleDelete(brand: Brand) { + /** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */ + async function handleToggleStatus(brand: Brand) { + const next = brand.status === "Active" ? "Inactive" : "Active" setDeletingId(brand.brandId) try { - await brandsApi.remove(brand.brandId) - toast.success("Brand deleted", brand.name) + await brandsApi.updateStatus(brand.brandId, next) + toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name) load() } catch (err) { - toast.error("Could not delete brand", errorMessage(err)) + toast.error("Could not update brand status", errorMessage(err)) } finally { setDeletingId(null) } @@ -206,6 +215,7 @@ export default function BrandsPage() { ID Name + Status Created At Actions @@ -215,6 +225,9 @@ export default function BrandsPage() { #{b.brandId} {b.name} + + {b.status} + {new Date(b.createdAt).toLocaleDateString()}
@@ -227,26 +240,32 @@ export default function BrandsPage() { + {/* Deactivate, not delete: the API has no DELETE for any master + (FR-MD-08) — records referenced by transactions must survive. */} } > - + {b.status === "Active" ? : } handleDelete(b)} + variant={b.status === "Active" ? "destructive" : "success"} + title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`} + description={ + b.status === "Active" + ? "The brand stays on existing items but cannot be assigned to new ones." + : "The brand becomes selectable again." + } + confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"} + onConfirm={() => handleToggleStatus(b)} />
diff --git a/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx new file mode 100644 index 0000000..c41f9a9 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx @@ -0,0 +1,233 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { useParams } from "next/navigation" +import { ArrowLeft, Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react" + +import { categoriesApi, subCategoriesApi } from "@/lib/api/categories" +import { errorMessage } from "@/lib/error-map" +import { validateCategoryName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { Category, SubCategory } from "@/types/master-data" + +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +/** + * Subcategories of one category — the single optional level below it (FR-MD-04). + * The hierarchy is exactly two deep, so there is no recursion here by design. + */ +export default function CategorySubCategoriesPage() { + const params = useParams<{ id: string }>() + const categoryId = Number(params.id) + + const [category, setCategory] = useState(null) + const [subCategories, setSubCategories] = useState(null) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [togglingId, setTogglingId] = useState(null) + + function load() { + setError(null) + categoriesApi + .get(categoryId) + .then((res) => setCategory(res.data)) + .catch((err) => setError(errorMessage(err))) + categoriesApi + .listSubCategories(categoryId, { pageSize: 200 }) + .then((res) => setSubCategories(res.items)) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [categoryId]) + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(sub: SubCategory) { + setEditing(sub) + setName(sub.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { + const nextErrors = validateCategoryName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + if (editing) { + // Re-read for a fresh If-Match; a concurrent edit surfaces as 412. + const current = await subCategoriesApi.get(editing.subCategoryId) + await subCategoriesApi.update(editing.subCategoryId, { name }, current.etag ?? "") + } else { + await categoriesApi.createSubCategory(categoryId, { name }) + } + toast.success(editing ? "Subcategory updated" : "Subcategory created", name) + setOpen(false) + setName("") + setEditing(null) + load() + } catch (err) { + setErrors({ name: errorMessage(err) }) + toast.error(editing ? "Could not update subcategory" : "Could not create subcategory", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function handleToggleStatus(sub: SubCategory) { + const next = sub.status === "Active" ? "Inactive" : "Active" + setTogglingId(sub.subCategoryId) + try { + await subCategoriesApi.updateStatus(sub.subCategoryId, next) + toast.success(next === "Inactive" ? "Subcategory deactivated" : "Subcategory activated", sub.name) + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } finally { + setTogglingId(null) + } + } + + return ( +
+
+
+ + + +
+

{category ? `${category.name} — Subcategories` : "Subcategories"}

+

+ The one optional level below a category (FR-MD-04). A subcategory cannot be moved to another category. +

+
+
+ + + New Subcategory} /> + + + {editing ? "Edit subcategory" : "New subcategory"} + Give the subcategory a name. + + + + Name + setName(e.target.value)} placeholder="Hex Bolts" aria-invalid={!!errors.name} /> + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && subCategories === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && subCategories !== null && subCategories.length === 0 && ( +
+ +

No subcategories yet — items can attach straight to the category.

+
+ )} + + {!error && subCategories !== null && subCategories.length > 0 && ( + + + + ID + Name + Status + Created At + Actions + + + + {subCategories.map((s) => ( + + #{s.subCategoryId} + {s.name} + + {s.status} + + {new Date(s.createdAt).toLocaleDateString()} + +
+ + + + + } + > + {s.status === "Active" ? : } + + handleToggleStatus(s)} + /> + +
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx index 18909ec..8c5dac3 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ChevronLeft, ChevronRight, ListTree, Pencil, Plus, Search, Trash2 } from "lucide-react" +import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" import { categoriesApi } from "@/lib/api/categories" import { errorMessage } from "@/lib/error-map" @@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common" import { Category } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" @@ -54,7 +55,7 @@ export default function CategoriesPage() { function load() { setError(null) categoriesApi - .list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE }) + .list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE }) .then((res) => { setCategories(res.items) setPagination(res.pagination) @@ -87,10 +88,15 @@ export default function CategoriesPage() { setSubmitting(true) try { - const category = editing - ? await categoriesApi.update(editing.categoryId, { name }) - : await categoriesApi.create({ name }) - toast.success(editing ? "Category updated" : "Category created", category.name) + let result + if (editing) { + // The list carries no ETag, so re-read for a fresh If-Match rather than guessing. + const current = await categoriesApi.get(editing.categoryId) + result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "") + } else { + result = await categoriesApi.create({ name }) + } + toast.success(editing ? "Category updated" : "Category created", result.data.name) setOpen(false) setName("") setEditing(null) @@ -104,14 +110,16 @@ export default function CategoriesPage() { } } - async function handleDelete(category: Category) { + /** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */ + async function handleToggleStatus(category: Category) { + const next = category.status === "Active" ? "Inactive" : "Active" setDeletingId(category.categoryId) try { - await categoriesApi.remove(category.categoryId) - toast.success("Category deleted", category.name) + await categoriesApi.updateStatus(category.categoryId, next) + toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name) load() } catch (err) { - toast.error("Could not delete category", errorMessage(err)) + toast.error("Could not update category status", errorMessage(err)) } finally { setDeletingId(null) } @@ -206,6 +214,7 @@ export default function CategoriesPage() { ID Name + Status Created At Actions @@ -215,9 +224,21 @@ export default function CategoriesPage() { #{c.categoryId} {c.name} + + {c.status} + {new Date(c.createdAt).toLocaleDateString()}
+ {/* Subcategories are their own resource now, not a nested tree. */} + + + + + {/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */} } > - + {c.status === "Active" ? : } handleDelete(c)} + variant={c.status === "Active" ? "destructive" : "success"} + title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`} + description={ + c.status === "Active" + ? "The category stays on existing items but cannot take new subcategories or items." + : "The category becomes selectable again." + } + confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"} + onConfirm={() => handleToggleStatus(c)} />
diff --git a/Frontend/erp-system/app/dashboard/products/variants/page.tsx b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx similarity index 53% rename from Frontend/erp-system/app/dashboard/products/variants/page.tsx rename to Frontend/erp-system/app/dashboard/products/item-types/page.tsx index 051ab76..71d6d22 100644 --- a/Frontend/erp-system/app/dashboard/products/variants/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx @@ -2,15 +2,16 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, Pencil, Plus, SwatchBook, Trash2 } from "lucide-react" +import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react" -import { variantCategoriesApi } from "@/lib/api/variants" +import { itemTypesApi } from "@/lib/api/item-types" import { errorMessage } from "@/lib/error-map" -import { validateVariantCategoryName } from "@/lib/validations/master-data" +import { validateItemTypeName } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { VariantCategory } from "@/types/master-data" +import { ItemType } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" @@ -19,22 +20,30 @@ import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" -export default function VariantsPage() { - const [categories, setCategories] = useState(null) +/** + * Item Types (docs/11 §2.7) — the dimension names (Color, Size, Material) the item + * builder's checkboxes read. Formerly "Variant Categories" in this app. + * + * These are names only. The values (Red, S, M) live in each item's generated SKU and are + * not stored, so nothing here links to an item — renaming a type leaves existing SKUs + * untouched. + */ +export default function ItemTypesPage() { + const [itemTypes, setItemTypes] = useState(null) const [error, setError] = useState(null) const [open, setOpen] = useState(false) - const [editing, setEditing] = useState(null) + const [editing, setEditing] = useState(null) const [name, setName] = useState("") const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) - const [deletingId, setDeletingId] = useState(null) + const [togglingId, setTogglingId] = useState(null) function load() { setError(null) - variantCategoriesApi - .list() - .then((res) => setCategories(res.items)) + itemTypesApi + .list({ pageSize: 200 }) + .then((res) => setItemTypes(res.items)) .catch((err) => setError(errorMessage(err))) } @@ -47,24 +56,28 @@ export default function VariantsPage() { setOpen(true) } - function openEditDialog(category: VariantCategory) { - setEditing(category) - setName(category.name) + function openEditDialog(itemType: ItemType) { + setEditing(itemType) + setName(itemType.name) setErrors({}) setOpen(true) } async function handleSubmit() { - const nextErrors = validateVariantCategoryName(name) + const nextErrors = validateItemTypeName(name) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) try { - const category = editing - ? await variantCategoriesApi.update(editing.variantCategoryId, { name }) - : await variantCategoriesApi.create({ name }) - toast.success(editing ? "Variant category updated" : "Variant category created", category.name) + if (editing) { + // Re-read for a fresh If-Match; a concurrent edit surfaces as 412. + const current = await itemTypesApi.get(editing.itemTypeId) + await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "") + } else { + await itemTypesApi.create({ name }) + } + toast.success(editing ? "Item type updated" : "Item type created", name) setOpen(false) setName("") setEditing(null) @@ -72,22 +85,24 @@ export default function VariantsPage() { load() } catch (err) { setErrors({ name: errorMessage(err) }) - toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err)) + toast.error(editing ? "Could not update item type" : "Could not create item type", errorMessage(err)) } finally { setSubmitting(false) } } - async function handleDelete(category: VariantCategory) { - setDeletingId(category.variantCategoryId) + /** Deactivate, never delete — the API has no DELETE (FR-MD-08). */ + async function handleToggleStatus(itemType: ItemType) { + const next = itemType.status === "Active" ? "Inactive" : "Active" + setTogglingId(itemType.itemTypeId) try { - await variantCategoriesApi.remove(category.variantCategoryId) - toast.success("Variant category deleted", category.name) + await itemTypesApi.updateStatus(itemType.itemTypeId, next) + toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name) load() } catch (err) { - toast.error("Could not delete category", errorMessage(err)) + toast.error("Could not update status", errorMessage(err)) } finally { - setDeletingId(null) + setTogglingId(null) } } @@ -99,23 +114,25 @@ export default function VariantsPage() {
-

Variants

-

Variant categories used by the item variant builder (e.g. Color, Size, Material).

+

Item Types

+

+ Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU. +

- Add Category} /> + Add Item Type} /> - {editing ? "Edit variant category" : "New variant category"} - Give the category a name. + {editing ? "Edit item type" : "New item type"} + Give the item type a name. - Name + Name setName(e.target.value)} placeholder="Material" @@ -140,7 +157,7 @@ export default function VariantsPage() {
{error}
)} - {!error && categories === null && ( + {!error && itemTypes === null && (
{Array.from({ length: 3 }).map((_, i) => ( @@ -148,37 +165,36 @@ export default function VariantsPage() {
)} - {!error && categories !== null && categories.length === 0 && ( + {!error && itemTypes !== null && itemTypes.length === 0 && (
-

No variant categories yet.

+

No item types yet.

)} - {!error && categories !== null && categories.length > 0 && ( + {!error && itemTypes !== null && itemTypes.length > 0 && ( ID Name + Status Created At Actions - {categories.map((c) => ( - - #{c.variantCategoryId} - {c.name} - {new Date(c.createdAt).toLocaleDateString()} + {itemTypes.map((t) => ( + + #{t.itemTypeId} + {t.name} + + {t.status} + + {new Date(t.createdAt).toLocaleDateString()}
- @@ -188,20 +204,24 @@ export default function VariantsPage() {
diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index a5666c7..bc9acc6 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -8,11 +8,13 @@ import { ArrowLeft, Plus, X } from "lucide-react" import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" import { brandsApi } from "@/lib/api/brands" -import { variantCategoriesApi } from "@/lib/api/variants" +import { itemTypesApi } from "@/lib/api/item-types" +import { productConfig } from "@/lib/api/product-config" +import { uomsApi } from "@/lib/api/uoms" import { errorMessage } from "@/lib/error-map" -import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data" +import { validateItemTypeName, validateVariantItemForm } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { Category, VariantCategory } from "@/types/master-data" +import { Brand, Category, ItemType, ProductConfig, SubCategory } from "@/types/master-data" import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" @@ -34,6 +36,10 @@ function buildVariantSku(categoryLabel: string, values: string[]): string { return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-") } +/** + * Colour is special-cased by name. This stays a frontend concern: item types are names + * only — there is no value table server-side to hang a hex column off (docs/10 Part C.9). + */ function isColorCategory(categoryName: string): boolean { return categoryName.trim().toLowerCase() === "color" } @@ -52,26 +58,31 @@ function partLabel(part: { name: string; value: string }): string { return isColorCategory(part.name) ? decodeColorValue(part.value).name : part.value } -// No Base UOM field on this form — every variant created here uses the base "EA" unit (uomId 1 in the seed data). -const DEFAULT_BASE_UOM_ID = 1 - export default function NewItemPage() { const router = useRouter() const [categories, setCategories] = useState(null) - const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null) - const [variantCategories, setVariantCategories] = useState(null) + const [brands, setBrands] = useState(null) + const [itemTypes, setItemTypes] = useState(null) + const [config, setConfig] = useState(null) + /** + * This form has no Base UOM field by design, so it adopts the first UOM as the base. + * It used to hardcode `uomId: 1`, which only worked because the mock seeded that id — + * against a real database that is a 422 waiting to happen, or worse, silently the wrong + * unit. Null here means "no UOM exists yet" and the form says so rather than guessing. + */ + const [baseUomId, setBaseUomId] = useState(null) const [loadError, setLoadError] = useState(null) const [categoryId, setCategoryId] = useState(null) + const [subCategories, setSubCategories] = useState([]) const [subCategoryId, setSubCategoryId] = useState(null) const [brandId, setBrandId] = useState(null) - const [checkedVariantCategoryIds, setCheckedVariantCategoryIds] = useState([]) + const [checkedItemTypeIds, setCheckedItemTypeIds] = useState([]) const [valuesByCategory, setValuesByCategory] = useState>({}) const [inputByCategory, setInputByCategory] = useState>({}) const [colorNameByCategory, setColorNameByCategory] = useState>({}) - const [quantities, setQuantities] = useState>({}) const [addingCategory, setAddingCategory] = useState(false) const [newCategoryName, setNewCategoryName] = useState("") @@ -83,23 +94,40 @@ export default function NewItemPage() { const [submitting, setSubmitting] = useState(false) useEffect(() => { - Promise.all([categoriesApi.list({ pageSize: 200 }), brandsApi.list({ pageSize: 200 }), variantCategoriesApi.list()]) - .then(([cat, br, vc]) => { + Promise.all([ + categoriesApi.list({ pageSize: 200, status: "Active" }), + brandsApi.list({ pageSize: 200, status: "Active" }), + itemTypesApi.list({ pageSize: 200, status: "Active" }), + productConfig(), + uomsApi.list({ pageSize: 1 }), + ]) + .then(([cat, br, types, cfg, uoms]) => { setCategories(cat.items) setBrands(br.items) - setVariantCategories(vc.items) + setItemTypes(types.items) + setConfig(cfg) + setBaseUomId(uoms.items[0]?.uomId ?? null) }) .catch((err) => setLoadError(errorMessage(err))) }, []) - const topCategories = useMemo(() => (categories ?? []).filter((c) => c.parentId === null), [categories]) - const subCategoryOptions = useMemo( - () => (categories ?? []).filter((c) => c.parentId === categoryId), - [categories, categoryId] - ) - const effectiveCategoryId = subCategoryId ?? categoryId - const effectiveCategoryLabel = - (categories ?? []).find((c) => c.categoryId === effectiveCategoryId)?.name ?? "" + // Subcategories are their own resource now — fetched per category rather than filtered + // out of a flat list by parentId (that column no longer exists). + useEffect(() => { + if (categoryId === null || !config?.subcategoriesEnabled) { + setSubCategories([]) + return + } + categoriesApi + .listSubCategories(categoryId, { pageSize: 200, status: "Active" }) + .then((res) => setSubCategories(res.items)) + .catch(() => setSubCategories([])) + }, [categoryId, config?.subcategoriesEnabled]) + + const categoryLabel = (categories ?? []).find((c) => c.categoryId === categoryId)?.name ?? "" + const subCategoryLabel = subCategories.find((s) => s.subCategoryId === subCategoryId)?.name ?? "" + /** SKU/name read best off the most specific level, but BOTH ids are sent to the server. */ + const effectiveLabel = subCategoryLabel || categoryLabel const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? "" function handleCategoryChange(value: number | null) { @@ -107,28 +135,27 @@ export default function NewItemPage() { setSubCategoryId(null) } - function toggleVariantCategory(variantCategoryId: number) { - setCheckedVariantCategoryIds((prev) => - prev.includes(variantCategoryId) ? prev.filter((id) => id !== variantCategoryId) : [...prev, variantCategoryId] + function toggleItemType(itemTypeId: number) { + setCheckedItemTypeIds((prev) => + prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId] ) - setQuantities({}) } - async function handleAddVariantCategory() { - const nextErrors = validateVariantCategoryName(newCategoryName) + async function handleAddItemType() { + const nextErrors = validateItemTypeName(newCategoryName) if (nextErrors.name) { setNewCategoryError(nextErrors.name) return } setAddingCategorySubmitting(true) try { - const category = await variantCategoriesApi.create({ name: newCategoryName }) - setVariantCategories((prev) => [...(prev ?? []), category]) - setCheckedVariantCategoryIds((prev) => [...prev, category.variantCategoryId]) + const created = await itemTypesApi.create({ name: newCategoryName }) + setItemTypes((prev) => [...(prev ?? []), created.data]) + setCheckedItemTypeIds((prev) => [...prev, created.data.itemTypeId]) setNewCategoryName("") setNewCategoryError(null) setAddingCategory(false) - toast.success("Variant category created", category.name) + toast.success("Item type created", created.data.name) } catch (err) { setNewCategoryError(errorMessage(err)) } finally { @@ -136,34 +163,32 @@ export default function NewItemPage() { } } - function addValue(variantCategoryId: number, overrideValue?: string) { - const value = (overrideValue ?? inputByCategory[variantCategoryId] ?? "").trim() + function addValue(itemTypeId: number, overrideValue?: string) { + const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim() if (value) { setValuesByCategory((prev) => { - const existing = prev[variantCategoryId] ?? [] + const existing = prev[itemTypeId] ?? [] if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev - return { ...prev, [variantCategoryId]: [...existing, value] } + return { ...prev, [itemTypeId]: [...existing, value] } }) - setQuantities({}) } - setInputByCategory((prev) => ({ ...prev, [variantCategoryId]: "" })) + setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" })) } - function removeValue(variantCategoryId: number, value: string) { + function removeValue(itemTypeId: number, value: string) { setValuesByCategory((prev) => ({ ...prev, - [variantCategoryId]: (prev[variantCategoryId] ?? []).filter((v) => v !== value), + [itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value), })) - setQuantities({}) } const activeCategories = useMemo( () => - (variantCategories ?? []) - .filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId)) - .map((vc) => ({ ...vc, values: valuesByCategory[vc.variantCategoryId] ?? [] })) - .filter((vc) => vc.values.length > 0), - [variantCategories, checkedVariantCategoryIds, valuesByCategory] + (itemTypes ?? []) + .filter((t) => checkedItemTypeIds.includes(t.itemTypeId)) + .map((t) => ({ ...t, values: valuesByCategory[t.itemTypeId] ?? [] })) + .filter((t) => t.values.length > 0), + [itemTypes, checkedItemTypeIds, valuesByCategory] ) const variants = useMemo(() => { @@ -183,44 +208,58 @@ export default function NewItemPage() { } return combinations.map((c) => ({ ...c, - sku: buildVariantSku(effectiveCategoryLabel, c.parts.map(partLabel)), + sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)), })) - }, [activeCategories, effectiveCategoryLabel]) + }, [activeCategories, effectiveLabel]) async function handleSubmit() { setSubmitError(null) - const nextErrors = validateVariantItemForm({ categoryId: effectiveCategoryId, hasVariants: variants.length > 0 }) + const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 }) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return + if (baseUomId === null) { + setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.") + return + } setSubmitting(true) + let created = 0 try { - let created = 0 for (const variant of variants) { - const qty = Number(quantities[variant.key] || 0) await itemsApi.create({ sku: variant.sku, - name: `${brandLabel ? brandLabel + " " : ""}${effectiveCategoryLabel} - ${variant.parts.map(partLabel).join("/")}`, - categoryId: effectiveCategoryId as number, + name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`, + // Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the + // category, which lost the parent entirely. The server rejects a mismatched + // pair with 422. + categoryId: categoryId as number, + subCategoryId, brandId, - baseUomId: DEFAULT_BASE_UOM_ID, - itemType: "Stocked", + baseUomId, + stockNature: "Stocked", trackingMode: "None", - initialQty: Number.isFinite(qty) ? qty : 0, }) created += 1 } toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`) router.push("/dashboard/products") } catch (err) { - setSubmitError(errorMessage(err)) - toast.error("Could not create variants", errorMessage(err)) + // Each row is its own POST with no transaction, so a failure partway (e.g. a + // duplicate SKU) leaves the earlier rows created. Say so rather than implying + // nothing happened. + const detail = errorMessage(err) + setSubmitError( + created > 0 + ? `${detail} — ${created} item${created === 1 ? "" : "s"} were already created before this failed.` + : detail, + ) + toast.error("Could not create all variants", detail) } finally { setSubmitting(false) } } - const loading = !categories || !brands || !variantCategories + const loading = !categories || !brands || !itemTypes || !config return (
@@ -230,7 +269,7 @@ export default function NewItemPage() {

New Item

-

Category, subcategory, brand, and variant categories (FR-MD-01).

+

Category, subcategory, brand, and item types (FR-MD-01).

@@ -240,6 +279,16 @@ export default function NewItemPage() { {loading && !loadError && } + {!loading && baseUomId === null && ( +
+ No unit of measure exists yet. Items need a base UOM —{" "} + + create one first + + . +
+ )} + {!loading && ( <>
@@ -250,7 +299,7 @@ export default function NewItemPage() { - {topCategories.map((c) => ( + {(categories ?? []).map((c) => ( {c.name} @@ -259,54 +308,63 @@ export default function NewItemPage() {
-
- - value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}> - - - - - {subCategoryOptions.map((c) => ( - - {c.name} - - ))} - - -
-
- - value={brandId} onValueChange={setBrandId}> - - - - - {(brands ?? []).map((b) => ( - - {b.name} - - ))} - - -
+ {/* Config flags are honoured by hiding the field: sending a gated value would + just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */} + {config?.subcategoriesEnabled && ( +
+ + value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}> + + + + + {subCategories.map((s) => ( + + {s.name} + + ))} + + +
+ )} + {config?.brandsEnabled && ( +
+ + value={brandId} onValueChange={setBrandId}> + + + + + {(brands ?? []).map((b) => ( + + {b.name} + + ))} + + +
+ )} + {/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no + item-type reference), so this section IS the enforcement. */} + {config?.itemTypesEnabled && (
-

Variants

+

Item types

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

- {(variantCategories ?? []).map((vc) => ( -
- - - Item - UOM - Bin - Qty - Unit cost - Hold status - Batch / Serial - - - - - {lines.map((line) => { - const item = itemFor(line.itemId) - const errors = lineErrors[line.key] ?? {} - return ( - - - value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> - - - - - {(items ?? []).map((i) => ( - - {i.sku} — {i.name} - - ))} - - - - - - value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> - - - - - {(uoms ?? []).map((u) => ( - - {u.name} - - ))} - - - - - - value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}> - - - - - {bins.map((b) => ( - - {b.code} - - ))} - - - - - updateLine(line.key, { qty: e.target.value })} - className="h-11 text-base" - /> - - - - updateLine(line.key, { unitCost: e.target.value })} - className="h-11 text-base" - /> - - - - value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}> - - - - - Available - On hold (inspection) - - - - - {item?.trackingMode === "Batch" && ( -
- updateLine(line.key, { batchNo: e.target.value })} - className="h-9 text-sm" - /> - updateLine(line.key, { expiryDate: e.target.value })} - className="h-9 text-sm" - /> - -
- )} - {item?.trackingMode === "Serial" && ( -
-