diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index f4c519b..8e8075d 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -13,37 +13,25 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** > **Scope note:** this foundation was built alongside the GRN feature and only covers the endpoints GRN consumes (items, warehouses/bins, vendors, uoms, purchase-orders, grns). Other master-data/procurement API methods still need their own `lib/api/*.ts` files when those screens are built. -> **2026-07-14 — mock data removed, `lib/api/mock-data.ts` deleted.** Every `lib/api/*.ts` file's commented-out real `fetch` block was restored and the in-memory mock block deleted, per user request. Master Data + Procurement modules (`categories`, `uoms`, `warehouses`, `items`, `vendors`, `requisitions`, `rfqs`, `purchase-orders`) now call the live backend (`Backend/ERPCore` §1/§2, both implemented + smoke-tested). GRN/Stock/Purchase-Return/Reason-Code modules (`grns`, `stock`, `stock-transfers`, `stock-adjustments`, `stock-counts`, `purchase-returns`, `reason-codes`, `wastage`) also now call real (documented-or-assumed) endpoint paths, but **no backend controller exists for any of them yet** (`Backend/PROGRESS.md` §3/§4/§5 are unstarted, and Purchase Return is explicitly deferred) — those calls will 404 against a running backend until that work happens. This was a deliberate tradeoff the user confirmed explicitly (see the two `AskUserQuestion` exchanges this session) rather than silently faking data. - ## 1. Auth - [~] Login screen — UI built (`app/login`); not yet wired to `POST /auth/login` / token storage - [~] Forgot password — add email screen — UI built (`app/login/forgot`); not yet wired to API - [~] Forgot password — verify OTP screen — UI built (`app/login/forgot/otp`); not yet wired to API - [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API -> **2026-07-14:** left untouched during the mock-data removal pass — there was never a mock `lib/api/auth.ts` to begin with (these screens simply don't call anything yet), and `Backend/PROGRESS.md` §6 confirms no `POST /auth/login` controller exists (`ICurrentUser`/JWT validation are wired, but there's no token issuer). Nothing to wire until that lands. - ## 2. Master Data screens -- [x] 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-14: wired to the live backend** (`lib/api/items.ts`), no more mock data. -- [x] 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. **2026-07-14: wired to the live backend.** Note: the real `ItemDetailDto` does not include a `conversions` field (only `Reorder`) — the Item detail page's conversion editor now round-trips purely through `PUT /items/{itemId}/uom-conversions`'s own request/response, not the GET response. -- [x] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04. **2026-07-14: wired to the live backend.** -- [x] Vendors (`app/dashboard/vendors` list + search/status-filter + create dialog, `[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-06. First screen this session to exercise the ETag/`If-Match`/412 pattern end-to-end (`lib/api-client.ts`'s `ApiResult` was built earlier but unused until now). **2026-07-14: wired to the live backend.** -- [x] Warehouses + Bins (`app/dashboard/warehouse` list + create-warehouse dialog, `[id]` bin list + create-bin dialog) — FR-WH-01/FR-MD-07. **2026-07-14: wired to the live backend**; added `warehousesApi.get()` (the page needed it but the original stub never had it) and wrapped the real `GET /warehouses/{id}/bins` (`IReadOnlyList`, not paged) into a synthetic single-page `PagedResponse` so existing `.items`-based call sites didn't need touching. -- [x] Item reorder settings — edited inline on the Item detail page (per-warehouse point/qty rows) via `PUT /items/{itemId}/reorder` — FR-MD-05. **2026-07-14: wired to the live backend.** +- [~] 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. +- [~] 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 +- [~] Vendors (`app/dashboard/vendors` list + search/status-filter + create dialog, `[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-06. First screen this session to exercise the ETag/`If-Match`/412 pattern end-to-end (`lib/api-client.ts`'s `ApiResult` was built earlier but unused until now). +- [~] Warehouses + Bins (`app/dashboard/warehouse` list + create-warehouse dialog, `[id]` bin list + create-bin dialog) — FR-WH-01/FR-MD-07. Frontend-only (see note below); no ETag handling since there's no edit/delete yet, only create. +- [~] Item reorder settings — edited inline on the Item detail page (per-warehouse point/qty rows) via `PUT /items/{itemId}/reorder` — FR-MD-05 ## 3. Procurement screens -- [x] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01. **2026-07-14: wired to the live backend**; dropped `RequisitionSummary.lineCount` (the real `RequisitionSummaryDto` doesn't return it) from the list screen. -- [x] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02. **2026-07-14: wired to the live backend**, with real contract mismatches found and fixed (see the dedicated deviation note below — this one needed real rework, not just an API-client swap). -- [x] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07. **2026-07-14: wired to the live backend.** -- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page. **Still `[~]`:** the API client now calls real endpoint paths, but `Backend/PROGRESS.md` §2 explicitly defers Purchase Return until GRN + Stock Core exist — these calls 404 against a live backend today. - -> **2026-07-14 — RFQ real-backend contract mismatches found while removing mock data (`Backend/ERPCore/Dtos/Procurement/RfqDtos.cs` / `RfqService.cs` vs. the frontend's speculative types):** -> - The backend does **not** persist which vendors were invited to an RFQ (`RfqService.MapRfq` never sets a vendor list) — `Rfq`/`RfqSummary` no longer carry `vendorIds`. `types/procurement.ts` and the `[id]`/list pages were updated to stop relying on it; the RFQ detail page now shows "Quoted: ..." (derived from `RfqComparison.vendorIds`, i.e. vendors who have actually submitted a quotation) instead of "Invited: ...". -> - `CreateRfqRequest.RequisitionId` is `[Required]` server-side, not optional as the frontend assumed — `app/dashboard/procurement/rfqs/new/page.tsx` now requires picking a Submitted requisition (a picker was added for the case where one wasn't passed in via `?requisitionId=`) before an RFQ can be created. -> - `RfqComparisonDto`'s real shape is `{ rfqId, vendorIds, rows: [{ itemId, qty, quotes: [{ vendorId, quotationId, unitPrice, leadDays }] }] }` — the frontend's assumed `{ lines: [{ cells }] }` naming was wrong; `types/procurement.ts` (`RfqComparisonRow`/`RfqComparisonCell`) and both consuming pages (RFQ detail, PO-from-RFQ prefill) were corrected to match. -> - The "Record a quotation" vendor picker on the RFQ detail page now offers any active vendor who hasn't already quoted (matching what `AddQuotationAsync` actually validates — vendor exists + hasn't already quoted, not "was invited") rather than a now-nonexistent "pending invited vendors" list. -> - `GET /rfqs` (list) still does not exist on `RfqsController` (only `GET /rfqs/{id}`) — `rfqsApi.list()` calls it anyway per the user's "remove all mock data" instruction, so the RFQ list screen 404s until that endpoint is added. Flagged here for whoever picks up `Backend/PROGRESS.md` §2. -> - Also fixed while cross-checking DTOs: `ReqLine.requiredBy` is nullable (`DateOnly?` server-side, not a mandatory string), and `RequisitionSummary` never had a `lineCount` field (removed from the requisitions list column and the RFQ picker label). +- [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01 +- [~] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02 +- [~] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07 +- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page ## 4. Receiving screens - [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail @@ -54,7 +42,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** > **`[~]` not `[x]`, by design:** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend exists yet** (this was frontend-only work; see the deviation below). > -> **2026-07-14 — mock data removed:** `lib/api/grns.ts` now calls real endpoint paths (`apiRequest`/`apiRequestWithETag` against `/grns`) instead of an in-memory store. There is still no `GrnsController` on the backend (`Backend/PROGRESS.md` §3 unstarted), so every call here 404s against a running backend — this was a deliberate, user-confirmed tradeoff (see `AskUserQuestion` exchange this session), not an oversight. Also added `grnsApi.getWithETag()` (the assumed `GET /grns/{id}` didn't have an ETag-returning variant, but `app/dashboard/receiving/grn/[id]/edit/page.tsx`'s `update()` call needs an `If-Match` token to send — matching the same pattern `purchase-orders.ts` already uses for `get`/`getWithETag`). +> **UI-only / mock-data mode (temporary):** `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` currently return **in-memory sample data** (`lib/api/mock-data.ts`) instead of calling the real API, so the three screens are fully browsable/demoable (list → create against a mocked PO or as a direct receipt → detail → confirm → release/reject) without a running backend. Each `lib/api/*.ts` file keeps the real `fetch`-based implementation **commented out directly above** the mock block — switch back by deleting the mock block, uncommenting the real block, and deleting `lib/api/mock-data.ts` once the GRN backend exists. `npm run dev` + `tsc --noEmit` + `eslint` are clean (aside from the pre-existing, unrelated `app/login/page.tsx` resolver-typing error and the two `react-hooks/set-state-in-effect` warnings shared with `hooks/use-mobile.ts`). > > **Deviation — `GET /grns` and `GET /grns/{id}`:** the API doc only specifies `POST /grns`, `POST /grns/{id}/confirm`, `POST /grns/{id}/lines/{id}/release` (no list/detail read). A list screen and a confirm/release screen both need to reload a GRN, so `lib/api/grns.ts` (`grnsApi.list`/`grnsApi.get`) and `types/grn.ts` assume these two GET endpoints will exist once the backend is built — flag this to whoever implements `Backend/PROGRESS.md` §3 so `docs/11-BACKEND-PHASE1.md` gets the corresponding doc update. @@ -70,11 +58,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Wastage (`.../stock/wastage` report, `/new` record) — **not a documented endpoint or SRS document type**: damage/theft-loss/expiry write-offs are modeled as Stock Adjustments with a loss-type reason code (FR-STK-07); `lib/api/wastage.ts` is a frontend-only lens over `stockAdjustmentsApi` + the shared mock ledger (filters to reason codes `DMG`/`LOSS`/`EXPWO`, flattens adjustment lines to per-item wastage records, sums matching outbound ledger entries for cost). No new backend concept — confirmed with the user before building (asked whether "Wastage" meant this vs. a distinct document type). - Sidebar: added "Stock" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/stock`; top-bar titles mapped per route (`components/Layouts/Header.tsx`) -> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. +> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4). > -> **2026-07-14 — mock data removed:** the in-memory Stock Core (`mockStockLayers`/`mockStockLedger`/`receiveLayer`/`consumeFifo`/etc., previously in `lib/api/mock-data.ts`) is gone. `stock.ts`/`stock-transfers.ts`/`stock-adjustments.ts`/`stock-counts.ts`/`reason-codes.ts` now call real endpoint paths; `stockApi.onHandList()` was rewritten to compose real (`itemsApi.list` × `warehousesApi.list` × `stockApi.onHand` per pair) calls instead of iterating a mock-derived key set. `wastage.ts` was rewritten the same way — it now calls `reasonCodesApi.list()`/`stockAdjustmentsApi.list()+get()`/`stockApi.ledger()` instead of reading mock arrays directly, so `wastageReasonCodeIds()` and `wastageApi.list()` are now `async` (both call sites in `app/dashboard/stock/wastage/{page,new/page}.tsx` were updated accordingly). **None of §5's backend exists yet** (`Backend/PROGRESS.md` §4/§5 unstarted), so every one of these calls 404s against a running backend — a deliberate, user-confirmed tradeoff (see `AskUserQuestion` exchange this session), not an oversight. `tsc --noEmit` and `eslint` are clean (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` pattern remain, unchanged from before this pass). -> -> **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions). +> **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience that iterates known item/warehouse pairs and calls the (documented) on-hand computation per pair; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions). > > **Simplifications (mock-data limitations, not spec decisions):** `StockLayer` has no per-bin field (matches the real ER model, docs/10 Part C.5 — only `StockLedger` carries `bin_id`), so Count lines don't attempt bin-level snapshotting. Transfers don't expose batch selection in the create UI (FIFO picks layers regardless of batch). Adjustment increases always cost at "last known cost" for that item/warehouse (FR-STK-07); there's no landed-cost/manual-cost override. In-transit quantity is shown for visibility at the destination warehouse only and is not subtracted a second time from the source's `available` (dispatch already reduced the source layer's `qtyRemaining`) — the docs' `available = onHand − onHold − reserved − inTransit(out)` formula is ambiguous on this point given dispatch semantics; this was a judgment call, noted here for whoever builds the real backend to confirm or correct. @@ -142,10 +128,3 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Housekeeping:** removed `app/dashboard/vendors/view vendors/` — confirmed byte-for-byte identical to `vendors/[id]/page.tsx` and untracked in git, same IDE-artifact pattern (malformed file-open path) as the garbled GRN duplicate folders removed in the Warehouse Management pass; noted here rather than silently dropped. Left `app/dashboard/receiving/grn/[id]/edit/` alone — it's untracked too but is a distinct, non-duplicate GRN-edit screen, not an artifact. - Same `[~]` posture as every other module this session: built against the documented+assumed Master Data contract (`docs/11-BACKEND-PHASE1.md` §2), no Master Data backend exists (`Backend/PROGRESS.md` §1 unchanged). - Verified: `tsc --noEmit` clean after clearing a stale `.next` type cache that still referenced the just-deleted `view vendors` route (same pre-existing `login/page.tsx` error only remains); `eslint` clean aside from the same established `set-state-in-effect` pattern; `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure, unrelated). All 5 new/changed routes confirmed rendering 200 with no error boundary against the dev server (one false-alarm 500 during testing traced to an unrelated stale process already bound to port 3000, not this code — retested clean on the actual dev server port). - -### 2026-07-14 — Mock data removed everywhere; Master Data + Procurement wired to the live backend -- User asked to integrate the (now-built) backend into the frontend, frontend-only, no mock data. Confirmed scope first: the backend only has controllers for Master Data (`Backend/PROGRESS.md` §1) and Procurement minus returns (§2) — GRN (§3), Stock Core (§4), Stock Transactions (§5), and Auth (§6) have no controllers at all yet. User's explicit instruction after that was still "remove mock data in all" — so every `lib/api/*.ts` module was switched to real `fetch` calls, accepting that GRN/Stock/Purchase-Return/Reason-Code calls will 404 against a live backend until that work exists (not silently left mocked). -- **Deleted `lib/api/mock-data.ts` entirely** and restored the real-`fetch` implementation in every one of: `categories`, `uoms`, `warehouses` (added missing `get()`; wrapped the real non-paged `GET /warehouses/{id}/bins` array response into a synthetic `PagedResponse` so existing `.items` call sites kept working), `items`, `vendors`, `requisitions`, `purchase-orders`, `purchase-returns`, `reason-codes`, `grns` (added `getWithETag()` for the GRN edit page's `If-Match`), `stock`, `stock-transfers`, `stock-adjustments`, `stock-counts`. `wastage.ts` had no prior real-mode block (it's a frontend-only lens with no documented endpoint of its own) — rewrote it to compose the now-real `reasonCodesApi`/`stockAdjustmentsApi`/`stockApi` calls instead of reading mock arrays directly; its two exports became `async` as a result, and both call sites (`app/dashboard/stock/wastage/{page,new/page}.tsx`) were updated. -- **Did not blindly trust the frontend's pre-written "real implementation" comments** — cross-checked every Master Data/Procurement DTO against the actual `Backend/ERPCore/Dtos/**/*.cs` and controllers before wiring, since those blocks were written speculatively before/alongside the real backend and had drifted in the RFQ case (see the dedicated §3 deviation note above): the backend doesn't persist RFQ-invited vendors, `requisitionId` is required (not optional) to create an RFQ, `RfqComparisonDto` uses `rows`/`quotes`/`quotationId` (not `lines`/`cells`), and `RequisitionSummaryDto` has no `lineCount`. Fixed `types/procurement.ts` and the RFQ list/detail/new pages plus the PO-from-RFQ prefill accordingly, rather than shipping types that would silently be `undefined` at runtime. -- Left `app/login/*` untouched — no mock auth existed to remove, and there's still no `POST /auth/login` controller to wire to. -- Verified: `tsc --noEmit` clean (only the pre-existing, unrelated `login/page.tsx` resolver-typing error remains — confirmed pre-existing via `git` history, not introduced here). `eslint` shows the same established `react-hooks/set-state-in-effect`/`static-components` pattern as before, confirmed unchanged by spot-checking it also fires on files untouched this session (`app/login/forgot/reset/page.tsx`). Did not start the backend/Postgres or click through the UI live in this pass — verification was type-check + lint only; the Master Data/Procurement screens should be smoke-tested against a running `dotnet run` + Postgres before considering this "done" in practice. diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx index 7c582b0..06d58ae 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -115,7 +115,7 @@ function NewPurchaseOrderContent() { setVendorId(rfqVendorId) setLines( rfq.lines.map((l): DraftLine => { - const cell = comparison.rows.find((row) => row.itemId === l.itemId)?.quotes.find((c) => c.vendorId === rfqVendorId) + const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId) return { key: newKey(), itemId: l.itemId, diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx index a121599..27d444c 100644 --- a/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx @@ -97,6 +97,7 @@ export default function RequisitionsListPage() { Doc No Status + Lines Requested by Created @@ -112,6 +113,7 @@ export default function RequisitionsListPage() { + {r.lineCount} #{r.requestedBy} {new Date(r.createdAt).toLocaleString()} diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx index 10a3ec3..a60d775 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx @@ -67,10 +67,13 @@ export default function RfqDetailPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [rfqId]) - // The backend derives `comparison.vendorIds` from vendors who have actually quoted - // (RfqService.GetComparisonAsync) — invited-but-not-yet-quoted vendors aren't - // persisted anywhere, so "pending" vendors can no longer be listed here. - const quotedVendorIds = useMemo(() => new Set(comparison?.vendorIds ?? []), [comparison]) + const quotedVendorIds = useMemo(() => { + const set = new Set() + for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId) + return set + }, [comparison]) + + const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds]) function itemFor(itemId: number) { return items.find((i) => i.itemId === itemId) @@ -79,14 +82,6 @@ export default function RfqDetailPage() { return vendors.find((v) => v.vendorId === vendorId) } - // The backend doesn't persist which vendors were "invited" — any active vendor that - // hasn't already quoted can be offered a quotation (AddQuotationAsync only checks the - // vendor exists and hasn't already quoted this RFQ, not that it was invited). - const quotableVendors = useMemo( - () => vendors.filter((v) => v.status === "Active" && !quotedVendorIds.has(v.vendorId)), - [vendors, quotedVendorIds] - ) - function selectQuoteVendor(vendorId: number | null) { setQuoteVendorId(vendorId) setQuoteFormError(null) @@ -161,9 +156,8 @@ export default function RfqDetailPage() {

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

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

Vendor comparison

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

No quotations recorded yet.

) : (
@@ -205,19 +199,19 @@ export default function RfqDetailPage() { Item - {comparison.vendorIds.map((vid) => ( + {rfq.vendorIds.map((vid) => ( {vendorFor(vid)?.code ?? `#${vid}`} ))} - {comparison.rows.map((row) => { - const item = itemFor(row.itemId) + {comparison.lines.map((line) => { + const item = itemFor(line.itemId) return ( - - {item ? `${item.sku} — ${item.name}` : `Item #${row.itemId}`} - {comparison.vendorIds.map((vid) => { - const cell = row.quotes.find((c) => c.vendorId === vid) + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {rfq.vendorIds.map((vid) => { + const cell = line.cells.find((c) => c.vendorId === vid) return ( {cell ? ( @@ -254,7 +248,7 @@ export default function RfqDetailPage() { )}
- {quotableVendors.length > 0 && ( + {pendingVendors.length > 0 && (

Record a quotation

@@ -262,12 +256,12 @@ export default function RfqDetailPage() { value={quoteVendorId} onValueChange={selectQuoteVendor}> - + - {quotableVendors.map((v) => ( - - {v.code} — {v.name} + {pendingVendors.map((vid) => ( + + {vendorFor(vid)?.code ?? `#${vid}`} — {vendorFor(vid)?.name} ))} diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx index f9740e7..ee05136 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx @@ -12,7 +12,7 @@ import { itemsApi } from "@/lib/api/items" import { errorMessage } from "@/lib/error-map" import { validateRfqLine } from "@/lib/validations/procurement" import { cn } from "@/lib/utils" -import { CreateRfqLineInput, RequisitionSummary } from "@/types/procurement" +import { CreateRfqLineInput } from "@/types/procurement" import { ItemListItem, Vendor } from "@/types/master-data" import { Button, buttonVariants } from "@/components/ui/button" @@ -44,17 +44,13 @@ function emptyLine(): DraftLine { function NewRfqContent() { const router = useRouter() const searchParams = useSearchParams() - const requisitionIdParam = Number(searchParams.get("requisitionId")) || null + const requisitionId = Number(searchParams.get("requisitionId")) || null const [items, setItems] = useState(null) const [vendors, setVendors] = useState(null) - const [requisitions, setRequisitions] = useState(null) const [loadError, setLoadError] = useState(null) - const [requisitionLoading, setRequisitionLoading] = useState(!!requisitionIdParam) + const [requisitionLoading, setRequisitionLoading] = useState(!!requisitionId) - // Backend requires a requisitionId to create an RFQ (CreateRfqRequest.RequisitionId - // is [Required]) — if one wasn't passed in via query param, the user must pick one. - const [requisitionId, setRequisitionId] = useState(requisitionIdParam) const [vendorIds, setVendorIds] = useState>(new Set()) const [lines, setLines] = useState([emptyLine()]) const [lineErrors, setLineErrors] = useState>>({}) @@ -63,46 +59,24 @@ function NewRfqContent() { const [submitting, setSubmitting] = useState(false) useEffect(() => { - Promise.all([ - itemsApi.list({ pageSize: 200, status: "Active" }), - vendorsApi.list({ pageSize: 200, status: "Active" }), - requisitionsApi.list({ status: "Submitted", pageSize: 200 }), - ]) - .then(([it, ve, req]) => { + Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), vendorsApi.list({ pageSize: 200, status: "Active" })]) + .then(([it, ve]) => { setItems(it.items) setVendors(ve.items) - setRequisitions(req.items) }) .catch((err) => setLoadError(errorMessage(err))) }, []) useEffect(() => { - if (!requisitionIdParam) return + if (!requisitionId) return requisitionsApi - .get(requisitionIdParam) + .get(requisitionId) .then((req) => { setLines(req.lines.map((l): DraftLine => ({ key: newKey(), itemId: l.itemId, qty: String(l.qty) }))) }) .catch((err) => setHeaderError(errorMessage(err))) .finally(() => setRequisitionLoading(false)) - }, [requisitionIdParam]) - - function selectRequisition(id: number | null) { - setRequisitionId(id) - setHeaderError(null) - if (!id) { - setLines([emptyLine()]) - return - } - setRequisitionLoading(true) - requisitionsApi - .get(id) - .then((req) => { - setLines(req.lines.map((l): DraftLine => ({ key: newKey(), itemId: l.itemId, qty: String(l.qty) }))) - }) - .catch((err) => setHeaderError(errorMessage(err))) - .finally(() => setRequisitionLoading(false)) - } + }, [requisitionId]) function toggleVendor(vendorId: number) { setVendorIds((prev) => { @@ -125,10 +99,6 @@ function NewRfqContent() { setHeaderError(null) setSubmitError(null) - if (!requisitionId) { - setHeaderError("Select a requisition to raise this RFQ against.") - return - } if (vendorIds.size === 0) { setHeaderError("Select at least one vendor to invite.") return @@ -192,24 +162,6 @@ function NewRfqContent() { {!loading && ( <> - {!requisitionIdParam && ( -
- - value={requisitionId} onValueChange={selectRequisition}> - - - - - {(requisitions ?? []).map((r) => ( - - {r.docNo} - - ))} - - -
- )} -
diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx index d1ce401..6cc1f9c 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx @@ -5,8 +5,10 @@ import Link from "next/link" import { FileText, Plus } from "lucide-react" import { rfqsApi } from "@/lib/api/rfqs" +import { vendorsApi } from "@/lib/api/vendors" import { errorMessage } from "@/lib/error-map" import { RfqSummary } from "@/types/procurement" +import { Vendor } from "@/types/master-data" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" @@ -15,15 +17,22 @@ import { RfqStatusBadge } from "@/components/procurement/status-badges" export default function RfqsListPage() { const [rfqs, setRfqs] = useState(null) + const [vendors, setVendors] = useState([]) const [error, setError] = useState(null) useEffect(() => { - rfqsApi - .list() - .then((r) => setRfqs(r.items)) + Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })]) + .then(([r, v]) => { + setRfqs(r.items) + setVendors(v.items) + }) .catch((err) => setError(errorMessage(err))) }, []) + function vendorNames(vendorIds: number[]) { + return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ") + } + return (
@@ -66,6 +75,7 @@ export default function RfqsListPage() { Doc No Requisition + Vendors invited Status Created @@ -78,7 +88,8 @@ export default function RfqsListPage() { {r.docNo} - #{r.requisitionId} + {r.requisitionId ? `#${r.requisitionId}` : } + {vendorNames(r.vendorIds)} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx index 92fe305..941f7fe 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx @@ -66,7 +66,6 @@ export default function EditGrnPage() { const grnId = Number(params.id) const [grn, setGrn] = useState(null) - const [etag, setEtag] = useState(null) const [warehouses, setWarehouses] = useState(null) const [items, setItems] = useState(null) const [uoms, setUoms] = useState(null) @@ -84,19 +83,18 @@ export default function EditGrnPage() { useEffect(() => { if (!Number.isFinite(grnId)) return Promise.all([ - grnsApi.getWithETag(grnId), + grnsApi.get(grnId), warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list(), ]) - .then(([{ data: g, etag: tag }, wh, it, uo]) => { + .then(([g, wh, it, uo]) => { if (g.status !== "Draft") { setLoadError(`${g.docNo} is ${g.status.toLowerCase()} and can no longer be edited.`) setGrn(g) return } setGrn(g) - setEtag(tag) setWarehouses(wh.items) setItems(it.items) setUoms(uo.items) @@ -190,23 +188,14 @@ export default function EditGrnPage() { } }) - if (!etag) { - setSubmitError("Missing concurrency token — reload the page and try again.") - return - } - setSubmitting(true) try { - const updated = await grnsApi.update( - grn.grnId, - { - poId: grn.poId, - vendorId: grn.vendorId, - warehouseId: warehouseId as number, - lines: payloadLines, - }, - etag - ) + const updated = await grnsApi.update(grn.grnId, { + poId: grn.poId, + vendorId: grn.vendorId, + warehouseId: warehouseId as number, + lines: payloadLines, + }) toast.success("GRN updated", `${updated.docNo} saved.`) router.push(`/dashboard/receiving/grn/${updated.grnId}`) } catch (err) { diff --git a/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx b/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx index 29c0d11..6a1fe16 100644 --- a/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx @@ -40,14 +40,9 @@ export default function NewWastagePage() { const [result, setResult] = useState(null) useEffect(() => { - Promise.all([ - warehousesApi.list(), - itemsApi.list({ pageSize: 200, status: "Active" }), - reasonCodesApi.list("Adjustment"), - wastageReasonCodeIds(), - ]) - .then(([wh, it, rc, wastageIdList]) => { - const wastageIds = new Set(wastageIdList) + Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")]) + .then(([wh, it, rc]) => { + const wastageIds = new Set(wastageReasonCodeIds()) setWarehouses(wh.items) setItems(it.items) setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId))) diff --git a/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx b/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx index 8c715bc..a5c5a1b 100644 --- a/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx @@ -31,9 +31,9 @@ export default function WastagePage() { const [reasonCodeId, setReasonCodeId] = useState("All") useEffect(() => { - Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list(), wastageReasonCodeIds()]) - .then(([rc, it, wh, wastageIdList]) => { - const wastageIds = new Set(wastageIdList) + Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) + .then(([rc, it, wh]) => { + const wastageIds = new Set(wastageReasonCodeIds()) setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId))) setItems(it.items) setWarehouses(wh.items) diff --git a/Frontend/erp-system/lib/api/categories.ts b/Frontend/erp-system/lib/api/categories.ts index 225de59..6602cc3 100644 --- a/Frontend/erp-system/lib/api/categories.ts +++ b/Frontend/erp-system/lib/api/categories.ts @@ -1,16 +1,64 @@ // One typed client method per Category endpoint (docs/11-BACKEND-PHASE1.md §2.3, FR-MD-04). -import { apiRequest } from "@/lib/api-client" +// +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens +// can be reviewed without a running backend. Restore the commented block and +// delete the mock block once Backend/PROGRESS.md §1 (Master Data) exists. import { PagedResponse } from "@/types/common" import { Category, CategoryTreeNode, CreateCategoryRequest } from "@/types/master-data" +import { allocateCategoryId, mockCategories, mockDelay } from "@/lib/api/mock-data" + +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest } from "@/lib/api-client" +// +// export const categoriesApi = { +// list() { +// return apiRequest>("/categories") +// }, +// tree() { +// return apiRequest("/categories?tree=true") +// }, +// create(request: CreateCategoryRequest) { +// return apiRequest("/categories", { method: "POST", body: request }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- +function buildTree(categories: Category[]): CategoryTreeNode[] { + const nodes = new Map(categories.map((c) => [c.categoryId, { ...c, children: [] }])) + const roots: CategoryTreeNode[] = [] + for (const node of nodes.values()) { + if (node.parentId !== null && nodes.has(node.parentId)) { + nodes.get(node.parentId)!.children.push(node) + } else { + roots.push(node) + } + } + return roots +} export const categoriesApi = { - list() { - return apiRequest>("/categories") + list(): Promise> { + const items = [...mockCategories].sort((a, b) => a.name.localeCompare(b.name)) + return mockDelay({ + items, + pagination: { page: 1, pageSize: 200, totalItems: items.length, totalPages: 1 }, + }) }, - tree() { - return apiRequest("/categories?tree=true") + + tree(): Promise { + return mockDelay(buildTree(mockCategories)) }, - create(request: CreateCategoryRequest) { - return apiRequest("/categories", { method: "POST", body: request }) + + create(request: CreateCategoryRequest): Promise { + const name = request.name.trim() + if (!name) return Promise.reject(new Error("Category name is required.")) + const parentId = request.parentId ?? null + if (parentId !== null && !mockCategories.some((c) => c.categoryId === parentId)) { + return Promise.reject(new Error("Selected parent category does not exist.")) + } + const category: Category = { categoryId: allocateCategoryId(), name, parentId } + mockCategories.push(category) + return mockDelay(category) }, } diff --git a/Frontend/erp-system/lib/api/grns.ts b/Frontend/erp-system/lib/api/grns.ts index 32ddd82..8021ced 100644 --- a/Frontend/erp-system/lib/api/grns.ts +++ b/Frontend/erp-system/lib/api/grns.ts @@ -1,71 +1,282 @@ // One typed client method per GRN endpoint (docs/11-BACKEND-PHASE1.md §4). // -// Note: no GrnsController exists yet (Backend/PROGRESS.md §3 is unstarted). -// These calls will 404 until that backend is built. GET /grns and GET -// /grns/{id} are also not in docs/11-BACKEND-PHASE1.md §4 — see -// Frontend/PROGRESS.md §4 for that assumed-endpoint deviation. +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with an in-memory mock store (lib/api/mock-data.ts) so the GRN +// screens (list/create/confirm/release) can be reviewed end-to-end without a +// running backend. Restore the commented block and delete the mock block once +// Backend/PROGRESS.md §3/§4 (GRN + Stock Core) exist. Note GET /grns and +// GET /grns/{id} are not yet in docs/11-BACKEND-PHASE1.md §4 — see the note in +// Frontend/PROGRESS.md §4. import { PagedResponse } from "@/types/common" -import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" import { ConfirmGrnResponse, CreateGrnRequest, + CreatedLayer, Grn, GrnStatus, GrnSummary, ReleaseAction, ReleaseGrnLineResponse, } from "@/types/grn" +import { allocateGrnId, allocateGrnLineId, mockDelay, mockGrns, mockPurchaseOrders, receiveLayer } from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +// +// export interface ListGrnsParams { +// page?: number +// pageSize?: number +// q?: string +// status?: GrnStatus +// poId?: number +// warehouseId?: number +// } +// +// export const grnsApi = { +// list(params: ListGrnsParams = {}) { +// return apiRequest>(`/grns${buildQuery(params)}`) +// }, +// +// get(grnId: number) { +// return apiRequest(`/grns/${grnId}`) +// }, +// +// async create(request: CreateGrnRequest) { +// const { data } = await apiRequestWithETag("/grns", { method: "POST", body: request }) +// return data +// }, +// +// // Draft-only — a GRN with createdLayers/ledger postings (Confirmed/Closed) is +// // immutable per docs/11 §4. +// async update(grnId: number, request: CreateGrnRequest, ifMatch: string) { +// const { data } = await apiRequestWithETag(`/grns/${grnId}`, { method: "PUT", body: request, ifMatch }) +// return data +// }, +// +// remove(grnId: number) { +// return apiRequest(`/grns/${grnId}`, { method: "DELETE" }) +// }, +// +// confirm(grnId: number, idempotencyKey?: string) { +// return apiRequest(`/grns/${grnId}/confirm`, { +// method: "POST", +// body: {}, +// idempotencyKey, +// }) +// }, +// +// releaseLine(grnId: number, grnLineId: number, action: ReleaseAction) { +// return apiRequest(`/grns/${grnId}/lines/${grnLineId}/release`, { +// method: "POST", +// body: { action }, +// }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export interface ListGrnsParams { page?: number pageSize?: number + /** Free-text search over doc no. and vendor/PO/warehouse id (docs/11 §1.5). */ q?: string status?: GrnStatus poId?: number warehouseId?: number } +function toSummary(grn: Grn): GrnSummary { + return { + grnId: grn.grnId, + docNo: grn.docNo, + poId: grn.poId, + vendorId: grn.vendorId, + warehouseId: grn.warehouseId, + status: grn.status, + createdAt: grn.createdAt, + } +} + export const grnsApi = { - list(params: ListGrnsParams = {}) { - return apiRequest>(`/grns${buildQuery(params)}`) - }, + list(params: ListGrnsParams = {}): Promise> { + const term = params.q?.trim().toLowerCase() - get(grnId: number) { - return apiRequest(`/grns/${grnId}`) - }, + const filtered = mockGrns + .filter((g) => !params.status || g.status === params.status) + .filter((g) => !params.poId || g.poId === params.poId) + .filter((g) => !params.warehouseId || g.warehouseId === params.warehouseId) + .filter((g) => { + if (!term) return true + const haystack = [g.docNo, String(g.poId ?? ""), String(g.vendorId), String(g.warehouseId)] + .join(" ") + .toLowerCase() + return haystack.includes(term) + }) + .map(toSummary) + .sort((a, b) => b.grnId - a.grnId) - getWithETag(grnId: number) { - return apiRequestWithETag(`/grns/${grnId}`) - }, + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 20 + const start = (page - 1) * pageSize + const items = filtered.slice(start, start + pageSize) + const totalItems = filtered.length + const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize) - async create(request: CreateGrnRequest) { - const { data } = await apiRequestWithETag("/grns", { method: "POST", body: request }) - return data - }, - - // Draft-only — a GRN with createdLayers/ledger postings (Confirmed/Closed) is - // immutable per docs/11 §4. - async update(grnId: number, request: CreateGrnRequest, ifMatch: string) { - const { data } = await apiRequestWithETag(`/grns/${grnId}`, { method: "PUT", body: request, ifMatch }) - return data - }, - - remove(grnId: number) { - return apiRequest(`/grns/${grnId}`, { method: "DELETE" }) - }, - - confirm(grnId: number, idempotencyKey?: string) { - return apiRequest(`/grns/${grnId}/confirm`, { - method: "POST", - body: {}, - idempotencyKey, + return mockDelay({ + items, + pagination: { page, pageSize, totalItems, totalPages }, }) }, - releaseLine(grnId: number, grnLineId: number, action: ReleaseAction) { - return apiRequest(`/grns/${grnId}/lines/${grnLineId}/release`, { - method: "POST", - body: { action }, - }) + get(grnId: number): Promise { + const grn = mockGrns.find((g) => g.grnId === grnId) + if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`)) + return mockDelay(grn) + }, + + create(request: CreateGrnRequest): Promise { + const grnId = allocateGrnId() + const referencedPo = request.poId ? mockPurchaseOrders.find((p) => p.poId === request.poId) : undefined + const grn: Grn = { + grnId, + docNo: `GRN-2026-${String(grnId).padStart(5, "0")}`, + poId: request.poId ?? null, + // Vendor is derived from the PO when receiving against one (as the real + // backend does) — request.vendorId is only meaningful for a direct receipt. + vendorId: referencedPo?.vendorId ?? request.vendorId ?? 0, + warehouseId: request.warehouseId, + status: "Draft", + createdBy: 17, + createdAt: new Date().toISOString(), + lines: request.lines.map((line) => ({ + grnLineId: allocateGrnLineId(), + poLineId: line.poLineId ?? null, + itemId: line.itemId, + uomId: line.uomId, + binId: line.binId ?? null, + qty: line.qty, + unitCost: line.unitCost, + receivedValue: Math.round(line.qty * line.unitCost * 100) / 100, + holdStatus: line.holdStatus, + batchId: line.batch ? allocateGrnLineId() : null, + })), + } + mockGrns.push(grn) + return mockDelay(grn) + }, + + confirm(grnId: number, idempotencyKey?: string): Promise { + void idempotencyKey // real backend dedupes on this; the mock always reprocesses + const grn = mockGrns.find((g) => g.grnId === grnId) + if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`)) + if (grn.status === "Confirmed" || grn.status === "Closed") { + return Promise.reject(new Error(`${grn.docNo} has already been confirmed.`)) + } + + grn.status = "Confirmed" + + const createdLayers: CreatedLayer[] = [] + const ledgerRefs: number[] = [] + + for (const line of grn.lines) { + // FR-GRN-06: each line creates a FIFO layer + posts an inbound ledger entry. + const { layer, ledger } = receiveLayer({ + itemId: line.itemId, + warehouseId: grn.warehouseId, + binId: line.binId, + batchId: line.batchId, + grnLineId: line.grnLineId, + qty: line.qty, + unitCost: line.unitCost, + userId: grn.createdBy, + sourceDocType: "GRN", + sourceDocId: grn.grnId, + }) + createdLayers.push({ + layerId: layer.layerId, + itemId: layer.itemId, + warehouseId: layer.warehouseId, + batchId: layer.batchId, + qtyReceived: layer.qtyReceived, + qtyRemaining: layer.qtyRemaining, + unitCost: layer.unitCost, + receiptDate: layer.receiptDate, + }) + ledgerRefs.push(ledger.ledgerId) + + // FR-PROC-07: accrue the PO line's received quantity as GRNs confirm. + if (line.poLineId && grn.poId) { + const po = mockPurchaseOrders.find((p) => p.poId === grn.poId) + const poLine = po?.lines.find((l) => l.poLineId === line.poLineId) + if (poLine) poLine.qtyReceived = Math.min(poLine.qty, poLine.qtyReceived + line.qty) + } + } + + let poStatus: string | null = null + if (grn.poId) { + const po = mockPurchaseOrders.find((p) => p.poId === grn.poId) + if (po) { + const fullyReceived = po.lines.every((l) => l.qtyReceived >= l.qty) + const anyReceived = po.lines.some((l) => l.qtyReceived > 0) + po.status = fullyReceived ? "FullyReceived" : anyReceived ? "PartiallyReceived" : po.status + poStatus = po.status + } + } + + const response: ConfirmGrnResponse = { + grnId: grn.grnId, + status: grn.status, + postedAt: new Date().toISOString(), + createdLayers, + ledgerRefs, + poStatus, + } + return mockDelay(response) + }, + + releaseLine(grnId: number, grnLineId: number, action: ReleaseAction): Promise { + const grn = mockGrns.find((g) => g.grnId === grnId) + const line = grn?.lines.find((l) => l.grnLineId === grnLineId) + if (!grn || !line) return Promise.reject(new Error(`Mock GRN line ${grnLineId} not found`)) + + line.holdStatus = action === "Release" ? "Available" : "Rejected" + return mockDelay({ grnLineId: line.grnLineId, holdStatus: line.holdStatus }) + }, + + // Draft-only — once confirmed, a GRN has created stock layers/ledger entries + // and is no longer safe to rewrite in place. + update(grnId: number, request: CreateGrnRequest): Promise { + const grn = mockGrns.find((g) => g.grnId === grnId) + if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`)) + if (grn.status !== "Draft") { + return Promise.reject(new Error(`${grn.docNo} is ${grn.status.toLowerCase()} and can no longer be edited.`)) + } + + const referencedPo = request.poId ? mockPurchaseOrders.find((p) => p.poId === request.poId) : undefined + grn.poId = request.poId ?? null + grn.vendorId = referencedPo?.vendorId ?? request.vendorId ?? grn.vendorId + grn.warehouseId = request.warehouseId + grn.lines = request.lines.map((line) => ({ + grnLineId: allocateGrnLineId(), + poLineId: line.poLineId ?? null, + itemId: line.itemId, + uomId: line.uomId, + binId: line.binId ?? null, + qty: line.qty, + unitCost: line.unitCost, + receivedValue: Math.round(line.qty * line.unitCost * 100) / 100, + holdStatus: line.holdStatus, + batchId: line.batch ? allocateGrnLineId() : null, + })) + return mockDelay(grn) + }, + + remove(grnId: number): Promise { + const grn = mockGrns.find((g) => g.grnId === grnId) + if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`)) + if (grn.status !== "Draft") { + return Promise.reject(new Error(`${grn.docNo} is ${grn.status.toLowerCase()} and can no longer be deleted.`)) + } + mockGrns.splice(mockGrns.indexOf(grn), 1) + return mockDelay(undefined) }, } diff --git a/Frontend/erp-system/lib/api/items.ts b/Frontend/erp-system/lib/api/items.ts index e3414fd..b1816bf 100644 --- a/Frontend/erp-system/lib/api/items.ts +++ b/Frontend/erp-system/lib/api/items.ts @@ -1,6 +1,11 @@ // One typed client method per Item endpoint (docs/11-BACKEND-PHASE1.md §2.1, FR-MD-01/05/08). -// `list` also backs the GRN/PO/Requisition/RFQ item pickers. -import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +// `list` also backs the GRN/PO/Requisition/RFQ item pickers built in earlier sessions. +// +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens +// can be reviewed without a running backend. Restore the commented block and +// delete the mock block once Backend/PROGRESS.md §1 (Master Data) exists. +import { ApiResult } from "@/lib/api-client" import { EntityStatus, PagedResponse } from "@/types/common" import { CreateItemRequest, @@ -12,7 +17,52 @@ import { UpdateUomConversionsRequest, UpdateUomConversionsResponse, } from "@/types/master-data" +import { + allocateItemId, + bumpItemVersion, + getItemVersion, + initItemVersion, + mockDelay, + mockItems, +} from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +// +// export interface ListItemsParams { +// page?: number +// pageSize?: number +// q?: string +// status?: EntityStatus +// categoryId?: number +// trackingMode?: TrackingMode +// } +// +// export const itemsApi = { +// list(params: ListItemsParams = {}) { +// return apiRequest>(`/items${buildQuery(params)}`) +// }, +// get(itemId: number) { +// return apiRequestWithETag(`/items/${itemId}`) +// }, +// create(request: CreateItemRequest) { +// return apiRequestWithETag("/items", { method: "POST", body: request }) +// }, +// update(itemId: number, request: UpdateItemRequest, ifMatch: string) { +// return apiRequestWithETag(`/items/${itemId}`, { method: "PUT", body: request, ifMatch }) +// }, +// updateStatus(itemId: number, status: EntityStatus) { +// return apiRequest(`/items/${itemId}/status`, { method: "PATCH", body: { status } }) +// }, +// updateReorder(itemId: number, request: UpdateItemReorderRequest) { +// return apiRequest<{ settings: UpdateItemReorderRequest["settings"] }>(`/items/${itemId}/reorder`, { method: "PUT", body: request }) +// }, +// updateUomConversions(itemId: number, request: UpdateUomConversionsRequest) { +// return apiRequest(`/items/${itemId}/uom-conversions`, { method: "PUT", body: request }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export interface ListItemsParams { page?: number pageSize?: number @@ -22,26 +72,132 @@ export interface ListItemsParams { trackingMode?: TrackingMode } +function toListItem(item: Item): ItemListItem { + return { + itemId: item.itemId, + sku: item.sku, + name: item.name, + categoryId: item.categoryId, + baseUomId: item.baseUomId, + defaultVendorId: item.defaultVendorId, + itemType: item.itemType, + trackingMode: item.trackingMode, + taxClass: item.taxClass, + status: item.status, + } +} + +function skuTaken(sku: string, excludeItemId?: number) { + return mockItems.some((i) => i.itemId !== excludeItemId && i.sku.toLowerCase() === sku.toLowerCase()) +} + export const itemsApi = { - list(params: ListItemsParams = {}) { - return apiRequest>(`/items${buildQuery(params)}`) + list(params: ListItemsParams = {}): Promise> { + const term = params.q?.trim().toLowerCase() + const filtered = mockItems + .filter((i) => !params.status || i.status === params.status) + .filter((i) => !params.categoryId || i.categoryId === params.categoryId) + .filter((i) => !params.trackingMode || i.trackingMode === params.trackingMode) + .filter((i) => !term || `${i.sku} ${i.name}`.toLowerCase().includes(term)) + .sort((a, b) => a.sku.localeCompare(b.sku)) + .map(toListItem) + + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 20 + const start = (page - 1) * pageSize + const items = filtered.slice(start, start + pageSize) + + return mockDelay({ + items, + pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 }, + }) }, - get(itemId: number) { - return apiRequestWithETag(`/items/${itemId}`) + + get(itemId: number): Promise> { + const item = mockItems.find((i) => i.itemId === itemId) + if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`)) + return mockDelay({ data: item, etag: String(getItemVersion(itemId)) }) }, - create(request: CreateItemRequest) { - return apiRequestWithETag("/items", { method: "POST", body: request }) + + create(request: CreateItemRequest): Promise> { + const sku = request.sku.trim() + if (!sku) return Promise.reject(new Error("SKU is required.")) + if (skuTaken(sku)) { + return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" })) + } + const item: Item = { + itemId: allocateItemId(), + sku, + name: request.name.trim(), + description: request.description?.trim() || null, + categoryId: request.categoryId, + baseUomId: request.baseUomId, + defaultVendorId: request.defaultVendorId ?? null, + itemType: request.itemType, + trackingMode: request.trackingMode, + taxClass: request.taxClass?.trim() || null, + status: "Active", + reorder: [], + conversions: [], + createdAt: new Date().toISOString(), + updatedAt: null, + } + mockItems.push(item) + initItemVersion(item.itemId) + return mockDelay({ data: item, etag: "1" }) }, - update(itemId: number, request: UpdateItemRequest, ifMatch: string) { - return apiRequestWithETag(`/items/${itemId}`, { method: "PUT", body: request, ifMatch }) + + update(itemId: number, request: UpdateItemRequest, ifMatch: string): Promise> { + const item = mockItems.find((i) => i.itemId === itemId) + if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`)) + if (String(getItemVersion(itemId)) !== ifMatch) { + return Promise.reject(Object.assign(new Error("The item was modified by another request."), { code: "CONCURRENCY_CONFLICT" })) + } + const sku = request.sku.trim() + if (!sku) return Promise.reject(new Error("SKU is required.")) + if (skuTaken(sku, itemId)) { + return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" })) + } + + item.sku = sku + item.name = request.name.trim() + item.description = request.description?.trim() || null + item.categoryId = request.categoryId + item.baseUomId = request.baseUomId + item.defaultVendorId = request.defaultVendorId ?? null + item.itemType = request.itemType + item.trackingMode = request.trackingMode + item.taxClass = request.taxClass?.trim() || null + item.updatedAt = new Date().toISOString() + + const next = bumpItemVersion(itemId) + return mockDelay({ data: item, etag: String(next) }) }, - updateStatus(itemId: number, status: EntityStatus) { - return apiRequest(`/items/${itemId}/status`, { method: "PATCH", body: { status } }) + + updateStatus(itemId: number, status: EntityStatus): Promise { + const item = mockItems.find((i) => i.itemId === itemId) + if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`)) + item.status = status + item.updatedAt = new Date().toISOString() + bumpItemVersion(itemId) + return mockDelay(undefined) }, - updateReorder(itemId: number, request: UpdateItemReorderRequest) { - return apiRequest<{ settings: UpdateItemReorderRequest["settings"] }>(`/items/${itemId}/reorder`, { method: "PUT", body: request }) + + updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: UpdateItemReorderRequest["settings"] }> { + const item = mockItems.find((i) => i.itemId === itemId) + if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`)) + item.reorder = request.settings + item.updatedAt = new Date().toISOString() + bumpItemVersion(itemId) + return mockDelay({ settings: item.reorder }) }, - updateUomConversions(itemId: number, request: UpdateUomConversionsRequest) { - return apiRequest(`/items/${itemId}/uom-conversions`, { method: "PUT", body: request }) + + updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise { + const item = mockItems.find((i) => i.itemId === itemId) + if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`)) + item.conversions = request.conversions.map((c, i) => ({ conversionId: 1000 + itemId * 10 + i, fromUom: c.fromUom, toUom: c.toUom, factor: c.factor })) + item.updatedAt = new Date().toISOString() + bumpItemVersion(itemId) + return mockDelay({ itemId: item.itemId, baseUomId: item.baseUomId, conversions: item.conversions }) }, } diff --git a/Frontend/erp-system/lib/api/mock-data.ts b/Frontend/erp-system/lib/api/mock-data.ts new file mode 100644 index 0000000..46b46cd --- /dev/null +++ b/Frontend/erp-system/lib/api/mock-data.ts @@ -0,0 +1,814 @@ +// Temporary in-memory sample data so the GRN screens can be reviewed as pure UI +// without a running backend. Shapes mirror docs/11-BACKEND-PHASE1.md exactly. +// +// This file (and the "MOCK" blocks in the sibling lib/api/*.ts files) is meant +// to be deleted once the real GRN backend exists — the original fetch-based +// implementations are left commented out in each file for that switch-back. +import { Bin, Category, Item, Uom, Vendor, Warehouse } from "@/types/master-data" +import { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement" +import { Grn } from "@/types/grn" +import { + AdjustmentStatus, + CountStatus, + CountType, + LedgerDirection, + LedgerEntry, + ReasonCode, + TransferStatus, +} from "@/types/stock" + +export const mockWarehouses: Warehouse[] = [ + { warehouseId: 1, code: "WH-MAIN", name: "Main Warehouse - Negombo" }, + { warehouseId: 2, code: "WH-COLOMBO", name: "Colombo Distribution Center" }, +] + +export const mockBins: Bin[] = [ + { binId: 1, warehouseId: 1, code: "A-01-01", binType: "Shelf" }, + { binId: 2, warehouseId: 1, code: "A-01-02", binType: "Shelf" }, + { binId: 3, warehouseId: 1, code: "B-02-01", binType: "Pallet" }, + { binId: 4, warehouseId: 2, code: "C-01-01", binType: "Shelf" }, + { binId: 5, warehouseId: 2, code: "C-01-02", binType: "Shelf" }, +] + +let nextWarehouseId = 3 +let nextBinId = 6 + +export function allocateWarehouseId() { + return nextWarehouseId++ +} + +export function allocateBinId() { + return nextBinId++ +} + +export const mockUoms: Uom[] = [ + { uomId: 1, name: "EA" }, + { uomId: 2, name: "Box-12" }, + { uomId: 3, name: "KG" }, +] + +let nextUomId = 4 + +export function allocateUomId() { + return nextUomId++ +} + +export const mockCategories: Category[] = [ + { categoryId: 3, name: "Hardware", parentId: null }, + { categoryId: 12, name: "Fasteners", parentId: 3 }, + { categoryId: 20, name: "Power Tools", parentId: null }, +] + +let nextCategoryId = 21 + +export function allocateCategoryId() { + return nextCategoryId++ +} + +export const mockVendors: Vendor[] = [ + { + vendorId: 5, + code: "VN-005", + name: "Lanka Steel Traders (Pvt) Ltd", + terms: "NET30", + taxReg: "134567890-7000", + currency: "LKR", + status: "Active", + createdAt: "2026-06-01T08:00:00Z", + updatedAt: null, + }, + { + vendorId: 8, + code: "VN-008", + name: "Ceylon Hardware Supplies", + terms: "NET45", + taxReg: "198765432-1000", + currency: "LKR", + status: "Active", + createdAt: "2026-06-05T08:00:00Z", + updatedAt: null, + }, +] + +// A handful more so the vendors list has something real to paginate/search through. +const extraVendorNames = [ + "Colombo Timber & Plywood Co.", + "Kandy Electrical Distributors", + "Galle Packaging Solutions", + "Jaffna Agro Supplies", + "Negombo Fasteners (Pvt) Ltd", + "Kurunegala Paints & Coatings", + "Trinco Marine Hardware", + "Ratnapura Gems & Tools", +] +for (let i = 0; i < extraVendorNames.length; i++) { + const vendorId = 9 + i + mockVendors.push({ + vendorId, + code: `VN-${String(vendorId).padStart(3, "0")}`, + name: extraVendorNames[i], + terms: i % 2 === 0 ? "NET30" : "NET60", + taxReg: `1${String(10000000 + vendorId * 137)}-${7000 + i}`, + currency: "LKR", + status: i % 5 === 0 ? "Inactive" : "Active", + createdAt: new Date(Date.UTC(2026, 6, 1 + i, 9, 0, 0)).toISOString(), + updatedAt: null, + }) +} + +let nextVendorId = 9 + extraVendorNames.length + +export function allocateVendorId() { + return nextVendorId++ +} + +// Concurrency token per vendor (stands in for the real backend's xmin/RowVersion +// ETag, docs/11 §1.6) — kept out-of-band since the public Vendor type has no +// version field of its own (it travels as an HTTP ETag header, not a body field). +const mockVendorVersions = new Map(mockVendors.map((v) => [v.vendorId, 1])) + +export function getVendorVersion(vendorId: number): number { + return mockVendorVersions.get(vendorId) ?? 1 +} + +export function bumpVendorVersion(vendorId: number): number { + const next = getVendorVersion(vendorId) + 1 + mockVendorVersions.set(vendorId, next) + return next +} + +export function initVendorVersion(vendorId: number) { + mockVendorVersions.set(vendorId, 1) +} + +// Full Item records (docs/11 §2.1). ItemListItem (the list/GRN-picker view) is +// derived from these in lib/api/items.ts, same "full record → mapped summary" +// pattern as mockPurchaseOrders → PurchaseOrderSummary. +export const mockItems: Item[] = [ + { + itemId: 1001, + sku: "ITM-1001", + name: "Steel Bolt M8x40", + description: "Grade 8.8 zinc-plated hex bolt", + categoryId: 12, + baseUomId: 1, + defaultVendorId: 5, + itemType: "Stocked", + trackingMode: "Batch", + taxClass: "STD", + status: "Active", + reorder: [{ warehouseId: 1, reorderPoint: 2500, reorderQty: 5000 }], + conversions: [{ conversionId: 33, fromUom: 2, toUom: 1, factor: 12 }], + createdAt: "2026-06-01T08:00:00Z", + updatedAt: null, + }, + { + itemId: 1002, + sku: "ITM-1002", + name: "Steel Nut M8", + description: "Grade 8 zinc-plated hex nut", + categoryId: 12, + baseUomId: 1, + defaultVendorId: 5, + itemType: "Stocked", + trackingMode: "None", + taxClass: "STD", + status: "Active", + reorder: [{ warehouseId: 1, reorderPoint: 5000, reorderQty: 8000 }], + conversions: [], + createdAt: "2026-06-01T08:05:00Z", + updatedAt: null, + }, + { + itemId: 1003, + sku: "ITM-1003", + name: "Cordless Drill 18V", + description: "18V lithium-ion cordless drill/driver, includes charger", + categoryId: 20, + baseUomId: 1, + defaultVendorId: 8, + itemType: "Stocked", + trackingMode: "Serial", + taxClass: "STD", + status: "Active", + reorder: [{ warehouseId: 2, reorderPoint: 15, reorderQty: 20 }], + conversions: [], + createdAt: "2026-06-05T08:10:00Z", + updatedAt: null, + }, +] + +let nextItemId = 1004 + +export function allocateItemId() { + return nextItemId++ +} + +// Concurrency token per item (same out-of-band ETag pattern as mockVendorVersions). +const mockItemVersions = new Map(mockItems.map((i) => [i.itemId, 1])) + +export function getItemVersion(itemId: number): number { + return mockItemVersions.get(itemId) ?? 1 +} + +export function bumpItemVersion(itemId: number): number { + const next = getItemVersion(itemId) + 1 + mockItemVersions.set(itemId, next) + return next +} + +export function initItemVersion(itemId: number) { + mockItemVersions.set(itemId, 1) +} + +export const mockPurchaseOrders: PurchaseOrder[] = [ + { + poId: 342, + docNo: "PO-2026-00342", + vendorId: 5, + requisitionId: 210, + status: "Approved", + approvalRequired: false, + createdBy: 17, + createdAt: "2026-07-07T09:40:00Z", + updatedAt: null, + totals: { subTotal: 112100.0, tax: 20178.0, grandTotal: 132278.0, currency: "LKR" }, + lines: [ + { poLineId: 900, itemId: 1001, uomId: 1, warehouseId: 1, qty: 5000, unitPrice: 12.5, tax: 0.18, qtyReceived: 0 }, + { poLineId: 901, itemId: 1002, uomId: 1, warehouseId: 1, qty: 8000, unitPrice: 6.2, tax: 0.18, qtyReceived: 0 }, + ], + }, + { + poId: 350, + docNo: "PO-2026-00350", + vendorId: 8, + requisitionId: null, + status: "PartiallyReceived", + approvalRequired: false, + createdBy: 17, + createdAt: "2026-07-09T09:00:00Z", + updatedAt: "2026-07-10T11:00:00Z", + totals: { subTotal: 22500.0, tax: 4050.0, grandTotal: 26550.0, currency: "LKR" }, + lines: [ + { poLineId: 910, itemId: 1003, uomId: 1, warehouseId: 2, qty: 50, unitPrice: 450.0, tax: 0.18, qtyReceived: 20 }, + ], + }, +] + +let nextPoId = 351 + +export function allocatePoId() { + return nextPoId++ +} + +// Concurrency token per PO (same out-of-band ETag pattern as mockVendorVersions, +// docs/11 §1.6) — backs PUT /purchase-orders/{poId}'s If-Match (FR-PROC-05, Option B). +const mockPoVersions = new Map(mockPurchaseOrders.map((p) => [p.poId, 1])) + +export function getPoVersion(poId: number): number { + return mockPoVersions.get(poId) ?? 1 +} + +export function bumpPoVersion(poId: number): number { + const next = getPoVersion(poId) + 1 + mockPoVersions.set(poId, next) + return next +} + +export function initPoVersion(poId: number) { + mockPoVersions.set(poId, 1) +} + +export const mockGrns: Grn[] = [ + { + grnId: 780, + docNo: "GRN-2026-00780", + poId: 342, + vendorId: 5, + warehouseId: 1, + status: "Draft", + createdBy: 17, + createdAt: "2026-07-11T10:00:00Z", + lines: [ + { + grnLineId: 1300, + poLineId: 900, + itemId: 1001, + uomId: 1, + binId: 1, + qty: 5000, + unitCost: 12.5, + receivedValue: 62500.0, + holdStatus: "OnHold", + batchId: 410, + }, + ], + }, + { + grnId: 781, + docNo: "GRN-2026-00781", + poId: null, + vendorId: 8, + warehouseId: 2, + status: "Confirmed", + createdBy: 17, + createdAt: "2026-07-10T14:30:00Z", + lines: [ + { + grnLineId: 1310, + poLineId: null, + itemId: 1003, + uomId: 1, + binId: 4, + qty: 5, + unitCost: 450.0, + receivedValue: 2250.0, + holdStatus: "Available", + batchId: null, + }, + ], + }, +] + +// A handful more so the list screen's pagination/search/filter controls have +// something real to page through (10 items total across statuses/warehouses). +const extraStatuses: Grn["status"][] = ["Draft", "Confirmed", "Closed", "Confirmed", "Draft", "Confirmed", "Closed", "Draft"] +for (let i = 0; i < extraStatuses.length; i++) { + const grnId = 782 + i + const warehouseId = i % 2 === 0 ? 1 : 2 + const vendorId = i % 2 === 0 ? 5 : 8 + const itemId = i % 2 === 0 ? 1001 : 1003 + const status = extraStatuses[i] + mockGrns.push({ + grnId, + docNo: `GRN-2026-${String(grnId).padStart(5, "0")}`, + poId: i % 3 === 0 ? null : 342, + vendorId, + warehouseId, + status, + createdBy: 17, + createdAt: new Date(Date.UTC(2026, 6, 1 + i, 9, 0, 0)).toISOString(), + lines: [ + { + grnLineId: 2000 + i, + poLineId: i % 3 === 0 ? null : 900, + itemId, + uomId: 1, + binId: warehouseId === 1 ? 1 : 4, + qty: 100 * (i + 1), + unitCost: 10 + i, + receivedValue: 100 * (i + 1) * (10 + i), + holdStatus: status === "Draft" ? "OnHold" : "Available", + batchId: null, + }, + ], + }) +} + +let nextGrnId = 782 + extraStatuses.length +let nextGrnLineId = 2000 + extraStatuses.length + +export function allocateGrnId() { + return nextGrnId++ +} + +export function allocateGrnLineId() { + return nextGrnLineId++ +} + +/** Small delay so loading states are visible when reviewing the UI. */ +export function mockDelay(value: T, ms = 300): Promise { + return new Promise((resolve) => setTimeout(() => resolve(value), ms)) +} + +// ============================================================================ +// Stock Core (FIFO layers + immutable ledger) — docs/10 Part C.5, FR-STK-01..04. +// GRN confirm and every stock transaction below post through these helpers so +// Stock Enquiry / Ledger / Valuation reflect what actually happened this session. +// ============================================================================ + +export interface MockStockLayer { + layerId: number + itemId: number + warehouseId: number + batchId: number | null + serialId: number | null + grnLineId: number | null + qtyReceived: number + qtyRemaining: number + unitCost: number + receiptDate: string +} + +export const mockStockLayers: MockStockLayer[] = [] +export const mockStockLedger: LedgerEntry[] = [] + +let nextLayerId = 9001 +let nextLedgerId = 55010 + +export function allocateLayerId() { + return nextLayerId++ +} + +export function allocateLedgerId() { + return nextLedgerId++ +} + +function round2(n: number) { + return Math.round(n * 100) / 100 +} + +function latestRunningBalance(itemId: number, warehouseId: number): number { + for (let i = mockStockLedger.length - 1; i >= 0; i--) { + const entry = mockStockLedger[i] + if (entry.itemId === itemId && entry.warehouseId === warehouseId) return entry.runningBalance + } + return 0 +} + +export function postLedgerEntry(input: { + itemId: number + warehouseId: number + binId?: number | null + batchId?: number | null + serialId?: number | null + userId: number + direction: LedgerDirection + qtyBase: number + unitCost: number + sourceDocType: string + sourceDocId: number +}): LedgerEntry { + const prior = latestRunningBalance(input.itemId, input.warehouseId) + const delta = input.direction === "In" ? input.qtyBase : -input.qtyBase + const entry: LedgerEntry = { + ledgerId: allocateLedgerId(), + itemId: input.itemId, + warehouseId: input.warehouseId, + binId: input.binId ?? null, + batchId: input.batchId ?? null, + serialId: input.serialId ?? null, + direction: input.direction, + qtyBase: input.qtyBase, + unitCost: input.unitCost, + value: round2(input.qtyBase * input.unitCost), + runningBalance: round2(prior + delta), + sourceDocType: input.sourceDocType, + sourceDocId: input.sourceDocId, + userId: input.userId, + createdAt: new Date().toISOString(), + } + mockStockLedger.push(entry) + return entry +} + +/** Creates a FIFO layer + posts the matching inbound ledger entry (FR-GRN-06 / FR-STK-02). */ +export function receiveLayer(input: { + itemId: number + warehouseId: number + binId?: number | null + batchId?: number | null + serialId?: number | null + grnLineId?: number | null + qty: number + unitCost: number + userId: number + sourceDocType: string + sourceDocId: number +}): { layer: MockStockLayer; ledger: LedgerEntry } { + const layer: MockStockLayer = { + layerId: allocateLayerId(), + itemId: input.itemId, + warehouseId: input.warehouseId, + batchId: input.batchId ?? null, + serialId: input.serialId ?? null, + grnLineId: input.grnLineId ?? null, + qtyReceived: input.qty, + qtyRemaining: input.qty, + unitCost: input.unitCost, + receiptDate: new Date().toISOString(), + } + mockStockLayers.push(layer) + const ledger = postLedgerEntry({ + itemId: input.itemId, + warehouseId: input.warehouseId, + binId: input.binId, + batchId: input.batchId, + serialId: input.serialId, + userId: input.userId, + direction: "In", + qtyBase: input.qty, + unitCost: input.unitCost, + sourceDocType: input.sourceDocType, + sourceDocId: input.sourceDocId, + }) + return { layer, ledger } +} + +/** 409 STOCK_NEGATIVE_BLOCKED (docs/11 §7) — thrown by consumeFifo when available < requested. */ +export class StockNegativeError extends Error { + code = "STOCK_NEGATIVE_BLOCKED" + constructor(itemId: number, warehouseId: number) { + super(`Not enough available stock for item #${itemId} at warehouse #${warehouseId}.`) + } +} + +/** A layer is unavailable while its originating GRN line is still on hold/rejected (docs/10 C.9). */ +function isLayerOnHold(layer: MockStockLayer): boolean { + if (!layer.grnLineId) return false + for (const grn of mockGrns) { + const line = grn.lines.find((l) => l.grnLineId === layer.grnLineId) + if (line) return line.holdStatus === "OnHold" || line.holdStatus === "Rejected" + } + return false +} + +/** Consumes the oldest open (non-held) layers first (FR-STK-03); throws StockNegativeError if insufficient. */ +export function consumeFifo( + itemId: number, + warehouseId: number, + qty: number +): { layerId: number; qtyConsumed: number; unitCost: number }[] { + let remaining = qty + const consumed: { layerId: number; qtyConsumed: number; unitCost: number }[] = [] + const candidates = mockStockLayers + .filter((l) => l.itemId === itemId && l.warehouseId === warehouseId && l.qtyRemaining > 0 && !isLayerOnHold(l)) + .sort((a, b) => new Date(a.receiptDate).getTime() - new Date(b.receiptDate).getTime()) + + for (const layer of candidates) { + if (remaining <= 0) break + const take = Math.min(layer.qtyRemaining, remaining) + layer.qtyRemaining = round2(layer.qtyRemaining - take) + remaining = round2(remaining - take) + consumed.push({ layerId: layer.layerId, qtyConsumed: take, unitCost: layer.unitCost }) + } + if (remaining > 0.0001) throw new StockNegativeError(itemId, warehouseId) + return consumed +} + +/** "Last cost" for an adjustment increase (FR-STK-07) when no more specific cost is supplied. */ +export function lastKnownCost(itemId: number, warehouseId: number): number { + const layers = mockStockLayers + .filter((l) => l.itemId === itemId && l.warehouseId === warehouseId) + .sort((a, b) => new Date(b.receiptDate).getTime() - new Date(a.receiptDate).getTime()) + return layers[0]?.unitCost ?? 10 +} + +/** + * Consumes stock for a Purchase Return against the specific layer its GRN line created + * (FR-PROC-08) — deliberately not routed through consumeFifo: a return disposes of the + * exact received batch (often On-hold/Rejected, which consumeFifo's isLayerOnHold filter + * would otherwise skip), not just "the oldest open layer for this item/warehouse". + * Throws StockNegativeError (409 STOCK_NEGATIVE_BLOCKED, docs/11 §3.4) if the return + * qty exceeds what remains on that layer. + */ +export function consumeLayerByGrnLine( + grnLineId: number, + qty: number +): { layerId: number; qtyConsumed: number; unitCost: number; itemId: number; warehouseId: number } { + const layer = mockStockLayers.find((l) => l.grnLineId === grnLineId) + if (!layer || layer.qtyRemaining < qty) { + const itemId = layer?.itemId ?? 0 + const warehouseId = layer?.warehouseId ?? 0 + throw new StockNegativeError(itemId, warehouseId) + } + layer.qtyRemaining = round2(layer.qtyRemaining - qty) + return { layerId: layer.layerId, qtyConsumed: qty, unitCost: layer.unitCost, itemId: layer.itemId, warehouseId: layer.warehouseId } +} + +export function computeOnHand(itemId: number, warehouseId: number) { + const layers = mockStockLayers.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId) + const onHand = round2(layers.reduce((sum, l) => sum + l.qtyRemaining, 0)) + const onHold = round2(layers.filter(isLayerOnHold).reduce((sum, l) => sum + l.qtyRemaining, 0)) + const inTransit = round2( + mockStockTransfers + .filter((t) => t.status === "InTransit" && t.destWarehouseId === warehouseId) + .flatMap((t) => t.lines) + .filter((l) => l.itemId === itemId) + .reduce((sum, l) => sum + l.qty, 0) + ) + const reserved = 0 + const available = Math.max(0, round2(onHand - onHold - reserved)) + return { onHand, onHold, inTransit, reserved, available } +} + +/** Every item/warehouse combination that currently has (or ever had) a layer — drives the Enquiry screen. */ +export function knownStockKeys(): { itemId: number; warehouseId: number }[] { + const seen = new Map() + for (const layer of mockStockLayers) { + seen.set(`${layer.itemId}:${layer.warehouseId}`, { itemId: layer.itemId, warehouseId: layer.warehouseId }) + } + return [...seen.values()] +} + +// --- Reference data (docs/11 §6) -------------------------------------------------- + +export interface MockItemReorder { + itemId: number + warehouseId: number + reorderPoint: number + reorderQty: number +} + +export const mockItemReorders: MockItemReorder[] = [ + { itemId: 1001, warehouseId: 1, reorderPoint: 2500, reorderQty: 5000 }, + { itemId: 1002, warehouseId: 1, reorderPoint: 5000, reorderQty: 8000 }, + { itemId: 1003, warehouseId: 2, reorderPoint: 15, reorderQty: 20 }, +] + +export const mockReasonCodes: ReasonCode[] = [ + { reasonCodeId: 1, code: "DMG", description: "Damage", context: "Adjustment" }, + { reasonCodeId: 2, code: "LOSS", description: "Theft/Loss", context: "Adjustment" }, + { reasonCodeId: 3, code: "CNTVAR", description: "Count Variance", context: "Adjustment" }, + { reasonCodeId: 4, code: "EXPWO", description: "Expiry Write-off", context: "Adjustment" }, + { reasonCodeId: 5, code: "SYSCORR", description: "System Correction", context: "Adjustment" }, + { reasonCodeId: 22, code: "QREJ", description: "Quality Reject", context: "Return" }, +] + +// --- Seed some prior receipts so Enquiry/Ledger/Valuation aren't empty on first load --- + +receiveLayer({ + itemId: 1001, warehouseId: 1, binId: 1, batchId: 411, qty: 3000, unitCost: 12.5, + userId: 17, sourceDocType: "GRN", sourceDocId: 779, +}) +receiveLayer({ + itemId: 1002, warehouseId: 1, binId: 2, qty: 6000, unitCost: 6.2, + userId: 17, sourceDocType: "GRN", sourceDocId: 779, +}) +receiveLayer({ + itemId: 1003, warehouseId: 2, binId: 4, qty: 20, unitCost: 450, + userId: 17, sourceDocType: "GRN", sourceDocId: 781, +}) + +// ============================================================================ +// Transfers (FR-STK-05/06) — create → dispatch (consume src) → receive (create dest). +// ============================================================================ + +export interface MockTransferLine { + transferLineId: number + itemId: number + srcBinId: number | null + destBinId: number | null + batchId: number | null + qty: number + /** Recorded on dispatch so receive() can create cost-preserving destination layers (FR-STK-06). */ + dispatchedChunks: { layerId: number; qtyConsumed: number; unitCost: number }[] +} + +export interface MockStockTransfer { + transferId: number + docNo: string + srcWarehouseId: number + destWarehouseId: number + status: TransferStatus + createdBy: number + createdAt: string + lines: MockTransferLine[] +} + +export const mockStockTransfers: MockStockTransfer[] = [] +let nextTransferId = 55 +let nextTransferLineId = 300 + +export function allocateTransferId() { + return nextTransferId++ +} + +export function allocateTransferLineId() { + return nextTransferLineId++ +} + +// ============================================================================ +// Adjustments (FR-STK-07) — auto-post on creation. +// ============================================================================ + +export interface MockAdjustmentLine { + adjLineId: number + itemId: number + binId: number | null + batchId: number | null + qtyDelta: number +} + +export interface MockStockAdjustment { + adjustmentId: number + docNo: string + warehouseId: number + reasonCodeId: number + status: AdjustmentStatus + createdBy: number + createdAt: string + lines: MockAdjustmentLine[] + ledgerRefs: number[] +} + +export const mockStockAdjustments: MockStockAdjustment[] = [] +let nextAdjustmentId = 77 +let nextAdjLineId = 210 + +export function allocateAdjustmentId() { + return nextAdjustmentId++ +} + +export function allocateAdjLineId() { + return nextAdjLineId++ +} + +// ============================================================================ +// Counts (FR-STK-08) — snapshot system qty → enter counted qty → post variance. +// ============================================================================ + +export interface MockCountLine { + countLineId: number + itemId: number + binId: number | null + systemQty: number + countedQty: number | null + variance: number | null +} + +export interface MockStockCount { + countId: number + docNo: string + warehouseId: number + countType: CountType + status: CountStatus + createdBy: number + createdAt: string + lines: MockCountLine[] +} + +export const mockStockCounts: MockStockCount[] = [] +let nextCountId = 30 +let nextCountLineId = 400 + +export function allocateCountId() { + return nextCountId++ +} + +export function allocateCountLineId() { + return nextCountLineId++ +} + +// ============================================================================ +// Procurement (FR-PROC-01..09) — Requisition → RFQ → Quotations → PO → Return. +// No Procurement backend exists yet; same frontend-only posture as GRN/Stock. +// ============================================================================ + +export const mockRequisitions: Requisition[] = [ + // Seeded to match mockPurchaseOrders[0].requisitionId (PO-2026-00342 was raised + // against this requisition) so the two screens cross-reference consistently. + { + requisitionId: 210, + docNo: "PR-2026-00210", + status: "Submitted", + requestedBy: 17, + createdAt: "2026-07-06T08:30:00Z", + lines: [ + { reqLineId: 501, itemId: 1001, qty: 5000, requiredBy: "2026-07-20" }, + { reqLineId: 502, itemId: 1002, qty: 8000, requiredBy: "2026-07-20" }, + ], + }, +] + +let nextRequisitionId = 211 +let nextReqLineId = 503 + +export function allocateRequisitionId() { + return nextRequisitionId++ +} + +export function allocateReqLineId() { + return nextReqLineId++ +} + +export const mockRfqs: Rfq[] = [] +let nextRfqId = 89 +let nextRfqLineId = 703 + +export function allocateRfqId() { + return nextRfqId++ +} + +export function allocateRfqLineId() { + return nextRfqLineId++ +} + +export const mockQuotations: Quotation[] = [] +let nextQuotationId = 141 + +export function allocateQuotationId() { + return nextQuotationId++ +} + +export const mockPurchaseReturns: PurchaseReturn[] = [] +let nextPurchaseReturnId = 61 +let nextPurchaseReturnLineId = 121 + +export function allocatePurchaseReturnId() { + return nextPurchaseReturnId++ +} + +export function allocatePurchaseReturnLineId() { + return nextPurchaseReturnLineId++ +} diff --git a/Frontend/erp-system/lib/api/purchase-orders.ts b/Frontend/erp-system/lib/api/purchase-orders.ts index 6e24860..50ea6fa 100644 --- a/Frontend/erp-system/lib/api/purchase-orders.ts +++ b/Frontend/erp-system/lib/api/purchase-orders.ts @@ -1,6 +1,12 @@ // One typed client method per Purchase Order endpoint (docs/11-BACKEND-PHASE1.md §3.3, // FR-PROC-03..07). `get`/`list` also back the GRN "against a PO" picker. -import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +// +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with in-memory sample data (lib/api/mock-data.ts) so the GRN and +// Procurement screens can be reviewed without a running backend. Restore the +// commented block and delete the mock block once Backend/PROGRESS.md §2/§3/§4 +// (Procurement + GRN + Stock Core) exist. +import { ApiResult } from "@/lib/api-client" import { PagedResponse } from "@/types/common" import { CancelPurchaseOrderRequest, @@ -10,7 +16,48 @@ import { PurchaseOrderSummary, UpdatePurchaseOrderRequest, } from "@/types/procurement" +import { + allocatePoId, + bumpPoVersion, + getPoVersion, + initPoVersion, + mockDelay, + mockPurchaseOrders, +} from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +// +// export interface ListPurchaseOrdersParams { +// page?: number +// pageSize?: number +// q?: string +// status?: PurchaseOrderStatus +// vendorId?: number +// } +// +// export const purchaseOrdersApi = { +// list(params: ListPurchaseOrdersParams = {}) { +// return apiRequest>(`/purchase-orders${buildQuery(params)}`) +// }, +// get(poId: number) { +// return apiRequest(`/purchase-orders/${poId}`) +// }, +// getWithETag(poId: number) { +// return apiRequestWithETag(`/purchase-orders/${poId}`) +// }, +// create(request: CreatePurchaseOrderRequest) { +// return apiRequestWithETag("/purchase-orders", { method: "POST", body: request }) +// }, +// update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string) { +// return apiRequestWithETag(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch }) +// }, +// cancel(poId: number, request: CancelPurchaseOrderRequest) { +// return apiRequest(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export interface ListPurchaseOrdersParams { page?: number pageSize?: number @@ -24,23 +71,131 @@ export function isPoEditable(status: PurchaseOrderStatus): boolean { return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled" } +function computeTotals(lines: CreatePurchaseOrderRequest["lines"], currency = "LKR") { + const subTotal = Math.round(lines.reduce((sum, l) => sum + l.qty * l.unitPrice, 0) * 100) / 100 + const tax = Math.round(lines.reduce((sum, l) => sum + l.qty * l.unitPrice * l.tax, 0) * 100) / 100 + return { subTotal, tax, grandTotal: Math.round((subTotal + tax) * 100) / 100, currency } +} + export const purchaseOrdersApi = { - list(params: ListPurchaseOrdersParams = {}) { - return apiRequest>(`/purchase-orders${buildQuery(params)}`) + list(params: ListPurchaseOrdersParams = {}): Promise> { + const term = params.q?.trim().toLowerCase() + const filtered = mockPurchaseOrders + .filter((po) => !params.status || po.status === params.status) + .filter((po) => !params.vendorId || po.vendorId === params.vendorId) + .filter((po) => !term || `${po.docNo} ${po.vendorId}`.toLowerCase().includes(term)) + .sort((a, b) => b.poId - a.poId) + .map( + (po): PurchaseOrderSummary => ({ + poId: po.poId, + docNo: po.docNo, + vendorId: po.vendorId, + status: po.status, + approvalRequired: po.approvalRequired, + createdAt: po.createdAt, + totals: po.totals, + }) + ) + + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 20 + const start = (page - 1) * pageSize + const items = filtered.slice(start, start + pageSize) + + return mockDelay({ + items, + pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 }, + }) }, - get(poId: number) { - return apiRequest(`/purchase-orders/${poId}`) + + get(poId: number): Promise { + const po = mockPurchaseOrders.find((p) => p.poId === poId) + if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`)) + return mockDelay(po) }, - getWithETag(poId: number) { - return apiRequestWithETag(`/purchase-orders/${poId}`) + + getWithETag(poId: number): Promise> { + const po = mockPurchaseOrders.find((p) => p.poId === poId) + if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`)) + return mockDelay({ data: po, etag: String(getPoVersion(poId)) }) }, - create(request: CreatePurchaseOrderRequest) { - return apiRequestWithETag("/purchase-orders", { method: "POST", body: request }) + + create(request: CreatePurchaseOrderRequest): Promise> { + if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line.")) + const poId = allocatePoId() + // FR-PROC-04: approvalRequired defaults false → auto-approved on creation. + const po: PurchaseOrder = { + poId, + docNo: `PO-2026-${String(poId).padStart(5, "0")}`, + vendorId: request.vendorId, + requisitionId: request.requisitionId ?? null, + status: "Approved", + approvalRequired: false, + createdBy: 17, + createdAt: new Date().toISOString(), + updatedAt: null, + totals: computeTotals(request.lines), + lines: request.lines.map((l, i) => ({ + poLineId: 900 + poId * 10 + i, + itemId: l.itemId, + uomId: l.uomId, + warehouseId: l.warehouseId, + qty: l.qty, + unitPrice: l.unitPrice, + tax: l.tax, + qtyReceived: 0, + })), + } + mockPurchaseOrders.push(po) + initPoVersion(poId) + return mockDelay({ data: po, etag: "1" }) }, - update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string) { - return apiRequestWithETag(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch }) + + update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string): Promise> { + const po = mockPurchaseOrders.find((p) => p.poId === poId) + if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`)) + if (!isPoEditable(po.status)) { + return Promise.reject(Object.assign(new Error(`${po.docNo} is ${po.status} and can no longer be edited.`), { code: "PO_NOT_EDITABLE" })) + } + if (String(getPoVersion(poId)) !== ifMatch) { + return Promise.reject(Object.assign(new Error("The purchase order was modified by another request."), { code: "CONCURRENCY_CONFLICT" })) + } + if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line.")) + + const priorQtyReceived = new Map(po.lines.map((l) => [l.poLineId, l.qtyReceived])) + po.vendorId = request.vendorId + po.requisitionId = request.requisitionId ?? null + po.totals = computeTotals(request.lines, po.totals.currency) + po.lines = request.lines.map((l, i) => { + // Preserve qtyReceived for lines that already existed (edit-while-open must not erase receipt progress). + const existingLineId = po.lines[i]?.poLineId + return { + poLineId: existingLineId ?? 900 + poId * 10 + i, + itemId: l.itemId, + uomId: l.uomId, + warehouseId: l.warehouseId, + qty: l.qty, + unitPrice: l.unitPrice, + tax: l.tax, + qtyReceived: existingLineId ? (priorQtyReceived.get(existingLineId) ?? 0) : 0, + } + }) + po.updatedAt = new Date().toISOString() + + const next = bumpPoVersion(poId) + return mockDelay({ data: po, etag: String(next) }) }, - cancel(poId: number, request: CancelPurchaseOrderRequest) { - return apiRequest(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request }) + + cancel(poId: number, request: CancelPurchaseOrderRequest): Promise { + const po = mockPurchaseOrders.find((p) => p.poId === poId) + if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`)) + if (po.lines.some((l) => l.qtyReceived > 0)) { + return Promise.reject(new Error(`${po.docNo} has receipts against it and can no longer be cancelled.`)) + } + if (!request.reason.trim()) return Promise.reject(new Error("A cancellation reason is required.")) + po.status = "Cancelled" + po.updatedAt = new Date().toISOString() + bumpPoVersion(poId) + return mockDelay(po) }, } diff --git a/Frontend/erp-system/lib/api/purchase-returns.ts b/Frontend/erp-system/lib/api/purchase-returns.ts index 61c1261..248262c 100644 --- a/Frontend/erp-system/lib/api/purchase-returns.ts +++ b/Frontend/erp-system/lib/api/purchase-returns.ts @@ -1,19 +1,113 @@ // One typed client method per Purchase Return endpoint (docs/11-BACKEND-PHASE1.md §3.4, FR-PROC-08). // -// Note: no PurchaseReturnsController exists yet (Backend/PROGRESS.md §2: "deferred — -// needs GRN lines + stock ledger/FIFO"). These calls will 404 until that's built. -import { apiRequest } from "@/lib/api-client" +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with in-memory sample data (lib/api/mock-data.ts) so the Procurement +// screens can be reviewed without a running backend. Restore the commented block +// and delete the mock block once Backend/PROGRESS.md §2 (Procurement) exists. import { PagedResponse } from "@/types/common" import { CreatePurchaseReturnRequest, PurchaseReturn, PurchaseReturnSummary } from "@/types/procurement" +import { + allocatePurchaseReturnId, + allocatePurchaseReturnLineId, + consumeLayerByGrnLine, + mockDelay, + mockPurchaseReturns, + postLedgerEntry, +} from "@/lib/api/mock-data" + +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest } from "@/lib/api-client" +// +// export const purchaseReturnsApi = { +// list() { +// return apiRequest>("/purchase-returns") +// }, +// get(returnId: number) { +// return apiRequest(`/purchase-returns/${returnId}`) +// }, +// create(request: CreatePurchaseReturnRequest) { +// return apiRequest("/purchase-returns", { method: "POST", body: request }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- +function toSummary(r: PurchaseReturn): PurchaseReturnSummary { + return { + returnId: r.returnId, + docNo: r.docNo, + vendorId: r.vendorId, + warehouseId: r.warehouseId, + reasonCodeId: r.reasonCodeId, + status: r.status, + createdAt: r.createdAt, + } +} export const purchaseReturnsApi = { - list() { - return apiRequest>("/purchase-returns") + list(): Promise> { + const items = [...mockPurchaseReturns].sort((a, b) => b.returnId - a.returnId).map(toSummary) + return mockDelay({ + items, + pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 }, + }) }, - get(returnId: number) { - return apiRequest(`/purchase-returns/${returnId}`) + + get(returnId: number): Promise { + const r = mockPurchaseReturns.find((x) => x.returnId === returnId) + if (!r) return Promise.reject(new Error(`Mock purchase return ${returnId} not found`)) + return mockDelay(r) }, - create(request: CreatePurchaseReturnRequest) { - return apiRequest("/purchase-returns", { method: "POST", body: request }) + + create(request: CreatePurchaseReturnRequest): Promise { + if (!request.reasonCodeId) { + return Promise.reject(Object.assign(new Error("A reason code is required for returns."), { code: "REASON_CODE_REQUIRED" })) + } + if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line.")) + + const returnId = allocatePurchaseReturnId() + const ledgerRefs: number[] = [] + const lines: PurchaseReturn["lines"] = [] + + try { + for (const line of request.lines) { + // FR-PROC-08: consumes the exact layer the GRN line created; throws + // StockNegativeError (409 STOCK_NEGATIVE_BLOCKED) if qty exceeds it. + const chunk = consumeLayerByGrnLine(line.grnLineId, line.qty) + const ledger = postLedgerEntry({ + itemId: line.itemId, + warehouseId: chunk.warehouseId, + userId: 17, + direction: "Out", + qtyBase: chunk.qtyConsumed, + unitCost: chunk.unitCost, + sourceDocType: "PurchaseReturn", + sourceDocId: returnId, + }) + ledgerRefs.push(ledger.ledgerId) + lines.push({ + returnLineId: allocatePurchaseReturnLineId(), + grnLineId: line.grnLineId, + itemId: line.itemId, + qty: line.qty, + }) + } + } catch (err) { + return Promise.reject(err) + } + + const purchaseReturn: PurchaseReturn = { + returnId, + docNo: `PRET-2026-${String(returnId).padStart(5, "0")}`, + vendorId: request.vendorId, + warehouseId: request.warehouseId, + reasonCodeId: request.reasonCodeId, + status: "Posted", + createdBy: 17, + createdAt: new Date().toISOString(), + lines, + ledgerRefs, + } + mockPurchaseReturns.push(purchaseReturn) + return mockDelay(purchaseReturn) }, } diff --git a/Frontend/erp-system/lib/api/reason-codes.ts b/Frontend/erp-system/lib/api/reason-codes.ts index 42e7390..0274f38 100644 --- a/Frontend/erp-system/lib/api/reason-codes.ts +++ b/Frontend/erp-system/lib/api/reason-codes.ts @@ -1,13 +1,27 @@ // One typed client method for reference data (docs/11-BACKEND-PHASE1.md §6). // -// Note: no ReasonCodesController exists yet — this will 404 until Stock -// Transactions (Backend/PROGRESS.md §5) is built. -import { apiRequest, buildQuery } from "@/lib/api-client" +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with in-memory sample data (lib/api/mock-data.ts). import { PagedResponse } from "@/types/common" import { ReasonCode, ReasonCodeContext } from "@/types/stock" +import { mockDelay, mockReasonCodes } from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest, buildQuery } from "@/lib/api-client" +// +// export const reasonCodesApi = { +// list(context?: ReasonCodeContext) { +// return apiRequest>(`/reason-codes${buildQuery({ context })}`) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export const reasonCodesApi = { - list(context?: ReasonCodeContext) { - return apiRequest>(`/reason-codes${buildQuery({ context })}`) + list(context?: ReasonCodeContext): Promise> { + const items = mockReasonCodes.filter((r) => !context || r.context === context) + return mockDelay({ + items, + pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 }, + }) }, } diff --git a/Frontend/erp-system/lib/api/requisitions.ts b/Frontend/erp-system/lib/api/requisitions.ts index c65f465..9442ff3 100644 --- a/Frontend/erp-system/lib/api/requisitions.ts +++ b/Frontend/erp-system/lib/api/requisitions.ts @@ -1,25 +1,103 @@ // One typed client method per Requisition endpoint (docs/11-BACKEND-PHASE1.md §3.1, FR-PROC-01). -import { apiRequest, buildQuery } from "@/lib/api-client" +// +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with in-memory sample data (lib/api/mock-data.ts) so the Procurement +// screens can be reviewed without a running backend. Restore the commented block +// and delete the mock block once Backend/PROGRESS.md §2 (Procurement) exists. import { PagedResponse } from "@/types/common" import { CreateRequisitionRequest, Requisition, RequisitionStatus, RequisitionSummary } from "@/types/procurement" +import { allocateReqLineId, allocateRequisitionId, mockDelay, mockRequisitions } from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest, buildQuery } from "@/lib/api-client" +// +// export interface ListRequisitionsParams { +// page?: number +// pageSize?: number +// status?: RequisitionStatus +// } +// +// export const requisitionsApi = { +// list(params: ListRequisitionsParams = {}) { +// return apiRequest>(`/requisitions${buildQuery(params)}`) +// }, +// get(requisitionId: number) { +// return apiRequest(`/requisitions/${requisitionId}`) +// }, +// create(request: CreateRequisitionRequest) { +// return apiRequest("/requisitions", { method: "POST", body: request }) +// }, +// submit(requisitionId: number) { +// return apiRequest(`/requisitions/${requisitionId}/submit`, { method: "POST", body: {} }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export interface ListRequisitionsParams { page?: number pageSize?: number status?: RequisitionStatus } +function toSummary(r: Requisition): RequisitionSummary { + return { + requisitionId: r.requisitionId, + docNo: r.docNo, + status: r.status, + requestedBy: r.requestedBy, + createdAt: r.createdAt, + lineCount: r.lines.length, + } +} + export const requisitionsApi = { - list(params: ListRequisitionsParams = {}) { - return apiRequest>(`/requisitions${buildQuery(params)}`) + list(params: ListRequisitionsParams = {}): Promise> { + const filtered = mockRequisitions + .filter((r) => !params.status || r.status === params.status) + .map(toSummary) + .sort((a, b) => b.requisitionId - a.requisitionId) + + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 20 + const start = (page - 1) * pageSize + const items = filtered.slice(start, start + pageSize) + + return mockDelay({ + items, + pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 }, + }) }, - get(requisitionId: number) { - return apiRequest(`/requisitions/${requisitionId}`) + + get(requisitionId: number): Promise { + const r = mockRequisitions.find((x) => x.requisitionId === requisitionId) + if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`)) + return mockDelay(r) }, - create(request: CreateRequisitionRequest) { - return apiRequest("/requisitions", { method: "POST", body: request }) + + create(request: CreateRequisitionRequest): Promise { + if (request.lines.length === 0) { + return Promise.reject(new Error("A requisition needs at least one line.")) + } + const requisition: Requisition = { + requisitionId: allocateRequisitionId(), + docNo: "", + status: "Draft", + requestedBy: 17, + createdAt: new Date().toISOString(), + lines: request.lines.map((l) => ({ reqLineId: allocateReqLineId(), itemId: l.itemId, qty: l.qty, requiredBy: l.requiredBy })), + } + requisition.docNo = `PR-2026-${String(requisition.requisitionId).padStart(5, "0")}` + mockRequisitions.push(requisition) + return mockDelay(requisition) }, - submit(requisitionId: number) { - return apiRequest(`/requisitions/${requisitionId}/submit`, { method: "POST", body: {} }) + + submit(requisitionId: number): Promise { + const r = mockRequisitions.find((x) => x.requisitionId === requisitionId) + if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`)) + if (r.status !== "Draft") { + return Promise.reject(new Error(`${r.docNo} has already been submitted.`)) + } + r.status = "Submitted" + return mockDelay(r) }, } diff --git a/Frontend/erp-system/lib/api/rfqs.ts b/Frontend/erp-system/lib/api/rfqs.ts index 397f2d4..b7380f7 100644 --- a/Frontend/erp-system/lib/api/rfqs.ts +++ b/Frontend/erp-system/lib/api/rfqs.ts @@ -1,26 +1,126 @@ // One typed client method per RFQ/Quotation endpoint (docs/11-BACKEND-PHASE1.md §3.2, FR-PROC-02). // -// Note: `list()` calls GET /rfqs, which is not implemented by RfqsController -// (only GET /rfqs/{id} exists) — see Backend/PROGRESS.md §2. This will 404 -// until that endpoint is added; flagged here rather than silently faked. -import { apiRequest } from "@/lib/api-client" +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with in-memory sample data (lib/api/mock-data.ts) so the Procurement +// screens can be reviewed without a running backend. Restore the commented block +// and delete the mock block once Backend/PROGRESS.md §2 (Procurement) exists. import { PagedResponse } from "@/types/common" -import { CreateQuotationRequest, CreateRfqRequest, Quotation, Rfq, RfqComparison, RfqSummary } from "@/types/procurement" +import { + CreateQuotationRequest, + CreateRfqRequest, + Quotation, + Rfq, + RfqComparison, + RfqComparisonLine, + RfqSummary, +} from "@/types/procurement" +import { + allocateQuotationId, + allocateRfqId, + allocateRfqLineId, + mockDelay, + mockQuotations, + mockRfqs, +} from "@/lib/api/mock-data" + +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest } from "@/lib/api-client" +// +// export const rfqsApi = { +// list() { +// return apiRequest>("/rfqs") +// }, +// get(rfqId: number) { +// return apiRequest(`/rfqs/${rfqId}`) +// }, +// create(request: CreateRfqRequest) { +// return apiRequest("/rfqs", { method: "POST", body: request }) +// }, +// addQuotation(rfqId: number, request: CreateQuotationRequest) { +// return apiRequest(`/rfqs/${rfqId}/quotations`, { method: "POST", body: request }) +// }, +// comparison(rfqId: number) { +// return apiRequest(`/rfqs/${rfqId}/comparison`) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- +function toSummary(r: Rfq): RfqSummary { + return { + rfqId: r.rfqId, + docNo: r.docNo, + requisitionId: r.requisitionId, + status: r.status, + vendorIds: r.vendorIds, + createdAt: r.createdAt, + } +} export const rfqsApi = { - list() { - return apiRequest>("/rfqs") + list(): Promise> { + const items = [...mockRfqs].sort((a, b) => b.rfqId - a.rfqId).map(toSummary) + return mockDelay({ + items, + pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 }, + }) }, - get(rfqId: number) { - return apiRequest(`/rfqs/${rfqId}`) + + get(rfqId: number): Promise { + const r = mockRfqs.find((x) => x.rfqId === rfqId) + if (!r) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`)) + return mockDelay(r) }, - create(request: CreateRfqRequest) { - return apiRequest("/rfqs", { method: "POST", body: request }) + + create(request: CreateRfqRequest): Promise { + if (request.vendorIds.length === 0) return Promise.reject(new Error("Select at least one vendor.")) + if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line.")) + const rfq: Rfq = { + rfqId: allocateRfqId(), + docNo: "", + requisitionId: request.requisitionId ?? null, + status: "Open", + vendorIds: request.vendorIds, + createdAt: new Date().toISOString(), + lines: request.lines.map((l) => ({ rfqLineId: allocateRfqLineId(), itemId: l.itemId, qty: l.qty })), + } + rfq.docNo = `RFQ-2026-${String(rfq.rfqId).padStart(5, "0")}` + mockRfqs.push(rfq) + return mockDelay(rfq) }, - addQuotation(rfqId: number, request: CreateQuotationRequest) { - return apiRequest(`/rfqs/${rfqId}/quotations`, { method: "POST", body: request }) + + addQuotation(rfqId: number, request: CreateQuotationRequest): Promise { + const rfq = mockRfqs.find((x) => x.rfqId === rfqId) + if (!rfq) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`)) + if (!rfq.vendorIds.includes(request.vendorId)) { + return Promise.reject(new Error("This vendor was not invited to the RFQ.")) + } + const quotation: Quotation = { + quotationId: allocateQuotationId(), + rfqId, + vendorId: request.vendorId, + createdAt: new Date().toISOString(), + lines: request.lines, + } + mockQuotations.push(quotation) + return mockDelay(quotation) }, - comparison(rfqId: number) { - return apiRequest(`/rfqs/${rfqId}/comparison`) + + comparison(rfqId: number): Promise { + const rfq = mockRfqs.find((x) => x.rfqId === rfqId) + if (!rfq) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`)) + const quotations = mockQuotations.filter((q) => q.rfqId === rfqId) + + const lines: RfqComparisonLine[] = rfq.lines.map((rfqLine) => ({ + itemId: rfqLine.itemId, + qty: rfqLine.qty, + cells: quotations + .map((q) => { + const line = q.lines.find((l) => l.itemId === rfqLine.itemId) + return line ? { vendorId: q.vendorId, unitPrice: line.unitPrice, leadDays: line.leadDays } : null + }) + .filter((c): c is { vendorId: number; unitPrice: number; leadDays: number } => c !== null), + })) + + return mockDelay({ rfqId, vendorIds: rfq.vendorIds, lines }) }, } diff --git a/Frontend/erp-system/lib/api/stock-adjustments.ts b/Frontend/erp-system/lib/api/stock-adjustments.ts index 525314f..b48f118 100644 --- a/Frontend/erp-system/lib/api/stock-adjustments.ts +++ b/Frontend/erp-system/lib/api/stock-adjustments.ts @@ -1,27 +1,152 @@ // One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5). // -// Note: no Stock Transactions backend exists yet (Backend/PROGRESS.md §5). These -// calls will 404 until that's built. GET /stock-adjustments and GET -// /stock-adjustments/{id} are also not documented in docs/11 §5.5 — same +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with the in-memory Stock Core (lib/api/mock-data.ts). Restore the +// commented block and delete the mock block once Backend/PROGRESS.md §5 +// (stock transactions) exists. GET /stock-adjustments and GET +// /stock-adjustments/{id} are not documented in docs/11 §5.5 — same // assumed-extension deviation as GRN (see Frontend/PROGRESS.md §5). -import { apiRequest, buildQuery } from "@/lib/api-client" import { PagedResponse } from "@/types/common" -import { CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock" +import { AdjustmentStatus, CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock" +import { + allocateAdjLineId, + allocateAdjustmentId, + consumeFifo, + lastKnownCost, + mockDelay, + mockStockAdjustments, + postLedgerEntry, + receiveLayer, +} from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest, buildQuery } from "@/lib/api-client" +// +// export interface ListAdjustmentsParams { +// page?: number +// pageSize?: number +// warehouseId?: number +// } +// +// export const stockAdjustmentsApi = { +// list(params: ListAdjustmentsParams = {}) { +// return apiRequest>(`/stock-adjustments${buildQuery(params)}`) +// }, +// get(adjustmentId: number) { +// return apiRequest(`/stock-adjustments/${adjustmentId}`) +// }, +// create(request: CreateAdjustmentRequest) { +// return apiRequest("/stock-adjustments", { method: "POST", body: request }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export interface ListAdjustmentsParams { page?: number pageSize?: number warehouseId?: number } +function toSummary(a: (typeof mockStockAdjustments)[number]): StockAdjustmentSummary { + return { + adjustmentId: a.adjustmentId, + docNo: a.docNo, + warehouseId: a.warehouseId, + reasonCodeId: a.reasonCodeId, + status: a.status, + createdAt: a.createdAt, + } +} + export const stockAdjustmentsApi = { - list(params: ListAdjustmentsParams = {}) { - return apiRequest>(`/stock-adjustments${buildQuery(params)}`) + list(params: ListAdjustmentsParams = {}): Promise> { + const filtered = mockStockAdjustments + .filter((a) => !params.warehouseId || a.warehouseId === params.warehouseId) + .map(toSummary) + .sort((a, b) => b.adjustmentId - a.adjustmentId) + + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 20 + const start = (page - 1) * pageSize + const items = filtered.slice(start, start + pageSize) + + return mockDelay({ + items, + pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 }, + }) }, - get(adjustmentId: number) { - return apiRequest(`/stock-adjustments/${adjustmentId}`) + + get(adjustmentId: number): Promise { + const a = mockStockAdjustments.find((x) => x.adjustmentId === adjustmentId) + if (!a) return Promise.reject(new Error(`Mock adjustment ${adjustmentId} not found`)) + return mockDelay(a) }, - create(request: CreateAdjustmentRequest) { - return apiRequest("/stock-adjustments", { method: "POST", body: request }) + + create(request: CreateAdjustmentRequest): Promise { + if (!request.reasonCodeId) { + return Promise.reject(Object.assign(new Error("A reason code is required for adjustments."), { code: "REASON_CODE_REQUIRED" })) + } + + const adjustmentId = allocateAdjustmentId() + const ledgerRefs: number[] = [] + const lines: StockAdjustment["lines"] = [] + + try { + for (const line of request.lines) { + const adjLineId = allocateAdjLineId() + lines.push({ adjLineId, itemId: line.itemId, binId: line.binId ?? null, batchId: line.batchId ?? null, qtyDelta: line.qtyDelta }) + + if (line.qtyDelta > 0) { + // FR-STK-07: increase creates a layer at the last known cost. + const unitCost = lastKnownCost(line.itemId, request.warehouseId) + const { ledger } = receiveLayer({ + itemId: line.itemId, + warehouseId: request.warehouseId, + binId: line.binId, + batchId: line.batchId, + qty: line.qtyDelta, + unitCost, + userId: 17, + sourceDocType: "Adjustment", + sourceDocId: adjustmentId, + }) + ledgerRefs.push(ledger.ledgerId) + } else if (line.qtyDelta < 0) { + // Decrease consumes FIFO layers (409 STOCK_NEGATIVE_BLOCKED if insufficient). + const chunks = consumeFifo(line.itemId, request.warehouseId, Math.abs(line.qtyDelta)) + for (const chunk of chunks) { + const ledger = postLedgerEntry({ + itemId: line.itemId, + warehouseId: request.warehouseId, + binId: line.binId, + batchId: line.batchId, + userId: 17, + direction: "Out", + qtyBase: chunk.qtyConsumed, + unitCost: chunk.unitCost, + sourceDocType: "Adjustment", + sourceDocId: adjustmentId, + }) + ledgerRefs.push(ledger.ledgerId) + } + } + } + } catch (err) { + return Promise.reject(err) + } + + const adjustment = { + adjustmentId, + docNo: `ADJ-2026-${String(adjustmentId).padStart(5, "0")}`, + warehouseId: request.warehouseId, + reasonCodeId: request.reasonCodeId, + status: "Posted" as AdjustmentStatus, + createdBy: 17, + createdAt: new Date().toISOString(), + lines, + ledgerRefs, + } + mockStockAdjustments.push(adjustment) + return mockDelay(adjustment) }, } diff --git a/Frontend/erp-system/lib/api/stock-counts.ts b/Frontend/erp-system/lib/api/stock-counts.ts index 7488ca0..a2427f6 100644 --- a/Frontend/erp-system/lib/api/stock-counts.ts +++ b/Frontend/erp-system/lib/api/stock-counts.ts @@ -1,12 +1,14 @@ // One typed client method per count endpoint (docs/11-BACKEND-PHASE1.md §5.6). // -// Note: no Stock Transactions backend exists yet (Backend/PROGRESS.md §5). These -// calls will 404 until that's built. GET /stock-counts and GET -// /stock-counts/{id} are also not documented in docs/11 §5.6 — same -// assumed-extension deviation as GRN (see Frontend/PROGRESS.md §5). -import { apiRequest, buildQuery } from "@/lib/api-client" +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with the in-memory Stock Core (lib/api/mock-data.ts). Restore the +// commented block and delete the mock block once Backend/PROGRESS.md §5 +// (stock transactions) exists. GET /stock-counts and GET /stock-counts/{id} +// are not documented in docs/11 §5.6 — same assumed-extension deviation as +// GRN (see Frontend/PROGRESS.md §5). import { PagedResponse } from "@/types/common" import { + CountStatus, CreateCountRequest, EnterCountsRequest, EnterCountsResponse, @@ -14,27 +16,183 @@ import { StockCount, StockCountSummary, } from "@/types/stock" +import { + allocateAdjLineId, + allocateAdjustmentId, + allocateCountId, + allocateCountLineId, + computeOnHand, + consumeFifo, + lastKnownCost, + mockDelay, + mockStockAdjustments, + mockStockCounts, + postLedgerEntry, + receiveLayer, +} from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest, buildQuery } from "@/lib/api-client" +// +// export interface ListCountsParams { +// page?: number +// pageSize?: number +// warehouseId?: number +// } +// +// export const stockCountsApi = { +// list(params: ListCountsParams = {}) { +// return apiRequest>(`/stock-counts${buildQuery(params)}`) +// }, +// get(countId: number) { +// return apiRequest(`/stock-counts/${countId}`) +// }, +// create(request: CreateCountRequest) { +// return apiRequest("/stock-counts", { method: "POST", body: request }) +// }, +// enterCounts(countId: number, request: EnterCountsRequest) { +// return apiRequest(`/stock-counts/${countId}/counts`, { method: "PUT", body: request }) +// }, +// post(countId: number) { +// return apiRequest(`/stock-counts/${countId}/post`, { method: "POST", body: {} }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export interface ListCountsParams { page?: number pageSize?: number warehouseId?: number } +function toSummary(c: (typeof mockStockCounts)[number]): StockCountSummary { + return { + countId: c.countId, + docNo: c.docNo, + warehouseId: c.warehouseId, + countType: c.countType, + status: c.status, + createdAt: c.createdAt, + } +} + export const stockCountsApi = { - list(params: ListCountsParams = {}) { - return apiRequest>(`/stock-counts${buildQuery(params)}`) + list(params: ListCountsParams = {}): Promise> { + const filtered = mockStockCounts + .filter((c) => !params.warehouseId || c.warehouseId === params.warehouseId) + .map(toSummary) + .sort((a, b) => b.countId - a.countId) + + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 20 + const start = (page - 1) * pageSize + const items = filtered.slice(start, start + pageSize) + + return mockDelay({ + items, + pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 }, + }) }, - get(countId: number) { - return apiRequest(`/stock-counts/${countId}`) + + get(countId: number): Promise { + const c = mockStockCounts.find((x) => x.countId === countId) + if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`)) + return mockDelay(c) }, - create(request: CreateCountRequest) { - return apiRequest("/stock-counts", { method: "POST", body: request }) + + create(request: CreateCountRequest): Promise { + const countId = allocateCountId() + const count = { + countId, + docNo: `CNT-2026-${String(countId).padStart(5, "0")}`, + warehouseId: request.warehouseId, + countType: request.countType, + status: "Draft" as CountStatus, + createdBy: 17, + createdAt: new Date().toISOString(), + lines: request.itemIds.map((itemId) => ({ + countLineId: allocateCountLineId(), + itemId, + binId: null, + systemQty: computeOnHand(itemId, request.warehouseId).onHand, + countedQty: null, + variance: null, + })), + } + mockStockCounts.push(count) + return mockDelay(count) }, - enterCounts(countId: number, request: EnterCountsRequest) { - return apiRequest(`/stock-counts/${countId}/counts`, { method: "PUT", body: request }) + + enterCounts(countId: number, request: EnterCountsRequest): Promise { + const c = mockStockCounts.find((x) => x.countId === countId) + if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`)) + + for (const input of request.lines) { + const line = c.lines.find((l) => l.countLineId === input.countLineId) + if (!line) continue + line.countedQty = input.countedQty + line.variance = Math.round((input.countedQty - line.systemQty) * 100) / 100 + } + return mockDelay({ lines: c.lines }) }, - post(countId: number) { - return apiRequest(`/stock-counts/${countId}/post`, { method: "POST", body: {} }) + + post(countId: number): Promise { + const c = mockStockCounts.find((x) => x.countId === countId) + if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`)) + if (c.status === "Posted") return Promise.reject(new Error(`${c.docNo} has already been posted.`)) + + const adjustmentId = allocateAdjustmentId() + const ledgerRefs: number[] = [] + const adjLines: { adjLineId: number; itemId: number; binId: number | null; batchId: number | null; qtyDelta: number }[] = [] + + for (const line of c.lines) { + if (!line.variance) continue + adjLines.push({ adjLineId: allocateAdjLineId(), itemId: line.itemId, binId: line.binId, batchId: null, qtyDelta: line.variance }) + + if (line.variance > 0) { + const unitCost = lastKnownCost(line.itemId, c.warehouseId) + const { ledger } = receiveLayer({ + itemId: line.itemId, + warehouseId: c.warehouseId, + qty: line.variance, + unitCost, + userId: 17, + sourceDocType: "Count", + sourceDocId: c.countId, + }) + ledgerRefs.push(ledger.ledgerId) + } else { + const chunks = consumeFifo(line.itemId, c.warehouseId, Math.abs(line.variance)) + for (const chunk of chunks) { + const ledger = postLedgerEntry({ + itemId: line.itemId, + warehouseId: c.warehouseId, + userId: 17, + direction: "Out", + qtyBase: chunk.qtyConsumed, + unitCost: chunk.unitCost, + sourceDocType: "Count", + sourceDocId: c.countId, + }) + ledgerRefs.push(ledger.ledgerId) + } + } + } + + // Count Variance reason code (docs/8.3 seed list) — posted as its own adjustment record. + mockStockAdjustments.push({ + adjustmentId, + docNo: `ADJ-2026-${String(adjustmentId).padStart(5, "0")}`, + warehouseId: c.warehouseId, + reasonCodeId: 3, + status: "Posted", + createdBy: 17, + createdAt: new Date().toISOString(), + lines: adjLines, + ledgerRefs, + }) + + c.status = "Posted" + return mockDelay({ countId: c.countId, status: c.status, adjustmentId, ledgerRefs }) }, } diff --git a/Frontend/erp-system/lib/api/stock-transfers.ts b/Frontend/erp-system/lib/api/stock-transfers.ts index 90f5f8b..b6c1552 100644 --- a/Frontend/erp-system/lib/api/stock-transfers.ts +++ b/Frontend/erp-system/lib/api/stock-transfers.ts @@ -1,10 +1,11 @@ // One typed client method per transfer endpoint (docs/11-BACKEND-PHASE1.md §5.4). // -// Note: no Stock Transactions backend exists yet (Backend/PROGRESS.md §5). These -// calls will 404 until that's built. GET /stock-transfers and GET -// /stock-transfers/{id} are also not documented in docs/11 §5.4 — same -// assumed-extension deviation as GRN (see Frontend/PROGRESS.md §5). -import { apiRequest, buildQuery } from "@/lib/api-client" +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with the in-memory Stock Core (lib/api/mock-data.ts). Restore the +// commented block and delete the mock block once Backend/PROGRESS.md §5 +// (stock transactions) exists. GET /stock-transfers and GET /stock-transfers/{id} +// are not documented in docs/11 §5.4 — same assumed-extension deviation as GRN +// (see Frontend/PROGRESS.md §5). import { PagedResponse } from "@/types/common" import { CreateTransferRequest, @@ -15,7 +16,50 @@ import { StockTransferSummary, TransferStatus, } from "@/types/stock" +import { + MockTransferLine, + allocateTransferId, + allocateTransferLineId, + consumeFifo, + mockDelay, + mockStockTransfers, + postLedgerEntry, + receiveLayer, +} from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest, buildQuery } from "@/lib/api-client" +// +// export interface ListTransfersParams { +// page?: number +// pageSize?: number +// status?: TransferStatus +// srcWarehouseId?: number +// destWarehouseId?: number +// } +// +// export const stockTransfersApi = { +// list(params: ListTransfersParams = {}) { +// return apiRequest>(`/stock-transfers${buildQuery(params)}`) +// }, +// get(transferId: number) { +// return apiRequest(`/stock-transfers/${transferId}`) +// }, +// create(request: CreateTransferRequest) { +// return apiRequest("/stock-transfers", { method: "POST", body: request }) +// }, +// dispatch(transferId: number) { +// return apiRequest(`/stock-transfers/${transferId}/dispatch`, { method: "POST", body: {} }) +// }, +// receive(transferId: number, lines: ReceiveTransferLineInput[]) { +// return apiRequest(`/stock-transfers/${transferId}/receive`, { +// method: "POST", +// body: { lines }, +// }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export interface ListTransfersParams { page?: number pageSize?: number @@ -24,23 +68,154 @@ export interface ListTransfersParams { destWarehouseId?: number } +function toPublicLine(line: MockTransferLine) { + return { + transferLineId: line.transferLineId, + itemId: line.itemId, + srcBinId: line.srcBinId, + destBinId: line.destBinId, + batchId: line.batchId, + qty: line.qty, + } +} + +function toSummary(t: (typeof mockStockTransfers)[number]): StockTransferSummary { + return { + transferId: t.transferId, + docNo: t.docNo, + srcWarehouseId: t.srcWarehouseId, + destWarehouseId: t.destWarehouseId, + status: t.status, + createdAt: t.createdAt, + } +} + +function toPublic(t: (typeof mockStockTransfers)[number]): StockTransfer { + return { ...toSummary(t), createdBy: t.createdBy, lines: t.lines.map(toPublicLine) } +} + export const stockTransfersApi = { - list(params: ListTransfersParams = {}) { - return apiRequest>(`/stock-transfers${buildQuery(params)}`) - }, - get(transferId: number) { - return apiRequest(`/stock-transfers/${transferId}`) - }, - create(request: CreateTransferRequest) { - return apiRequest("/stock-transfers", { method: "POST", body: request }) - }, - dispatch(transferId: number) { - return apiRequest(`/stock-transfers/${transferId}/dispatch`, { method: "POST", body: {} }) - }, - receive(transferId: number, lines: ReceiveTransferLineInput[]) { - return apiRequest(`/stock-transfers/${transferId}/receive`, { - method: "POST", - body: { lines }, + list(params: ListTransfersParams = {}): Promise> { + const filtered = mockStockTransfers + .filter((t) => !params.status || t.status === params.status) + .filter((t) => !params.srcWarehouseId || t.srcWarehouseId === params.srcWarehouseId) + .filter((t) => !params.destWarehouseId || t.destWarehouseId === params.destWarehouseId) + .map(toSummary) + .sort((a, b) => b.transferId - a.transferId) + + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 20 + const start = (page - 1) * pageSize + const items = filtered.slice(start, start + pageSize) + + return mockDelay({ + items, + pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 }, }) }, + + get(transferId: number): Promise { + const t = mockStockTransfers.find((x) => x.transferId === transferId) + if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`)) + return mockDelay(toPublic(t)) + }, + + create(request: CreateTransferRequest): Promise { + const transferId = allocateTransferId() + const t = { + transferId, + docNo: `TRF-2026-${String(transferId).padStart(5, "0")}`, + srcWarehouseId: request.srcWarehouseId, + destWarehouseId: request.destWarehouseId, + status: "Draft" as TransferStatus, + createdBy: 17, + createdAt: new Date().toISOString(), + lines: request.lines.map((l) => ({ + transferLineId: allocateTransferLineId(), + itemId: l.itemId, + srcBinId: l.srcBinId ?? null, + destBinId: l.destBinId ?? null, + batchId: l.batchId ?? null, + qty: l.qty, + dispatchedChunks: [], + })), + } + mockStockTransfers.push(t) + return mockDelay(toPublic(t)) + }, + + dispatch(transferId: number): Promise { + const t = mockStockTransfers.find((x) => x.transferId === transferId) + if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`)) + if (t.status !== "Draft") return Promise.reject(new Error(`${t.docNo} has already been dispatched.`)) + + const consumedLayers: { layerId: number; qtyConsumed: number; unitCost: number }[] = [] + const ledgerRefs: number[] = [] + + try { + for (const line of t.lines) { + const chunks = consumeFifo(line.itemId, t.srcWarehouseId, line.qty) + line.dispatchedChunks = chunks + for (const chunk of chunks) { + const ledger = postLedgerEntry({ + itemId: line.itemId, + warehouseId: t.srcWarehouseId, + binId: line.srcBinId, + batchId: line.batchId, + userId: t.createdBy, + direction: "Out", + qtyBase: chunk.qtyConsumed, + unitCost: chunk.unitCost, + sourceDocType: "Transfer", + sourceDocId: t.transferId, + }) + consumedLayers.push(chunk) + ledgerRefs.push(ledger.ledgerId) + } + } + } catch (err) { + return Promise.reject(err) + } + + t.status = "InTransit" + return mockDelay({ transferId: t.transferId, status: t.status, consumedLayers, ledgerRefs }) + }, + + receive(transferId: number, lines: ReceiveTransferLineInput[]): Promise { + const t = mockStockTransfers.find((x) => x.transferId === transferId) + if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`)) + if (t.status !== "InTransit") return Promise.reject(new Error(`${t.docNo} is not in transit.`)) + + const createdLayers: { layerId: number; warehouseId: number; qtyReceived: number; unitCost: number }[] = [] + const ledgerRefs: number[] = [] + + for (const input of lines) { + const line = t.lines.find((l) => l.transferLineId === input.transferLineId) + if (!line) continue + // Cost-preserving (FR-STK-06): one destination layer per dispatched chunk, at its exact source cost. + for (const chunk of line.dispatchedChunks) { + const { layer, ledger } = receiveLayer({ + itemId: line.itemId, + warehouseId: t.destWarehouseId, + binId: line.destBinId, + batchId: line.batchId, + qty: chunk.qtyConsumed, + unitCost: chunk.unitCost, + userId: t.createdBy, + sourceDocType: "Transfer", + sourceDocId: t.transferId, + }) + createdLayers.push({ + layerId: layer.layerId, + warehouseId: layer.warehouseId, + qtyReceived: layer.qtyReceived, + unitCost: layer.unitCost, + }) + ledgerRefs.push(ledger.ledgerId) + } + } + + t.status = "Received" + return mockDelay({ transferId: t.transferId, status: t.status, createdLayers, ledgerRefs }) + }, } diff --git a/Frontend/erp-system/lib/api/stock.ts b/Frontend/erp-system/lib/api/stock.ts index 5900b5a..58d31be 100644 --- a/Frontend/erp-system/lib/api/stock.ts +++ b/Frontend/erp-system/lib/api/stock.ts @@ -1,15 +1,62 @@ // One typed client method per stock-enquiry endpoint (docs/11-BACKEND-PHASE1.md §5.1-5.3, §5.7). // -// Note: no Stock Core backend exists yet (Backend/PROGRESS.md §4 is unstarted). -// These calls will 404 until that's built. `onHandList`/`createReorderRequisition` -// are frontend-only conveniences, not documented endpoints (same posture as the -// GRN list/detail assumed-extension deviation, see Frontend/PROGRESS.md §5). -import { apiRequest, buildQuery } from "@/lib/api-client" +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with the in-memory Stock Core (lib/api/mock-data.ts) so the Stock +// Management screens can be reviewed without a running backend. Restore the +// commented block and delete the mock block once Backend/PROGRESS.md §4 +// (Stock Core) exists. import { PagedResponse } from "@/types/common" import { LedgerEntry, OnHand, ReorderAlert, ReorderRequisitionResponse, Valuation } from "@/types/stock" -import { itemsApi } from "@/lib/api/items" -import { warehousesApi } from "@/lib/api/warehouses" +import { + allocateReqLineId, + allocateRequisitionId, + computeOnHand, + knownStockKeys, + mockDelay, + mockItemReorders, + mockRequisitions, + mockStockLayers, + mockStockLedger, +} from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest, buildQuery } from "@/lib/api-client" +// +// export interface LedgerQuery { +// itemId?: number +// warehouseId?: number +// from?: string +// to?: string +// page?: number +// pageSize?: number +// } +// +// export const stockApi = { +// onHand(itemId: number, warehouseId: number) { +// return apiRequest(`/stock/on-hand${buildQuery({ itemId, warehouseId })}`) +// }, +// +// ledger(params: LedgerQuery) { +// return apiRequest>(`/stock/ledger${buildQuery(params)}`) +// }, +// +// valuation(itemId: number, warehouseId: number) { +// return apiRequest(`/stock/valuation${buildQuery({ itemId, warehouseId })}`) +// }, +// +// reorderAlerts(warehouseId?: number) { +// return apiRequest>(`/stock/reorder-alerts${buildQuery({ warehouseId })}`) +// }, +// +// createReorderRequisition(itemId: number, warehouseId: number) { +// return apiRequest( +// `/stock/reorder-alerts/${itemId}/requisition${buildQuery({ warehouseId })}`, +// { method: "POST", body: {} } +// ) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export interface LedgerQuery { itemId?: number warehouseId?: number @@ -20,37 +67,124 @@ export interface LedgerQuery { } export const stockApi = { - onHand(itemId: number, warehouseId: number) { - return apiRequest(`/stock/on-hand${buildQuery({ itemId, warehouseId })}`) + onHand(itemId: number, warehouseId: number): Promise { + const computed = computeOnHand(itemId, warehouseId) + return mockDelay({ + itemId, + warehouseId, + ...computed, + asOf: new Date().toISOString(), + }) }, - /** Every item x warehouse combination — composed client-side, not a documented endpoint. */ - async onHandList(): Promise { - const [items, warehouses] = await Promise.all([ - itemsApi.list({ pageSize: 200 }), - warehousesApi.list(), - ]) - const pairs = items.items.flatMap((item) => warehouses.items.map((wh) => ({ item, wh }))) - const rows = await Promise.all(pairs.map(({ item, wh }) => stockApi.onHand(item.itemId, wh.warehouseId))) - return rows.filter((r) => r.onHand > 0 || r.onHold > 0 || r.inTransit > 0) + /** Every item/warehouse pair currently on record — the Enquiry screen's row source. */ + onHandList(): Promise { + const rows = knownStockKeys().map(({ itemId, warehouseId }) => ({ + itemId, + warehouseId, + ...computeOnHand(itemId, warehouseId), + asOf: new Date().toISOString(), + })) + return mockDelay(rows) }, - ledger(params: LedgerQuery) { - return apiRequest>(`/stock/ledger${buildQuery(params)}`) + ledger(params: LedgerQuery): Promise> { + const from = params.from ? new Date(params.from).getTime() : null + const to = params.to ? new Date(params.to).getTime() : null + + const filtered = mockStockLedger + .filter((e) => !params.itemId || e.itemId === params.itemId) + .filter((e) => !params.warehouseId || e.warehouseId === params.warehouseId) + .filter((e) => from === null || new Date(e.createdAt).getTime() >= from) + .filter((e) => to === null || new Date(e.createdAt).getTime() <= to) + .sort((a, b) => b.ledgerId - a.ledgerId) + + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 20 + const start = (page - 1) * pageSize + const items = filtered.slice(start, start + pageSize) + + return mockDelay({ + items, + pagination: { + page, + pageSize, + totalItems: filtered.length, + totalPages: pageSize <= 0 ? 0 : Math.ceil(filtered.length / pageSize), + }, + }) }, - valuation(itemId: number, warehouseId: number) { - return apiRequest(`/stock/valuation${buildQuery({ itemId, warehouseId })}`) + valuation(itemId: number, warehouseId: number): Promise { + const layers = mockStockLayers + .filter((l) => l.itemId === itemId && l.warehouseId === warehouseId && l.qtyRemaining > 0) + .sort((a, b) => new Date(a.receiptDate).getTime() - new Date(b.receiptDate).getTime()) + .map((l) => ({ + layerId: l.layerId, + qtyRemaining: l.qtyRemaining, + unitCost: l.unitCost, + value: Math.round(l.qtyRemaining * l.unitCost * 100) / 100, + receiptDate: l.receiptDate, + })) + + const totalQty = layers.reduce((sum, l) => sum + l.qtyRemaining, 0) + const totalValue = Math.round(layers.reduce((sum, l) => sum + l.value, 0) * 100) / 100 + + return mockDelay({ + itemId, + warehouseId, + layers, + totalQty, + totalValue, + currency: "LKR", + costingMethod: "FIFO", + }) }, - reorderAlerts(warehouseId?: number) { - return apiRequest>(`/stock/reorder-alerts${buildQuery({ warehouseId })}`) + reorderAlerts(warehouseId?: number): Promise> { + const items = mockItemReorders + .filter((r) => !warehouseId || r.warehouseId === warehouseId) + .map((r) => ({ ...r, available: computeOnHand(r.itemId, r.warehouseId).available })) + .filter((r) => r.available <= r.reorderPoint) + .map((r) => ({ + itemId: r.itemId, + warehouseId: r.warehouseId, + available: r.available, + reorderPoint: r.reorderPoint, + reorderQty: r.reorderQty, + suggestedRequisitionQty: r.reorderQty, + })) + + return mockDelay({ + items, + pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 }, + }) }, - createReorderRequisition(itemId: number, warehouseId: number) { - return apiRequest( - `/stock/reorder-alerts/${itemId}/requisition${buildQuery({ warehouseId })}`, - { method: "POST", body: {} } - ) + createReorderRequisition(itemId: number, warehouseId: number): Promise { + const setting = mockItemReorders.find((r) => r.itemId === itemId && r.warehouseId === warehouseId) + const qty = setting?.reorderQty ?? 0 + const requisitionId = allocateRequisitionId() + const requiredBy = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + + // Genuinely lands in the Requisitions list (§3), not a fabricated response — + // same "wire mock modules together" posture as GRN confirm → Stock Core. + mockRequisitions.push({ + requisitionId, + docNo: `PR-2026-${String(requisitionId).padStart(5, "0")}`, + status: "Draft", + requestedBy: 17, + createdAt: new Date().toISOString(), + lines: [{ reqLineId: allocateReqLineId(), itemId, qty, requiredBy }], + }) + + return mockDelay({ + requisitionId, + docNo: `PR-2026-${String(requisitionId).padStart(5, "0")}`, + itemId, + warehouseId, + qty, + status: "Draft", + }) }, } diff --git a/Frontend/erp-system/lib/api/uoms.ts b/Frontend/erp-system/lib/api/uoms.ts index 4e85657..c6c33b3 100644 --- a/Frontend/erp-system/lib/api/uoms.ts +++ b/Frontend/erp-system/lib/api/uoms.ts @@ -1,14 +1,43 @@ // One typed client method per UOM endpoint (docs/11-BACKEND-PHASE1.md §2.2, FR-MD-02). -// `list` also backs the GRN/PO line UOM picker. -import { apiRequest } from "@/lib/api-client" +// `list` also backs the GRN/PO line UOM picker built in earlier sessions. +// +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens +// can be reviewed without a running backend. Restore the commented block and +// delete the mock block once Backend/PROGRESS.md §1 (Master Data) exists. import { PagedResponse } from "@/types/common" import { CreateUomRequest, Uom } from "@/types/master-data" +import { allocateUomId, mockDelay, mockUoms } from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest } from "@/lib/api-client" +// +// export const uomsApi = { +// list() { +// return apiRequest>("/uoms") +// }, +// create(request: CreateUomRequest) { +// return apiRequest("/uoms", { method: "POST", body: request }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export const uomsApi = { - list() { - return apiRequest>("/uoms") + list(): Promise> { + return mockDelay({ + items: mockUoms, + pagination: { page: 1, pageSize: 20, totalItems: mockUoms.length, totalPages: 1 }, + }) }, - create(request: CreateUomRequest) { - return apiRequest("/uoms", { method: "POST", body: request }) + + create(request: CreateUomRequest): Promise { + const name = request.name.trim() + if (!name) return Promise.reject(new Error("UOM name is required.")) + if (mockUoms.some((u) => u.name.toLowerCase() === name.toLowerCase())) { + return Promise.reject(Object.assign(new Error(`UOM "${name}" already exists.`), { code: "SKU_DUPLICATE" })) + } + const uom: Uom = { uomId: allocateUomId(), name } + mockUoms.push(uom) + return mockDelay(uom) }, } diff --git a/Frontend/erp-system/lib/api/vendors.ts b/Frontend/erp-system/lib/api/vendors.ts index 800ec18..360c20e 100644 --- a/Frontend/erp-system/lib/api/vendors.ts +++ b/Frontend/erp-system/lib/api/vendors.ts @@ -1,10 +1,63 @@ // One typed client method per vendor (supplier) endpoint (docs/11-BACKEND-PHASE1.md -// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate (masters -// are deactivated, not hard-deleted, FR-MD-08). -import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +// §2.4, FR-MD-06). "GET/PUT/PATCH follow the Item pattern" per the doc — ETag/If-Match +// on update, PATCH status for deactivate (masters are deactivated, not hard-deleted, +// FR-MD-08). +// +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens can +// be reviewed without a running backend. Restore the commented block and delete +// the mock block once Backend/PROGRESS.md §1 (Master Data) exists. +import { ApiResult } from "@/lib/api-client" import { EntityStatus, PagedResponse } from "@/types/common" import { Vendor } from "@/types/master-data" +import { + allocateVendorId, + bumpVendorVersion, + getVendorVersion, + initVendorVersion, + mockDelay, + mockVendors, +} from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +// +// export interface ListVendorsParams { +// page?: number +// pageSize?: number +// q?: string +// status?: EntityStatus +// } +// +// export interface CreateVendorRequest { +// code: string +// name: string +// terms?: string | null +// taxReg?: string | null +// currency: string +// } +// +// export type UpdateVendorRequest = CreateVendorRequest +// +// export const vendorsApi = { +// list(params: ListVendorsParams = {}) { +// return apiRequest>(`/vendors${buildQuery(params)}`) +// }, +// get(vendorId: number) { +// return apiRequestWithETag(`/vendors/${vendorId}`) +// }, +// create(request: CreateVendorRequest) { +// return apiRequestWithETag("/vendors", { method: "POST", body: request }) +// }, +// update(vendorId: number, request: UpdateVendorRequest, ifMatch: string) { +// return apiRequestWithETag(`/vendors/${vendorId}`, { method: "PUT", body: request, ifMatch }) +// }, +// updateStatus(vendorId: number, status: EntityStatus) { +// return apiRequest(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export interface ListVendorsParams { page?: number pageSize?: number @@ -22,20 +75,91 @@ export interface CreateVendorRequest { export type UpdateVendorRequest = CreateVendorRequest +function codeTaken(code: string, excludeVendorId?: number) { + return mockVendors.some((v) => v.vendorId !== excludeVendorId && v.code.toLowerCase() === code.toLowerCase()) +} + export const vendorsApi = { - list(params: ListVendorsParams = {}) { - return apiRequest>(`/vendors${buildQuery(params)}`) + list(params: ListVendorsParams = {}): Promise> { + const term = params.q?.trim().toLowerCase() + const filtered = mockVendors + .filter((v) => !params.status || v.status === params.status) + .filter((v) => !term || `${v.code} ${v.name}`.toLowerCase().includes(term)) + + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 20 + const start = (page - 1) * pageSize + const items = filtered.slice(start, start + pageSize) + const totalItems = filtered.length + const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize) + + return mockDelay({ + items, + pagination: { page, pageSize, totalItems, totalPages }, + }) }, - get(vendorId: number) { - return apiRequestWithETag(`/vendors/${vendorId}`) + + get(vendorId: number): Promise> { + const v = mockVendors.find((x) => x.vendorId === vendorId) + if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`)) + return mockDelay({ data: v, etag: String(getVendorVersion(vendorId)) }) }, - create(request: CreateVendorRequest) { - return apiRequestWithETag("/vendors", { method: "POST", body: request }) + + create(request: CreateVendorRequest): Promise> { + const code = request.code.trim() + if (!code) return Promise.reject(new Error("Vendor code is required.")) + if (codeTaken(code)) { + return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" })) + } + const vendor: Vendor = { + vendorId: allocateVendorId(), + code, + name: request.name.trim(), + terms: request.terms?.trim() || null, + taxReg: request.taxReg?.trim() || null, + currency: request.currency.trim().toUpperCase(), + status: "Active", + createdAt: new Date().toISOString(), + updatedAt: null, + } + mockVendors.push(vendor) + initVendorVersion(vendor.vendorId) + return mockDelay({ data: vendor, etag: "1" }) }, - update(vendorId: number, request: UpdateVendorRequest, ifMatch: string) { - return apiRequestWithETag(`/vendors/${vendorId}`, { method: "PUT", body: request, ifMatch }) + + update(vendorId: number, request: UpdateVendorRequest, ifMatch: string): Promise> { + const v = mockVendors.find((x) => x.vendorId === vendorId) + if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`)) + + if (String(getVendorVersion(vendorId)) !== ifMatch) { + return Promise.reject( + Object.assign(new Error("The vendor was modified by another request."), { code: "CONCURRENCY_CONFLICT" }) + ) + } + + const code = request.code.trim() + if (!code) return Promise.reject(new Error("Vendor code is required.")) + if (codeTaken(code, vendorId)) { + return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" })) + } + + v.code = code + v.name = request.name.trim() + v.terms = request.terms?.trim() || null + v.taxReg = request.taxReg?.trim() || null + v.currency = request.currency.trim().toUpperCase() + v.updatedAt = new Date().toISOString() + + const next = bumpVendorVersion(vendorId) + return mockDelay({ data: v, etag: String(next) }) }, - updateStatus(vendorId: number, status: EntityStatus) { - return apiRequest(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } }) + + updateStatus(vendorId: number, status: EntityStatus): Promise { + const v = mockVendors.find((x) => x.vendorId === vendorId) + if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`)) + v.status = status + v.updatedAt = new Date().toISOString() + bumpVendorVersion(vendorId) + return mockDelay(undefined) }, } diff --git a/Frontend/erp-system/lib/api/warehouses.ts b/Frontend/erp-system/lib/api/warehouses.ts index b3954b2..8f130ec 100644 --- a/Frontend/erp-system/lib/api/warehouses.ts +++ b/Frontend/erp-system/lib/api/warehouses.ts @@ -1,9 +1,47 @@ // One typed client method per warehouse/bin endpoint (docs/11-BACKEND-PHASE1.md §2.5, // FR-MD-07/FR-WH-01). -import { apiRequest } from "@/lib/api-client" +// +// UI-ONLY MODE: the real fetch-based implementation is commented out below and +// replaced with in-memory sample data (lib/api/mock-data.ts) so the GRN/Stock/ +// Warehouse screens can be reviewed without a running backend. Restore the +// commented block and delete the mock block once Backend/PROGRESS.md §1 +// (Master Data) exists. import { PagedResponse } from "@/types/common" import { Bin, Warehouse } from "@/types/master-data" +import { allocateBinId, allocateWarehouseId, mockBins, mockDelay, mockWarehouses } from "@/lib/api/mock-data" +// --- Real implementation (restore when the backend exists) -------------------- +// import { apiRequest } from "@/lib/api-client" +// +// export interface CreateWarehouseRequest { +// code: string +// name: string +// } +// +// export interface CreateBinRequest { +// code: string +// binType?: string | null +// } +// +// export const warehousesApi = { +// list() { +// return apiRequest>("/warehouses") +// }, +// +// create(request: CreateWarehouseRequest) { +// return apiRequest("/warehouses", { method: "POST", body: request }) +// }, +// +// listBins(warehouseId: number) { +// return apiRequest>(`/warehouses/${warehouseId}/bins`) +// }, +// +// createBin(warehouseId: number, request: CreateBinRequest) { +// return apiRequest(`/warehouses/${warehouseId}/bins`, { method: "POST", body: request }) +// }, +// } + +// --- Mock implementation (UI-only review) -------------------------------------- export interface CreateWarehouseRequest { code: string name: string @@ -15,24 +53,46 @@ export interface CreateBinRequest { } export const warehousesApi = { - list() { - return apiRequest>("/warehouses") + list(): Promise> { + return mockDelay({ + items: mockWarehouses, + pagination: { page: 1, pageSize: 20, totalItems: mockWarehouses.length, totalPages: 1 }, + }) }, - get(warehouseId: number) { - return apiRequest(`/warehouses/${warehouseId}`) + get(warehouseId: number): Promise { + const wh = mockWarehouses.find((w) => w.warehouseId === warehouseId) + if (!wh) return Promise.reject(new Error(`Mock warehouse ${warehouseId} not found`)) + return mockDelay(wh) }, - create(request: CreateWarehouseRequest) { - return apiRequest("/warehouses", { method: "POST", body: request }) + create(request: CreateWarehouseRequest): Promise { + const code = request.code.trim() + if (!code) return Promise.reject(new Error("Warehouse code is required.")) + if (mockWarehouses.some((w) => w.code.toLowerCase() === code.toLowerCase())) { + return Promise.reject(Object.assign(new Error(`Warehouse code "${code}" already exists.`), { code: "SKU_DUPLICATE" })) + } + const warehouse: Warehouse = { warehouseId: allocateWarehouseId(), code, name: request.name.trim() } + mockWarehouses.push(warehouse) + return mockDelay(warehouse) }, - async listBins(warehouseId: number): Promise> { - const items = await apiRequest(`/warehouses/${warehouseId}/bins`) - return { items, pagination: { page: 1, pageSize: items.length, totalItems: items.length, totalPages: 1 } } + listBins(warehouseId: number): Promise> { + const items = mockBins.filter((b) => b.warehouseId === warehouseId) + return mockDelay({ + items, + pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 }, + }) }, - createBin(warehouseId: number, request: CreateBinRequest) { - return apiRequest(`/warehouses/${warehouseId}/bins`, { method: "POST", body: request }) + createBin(warehouseId: number, request: CreateBinRequest): Promise { + const code = request.code.trim() + if (!code) return Promise.reject(new Error("Bin code is required.")) + if (mockBins.some((b) => b.warehouseId === warehouseId && b.code.toLowerCase() === code.toLowerCase())) { + return Promise.reject(Object.assign(new Error(`Bin code "${code}" already exists in this warehouse.`), { code: "SKU_DUPLICATE" })) + } + const bin: Bin = { binId: allocateBinId(), warehouseId, code, binType: request.binType?.trim() || null } + mockBins.push(bin) + return mockDelay(bin) }, } diff --git a/Frontend/erp-system/lib/api/wastage.ts b/Frontend/erp-system/lib/api/wastage.ts index 72e82ce..4fdbaa0 100644 --- a/Frontend/erp-system/lib/api/wastage.ts +++ b/Frontend/erp-system/lib/api/wastage.ts @@ -1,22 +1,20 @@ // "Wastage" is not a distinct document type in docs/10-BACKEND-PHASE1.md or the // SRS — stock write-offs (damage, theft/loss, expiry) are modeled as Stock -// Adjustments with a mandatory reason code (FR-STK-07). This module is a -// frontend-only lens: it composes stockAdjustmentsApi/reasonCodesApi/stockApi -// (real endpoints, though none of §5's backend exists yet — see -// Backend/PROGRESS.md §5), filtered to loss-type reason codes and flattened to -// per-line records for a focused "record wastage" flow and report. No new -// backend concept. +// Adjustments with a mandatory reason code (FR-STK-07), and the reason-code seed +// list (docs/8.3 / docs/11 §6) already includes Damage / Theft-Loss / Expiry +// Write-off. This module is a frontend-only lens: it reuses stockAdjustmentsApi +// and the shared mock Stock Core, filtered to loss-type reason codes and +// flattened to per-line records for a focused "record wastage" flow and report. +// No new backend concept, no new mock store. import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments" -import { stockApi } from "@/lib/api/stock" -import { reasonCodesApi } from "@/lib/api/reason-codes" +import { mockDelay, mockReasonCodes, mockStockAdjustments, mockStockLedger } from "@/lib/api/mock-data" import { StockAdjustment } from "@/types/stock" /** Reason-code strings treated as "wastage" (loss-type) causes, per docs/8.3. */ const WASTAGE_CODES = new Set(["DMG", "LOSS", "EXPWO"]) -export async function wastageReasonCodeIds(): Promise { - const { items } = await reasonCodesApi.list("Adjustment") - return items.filter((r) => WASTAGE_CODES.has(r.code)).map((r) => r.reasonCodeId) +export function wastageReasonCodeIds(): number[] { + return mockReasonCodes.filter((r) => WASTAGE_CODES.has(r.code)).map((r) => r.reasonCodeId) } export interface WastageRecord { @@ -48,23 +46,26 @@ export interface RecordWastageInput { } export const wastageApi = { - async list(params: ListWastageParams = {}): Promise { - const wastageIds = new Set(await wastageReasonCodeIds()) - const { items: summaries } = await stockAdjustmentsApi.list({ warehouseId: params.warehouseId, pageSize: 200 }) - const candidates = summaries.filter( - (a) => wastageIds.has(a.reasonCodeId) && (!params.reasonCodeId || a.reasonCodeId === params.reasonCodeId) - ) - - const adjustments = await Promise.all(candidates.map((a) => stockAdjustmentsApi.get(a.adjustmentId))) + list(params: ListWastageParams = {}): Promise { + const wastageIds = new Set(wastageReasonCodeIds()) const records: WastageRecord[] = [] - for (const adj of adjustments) { + for (const adj of mockStockAdjustments) { + if (!wastageIds.has(adj.reasonCodeId)) continue + if (params.warehouseId && adj.warehouseId !== params.warehouseId) continue + if (params.reasonCodeId && adj.reasonCodeId !== params.reasonCodeId) continue + for (const line of adj.lines) { if (line.qtyDelta >= 0) continue // wastage is always a decrease - const { items: ledger } = await stockApi.ledger({ itemId: line.itemId, warehouseId: adj.warehouseId, pageSize: 200 }) - const value = ledger - .filter((l) => l.sourceDocType === "Adjustment" && l.sourceDocId === adj.adjustmentId && l.direction === "Out") + const value = mockStockLedger + .filter( + (l) => + l.sourceDocType === "Adjustment" && + l.sourceDocId === adj.adjustmentId && + l.itemId === line.itemId && + l.direction === "Out" + ) .reduce((sum, l) => sum + l.value, 0) records.push({ @@ -83,7 +84,7 @@ export const wastageApi = { } records.sort((a, b) => b.adjustmentId - a.adjustmentId) - return records + return mockDelay(records) }, /** Records wastage as a single-line, negative-qtyDelta stock adjustment (FR-STK-07). */ diff --git a/Frontend/erp-system/types/procurement.ts b/Frontend/erp-system/types/procurement.ts index b2c8f79..fe3e20e 100644 --- a/Frontend/erp-system/types/procurement.ts +++ b/Frontend/erp-system/types/procurement.ts @@ -1,5 +1,6 @@ // Procurement DTOs (docs/11-BACKEND-PHASE1.md §3; FR-PROC-01..09). Mirrors the -// real Backend/ERPCore/Dtos/Procurement/*.cs shapes. +// planned Dtos/Procurement/*.cs — no Procurement backend exists yet, see +// Frontend/PROGRESS.md §3 for the same frontend-only posture as GRN/Stock. // --- 3.1 Requisitions -------------------------------------------------------------- @@ -9,7 +10,7 @@ export interface ReqLine { reqLineId: number itemId: number qty: number - requiredBy: string | null + requiredBy: string } export interface Requisition { @@ -21,19 +22,19 @@ export interface Requisition { lines: ReqLine[] } -/** Matches RequisitionSummaryDto — the backend does not include a line count on the summary row. */ export interface RequisitionSummary { requisitionId: number docNo: string status: RequisitionStatus requestedBy: number createdAt: string + lineCount: number } export interface CreateReqLineInput { itemId: number qty: number - requiredBy?: string | null + requiredBy: string } export interface CreateRequisitionRequest { @@ -42,7 +43,8 @@ export interface CreateRequisitionRequest { // --- 3.2 RFQs & Quotations ---------------------------------------------------------- -/** Phase 1 backend only ever sets "Open" (RfqDtos.cs); no server-side transition to Closed. */ +/** Phase 1 only documents "Open" on creation; "Closed" is a frontend-only convenience + * applied once a PO is created from the RFQ (see Frontend/PROGRESS.md §3 deviation note). */ export type RfqStatus = "Open" | "Closed" export interface RfqLine { @@ -51,14 +53,12 @@ export interface RfqLine { qty: number } -/** Note: the backend does not persist invited vendors (RfqService.MapRfq) — there is - * no `vendorIds` field on the stored RFQ. Vendors who have quoted are only derivable - * from `RfqComparison.vendorIds`. */ export interface Rfq { rfqId: number docNo: string - requisitionId: number + requisitionId: number | null status: RfqStatus + vendorIds: number[] createdAt: string lines: RfqLine[] } @@ -66,8 +66,9 @@ export interface Rfq { export interface RfqSummary { rfqId: number docNo: string - requisitionId: number + requisitionId: number | null status: RfqStatus + vendorIds: number[] createdAt: string } @@ -77,8 +78,7 @@ export interface CreateRfqLineInput { } export interface CreateRfqRequest { - /** Required server-side (CreateRfqRequest.RequisitionId has [Required]). */ - requisitionId: number + requisitionId?: number | null vendorIds: number[] lines: CreateRfqLineInput[] } @@ -102,25 +102,22 @@ export interface CreateQuotationRequest { lines: QuotationLine[] } -/** One vendor's price/lead-time for a given RFQ line (docs/11 §3.2, RfqComparisonCellDto). */ export interface RfqComparisonCell { vendorId: number - quotationId: number unitPrice: number leadDays: number } -export interface RfqComparisonRow { +export interface RfqComparisonLine { itemId: number qty: number - quotes: RfqComparisonCell[] + cells: RfqComparisonCell[] } -/** `vendorIds` here are the vendors who have quoted, not the vendors originally invited. */ export interface RfqComparison { rfqId: number vendorIds: number[] - rows: RfqComparisonRow[] + lines: RfqComparisonLine[] } // --- 3.3 Purchase Orders -------------------------------------------------------------