From 5cf9588728c0d41abc5b1c2701bd8d4b5490fa6e Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Wed, 22 Jul 2026 16:14:49 +0530 Subject: [PATCH 1/9] role code error fixed --- Backend/ERPCore/Services/RoleService.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Backend/ERPCore/Services/RoleService.cs b/Backend/ERPCore/Services/RoleService.cs index d9a9acd..4fb4fd7 100644 --- a/Backend/ERPCore/Services/RoleService.cs +++ b/Backend/ERPCore/Services/RoleService.cs @@ -187,8 +187,12 @@ public sealed class RoleService : IRoleService if (string.IsNullOrWhiteSpace(roleCode)) return new MeResponseDto(null, null, Array.Empty()); + // AuthHex mints the RoleCode claim independently of ERPCore's stored casing + // (e.g. token "ADMIN" vs seeded "Admin"), so match case-insensitively — an + // identity code differing only by case must not lock the user out of the nav. + var normalized = roleCode.Trim(); var role = await _roles.Query().AsNoTracking() - .FirstOrDefaultAsync(r => r.Code == roleCode, ct); + .FirstOrDefaultAsync(r => r.Code.ToLower() == normalized.ToLower(), ct); if (role is null) return new MeResponseDto(roleCode, null, Array.Empty()); -- 2.52.0 From a1b398546934980d4d8d6d6873e3cb2134ac1ff5 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Thu, 23 Jul 2026 12:12:32 +0530 Subject: [PATCH 2/9] feat: Implement fixed sale price functionality for items - Added a toggle for fixed sale price vs stock value in the item creation form. - Introduced validation to ensure all variants have a price greater than 0 when fixed price mode is selected. - Updated the item model to include a nullable salePrice field, which is used for sales only and does not affect GRN/FIFO/ledger. - Enhanced the GRN page to allow off-PO items and included a refresh button to update the item list without reloading the page. - Updated documentation to reflect changes in item pricing and GRN handling. --- Backend/ERPCore/Domain/Entities/Item.cs | 8 ++ Backend/ERPCore/Dtos/Items/ItemDtos.cs | 8 +- .../Configurations/ItemConfiguration.cs | 3 + .../Migrations/ErpDbContextModelSnapshot.cs | 4 + Backend/ERPCore/Services/ItemService.cs | 6 +- Backend/PROGRESS.md | 8 +- Frontend/PROGRESS.md | 9 +- .../app/dashboard/products/new/page.tsx | 101 +++++++++++++++++- .../app/dashboard/receiving/grn/new/page.tsx | 55 +++++++++- .../erp-system/lib/validations/master-data.ts | 20 ++++ Frontend/erp-system/types/master-data.ts | 6 ++ docs/02-SECURITY.md | 4 +- docs/10-BACKEND-PHASE1.md | 13 ++- docs/11-BACKEND-PHASE1.md | 21 +++- docs/20-FRONTEND.md | 4 +- 15 files changed, 245 insertions(+), 25 deletions(-) diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs index ba9ed6c..1065286 100644 --- a/Backend/ERPCore/Domain/Entities/Item.cs +++ b/Backend/ERPCore/Domain/Entities/Item.cs @@ -33,6 +33,14 @@ public class Item public StockNature StockNature { get; set; } public TrackingMode TrackingMode { get; set; } public string? TaxClass { get; set; } + + /// + /// Optional fixed selling price used by Sales only. null means "use stock value" + /// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price. + /// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9). + /// + public decimal? SalePrice { get; set; } + public EntityStatus Status { get; set; } = EntityStatus.Active; public DateTime CreatedAt { get; set; } diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs index adddcfa..9433c76 100644 --- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs +++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs @@ -9,7 +9,7 @@ namespace ERPCore.Dtos.Items; public sealed record ItemListItemDto( int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode, - string? TaxClass, EntityStatus Status); + string? TaxClass, decimal? SalePrice, EntityStatus Status); /// A single per-warehouse reorder policy row. public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty); @@ -27,7 +27,7 @@ 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, + string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList Reorder, IReadOnlyList Conversions, DateTime CreatedAt, DateTime? UpdatedAt); @@ -62,6 +62,8 @@ public sealed class CreateItemRequest [Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; } [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; [StringLength(20)] public string? TaxClass { get; set; } + /// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value. + [Range(0, double.MaxValue)] public decimal? SalePrice { get; set; } } public sealed class UpdateItemRequest @@ -79,6 +81,8 @@ public sealed class UpdateItemRequest [Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; } [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; [StringLength(20)] public string? TaxClass { get; set; } + /// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value. + [Range(0, double.MaxValue)] public decimal? SalePrice { get; set; } } public sealed class UpdateItemStatusRequest diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs index dab0f1f..53feb18 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs @@ -19,6 +19,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration builder.Property(i => i.Description).HasMaxLength(1000); builder.Property(i => i.TaxClass).HasMaxLength(20); + // Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value). + builder.Property(i => i.SalePrice).HasPrecision(18, 4); + builder.Property(i => i.StockNature) .HasConversion().HasMaxLength(20).IsRequired(); builder.Property(i => i.TrackingMode) diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs index fa5a7fb..237f996 100644 --- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs @@ -385,6 +385,10 @@ namespace ERPCore.Infra.Persistence.Migrations .HasColumnType("xid") .HasColumnName("xmin"); + b.Property("SalePrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + b.Property("Sku") .IsRequired() .HasMaxLength(50) diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index c28808c..79a24c5 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -79,7 +79,7 @@ public sealed class ItemService : IItemService .Select(i => new ItemListItemDto( i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.BaseUomId, i.DefaultVendorId, - i.StockNature, i.TrackingMode, i.TaxClass, i.Status)) + i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); @@ -117,6 +117,7 @@ public sealed class ItemService : IItemService StockNature = request.StockNature, TrackingMode = request.TrackingMode, TaxClass = request.TaxClass, + SalePrice = request.SalePrice, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }; @@ -158,6 +159,7 @@ public sealed class ItemService : IItemService item.StockNature = request.StockNature; item.TrackingMode = request.TrackingMode; item.TaxClass = request.TaxClass; + item.SalePrice = request.SalePrice; item.UpdatedAt = DateTime.UtcNow; await SaveGuardingConcurrencyAsync(ct); @@ -348,7 +350,7 @@ public sealed class ItemService : IItemService private static ItemDetailDto ToDetail(Item i) => new( i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId, i.BaseUomId, i.DefaultVendorId, - i.StockNature, i.TrackingMode, i.TaxClass, i.Status, + i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status, i.ReorderSettings .OrderBy(r => r.WarehouseId) .Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty)) diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index 4cbffea..d9b4286 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -27,6 +27,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only - [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId` - [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes +- [x] Item **sale price** (FR-MD-01, 2026-07-22) — nullable `Item.SalePrice` (`numeric(18,4)`); on all Item DTOs (list/detail/create/update), validated `>= 0`. **Sales-only** — never enters GRN/FIFO/ledger. `null` ⇒ sell at stock value. Migration `AddItemSalePrice`. See the 2026-07-22 Done entry. > ### 2026-07-16 — Brands, Subcategories, Item Types, Product Config (migration #2) > Makes real three concepts the frontend had been faking on mock data (`Frontend/erp-system/lib/api/mock-data.ts`), per docs/10 §B.3.1 FR-MD-09/10/11 and docs/11 §2.3/2.6/2.7/2.8. @@ -75,6 +76,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 3. Goods Receipt > Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate. - [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **defaults to the PO price but is now overridable per line** (variance recorded vs `poUnitPrice` snapshot — 02-SECURITY C.3 revised 2026-07-20; the old "client cost ignored" block is gone); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. **Discount/VAT added** — see the 2026-07-20 entry. +- [x] **Off-PO lines on a PO-based GRN** (FR-GRN-01, 2026-07-22) — a line with `poLineId: null` on a PO-based GRN is received like a direct line (entered `unitCost`, no over-receipt check, PO balances untouched). **No code change was needed** — `GrnService.CreateAsync` already branches per-line on `input.PoLineId is not null`; documented + frontend-enabled. Same review/audit surface as AR-04 (02-SECURITY C.3). - [x] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred. - [x] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4). @@ -111,7 +113,11 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## Done -### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (migration `AddGrnPricingAndPoDraft`) +### 2026-07-22 — Item fixed sale price + GRN off-PO items (migration `AddItemSalePrice`) +- **Item sale price (FR-MD-01).** New nullable `Item.SalePrice` (`numeric(18,4)`, `ItemConfiguration.HasPrecision(18,4)`), threaded through `ItemListItemDto`/`ItemDetailDto`/`CreateItemRequest`/`UpdateItemRequest` (`[Range(0, …)]`) and mapped in `ItemService` (create/update/`ToDetail`/list projection). **Sales-only** — it never touches `GrnService`, FIFO, `StockLayer`, or the ledger, so receipt/costing behaviour is byte-for-byte unchanged. `null` ⇒ "use stock value"; the fixed-vs-stock choice is a frontend toggle, not a server field (no `price_mode` enum). docs/10 C.1/C.9 + decision #14, docs/11 §2.1, 02-SECURITY C.1. +- **GRN off-PO items (FR-GRN-01).** A PO-based GRN may now carry lines with `poLineId: null` (item not on the PO). **No backend change** — `GrnService.CreateAsync` already routed such lines through the direct-receipt path (entered cost, no over-receipt check, no PO-balance update). Documented as intended behaviour; the frontend now exposes it. docs/10 FR-GRN-01/C.3 (`po_line_id` nullable) + decision #15, docs/11 §4.1, 02-SECURITY C.3. +- **Migration** `AddItemSalePrice` — single nullable column add; no backfill (`Down()` drops it). **Applied** to the local DB (`dotnet ef database update` → Done). +- **Verified:** `dotnet build` clean (compile succeeded; the only earlier failure was the running dev exe holding a file lock, resolved by stopping it). Migration Up applied. Frontend `tsc --noEmit` clean. End-to-end runtime smoke (Swagger/UI) still to be run by the user. - **PO draft lifecycle (FR-PROC-05 revised).** `CreatePurchaseOrderRequest.SaveAsDraft` (default `false` → auto-approve unchanged; `true` → `Draft`). New `POST /purchase-orders/{id}/submit` (Draft→Approved, else `409 PO_NOT_EDITABLE`) and `DELETE /purchase-orders/{id}` (Draft-only, else 409). `IsEditable` narrowed from "not FullyReceived/Closed/Cancelled" to **`Draft` only** — so `PUT` now 409s on any submitted PO. **Option B ("freely edit while open") is superseded**; docs/10 FR-PROC-05, docs/11 §3.3 updated. ⚠️ **Every pre-existing PO is `Approved` and therefore now uneditable/undeletable** — intended, not a regression. No schema change (reuses the existing `Draft` enum value). - **GRN discount + VAT + price override.** `GrnLine` gained `PoUnitPrice`(nullable snapshot), `DiscountPct`, `NetUnitCost`, `VatPct`, `VatAmount`, `LineTotal`. All derived figures **server-computed**, never client-supplied. FIFO layer + ledger now cost at **`NetUnitCost`** (after discount) — VAT is recoverable and never enters stock value (docs/10 FR-GRN-06 revised). For a PO line, `unitCost` defaults to the PO price but an entered override wins and a **variance** is recorded against `PoUnitPrice` (02-SECURITY C.3 revised — the "client cost ignored, decision locked" control is deliberately loosened; the variance trail + audit log are the compensating control). Multi-GRN-per-PO at differing prices (the 20/50/30 case) already worked via `openQty`/`QtyReceived` and is untouched. - **Migration** `AddGrnPricingAndPoDraft` — hand-added a data backfill (`UPDATE grn_lines SET NetUnitCost = UnitCost, LineTotal = ReceivedValue`) so existing GRN lines stay consistent with their already-posted FIFO layers; `PoUnitPrice` left NULL for historical rows (no retroactive variance). `Down()` drops the six columns cleanly. diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 1b76f10..5c28774 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -27,7 +27,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API ## 2. Master Data screens -- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. **2026-07-15, rebuilt twice this session — see the two same-day entries below for the full history; current state:** `/new` is now Category → Subcategory (always visible, disabled with "No subcategories" when the category has none) → Brand → a checkbox-driven **variant builder** sourced live from `variantCategoriesApi`, with no SKU/Name/Description/Default vendor/Tax class/Item type/Tracking mode/Base UOM fields left on the form at all (Item type/tracking mode/base UOM are now fixed constants — `"Stocked"`/`"None"`/`uomId 1` — baked into the submit call, not user-facing). Checking a category (Color, Size, or any custom one) reveals its value-entry UI; Color gets a native color picker + a required name field (stored internally as `"name|hex"`, decoded everywhere it's displayed/used for SKU) while every other category is free-text chips. A flat table (one column per checked category + SKU + Quantity) generalizes to any number of active categories via a cartesian-product `useMemo`, replacing the earlier hardcoded 2-column Color×Size matrix. Submitting loops `itemsApi.create()` once per combination; SKU is `--...`; item name is ` - /...`. Added optional `brandId`/`initialQty` to `Item`/`CreateItemRequest`/`ItemListItem` (`types/master-data.ts`) — **deviation:** neither field is in the documented Item DTO (`docs/11-BACKEND-PHASE1.md` §2.1); `initialQty` is captured but not wired into the Stock Core ledger (informational only, no warehouse/GRN behind it). +- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. **2026-07-22:** `/new` gained a **"Fixed price / Use stock value" sale-price toggle** — see the 2026-07-22 entry. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. **2026-07-15, rebuilt twice this session — see the two same-day entries below for the full history; current state:** `/new` is now Category → Subcategory (always visible, disabled with "No subcategories" when the category has none) → Brand → a checkbox-driven **variant builder** sourced live from `variantCategoriesApi`, with no SKU/Name/Description/Default vendor/Tax class/Item type/Tracking mode/Base UOM fields left on the form at all (Item type/tracking mode/base UOM are now fixed constants — `"Stocked"`/`"None"`/`uomId 1` — baked into the submit call, not user-facing). Checking a category (Color, Size, or any custom one) reveals its value-entry UI; Color gets a native color picker + a required name field (stored internally as `"name|hex"`, decoded everywhere it's displayed/used for SKU) while every other category is free-text chips. A flat table (one column per checked category + SKU + Quantity) generalizes to any number of active categories via a cartesian-product `useMemo`, replacing the earlier hardcoded 2-column Color×Size matrix. Submitting loops `itemsApi.create()` once per combination; SKU is `--...`; item name is ` - /...`. Added optional `brandId`/`initialQty` to `Item`/`CreateItemRequest`/`ItemListItem` (`types/master-data.ts`) — **deviation:** neither field is in the documented Item DTO (`docs/11-BACKEND-PHASE1.md` §2.1); `initialQty` is captured but not wired into the Stock Core ledger (informational only, no warehouse/GRN behind it). - [~] UOM + conversions (`app/dashboard/products/uoms` flat list + create dialog; conversions edited inline on the Item detail page via `PUT /items/{itemId}/uom-conversions`) — FR-MD-02/03 - [~] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04. **2026-07-15:** added debounced search + Previous/Next pagination (`categoriesApi.list()` now takes `page`/`pageSize`/`q`/`sortOrder`, page size 5), matching the Vendor list's pagination pattern. - [~] Brands (`app/dashboard/products/brands` list + create/edit dialog + delete) — **not a documented FR/endpoint**; `lib/api/brands.ts` treats it as a standalone name-only master, same shape as Categories, since Item has no `brandId` in the doc. **2026-07-15:** added the same debounced search + pagination as Categories; `Item`/`CreateItemRequest`/`ItemListItem` gained `brandId` so the new-item variant builder (above) can attach a brand. @@ -44,7 +44,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 4. Receiving screens - [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail -- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry. +- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry. **2026-07-22:** "Add line" now works in **PO mode** (off-PO items) + **"New item"** (opens `/dashboard/products/new` in a new tab) + **refresh** icon — see the 2026-07-22 entry. - [~] GRN confirm (`app/dashboard/receiving/grn/[id]/page.tsx`) — renders returned `createdLayers`/`ledgerRefs`/`poStatus` as a confirmation panel (20-FRONTEND §4); sends a stable `Idempotency-Key` per detail-page session - [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed` - Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn` @@ -90,6 +90,11 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## Done +### 2026-07-22 — Item fixed sale price + GRN off-PO items / inline create +- **Item sale-price toggle** (`app/dashboard/products/new/page.tsx`). New "Fixed price / Use stock value" segmented toggle (default **stock**). **Stock** sends `salePrice: null` on every created item. **Fixed** reveals a top "fix value" input that pre-fills a per-variant **Sale price** column (`priceFor(key) = pricesByKey[key] ?? fixValue`, so editing a row overrides only it while the rest follow the shared value); submit is blocked until **every** generated variant has a price `> 0` (`validateVariantPrices` in `lib/validations/master-data.ts`). Each variant's price rides its own `POST /items` in the existing non-transactional create loop. `types/master-data.ts`: `salePrice` added to `CreateItemRequest` (optional) and `Item`/`ItemListItem` (`number|null`). +- **GRN off-PO items + inline create** (`app/dashboard/receiving/grn/new/page.tsx`). "Add line" is now shown in **both** PO and direct mode — an added PO-mode line has `poLineId: null` (editable item/UOM, `unitCost` required) and the server receives it as a direct line. New **"New item"** button opens `/dashboard/products/new` in a new browser tab (`window.open(..., "_blank", "noopener,noreferrer")` — the first new-tab pattern in the app), and a **refresh** icon (`refreshItems`) re-pulls `GET /items?status=Active` so the new item is selectable without reloading the in-progress GRN. Existing `validateLine` covers off-PO lines unchanged. +- **Verified:** `tsc --noEmit` clean. Runtime browser verification (create fixed-priced variants; add an off-PO line + inline item on a PO GRN) is the next step. + ### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (backend + frontend, same pass) - **PO draft lifecycle.** `lib/api/purchase-orders.ts`: `isPoEditable` narrowed to `status === "Draft"` (was the three-status exclusion); added `submit(poId)` and `remove(poId)`. `types/procurement.ts`: `saveAsDraft?` on `CreatePurchaseOrderRequest`; `UpdatePurchaseOrderRequest` now `Omit`s it. `/new`: the single "Create PO" button split into **Save as draft** / **Create & submit**. `/[id]`: **Draft** shows the editable line grid + **Submit** + **Delete draft**; an issued-but-open PO (`Approved`/`PartiallyReceived`) is read-only with **Cancel PO** (with reason, disabled once receipts exist); closed/cancelled are terminal. The old Cancel button was gated on `isPoEditable` — that would have shown Cancel only for Draft, so the affordances were re-split into `editable` (Draft) vs `cancellable` (Approved/PartiallyReceived). - **GRN discount/VAT/variance.** `types/grn.ts`: `discountPct`/`vatPct` on `CreateGrnLineInput`; `poUnitPrice`/`discountPct`/`netUnitCost`/`vatPct`/`vatAmount`/`lineTotal`/`priceVariance` on `GrnLine`. `/new`: per-line Disc %/VAT % inputs, a client mirror of the server arithmetic (`computeLine`, display-only — server stays authoritative) driving a live Line total + document total, and a PO-price variance hint when the entered unit cost differs from the prefilled PO price. `/[id]`: renders the full cost breakdown (unit cost + variance, disc %, net cost, received value, VAT, line total) and a stock-value/VAT/document-total footer. `lib/validations/grn.ts`: 0–100 range checks on the two percentages. diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index d3b99e5..37ff5f3 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -13,7 +13,7 @@ import { productConfig } from "@/lib/api/product-config" import { uomsApi } from "@/lib/api/uoms" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" -import { validateVariantItemForm } from "@/lib/validations/master-data" +import { validateVariantItemForm, validateVariantPrices } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" import { Brand, Category, ItemType, ProductConfig, StockNature, SubCategory } from "@/types/master-data" @@ -73,10 +73,21 @@ export default function NewItemPage() { // submit, without having to remove and re-add the whole value that produced it. const [removedVariantKeys, setRemovedVariantKeys] = useState>(new Set()) + // Sales pricing (FR-MD-01). "stock" ⇒ salePrice sent as null (sell at FIFO value); + // "fixed" ⇒ every variant must carry a price. `fixValue` is the shared default that + // pre-fills rows; a per-key entry in `pricesByKey` overrides it for that one row only. + const [priceMode, setPriceMode] = useState<"stock" | "fixed">("stock") + const [fixValue, setFixValue] = useState("") + const [pricesByKey, setPricesByKey] = useState>({}) + const [priceErrors, setPriceErrors] = useState>({}) + const [errors, setErrors] = useState>({}) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) + // A row shows its own override if set, otherwise it follows the shared fix value. + const priceFor = (key: string) => pricesByKey[key] ?? fixValue + useEffect(() => { Promise.all([ categoriesApi.list({ pageSize: 200, status: "Active" }), @@ -190,7 +201,11 @@ export default function NewItemPage() { setSubmitError(null) const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 }) setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + // In fixed mode, block the whole submit until every variant has a price > 0. + const nextPriceErrors = + priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {} + setPriceErrors(nextPriceErrors) + if (Object.keys(nextErrors).length > 0 || Object.keys(nextPriceErrors).length > 0) return if (baseUomId === null) { setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.") return @@ -212,6 +227,7 @@ export default function NewItemPage() { baseUomId, stockNature, trackingMode: "None", + salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null, }) created += 1 } @@ -384,6 +400,59 @@ export default function NewItemPage() { + {/* Sales pricing (FR-MD-01). The toggle is frontend-only: "stock" sends + salePrice=null (sold at FIFO value); "fixed" requires a price per variant. */} +
+
+

Sale price

+

+ Choose a fixed selling price, or leave it to the item's stock value. +

+
+ +
+ + +
+ + {priceMode === "fixed" && ( +
+ + setFixValue(e.target.value)} + placeholder="0.00" + className="h-11 text-base" + /> +

+ Edit any row below to give that variant a different price. +

+
+ )} +
+ {/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no item-type reference), so this section IS the enforcement. */} {config?.itemTypesEnabled && ( @@ -469,6 +538,9 @@ export default function NewItemPage() { {cat.name} ))} SKU + {priceMode === "fixed" && ( + Sale price + )} {/* Quantity column removed 2026-07-17: there is no `initialQty` on the Item contract and no initial-receipt flow — stock arrives via a GRN. The input was informational-only under the mock and would now be a @@ -485,6 +557,31 @@ export default function NewItemPage() { ))} {variant.sku} + {priceMode === "fixed" && ( + + { + const value = e.target.value + setPricesByKey((prev) => ({ ...prev, [variant.key]: value })) + setPriceErrors((prev) => { + if (!prev[variant.key]) return prev + const next = { ...prev } + delete next[variant.key] + return next + }) + }} + placeholder="0.00" + aria-invalid={!!priceErrors[variant.key]} + className="h-10 w-28 text-base" + /> + + + )} - )} + {/* Create a brand-new item in a separate tab, then refresh to pick it up. */} + + + {poLoading && } diff --git a/Frontend/erp-system/lib/validations/master-data.ts b/Frontend/erp-system/lib/validations/master-data.ts index c050f3e..c2a5571 100644 --- a/Frontend/erp-system/lib/validations/master-data.ts +++ b/Frontend/erp-system/lib/validations/master-data.ts @@ -73,3 +73,23 @@ export function validateVariantItemForm(input: { if (!input.hasVariants) errors.variants = "Check at least one variant category and add its values" return errors } + +/** + * Fixed-price mode requires every generated variant to carry a sale price > 0 + * (docs/20 §3.1). Returns a map keyed by variant key → message; empty when valid. + * In "stock" mode there is nothing to validate (prices are sent as null). + */ +export function validateVariantPrices( + variantKeys: string[], + priceFor: (key: string) => string, +): Record { + const errors: Record = {} + for (const key of variantKeys) { + const raw = priceFor(key).trim() + const value = Number(raw) + if (raw === "" || Number.isNaN(value) || value <= 0) { + errors[key] = "Enter a price greater than 0" + } + } + return errors +} diff --git a/Frontend/erp-system/types/master-data.ts b/Frontend/erp-system/types/master-data.ts index 9a20b4c..98363da 100644 --- a/Frontend/erp-system/types/master-data.ts +++ b/Frontend/erp-system/types/master-data.ts @@ -22,6 +22,8 @@ export interface ItemListItem { stockNature: StockNature trackingMode: TrackingMode taxClass: string | null + /** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */ + salePrice: number | null status: EntityStatus } @@ -59,6 +61,8 @@ export interface Item { stockNature: StockNature trackingMode: TrackingMode taxClass: string | null + /** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */ + salePrice: number | null status: EntityStatus reorder: ItemReorderSetting[] conversions: UomConversion[] @@ -81,6 +85,8 @@ export interface CreateItemRequest { stockNature: StockNature trackingMode: TrackingMode taxClass?: string | null + /** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */ + salePrice?: number | null } export type UpdateItemRequest = CreateItemRequest diff --git a/docs/02-SECURITY.md b/docs/02-SECURITY.md index 3c80f7c..05cc993 100644 --- a/docs/02-SECURITY.md +++ b/docs/02-SECURITY.md @@ -74,6 +74,7 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin - [ ] Create/update DTOs exclude server-controlled fields (`status`, ids, timestamps) - [ ] Deactivate — not delete — referenced masters (FR-MD-08); hard delete blocked → `MASTER_IN_USE` - [ ] Nested/reference writes validate the target exists and is active +- [ ] `Item.salePrice` is a **legitimately client-supplied** field (a deliberate exception to B.6's over-posting list) — validated `>= 0` server-side, nullable. It is **Sales-only** (never enters GRN/FIFO/ledger), so unlike GRN `unitCost` it has **no** inventory-value or costing impact; the fixed/stock-value choice is frontend UX (`docs/11 §2.1`). ### C.2 Procurement (Requisition / RFQ / PO / Purchase Return) - [ ] PO totals computed **server-side** from lines (never trust client totals) @@ -86,7 +87,8 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin - [ ] `unitCost` **defaults to the PO line price**; a per-line override **is now permitted** *(decision revised 2026-07-20 — was "locked, client cost ignored")*. When an override is entered it is used, and the PO price is snapshotted (`poUnitPrice`) so a **`priceVariance` is recorded** against it for review. Rationale: one PO legitimately spans batches received at different prices; the variance trail (plus the audit log) is the compensating control that replaces the old hard block. - [ ] **Derived figures stay server-computed** — `netUnitCost`/`receivedValue`/`vatAmount`/`lineTotal` are never accepted from the client, so the client cannot inflate stock value except by an *auditable* unit-cost override. Discount reduces inventory cost; **VAT is recoverable and never enters stock value**. - [ ] Direct GRN (no PO) remains the higher-scrutiny path where cost is entered with no PO to compare against → review flag + audit (**AR-04**) -- [ ] Over-receipt tolerance enforced server-side → `OVER_RECEIPT_TOLERANCE` +- [ ] **Off-PO lines on a PO-based GRN** (`poLineId: null`, 2026-07-22) are the **same exposure class as AR-04** — cost is entered with no PO line to compare against, and `OVER_RECEIPT_TOLERANCE` does not apply to them. Treat them with the direct-receipt scrutiny (review flag + audit); they do not touch PO balances. +- [ ] Over-receipt tolerance enforced server-side → `OVER_RECEIPT_TOLERANCE` (PO-linked lines only; off-PO lines have no PO qty to check) - [ ] On-hold stock is not issuable (FR-WH-07); expired batch blocked ### C.4 Stock Core (FIFO / Ledger) diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index 6e65db5..8fd58c0 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -118,7 +118,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users ### B.3.1 Master Data (FR-MD) | ID | Requirement | Pri | |---|---|---| -| FR-MD-01 | Maintain **Item master**: SKU (unique), name, description, category (+ optional subcategory), optional brand, **stock nature** (Stocked/NonStocked/Service), tracking mode (None/Batch/Serial), status, tax class, default vendor. | M | +| FR-MD-01 | Maintain **Item master**: SKU (unique), name, description, category (+ optional subcategory), optional brand, **stock nature** (Stocked/NonStocked/Service), tracking mode (None/Batch/Serial), status, tax class, default vendor, **optional fixed sale price** (nullable; Sales-only — never enters costing/GRN/FIFO; `null` ⇒ item is sold at its stock/FIFO value). | M | | FR-MD-02 | Maintain **UOM master** with base UOM per item and **conversion factors** (purchase→stock→base). | M | | FR-MD-03 | Convert quantities between UOMs on every transaction; store base-UOM quantity in the ledger. | M | | FR-MD-04 | Maintain **item categories with one optional subcategory level**. An item references a category (required) and a subcategory (optional) that must belong to it. Deeper nesting is not supported. | S | @@ -146,7 +146,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users ### B.3.3 Goods Receipt (FR-GRN) | ID | Requirement | Pri | |---|---|---| -| FR-GRN-01 | Create **GRN** against an approved PO, defaulting lines/quantities from open PO lines. | M | +| FR-GRN-01 | Create **GRN** against an approved PO, defaulting lines/quantities from open PO lines. **Additional lines for items not on the PO are permitted** — a line with no `po_line_id` is received like a direct receipt (entered cost, no over-receipt check) and does not affect PO line balances. Off-PO lines are a review surface (see 02-SECURITY C.3). | M | | FR-GRN-02 | Support **GRN without PO** (direct/emergency) by permission, flagged for review. | S | | FR-GRN-03 | Support **over/under-receipt tolerances** (per item or global); warn or block beyond tolerance. | S | | FR-GRN-04 | Capture **batch + expiry** and/or **serial numbers** for tracked items on receipt. | M | @@ -250,6 +250,8 @@ Adjustment: Damage, Theft/Loss, Count Variance, Expiry Write-off, System Correct | 11 | Category hierarchy depth | **Resolved:** dedicated `SUBCATEGORY` table, exactly two levels; `CATEGORY.parent_id` dropped. Item carries both FKs (subcategory nullable). Arbitrary nesting is not coming back. | | 12 | Item types / variants | **Resolved:** the `ItemType` **enum** was replaced by an **unreferenced master list**; Stocked/NonStocked/Service survives as `stock_nature`. Values are **SKU-encoded only** — no value table, no item link, no product-variation model (Part C.9 records the accepted trade-off). | | 13 | Product-config authorization | **Open:** `PUT /product-config` is gated by the door policy only, like every other endpoint. A `CONFIG_MANAGE` permission is reserved for when per-endpoint RBAC lands (decision #6). Until then any ERP-admitted user can flip the flags. | +| 14 | Item sale price (fixed vs stock value) | **Resolved (2026-07-22):** a single **nullable** `ITEM.sale_price` — `NULL` ⇒ sell at stock/FIFO value, a value ⇒ fixed price. **Sales-only** (never touches GRN/FIFO/ledger). No `price_mode` enum; the create-time fixed/stock toggle is frontend UX that requires a price per generated variant when "fixed" is chosen (Part C.9). | +| 15 | Off-PO lines on a PO-based GRN | **Resolved (2026-07-22):** allowed. `GRN_LINE.po_line_id` is nullable; a null line on a PO-based GRN is received like a direct receipt (entered cost, no over-receipt check) and does not touch PO balances. Same cost-entry/fraud surface as GRN-without-PO (AR-04) — flagged for review, not blocked (02-SECURITY C.3). | --- @@ -267,7 +269,8 @@ UOM(uom_id PK, name) UOM_CONVERSION(conversion_id PK, item_id FK→ITEM, from_uom FK→UOM, to_uom FK→UOM, factor) ITEM(item_id PK, sku, name, category_id FK→CATEGORY, subcategory_id FK→SUBCATEGORY [nullable], brand_id FK→BRAND [nullable], base_uom_id FK→UOM, - default_vendor_id FK→VENDOR, stock_nature, tracking_mode, tax_class, status) + default_vendor_id FK→VENDOR, stock_nature, tracking_mode, tax_class, + sale_price [nullable], status) -- sale_price: Sales-only selling price; NULL ⇒ sell at stock (FIFO) value ITEM_REORDER(reorder_id PK, item_id FK→ITEM, warehouse_id FK→WAREHOUSE, reorder_point, reorder_qty) VENDOR(vendor_id PK, code, name, terms, tax_reg, currency, status) WAREHOUSE(warehouse_id PK, code, name) @@ -303,8 +306,9 @@ PURCHASE_RETURN_LINE(return_line_id PK, return_id FK→PURCHASE_RETURN, ``` GRN(grn_id PK, doc_no, po_id FK→PURCHASE_ORDER, vendor_id FK→VENDOR, warehouse_id FK→WAREHOUSE, status, created_by FK→USER, created_at) -GRN_LINE(grn_line_id PK, grn_id FK→GRN, po_line_id FK→PO_LINE, item_id FK→ITEM, uom_id FK→UOM, +GRN_LINE(grn_line_id PK, grn_id FK→GRN, po_line_id FK→PO_LINE [nullable], item_id FK→ITEM, uom_id FK→UOM, bin_id FK→BIN, batch_id FK→BATCH, qty, unit_cost, received_value, hold_status) + -- po_line_id nullable: NULL for a direct receipt OR an off-PO line added to a PO-based GRN (FR-GRN-01) ``` ## C.4 Batch / Serial @@ -362,6 +366,7 @@ Note: `USER_ROLE` from the original placeholder sketch was dropped — a user ha ## C.9 Modeling notes (load-bearing) - **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) exists **only** to populate the frontend item-builder's dropdown via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy. - *Accepted trade-off (a decision, not an oversight):* the backend cannot answer "list all blue items", cannot filter or report by colour/size, and cannot validate that a SKU's segments correspond to real item types. Renaming an item type (`Color` → `Colour`) does **not** touch existing SKUs, which keep their old segments — the two are permanently decoupled the moment an item is created. If value-level querying is ever needed, an `ITEM_TYPE_VALUE` table plus a link table can be added additively, but existing SKUs will not be back-fillable without parsing them by hand. +- **Sale price is a per-item scalar, not a variant/price table.** Because each "variant" is its own `ITEM` row (above), the optional selling price lives directly on `ITEM.sale_price` (nullable). `NULL` means "use stock value" — Sales values the item at its FIFO stock cost at sale time (FR-STK-04 / `STOCK_LAYER`); a value is a fixed selling price. It is **Sales-only**: it never participates in GRN, FIFO layering, or the stock ledger, so receipt/costing behaviour is identical whether the item is fixed-priced or not. The create-time "fixed price vs use stock value" choice is a **frontend UX toggle** — the contract is simply the nullable column, and the item builder requires a price on every generated variant when the user picks fixed pricing. - **Two-level categories.** `CATEGORY` no longer self-nests; `SUBCATEGORY` is the single optional level below it. An item stores both FKs rather than pointing only at the deepest node, so the parent is never inferred or lost. A subcategory cannot be reparented (it would silently invalidate the category of every item referencing it) — deactivate and recreate instead. - **Product config is a singleton, and only two of its flags are enforceable.** `subcategories_enabled` / `brands_enabled` gate item writes (`CONFIG_DISABLED`, 422). `item_types_enabled` is **advisory only** — since items carry no item-type reference, there is nothing on a write to reject; the frontend honours it by hiding the builder's type section. Reads are never gated, so existing data stays readable after a flag is switched off. - **FIFO = two structures.** `STOCK_LAYER` answers valuation ("what's on hand and at what cost"); `STOCK_LEDGER` answers history ("what moved, when, by whom"). Layers are keyed per item **per warehouse**. diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index 1bf4b16..fbcf01b 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -164,6 +164,8 @@ docs/10 C.9): every write below forwards to AuthHex's new `/api/role` functions ### 2.1 Items > **`itemType` → `stockNature` (2026-07-16).** The Stocked/NonStocked/Service field was renamed so the name `itemType` could be taken by the new Item Type master (§2.7) — an unrelated concept. Items gained `subCategoryId` and `brandId` (both nullable). Items carry **no** item-type reference: the values chosen in the builder are encoded into the client-generated SKU (docs/10 Part C.9). +> +> **`salePrice` added (nullable, 2026-07-22).** Every item body below carries `salePrice` (`number|null`). It is the **Sales-only** fixed selling price: `null` ⇒ the item is sold at its stock/FIFO value; a value ⇒ fixed price. It never affects GRN/FIFO/ledger. On write it is optional; when supplied it must be `>= 0` (else `400` validation). The item builder's "fixed price / use stock value" toggle is UI-only — the contract is just the nullable field. #### `GET /items` Query: `q`, `status` (`Active|Inactive`), `categoryId`, `subCategoryId`, `brandId`, `trackingMode` (`None|Batch|Serial`), + paging. @@ -171,7 +173,8 @@ Query: `q`, `status` (`Active|Inactive`), `categoryId`, `subCategoryId`, `brandI ```json { "items": [ { "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40", "categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5, - "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "status": "Active" } ], + "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD", + "salePrice": 12.5000, "status": "Active" } ], "pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } } ``` @@ -181,7 +184,8 @@ Query: `q`, `status` (`Active|Inactive`), `categoryId`, `subCategoryId`, `brandI "description": "Grade 8.8 zinc-plated hex bolt", "categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD", - "status": "Active", "reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ], + "salePrice": 12.5000, "status": "Active", + "reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ], "conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ], "createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" } ``` @@ -192,14 +196,14 @@ The `sku` is **generated by the client** (it encodes the chosen item-type values ```json { "sku": "ITM-1002", "name": "Steel Nut M8", "description": "Grade 8 zinc-plated hex nut", "categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5, - "stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD" } + "stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD", "salePrice": 3.2500 } ``` **201 Created** — `Location: /api/v1/items/1002` ```json { "itemId": 1002, "sku": "ITM-1002", "name": "Steel Nut M8", "categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD", - "status": "Active", "createdAt": "2026-07-07T09:30:00Z" } + "salePrice": 3.2500, "status": "Active", "createdAt": "2026-07-07T09:30:00Z" } ``` `400` → `code: SKU_DUPLICATE` if SKU exists. `422` → `code: CONFIG_DISABLED` if `subCategoryId` is sent while subcategories are disabled, or `brandId` while brands are disabled (§2.8). @@ -530,11 +534,18 @@ Against a PO (lines default from open PO lines) or direct (`poId: null`, by perm { "poId": 342, "warehouseId": 1, "lines": [ { "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, "qty": 5000, "unitCost": 12.50, "discountPct": 10, "vatPct": 18, "holdStatus": "OnHold", - "batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } } ] } + "batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } }, + { "poLineId": null, "itemId": 1050, "uomId": 1, "qty": 20, + "unitCost": 8.00, "holdStatus": "Available" } ] } ``` `discountPct`/`vatPct` optional (default 0, range 0–100). `unitCost` on a **PO line** is an optional override: 0/omitted uses the PO price; a value wins and a variance is recorded (02-SECURITY C.3, revised). On a direct receipt `unitCost` is required. +**Off-PO lines (`poLineId: null`) are allowed even on a PO-based GRN** (2026-07-22, FR-GRN-01) — the second +line above receives an item that is not on the PO. Such a line behaves exactly like a direct-receipt line: +`unitCost` is required, `OVER_RECEIPT_TOLERANCE` does **not** apply (there is no PO qty to check), and no PO +line balance is touched. The inline "create new item" UI simply calls `POST /items` (§2.1) first, then adds +the returned item as an off-PO line. **201 Created** — status `Draft`. All derived figures are **server-computed**: `netUnitCost = unitCost × (1 − discountPct/100)`, `receivedValue = qty × netUnitCost` (after discount, **before** VAT — this is the stock value), `vatAmount = receivedValue × vatPct/100`, diff --git a/docs/20-FRONTEND.md b/docs/20-FRONTEND.md index 151e246..426ac26 100644 --- a/docs/20-FRONTEND.md +++ b/docs/20-FRONTEND.md @@ -143,9 +143,11 @@ Vendors, Items, Categories, Subcategories, UOM, Warehouses, Brands, and Item Typ - **SKU generation stays client-side** (`buildVariantSku`) and is the *only* record of which colour/size an item is; the server only uniqueness-checks it. **Nothing can query items by colour** — accepted (`docs/10 Part C.9`). - **`remove()` → `updateStatus(id, "Inactive")`** everywhere. There are no `DELETE` endpoints on any master (FR-MD-08); the lists show a Status column and Deactivate/Activate. - **`initialQty` is gone** from the builder — the Item contract has no such field and there is no initial-receipt flow. Stock arrives via a GRN. +- **Sale-price toggle on the builder (2026-07-22).** A "Fixed price / Use stock value" toggle sits on `/dashboard/products/new`. **Use stock value** (default) sends `salePrice: null` on every created item (sold at FIFO value). **Fixed price** reveals a top "fix value" input that pre-fills a per-variant `Sale price` column; each row is editable, and submit is blocked until **every** generated variant has a price `> 0`. The toggle is **frontend-only** — the contract is just the nullable `salePrice` field (`docs/11 §2.1`); it is Sales-only and never affects GRN/costing. The non-transactional create loop still applies — each variant's `salePrice` rides its own `POST /items`. - **Product Configuration** (`app/dashboard/products/settings`, `GET`/`PUT /product-config`) — only **3** of the original design's ~13 toggles exist. `subcategoriesEnabled`/`brandsEnabled` are server-enforced (`CONFIG_DISABLED`); **`itemTypesEnabled` is advisory** and this app is what honours it (it hides the builder's type section). The UI states that distinction on the screen rather than implying a guarantee. - **Non-transactional create loop:** the builder's per-row `itemsApi.create()` has no transaction — a `SKU_DUPLICATE` on row 7 of 12 leaves 6 items created. The error message now says how many landed rather than implying nothing happened. A transactional bulk-create endpoint would be the real fix. - **GRN edit/delete removed** — the API has no `PUT`/`DELETE` for a GRN; receipts are corrected by reversing documents (FR-X-05). +- **GRN off-PO items + inline item create (2026-07-22).** On `/dashboard/receiving/grn/new`, "Add line" is available in **both** PO and direct mode — an added line in PO mode has `poLineId: null` and receives an item not on the PO (editable item/UOM dropdowns, `unitCost` required). A **"New item"** button opens `/dashboard/products/new` in a **new browser tab** (`window.open`, the first such pattern in the app), and a **refresh icon** re-pulls `GET /items?status=Active` so the newly created item is selectable **without** reloading the screen and losing the in-progress GRN. Server treats off-PO lines as direct receipts (no over-receipt check) — `docs/11 §4.1`. - **RFQ invited-vendors is not persisted** — `POST /rfqs` validates `vendorIds` then discards them, so the list/detail screens show quotations received instead of vendors invited. - **Known gap — serial numbers:** FR-GRN-04 requires capturing serials on receipt, but `CreateGrnLineInput` has no such field (only `batch`). The UI does not collect them rather than silently discarding them. Needs a backend change to honour the requirement. @@ -159,7 +161,7 @@ Vendors, Items, Categories, Subcategories, UOM, Warehouses, Brands, and Item Typ **Client-side (UX only — safe to check locally):** purely input-level facts the browser already has. - Required fields present. - Format: SKU pattern, numeric fields numeric, date format, positive integers. -- Range/bounds: `qty > 0`, `unitPrice >= 0`, `factor > 0`. +- Range/bounds: `qty > 0`, `unitPrice >= 0`, `factor > 0`. Item `salePrice` is a client-supplied number — the builder requires `> 0` per variant in fixed mode; the server only checks `>= 0` on a supplied value (`docs/11 §2.1`). - Simple cross-field input rules: transfer `destWarehouseId != srcWarehouseId`. - Enum membership via constrained dropdowns (`stockNature` — ex-`itemType`, `trackingMode`, `countType`, `holdStatus`). Note the **Item Type** dropdown is *not* in this category: it's server data (`GET /item-types`), not an enum. -- 2.52.0 From 7366ca93c07adb7e43da4703e69bb20abe0d2a6c Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Thu, 23 Jul 2026 12:53:26 +0530 Subject: [PATCH 3/9] feat: implement theme toggle and enhance UI components --- Frontend/erp-system/app/dashboard/layout.tsx | 2 +- .../app/dashboard/procurement/page.tsx | 52 ----------------- .../procurement/purchase-orders/new/page.tsx | 33 +++++++++-- .../app/dashboard/products/[id]/page.tsx | 6 +- .../app/dashboard/receiving/grn/new/page.tsx | 19 ++++--- .../app/dashboard/warehouse/page.tsx | 42 +++++++++----- Frontend/erp-system/app/layout.tsx | 6 +- .../components/Layouts/AppSidebar.tsx | 57 +++++++++++-------- .../erp-system/components/Layouts/Header.tsx | 37 ++++++------ .../erp-system/components/theme-toggle.tsx | 38 +++++++++++++ Frontend/erp-system/package.json | 1 + 11 files changed, 173 insertions(+), 120 deletions(-) create mode 100644 Frontend/erp-system/components/theme-toggle.tsx diff --git a/Frontend/erp-system/app/dashboard/layout.tsx b/Frontend/erp-system/app/dashboard/layout.tsx index 55e4289..17b3edc 100644 --- a/Frontend/erp-system/app/dashboard/layout.tsx +++ b/Frontend/erp-system/app/dashboard/layout.tsx @@ -18,7 +18,7 @@ export default function DashboardLayout({
-
+
{children}
diff --git a/Frontend/erp-system/app/dashboard/procurement/page.tsx b/Frontend/erp-system/app/dashboard/procurement/page.tsx index cc3a0c4..bb36515 100644 --- a/Frontend/erp-system/app/dashboard/procurement/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/page.tsx @@ -1,35 +1,3 @@ -import Link from "next/link" -import { ClipboardList, FileText, PackageX, ShoppingCart, type LucideIcon } from "lucide-react" - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" - -const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [ - { - title: "Requisitions", - description: "Raise a purchase requisition and submit it into procurement.", - href: "/dashboard/procurement/requisitions", - icon: ClipboardList, - }, - { - title: "RFQs", - description: "Request quotations from vendors, record pricing, and compare side by side.", - href: "/dashboard/procurement/rfqs", - icon: FileText, - }, - { - title: "Purchase Orders", - description: "Save as draft (editable/deletable) or submit to lock; cancel an issued PO before receipt.", - href: "/dashboard/procurement/purchase-orders", - icon: ShoppingCart, - }, - { - title: "Purchase Returns", - description: "Return received goods to a vendor, referencing the original GRN line.", - href: "/dashboard/procurement/purchase-returns", - icon: PackageX, - }, -] - export default function ProcurementHubPage() { return (
@@ -39,26 +7,6 @@ export default function ProcurementHubPage() { Requisition → RFQ (optional) → Purchase Order → Purchase Return (FR-PROC-01..09).

- -
- {areas.map((area) => ( - - - -
-
- -
- {area.title} -
-
- -

{area.description}

-
-
- - ))} -
) } 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 411799d..f956a5d 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 @@ -238,17 +238,40 @@ function NewPurchaseOrderContent() { {!loading && ( <> -
+
- - value={vendorId} onValueChange={setVendorId}> + + + value={vendorId} + onValueChange={setVendorId} + items={(vendors ?? []).map((v) => ({ label: v.code, value: v.vendorId }))} + > - + {(vendors ?? []).map((v) => ( - {v.code} — {v.name} + {v.code} + + ))} + + +
+
+ + + value={vendorId} + onValueChange={setVendorId} + items={(vendors ?? []).map((v) => ({ label: v.name, value: v.vendorId }))} + > + + + + + {(vendors ?? []).map((v) => ( + + {v.name} ))} diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index 63ff5ab..4636569 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -113,7 +113,11 @@ export default function ItemDetailPage() { try { const result = await itemsApi.update( item.itemId, - { sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, baseUomId: baseUomId as number, defaultVendorId, stockNature, 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) diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index 99ce3ef..e8eb828 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -385,13 +385,18 @@ export default function NewGrnPage() {
-

Lines

- {mode === "direct" && ( - - )} +
+

Lines

+ {mode === "po" && ( +

+ Lines default from the PO's open quantities — add a row for anything received that wasn't ordered. +

+ )} +
+
{poLoading && } diff --git a/Frontend/erp-system/app/dashboard/warehouse/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/page.tsx index 823785e..8a2aad4 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/page.tsx @@ -5,7 +5,7 @@ import Link from "next/link" import { ArrowLeft, Plus, Warehouse as WarehouseIcon } from "lucide-react" import { warehousesApi } from "@/lib/api/warehouses" -import { errorMessage, fieldErrors } from "@/lib/error-map" +import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" import { Bin, Warehouse } from "@/types/master-data" @@ -24,17 +24,36 @@ import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" +/** First word of the name, uppercased and stripped to alphanumerics — falls back to "WH" + * so an empty/punctuation-only name still yields a usable base. */ +function warehouseCodeBase(name: string): string { + const firstWord = name.trim().split(/\s+/)[0] ?? "" + const cleaned = firstWord.toUpperCase().replace(/[^A-Z0-9]/g, "") + return cleaned.slice(0, 10) || "WH" +} + +/** Appends a numeric suffix until the code doesn't collide with an existing one — the + * backend enforces global uniqueness (409 on conflict) but has no generation of its own. */ +function generateWarehouseCode(name: string, existingCodes: string[]): string { + const base = `WH-${warehouseCodeBase(name)}` + if (!existingCodes.includes(base)) return base + let suffix = 2 + while (existingCodes.includes(`${base}${suffix}`)) suffix += 1 + return `${base}${suffix}` +} + export default function WarehousesPage() { const [warehouses, setWarehouses] = useState(null) const [bins, setBins] = useState(null) const [error, setError] = useState(null) const [open, setOpen] = useState(false) - const [code, setCode] = useState("") const [name, setName] = useState("") const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) + const generatedCode = name.trim() ? generateWarehouseCode(name, (warehouses ?? []).map((w) => w.code)) : "" + function load() { warehousesApi .list() @@ -54,23 +73,21 @@ export default function WarehousesPage() { async function handleCreate() { const nextErrors: Record = {} - if (!code.trim()) nextErrors.code = "Warehouse code is required" if (!name.trim()) nextErrors.name = "Warehouse name is required" setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) try { - const warehouse = await warehousesApi.create({ code, name }) + const warehouse = await warehousesApi.create({ code: generatedCode, name }) toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`) setOpen(false) - setCode("") setName("") setErrors({}) load() } catch (err) { - const fe = fieldErrors(err) - if (fe?.code) setErrors({ code: fe.code }) + // A 409 here means another creation raced ours for the same generated code — the + // proactive de-dupe above only knows about warehouses loaded when the dialog opened. toast.error("Could not create warehouse", errorMessage(err)) } finally { setSubmitting(false) @@ -104,19 +121,18 @@ export default function WarehousesPage() { New warehouse - Create a new warehouse. Bins are added from its detail page. + Create a new warehouse. Its code is generated from the name. Bins are added from its detail page. - - Code - setCode(e.target.value)} placeholder="WH-MAIN" aria-invalid={!!errors.code} /> - - Name setName(e.target.value)} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} /> + + Code (auto-generated) + +
@@ -172,28 +178,28 @@ function SidebarContent({
{!iconOnly && ( <> {item.title} {item.chevron && !hasChildren && ( )} @@ -207,8 +213,8 @@ function SidebarContent({ aria-label={isOpen ? `Collapse ${item.title}` : `Expand ${item.title}`} aria-expanded={isOpen} className={cn( - "mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-white/60", - isActive ? "text-indigo-500" : "text-slate-400" + "mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-foreground/10", + isActive ? "text-primary" : "text-muted-foreground" )} > {child.title} @@ -268,8 +274,8 @@ function SidebarContent({
-
- +
+
@@ -287,13 +293,18 @@ export function AppSidebar() { // While /auth/me hasn't resolved yet, show nothing rather than briefly // flashing the full menu to a restricted role. Once resolved, a nav item // is visible if its own code is granted, or (for parents) if any child is. + // + // "procurement" is exempted from that check (frontend-only): no role is currently + // seeded with NAV:procurement or its children server-side, which would hide the whole + // section for everyone. Remove this bypass once roles are granted the permission + // properly (Settings → Roles → Sidebar permissions) or a backend seed grants it. const visibleItems = loading ? [] : navItems - .filter((item) => navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code))) + .filter((item) => item.code === "procurement" || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code))) .map((item) => ({ ...item, - children: item.children?.filter((c) => navCodes.includes(c.code)), + children: item.code === "procurement" ? item.children : item.children?.filter((c) => navCodes.includes(c.code)), })) // Close mobile menu on route change @@ -326,7 +337,7 @@ export function AppSidebar() { type="button" onClick={() => setMobileOpen(true)} aria-label="Open menu" - className="fixed top-5 left-5 z-40 flex size-10 items-center justify-center rounded-2xl bg-white shadow-sm ring-1 ring-black/5 text-slate-600 hover:bg-slate-50 lg:hidden" + className="fixed top-5 left-5 z-40 flex size-10 items-center justify-center rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 text-muted-foreground hover:bg-muted lg:hidden" > diff --git a/Frontend/erp-system/components/Layouts/Header.tsx b/Frontend/erp-system/components/Layouts/Header.tsx index e0f6f5d..8768f7c 100644 --- a/Frontend/erp-system/components/Layouts/Header.tsx +++ b/Frontend/erp-system/components/Layouts/Header.tsx @@ -9,6 +9,7 @@ import { authApi } from "@/lib/api/auth" import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session" import { AuthUser } from "@/types/auth" import { cn } from "@/lib/utils" +import { ThemeToggle } from "@/components/theme-toggle" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { @@ -163,48 +164,50 @@ export function Header() { } return ( -
+
{showBackButton && ( )} -

{title}

+

{title}

+ + } > {unreadCount > 0 && ( - + )} -
-

Notifications

+
+

Notifications

{unreadCount > 0 && ( @@ -221,22 +224,22 @@ export function Header() {
-

{notification.title}

+

{notification.title}

{notification.description}

-

{notification.time}

+

{notification.time}

)) @@ -246,19 +249,19 @@ export function Header() { - + - + {initials(displayName(user))} - + {displayName(user)}
-

{displayName(user)}

+

{displayName(user)}

{user?.email &&

{user.email}

}
diff --git a/Frontend/erp-system/components/theme-toggle.tsx b/Frontend/erp-system/components/theme-toggle.tsx new file mode 100644 index 0000000..9d7fc7e --- /dev/null +++ b/Frontend/erp-system/components/theme-toggle.tsx @@ -0,0 +1,38 @@ +"use client" + +import { useEffect, useState } from "react" +import { Moon, Sun } from "lucide-react" +import { useTheme } from "next-themes" + +import { cn } from "@/lib/utils" + +/** + * Renders an empty slot until mounted: `resolvedTheme` is unknown on the server (and on + * the client's first paint, before next-themes reads localStorage), so rendering an icon + * before that would either be wrong or cause a hydration mismatch. + */ +export function ThemeToggle({ className }: { className?: string }) { + const { resolvedTheme, setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + if (!mounted) { + return