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.
+
+ 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"
+ />
+
+
+ )}