Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1b3985469 |
@@ -33,6 +33,14 @@ public class Item
|
||||
public StockNature StockNature { get; set; }
|
||||
public TrackingMode TrackingMode { get; set; }
|
||||
public string? TaxClass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional fixed selling price used by Sales only. <c>null</c> 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).
|
||||
/// </summary>
|
||||
public decimal? SalePrice { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>A single per-warehouse reorder policy row.</summary>
|
||||
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<ItemReorderDto> Reorder,
|
||||
string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
IReadOnlyList<UomConversionDto> 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; }
|
||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||
[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; }
|
||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemStatusRequest
|
||||
|
||||
@@ -19,6 +19,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
||||
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<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.TrackingMode)
|
||||
|
||||
@@ -385,6 +385,10 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<decimal?>("SalePrice")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<string>("Sku")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
|
||||
@@ -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<ItemListItemDto>.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))
|
||||
|
||||
+7
-1
@@ -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
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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 `<CategoryCode>-<value1Code>-<value2Code>...`; item name is `<Brand> <Category> - <value1>/<value2>...`. 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 `<CategoryCode>-<value1Code>-<value2Code>...`; item name is `<Brand> <Category> - <value1>/<value2>...`. 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
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function DashboardLayout({
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-6 lg:p-8">
|
||||
<Breadcrumbs />
|
||||
<div className="rounded-xl bg-card border border-border shadow-sm">
|
||||
<div className="rounded-xl bg-card border border-gray-200 shadow-sm">
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -7,6 +39,26 @@ export default function ProcurementHubPage() {
|
||||
Requisition → RFQ (optional) → Purchase Order → Purchase Return (FR-PROC-01..09).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{areas.map((area) => (
|
||||
<Link key={area.href} href={area.href}>
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<area.icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{area.title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-base text-muted-foreground">{area.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -238,40 +238,17 @@ function NewPurchaseOrderContent() {
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Vendor code</Label>
|
||||
<Select<number | null>
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
items={(vendors ?? []).map((v) => ({ label: v.code, value: v.vendorId }))}
|
||||
>
|
||||
<Label className="text-base">Vendor</Label>
|
||||
<Select<number | null> value={vendorId} onValueChange={setVendorId}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor code" />
|
||||
<SelectValue placeholder="Select vendor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Vendor name</Label>
|
||||
<Select<number | null>
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
items={(vendors ?? []).map((v) => ({ label: v.name, value: v.vendorId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor name" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.name}
|
||||
{v.code} — {v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -113,11 +113,7 @@ 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)
|
||||
|
||||
@@ -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<Set<string>>(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<string>("")
|
||||
const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
|
||||
const [priceErrors, setPriceErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sales pricing (FR-MD-01). The toggle is frontend-only: "stock" sends
|
||||
salePrice=null (sold at FIFO value); "fixed" requires a price per variant. */}
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Sale price</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a fixed selling price, or leave it to the item's stock value.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-fit rounded-lg border p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPriceMode("stock")}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
||||
priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
Use stock value
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPriceMode("fixed")}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
||||
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
Fixed price
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{priceMode === "fixed" && (
|
||||
<div className="flex max-w-xs flex-col gap-2">
|
||||
<Label className="text-base">Fix value (applies to all variants)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={fixValue}
|
||||
onChange={(e) => setFixValue(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Edit any row below to give that variant a different price.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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() {
|
||||
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
|
||||
))}
|
||||
<TableHead className="h-11 px-3 text-sm text-indigo-700">SKU</TableHead>
|
||||
{priceMode === "fixed" && (
|
||||
<TableHead className="h-11 px-3 text-sm text-indigo-700">Sale price</TableHead>
|
||||
)}
|
||||
{/* 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() {
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
|
||||
{priceMode === "fixed" && (
|
||||
<TableCell className="px-3 py-2.5">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={priceFor(variant.key)}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="py-2.5 pr-3 pl-0">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
@@ -107,6 +107,7 @@ export default function NewGrnPage() {
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [refreshingItems, setRefreshingItems] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -188,6 +189,21 @@ export default function NewGrnPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-pull the active items list so an item created in the other tab (via "New item")
|
||||
// becomes selectable without reloading the whole screen and losing the in-progress GRN.
|
||||
async function refreshItems() {
|
||||
setRefreshingItems(true)
|
||||
try {
|
||||
const res = await itemsApi.list({ pageSize: 200, status: "Active" })
|
||||
setItems(res.items)
|
||||
toast.success("Items refreshed", `${res.items.length} active item${res.items.length === 1 ? "" : "s"} loaded.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not refresh items", errorMessage(err))
|
||||
} finally {
|
||||
setRefreshingItems(false)
|
||||
}
|
||||
}
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
@@ -384,19 +400,43 @@ export default function NewGrnPage() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{mode === "po" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lines default from the PO's open quantities — add a row for anything received that wasn't ordered.
|
||||
PO lines are prefilled. Use “Add line” to receive an item that isn’t on the PO.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Off-PO items are allowed on a PO-based GRN — the server treats a line with
|
||||
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
{/* Create a brand-new item in a separate tab, then refresh to pick it up. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => window.open("/dashboard/products/new", "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<ExternalLink className="size-5" />
|
||||
New item
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={refreshItems}
|
||||
disabled={refreshingItems}
|
||||
aria-label="Refresh items"
|
||||
title="Refresh items"
|
||||
>
|
||||
<RefreshCw className={cn("size-5", refreshingItems && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{poLoading && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
@@ -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 } from "@/lib/error-map"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Bin, Warehouse } from "@/types/master-data"
|
||||
|
||||
@@ -24,36 +24,17 @@ 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<Warehouse[] | null>(null)
|
||||
const [bins, setBins] = useState<Bin[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const generatedCode = name.trim() ? generateWarehouseCode(name, (warehouses ?? []).map((w) => w.code)) : ""
|
||||
|
||||
function load() {
|
||||
warehousesApi
|
||||
.list()
|
||||
@@ -73,21 +54,23 @@ export default function WarehousesPage() {
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
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: generatedCode, name })
|
||||
const warehouse = await warehousesApi.create({ code, name })
|
||||
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
|
||||
setOpen(false)
|
||||
setCode("")
|
||||
setName("")
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
// 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.
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.code) setErrors({ code: fe.code })
|
||||
toast.error("Could not create warehouse", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
@@ -121,18 +104,19 @@ export default function WarehousesPage() {
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New warehouse</DialogTitle>
|
||||
<DialogDescription>Create a new warehouse. Its code is generated from the name. Bins are added from its detail page.</DialogDescription>
|
||||
<DialogDescription>Create a new warehouse. Bins are added from its detail page.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.code}>
|
||||
<FieldLabel htmlFor="wh-code">Code</FieldLabel>
|
||||
<Input id="wh-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="WH-MAIN" aria-invalid={!!errors.code} />
|
||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="wh-name">Name</FieldLabel>
|
||||
<Input id="wh-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="wh-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="wh-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -28,12 +27,9 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -39,9 +39,6 @@ const navItems: {
|
||||
title: string
|
||||
code: string
|
||||
href: string
|
||||
/** Where clicking the row actually navigates, if different from `href`. `href` itself
|
||||
* stays the section prefix used to decide whether this row is "active". */
|
||||
landingHref?: string
|
||||
icon: LucideIcon
|
||||
chevron?: boolean
|
||||
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
|
||||
@@ -60,21 +57,18 @@ const navItems: {
|
||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
||||
],
|
||||
},
|
||||
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
||||
{
|
||||
title: "Procurement",
|
||||
code: "procurement",
|
||||
href: "/dashboard/procurement",
|
||||
// Clicking "Procurement" itself lands on Purchase Orders — the hub page underneath
|
||||
// has nothing on it (its card grid was removed once the sidebar grew these sub-items).
|
||||
landingHref: "/dashboard/procurement/purchase-orders",
|
||||
icon: ClipboardList,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
|
||||
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
|
||||
{ title: "Vendors", code: "procurement.vendors", href: "/dashboard/vendors", icon: Truck },
|
||||
{ title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList },
|
||||
{ title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText },
|
||||
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
|
||||
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
|
||||
],
|
||||
},
|
||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
@@ -132,7 +126,7 @@ function SidebarContent({
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"flex h-full flex-col rounded-3xl bg-card p-3 shadow-sm ring-1 ring-foreground/10 transition-[width] duration-300 ease-in-out",
|
||||
"flex h-full flex-col rounded-3xl bg-white p-3 shadow-sm ring-1 ring-black/5 transition-[width] duration-300 ease-in-out",
|
||||
!isMobile && (collapsed ? "w-20" : "w-64")
|
||||
)}
|
||||
>
|
||||
@@ -144,13 +138,13 @@ function SidebarContent({
|
||||
)}
|
||||
>
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5" onClick={onClose}>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-primary">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-indigo-50">
|
||||
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-indigo-600">
|
||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
{!iconOnly && (
|
||||
<span className="text-lg font-bold tracking-tight text-foreground">Hexa ERP</span>
|
||||
<span className="text-lg font-bold tracking-tight text-slate-900">Hexa ERP</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -158,7 +152,7 @@ function SidebarContent({
|
||||
type="button"
|
||||
onClick={isMobile ? onClose : onCollapse}
|
||||
aria-label={isMobile ? "Close menu" : collapsed ? "Expand sidebar" : "Minimize sidebar"}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-xl text-slate-400 hover:bg-slate-50 hover:text-slate-600"
|
||||
>
|
||||
{isMobile ? <X className="size-4" /> : <Menu className="size-4" />}
|
||||
</button>
|
||||
@@ -178,28 +172,28 @@ function SidebarContent({
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-2xl transition-colors",
|
||||
isActive ? "bg-primary/10" : "hover:bg-muted"
|
||||
isActive ? "bg-indigo-50" : "hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={item.landingHref ?? item.href}
|
||||
href={item.href}
|
||||
title={iconOnly ? item.title : undefined}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold",
|
||||
iconOnly && "justify-center px-0",
|
||||
isActive ? "text-primary" : "text-foreground"
|
||||
isActive ? "text-indigo-600" : "text-slate-700"
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("size-5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
||||
className={cn("size-5 shrink-0", isActive ? "text-indigo-600" : "text-slate-400")}
|
||||
/>
|
||||
{!iconOnly && (
|
||||
<>
|
||||
<span className="flex-1">{item.title}</span>
|
||||
{item.chevron && !hasChildren && (
|
||||
<ChevronRight
|
||||
className={cn("size-4 shrink-0", isActive ? "text-primary/70" : "text-muted-foreground")}
|
||||
className={cn("size-4 shrink-0", isActive ? "text-indigo-400" : "text-slate-300")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -213,8 +207,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-foreground/10",
|
||||
isActive ? "text-primary" : "text-muted-foreground"
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
@@ -251,12 +245,12 @@ function SidebarContent({
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
||||
childActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
? "bg-indigo-50 text-indigo-600"
|
||||
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
||||
)}
|
||||
>
|
||||
<child.icon
|
||||
className={cn("size-4 shrink-0", childActive ? "text-primary" : "text-muted-foreground")}
|
||||
className={cn("size-4 shrink-0", childActive ? "text-indigo-600" : "text-slate-400")}
|
||||
/>
|
||||
{child.title}
|
||||
</Link>
|
||||
@@ -274,8 +268,8 @@ function SidebarContent({
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center justify-center pt-6">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-muted-foreground">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-black/5">
|
||||
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-slate-400">
|
||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -293,18 +287,13 @@ 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) => item.code === "procurement" || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
|
||||
.filter((item) => navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.code === "procurement" ? item.children : item.children?.filter((c) => navCodes.includes(c.code)),
|
||||
children: item.children?.filter((c) => navCodes.includes(c.code)),
|
||||
}))
|
||||
|
||||
// Close mobile menu on route change
|
||||
@@ -337,7 +326,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-card shadow-sm ring-1 ring-foreground/10 text-muted-foreground hover:bg-muted lg:hidden"
|
||||
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"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 {
|
||||
@@ -164,50 +163,48 @@ export function Header() {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="mx-6 mt-3 mb-6 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-4 pl-14 shadow-sm ring-1 ring-foreground/10 lg:mx-8 lg:mt-4 lg:gap-4 lg:p-4">
|
||||
<header className="mx-6 mt-3 mb-6 flex items-center justify-between gap-2 rounded-3xl bg-white py-4 pr-4 pl-14 shadow-sm ring-1 ring-black/5 lg:mx-8 lg:mt-4 lg:gap-4 lg:p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{showBackButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="Go back"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
<h1 className="text-base font-bold tracking-tight text-foreground sm:text-xl">{title}</h1>
|
||||
<h1 className="text-base font-bold tracking-tight text-slate-900 sm:text-xl">{title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<ThemeToggle />
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
className="relative flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="relative flex size-10 shrink-0 items-center justify-center rounded-full text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Bell className="size-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary p-0" />
|
||||
<Badge className="absolute top-1.5 right-1.5 size-2 rounded-full bg-indigo-600 p-0" />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[calc(100vw-2rem)] max-w-sm sm:w-96"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-foreground/10 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-foreground">Notifications</p>
|
||||
<div className="flex items-center justify-between border-b border-black/5 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-slate-900">Notifications</p>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllAsRead}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
className="text-xs font-medium text-indigo-600 hover:underline"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
@@ -224,22 +221,22 @@ export function Header() {
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
"flex gap-3 border-b border-foreground/10 px-4 py-3 last:border-0",
|
||||
notification.unread && "bg-primary/10"
|
||||
"flex gap-3 border-b border-black/5 px-4 py-3 last:border-0",
|
||||
notification.unread && "bg-indigo-50/50"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 size-2 shrink-0 rounded-full",
|
||||
notification.unread ? "bg-primary" : "bg-transparent"
|
||||
notification.unread ? "bg-indigo-600" : "bg-transparent"
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{notification.title}</p>
|
||||
<p className="text-sm font-medium text-slate-900">{notification.title}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{notification.description}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{notification.time}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{notification.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -249,19 +246,19 @@ export function Header() {
|
||||
</Popover>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted">
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-slate-50">
|
||||
<Avatar>
|
||||
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
|
||||
<AvatarFallback className="bg-indigo-50 font-semibold text-indigo-600">
|
||||
{initials(displayName(user))}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden text-sm font-semibold text-foreground sm:block">
|
||||
<span className="hidden text-sm font-semibold text-slate-700 sm:block">
|
||||
{displayName(user)}
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-2">
|
||||
<div className="px-2 py-2.5">
|
||||
<p className="text-base font-semibold text-foreground">{displayName(user)}</p>
|
||||
<p className="text-base font-semibold text-slate-900">{displayName(user)}</p>
|
||||
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"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 <div className={cn("size-10 shrink-0", className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
const isDark = resolvedTheme === "dark"
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{isDark ? <Sun className="size-5" /> : <Moon className="size-5" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "16.2.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-day-picker": "^10.0.1",
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user