From 041579447386d77f8fd7c1b735e72f4f5ccfcd41 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Tue, 14 Jul 2026 16:01:47 +0530 Subject: [PATCH] Refactor API modules to remove mock implementations and integrate real endpoints - Updated stock-transfers.ts to replace mock data with API requests for stock transfers. - Refactored stock.ts to utilize API calls for stock inquiries instead of mock data. - Modified uoms.ts to implement real API requests for unit of measure operations. - Transitioned vendors.ts to use actual API endpoints for vendor management, removing mock data handling. - Updated warehouses.ts to replace mock implementations with real API calls for warehouse and bin management. - Refactored wastage.ts to utilize stock adjustments and reason codes APIs, removing mock data dependencies. - Adjusted procurement.ts to align with backend DTO shapes, ensuring consistency with planned backend structures. --- Frontend/PROGRESS.md | 47 +- .../procurement/purchase-orders/new/page.tsx | 2 +- .../procurement/requisitions/page.tsx | 2 - .../dashboard/procurement/rfqs/[id]/page.tsx | 50 +- .../dashboard/procurement/rfqs/new/page.tsx | 64 +- .../app/dashboard/procurement/rfqs/page.tsx | 19 +- .../receiving/grn/[id]/edit/page.tsx | 27 +- .../app/dashboard/stock/wastage/new/page.tsx | 11 +- .../app/dashboard/stock/wastage/page.tsx | 6 +- Frontend/erp-system/lib/api/categories.ts | 62 +- Frontend/erp-system/lib/api/grns.ts | 291 +------ Frontend/erp-system/lib/api/items.ts | 188 +--- Frontend/erp-system/lib/api/mock-data.ts | 814 ------------------ .../erp-system/lib/api/purchase-orders.ts | 181 +--- .../erp-system/lib/api/purchase-returns.ts | 112 +-- Frontend/erp-system/lib/api/reason-codes.ts | 24 +- Frontend/erp-system/lib/api/requisitions.ts | 96 +-- Frontend/erp-system/lib/api/rfqs.ts | 130 +-- .../erp-system/lib/api/stock-adjustments.ts | 147 +--- Frontend/erp-system/lib/api/stock-counts.ts | 188 +--- .../erp-system/lib/api/stock-transfers.ts | 217 +---- Frontend/erp-system/lib/api/stock.ts | 192 +---- Frontend/erp-system/lib/api/uoms.ts | 41 +- Frontend/erp-system/lib/api/vendors.ts | 150 +--- Frontend/erp-system/lib/api/warehouses.ts | 84 +- Frontend/erp-system/lib/api/wastage.ts | 49 +- Frontend/erp-system/types/procurement.ts | 33 +- 27 files changed, 416 insertions(+), 2811 deletions(-) delete mode 100644 Frontend/erp-system/lib/api/mock-data.ts diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 8e8075d..f4c519b 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -13,25 +13,37 @@ 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 -- [~] 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 +- [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.** ## 3. Procurement screens -- [~] 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 +- [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). ## 4. Receiving screens - [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail @@ -42,7 +54,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). > -> **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`). +> **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`). > > **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. @@ -58,9 +70,11 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Wastage (`.../stock/wastage` report, `/new` record) — **not a documented endpoint or SRS document type**: damage/theft-loss/expiry write-offs are modeled as Stock Adjustments with a loss-type reason code (FR-STK-07); `lib/api/wastage.ts` is a frontend-only lens over `stockAdjustmentsApi` + the shared mock ledger (filters to reason codes `DMG`/`LOSS`/`EXPWO`, flattens adjustment lines to per-item wastage records, sums matching outbound ledger entries for cost). No new backend concept — confirmed with the user before building (asked whether "Wastage" meant this vs. a distinct document type). - Sidebar: added "Stock" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/stock`; top-bar titles mapped per route (`components/Layouts/Header.tsx`) -> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4). +> **`[~]` 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**. > -> **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). +> **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). > > **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. @@ -128,3 +142,10 @@ 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 06d58ae..7c582b0 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.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId) + const cell = comparison.rows.find((row) => row.itemId === l.itemId)?.quotes.find((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 27d444c..a121599 100644 --- a/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx @@ -97,7 +97,6 @@ export default function RequisitionsListPage() { Doc No Status - Lines Requested by Created @@ -113,7 +112,6 @@ 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 a60d775..10a3ec3 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx @@ -67,13 +67,10 @@ export default function RfqDetailPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [rfqId]) - 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]) + // 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]) function itemFor(itemId: number) { return items.find((i) => i.itemId === itemId) @@ -82,6 +79,14 @@ 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) @@ -156,8 +161,9 @@ export default function RfqDetailPage() {

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

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

Vendor comparison

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

No quotations recorded yet.

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

Record a quotation

@@ -256,12 +262,12 @@ export default function RfqDetailPage() { value={quoteVendorId} onValueChange={selectQuoteVendor}> - + - {pendingVendors.map((vid) => ( - - {vendorFor(vid)?.code ?? `#${vid}`} — {vendorFor(vid)?.name} + {quotableVendors.map((v) => ( + + {v.code} — {v.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 ee05136..f9740e7 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 } from "@/types/procurement" +import { CreateRfqLineInput, RequisitionSummary } from "@/types/procurement" import { ItemListItem, Vendor } from "@/types/master-data" import { Button, buttonVariants } from "@/components/ui/button" @@ -44,13 +44,17 @@ function emptyLine(): DraftLine { function NewRfqContent() { const router = useRouter() const searchParams = useSearchParams() - const requisitionId = Number(searchParams.get("requisitionId")) || null + const requisitionIdParam = 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(!!requisitionId) + const [requisitionLoading, setRequisitionLoading] = useState(!!requisitionIdParam) + // 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>>({}) @@ -59,24 +63,46 @@ function NewRfqContent() { const [submitting, setSubmitting] = useState(false) useEffect(() => { - Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), vendorsApi.list({ pageSize: 200, status: "Active" })]) - .then(([it, ve]) => { + Promise.all([ + itemsApi.list({ pageSize: 200, status: "Active" }), + vendorsApi.list({ pageSize: 200, status: "Active" }), + requisitionsApi.list({ status: "Submitted", pageSize: 200 }), + ]) + .then(([it, ve, req]) => { setItems(it.items) setVendors(ve.items) + setRequisitions(req.items) }) .catch((err) => setLoadError(errorMessage(err))) }, []) useEffect(() => { - if (!requisitionId) return + if (!requisitionIdParam) return requisitionsApi - .get(requisitionId) + .get(requisitionIdParam) .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]) + }, [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)) + } function toggleVendor(vendorId: number) { setVendorIds((prev) => { @@ -99,6 +125,10 @@ 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 @@ -162,6 +192,24 @@ 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 6cc1f9c..d1ce401 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx @@ -5,10 +5,8 @@ import Link from "next/link" import { FileText, Plus } from "lucide-react" import { rfqsApi } from "@/lib/api/rfqs" -import { vendorsApi } from "@/lib/api/vendors" import { errorMessage } from "@/lib/error-map" import { RfqSummary } from "@/types/procurement" -import { Vendor } from "@/types/master-data" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" @@ -17,22 +15,15 @@ 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(() => { - Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })]) - .then(([r, v]) => { - setRfqs(r.items) - setVendors(v.items) - }) + rfqsApi + .list() + .then((r) => setRfqs(r.items)) .catch((err) => setError(errorMessage(err))) }, []) - function vendorNames(vendorIds: number[]) { - return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ") - } - return (
@@ -75,7 +66,6 @@ export default function RfqsListPage() { Doc No Requisition - Vendors invited Status Created @@ -88,8 +78,7 @@ export default function RfqsListPage() { {r.docNo} - {r.requisitionId ? `#${r.requisitionId}` : } - {vendorNames(r.vendorIds)} + #{r.requisitionId} 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 941f7fe..92fe305 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,6 +66,7 @@ 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) @@ -83,18 +84,19 @@ export default function EditGrnPage() { useEffect(() => { if (!Number.isFinite(grnId)) return Promise.all([ - grnsApi.get(grnId), + grnsApi.getWithETag(grnId), warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list(), ]) - .then(([g, wh, it, uo]) => { + .then(([{ data: g, etag: tag }, 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) @@ -188,14 +190,23 @@ 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, - }) + const updated = await grnsApi.update( + grn.grnId, + { + poId: grn.poId, + vendorId: grn.vendorId, + warehouseId: warehouseId as number, + lines: payloadLines, + }, + etag + ) 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 6a1fe16..29c0d11 100644 --- a/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx @@ -40,9 +40,14 @@ export default function NewWastagePage() { const [result, setResult] = useState(null) useEffect(() => { - Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")]) - .then(([wh, it, rc]) => { - const wastageIds = new Set(wastageReasonCodeIds()) + Promise.all([ + warehousesApi.list(), + itemsApi.list({ pageSize: 200, status: "Active" }), + reasonCodesApi.list("Adjustment"), + wastageReasonCodeIds(), + ]) + .then(([wh, it, rc, wastageIdList]) => { + const wastageIds = new Set(wastageIdList) 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 a5c5a1b..8c715bc 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()]) - .then(([rc, it, wh]) => { - const wastageIds = new Set(wastageReasonCodeIds()) + Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list(), wastageReasonCodeIds()]) + .then(([rc, it, wh, wastageIdList]) => { + const wastageIds = new Set(wastageIdList) 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 6602cc3..225de59 100644 --- a/Frontend/erp-system/lib/api/categories.ts +++ b/Frontend/erp-system/lib/api/categories.ts @@ -1,64 +1,16 @@ // One typed client method per Category endpoint (docs/11-BACKEND-PHASE1.md §2.3, FR-MD-04). -// -// 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 { apiRequest } from "@/lib/api-client" 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(): 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 }, - }) + list() { + return apiRequest>("/categories") }, - - tree(): Promise { - return mockDelay(buildTree(mockCategories)) + tree() { + return apiRequest("/categories?tree=true") }, - - 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) + create(request: CreateCategoryRequest) { + return apiRequest("/categories", { method: "POST", body: request }) }, } diff --git a/Frontend/erp-system/lib/api/grns.ts b/Frontend/erp-system/lib/api/grns.ts index 8021ced..32ddd82 100644 --- a/Frontend/erp-system/lib/api/grns.ts +++ b/Frontend/erp-system/lib/api/grns.ts @@ -1,282 +1,71 @@ // One typed client method per GRN endpoint (docs/11-BACKEND-PHASE1.md §4). // -// 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. +// 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. 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 = {}): Promise> { - const term = params.q?.trim().toLowerCase() + list(params: ListGrnsParams = {}) { + return apiRequest>(`/grns${buildQuery(params)}`) + }, - 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) + get(grnId: number) { + return apiRequest(`/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) + getWithETag(grnId: number) { + return apiRequestWithETag(`/grns/${grnId}`) + }, - return mockDelay({ - items, - pagination: { page, pageSize, totalItems, totalPages }, + 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, }) }, - 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) + releaseLine(grnId: number, grnLineId: number, action: ReleaseAction) { + return apiRequest(`/grns/${grnId}/lines/${grnLineId}/release`, { + method: "POST", + body: { action }, + }) }, } diff --git a/Frontend/erp-system/lib/api/items.ts b/Frontend/erp-system/lib/api/items.ts index b1816bf..e3414fd 100644 --- a/Frontend/erp-system/lib/api/items.ts +++ b/Frontend/erp-system/lib/api/items.ts @@ -1,11 +1,6 @@ // 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 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" +// `list` also backs the GRN/PO/Requisition/RFQ item pickers. +import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" import { EntityStatus, PagedResponse } from "@/types/common" import { CreateItemRequest, @@ -17,52 +12,7 @@ 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 @@ -72,132 +22,26 @@ 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 = {}): 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 }, - }) + list(params: ListItemsParams = {}) { + return apiRequest>(`/items${buildQuery(params)}`) }, - - 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)) }) + get(itemId: number) { + return apiRequestWithETag(`/items/${itemId}`) }, - - 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" }) + create(request: CreateItemRequest) { + return apiRequestWithETag("/items", { method: "POST", body: request }) }, - - 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) }) + update(itemId: number, request: UpdateItemRequest, ifMatch: string) { + return apiRequestWithETag(`/items/${itemId}`, { method: "PUT", body: request, ifMatch }) }, - - 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) + updateStatus(itemId: number, status: EntityStatus) { + return apiRequest(`/items/${itemId}/status`, { method: "PATCH", body: { status } }) }, - - 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 }) + updateReorder(itemId: number, request: UpdateItemReorderRequest) { + return apiRequest<{ settings: UpdateItemReorderRequest["settings"] }>(`/items/${itemId}/reorder`, { 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 }) + updateUomConversions(itemId: number, request: UpdateUomConversionsRequest) { + return apiRequest(`/items/${itemId}/uom-conversions`, { method: "PUT", body: request }) }, } diff --git a/Frontend/erp-system/lib/api/mock-data.ts b/Frontend/erp-system/lib/api/mock-data.ts deleted file mode 100644 index 46b46cd..0000000 --- a/Frontend/erp-system/lib/api/mock-data.ts +++ /dev/null @@ -1,814 +0,0 @@ -// 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 50ea6fa..6e24860 100644 --- a/Frontend/erp-system/lib/api/purchase-orders.ts +++ b/Frontend/erp-system/lib/api/purchase-orders.ts @@ -1,12 +1,6 @@ // 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. -// -// 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 { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" import { PagedResponse } from "@/types/common" import { CancelPurchaseOrderRequest, @@ -16,48 +10,7 @@ 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 @@ -71,131 +24,23 @@ 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 = {}): 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 }, - }) + list(params: ListPurchaseOrdersParams = {}) { + return apiRequest>(`/purchase-orders${buildQuery(params)}`) }, - - 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) + get(poId: number) { + return apiRequest(`/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)) }) + getWithETag(poId: number) { + return apiRequestWithETag(`/purchase-orders/${poId}`) }, - - 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" }) + create(request: CreatePurchaseOrderRequest) { + return apiRequestWithETag("/purchase-orders", { method: "POST", body: request }) }, - - 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) }) + update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string) { + return apiRequestWithETag(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch }) }, - - 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) + cancel(poId: number, request: CancelPurchaseOrderRequest) { + return apiRequest(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request }) }, } diff --git a/Frontend/erp-system/lib/api/purchase-returns.ts b/Frontend/erp-system/lib/api/purchase-returns.ts index 248262c..61c1261 100644 --- a/Frontend/erp-system/lib/api/purchase-returns.ts +++ b/Frontend/erp-system/lib/api/purchase-returns.ts @@ -1,113 +1,19 @@ // One typed client method per Purchase Return endpoint (docs/11-BACKEND-PHASE1.md §3.4, FR-PROC-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 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. +// 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" 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(): 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 }, - }) + list() { + return apiRequest>("/purchase-returns") }, - - 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) + get(returnId: number) { + return apiRequest(`/purchase-returns/${returnId}`) }, - - 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) + create(request: CreatePurchaseReturnRequest) { + return apiRequest("/purchase-returns", { method: "POST", body: request }) }, } diff --git a/Frontend/erp-system/lib/api/reason-codes.ts b/Frontend/erp-system/lib/api/reason-codes.ts index 0274f38..42e7390 100644 --- a/Frontend/erp-system/lib/api/reason-codes.ts +++ b/Frontend/erp-system/lib/api/reason-codes.ts @@ -1,27 +1,13 @@ // One typed client method for reference data (docs/11-BACKEND-PHASE1.md §6). // -// UI-ONLY MODE: the real fetch-based implementation is commented out below and -// replaced with in-memory sample data (lib/api/mock-data.ts). +// Note: no ReasonCodesController exists yet — this will 404 until Stock +// Transactions (Backend/PROGRESS.md §5) is built. +import { apiRequest, buildQuery } from "@/lib/api-client" 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): Promise> { - const items = mockReasonCodes.filter((r) => !context || r.context === context) - return mockDelay({ - items, - pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 }, - }) + list(context?: ReasonCodeContext) { + return apiRequest>(`/reason-codes${buildQuery({ context })}`) }, } diff --git a/Frontend/erp-system/lib/api/requisitions.ts b/Frontend/erp-system/lib/api/requisitions.ts index 9442ff3..c65f465 100644 --- a/Frontend/erp-system/lib/api/requisitions.ts +++ b/Frontend/erp-system/lib/api/requisitions.ts @@ -1,103 +1,25 @@ // One typed client method per Requisition endpoint (docs/11-BACKEND-PHASE1.md §3.1, FR-PROC-01). -// -// 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 { apiRequest, buildQuery } from "@/lib/api-client" 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 = {}): 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 }, - }) + list(params: ListRequisitionsParams = {}) { + return apiRequest>(`/requisitions${buildQuery(params)}`) }, - - 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) + get(requisitionId: number) { + return apiRequest(`/requisitions/${requisitionId}`) }, - - 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) + create(request: CreateRequisitionRequest) { + return apiRequest("/requisitions", { method: "POST", body: request }) }, - - 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) + submit(requisitionId: number) { + return apiRequest(`/requisitions/${requisitionId}/submit`, { method: "POST", body: {} }) }, } diff --git a/Frontend/erp-system/lib/api/rfqs.ts b/Frontend/erp-system/lib/api/rfqs.ts index b7380f7..397f2d4 100644 --- a/Frontend/erp-system/lib/api/rfqs.ts +++ b/Frontend/erp-system/lib/api/rfqs.ts @@ -1,126 +1,26 @@ // One typed client method per RFQ/Quotation endpoint (docs/11-BACKEND-PHASE1.md §3.2, FR-PROC-02). // -// 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. +// 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" import { PagedResponse } from "@/types/common" -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, - } -} +import { CreateQuotationRequest, CreateRfqRequest, Quotation, Rfq, RfqComparison, RfqSummary } from "@/types/procurement" export const rfqsApi = { - 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 }, - }) + list() { + return apiRequest>("/rfqs") }, - - 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) + get(rfqId: number) { + return apiRequest(`/rfqs/${rfqId}`) }, - - 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) + create(request: CreateRfqRequest) { + return apiRequest("/rfqs", { 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) + addQuotation(rfqId: number, request: CreateQuotationRequest) { + return apiRequest(`/rfqs/${rfqId}/quotations`, { method: "POST", body: request }) }, - - 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 }) + comparison(rfqId: number) { + return apiRequest(`/rfqs/${rfqId}/comparison`) }, } diff --git a/Frontend/erp-system/lib/api/stock-adjustments.ts b/Frontend/erp-system/lib/api/stock-adjustments.ts index b48f118..525314f 100644 --- a/Frontend/erp-system/lib/api/stock-adjustments.ts +++ b/Frontend/erp-system/lib/api/stock-adjustments.ts @@ -1,152 +1,27 @@ // One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5). // -// 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 +// 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 // assumed-extension deviation as GRN (see Frontend/PROGRESS.md §5). +import { apiRequest, buildQuery } from "@/lib/api-client" import { PagedResponse } from "@/types/common" -import { AdjustmentStatus, CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock" -import { - allocateAdjLineId, - allocateAdjustmentId, - consumeFifo, - lastKnownCost, - mockDelay, - mockStockAdjustments, - postLedgerEntry, - receiveLayer, -} from "@/lib/api/mock-data" +import { CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock" -// --- 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 = {}): 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 }, - }) + list(params: ListAdjustmentsParams = {}) { + return apiRequest>(`/stock-adjustments${buildQuery(params)}`) }, - - 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) + get(adjustmentId: number) { + return apiRequest(`/stock-adjustments/${adjustmentId}`) }, - - 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) + create(request: CreateAdjustmentRequest) { + return apiRequest("/stock-adjustments", { method: "POST", body: request }) }, } diff --git a/Frontend/erp-system/lib/api/stock-counts.ts b/Frontend/erp-system/lib/api/stock-counts.ts index a2427f6..7488ca0 100644 --- a/Frontend/erp-system/lib/api/stock-counts.ts +++ b/Frontend/erp-system/lib/api/stock-counts.ts @@ -1,14 +1,12 @@ // One typed client method per count endpoint (docs/11-BACKEND-PHASE1.md §5.6). // -// 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). +// 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" import { PagedResponse } from "@/types/common" import { - CountStatus, CreateCountRequest, EnterCountsRequest, EnterCountsResponse, @@ -16,183 +14,27 @@ 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 = {}): 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 }, - }) + list(params: ListCountsParams = {}) { + return apiRequest>(`/stock-counts${buildQuery(params)}`) }, - - 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) + get(countId: number) { + return apiRequest(`/stock-counts/${countId}`) }, - - 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) + create(request: CreateCountRequest) { + return apiRequest("/stock-counts", { method: "POST", 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 }) + enterCounts(countId: number, request: EnterCountsRequest) { + return apiRequest(`/stock-counts/${countId}/counts`, { method: "PUT", body: request }) }, - - 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 }) + post(countId: number) { + return apiRequest(`/stock-counts/${countId}/post`, { method: "POST", body: {} }) }, } diff --git a/Frontend/erp-system/lib/api/stock-transfers.ts b/Frontend/erp-system/lib/api/stock-transfers.ts index b6c1552..90f5f8b 100644 --- a/Frontend/erp-system/lib/api/stock-transfers.ts +++ b/Frontend/erp-system/lib/api/stock-transfers.ts @@ -1,11 +1,10 @@ // One typed client method per transfer endpoint (docs/11-BACKEND-PHASE1.md §5.4). // -// 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). +// 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" import { PagedResponse } from "@/types/common" import { CreateTransferRequest, @@ -16,50 +15,7 @@ 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 @@ -68,154 +24,23 @@ 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 = {}): 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 }, + 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 }, }) }, - - 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 58d31be..5900b5a 100644 --- a/Frontend/erp-system/lib/api/stock.ts +++ b/Frontend/erp-system/lib/api/stock.ts @@ -1,62 +1,15 @@ // One typed client method per stock-enquiry endpoint (docs/11-BACKEND-PHASE1.md §5.1-5.3, §5.7). // -// 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. +// 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" import { PagedResponse } from "@/types/common" import { LedgerEntry, OnHand, ReorderAlert, ReorderRequisitionResponse, Valuation } from "@/types/stock" -import { - allocateReqLineId, - allocateRequisitionId, - computeOnHand, - knownStockKeys, - mockDelay, - mockItemReorders, - mockRequisitions, - mockStockLayers, - mockStockLedger, -} from "@/lib/api/mock-data" +import { itemsApi } from "@/lib/api/items" +import { warehousesApi } from "@/lib/api/warehouses" -// --- 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 @@ -67,124 +20,37 @@ export interface LedgerQuery { } export const stockApi = { - onHand(itemId: number, warehouseId: number): Promise { - const computed = computeOnHand(itemId, warehouseId) - return mockDelay({ - itemId, - warehouseId, - ...computed, - asOf: new Date().toISOString(), - }) + onHand(itemId: number, warehouseId: number) { + return apiRequest(`/stock/on-hand${buildQuery({ itemId, warehouseId })}`) }, - /** 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) + /** 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) }, - 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), - }, - }) + ledger(params: LedgerQuery) { + return apiRequest>(`/stock/ledger${buildQuery(params)}`) }, - 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", - }) + valuation(itemId: number, warehouseId: number) { + return apiRequest(`/stock/valuation${buildQuery({ itemId, 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 }, - }) + reorderAlerts(warehouseId?: number) { + return apiRequest>(`/stock/reorder-alerts${buildQuery({ warehouseId })}`) }, - 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", - }) + createReorderRequisition(itemId: number, warehouseId: number) { + return apiRequest( + `/stock/reorder-alerts/${itemId}/requisition${buildQuery({ warehouseId })}`, + { method: "POST", body: {} } + ) }, } diff --git a/Frontend/erp-system/lib/api/uoms.ts b/Frontend/erp-system/lib/api/uoms.ts index c6c33b3..4e85657 100644 --- a/Frontend/erp-system/lib/api/uoms.ts +++ b/Frontend/erp-system/lib/api/uoms.ts @@ -1,43 +1,14 @@ // 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 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. +// `list` also backs the GRN/PO line UOM picker. +import { apiRequest } from "@/lib/api-client" 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(): Promise> { - return mockDelay({ - items: mockUoms, - pagination: { page: 1, pageSize: 20, totalItems: mockUoms.length, totalPages: 1 }, - }) + list() { + return apiRequest>("/uoms") }, - - 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) + create(request: CreateUomRequest) { + return apiRequest("/uoms", { method: "POST", body: request }) }, } diff --git a/Frontend/erp-system/lib/api/vendors.ts b/Frontend/erp-system/lib/api/vendors.ts index 360c20e..800ec18 100644 --- a/Frontend/erp-system/lib/api/vendors.ts +++ b/Frontend/erp-system/lib/api/vendors.ts @@ -1,63 +1,10 @@ // One typed client method per vendor (supplier) endpoint (docs/11-BACKEND-PHASE1.md -// §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" +// §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" 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 @@ -75,91 +22,20 @@ 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 = {}): 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 }, - }) + list(params: ListVendorsParams = {}) { + return apiRequest>(`/vendors${buildQuery(params)}`) }, - - 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)) }) + get(vendorId: number) { + return apiRequestWithETag(`/vendors/${vendorId}`) }, - - 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" }) + create(request: CreateVendorRequest) { + return apiRequestWithETag("/vendors", { method: "POST", body: request }) }, - - 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) }) + update(vendorId: number, request: UpdateVendorRequest, ifMatch: string) { + return apiRequestWithETag(`/vendors/${vendorId}`, { method: "PUT", body: request, ifMatch }) }, - - 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) + updateStatus(vendorId: number, status: EntityStatus) { + return apiRequest(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } }) }, } diff --git a/Frontend/erp-system/lib/api/warehouses.ts b/Frontend/erp-system/lib/api/warehouses.ts index 8f130ec..b3954b2 100644 --- a/Frontend/erp-system/lib/api/warehouses.ts +++ b/Frontend/erp-system/lib/api/warehouses.ts @@ -1,47 +1,9 @@ // One typed client method per warehouse/bin endpoint (docs/11-BACKEND-PHASE1.md §2.5, // FR-MD-07/FR-WH-01). -// -// 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 { apiRequest } from "@/lib/api-client" 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 @@ -53,46 +15,24 @@ export interface CreateBinRequest { } export const warehousesApi = { - list(): Promise> { - return mockDelay({ - items: mockWarehouses, - pagination: { page: 1, pageSize: 20, totalItems: mockWarehouses.length, totalPages: 1 }, - }) + list() { + return apiRequest>("/warehouses") }, - 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) + get(warehouseId: number) { + return apiRequest(`/warehouses/${warehouseId}`) }, - 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) + create(request: CreateWarehouseRequest) { + return apiRequest("/warehouses", { method: "POST", body: request }) }, - 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 }, - }) + 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 } } }, - 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) + createBin(warehouseId: number, request: CreateBinRequest) { + return apiRequest(`/warehouses/${warehouseId}/bins`, { method: "POST", body: request }) }, } diff --git a/Frontend/erp-system/lib/api/wastage.ts b/Frontend/erp-system/lib/api/wastage.ts index 4fdbaa0..72e82ce 100644 --- a/Frontend/erp-system/lib/api/wastage.ts +++ b/Frontend/erp-system/lib/api/wastage.ts @@ -1,20 +1,22 @@ // "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), 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. +// 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. import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments" -import { mockDelay, mockReasonCodes, mockStockAdjustments, mockStockLedger } from "@/lib/api/mock-data" +import { stockApi } from "@/lib/api/stock" +import { reasonCodesApi } from "@/lib/api/reason-codes" 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 function wastageReasonCodeIds(): number[] { - return mockReasonCodes.filter((r) => WASTAGE_CODES.has(r.code)).map((r) => r.reasonCodeId) +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 interface WastageRecord { @@ -46,26 +48,23 @@ export interface RecordWastageInput { } export const wastageApi = { - list(params: ListWastageParams = {}): Promise { - const wastageIds = new Set(wastageReasonCodeIds()) + 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))) const records: WastageRecord[] = [] - 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 adj of adjustments) { for (const line of adj.lines) { if (line.qtyDelta >= 0) continue // wastage is always a decrease - const value = mockStockLedger - .filter( - (l) => - l.sourceDocType === "Adjustment" && - l.sourceDocId === adj.adjustmentId && - l.itemId === line.itemId && - l.direction === "Out" - ) + 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") .reduce((sum, l) => sum + l.value, 0) records.push({ @@ -84,7 +83,7 @@ export const wastageApi = { } records.sort((a, b) => b.adjustmentId - a.adjustmentId) - return mockDelay(records) + return 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 fe3e20e..b2c8f79 100644 --- a/Frontend/erp-system/types/procurement.ts +++ b/Frontend/erp-system/types/procurement.ts @@ -1,6 +1,5 @@ // Procurement DTOs (docs/11-BACKEND-PHASE1.md §3; FR-PROC-01..09). Mirrors the -// planned Dtos/Procurement/*.cs — no Procurement backend exists yet, see -// Frontend/PROGRESS.md §3 for the same frontend-only posture as GRN/Stock. +// real Backend/ERPCore/Dtos/Procurement/*.cs shapes. // --- 3.1 Requisitions -------------------------------------------------------------- @@ -10,7 +9,7 @@ export interface ReqLine { reqLineId: number itemId: number qty: number - requiredBy: string + requiredBy: string | null } export interface Requisition { @@ -22,19 +21,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 + requiredBy?: string | null } export interface CreateRequisitionRequest { @@ -43,8 +42,7 @@ export interface CreateRequisitionRequest { // --- 3.2 RFQs & Quotations ---------------------------------------------------------- -/** 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). */ +/** Phase 1 backend only ever sets "Open" (RfqDtos.cs); no server-side transition to Closed. */ export type RfqStatus = "Open" | "Closed" export interface RfqLine { @@ -53,12 +51,14 @@ 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 | null + requisitionId: number status: RfqStatus - vendorIds: number[] createdAt: string lines: RfqLine[] } @@ -66,9 +66,8 @@ export interface Rfq { export interface RfqSummary { rfqId: number docNo: string - requisitionId: number | null + requisitionId: number status: RfqStatus - vendorIds: number[] createdAt: string } @@ -78,7 +77,8 @@ export interface CreateRfqLineInput { } export interface CreateRfqRequest { - requisitionId?: number | null + /** Required server-side (CreateRfqRequest.RequisitionId has [Required]). */ + requisitionId: number vendorIds: number[] lines: CreateRfqLineInput[] } @@ -102,22 +102,25 @@ 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 RfqComparisonLine { +export interface RfqComparisonRow { itemId: number qty: number - cells: RfqComparisonCell[] + quotes: RfqComparisonCell[] } +/** `vendorIds` here are the vendors who have quoted, not the vendors originally invited. */ export interface RfqComparison { rfqId: number vendorIds: number[] - lines: RfqComparisonLine[] + rows: RfqComparisonRow[] } // --- 3.3 Purchase Orders -------------------------------------------------------------