Dev #7
+102
-30
@@ -5,11 +5,13 @@ Spec: `docs/20-FRONTEND.md` (flows + rules) · `docs/11-BACKEND-PHASE1.md` (API
|
||||
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
|
||||
|
||||
## 0. Foundation
|
||||
- [ ] `NEXT_PUBLIC_API_BASE_URL` wired (`.env.local`)
|
||||
- [ ] Typed API client / fetch wrapper (one method per endpoint) + bearer token handling
|
||||
- [ ] Shared TS types mirroring API DTOs (`types/`)
|
||||
- [ ] Dependency-free client validation helpers (format/required/range)
|
||||
- [ ] `ProblemDetails` normalizer + `code → message` map (`lib/`)
|
||||
- [x] `NEXT_PUBLIC_API_BASE_URL` wired (`.env.local` / `.env.local.example`, alongside the existing `NEXT_PUBLIC_AUTH_API_BASE_URL`) — points at `Backend/ERPCore`'s https profile (`7112`)
|
||||
- [x] Typed API client / fetch wrapper (`lib/api-client.ts`: `apiRequest`/`apiRequestWithETag`/`buildQuery`) + bearer token handling (`lib/auth-token.ts`, degrades gracefully — no login endpoint wired yet, see §1)
|
||||
- [x] Shared TS types mirroring API DTOs (`types/common.ts`, `types/master-data.ts`, `types/procurement.ts`, `types/grn.ts`) — built only for the subset GRN needs (Item/Warehouse/Bin/Vendor/Uom/PurchaseOrder/Grn)
|
||||
- [~] Client validation helpers (`lib/validations/grn.ts`) — **deviation**: uses `zod` (already a project dependency, used by `lib/validations.ts`/login), not hand-rolled, to stay consistent with the codebase's existing pattern rather than introduce a second validation approach. UX-only; server remains authoritative (docs/20-FRONTEND.md §3)
|
||||
- [x] `ProblemDetails` normalizer (`ApiError` in `lib/api-client.ts`) + `code → message` map (`lib/error-map.ts`)
|
||||
|
||||
> **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.
|
||||
|
||||
## 1. Auth
|
||||
- [~] Login screen — UI built (`app/login`); not yet wired to `POST /auth/login` / token storage
|
||||
@@ -18,41 +20,111 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
- [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API
|
||||
|
||||
## 2. Master Data screens
|
||||
- [ ] Items (list + create/edit, ETag handling)
|
||||
- [ ] UOM + conversions
|
||||
- [ ] Categories (tree)
|
||||
- [ ] Vendors
|
||||
- [ ] Warehouses + Bins
|
||||
- [ ] Item reorder settings
|
||||
- [~] 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<T>` was built earlier but unused until now).
|
||||
- [~] Warehouses + Bins (`app/dashboard/warehouse` list + create-warehouse dialog, `[id]` bin list + create-bin dialog) — FR-WH-01/FR-MD-07. Frontend-only (see note below); no ETag handling since there's no edit/delete yet, only create.
|
||||
- [~] Item reorder settings — edited inline on the Item detail page (per-warehouse point/qty rows) via `PUT /items/{itemId}/reorder` — FR-MD-05
|
||||
|
||||
## 3. Procurement screens
|
||||
- [ ] Requisition (create + submit)
|
||||
- [ ] RFQ + quotations + comparison view
|
||||
- [ ] Purchase Order (create, edit-while-open, cancel)
|
||||
- [ ] Purchase Return
|
||||
- [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01
|
||||
- [~] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02
|
||||
- [~] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07
|
||||
- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page
|
||||
|
||||
## 4. Receiving screens
|
||||
- [ ] GRN create (qty, bin, batch/serial capture)
|
||||
- [ ] GRN confirm (render created layers / ledger refs)
|
||||
- [ ] Inspection hold release / reject
|
||||
- [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail
|
||||
- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`
|
||||
- [~] GRN confirm (`app/dashboard/receiving/grn/[id]/page.tsx`) — renders returned `createdLayers`/`ledgerRefs`/`poStatus` as a confirmation panel (20-FRONTEND §4); sends a stable `Idempotency-Key` per detail-page session
|
||||
- [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed`
|
||||
- Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn`
|
||||
|
||||
> **`[~]` 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`).
|
||||
>
|
||||
> **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.
|
||||
|
||||
## 5. Stock screens
|
||||
- [ ] Stock enquiry (onHand/available/onHold/inTransit)
|
||||
- [ ] Ledger view · Valuation view
|
||||
- [ ] Transfer (create → dispatch → receive)
|
||||
- [ ] Adjustment (reason code required)
|
||||
- [ ] Count (cycle/full → enter → post)
|
||||
- [ ] Reorder alerts (+ create requisition)
|
||||
- [~] Stock hub (`app/dashboard/stock/page.tsx`) — card grid linking to all 7 areas below
|
||||
- [~] Stock enquiry (`.../stock/enquiry`) — onHand/available/onHold/inTransit/reserved, search by SKU/name + warehouse filter, links to Valuation per row
|
||||
- [~] Ledger view (`.../stock/ledger`) — filterable by item/warehouse/date range, paginated
|
||||
- [~] Valuation view (`.../stock/valuation`) — item+warehouse picker (also reachable via `?itemId=&warehouseId=` from Enquiry), FIFO layer breakdown + totals
|
||||
- [~] Transfer (`.../stock/transfers` list, `/new` create, `/[id]` dispatch → receive) — cost-preserving per line (FR-STK-06)
|
||||
- [~] Adjustment (`.../stock/adjustments` list, `/new` create) — reason code mandatory, auto-posts on submit (no separate confirm step, matching FR-STK-07)
|
||||
- [~] Count (`.../stock/counts` list, `/new` create, `/[id]` enter counts → post) — posting creates a linked variance adjustment
|
||||
- [~] Reorder alerts (`.../stock/reorder-alerts`) — items ≤ reorder point, one-click "Create requisition"
|
||||
- [~] 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).
|
||||
>
|
||||
> **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience that iterates known item/warehouse pairs and calls the (documented) on-hand computation per pair; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions).
|
||||
>
|
||||
> **Simplifications (mock-data limitations, not spec decisions):** `StockLayer` has no per-bin field (matches the real ER model, docs/10 Part C.5 — only `StockLedger` carries `bin_id`), so Count lines don't attempt bin-level snapshotting. Transfers don't expose batch selection in the create UI (FIFO picks layers regardless of batch). Adjustment increases always cost at "last known cost" for that item/warehouse (FR-STK-07); there's no landed-cost/manual-cost override. In-transit quantity is shown for visibility at the destination warehouse only and is not subtracted a second time from the source's `available` (dispatch already reduced the source layer's `qtyRemaining`) — the docs' `available = onHand − onHold − reserved − inTransit(out)` formula is ambiguous on this point given dispatch semantics; this was a judgment call, noted here for whoever builds the real backend to confirm or correct.
|
||||
|
||||
## 6. Validation posture (20-FRONTEND §3)
|
||||
- [ ] Client format/required/range checks on all forms
|
||||
- [ ] Surface server `ProblemDetails` incl. domain codes; map to fields/messages
|
||||
- [ ] `412` conflict → prompt refetch before retry
|
||||
- [ ] No client-side gating on stock/availability/status (server-authoritative)
|
||||
- [~] Client format/required/range checks on all forms — done for GRN create (`lib/validations/grn.ts`); not yet done for other forms
|
||||
- [x] Surface server `ProblemDetails` incl. domain codes; map to fields/messages — `lib/error-map.ts` (`errorMessage`/`fieldErrors`), used by GRN create/detail
|
||||
- [ ] `412` conflict → prompt refetch before retry — `apiRequestWithETag` surfaces the ETag but no screen edits a GRN yet (GRN has no PUT), so untested in practice
|
||||
- [x] No client-side gating on stock/availability/status (server-authoritative) — GRN create always submits to the server and surfaces `OVER_RECEIPT_TOLERANCE`/etc. via `error-map.ts` rather than pre-blocking
|
||||
|
||||
## 7. UX states
|
||||
- [ ] Loading / empty / error states on every list
|
||||
- [ ] Transactional actions show server-returned side effects as confirmation
|
||||
- [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt
|
||||
- [x] Transactional actions show server-returned side effects as confirmation — GRN confirm renders `createdLayers`/`ledgerRefs`/`poStatus` from the response
|
||||
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
### 2026-07-13 — GRN screens + frontend foundation (frontend-only; no backend changes)
|
||||
- Foundation: `lib/api-client.ts`, `lib/error-map.ts`, `lib/auth-token.ts`, `types/{common,master-data,procurement,grn}.ts`, `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` — scoped to what the GRN flow needs, not the full API surface.
|
||||
- Added the shadcn `select` primitive (`npx shadcn add select`) — wasn't in `components/ui/` yet; needed for PO/vendor/warehouse/bin/item/hold-status pickers.
|
||||
- Screens: GRN list, GRN create (PO-based + direct receipt, batch/serial capture by `trackingMode`), GRN detail (confirm + release/reject). Sidebar nav entry added.
|
||||
- **This was explicitly frontend-only** (user interrupted an initial backend+frontend plan and asked for frontend only). No GRN backend exists — `Backend/PROGRESS.md` §3/§4 are unchanged. The screens are built against the contract in `docs/11-BACKEND-PHASE1.md` §4 plus two assumed-but-undocumented endpoints (`GET /grns`, `GET /grns/{id}`, see §4 note above); none of it is runnable end-to-end yet.
|
||||
- Verified: `tsc --noEmit` clean for all new/edited files (one pre-existing, unrelated error remains in `app/login/page.tsx`); `eslint` clean aside from two `react-hooks/set-state-in-effect` warnings matching an already-existing pattern in `hooks/use-mobile.ts`; all three routes confirmed rendering (200, correct content, no error boundary) via SSR against the dev server.
|
||||
|
||||
### 2026-07-13 — Stock Management screens (frontend-only; no backend changes)
|
||||
- `types/stock.ts`: full DTO set for on-hand, ledger, valuation, transfers, adjustments, counts, reorder alerts (docs/11 §5).
|
||||
- `lib/api/mock-data.ts` gained a real in-memory Stock Core: `mockStockLayers`/`mockStockLedger` + `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`/`lastKnownCost` helpers, plus `mockItemReorders` and `mockReasonCodes` seed data. `lib/api/grns.ts`'s `confirm()` was refactored to post through these helpers instead of fabricating a response, and now also accrues PO `qtyReceived`/recomputes PO status — so GRN and Stock screens are genuinely connected this session.
|
||||
- New API modules: `lib/api/stock.ts` (on-hand/ledger/valuation/reorder-alerts), `stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`, `reason-codes.ts` — same commented-real-block + active-mock-block pattern as the GRN modules.
|
||||
- Screens: hub, Enquiry, Ledger, Valuation, Transfers (list/new/detail with dispatch+receive), Adjustments (list/new, auto-post), Counts (list/new/detail with enter-counts+post), Reorder Alerts. New shared badge set `components/stock/status-badges.tsx` (same fixed-size red/green/yellow convention as `components/receiving/status-badges.tsx`). Sidebar + header-title mappings added.
|
||||
- Same posture as the GRN pass: `[~]` not `[x]`, frontend built ahead of a nonexistent Stock Core backend, deviations/simplifications recorded in the §5 note above. `tsc --noEmit` and `eslint` clean (only the same pre-existing/established issues as the GRN pass).
|
||||
|
||||
### 2026-07-13 — Wastage screens (frontend-only; no backend changes)
|
||||
- `lib/api/wastage.ts`: no new backend concept — confirmed with the user that "Wastage" should be a focused UI lens over the just-built Stock Adjustments (damage/theft-loss/expiry write-off reason codes), not a distinct document type. Filters `mockStockAdjustments` to loss-type reason codes, flattens to per-item `WastageRecord`s, and computes cost per record from matching outbound `mockStockLedger` entries.
|
||||
- Screens: `.../stock/wastage` (report — totals cards, warehouse/reason filters, per-item table) and `.../stock/wastage/new` (single-line record form, reason dropdown restricted to wastage-type codes, posts via the existing `stockAdjustmentsApi.create`). Added a "Wastage" card to the Stock hub and header-title mappings.
|
||||
- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from one more instance of the already-established `set-state-in-effect` pattern.
|
||||
|
||||
### 2026-07-13 — Warehouse Management screens (frontend-only; no backend changes)
|
||||
- Scope, per user selection out of the four FR-WH sub-areas offered (Warehouses & Bins / Stock Locator / Batch & Serial / Putaway): **Warehouses & Bins master data only** (FR-WH-01, FR-MD-07). The other three (bin-level Stock Locator, Batch/Serial tracking, Putaway) were **not** built — flagged here so a future pass knows they're still open, not forgotten.
|
||||
- `lib/api/warehouses.ts` gained `create`/`get`/`createBin` (previously list/listBins only, read-only) — duplicate-code validation mirrors the real `SKU_DUPLICATE`-style 400 pattern used elsewhere. `mock-data.ts` gained `allocateWarehouseId`/`allocateBinId`.
|
||||
- Screens: `app/dashboard/warehouse` (list + "New Warehouse" `Dialog` form) and `app/dashboard/warehouse/[id]` (bin list + "New Bin" `Dialog` form) — used `components/ui/dialog.tsx` instead of a full page for these two-field creates, since a whole page felt heavy for that. Sidebar "Warehouses" entry + header-title mapping added.
|
||||
- Housekeeping: removed two stray duplicate route folders (`app/dashboard/receiving/grn/create new GRN/`, `.../view GRN/`) that were byte-for-byte copies of the real `new/` and `[id]/` GRN pages under garbled folder names — almost certainly an IDE artifact from an earlier malformed file-open path, not intentional work (confirmed untracked in git before removing). Also noted, but deliberately left alone: `app/warehouse/*`, `components/warehouse/`, `lib/warehouse/` are pre-existing **empty** scaffold folders (no files at all) from initial project setup — Warehouse Management was built under `app/dashboard/warehouse/*` instead so it gets the dashboard chrome (sidebar/header) for free, consistent with every other screen this session.
|
||||
- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from one `exhaustive-deps` warning (not an error) on `[id]/page.tsx`'s `loadBins` helper.
|
||||
|
||||
### 2026-07-13 — Vendor (Supplier) management screens (frontend-only; no backend changes)
|
||||
- Confirmed with the user first: "supplier/shop" has no distinct "Shop" entity in the SRS/docs — scoped this to the documented Vendor master (FR-MD-06, docs/11 §2.4), "supplier" being the standard ERP synonym.
|
||||
- `lib/api/vendors.ts` extended from list-only to `get`/`create`/`update`/`updateStatus`. This is the **first screen to exercise the ETag/If-Match/412 pattern**: `mock-data.ts` gained a per-vendor concurrency-token map (`getVendorVersion`/`bumpVendorVersion`/`initVendorVersion`, standing in for the real backend's `xmin` — the public `Vendor` type has no version field of its own since it travels as an HTTP `ETag` header, not a body field) so `update()` genuinely rejects a stale `If-Match` with `CONCURRENCY_CONFLICT`, matching `docs/11 §1.6` and `20-FRONTEND.md §3.2`.
|
||||
- Screens: `app/dashboard/vendors` (list, search + status filter, "New Vendor" dialog) and `app/dashboard/vendors/[id]` (full edit form using the real `apiRequestWithETag`-shaped `ApiResult<T>`, a dedicated conflict banner with "Reload before retrying" per the 412 UX rule rather than a generic toast, and an Activate/Deactivate toggle via `PATCH status`, FR-MD-08 — deactivate, not hard-delete). Sidebar "Vendors" entry + header-title mapping added.
|
||||
- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from the same established `set-state-in-effect` pattern used throughout this session.
|
||||
- **Follow-up (same day):** `vendorsApi.list()` didn't actually paginate (always returned page 1 / all matches, same latent gap the GRN list had before its own pagination pass) — fixed to slice by `page`/`pageSize` properly, added the same Previous/Next pagination controls used on the GRN and Stock list screens, and seeded 8 more sample vendors so there's something real to page through.
|
||||
|
||||
### 2026-07-13 — Procurement screens: Requisition → RFQ → PO → Purchase Return (frontend-only; no backend changes)
|
||||
- `types/procurement.ts` grew from a GRN-support subset (PO read types only) to the full §3 DTO set: Requisition/ReqLine, Rfq/RfqLine/Quotation/RfqComparison, PO create/update/cancel request types, PurchaseReturn/PurchaseReturnLine — mirrors `docs/11-BACKEND-PHASE1.md` §3 request/response JSON exactly (no `deliveryDate` field on PO lines, since the documented `POST /purchase-orders` example doesn't carry one despite FR-PROC-03's prose — contract-over-prose per `docs/20-FRONTEND.md` §1).
|
||||
- `lib/api/mock-data.ts`: added `mockRequisitions`/`mockRfqs`/`mockQuotations`/`mockPurchaseReturns` + allocators, a PO concurrency-token map (`getPoVersion`/`bumpPoVersion`/`initPoVersion`, same out-of-band ETag pattern as vendors), and `consumeLayerByGrnLine` — a *new* consumption path deliberately separate from `consumeFifo`: a Purchase Return disposes of the exact layer its GRN line created (often `OnHold`/`Rejected`, which `consumeFifo`'s hold filter would otherwise skip), not "the oldest open layer for this item/warehouse". Seeded Requisition #210 to match the existing `mockPurchaseOrders[0].requisitionId` so the two screens cross-reference.
|
||||
- New API modules: `lib/api/requisitions.ts`, `lib/api/rfqs.ts` (create/addQuotation/comparison — comparison is computed client-side from recorded quotations), `lib/api/purchase-returns.ts`. `lib/api/purchase-orders.ts` extended from list/get-only (its original GRN-support scope) to full create/update/cancel; added `getWithETag`/`isPoEditable` without touching the existing plain `get()` GRN's create-flow already depends on, so no existing call site broke.
|
||||
- **Wiring, not just new screens:** `stockApi.createReorderRequisition` (Stock → Reorder Alerts, built in an earlier session) previously fabricated a response with no backing record; it now pushes a real row into `mockRequisitions`, so a reorder-triggered requisition genuinely shows up in the new Requisitions list — same "connect the mock modules together" posture as GRN confirm → Stock Core.
|
||||
- Screens: Requisition (list/new/detail+Submit), RFQ (list/new with checkbox vendor multi-invite reusing the Stock Count's `Checkbox` pattern/new with quotation-recording form + vendor-by-line comparison table/"Create PO from vendor"), Purchase Order (list/new — accepts `?requisitionId=` or `?rfqId=&vendorId=` to prefill lines and pricing/detail with inline edit-while-open using the vendor `[id]` page's ETag+412-conflict-banner pattern, plus a Cancel-with-reason flow client-disabled when any line has receipts), Purchase Return (list/new — pick a Confirmed/Closed GRN, checkbox+qty its lines; also reachable via a new "Create Return" button next to `Rejected` lines on the GRN detail page, matching the SRS flow diagram's Reject→Purchase Return step). New `components/procurement/status-badges.tsx` (same fixed-width badge convention as Receiving/Stock) and `lib/validations/procurement.ts` (zod-free hand-rolled, matching the GRN validation file's style, not its `zod` deviation).
|
||||
- Sidebar: added "Procurement" nav entry between Vendors and Receiving; header title mappings added for all new routes.
|
||||
- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend exists (`Backend/PROGRESS.md` §2 unchanged).
|
||||
- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from the same established `set-state-in-effect` pattern used throughout this session (confirmed it also fires on the pre-existing `grn/page.tsx`/`vendors/page.tsx`/`hooks/use-mobile.ts` — not a regression); `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure blocks the full build, unrelated). All 12 new routes plus the GRN-detail "Create Return" link (including three query-param-prefilled variants) confirmed rendering 200 with no error boundary via SSR against the dev server.
|
||||
|
||||
### 2026-07-13 — Master Data screens: Items, UOM, Categories, Reorder settings (frontend-only; no backend changes)
|
||||
- `types/master-data.ts`: `ItemListItem` (the GRN/PO/Requisition/RFQ item-picker subset built in earlier sessions) is now a derived view of a new full `Item` type — SKU/name/description/category/baseUom/defaultVendor/type/trackingMode/taxClass/status plus `reorder: ItemReorderSetting[]` (matches the documented `GET /items/{itemId}` example inline) and `conversions: UomConversion[]` (**deviation**: the doc's example response only shows `reorder`, and conversions are otherwise reachable only via `PUT /items/{itemId}/uom-conversions` with no matching GET — embedding them on the full resource, like the assumed `GET /grns`/`GET /grns/{id}` reads elsewhere in this app, lets the Item detail screen show current conversions before editing). Also added `Category`/`CategoryTreeNode`, `CreateUomRequest`, `CreateCategoryRequest`, and Item create/update/reorder/conversion request types (docs/11 §2.1-2.3).
|
||||
- `lib/api/mock-data.ts`: `mockItems` changed storage shape from `ItemListItem[]` to full `Item[]` (only `mock-data.ts` and `lib/api/items.ts` touched it directly, confirmed by grep, so no other call site broke) — `lib/api/items.ts`'s `list()` now maps down to `ItemListItem`, same "full record → mapped summary" pattern as `mockPurchaseOrders` → `PurchaseOrderSummary`. Added a per-item concurrency-token map (`getItemVersion`/`bumpItemVersion`/`initItemVersion`, same out-of-band ETag pattern as vendors/POs), `mockCategories` seeded with a 2-root/1-child tree matching the category IDs the existing sample items already reference (12 "Fasteners" under 3 "Hardware"; 20 "Power Tools"), and a UOM id allocator.
|
||||
- New API modules: `lib/api/categories.ts` (`list`/`tree`/`create` — `tree()` builds the nested structure client-side from the flat list, since the mock has no separate tree-storage concept). `lib/api/items.ts` grew from list-only (its original GRN-picker scope) to full `get`/`create`/`update`/`updateStatus`/`updateReorder`/`updateUomConversions`; `lib/api/uoms.ts` gained `create`.
|
||||
- Screens: Items (`app/dashboard/products` — **reused the pre-existing "Products" sidebar entry and stub route** rather than adding a new nav item, since it was already wired to an empty placeholder page; list has search + category/tracking-mode/status filters + pagination, `/new` create, `/[id]` detail combining three independently-saved sections in one page — basic info with ETag/If-Match + 412-conflict banner mirroring the Vendor `[id]` page, a Reorder Settings row-editor posting `PUT /items/{itemId}/reorder`, and a UOM Conversions row-editor posting `PUT /items/{itemId}/uom-conversions` — matching how the API groups these as sub-resources of Item rather than separate top-level screens). UOM (`app/dashboard/products/uoms` — flat list + create dialog, same shape as the Warehouses list). Categories (`app/dashboard/products/categories` — indented recursive tree view + create dialog with a parent picker). `lib/validations/master-data.ts` added (hand-rolled, matching the GRN validation file's style, not its `zod` deviation). Header title mappings added for all `/dashboard/products/*` routes.
|
||||
- **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).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppSidebar } from "@/components/Layouts/AppSidebar"
|
||||
import { Header } from "@/components/Layouts/Header"
|
||||
import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs"
|
||||
import { Toaster } from "@/components/ui/toast"
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -14,6 +15,7 @@ export default function DashboardLayout({
|
||||
<Header />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-6 lg:p-8">
|
||||
<Breadcrumbs />
|
||||
<div className="rounded-xl bg-card border border-gray-200 shadow-sm">
|
||||
<div className="p-6">
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import Link from "next/link"
|
||||
import { ClipboardList, FileText, PackageX, ShoppingCart, type LucideIcon } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
|
||||
{
|
||||
title: "Requisitions",
|
||||
description: "Raise a purchase requisition and submit it into procurement.",
|
||||
href: "/dashboard/procurement/requisitions",
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
title: "RFQs",
|
||||
description: "Request quotations from vendors, record pricing, and compare side by side.",
|
||||
href: "/dashboard/procurement/rfqs",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: "Purchase Orders",
|
||||
description: "Auto-approved on creation, freely editable while open, cancellable before receipt.",
|
||||
href: "/dashboard/procurement/purchase-orders",
|
||||
icon: ShoppingCart,
|
||||
},
|
||||
{
|
||||
title: "Purchase Returns",
|
||||
description: "Return received goods to a vendor, referencing the original GRN line.",
|
||||
href: "/dashboard/procurement/purchase-returns",
|
||||
icon: PackageX,
|
||||
},
|
||||
]
|
||||
|
||||
export default function ProcurementHubPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Procurement</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Requisition → RFQ (optional) → Purchase Order → Purchase Return (FR-PROC-01..09).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{areas.map((area) => (
|
||||
<Link key={area.href} href={area.href}>
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<area.icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{area.title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-base text-muted-foreground">{area.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Trash2 } from "lucide-react"
|
||||
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validatePoLine } from "@/lib/validations/procurement"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreatePoLineInput, PurchaseOrder } from "@/types/procurement"
|
||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
poLineId: number | null
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
warehouseId: number | null
|
||||
qty: string
|
||||
unitPrice: string
|
||||
tax: string
|
||||
qtyReceived: number
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function newKey() {
|
||||
keySeq += 1
|
||||
return `poeditline-${keySeq}`
|
||||
}
|
||||
|
||||
export default function PurchaseOrderDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const poId = Number(params.id)
|
||||
|
||||
const [po, setPo] = useState<PurchaseOrder | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [lines, setLines] = useState<DraftLine[]>([])
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [conflict, setConflict] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [showCancelForm, setShowCancelForm] = useState(false)
|
||||
const [cancelReason, setCancelReason] = useState("")
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
|
||||
function toDraftLines(order: PurchaseOrder): DraftLine[] {
|
||||
return order.lines.map((l) => ({
|
||||
key: newKey(),
|
||||
poLineId: l.poLineId,
|
||||
itemId: l.itemId,
|
||||
uomId: l.uomId,
|
||||
warehouseId: l.warehouseId,
|
||||
qty: String(l.qty),
|
||||
unitPrice: String(l.unitPrice),
|
||||
tax: String(l.tax),
|
||||
qtyReceived: l.qtyReceived,
|
||||
}))
|
||||
}
|
||||
|
||||
function load() {
|
||||
setLoadError(null)
|
||||
purchaseOrdersApi
|
||||
.getWithETag(poId)
|
||||
.then(({ data, etag: tag }) => {
|
||||
setPo(data)
|
||||
setEtag(tag)
|
||||
setLines(toDraftLines(data))
|
||||
setConflict(false)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(poId)) return
|
||||
load()
|
||||
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list(), warehousesApi.list(), vendorsApi.list({ pageSize: 200 })])
|
||||
.then(([it, uo, wh, ve]) => {
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
setWarehouses(wh.items)
|
||||
setVendors(ve.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poId])
|
||||
|
||||
function itemFor(itemId: number | null) {
|
||||
return items.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
function uomName(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
}
|
||||
function warehouseCode(warehouseId: number) {
|
||||
return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}`
|
||||
}
|
||||
function vendorCode(vendorId: number) {
|
||||
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
||||
}
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!po || !etag) return
|
||||
setSaveError(null)
|
||||
|
||||
if (lines.length === 0) {
|
||||
setSaveError("A purchase order needs at least one line.")
|
||||
return
|
||||
}
|
||||
const nextLineErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of lines) {
|
||||
const errors = validatePoLine({
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
unitPrice: line.unitPrice,
|
||||
tax: line.tax,
|
||||
})
|
||||
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
||||
}
|
||||
setLineErrors(nextLineErrors)
|
||||
if (Object.keys(nextLineErrors).length > 0) {
|
||||
setSaveError("Fix the highlighted lines before saving.")
|
||||
return
|
||||
}
|
||||
|
||||
const payloadLines: CreatePoLineInput[] = lines.map((l) => ({
|
||||
itemId: l.itemId as number,
|
||||
uomId: l.uomId as number,
|
||||
warehouseId: l.warehouseId as number,
|
||||
qty: Number(l.qty),
|
||||
unitPrice: Number(l.unitPrice),
|
||||
tax: Number(l.tax),
|
||||
}))
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag)
|
||||
setPo(result.data)
|
||||
setEtag(result.etag)
|
||||
setLines(toDraftLines(result.data))
|
||||
toast.success("Purchase order saved", `${result.data.docNo} updated (FR-PROC-05, edit-while-open).`)
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : (err as { code?: string })?.code
|
||||
if (code === "CONCURRENCY_CONFLICT") {
|
||||
setConflict(true)
|
||||
setSaveError(errorMessage(err))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not save purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!po) return
|
||||
if (!cancelReason.trim()) {
|
||||
setSaveError("A cancellation reason is required.")
|
||||
return
|
||||
}
|
||||
setCancelling(true)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.cancel(po.poId, { reason: cancelReason.trim() })
|
||||
setPo(updated)
|
||||
setShowCancelForm(false)
|
||||
toast.success("Purchase order cancelled", updated.docNo)
|
||||
} catch (err) {
|
||||
toast.error("Could not cancel purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError && !po) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Back to purchase orders
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!po) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const editable = isPoEditable(po.status) && !conflict
|
||||
const hasReceipts = po.lines.some((l) => l.qtyReceived > 0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{po.docNo}</h1>
|
||||
<PoStatusBadge status={po.status} />
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Vendor {vendorCode(po.vendorId)} {po.requisitionId ? `— from Requisition #${po.requisitionId}` : ""} — {po.totals.currency} {po.totals.grandTotal.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPoEditable(po.status) && !showCancelForm && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
onClick={() => setShowCancelForm(true)}
|
||||
disabled={hasReceipts}
|
||||
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
|
||||
>
|
||||
<Ban className="size-5" />
|
||||
Cancel PO
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCancelForm && (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-5">
|
||||
<p className="text-base font-semibold text-destructive">Cancel {po.docNo}</p>
|
||||
<Input
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
placeholder="Reason (e.g. Duplicate order)"
|
||||
className="h-11 max-w-md text-base"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
|
||||
{cancelling ? "Cancelling…" : "Confirm cancellation"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflict && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>{saveError ?? "This purchase order was changed by someone else."} Reload before retrying.</p>
|
||||
<Button size="sm" variant="outline" onClick={load}>
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && !conflict && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{editable && (
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-36 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Tax</TableHead>
|
||||
{editable && <TableHead className="h-12 w-10 px-3" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const item = itemFor(line.itemId)
|
||||
if (!editable) {
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.uomId ? uomName(line.uomId) : "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qtyReceived}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{Number(line.unitPrice).toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{(Number(line.tax) * 100).toFixed(0)}%</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
|
||||
<SelectValue placeholder="Warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min={line.qtyReceived}
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qtyReceived}</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitPrice}
|
||||
aria-invalid={!!errors.unitPrice}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.tax}
|
||||
aria-invalid={!!errors.tax}
|
||||
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeLine(line.key)}
|
||||
aria-label="Remove line"
|
||||
disabled={line.qtyReceived > 0}
|
||||
title={line.qtyReceived > 0 ? "Cannot remove a line that already has receipts" : undefined}
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
import { rfqsApi } from "@/lib/api/rfqs"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validatePoLine } from "@/lib/validations/procurement"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreatePoLineInput } from "@/types/procurement"
|
||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
warehouseId: number | null
|
||||
qty: string
|
||||
unitPrice: string
|
||||
tax: string
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function newKey() {
|
||||
keySeq += 1
|
||||
return `poline-${keySeq}`
|
||||
}
|
||||
|
||||
function emptyLine(): DraftLine {
|
||||
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18" }
|
||||
}
|
||||
|
||||
function NewPurchaseOrderContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const requisitionId = Number(searchParams.get("requisitionId")) || null
|
||||
const rfqId = Number(searchParams.get("rfqId")) || null
|
||||
const rfqVendorId = Number(searchParams.get("vendorId")) || null
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||
const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [vendorId, setVendorId] = useState<number | null>(rfqVendorId)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list(),
|
||||
warehousesApi.list(),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
])
|
||||
.then(([it, uo, wh, ve]) => {
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
setWarehouses(wh.items)
|
||||
setVendors(ve.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (requisitionId) {
|
||||
requisitionsApi
|
||||
.get(requisitionId)
|
||||
.then((req) => {
|
||||
setLines(
|
||||
req.lines.map(
|
||||
(l): DraftLine => ({
|
||||
key: newKey(),
|
||||
itemId: l.itemId,
|
||||
uomId: null,
|
||||
warehouseId: null,
|
||||
qty: String(l.qty),
|
||||
unitPrice: "",
|
||||
tax: "0.18",
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
.catch((err) => setHeaderError(errorMessage(err)))
|
||||
.finally(() => setPrefillLoading(false))
|
||||
return
|
||||
}
|
||||
|
||||
if (rfqId && rfqVendorId) {
|
||||
Promise.all([rfqsApi.get(rfqId), rfqsApi.comparison(rfqId)])
|
||||
.then(([rfq, comparison]) => {
|
||||
setVendorId(rfqVendorId)
|
||||
setLines(
|
||||
rfq.lines.map((l): DraftLine => {
|
||||
const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId)
|
||||
return {
|
||||
key: newKey(),
|
||||
itemId: l.itemId,
|
||||
uomId: null,
|
||||
warehouseId: null,
|
||||
qty: String(l.qty),
|
||||
unitPrice: cell ? String(cell.unitPrice) : "",
|
||||
tax: "0.18",
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
.catch((err) => setHeaderError(errorMessage(err)))
|
||||
.finally(() => setPrefillLoading(false))
|
||||
return
|
||||
}
|
||||
|
||||
setPrefillLoading(false)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [requisitionId, rfqId, rfqVendorId])
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
function itemFor(itemId: number | null) {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setHeaderError(null)
|
||||
setSubmitError(null)
|
||||
|
||||
if (!vendorId) {
|
||||
setHeaderError("Select a vendor.")
|
||||
return
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
setSubmitError("Add at least one line.")
|
||||
return
|
||||
}
|
||||
|
||||
const nextLineErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of lines) {
|
||||
const errors = validatePoLine({
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
unitPrice: line.unitPrice,
|
||||
tax: line.tax,
|
||||
})
|
||||
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
||||
}
|
||||
setLineErrors(nextLineErrors)
|
||||
if (Object.keys(nextLineErrors).length > 0) {
|
||||
setSubmitError("Fix the highlighted lines before submitting.")
|
||||
return
|
||||
}
|
||||
|
||||
const payloadLines: CreatePoLineInput[] = lines.map((l) => ({
|
||||
itemId: l.itemId as number,
|
||||
uomId: l.uomId as number,
|
||||
warehouseId: l.warehouseId as number,
|
||||
qty: Number(l.qty),
|
||||
unitPrice: Number(l.unitPrice),
|
||||
tax: Number(l.tax),
|
||||
}))
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const { data: po } = await purchaseOrdersApi.create({
|
||||
vendorId,
|
||||
requisitionId: requisitionId ?? (rfqId ? undefined : null),
|
||||
lines: payloadLines,
|
||||
})
|
||||
toast.success("Purchase order created", `${po.docNo} — auto-approved (FR-PROC-04).`)
|
||||
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not create purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loading = !items || !uoms || !warehouses || !vendors || prefillLoading
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Purchase Order</h1>
|
||||
<p className="text-base text-muted-foreground">Auto-approved on creation; freely editable while open (FR-PROC-03..05).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Vendor</Label>
|
||||
<Select<number | null> value={vendorId} onValueChange={setVendorId}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code} — {v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{requisitionId && (
|
||||
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
|
||||
)}
|
||||
{rfqId && <div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From RFQ #{rfqId}</div>}
|
||||
</div>
|
||||
|
||||
{headerError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{lines.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-40 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Tax</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const item = itemFor(line.itemId)
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{requisitionId || rfqId ? (
|
||||
<div className="flex h-11 items-center text-base">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(uoms ?? []).map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
|
||||
<SelectValue placeholder="Warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitPrice}
|
||||
aria-invalid={!!errors.unitPrice}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.tax}
|
||||
aria-invalid={!!errors.tax}
|
||||
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create PO"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewPurchaseOrderPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<NewPurchaseOrderContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Plus, ShoppingCart } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
type StatusFilter = PurchaseOrderStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function PurchaseOrdersListPage() {
|
||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query, status])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
purchaseOrdersApi
|
||||
.list({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status })
|
||||
.then((res) => {
|
||||
setPos(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page, query, status])
|
||||
useEffect(() => {
|
||||
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
function vendorCode(vendorId: number) {
|
||||
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
|
||||
<p className="text-base text-muted-foreground">Auto-approved on creation and freely editable while open (FR-PROC-03..05).</p>
|
||||
</div>
|
||||
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New PO
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search doc no., vendor…"
|
||||
className="h-12 w-full flex-1 basis-0 text-base"
|
||||
aria-label="Search purchase orders"
|
||||
/>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
|
||||
<SelectTrigger className="h-12! w-full sm:w-56 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
|
||||
<SelectItem value="Approved" className="text-base">Approved</SelectItem>
|
||||
<SelectItem value="PartiallyReceived" className="text-base">Partially received</SelectItem>
|
||||
<SelectItem value="FullyReceived" className="text-base">Fully received</SelectItem>
|
||||
<SelectItem value="Closed" className="text-base">Closed</SelectItem>
|
||||
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasFilters && (
|
||||
<Button type="button" variant="ghost" onClick={() => { setSearchInput(""); setStatus("All") }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && pos === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && pos !== null && pos.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ShoppingCart className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">{hasFilters ? "No purchase orders match your search/filter." : "No purchase orders yet."}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && pos !== null && pos.length > 0 && (
|
||||
<>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pos.map((po) => (
|
||||
<TableRow key={po.poId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { purchaseReturnsApi } from "@/lib/api/purchase-returns"
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateReturnLine } from "@/lib/validations/procurement"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreatePurchaseReturnLineInput } from "@/types/procurement"
|
||||
import { Grn, GrnLine } from "@/types/grn"
|
||||
import { ItemListItem } from "@/types/master-data"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { HoldStatusBadge } from "@/components/receiving/status-badges"
|
||||
|
||||
interface LineState {
|
||||
selected: boolean
|
||||
qty: string
|
||||
}
|
||||
|
||||
function NewPurchaseReturnContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const presetGrnId = Number(searchParams.get("grnId")) || null
|
||||
const presetGrnLineId = Number(searchParams.get("grnLineId")) || null
|
||||
|
||||
const [grns, setGrns] = useState<Grn[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [grnId, setGrnId] = useState<number | null>(presetGrnId)
|
||||
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
|
||||
const [lineState, setLineState] = useState<Record<number, LineState>>({})
|
||||
const [lineErrors, setLineErrors] = useState<Record<number, Record<string, string>>>({})
|
||||
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([grnsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
|
||||
.then(([grnList, it, rc]) => {
|
||||
// Only Confirmed/Closed GRNs have posted stock layers to return against.
|
||||
Promise.all(grnList.items.filter((g) => g.status === "Confirmed" || g.status === "Closed").map((g) => grnsApi.get(g.grnId))).then(setGrns)
|
||||
setItems(it.items)
|
||||
setReasonCodes(rc.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const selectedGrn = grns?.find((g) => g.grnId === grnId) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGrn) {
|
||||
setLineState({})
|
||||
return
|
||||
}
|
||||
const next: Record<number, LineState> = {}
|
||||
for (const line of selectedGrn.lines) {
|
||||
next[line.grnLineId] = {
|
||||
selected: presetGrnLineId ? line.grnLineId === presetGrnLineId : false,
|
||||
qty: presetGrnLineId && line.grnLineId === presetGrnLineId ? String(line.qty) : "",
|
||||
}
|
||||
}
|
||||
setLineState(next)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedGrn?.grnId])
|
||||
|
||||
function itemFor(itemId: number) {
|
||||
return items.find((i) => i.itemId === itemId)
|
||||
}
|
||||
|
||||
function toggleLine(line: GrnLine) {
|
||||
setLineState((prev) => ({
|
||||
...prev,
|
||||
[line.grnLineId]: { selected: !prev[line.grnLineId]?.selected, qty: prev[line.grnLineId]?.qty || String(line.qty) },
|
||||
}))
|
||||
}
|
||||
|
||||
function setQty(grnLineId: number, qty: string) {
|
||||
setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty } }))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setHeaderError(null)
|
||||
setSubmitError(null)
|
||||
|
||||
if (!selectedGrn) {
|
||||
setHeaderError("Select a GRN to return against.")
|
||||
return
|
||||
}
|
||||
if (!reasonCodeId) {
|
||||
setHeaderError("Select a reason code.")
|
||||
return
|
||||
}
|
||||
|
||||
const selectedLines = selectedGrn.lines.filter((l) => lineState[l.grnLineId]?.selected)
|
||||
if (selectedLines.length === 0) {
|
||||
setSubmitError("Select at least one line to return.")
|
||||
return
|
||||
}
|
||||
|
||||
const nextErrors: Record<number, Record<string, string>> = {}
|
||||
for (const line of selectedLines) {
|
||||
const errors = validateReturnLine({ grnLineId: line.grnLineId, qty: lineState[line.grnLineId].qty, maxQty: line.qty })
|
||||
if (Object.keys(errors).length > 0) nextErrors[line.grnLineId] = errors
|
||||
}
|
||||
setLineErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setSubmitError("Fix the highlighted lines before submitting.")
|
||||
return
|
||||
}
|
||||
|
||||
const payloadLines: CreatePurchaseReturnLineInput[] = selectedLines.map((l) => ({
|
||||
grnLineId: l.grnLineId,
|
||||
itemId: l.itemId,
|
||||
qty: Number(lineState[l.grnLineId].qty),
|
||||
}))
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const purchaseReturn = await purchaseReturnsApi.create({
|
||||
vendorId: selectedGrn.vendorId,
|
||||
warehouseId: selectedGrn.warehouseId,
|
||||
reasonCodeId,
|
||||
lines: payloadLines,
|
||||
})
|
||||
toast.success("Purchase return posted", `${purchaseReturn.docNo} — ${purchaseReturn.ledgerRefs.length} ledger entr${purchaseReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`)
|
||||
router.push("/dashboard/procurement/purchase-returns")
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not post purchase return", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loading = !grns
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Purchase Return</h1>
|
||||
<p className="text-base text-muted-foreground">Return received goods to the vendor; posts an outbound ledger entry immediately (FR-PROC-08).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label className="text-base">Goods Receipt Note</Label>
|
||||
<Select<number | null> value={grnId} onValueChange={setGrnId} disabled={!!presetGrnId}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select a confirmed GRN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(grns ?? []).map((g) => (
|
||||
<SelectItem key={g.grnId} value={g.grnId} className="text-base">
|
||||
{g.docNo} — Vendor #{g.vendorId}, Warehouse #{g.warehouseId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Reason</Label>
|
||||
<Select<number | null> value={reasonCodeId} onValueChange={setReasonCodeId}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{reasonCodes.map((rc) => (
|
||||
<SelectItem key={rc.reasonCodeId} value={rc.reasonCodeId} className="text-base">
|
||||
{rc.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{headerError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||
)}
|
||||
|
||||
{selectedGrn && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedGrn.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
|
||||
const errors = lineErrors[line.grnLineId] ?? {}
|
||||
return (
|
||||
<TableRow key={line.grnLineId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<HoldStatusBadge status={line.holdStatus} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={state.qty}
|
||||
disabled={!state.selected}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => setQty(line.grnLineId, e.target.value)}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Posting…" : "Post Return"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewPurchaseReturnPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<NewPurchaseReturnContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { PackageX, Plus } from "lucide-react"
|
||||
|
||||
import { purchaseReturnsApi } from "@/lib/api/purchase-returns"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { PurchaseReturnSummary } from "@/types/procurement"
|
||||
import { Vendor, Warehouse } from "@/types/master-data"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
export default function PurchaseReturnsListPage() {
|
||||
const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([purchaseReturnsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
||||
.then(([r, v, w, rc]) => {
|
||||
setReturns(r.items)
|
||||
setVendors(v.items)
|
||||
setWarehouses(w.items)
|
||||
setReasonCodes(rc.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
function vendorCode(id: number) {
|
||||
return vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`
|
||||
}
|
||||
function warehouseCode(id: number) {
|
||||
return warehouses.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||
}
|
||||
function reasonLabel(id: number) {
|
||||
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1>
|
||||
<p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p>
|
||||
</div>
|
||||
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Return
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && returns === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && returns !== null && returns.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<PackageX className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No purchase returns yet.</p>
|
||||
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Return
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && returns !== null && returns.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{returns.map((r) => (
|
||||
<TableRow key={r.returnId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorCode(r.vendorId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, FileText, Send, ShoppingCart } from "lucide-react"
|
||||
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Requisition } from "@/types/procurement"
|
||||
import { ItemListItem } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
export default function RequisitionDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const requisitionId = Number(params.id)
|
||||
|
||||
const [requisition, setRequisition] = useState<Requisition | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
requisitionsApi.get(requisitionId).then(setRequisition).catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(requisitionId)) return
|
||||
load()
|
||||
itemsApi.list({ pageSize: 200 }).then((res) => setItems(res.items)).catch(() => {})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [requisitionId])
|
||||
|
||||
function itemFor(itemId: number) {
|
||||
return items.find((i) => i.itemId === itemId)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!requisition) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updated = await requisitionsApi.submit(requisition.requisitionId)
|
||||
setRequisition(updated)
|
||||
toast.success("Requisition submitted", `${updated.docNo} is ready for RFQ or a direct PO.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not submit requisition", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !requisition) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
if (!requisition) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/requisitions" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{requisition.docNo}</h1>
|
||||
<RequisitionStatusBadge status={requisition.status} />
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Requested by #{requisition.requestedBy} — {new Date(requisition.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{requisition.status === "Draft" && (
|
||||
<Button size="lg" onClick={handleSubmit} disabled={submitting}>
|
||||
<Send className="size-5" />
|
||||
{submitting ? "Submitting…" : "Submit"}
|
||||
</Button>
|
||||
)}
|
||||
{requisition.status === "Submitted" && (
|
||||
<>
|
||||
<Link
|
||||
href={`/dashboard/procurement/rfqs/new?requisitionId=${requisition.requisitionId}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||
>
|
||||
<FileText className="size-5" />
|
||||
Create RFQ
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/new?requisitionId=${requisition.requisitionId}`}
|
||||
className={cn(buttonVariants({ size: "lg" }))}
|
||||
>
|
||||
<ShoppingCart className="size-5" />
|
||||
Create PO
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Required by</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requisition.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
return (
|
||||
<TableRow key={line.reqLineId}>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.requiredBy}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateRequisitionLine } from "@/lib/validations/procurement"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateReqLineInput } from "@/types/procurement"
|
||||
import { ItemListItem } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
itemId: number | null
|
||||
qty: string
|
||||
requiredBy: string
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function newKey() {
|
||||
keySeq += 1
|
||||
return `rline-${keySeq}`
|
||||
}
|
||||
|
||||
function emptyLine(): DraftLine {
|
||||
return { key: newKey(), itemId: null, qty: "", requiredBy: "" }
|
||||
}
|
||||
|
||||
export default function NewRequisitionPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
|
||||
if (lines.length === 0) {
|
||||
setSubmitError("Add at least one line.")
|
||||
return
|
||||
}
|
||||
|
||||
const nextLineErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of lines) {
|
||||
const errors = validateRequisitionLine({ itemId: line.itemId, qty: line.qty, requiredBy: line.requiredBy })
|
||||
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
||||
}
|
||||
setLineErrors(nextLineErrors)
|
||||
if (Object.keys(nextLineErrors).length > 0) {
|
||||
setSubmitError("Fix the highlighted lines before submitting.")
|
||||
return
|
||||
}
|
||||
|
||||
const payloadLines: CreateReqLineInput[] = lines.map((l) => ({
|
||||
itemId: l.itemId as number,
|
||||
qty: Number(l.qty),
|
||||
requiredBy: l.requiredBy,
|
||||
}))
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const requisition = await requisitionsApi.create({ lines: payloadLines })
|
||||
toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`)
|
||||
router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not create requisition", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loading = !items
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/requisitions" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Requisition</h1>
|
||||
<p className="text-base text-muted-foreground">Request items for procurement; submit once the lines are ready (FR-PROC-01).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{lines.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-72 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Required by</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="date"
|
||||
value={line.requiredBy}
|
||||
aria-invalid={!!errors.requiredBy}
|
||||
onChange={(e) => updateLine(line.key, { requiredBy: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.requiredBy ? { message: errors.requiredBy } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/procurement/requisitions" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create Requisition"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ClipboardList, Plus } from "lucide-react"
|
||||
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { RequisitionStatus, RequisitionSummary } from "@/types/procurement"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
type StatusFilter = RequisitionStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function RequisitionsListPage() {
|
||||
const [requisitions, setRequisitions] = useState<RequisitionSummary[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
useEffect(() => setPage(1), [status])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
requisitionsApi
|
||||
.list({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status })
|
||||
.then((res) => {
|
||||
setRequisitions(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page, status])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Requisitions</h1>
|
||||
<p className="text-base text-muted-foreground">Raise a purchase requisition and submit it into procurement (FR-PROC-01).</p>
|
||||
</div>
|
||||
<Link href="/dashboard/procurement/requisitions/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Requisition
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
|
||||
<SelectTrigger className="h-12! w-56 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
|
||||
<SelectItem value="Submitted" className="text-base">Submitted</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && requisitions === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && requisitions !== null && requisitions.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ClipboardList className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No requisitions yet.</p>
|
||||
<Link href="/dashboard/procurement/requisitions/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Requisition
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && requisitions !== null && requisitions.length > 0 && (
|
||||
<>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Lines</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Requested by</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requisitions.map((r) => (
|
||||
<TableRow key={r.requisitionId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/requisitions/${r.requisitionId}`} className="font-medium text-foreground hover:underline">
|
||||
{r.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<RequisitionStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">#{r.requestedBy}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ShoppingCart } from "lucide-react"
|
||||
|
||||
import { rfqsApi } from "@/lib/api/rfqs"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateQuotationLine } from "@/lib/validations/procurement"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { QuotationLine, Rfq, RfqComparison } from "@/types/procurement"
|
||||
import { ItemListItem, Vendor } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { RfqStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
interface QuoteDraft {
|
||||
unitPrice: string
|
||||
leadDays: string
|
||||
}
|
||||
|
||||
export default function RfqDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const rfqId = Number(params.id)
|
||||
|
||||
const [rfq, setRfq] = useState<Rfq | null>(null)
|
||||
const [comparison, setComparison] = useState<RfqComparison | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [quoteVendorId, setQuoteVendorId] = useState<number | null>(null)
|
||||
const [quoteLines, setQuoteLines] = useState<Record<number, QuoteDraft>>({})
|
||||
const [quoteErrors, setQuoteErrors] = useState<Record<number, Record<string, string>>>({})
|
||||
const [quoteFormError, setQuoteFormError] = useState<string | null>(null)
|
||||
const [submittingQuote, setSubmittingQuote] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
Promise.all([rfqsApi.get(rfqId), rfqsApi.comparison(rfqId)])
|
||||
.then(([r, c]) => {
|
||||
setRfq(r)
|
||||
setComparison(c)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(rfqId)) return
|
||||
load()
|
||||
Promise.all([itemsApi.list({ pageSize: 200 }), vendorsApi.list({ pageSize: 200 })])
|
||||
.then(([it, ve]) => {
|
||||
setItems(it.items)
|
||||
setVendors(ve.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rfqId])
|
||||
|
||||
const quotedVendorIds = useMemo(() => {
|
||||
const set = new Set<number>()
|
||||
for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId)
|
||||
return set
|
||||
}, [comparison])
|
||||
|
||||
const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds])
|
||||
|
||||
function itemFor(itemId: number) {
|
||||
return items.find((i) => i.itemId === itemId)
|
||||
}
|
||||
function vendorFor(vendorId: number) {
|
||||
return vendors.find((v) => v.vendorId === vendorId)
|
||||
}
|
||||
|
||||
function selectQuoteVendor(vendorId: number | null) {
|
||||
setQuoteVendorId(vendorId)
|
||||
setQuoteFormError(null)
|
||||
setQuoteErrors({})
|
||||
if (!rfq) return
|
||||
const draft: Record<number, QuoteDraft> = {}
|
||||
for (const line of rfq.lines) draft[line.itemId] = { unitPrice: "", leadDays: "" }
|
||||
setQuoteLines(draft)
|
||||
}
|
||||
|
||||
async function handleSubmitQuote() {
|
||||
if (!rfq || !quoteVendorId) {
|
||||
setQuoteFormError("Select a vendor first.")
|
||||
return
|
||||
}
|
||||
const nextErrors: Record<number, Record<string, string>> = {}
|
||||
for (const line of rfq.lines) {
|
||||
const draft = quoteLines[line.itemId] ?? { unitPrice: "", leadDays: "" }
|
||||
const errors = validateQuotationLine(draft)
|
||||
if (Object.keys(errors).length > 0) nextErrors[line.itemId] = errors
|
||||
}
|
||||
setQuoteErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setQuoteFormError("Fix the highlighted fields before submitting.")
|
||||
return
|
||||
}
|
||||
|
||||
const lines: QuotationLine[] = rfq.lines.map((l) => ({
|
||||
itemId: l.itemId,
|
||||
unitPrice: Number(quoteLines[l.itemId].unitPrice),
|
||||
leadDays: Number(quoteLines[l.itemId].leadDays),
|
||||
}))
|
||||
|
||||
setSubmittingQuote(true)
|
||||
try {
|
||||
await rfqsApi.addQuotation(rfqId, { vendorId: quoteVendorId, lines })
|
||||
toast.success("Quotation recorded", `${vendorFor(quoteVendorId)?.code ?? `Vendor #${quoteVendorId}`} priced ${lines.length} line(s).`)
|
||||
setQuoteVendorId(null)
|
||||
setQuoteLines({})
|
||||
const c = await rfqsApi.comparison(rfqId)
|
||||
setComparison(c)
|
||||
} catch (err) {
|
||||
setQuoteFormError(errorMessage(err))
|
||||
toast.error("Could not record quotation", errorMessage(err))
|
||||
} finally {
|
||||
setSubmittingQuote(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !rfq) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
if (!rfq || !comparison) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/rfqs" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
|
||||
<RfqStatusBadge status={rfq.status} />
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId} — ` : ""}
|
||||
Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rfq.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
return (
|
||||
<TableRow key={line.rfqLineId}>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Vendor comparison</h2>
|
||||
{comparison.lines.every((l) => l.cells.length === 0) ? (
|
||||
<p className="text-base text-muted-foreground">No quotations recorded yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
{rfq.vendorIds.map((vid) => (
|
||||
<TableHead key={vid} className="h-12 px-3 text-sm">{vendorFor(vid)?.code ?? `#${vid}`}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{comparison.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
return (
|
||||
<TableRow key={line.itemId}>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
{rfq.vendorIds.map((vid) => {
|
||||
const cell = line.cells.find((c) => c.vendorId === vid)
|
||||
return (
|
||||
<TableCell key={vid} className="px-3 py-3.5">
|
||||
{cell ? (
|
||||
<span>
|
||||
{cell.unitPrice.toFixed(2)} <span className="text-sm text-muted-foreground">({cell.leadDays}d)</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{[...quotedVendorIds].length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[...quotedVendorIds].map((vid) => (
|
||||
<Link
|
||||
key={vid}
|
||||
href={`/dashboard/procurement/purchase-orders/new?rfqId=${rfqId}&vendorId=${vid}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
<ShoppingCart className="size-4" />
|
||||
Create PO from {vendorFor(vid)?.code ?? `#${vid}`}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingVendors.length > 0 && (
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<h2 className="text-base font-semibold text-foreground">Record a quotation</h2>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:w-72">
|
||||
<Label className="text-base">Vendor</Label>
|
||||
<Select<number | null> value={quoteVendorId} onValueChange={selectQuoteVendor}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Select an invited vendor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pendingVendors.map((vid) => (
|
||||
<SelectItem key={vid} value={vid} className="text-base">
|
||||
{vendorFor(vid)?.code ?? `#${vid}`} — {vendorFor(vid)?.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{quoteVendorId && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-36 px-3 text-sm">Unit price</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Lead days</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rfq.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const draft = quoteLines[line.itemId] ?? { unitPrice: "", leadDays: "" }
|
||||
const errors = quoteErrors[line.itemId] ?? {}
|
||||
return (
|
||||
<TableRow key={line.rfqLineId}>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={draft.unitPrice}
|
||||
aria-invalid={!!errors.unitPrice}
|
||||
onChange={(e) => setQuoteLines((prev) => ({ ...prev, [line.itemId]: { ...prev[line.itemId], unitPrice: e.target.value } }))}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={draft.leadDays}
|
||||
aria-invalid={!!errors.leadDays}
|
||||
onChange={(e) => setQuoteLines((prev) => ({ ...prev, [line.itemId]: { ...prev[line.itemId], leadDays: e.target.value } }))}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.leadDays ? { message: errors.leadDays } : undefined]} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{quoteFormError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{quoteFormError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={handleSubmitQuote} disabled={!quoteVendorId || submittingQuote}>
|
||||
{submittingQuote ? "Saving…" : "Save quotation"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { rfqsApi } from "@/lib/api/rfqs"
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
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 { ItemListItem, Vendor } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
itemId: number | null
|
||||
qty: string
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function newKey() {
|
||||
keySeq += 1
|
||||
return `rfqline-${keySeq}`
|
||||
}
|
||||
|
||||
function emptyLine(): DraftLine {
|
||||
return { key: newKey(), itemId: null, qty: "" }
|
||||
}
|
||||
|
||||
function NewRfqContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const requisitionId = Number(searchParams.get("requisitionId")) || null
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [requisitionLoading, setRequisitionLoading] = useState(!!requisitionId)
|
||||
|
||||
const [vendorIds, setVendorIds] = useState<Set<number>>(new Set())
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), vendorsApi.list({ pageSize: 200, status: "Active" })])
|
||||
.then(([it, ve]) => {
|
||||
setItems(it.items)
|
||||
setVendors(ve.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!requisitionId) return
|
||||
requisitionsApi
|
||||
.get(requisitionId)
|
||||
.then((req) => {
|
||||
setLines(req.lines.map((l): DraftLine => ({ key: newKey(), itemId: l.itemId, qty: String(l.qty) })))
|
||||
})
|
||||
.catch((err) => setHeaderError(errorMessage(err)))
|
||||
.finally(() => setRequisitionLoading(false))
|
||||
}, [requisitionId])
|
||||
|
||||
function toggleVendor(vendorId: number) {
|
||||
setVendorIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(vendorId)) next.delete(vendorId)
|
||||
else next.add(vendorId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setHeaderError(null)
|
||||
setSubmitError(null)
|
||||
|
||||
if (vendorIds.size === 0) {
|
||||
setHeaderError("Select at least one vendor to invite.")
|
||||
return
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
setSubmitError("Add at least one line.")
|
||||
return
|
||||
}
|
||||
|
||||
const nextLineErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of lines) {
|
||||
const errors = validateRfqLine({ itemId: line.itemId, qty: line.qty })
|
||||
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
||||
}
|
||||
setLineErrors(nextLineErrors)
|
||||
if (Object.keys(nextLineErrors).length > 0) {
|
||||
setSubmitError("Fix the highlighted lines before submitting.")
|
||||
return
|
||||
}
|
||||
|
||||
const payloadLines: CreateRfqLineInput[] = lines.map((l) => ({ itemId: l.itemId as number, qty: Number(l.qty) }))
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const rfq = await rfqsApi.create({ requisitionId, vendorIds: [...vendorIds], lines: payloadLines })
|
||||
toast.success("RFQ created", `${rfq.docNo} sent to ${vendorIds.size} vendor(s).`)
|
||||
router.push(`/dashboard/procurement/rfqs/${rfq.rfqId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not create RFQ", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function itemFor(itemId: number | null) {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
|
||||
const loading = !items || !vendors || requisitionLoading
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/rfqs" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New RFQ</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{requisitionId ? `Request quotations for Requisition #${requisitionId}` : "Request quotations from one or more vendors (FR-PROC-02)."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Invite vendors</Label>
|
||||
<div className="grid grid-cols-1 gap-1 rounded-xl border p-3 sm:grid-cols-2">
|
||||
{(vendors ?? []).map((v) => (
|
||||
<label key={v.vendorId} className="flex items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-muted/50">
|
||||
<Checkbox checked={vendorIds.has(v.vendorId)} onCheckedChange={() => toggleVendor(v.vendorId)} />
|
||||
<span className="text-base font-medium">{v.code}</span>
|
||||
<span className="text-sm text-muted-foreground">{v.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{headerError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{!requisitionId && (
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lines.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-72 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
|
||||
{!requisitionId && <TableHead className="h-12 w-10 px-3" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const item = itemFor(line.itemId)
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{requisitionId ? (
|
||||
<div className="flex h-11 items-center text-base">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
{!requisitionId && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/procurement/rfqs" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Sending…" : "Create RFQ"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewRfqPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<NewRfqContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
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"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { RfqStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
export default function RfqsListPage() {
|
||||
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })])
|
||||
.then(([r, v]) => {
|
||||
setRfqs(r.items)
|
||||
setVendors(v.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
function vendorNames(vendorIds: number[]) {
|
||||
return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">RFQs</h1>
|
||||
<p className="text-base text-muted-foreground">Request quotations from vendors and compare pricing (FR-PROC-02).</p>
|
||||
</div>
|
||||
<Link href="/dashboard/procurement/rfqs/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New RFQ
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && rfqs === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rfqs !== null && rfqs.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<FileText className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No RFQs yet.</p>
|
||||
<Link href="/dashboard/procurement/rfqs/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New RFQ
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rfqs !== null && rfqs.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Requisition</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Vendors invited</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rfqs.map((r) => (
|
||||
<TableRow key={r.rfqId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/rfqs/${r.rfqId}`} className="font-medium text-foreground hover:underline">
|
||||
{r.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.requisitionId ? `#${r.requisitionId}` : <span className="text-muted-foreground">—</span>}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorNames(r.vendorIds)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<RfqStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface ReorderDraft {
|
||||
key: string
|
||||
warehouseId: number | null
|
||||
reorderPoint: string
|
||||
reorderQty: string
|
||||
}
|
||||
|
||||
interface ConversionDraft {
|
||||
key: string
|
||||
fromUom: number | null
|
||||
toUom: number | null
|
||||
factor: string
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function newKey() {
|
||||
keySeq += 1
|
||||
return `row-${keySeq}`
|
||||
}
|
||||
|
||||
export default function ItemDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const itemId = Number(params.id)
|
||||
|
||||
const [item, setItem] = useState<Item | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
|
||||
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
|
||||
const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[]>([])
|
||||
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
// Basic info form
|
||||
const [sku, setSku] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||
const [itemType, setItemType] = useState<ItemType>("Stocked")
|
||||
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
||||
const [taxClass, setTaxClass] = useState("")
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [conflict, setConflict] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||
|
||||
// Reorder settings
|
||||
const [reorderLines, setReorderLines] = useState<ReorderDraft[]>([])
|
||||
const [reorderErrors, setReorderErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [reorderSaveError, setReorderSaveError] = useState<string | null>(null)
|
||||
const [savingReorder, setSavingReorder] = useState(false)
|
||||
|
||||
// UOM conversions
|
||||
const [conversionLines, setConversionLines] = useState<ConversionDraft[]>([])
|
||||
const [conversionErrors, setConversionErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [conversionSaveError, setConversionSaveError] = useState<string | null>(null)
|
||||
const [savingConversions, setSavingConversions] = useState(false)
|
||||
|
||||
function applyItem(data: Item) {
|
||||
setItem(data)
|
||||
setSku(data.sku)
|
||||
setName(data.name)
|
||||
setDescription(data.description ?? "")
|
||||
setCategoryId(data.categoryId)
|
||||
setBaseUomId(data.baseUomId)
|
||||
setDefaultVendorId(data.defaultVendorId)
|
||||
setItemType(data.itemType)
|
||||
setTrackingMode(data.trackingMode)
|
||||
setTaxClass(data.taxClass ?? "")
|
||||
setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) })))
|
||||
setConversionLines(data.conversions.map((c): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) })))
|
||||
}
|
||||
|
||||
function load() {
|
||||
setLoadError(null)
|
||||
itemsApi
|
||||
.get(itemId)
|
||||
.then(({ data, etag: tag }) => {
|
||||
applyItem(data)
|
||||
setEtag(tag)
|
||||
setConflict(false)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(itemId)) return
|
||||
load()
|
||||
Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
.then(([cat, uo, ve, wh]) => {
|
||||
setCategories(cat.items)
|
||||
setUoms(uo.items)
|
||||
setVendors(ve.items)
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemId])
|
||||
|
||||
async function handleSave() {
|
||||
if (!item || !etag) return
|
||||
setSaveError(null)
|
||||
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId })
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await itemsApi.update(
|
||||
item.itemId,
|
||||
{ sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null },
|
||||
etag
|
||||
)
|
||||
applyItem(result.data)
|
||||
setEtag(result.etag)
|
||||
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : (err as { code?: string })?.code
|
||||
if (code === "CONCURRENCY_CONFLICT") {
|
||||
setConflict(true)
|
||||
setSaveError(errorMessage(err))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku }))
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not save item", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus() {
|
||||
if (!item) return
|
||||
const next = item.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingStatus(true)
|
||||
try {
|
||||
await itemsApi.updateStatus(item.itemId, next)
|
||||
toast.success(next === "Active" ? "Item activated" : "Item deactivated")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
function updateReorderLine(key: string, patch: Partial<ReorderDraft>) {
|
||||
setReorderLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
function removeReorderLine(key: string) {
|
||||
setReorderLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
async function handleSaveReorder() {
|
||||
if (!item) return
|
||||
setReorderSaveError(null)
|
||||
const nextErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of reorderLines) {
|
||||
const errs = validateReorderLine({ warehouseId: line.warehouseId, reorderPoint: line.reorderPoint, reorderQty: line.reorderQty })
|
||||
if (Object.keys(errs).length > 0) nextErrors[line.key] = errs
|
||||
}
|
||||
setReorderErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setReorderSaveError("Fix the highlighted rows before saving.")
|
||||
return
|
||||
}
|
||||
|
||||
const settings: ItemReorderSetting[] = reorderLines.map((l) => ({
|
||||
warehouseId: l.warehouseId as number,
|
||||
reorderPoint: Number(l.reorderPoint),
|
||||
reorderQty: Number(l.reorderQty),
|
||||
}))
|
||||
|
||||
setSavingReorder(true)
|
||||
try {
|
||||
const result = await itemsApi.updateReorder(item.itemId, { settings })
|
||||
setItem((prev) => (prev ? { ...prev, reorder: result.settings } : prev))
|
||||
toast.success("Reorder settings saved")
|
||||
} catch (err) {
|
||||
setReorderSaveError(errorMessage(err))
|
||||
toast.error("Could not save reorder settings", errorMessage(err))
|
||||
} finally {
|
||||
setSavingReorder(false)
|
||||
}
|
||||
}
|
||||
|
||||
function updateConversionLine(key: string, patch: Partial<ConversionDraft>) {
|
||||
setConversionLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
function removeConversionLine(key: string) {
|
||||
setConversionLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
async function handleSaveConversions() {
|
||||
if (!item) return
|
||||
setConversionSaveError(null)
|
||||
const nextErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of conversionLines) {
|
||||
const errs = validateConversionLine({ fromUom: line.fromUom, toUom: line.toUom, factor: line.factor })
|
||||
if (Object.keys(errs).length > 0) nextErrors[line.key] = errs
|
||||
}
|
||||
setConversionErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setConversionSaveError("Fix the highlighted rows before saving.")
|
||||
return
|
||||
}
|
||||
|
||||
const conversions = conversionLines.map((l) => ({ fromUom: l.fromUom as number, toUom: l.toUom as number, factor: Number(l.factor) }))
|
||||
|
||||
setSavingConversions(true)
|
||||
try {
|
||||
const result = await itemsApi.updateUomConversions(item.itemId, { conversions })
|
||||
setItem((prev) => (prev ? { ...prev, conversions: result.conversions } : prev))
|
||||
setConversionLines(result.conversions.map((c: UomConversion): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) })))
|
||||
toast.success("UOM conversions saved")
|
||||
} catch (err) {
|
||||
setConversionSaveError(errorMessage(err))
|
||||
toast.error("Could not save UOM conversions", errorMessage(err))
|
||||
} finally {
|
||||
setSavingConversions(false)
|
||||
}
|
||||
}
|
||||
|
||||
function uomName(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
}
|
||||
|
||||
if (loadError && !item) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Back to items
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{item.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="lg" variant={item.status === "Active" ? "destructive" : "success"} onClick={handleToggleStatus} disabled={togglingStatus}>
|
||||
{togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>{saveError ?? "This item was changed by someone else."} Reload before retrying.</p>
|
||||
<Button size="sm" variant="outline" onClick={load}>
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && !conflict && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<h2 className="text-base font-semibold text-foreground">Basic info</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">SKU</Label>
|
||||
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label className="text-base">Description</Label>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}>
|
||||
<SelectValue placeholder="Select base UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Default vendor</Label>
|
||||
<Select<number | null> value={defaultVendorId} onValueChange={setDefaultVendorId} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendors.map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code} — {v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax class</Label>
|
||||
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Item type</Label>
|
||||
<Select<ItemType> value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem>
|
||||
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem>
|
||||
<SelectItem value="Service" className="text-base">Service</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tracking mode</Label>
|
||||
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="None" className="text-base">None</SelectItem>
|
||||
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
|
||||
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/products")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Reorder settings</h2>
|
||||
<p className="text-sm text-muted-foreground">Per-warehouse reorder point and quantity (FR-MD-05).</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => setReorderLines((prev) => [...prev, { key: newKey(), warehouseId: null, reorderPoint: "", reorderQty: "" }])}>
|
||||
<Plus className="size-5" />
|
||||
Add row
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{reorderLines.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reorder point</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reorder qty</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reorderLines.map((line) => {
|
||||
const errs = reorderErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateReorderLine(line.key, { warehouseId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.warehouseId}>
|
||||
<SelectValue placeholder="Warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errs.warehouseId ? { message: errs.warehouseId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input type="number" min="0" step="any" value={line.reorderPoint} aria-invalid={!!errs.reorderPoint} onChange={(e) => updateReorderLine(line.key, { reorderPoint: e.target.value })} className="h-11 text-base" />
|
||||
<FieldError errors={[errs.reorderPoint ? { message: errs.reorderPoint } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input type="number" min="0" step="any" value={line.reorderQty} aria-invalid={!!errs.reorderQty} onChange={(e) => updateReorderLine(line.key, { reorderQty: e.target.value })} className="h-11 text-base" />
|
||||
<FieldError errors={[errs.reorderQty ? { message: errs.reorderQty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeReorderLine(line.key)} aria-label="Remove row">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{reorderSaveError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{reorderSaveError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={handleSaveReorder} disabled={savingReorder}>
|
||||
{savingReorder ? "Saving…" : "Save reorder settings"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">UOM conversions</h2>
|
||||
<p className="text-sm text-muted-foreground">Purchase/stock UOM → base UOM conversion factors (FR-MD-02/03).</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => setConversionLines((prev) => [...prev, { key: newKey(), fromUom: null, toUom: item.baseUomId, factor: "" }])}>
|
||||
<Plus className="size-5" />
|
||||
Add row
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conversionLines.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">From UOM</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">To UOM</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Factor</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{conversionLines.map((line) => {
|
||||
const errs = conversionErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.fromUom} onValueChange={(v) => updateConversionLine(line.key, { fromUom: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.fromUom}>
|
||||
<SelectValue placeholder="From" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errs.fromUom ? { message: errs.fromUom } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.toUom} onValueChange={(v) => updateConversionLine(line.key, { toUom: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.toUom}>
|
||||
<SelectValue placeholder="To" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errs.toUom ? { message: errs.toUom } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input type="number" min="0" step="any" value={line.factor} aria-invalid={!!errs.factor} onChange={(e) => updateConversionLine(line.key, { factor: e.target.value })} className="h-11 text-base" />
|
||||
<FieldError errors={[errs.factor ? { message: errs.factor } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeConversionLine(line.key)} aria-label="Remove row">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{conversionSaveError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{conversionSaveError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={handleSaveConversions} disabled={savingConversions}>
|
||||
{savingConversions ? "Saving…" : "Save conversions"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03). {warehouses.length === 0 && "No warehouses configured yet."}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ListTree, Plus } from "lucide-react"
|
||||
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Category, CategoryTreeNode } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
function TreeNode({ node, depth }: { node: CategoryTreeNode; depth: number }) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2.5 hover:bg-muted/50"
|
||||
style={{ paddingLeft: `${depth * 24 + 12}px` }}
|
||||
>
|
||||
<ListTree className="size-4 text-muted-foreground" />
|
||||
<span className="text-base font-medium text-foreground">{node.name}</span>
|
||||
<span className="text-sm text-muted-foreground">#{node.categoryId}</span>
|
||||
</div>
|
||||
{node.children.map((child) => (
|
||||
<TreeNode key={child.categoryId} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [tree, setTree] = useState<CategoryTreeNode[] | null>(null)
|
||||
const [flat, setFlat] = useState<Category[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [parentId, setParentId] = useState<number | null>(null)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
Promise.all([categoriesApi.tree(), categoriesApi.list()])
|
||||
.then(([t, f]) => {
|
||||
setTree(t)
|
||||
setFlat(f.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors = validateCategoryName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const category = await categoriesApi.create({ name, parentId })
|
||||
toast.success("Category created", category.name)
|
||||
setOpen(false)
|
||||
setName("")
|
||||
setParentId(null)
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
setErrors({ name: errorMessage(err) })
|
||||
toast.error("Could not create category", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
||||
<p className="text-base text-muted-foreground">Hierarchical item category structure (FR-MD-04).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Category</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New category</DialogTitle>
|
||||
<DialogDescription>Optionally nest it under an existing category.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="cat-name">Name</FieldLabel>
|
||||
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cat-parent">Parent (optional)</FieldLabel>
|
||||
<Select<number | null> value={parentId} onValueChange={setParentId}>
|
||||
<SelectTrigger id="cat-parent" className="w-full">
|
||||
<SelectValue placeholder="None — top-level category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{flat.map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && tree === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && tree !== null && tree.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ListTree className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No categories yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && tree !== null && tree.length > 0 && (
|
||||
<div className="flex flex-col rounded-xl border p-3">
|
||||
{tree.map((node) => (
|
||||
<TreeNode key={node.categoryId} node={node} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateItemForm } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ItemType, TrackingMode } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function NewItemPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [categories, setCategories] = useState<{ categoryId: number; name: string }[] | null>(null)
|
||||
const [uoms, setUoms] = useState<{ uomId: number; name: string }[] | null>(null)
|
||||
const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [sku, setSku] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||
const [itemType, setItemType] = useState<ItemType>("Stocked")
|
||||
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
||||
const [taxClass, setTaxClass] = useState("STD")
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200, status: "Active" })])
|
||||
.then(([cat, uo, ve]) => {
|
||||
setCategories(cat.items)
|
||||
setUoms(uo.items)
|
||||
setVendors(ve.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId })
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const { data: item } = await itemsApi.create({
|
||||
sku,
|
||||
name,
|
||||
description: description || null,
|
||||
categoryId: categoryId as number,
|
||||
baseUomId: baseUomId as number,
|
||||
defaultVendorId,
|
||||
itemType,
|
||||
trackingMode,
|
||||
taxClass: taxClass || null,
|
||||
})
|
||||
toast.success("Item created", `${item.sku} — ${item.name}`)
|
||||
router.push(`/dashboard/products/${item.itemId}`)
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku }))
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not create item", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loading = !categories || !uoms || !vendors
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
|
||||
<p className="text-base text-muted-foreground">SKU, category, base UOM, item type, and tracking mode (FR-MD-01).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-64 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">SKU</Label>
|
||||
<Input value={sku} onChange={(e) => setSku(e.target.value)} placeholder="ITM-1004" aria-invalid={!!errors.sku} className="h-12 text-base" />
|
||||
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Steel Washer M8" aria-invalid={!!errors.name} className="h-12 text-base" />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label className="text-base">Description (optional)</Label>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} className="h-12 text-base" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<Select<number | null> value={categoryId} onValueChange={setCategoryId}>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categories ?? []).map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId}>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}>
|
||||
<SelectValue placeholder="Select base UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(uoms ?? []).map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Default vendor (optional)</Label>
|
||||
<Select<number | null> value={defaultVendorId} onValueChange={setDefaultVendorId}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code} — {v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax class (optional)</Label>
|
||||
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} placeholder="STD" className="h-12 text-base" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Item type</Label>
|
||||
<Select<ItemType> value={itemType} onValueChange={(v) => v && setItemType(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem>
|
||||
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem>
|
||||
<SelectItem value="Service" className="text-base">Service</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tracking mode</Label>
|
||||
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="None" className="text-base">None</SelectItem>
|
||||
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
|
||||
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create Item"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,250 @@
|
||||
export default function ProductsPage() {
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Category, ItemListItem, TrackingMode } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
type TrackingFilter = TrackingMode | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function ItemsPage() {
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [categoryId, setCategoryId] = useState<number | "All">("All")
|
||||
const [trackingMode, setTrackingMode] = useState<TrackingFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query, status, categoryId, trackingMode])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
itemsApi
|
||||
.list({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
categoryId: categoryId === "All" ? undefined : categoryId,
|
||||
trackingMode: trackingMode === "All" ? undefined : trackingMode,
|
||||
})
|
||||
.then((res) => {
|
||||
setItems(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page, query, status, categoryId, trackingMode])
|
||||
useEffect(() => {
|
||||
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
function categoryName(id: number) {
|
||||
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All"
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Products</h1>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
||||
<p className="text-base text-muted-foreground">Item master — SKU, tracking mode, category, default vendor (FR-MD-01).</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Ruler className="size-5" />
|
||||
UOMs
|
||||
</Link>
|
||||
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<ListTree className="size-5" />
|
||||
Categories
|
||||
</Link>
|
||||
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Item
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search SKU or name…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search items"
|
||||
/>
|
||||
</div>
|
||||
<Select<number | "All"> value={categoryId} onValueChange={(v) => setCategoryId(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All categories</SelectItem>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<TrackingFilter> value={trackingMode} onValueChange={(v) => setTrackingMode(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue placeholder="All tracking modes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All tracking modes</SelectItem>
|
||||
<SelectItem value="None" className="text-base">None</SelectItem>
|
||||
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
|
||||
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && items === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Package className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p>
|
||||
{!hasFilters && (
|
||||
<Link href="/dashboard/products/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Item
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">SKU</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Category</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Tracking</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.itemId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">
|
||||
<Link href={`/dashboard/products/${item.itemId}`} className="hover:underline">
|
||||
{item.sku}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item.itemType}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item.trackingMode}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link
|
||||
href={`/dashboard/products/${item.itemId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${item.sku}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Ruler } from "lucide-react"
|
||||
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateUomName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Uom } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function UomsPage() {
|
||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
function load() {
|
||||
uomsApi.list().then((res) => setUoms(res.items)).catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors = validateUomName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const uom = await uomsApi.create({ name })
|
||||
toast.success("UOM created", uom.name)
|
||||
setOpen(false)
|
||||
setName("")
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.name) setErrors({ name: fe.name })
|
||||
toast.error("Could not create UOM", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1>
|
||||
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New UOM</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New UOM</DialogTitle>
|
||||
<DialogDescription>e.g. EA, KG, Box-12.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="uom-name">Name</FieldLabel>
|
||||
<Input id="uom-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Box-12" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && uoms === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && uoms !== null && uoms.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Ruler className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No UOMs yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && uoms !== null && uoms.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{uoms.map((u) => (
|
||||
<TableRow key={u.uomId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{u.name}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateLine, splitSerials } from "@/lib/validations/grn"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateGrnLineInput, Grn, HoldStatus } from "@/types/grn"
|
||||
import { Bin, ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
poLineId: number | null
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
binId: number | null
|
||||
qty: string
|
||||
unitCost: string
|
||||
holdStatus: HoldStatus
|
||||
batchNo: string
|
||||
expiryDate: string
|
||||
serialNumbersText: string
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function newKey() {
|
||||
keySeq += 1
|
||||
return `egline-${keySeq}`
|
||||
}
|
||||
|
||||
function emptyLine(): DraftLine {
|
||||
return {
|
||||
key: newKey(),
|
||||
poLineId: null,
|
||||
itemId: null,
|
||||
uomId: null,
|
||||
binId: null,
|
||||
qty: "",
|
||||
unitCost: "",
|
||||
holdStatus: "Available",
|
||||
batchNo: "",
|
||||
expiryDate: "",
|
||||
serialNumbersText: "",
|
||||
}
|
||||
}
|
||||
|
||||
export default function EditGrnPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const grnId = Number(params.id)
|
||||
|
||||
const [grn, setGrn] = useState<Grn | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||
const [bins, setBins] = useState<Bin[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [lines, setLines] = useState<DraftLine[]>([])
|
||||
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(grnId)) return
|
||||
Promise.all([
|
||||
grnsApi.get(grnId),
|
||||
warehousesApi.list(),
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list(),
|
||||
])
|
||||
.then(([g, wh, it, uo]) => {
|
||||
if (g.status !== "Draft") {
|
||||
setLoadError(`${g.docNo} is ${g.status.toLowerCase()} and can no longer be edited.`)
|
||||
setGrn(g)
|
||||
return
|
||||
}
|
||||
setGrn(g)
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
setWarehouseId(g.warehouseId)
|
||||
setLines(
|
||||
g.lines.map(
|
||||
(l): DraftLine => ({
|
||||
key: newKey(),
|
||||
poLineId: l.poLineId,
|
||||
itemId: l.itemId,
|
||||
uomId: l.uomId,
|
||||
binId: l.binId,
|
||||
qty: String(l.qty),
|
||||
unitCost: String(l.unitCost),
|
||||
holdStatus: l.holdStatus,
|
||||
batchNo: "",
|
||||
expiryDate: "",
|
||||
serialNumbersText: "",
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [grnId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!warehouseId) {
|
||||
setBins([])
|
||||
return
|
||||
}
|
||||
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
|
||||
}, [warehouseId])
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
function itemFor(itemId: number | null) {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!grn) return
|
||||
setSubmitError(null)
|
||||
setHeaderError(null)
|
||||
|
||||
if (!warehouseId) {
|
||||
setHeaderError("Select a warehouse.")
|
||||
return
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
setSubmitError("Add at least one line.")
|
||||
return
|
||||
}
|
||||
|
||||
const nextLineErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of lines) {
|
||||
const errors = validateLine({
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
|
||||
batchNo: line.batchNo,
|
||||
serialNumbersText: line.serialNumbersText,
|
||||
})
|
||||
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
||||
}
|
||||
setLineErrors(nextLineErrors)
|
||||
if (Object.keys(nextLineErrors).length > 0) {
|
||||
setSubmitError("Fix the highlighted lines before submitting.")
|
||||
return
|
||||
}
|
||||
|
||||
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
|
||||
const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None"
|
||||
return {
|
||||
poLineId: l.poLineId,
|
||||
itemId: l.itemId as number,
|
||||
uomId: l.uomId as number,
|
||||
binId: l.binId,
|
||||
qty: Number(l.qty),
|
||||
unitCost: Number(l.unitCost),
|
||||
holdStatus: l.holdStatus,
|
||||
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
|
||||
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
|
||||
}
|
||||
})
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updated = await grnsApi.update(grn.grnId, {
|
||||
poId: grn.poId,
|
||||
vendorId: grn.vendorId,
|
||||
warehouseId: warehouseId as number,
|
||||
lines: payloadLines,
|
||||
})
|
||||
toast.success("GRN updated", `${updated.docNo} saved.`)
|
||||
router.push(`/dashboard/receiving/grn/${updated.grnId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not update GRN", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={grn ? `/dashboard/receiving/grn/${grn.grnId}` : "/dashboard/receiving/grn"}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-foreground">Edit GRN</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const loading = !grn || !warehouses || !items || !uoms
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={grn ? `/dashboard/receiving/grn/${grn.grnId}` : "/dashboard/receiving/grn"}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Edit {grn?.docNo ?? "GRN"}</h1>
|
||||
<p className="text-base text-muted-foreground">Only Draft GRNs can be edited — confirming posts stock layers permanently.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <Skeleton className="h-12 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{headerError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{lines.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-36 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-48 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(uoms ?? []).map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitCost}
|
||||
aria-invalid={!!errors.unitCost}
|
||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<HoldStatus> value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
||||
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{item?.trackingMode === "Batch" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Input
|
||||
placeholder="Batch no."
|
||||
value={line.batchNo}
|
||||
aria-invalid={!!errors.batchNo}
|
||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={line.expiryDate}
|
||||
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{item?.trackingMode === "Serial" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<textarea
|
||||
placeholder="One serial per line"
|
||||
value={line.serialNumbersText}
|
||||
aria-invalid={!!errors.serialNumbers}
|
||||
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
|
||||
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{(!item || item.trackingMode === "None") && (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link
|
||||
href={`/dashboard/receiving/grn/${grnId}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ConfirmGrnResponse, Grn } from "@/types/grn"
|
||||
import { Bin, ItemListItem, Uom } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
|
||||
|
||||
export default function GrnDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const grnId = Number(params.id)
|
||||
|
||||
const [grn, setGrn] = useState<Grn | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [bins, setBins] = useState<Bin[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [confirmResult, setConfirmResult] = useState<ConfirmGrnResponse | null>(null)
|
||||
const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
|
||||
|
||||
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
|
||||
const idempotencyKey = useRef(crypto.randomUUID())
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(grnId)) return
|
||||
grnsApi.get(grnId).then(setGrn).catch((err) => setError(errorMessage(err)))
|
||||
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list()])
|
||||
.then(([it, uo]) => {
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [grnId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!grn) return
|
||||
warehousesApi.listBins(grn.warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
|
||||
}, [grn?.warehouseId])
|
||||
|
||||
function itemFor(itemId: number) {
|
||||
return items.find((i) => i.itemId === itemId)
|
||||
}
|
||||
function uomFor(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
}
|
||||
function binFor(binId: number | null) {
|
||||
if (!binId) return "—"
|
||||
return bins.find((b) => b.binId === binId)?.code ?? `#${binId}`
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!grn) return
|
||||
setError(null)
|
||||
setConfirming(true)
|
||||
try {
|
||||
const result = await grnsApi.confirm(grn.grnId, idempotencyKey.current)
|
||||
setConfirmResult(result)
|
||||
setGrn((prev) => (prev ? { ...prev, status: result.status } : prev))
|
||||
toast.success("GRN confirmed", `${result.createdLayers.length} layer(s) posted to stock.`)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
toast.error("Could not confirm GRN", errorMessage(err))
|
||||
} finally {
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRelease(grnLineId: number, action: "Release" | "Reject") {
|
||||
if (!grn) return
|
||||
setError(null)
|
||||
setReleasingLineId(grnLineId)
|
||||
try {
|
||||
const result = await grnsApi.releaseLine(grn.grnId, grnLineId, action)
|
||||
setGrn((prev) =>
|
||||
prev
|
||||
? { ...prev, lines: prev.lines.map((l) => (l.grnLineId === grnLineId ? { ...l, holdStatus: result.holdStatus } : l)) }
|
||||
: prev
|
||||
)
|
||||
toast.success(action === "Release" ? "Line released" : "Line rejected", `Hold status is now ${result.holdStatus}.`)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
toast.error("Could not update line", errorMessage(err))
|
||||
} finally {
|
||||
setReleasingLineId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !grn) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
if (!grn) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
|
||||
<GrnStatusBadge status={grn.status} />
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{grn.poId ? `Against PO #${grn.poId}` : "Direct receipt"} — Vendor #{grn.vendorId} — Warehouse #{grn.warehouseId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{grn.status === "Draft" && (
|
||||
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
||||
<PackageCheck className="size-5" />
|
||||
{confirming ? "Confirming…" : "Confirm GRN"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{confirmResult && (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="size-6" />
|
||||
<p className="text-base font-semibold">Confirmed — stock layers created (FR-GRN-06)</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Layers: {confirmResult.createdLayers.map((l) => `#${l.layerId} (${l.qtyReceived} @ ${l.unitCost})`).join(", ")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {confirmResult.ledgerRefs.join(", ")}</div>
|
||||
{confirmResult.poStatus && <div className="text-sm text-muted-foreground">PO status: {confirmResult.poStatus}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received value</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
|
||||
{grn.status === "Confirmed" && <TableHead className="h-12 px-3 text-sm">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{grn.lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
return (
|
||||
<TableRow key={line.grnLineId}>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{uomFor(line.uomId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.unitCost.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.receivedValue.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<HoldStatusBadge status={line.holdStatus} />
|
||||
</TableCell>
|
||||
{grn.status === "Confirmed" && (
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{line.holdStatus === "OnHold" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="success"
|
||||
size="sm"
|
||||
disabled={releasingLineId === line.grnLineId}
|
||||
onClick={() => handleRelease(line.grnLineId, "Release")}
|
||||
>
|
||||
<ShieldAlert className="size-4" />
|
||||
Release
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={releasingLineId === line.grnLineId}
|
||||
onClick={() => handleRelease(line.grnLineId, "Reject")}
|
||||
>
|
||||
<XCircle className="size-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
) : line.holdStatus === "Rejected" ? (
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-returns/new?grnId=${grn.grnId}&grnLineId=${line.grnLineId}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
<PackageX className="size-4" />
|
||||
Create Return
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type Mode = "po" | "direct"
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
poLineId: number | null
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
binId: number | null
|
||||
qty: string
|
||||
unitCost: string
|
||||
holdStatus: HoldStatus
|
||||
batchNo: string
|
||||
expiryDate: string
|
||||
serialNumbersText: string
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function newKey() {
|
||||
keySeq += 1
|
||||
return `gline-${keySeq}`
|
||||
}
|
||||
|
||||
function emptyLine(): DraftLine {
|
||||
return {
|
||||
key: newKey(),
|
||||
poLineId: null,
|
||||
itemId: null,
|
||||
uomId: null,
|
||||
binId: null,
|
||||
qty: "",
|
||||
unitCost: "",
|
||||
holdStatus: "Available",
|
||||
batchNo: "",
|
||||
expiryDate: "",
|
||||
serialNumbersText: "",
|
||||
}
|
||||
}
|
||||
|
||||
export default function NewGrnPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [mode, setMode] = useState<Mode>("po")
|
||||
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrderSummary[] | null>(null)
|
||||
const [bins, setBins] = useState<Bin[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [vendorId, setVendorId] = useState<number | null>(null)
|
||||
const [poId, setPoId] = useState<number | null>(null)
|
||||
const [poLoading, setPoLoading] = useState(false)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
warehousesApi.list(),
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list(),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([wh, it, uo, ve, po]) => {
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
setVendors(ve.items)
|
||||
setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived"))
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!warehouseId) {
|
||||
setBins([])
|
||||
return
|
||||
}
|
||||
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
|
||||
}, [warehouseId])
|
||||
|
||||
function switchMode(next: Mode) {
|
||||
setMode(next)
|
||||
setPoId(null)
|
||||
setVendorId(null)
|
||||
setLines([emptyLine()])
|
||||
setHeaderError(null)
|
||||
setLineErrors({})
|
||||
}
|
||||
|
||||
async function handleSelectPo(nextPoId: number | null) {
|
||||
setPoId(nextPoId)
|
||||
if (!nextPoId) {
|
||||
setLines([emptyLine()])
|
||||
return
|
||||
}
|
||||
|
||||
setPoLoading(true)
|
||||
setHeaderError(null)
|
||||
try {
|
||||
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
|
||||
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
|
||||
if (openLines.length === 0) {
|
||||
setHeaderError("This purchase order has no open (unreceived) lines.")
|
||||
setLines([])
|
||||
return
|
||||
}
|
||||
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
setLines(
|
||||
openLines.map(
|
||||
(l): DraftLine => ({
|
||||
key: newKey(),
|
||||
poLineId: l.poLineId,
|
||||
itemId: l.itemId,
|
||||
uomId: l.uomId,
|
||||
binId: null,
|
||||
qty: String(l.qty - l.qtyReceived),
|
||||
unitCost: String(l.unitPrice),
|
||||
holdStatus: "Available",
|
||||
batchNo: "",
|
||||
expiryDate: "",
|
||||
serialNumbersText: "",
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setHeaderError(errorMessage(err))
|
||||
} finally {
|
||||
setPoLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
function itemFor(itemId: number | null) {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
setHeaderError(null)
|
||||
|
||||
const header = grnHeaderSchema.safeParse({
|
||||
warehouseId: warehouseId ?? 0,
|
||||
vendorId: mode === "direct" ? vendorId : null,
|
||||
poId: mode === "po" ? poId : null,
|
||||
})
|
||||
if (!header.success) {
|
||||
setHeaderError(header.error.issues[0]?.message ?? "Check the header fields.")
|
||||
return
|
||||
}
|
||||
if (mode === "direct" && !vendorId) {
|
||||
setHeaderError("Select a vendor for a direct receipt.")
|
||||
return
|
||||
}
|
||||
if (mode === "po" && !poId) {
|
||||
setHeaderError("Select a purchase order.")
|
||||
return
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
setSubmitError("Add at least one line.")
|
||||
return
|
||||
}
|
||||
|
||||
const nextLineErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of lines) {
|
||||
const errors = validateLine({
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
|
||||
batchNo: line.batchNo,
|
||||
serialNumbersText: line.serialNumbersText,
|
||||
})
|
||||
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
||||
}
|
||||
setLineErrors(nextLineErrors)
|
||||
if (Object.keys(nextLineErrors).length > 0) {
|
||||
setSubmitError("Fix the highlighted lines before submitting.")
|
||||
return
|
||||
}
|
||||
|
||||
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
|
||||
const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None"
|
||||
return {
|
||||
poLineId: l.poLineId,
|
||||
itemId: l.itemId as number,
|
||||
uomId: l.uomId as number,
|
||||
binId: l.binId,
|
||||
qty: Number(l.qty),
|
||||
unitCost: Number(l.unitCost),
|
||||
holdStatus: l.holdStatus,
|
||||
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
|
||||
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
|
||||
}
|
||||
})
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const grn = await grnsApi.create({
|
||||
poId: mode === "po" ? poId : null,
|
||||
vendorId: mode === "direct" ? vendorId : null,
|
||||
warehouseId: warehouseId as number,
|
||||
lines: payloadLines,
|
||||
})
|
||||
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
|
||||
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not create GRN", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loading = !warehouses || !items || !uoms || !vendors || !purchaseOrders
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New GRN</h1>
|
||||
<p className="text-base text-muted-foreground">Receive goods against a purchase order, or record a direct receipt (FR-GRN-01/02).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-12 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="flex w-fit gap-2 rounded-full border border-input bg-muted/40 p-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={mode === "po" ? "default" : "ghost"}
|
||||
className="rounded-full"
|
||||
onClick={() => switchMode("po")}
|
||||
>
|
||||
Against PO
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={mode === "direct" ? "default" : "ghost"}
|
||||
className="rounded-full"
|
||||
onClick={() => switchMode("direct")}
|
||||
>
|
||||
Direct receipt
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{mode === "po" && (
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label className="text-base">Purchase order</Label>
|
||||
<Select<number | null> value={poId} onValueChange={(v) => handleSelectPo(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select an approved/partially received PO" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(purchaseOrders ?? []).map((p) => (
|
||||
<SelectItem key={p.poId} value={p.poId} className="text-base">
|
||||
{p.docNo} — Vendor #{p.vendorId} ({p.status})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "direct" && (
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label className="text-base">Vendor</Label>
|
||||
<Select<number | null> value={vendorId} onValueChange={(v) => setVendorId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code} — {v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{headerError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{mode === "direct" && (
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{poLoading && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-36 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-48 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(uoms ?? []).map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitCost}
|
||||
aria-invalid={!!errors.unitCost}
|
||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<HoldStatus>
|
||||
value={line.holdStatus}
|
||||
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
||||
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{item?.trackingMode === "Batch" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Input
|
||||
placeholder="Batch no."
|
||||
value={line.batchNo}
|
||||
aria-invalid={!!errors.batchNo}
|
||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={line.expiryDate}
|
||||
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{item?.trackingMode === "Serial" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<textarea
|
||||
placeholder="One serial per line"
|
||||
value={line.serialNumbersText}
|
||||
aria-invalid={!!errors.serialNumbers}
|
||||
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
|
||||
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{(!item || item.trackingMode === "None") && (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create GRN"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Pencil, Plus, Search, Trash2 } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { GrnStatus, GrnSummary } from "@/types/grn"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||
|
||||
type StatusFilter = GrnStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 5
|
||||
|
||||
export default function GrnListPage() {
|
||||
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
|
||||
// Debounce the search box so typing doesn't refetch on every keystroke.
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
// Any filter change starts back at page 1.
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [query, status])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
grnsApi
|
||||
.list({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
})
|
||||
.then((res) => {
|
||||
setGrns(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page, query, status])
|
||||
|
||||
async function handleDelete(grn: GrnSummary) {
|
||||
setDeletingId(grn.grnId)
|
||||
try {
|
||||
await grnsApi.remove(grn.grnId)
|
||||
toast.success("GRN deleted", `${grn.docNo} has been removed.`)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete GRN", errorMessage(err))
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Goods Receipt Notes</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Receive goods against a purchase order, or record a direct receipt.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/receiving/grn/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New GRN
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search doc no., PO, vendor, warehouse…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search GRNs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
|
||||
<SelectItem value="Confirmed" className="text-base">Confirmed</SelectItem>
|
||||
<SelectItem value="Closed" className="text-base">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{hasFilters && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearchInput("")
|
||||
setStatus("All")
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && grns === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && grns !== null && grns.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<PackageSearch className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No GRNs match your search/filter." : "No GRNs yet. Create one from an approved purchase order."}
|
||||
</p>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
setSearchInput("")
|
||||
setStatus("All")
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : (
|
||||
<Link href="/dashboard/receiving/grn/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New GRN
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && grns !== null && grns.length > 0 && (
|
||||
<>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">PO</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{grns.map((grn) => {
|
||||
const isDraft = grn.status === "Draft"
|
||||
return (
|
||||
<TableRow key={grn.grnId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link
|
||||
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{grn.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{grn.poId ?? <span className="text-muted-foreground">Direct</span>}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{grn.vendorId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{grn.warehouseId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<GrnStatusBadge status={grn.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${grn.docNo}`}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
|
||||
{isDraft ? (
|
||||
<Link
|
||||
href={`/dashboard/receiving/grn/${grn.grnId}/edit`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${grn.docNo}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled
|
||||
aria-label={`Edit ${grn.docNo} (not editable once ${grn.status.toLowerCase()})`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
aria-label={`Delete ${grn.docNo}`}
|
||||
disabled={!isDraft || deletingId === grn.grnId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${grn.docNo}?`}
|
||||
description="This permanently removes the draft GRN. It has not been confirmed, so no stock layers or ledger entries exist yet."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(grn)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, CheckCircle2, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateAdjustmentLineInput, ReasonCode, StockAdjustment } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
itemId: number | null
|
||||
qtyDelta: string
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function newKey() {
|
||||
keySeq += 1
|
||||
return `aline-${keySeq}`
|
||||
}
|
||||
|
||||
function emptyLine(): DraftLine {
|
||||
return { key: newKey(), itemId: null, qtyDelta: "" }
|
||||
}
|
||||
|
||||
export default function NewAdjustmentPage() {
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [result, setResult] = useState<StockAdjustment | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
|
||||
.then(([wh, it, rc]) => {
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
setReasonCodes(rc.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => (prev.length > 1 ? prev.filter((l) => l.key !== key) : prev))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
|
||||
if (!warehouseId) {
|
||||
setSubmitError("Select a warehouse.")
|
||||
return
|
||||
}
|
||||
if (!reasonCodeId) {
|
||||
setSubmitError("A reason code is required for adjustments.")
|
||||
return
|
||||
}
|
||||
|
||||
const nextLineErrors: Record<string, string> = {}
|
||||
for (const line of lines) {
|
||||
if (!line.itemId) nextLineErrors[line.key] = "Select an item"
|
||||
else if (!line.qtyDelta || Number(line.qtyDelta) === 0) nextLineErrors[line.key] = "Enter a non-zero quantity change"
|
||||
}
|
||||
setLineErrors(nextLineErrors)
|
||||
if (Object.keys(nextLineErrors).length > 0) {
|
||||
setSubmitError("Fix the highlighted lines before submitting.")
|
||||
return
|
||||
}
|
||||
|
||||
const payloadLines: CreateAdjustmentLineInput[] = lines.map((l) => ({
|
||||
itemId: l.itemId as number,
|
||||
qtyDelta: Number(l.qtyDelta),
|
||||
}))
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const adjustment = await stockAdjustmentsApi.create({ warehouseId, reasonCodeId, lines: payloadLines })
|
||||
setResult(adjustment)
|
||||
toast.success("Adjustment posted", `${adjustment.docNo} posted immediately (auto-post, FR-STK-07).`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not post adjustment", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function startAnother() {
|
||||
setResult(null)
|
||||
setLines([emptyLine()])
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
const loading = !warehouses || !items || !reasonCodes
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Adjustment</h1>
|
||||
<p className="text-base text-muted-foreground">Posts immediately on creation (FR-STK-07) — a reason code is mandatory.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-12 w-full" />}
|
||||
|
||||
{!loading && result && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="size-6" />
|
||||
<p className="text-base font-semibold">{result.docNo} posted</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {result.ledgerRefs.join(", ")}</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Back to list
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={startAnother}>
|
||||
New adjustment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !result && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Reason code</Label>
|
||||
<Select<number | null> value={reasonCodeId} onValueChange={(v) => setReasonCodeId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(reasonCodes ?? []).map((r) => (
|
||||
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
|
||||
{r.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-72 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-40 px-3 text-sm">Qty change</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="e.g. -15 or 50"
|
||||
value={line.qtyDelta}
|
||||
aria-invalid={!!lineErrors[line.key]}
|
||||
onChange={(e) => updateLine(line.key, { qtyDelta: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Posting…" : "Post Adjustment"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, SlidersHorizontal } from "lucide-react"
|
||||
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { StockAdjustmentSummary } from "@/types/stock"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { AdjustmentStatusBadge } from "@/components/stock/status-badges"
|
||||
|
||||
export default function AdjustmentsListPage() {
|
||||
const [adjustments, setAdjustments] = useState<StockAdjustmentSummary[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [reasonLabels, setReasonLabels] = useState<Map<number, string>>(new Map())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([stockAdjustmentsApi.list({ pageSize: 50 }), warehousesApi.list(), reasonCodesApi.list("Adjustment")])
|
||||
.then(([a, wh, rc]) => {
|
||||
setAdjustments(a.items)
|
||||
setWarehouses(wh.items)
|
||||
setReasonLabels(new Map(rc.items.map((r) => [r.reasonCodeId, r.description])))
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Adjustments</h1>
|
||||
<p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Adjustment
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && adjustments === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && adjustments !== null && adjustments.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<SlidersHorizontal className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No adjustments yet.</p>
|
||||
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Adjustment
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && adjustments !== null && adjustments.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.map((a) => (
|
||||
<TableRow key={a.adjustmentId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{a.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{whCode(a.warehouseId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<AdjustmentStatusBadge status={a.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(a.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, CheckCircle2, ClipboardCheck, Save } from "lucide-react"
|
||||
|
||||
import { stockCountsApi } from "@/lib/api/stock-counts"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PostCountResponse, StockCount } from "@/types/stock"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { CountStatusBadge } from "@/components/stock/status-badges"
|
||||
|
||||
export default function CountDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const countId = Number(params.id)
|
||||
|
||||
const [count, setCount] = useState<StockCount | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [drafts, setDrafts] = useState<Record<number, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [posting, setPosting] = useState(false)
|
||||
const [postResult, setPostResult] = useState<PostCountResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(countId)) return
|
||||
stockCountsApi
|
||||
.get(countId)
|
||||
.then((c) => {
|
||||
setCount(c)
|
||||
setDrafts(Object.fromEntries(c.lines.map((l) => [l.countLineId, l.countedQty !== null ? String(l.countedQty) : ""])))
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [countId])
|
||||
|
||||
async function handleSave() {
|
||||
if (!count) return
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const lines = Object.entries(drafts)
|
||||
.filter(([, v]) => v !== "")
|
||||
.map(([countLineId, v]) => ({ countLineId: Number(countLineId), countedQty: Number(v) }))
|
||||
const result = await stockCountsApi.enterCounts(countId, { lines })
|
||||
setCount((prev) => (prev ? { ...prev, lines: result.lines } : prev))
|
||||
toast.success("Counts saved", "Variances recalculated.")
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
toast.error("Could not save counts", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePost() {
|
||||
setError(null)
|
||||
setPosting(true)
|
||||
try {
|
||||
const result = await stockCountsApi.post(countId)
|
||||
setPostResult(result)
|
||||
setCount((prev) => (prev ? { ...prev, status: result.status } : prev))
|
||||
toast.success("Count posted", `Variance adjustment ${result.adjustmentId} created.`)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
toast.error("Could not post count", errorMessage(err))
|
||||
} finally {
|
||||
setPosting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !count) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
if (!count) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{count.docNo}</h1>
|
||||
<CountStatusBadge status={count.status} />
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{count.countType} count · Warehouse #{count.warehouseId}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{count.status === "Draft" && (
|
||||
<div className="flex gap-3">
|
||||
<Button size="lg" variant="outline" onClick={handleSave} disabled={saving}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save counts"}
|
||||
</Button>
|
||||
<Button size="lg" onClick={handlePost} disabled={posting}>
|
||||
<ClipboardCheck className="size-5" />
|
||||
{posting ? "Posting…" : "Post count"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{postResult && (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="size-6" />
|
||||
<p className="text-base font-semibold">Posted — variance adjustment #{postResult.adjustmentId} created</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {postResult.ledgerRefs.join(", ") || "none (no variance)"}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">System qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Counted qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Variance</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{count.lines.map((line) => (
|
||||
<TableRow key={line.countLineId}>
|
||||
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.systemQty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{count.status === "Draft" ? (
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={drafts[line.countLineId] ?? ""}
|
||||
onChange={(e) => setDrafts((prev) => ({ ...prev, [line.countLineId]: e.target.value }))}
|
||||
className="h-11 w-32 text-base"
|
||||
/>
|
||||
) : (
|
||||
(line.countedQty ?? "—")
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className={cn("px-3 py-3.5 font-medium", line.variance && line.variance !== 0 && (line.variance > 0 ? "text-success" : "text-destructive"))}>
|
||||
{line.variance ?? "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { stockCountsApi } from "@/lib/api/stock-counts"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CountType } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function NewCountPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [countType, setCountType] = useState<CountType>("Cycle")
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<number>>(new Set())
|
||||
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" })])
|
||||
.then(([wh, it]) => {
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
function toggleItem(itemId: number) {
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(itemId)) next.delete(itemId)
|
||||
else next.add(itemId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
|
||||
if (!warehouseId) {
|
||||
setSubmitError("Select a warehouse.")
|
||||
return
|
||||
}
|
||||
if (selectedItemIds.size === 0) {
|
||||
setSubmitError("Select at least one item to count.")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const count = await stockCountsApi.create({ warehouseId, countType, itemIds: [...selectedItemIds] })
|
||||
toast.success("Count created", `${count.docNo} — system quantities snapshotted.`)
|
||||
router.push(`/dashboard/stock/counts/${count.countId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not create count", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loading = !warehouses || !items
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Count</h1>
|
||||
<p className="text-base text-muted-foreground">System quantities are snapshotted immediately; enter counted quantities next.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-12 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Count type</Label>
|
||||
<Select<CountType> value={countType} onValueChange={(v) => setCountType(v ?? "Cycle")}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Cycle" className="text-base">Cycle (partial, scheduled)</SelectItem>
|
||||
<SelectItem value="Full" className="text-base">Full physical count</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label className="text-base">Items to count</Label>
|
||||
<div className="flex flex-col gap-1 rounded-xl border p-3">
|
||||
{(items ?? []).map((item) => (
|
||||
<label key={item.itemId} className="flex items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-muted/50">
|
||||
<Checkbox checked={selectedItemIds.has(item.itemId)} onCheckedChange={() => toggleItem(item.itemId)} />
|
||||
<span className="text-base font-medium">{item.sku}</span>
|
||||
<span className="text-sm text-muted-foreground">{item.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create Count"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ClipboardList, Plus } from "lucide-react"
|
||||
|
||||
import { stockCountsApi } from "@/lib/api/stock-counts"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { StockCountSummary } from "@/types/stock"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { CountStatusBadge } from "@/components/stock/status-badges"
|
||||
|
||||
export default function CountsListPage() {
|
||||
const [counts, setCounts] = useState<StockCountSummary[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([stockCountsApi.list({ pageSize: 50 }), warehousesApi.list()])
|
||||
.then(([c, wh]) => {
|
||||
setCounts(c.items)
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Counts</h1>
|
||||
<p className="text-base text-muted-foreground">Cycle or full physical counts (FR-STK-08).</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Count
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && counts === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && counts !== null && counts.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ClipboardList className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No counts yet.</p>
|
||||
<Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Count
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && counts !== null && counts.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{counts.map((c) => (
|
||||
<TableRow key={c.countId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/stock/counts/${c.countId}`} className="font-medium text-primary hover:underline">
|
||||
{c.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{whCode(c.warehouseId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{c.countType}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<CountStatusBadge status={c.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, PackageSearch, Search } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { OnHand } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
export default function StockEnquiryPage() {
|
||||
const [rows, setRows] = useState<OnHand[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([stockApi.onHandList(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
.then(([r, it, wh]) => {
|
||||
setRows(r)
|
||||
setItems(it.items)
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
|
||||
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!rows) return null
|
||||
const term = search.trim().toLowerCase()
|
||||
return rows
|
||||
.filter((r) => warehouseId === "All" || r.warehouseId === warehouseId)
|
||||
.filter((r) => {
|
||||
if (!term) return true
|
||||
const item = itemsById.get(r.itemId)
|
||||
const haystack = `${item?.sku ?? ""} ${item?.name ?? ""}`.toLowerCase()
|
||||
return haystack.includes(term)
|
||||
})
|
||||
.sort((a, b) => a.itemId - b.itemId || a.warehouseId - b.warehouseId)
|
||||
}, [rows, search, warehouseId, itemsById])
|
||||
|
||||
const loading = rows === null || items === null || warehouses === null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Enquiry</h1>
|
||||
<p className="text-base text-muted-foreground">On-hand, available, on-hold, and in-transit by item and warehouse (FR-STK-12).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by SKU or item name…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search items"
|
||||
/>
|
||||
</div>
|
||||
<Select<number | "All"> value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All warehouses</SelectItem>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered !== null && filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<PackageSearch className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No stock matches your search/filter.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered !== null && filtered.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">On hand</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Available</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">On hold</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">In transit</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reserved</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Valuation</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((row) => {
|
||||
const item = itemsById.get(row.itemId)
|
||||
const wh = warehousesById.get(row.warehouseId)
|
||||
return (
|
||||
<TableRow key={`${row.itemId}-${row.warehouseId}`}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="font-medium">{item?.sku ?? `#${row.itemId}`}</div>
|
||||
<div className="text-sm text-muted-foreground">{item?.name}</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${row.warehouseId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.onHand}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{row.available}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.onHold}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.inTransit}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.reserved}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link
|
||||
href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LedgerEntry } from "@/types/stock"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function StockLedgerPage() {
|
||||
const [entries, setEntries] = useState<LedgerEntry[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [itemId, setItemId] = useState<number | "All">("All")
|
||||
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
|
||||
const [from, setFrom] = useState("")
|
||||
const [to, setTo] = useState("")
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
.then(([it, wh]) => {
|
||||
setItems(it.items)
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [itemId, warehouseId, from, to])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
stockApi
|
||||
.ledger({
|
||||
itemId: itemId === "All" ? undefined : itemId,
|
||||
warehouseId: warehouseId === "All" ? undefined : warehouseId,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setEntries(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [itemId, warehouseId, from, to, page])
|
||||
|
||||
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
|
||||
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
||||
|
||||
const loading = entries === null || items === null || warehouses === null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Ledger</h1>
|
||||
<p className="text-base text-muted-foreground">Immutable, append-only movement journal (FR-STK-01).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Item</Label>
|
||||
<Select<number | "All"> value={itemId} onValueChange={(v) => setItemId(v ?? "All")}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All items</SelectItem>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | "All"> value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? "All")}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All warehouses</SelectItem>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">From</Label>
|
||||
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">To</Label>
|
||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && entries !== null && entries.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ScrollText className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No ledger entries match this filter.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && entries !== null && entries.length > 0 && (
|
||||
<>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Direction</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Value</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Running balance</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Source</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => {
|
||||
const item = itemsById.get(entry.itemId)
|
||||
const wh = warehousesById.get(entry.warehouseId)
|
||||
return (
|
||||
<TableRow key={entry.ledgerId}>
|
||||
<TableCell className="px-3 py-3.5">{new Date(entry.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item?.sku ?? `#${entry.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${entry.warehouseId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-20 justify-center text-sm",
|
||||
entry.direction === "In"
|
||||
? "border-transparent bg-success/10 text-success"
|
||||
: "border-transparent bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{entry.qtyBase}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{entry.runningBalance}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Link from "next/link"
|
||||
import {
|
||||
AlertOctagon,
|
||||
AlertTriangle,
|
||||
ArrowLeftRight,
|
||||
BadgeDollarSign,
|
||||
ClipboardList,
|
||||
PackageSearch,
|
||||
ScrollText,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
|
||||
{
|
||||
title: "Stock Enquiry",
|
||||
description: "On-hand, available, on-hold, and in-transit quantities by item and warehouse.",
|
||||
href: "/dashboard/stock/enquiry",
|
||||
icon: PackageSearch,
|
||||
},
|
||||
{
|
||||
title: "Stock Ledger",
|
||||
description: "The immutable, append-only movement journal — every in/out with running balance.",
|
||||
href: "/dashboard/stock/ledger",
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
title: "Valuation",
|
||||
description: "FIFO cost-layer breakdown and total stock value by item and warehouse.",
|
||||
href: "/dashboard/stock/valuation",
|
||||
icon: BadgeDollarSign,
|
||||
},
|
||||
{
|
||||
title: "Transfers",
|
||||
description: "Move stock between warehouses: create, dispatch, and receive (in-transit).",
|
||||
href: "/dashboard/stock/transfers",
|
||||
icon: ArrowLeftRight,
|
||||
},
|
||||
{
|
||||
title: "Adjustments",
|
||||
description: "Increase, decrease, or write off stock with a mandatory reason code.",
|
||||
href: "/dashboard/stock/adjustments",
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
title: "Counts",
|
||||
description: "Cycle or full physical counts — snapshot, enter counts, post variance.",
|
||||
href: "/dashboard/stock/counts",
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
title: "Reorder Alerts",
|
||||
description: "Items at or below their reorder point, with a one-click requisition.",
|
||||
href: "/dashboard/stock/reorder-alerts",
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
title: "Wastage",
|
||||
description: "Damage, theft/loss, and expiry write-offs — reason-coded adjustments with a totals report.",
|
||||
href: "/dashboard/stock/wastage",
|
||||
icon: AlertOctagon,
|
||||
},
|
||||
]
|
||||
|
||||
export default function StockHubPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Management</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
FIFO-costed stock across multiple warehouses (FR-STK-01..14).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{areas.map((area) => (
|
||||
<Link key={area.href} href={area.href}>
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<area.icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{area.title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-base text-muted-foreground">{area.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2 } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ReorderAlert } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function ReorderAlertsPage() {
|
||||
const [alerts, setAlerts] = useState<ReorderAlert[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [requesting, setRequesting] = useState<string | null>(null)
|
||||
const [requested, setRequested] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([stockApi.reorderAlerts(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
.then(([a, it, wh]) => {
|
||||
setAlerts(a.items)
|
||||
setItems(it.items)
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
|
||||
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
||||
|
||||
async function handleRequisition(alert: ReorderAlert) {
|
||||
const key = `${alert.itemId}-${alert.warehouseId}`
|
||||
setRequesting(key)
|
||||
try {
|
||||
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
|
||||
setRequested((prev) => new Set(prev).add(key))
|
||||
toast.success("Requisition created", `${res.docNo} for ${res.qty} units.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not create requisition", errorMessage(err))
|
||||
} finally {
|
||||
setRequesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const loading = !alerts || !items || !warehouses
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Reorder Alerts</h1>
|
||||
<p className="text-base text-muted-foreground">Items at or below their reorder point (FR-STK-10).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && alerts.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<CheckCircle2 className="size-12 text-success" />
|
||||
<p className="text-base text-muted-foreground">Nothing is below its reorder point right now.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && alerts.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Available</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reorder point</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Suggested qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alerts.map((alert) => {
|
||||
const key = `${alert.itemId}-${alert.warehouseId}`
|
||||
const item = itemsById.get(alert.itemId)
|
||||
const wh = warehousesById.get(alert.warehouseId)
|
||||
const done = requested.has(key)
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-4 text-warning" />
|
||||
<div>
|
||||
<div className="font-medium">{item?.sku ?? `#${alert.itemId}`}</div>
|
||||
<div className="text-sm text-muted-foreground">{item?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${alert.warehouseId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium text-destructive">{alert.available}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{alert.reorderPoint}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{alert.suggestedRequisitionQty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={done ? "outline" : "default"}
|
||||
disabled={done || requesting === key}
|
||||
onClick={() => handleRequisition(alert)}
|
||||
>
|
||||
{done ? "Requisitioned" : requesting === key ? "Creating…" : "Create requisition"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, CheckCircle2, PackageCheck, Truck } from "lucide-react"
|
||||
|
||||
import { stockTransfersApi } from "@/lib/api/stock-transfers"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { TransferStatusBadge } from "@/components/stock/status-badges"
|
||||
|
||||
export default function TransferDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const transferId = Number(params.id)
|
||||
|
||||
const [transfer, setTransfer] = useState<StockTransfer | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dispatching, setDispatching] = useState(false)
|
||||
const [receiving, setReceiving] = useState(false)
|
||||
const [dispatchResult, setDispatchResult] = useState<DispatchTransferResponse | null>(null)
|
||||
const [receiveResult, setReceiveResult] = useState<ReceiveTransferResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(transferId)) return
|
||||
stockTransfersApi.get(transferId).then(setTransfer).catch((err) => setError(errorMessage(err)))
|
||||
}, [transferId])
|
||||
|
||||
async function handleDispatch() {
|
||||
setError(null)
|
||||
setDispatching(true)
|
||||
try {
|
||||
const result = await stockTransfersApi.dispatch(transferId)
|
||||
setDispatchResult(result)
|
||||
setTransfer((prev) => (prev ? { ...prev, status: result.status } : prev))
|
||||
toast.success("Transfer dispatched", `${result.consumedLayers.length} layer(s) consumed at source.`)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
toast.error("Could not dispatch transfer", errorMessage(err))
|
||||
} finally {
|
||||
setDispatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReceive() {
|
||||
if (!transfer) return
|
||||
setError(null)
|
||||
setReceiving(true)
|
||||
try {
|
||||
const result = await stockTransfersApi.receive(
|
||||
transferId,
|
||||
transfer.lines.map((l) => ({ transferLineId: l.transferLineId, qty: l.qty }))
|
||||
)
|
||||
setReceiveResult(result)
|
||||
setTransfer((prev) => (prev ? { ...prev, status: result.status } : prev))
|
||||
toast.success("Transfer received", `${result.createdLayers.length} layer(s) created at destination.`)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
toast.error("Could not receive transfer", errorMessage(err))
|
||||
} finally {
|
||||
setReceiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !transfer) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
if (!transfer) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{transfer.docNo}</h1>
|
||||
<TransferStatusBadge status={transfer.status} />
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Warehouse #{transfer.srcWarehouseId} → Warehouse #{transfer.destWarehouseId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{transfer.status === "Draft" && (
|
||||
<Button size="lg" onClick={handleDispatch} disabled={dispatching}>
|
||||
<Truck className="size-5" />
|
||||
{dispatching ? "Dispatching…" : "Dispatch"}
|
||||
</Button>
|
||||
)}
|
||||
{transfer.status === "InTransit" && (
|
||||
<Button size="lg" onClick={handleReceive} disabled={receiving}>
|
||||
<PackageCheck className="size-5" />
|
||||
{receiving ? "Receiving…" : "Receive"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{dispatchResult && (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="size-6" />
|
||||
<p className="text-base font-semibold">Dispatched — source layers consumed (cost-preserving, FR-STK-06)</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Consumed: {dispatchResult.consumedLayers.map((l) => `#${l.layerId} (${l.qtyConsumed} @ ${l.unitCost})`).join(", ")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {dispatchResult.ledgerRefs.join(", ")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{receiveResult && (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="size-6" />
|
||||
<p className="text-base font-semibold">Received — destination layers created</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Created: {receiveResult.createdLayers.map((l) => `#${l.layerId} (${l.qtyReceived} @ ${l.unitCost})`).join(", ")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {receiveResult.ledgerRefs.join(", ")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Src bin</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Dest bin</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transfer.lines.map((line) => (
|
||||
<TableRow key={line.transferLineId}>
|
||||
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.srcBinId ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.destBinId ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { stockTransfersApi } from "@/lib/api/stock-transfers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateTransferLineInput } from "@/types/stock"
|
||||
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
itemId: number | null
|
||||
destBinId: number | null
|
||||
qty: string
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function newKey() {
|
||||
keySeq += 1
|
||||
return `tline-${keySeq}`
|
||||
}
|
||||
|
||||
function emptyLine(): DraftLine {
|
||||
return { key: newKey(), itemId: null, destBinId: null, qty: "" }
|
||||
}
|
||||
|
||||
export default function NewTransferPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [srcBins, setSrcBins] = useState<Bin[]>([])
|
||||
const [destBins, setDestBins] = useState<Bin[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [srcWarehouseId, setSrcWarehouseId] = useState<number | null>(null)
|
||||
const [destWarehouseId, setDestWarehouseId] = useState<number | null>(null)
|
||||
const [srcBinId, setSrcBinId] = useState<number | null>(null)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" })])
|
||||
.then(([wh, it]) => {
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!srcWarehouseId) {
|
||||
setSrcBins([])
|
||||
return
|
||||
}
|
||||
warehousesApi.listBins(srcWarehouseId).then((r) => setSrcBins(r.items)).catch(() => setSrcBins([]))
|
||||
}, [srcWarehouseId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!destWarehouseId) {
|
||||
setDestBins([])
|
||||
return
|
||||
}
|
||||
warehousesApi.listBins(destWarehouseId).then((r) => setDestBins(r.items)).catch(() => setDestBins([]))
|
||||
}, [destWarehouseId])
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => (prev.length > 1 ? prev.filter((l) => l.key !== key) : prev))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
|
||||
if (!srcWarehouseId || !destWarehouseId) {
|
||||
setSubmitError("Select both a source and destination warehouse.")
|
||||
return
|
||||
}
|
||||
if (srcWarehouseId === destWarehouseId) {
|
||||
setSubmitError("Source and destination warehouses must be different.")
|
||||
return
|
||||
}
|
||||
|
||||
const nextLineErrors: Record<string, string> = {}
|
||||
for (const line of lines) {
|
||||
if (!line.itemId) nextLineErrors[line.key] = "Select an item"
|
||||
else if (!line.qty || Number(line.qty) <= 0) nextLineErrors[line.key] = "Quantity must be greater than 0"
|
||||
}
|
||||
setLineErrors(nextLineErrors)
|
||||
if (Object.keys(nextLineErrors).length > 0) {
|
||||
setSubmitError("Fix the highlighted lines before submitting.")
|
||||
return
|
||||
}
|
||||
|
||||
const payloadLines: CreateTransferLineInput[] = lines.map((l) => ({
|
||||
itemId: l.itemId as number,
|
||||
srcBinId,
|
||||
destBinId: l.destBinId,
|
||||
qty: Number(l.qty),
|
||||
}))
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, lines: payloadLines })
|
||||
toast.success("Transfer created", `${transfer.docNo} is ready to dispatch.`)
|
||||
router.push(`/dashboard/stock/transfers/${transfer.transferId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not create transfer", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loading = !warehouses || !items
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Transfer</h1>
|
||||
<p className="text-base text-muted-foreground">Create a transfer, then dispatch and receive it (FR-STK-05).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-12 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">From warehouse</Label>
|
||||
<Select<number | null> value={srcWarehouseId} onValueChange={(v) => setSrcWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">From bin (optional)</Label>
|
||||
<Select<number | null> value={srcBinId} onValueChange={(v) => setSrcBinId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Any bin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{srcBins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">To warehouse</Label>
|
||||
<Select<number | null> value={destWarehouseId} onValueChange={(v) => setDestWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? [])
|
||||
.filter((w) => w.warehouseId !== srcWarehouseId)
|
||||
.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-64 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-40 px-3 text-sm">Destination bin</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{destBins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!lineErrors[line.key]}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create Transfer"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ArrowLeftRight, Plus } from "lucide-react"
|
||||
|
||||
import { stockTransfersApi } from "@/lib/api/stock-transfers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { StockTransferSummary } from "@/types/stock"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { TransferStatusBadge } from "@/components/stock/status-badges"
|
||||
|
||||
export default function TransfersListPage() {
|
||||
const [transfers, setTransfers] = useState<StockTransferSummary[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([stockTransfersApi.list({ pageSize: 50 }), warehousesApi.list()])
|
||||
.then(([t, wh]) => {
|
||||
setTransfers(t.items)
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Transfers</h1>
|
||||
<p className="text-base text-muted-foreground">Move stock between warehouses (FR-STK-05/06).</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Transfer
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && transfers === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && transfers !== null && transfers.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ArrowLeftRight className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No transfers yet.</p>
|
||||
<Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Transfer
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && transfers !== null && transfers.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">From</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">To</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transfers.map((t) => (
|
||||
<TableRow key={t.transferId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/stock/transfers/${t.transferId}`} className="font-medium text-primary hover:underline">
|
||||
{t.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{whCode(t.srcWarehouseId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{whCode(t.destWarehouseId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<TransferStatusBadge status={t.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(t.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, BadgeDollarSign } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Valuation } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
function ValuationContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [valuation, setValuation] = useState<Valuation | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const itemId = Number(searchParams.get("itemId")) || null
|
||||
const warehouseId = Number(searchParams.get("warehouseId")) || null
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
.then(([it, wh]) => {
|
||||
setItems(it.items)
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemId || !warehouseId) {
|
||||
setValuation(null)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
stockApi
|
||||
.valuation(itemId, warehouseId)
|
||||
.then(setValuation)
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [itemId, warehouseId])
|
||||
|
||||
const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId])
|
||||
|
||||
function setParam(key: "itemId" | "warehouseId", value: number) {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set(key, String(value))
|
||||
router.push(`/dashboard/stock/valuation?${params.toString()}`)
|
||||
}
|
||||
|
||||
const loading = items === null || warehouses === null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Valuation</h1>
|
||||
<p className="text-base text-muted-foreground">FIFO cost-layer breakdown and total stock value (FR-STK-04).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton className="h-12 w-full" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Item</Label>
|
||||
<Select<number | null> value={itemId} onValueChange={(v) => v && setParam("itemId", v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select an item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => v && setParam("warehouseId", v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select a warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!itemId || !warehouseId ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<BadgeDollarSign className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">Select an item and warehouse to see its FIFO valuation.</p>
|
||||
</div>
|
||||
) : valuation === null ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl border p-5">
|
||||
<p className="text-sm text-muted-foreground">Item</p>
|
||||
<p className="text-lg font-semibold">{item?.sku ?? `#${itemId}`}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border p-5">
|
||||
<p className="text-sm text-muted-foreground">Total quantity</p>
|
||||
<p className="text-lg font-semibold">{valuation.totalQty}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border p-5">
|
||||
<p className="text-sm text-muted-foreground">Total value ({valuation.currency})</p>
|
||||
<p className="text-lg font-semibold">{valuation.totalValue.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{valuation.layers.length === 0 ? (
|
||||
<p className="text-base text-muted-foreground">No open FIFO layers for this item/warehouse.</p>
|
||||
) : (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Layer</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Receipt date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Qty remaining</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Value</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{valuation.layers.map((layer) => (
|
||||
<TableRow key={layer.layerId}>
|
||||
<TableCell className="px-3 py-3.5">#{layer.layerId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(layer.receiptDate).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{layer.qtyRemaining}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{layer.unitCost.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{layer.value.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StockValuationPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<ValuationContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertOctagon, ArrowLeft, CheckCircle2 } from "lucide-react"
|
||||
|
||||
import { wastageApi, wastageReasonCodeIds } from "@/lib/api/wastage"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ReasonCode, StockAdjustment } from "@/types/stock"
|
||||
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function NewWastagePage() {
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[] | null>(null)
|
||||
const [bins, setBins] = useState<Bin[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [itemId, setItemId] = useState<number | null>(null)
|
||||
const [binId, setBinId] = useState<number | null>(null)
|
||||
const [qty, setQty] = useState("")
|
||||
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [result, setResult] = useState<StockAdjustment | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
|
||||
.then(([wh, it, rc]) => {
|
||||
const wastageIds = new Set(wastageReasonCodeIds())
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!warehouseId) {
|
||||
setBins([])
|
||||
return
|
||||
}
|
||||
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
|
||||
}, [warehouseId])
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!warehouseId) nextErrors.warehouseId = "Select a warehouse"
|
||||
if (!itemId) nextErrors.itemId = "Select an item"
|
||||
if (!qty || Number(qty) <= 0) nextErrors.qty = "Quantity must be greater than 0"
|
||||
if (!reasonCodeId) nextErrors.reasonCodeId = "Select a wastage reason"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const adjustment = await wastageApi.record({
|
||||
warehouseId: warehouseId as number,
|
||||
itemId: itemId as number,
|
||||
binId,
|
||||
qty: Number(qty),
|
||||
reasonCodeId: reasonCodeId as number,
|
||||
})
|
||||
setResult(adjustment)
|
||||
toast.success("Wastage recorded", `${adjustment.docNo} posted immediately (auto-post, FR-STK-07).`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not record wastage", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function startAnother() {
|
||||
setResult(null)
|
||||
setItemId(null)
|
||||
setQty("")
|
||||
setReasonCodeId(null)
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
const loading = !warehouses || !items || !reasonCodes
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Record Wastage</h1>
|
||||
<p className="text-base text-muted-foreground">Posts immediately as a stock adjustment (FR-STK-07) — a reason code is mandatory.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-12 w-full" />}
|
||||
|
||||
{!loading && result && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="size-6" />
|
||||
<p className="text-base font-semibold">{result.docNo} posted</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {result.ledgerRefs.join(", ")}</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Back to wastage report
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={startAnother}>
|
||||
Record another
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !result && (
|
||||
<>
|
||||
{(reasonCodes ?? []).length === 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
|
||||
<AlertOctagon className="size-5" />
|
||||
No wastage-type reason codes (Damage / Theft-Loss / Expiry Write-off) are configured.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Bin (optional)</Label>
|
||||
<Select<number | null> value={binId} onValueChange={(v) => setBinId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Item</Label>
|
||||
<Select<number | null> value={itemId} onValueChange={(v) => setItemId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Quantity wasted</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => setQty(e.target.value)}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label className="text-base">Reason</Label>
|
||||
<Select<number | null> value={reasonCodeId} onValueChange={(v) => setReasonCodeId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select a wastage reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(reasonCodes ?? []).map((r) => (
|
||||
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
|
||||
{r.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.reasonCodeId ? { message: errors.reasonCodeId } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" variant="destructive" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : "Record Wastage"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertOctagon, ArrowLeft, Plus } from "lucide-react"
|
||||
|
||||
import { wastageApi, wastageReasonCodeIds, WastageRecord } from "@/lib/api/wastage"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
export default function WastagePage() {
|
||||
const [records, setRecords] = useState<WastageRecord[] | null>(null)
|
||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
|
||||
const [reasonCodeId, setReasonCodeId] = useState<number | "All">("All")
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
.then(([rc, it, wh]) => {
|
||||
const wastageIds = new Set(wastageReasonCodeIds())
|
||||
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
|
||||
setItems(it.items)
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
wastageApi
|
||||
.list({
|
||||
warehouseId: warehouseId === "All" ? undefined : warehouseId,
|
||||
reasonCodeId: reasonCodeId === "All" ? undefined : reasonCodeId,
|
||||
})
|
||||
.then(setRecords)
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [warehouseId, reasonCodeId])
|
||||
|
||||
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
|
||||
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
||||
const reasonById = useMemo(() => new Map((reasonCodes ?? []).map((r) => [r.reasonCodeId, r])), [reasonCodes])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
if (!records) return { qty: 0, value: 0 }
|
||||
return records.reduce(
|
||||
(acc, r) => ({ qty: acc.qty + r.qty, value: Math.round((acc.value + r.value) * 100) / 100 }),
|
||||
{ qty: 0, value: 0 }
|
||||
)
|
||||
}, [records])
|
||||
|
||||
const loading = !records || !reasonCodes || !items || !warehouses
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Wastage</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Damage, theft/loss, and expiry write-offs — posted as reason-coded stock adjustments (FR-STK-07).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/stock/wastage/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
Record Wastage
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-destructive/20 bg-destructive/5 p-5">
|
||||
<p className="text-sm text-muted-foreground">Total quantity wasted</p>
|
||||
<p className="text-2xl font-semibold text-destructive">{totals.qty}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-destructive/20 bg-destructive/5 p-5">
|
||||
<p className="text-sm text-muted-foreground">Total value wasted (LKR)</p>
|
||||
<p className="text-2xl font-semibold text-destructive">{totals.value.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | "All"> value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? "All")}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All warehouses</SelectItem>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Reason</Label>
|
||||
<Select<number | "All"> value={reasonCodeId} onValueChange={(v) => setReasonCodeId(v ?? "All")}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All reasons</SelectItem>
|
||||
{(reasonCodes ?? []).map((r) => (
|
||||
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
|
||||
{r.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && records.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<AlertOctagon className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No wastage recorded for this filter.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && records.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Qty wasted</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Value (LKR)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => {
|
||||
const item = itemsById.get(r.itemId)
|
||||
const wh = warehousesById.get(r.warehouseId)
|
||||
const reason = reasonById.get(r.reasonCodeId)
|
||||
return (
|
||||
<TableRow key={`${r.adjustmentId}-${r.adjLineId}`}>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="font-medium">{item?.sku ?? `#${r.itemId}`}</div>
|
||||
<div className="text-sm text-muted-foreground">{item?.name}</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${r.warehouseId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-destructive/10 px-2.5 text-sm text-destructive">
|
||||
{reason?.description ?? `#${r.reasonCodeId}`}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium text-destructive">-{r.qty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.value.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function VendorDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const vendorId = Number(params.id)
|
||||
|
||||
const [vendor, setVendor] = useState<Vendor | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [code, setCode] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [terms, setTerms] = useState("")
|
||||
const [taxReg, setTaxReg] = useState("")
|
||||
const [currency, setCurrency] = useState("")
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [conflict, setConflict] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||
|
||||
function load() {
|
||||
setLoadError(null)
|
||||
vendorsApi
|
||||
.get(vendorId)
|
||||
.then(({ data, etag: tag }) => {
|
||||
setVendor(data)
|
||||
setEtag(tag)
|
||||
setCode(data.code)
|
||||
setName(data.name)
|
||||
setTerms(data.terms ?? "")
|
||||
setTaxReg(data.taxReg ?? "")
|
||||
setCurrency(data.currency)
|
||||
setConflict(false)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Number.isFinite(vendorId)) load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [vendorId])
|
||||
|
||||
async function handleSave() {
|
||||
setSaveError(null)
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!code.trim()) nextErrors.code = "Vendor code is required"
|
||||
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!currency.trim()) nextErrors.currency = "Currency is required"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0 || !etag) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, currency }, etag)
|
||||
setVendor(result.data)
|
||||
setEtag(result.etag)
|
||||
toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`)
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : (err as { code?: string })?.code
|
||||
if (code === "CONCURRENCY_CONFLICT") {
|
||||
setConflict(true)
|
||||
setSaveError(errorMessage(err))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.code) setErrors({ code: fe.code })
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not save vendor", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus() {
|
||||
if (!vendor) return
|
||||
const next = vendor.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingStatus(true)
|
||||
try {
|
||||
await vendorsApi.updateStatus(vendorId, next)
|
||||
toast.success(next === "Active" ? "Vendor activated" : "Vendor deactivated")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError && !vendor) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
<Link href="/dashboard/vendors" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Back to vendors
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!vendor) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/vendors" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{vendor.code}</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
vendor.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{vendor.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{vendor.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant={vendor.status === "Active" ? "destructive" : "success"}
|
||||
onClick={handleToggleStatus}
|
||||
disabled={togglingStatus}
|
||||
>
|
||||
{togglingStatus ? "Updating…" : vendor.status === "Active" ? "Deactivate" : "Activate"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>{saveError ?? "This vendor was changed by someone else."} Reload before retrying.</p>
|
||||
<Button size="sm" variant="outline" onClick={load}>
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && !conflict && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Code</Label>
|
||||
<Input value={code} onChange={(e) => setCode(e.target.value)} aria-invalid={!!errors.code} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Payment terms</Label>
|
||||
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax registration</Label>
|
||||
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Currency</Label>
|
||||
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/vendors")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
|
||||
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 5
|
||||
|
||||
export default function VendorsPage() {
|
||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [terms, setTerms] = useState("")
|
||||
const [taxReg, setTaxReg] = useState("")
|
||||
const [currency, setCurrency] = useState("LKR")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [query, status])
|
||||
|
||||
function load() {
|
||||
vendorsApi
|
||||
.list({ q: query || undefined, status: status === "All" ? undefined : status, page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setVendors(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [query, status, page])
|
||||
|
||||
function resetForm() {
|
||||
setCode("")
|
||||
setName("")
|
||||
setTerms("")
|
||||
setTaxReg("")
|
||||
setCurrency("LKR")
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!code.trim()) nextErrors.code = "Vendor code is required"
|
||||
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!currency.trim()) nextErrors.currency = "Currency is required"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await vendorsApi.create({ code, name, terms: terms || null, taxReg: taxReg || null, currency })
|
||||
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
load()
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.code) setErrors({ code: fe.code })
|
||||
toast.error("Could not create vendor", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// FR-MD-08: masters are deactivated, never hard-deleted — "Delete" in the UI
|
||||
// maps to PATCH status → Inactive, same mechanism as the detail page's toggle.
|
||||
async function handleDeactivate(vendor: Vendor) {
|
||||
setActionPendingId(vendor.vendorId)
|
||||
try {
|
||||
await vendorsApi.updateStatus(vendor.vendorId, "Inactive")
|
||||
toast.success("Vendor deleted", `${vendor.code} has been deactivated.`)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete vendor", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Vendors</h1>
|
||||
<p className="text-base text-muted-foreground">Supplier master data — code, terms, tax registration, currency (FR-MD-06).</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg">
|
||||
<Plus className="size-5" />
|
||||
New Vendor
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New vendor</DialogTitle>
|
||||
<DialogDescription>Create a supplier record.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.code}>
|
||||
<FieldLabel htmlFor="v-code">Code</FieldLabel>
|
||||
<Input id="v-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="VN-005" aria-invalid={!!errors.code} />
|
||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="v-name">Name</FieldLabel>
|
||||
<Input id="v-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.currency}>
|
||||
<FieldLabel htmlFor="v-currency">Currency</FieldLabel>
|
||||
<Input id="v-currency" value={currency} onChange={(e) => setCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
|
||||
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search code or name…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search vendors"
|
||||
/>
|
||||
</div>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && vendors === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && vendors !== null && vendors.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Truck className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No vendors match your search/filter." : "No vendors yet."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && vendors !== null && vendors.length > 0 && (
|
||||
<>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Terms</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vendors.map((v) => (
|
||||
<TableRow key={v.vendorId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{v.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{v.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{v.terms ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{v.currency}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
v.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{v.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={<Button variant="ghost" size="icon-sm" aria-label={`View ${v.code}`} />}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>{v.code}</DialogTitle>
|
||||
<DialogDescription>{v.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 text-base">
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<span className="text-muted-foreground">Payment terms</span>
|
||||
<span className="font-medium">{v.terms ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<span className="text-muted-foreground">Tax registration</span>
|
||||
<span className="font-medium">{v.taxReg ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<span className="text-muted-foreground">Currency</span>
|
||||
<span className="font-medium">{v.currency}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
v.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{v.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span className="font-medium">{new Date(v.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center pt-2">
|
||||
<DialogClose render={<Button variant="outline" className="min-w-36" />}>Close</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Link
|
||||
href={`/dashboard/vendors/${v.vendorId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Update ${v.code}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
aria-label={`Delete ${v.code}`}
|
||||
disabled={v.status === "Inactive" || actionPendingId === v.vendorId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${v.code}?`}
|
||||
description={`This deactivates ${v.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its detail page.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDeactivate(v)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, MapPinned, Plus } from "lucide-react"
|
||||
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Bin, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function WarehouseDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const warehouseId = Number(params.id)
|
||||
|
||||
const [warehouse, setWarehouse] = useState<Warehouse | null>(null)
|
||||
const [bins, setBins] = useState<Bin[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
const [binType, setBinType] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
function loadBins() {
|
||||
warehousesApi.listBins(warehouseId).then((res) => setBins(res.items)).catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(warehouseId)) return
|
||||
warehousesApi.get(warehouseId).then(setWarehouse).catch((err) => setError(errorMessage(err)))
|
||||
loadBins()
|
||||
}, [warehouseId])
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!code.trim()) nextErrors.code = "Bin code is required"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const bin = await warehousesApi.createBin(warehouseId, { code, binType: binType || null })
|
||||
toast.success("Bin created", bin.code)
|
||||
setOpen(false)
|
||||
setCode("")
|
||||
setBinType("")
|
||||
setErrors({})
|
||||
loadBins()
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.code) setErrors({ code: fe.code })
|
||||
toast.error("Could not create bin", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !warehouse) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
if (!warehouse || !bins) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/warehouse" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{warehouse.code}</h1>
|
||||
<p className="text-base text-muted-foreground">{warehouse.name} · Bin/location structure (FR-WH-01, FR-MD-07)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg">
|
||||
<Plus className="size-5" />
|
||||
New Bin
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New bin</DialogTitle>
|
||||
<DialogDescription>Add a bin/location to {warehouse.code}.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.code}>
|
||||
<FieldLabel htmlFor="bin-code">Code</FieldLabel>
|
||||
<Input id="bin-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="A-01-01" aria-invalid={!!errors.code} />
|
||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bin-type">Bin type (optional)</FieldLabel>
|
||||
<Input id="bin-type" value={binType} onChange={(e) => setBinType(e.target.value)} placeholder="Shelf, Pallet, …" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{bins.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<MapPinned className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No bins yet in this warehouse.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bins.map((b) => (
|
||||
<TableRow key={b.binId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{b.binType ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Warehouse as WarehouseIcon } from "lucide-react"
|
||||
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Bin, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function WarehousesPage() {
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [bins, setBins] = useState<Bin[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
function load() {
|
||||
warehousesApi
|
||||
.list()
|
||||
.then(async (res) => {
|
||||
setWarehouses(res.items)
|
||||
const allBins = await Promise.all(res.items.map((w) => warehousesApi.listBins(w.warehouseId)))
|
||||
setBins(allBins.flatMap((b) => b.items))
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
function binCount(warehouseId: number) {
|
||||
return (bins ?? []).filter((b) => b.warehouseId === warehouseId).length
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!code.trim()) nextErrors.code = "Warehouse code is required"
|
||||
if (!name.trim()) nextErrors.name = "Warehouse name is required"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const warehouse = await warehousesApi.create({ code, name })
|
||||
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
|
||||
setOpen(false)
|
||||
setCode("")
|
||||
setName("")
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.code) setErrors({ code: fe.code })
|
||||
toast.error("Could not create warehouse", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loading = !warehouses || !bins
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Warehouses</h1>
|
||||
<p className="text-base text-muted-foreground">Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg">
|
||||
<Plus className="size-5" />
|
||||
New Warehouse
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New warehouse</DialogTitle>
|
||||
<DialogDescription>Create a new warehouse. Bins are added from its detail page.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.code}>
|
||||
<FieldLabel htmlFor="wh-code">Code</FieldLabel>
|
||||
<Input id="wh-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="WH-MAIN" aria-invalid={!!errors.code} />
|
||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="wh-name">Name</FieldLabel>
|
||||
<Input id="wh-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && warehouses.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<WarehouseIcon className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No warehouses yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && warehouses.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Bins</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{warehouses.map((w) => (
|
||||
<TableRow key={w.warehouseId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/warehouse/${w.warehouseId}`} className="font-medium text-primary hover:underline">
|
||||
{w.code}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{w.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{binCount(w.warehouseId)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,13 +4,18 @@ import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
Building2,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
HelpCircle,
|
||||
LayoutGrid,
|
||||
Menu,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Truck,
|
||||
Warehouse,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
@@ -20,6 +25,11 @@ import { cn } from "@/lib/utils"
|
||||
const navItems: { title: string; href: string; icon: LucideIcon; chevron?: boolean }[] = [
|
||||
{ title: "Dashboard", href: "/dashboard", icon: LayoutGrid },
|
||||
{ title: "Products", href: "/dashboard/products", icon: Package, chevron: true },
|
||||
{ title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
||||
{ title: "Procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true },
|
||||
{ title: "Receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "Stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||
{ title: "Warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||
{ title: "Settings", href: "/dashboard/settings", icon: Settings, chevron: true },
|
||||
{ title: "Help", href: "/dashboard/help", icon: HelpCircle },
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
|
||||
// Per-segment display labels. Deliberately separate from Header.tsx's
|
||||
// titleFromPath (which resolves one *full* path to a page title) — a
|
||||
// breadcrumb needs a label per *segment*, including grouping segments that
|
||||
// have no page of their own (e.g. "receiving", "grn").
|
||||
const SEGMENT_LABELS: Record<string, string> = {
|
||||
dashboard: "Dashboard",
|
||||
products: "Items",
|
||||
uoms: "UOMs",
|
||||
categories: "Categories",
|
||||
new: "New",
|
||||
edit: "Edit",
|
||||
vendors: "Vendors",
|
||||
warehouse: "Warehouses",
|
||||
receiving: "Receiving",
|
||||
grn: "Goods Receipt Notes",
|
||||
stock: "Stock",
|
||||
enquiry: "Enquiry",
|
||||
ledger: "Ledger",
|
||||
valuation: "Valuation",
|
||||
transfers: "Transfers",
|
||||
adjustments: "Adjustments",
|
||||
counts: "Counts",
|
||||
"reorder-alerts": "Reorder Alerts",
|
||||
wastage: "Wastage",
|
||||
procurement: "Procurement",
|
||||
requisitions: "Requisitions",
|
||||
rfqs: "RFQs",
|
||||
"purchase-orders": "Purchase Orders",
|
||||
"purchase-returns": "Purchase Returns",
|
||||
}
|
||||
|
||||
function labelFor(segment: string): string {
|
||||
if (SEGMENT_LABELS[segment]) return SEGMENT_LABELS[segment]
|
||||
if (/^[0-9]+$/.test(segment)) return `#${segment}`
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||
}
|
||||
|
||||
const linkClass =
|
||||
"truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname() || "/dashboard"
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
|
||||
// Nothing to show above the dashboard root itself.
|
||||
if (segments.length <= 1) return null
|
||||
|
||||
const crumbs = segments.map((segment, i) => ({
|
||||
href: "/" + segments.slice(0, i + 1).join("/"),
|
||||
label: labelFor(segment),
|
||||
}))
|
||||
|
||||
return (
|
||||
<Breadcrumb className="mb-4">
|
||||
<BreadcrumbList>
|
||||
{crumbs.map((crumb, i) => {
|
||||
const isLast = i === crumbs.length - 1
|
||||
return (
|
||||
<Fragment key={crumb.href}>
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<Link href={crumb.href} className={linkClass}>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{!isLast && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,60 @@ import {
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
|
||||
const PROCUREMENT_TITLES: Record<string, string> = {
|
||||
"/dashboard/procurement": "Procurement",
|
||||
"/dashboard/procurement/requisitions": "Requisitions",
|
||||
"/dashboard/procurement/requisitions/new": "New Requisition",
|
||||
"/dashboard/procurement/rfqs": "RFQs",
|
||||
"/dashboard/procurement/rfqs/new": "New RFQ",
|
||||
"/dashboard/procurement/purchase-orders": "Purchase Orders",
|
||||
"/dashboard/procurement/purchase-orders/new": "New Purchase Order",
|
||||
"/dashboard/procurement/purchase-returns": "Purchase Returns",
|
||||
"/dashboard/procurement/purchase-returns/new": "New Purchase Return",
|
||||
}
|
||||
|
||||
const STOCK_TITLES: Record<string, string> = {
|
||||
"/dashboard/stock": "Stock Management",
|
||||
"/dashboard/stock/enquiry": "Stock Enquiry",
|
||||
"/dashboard/stock/ledger": "Stock Ledger",
|
||||
"/dashboard/stock/valuation": "Valuation",
|
||||
"/dashboard/stock/transfers": "Stock Transfers",
|
||||
"/dashboard/stock/transfers/new": "New Transfer",
|
||||
"/dashboard/stock/adjustments": "Stock Adjustments",
|
||||
"/dashboard/stock/adjustments/new": "New Adjustment",
|
||||
"/dashboard/stock/counts": "Stock Counts",
|
||||
"/dashboard/stock/counts/new": "New Count",
|
||||
"/dashboard/stock/reorder-alerts": "Reorder Alerts",
|
||||
"/dashboard/stock/wastage": "Wastage",
|
||||
"/dashboard/stock/wastage/new": "Record Wastage",
|
||||
}
|
||||
|
||||
function titleFromPath(pathname: string) {
|
||||
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes"
|
||||
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
|
||||
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note"
|
||||
|
||||
if (STOCK_TITLES[pathname]) return STOCK_TITLES[pathname]
|
||||
if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer"
|
||||
if (/^\/dashboard\/stock\/counts\/[^/]+$/.test(pathname)) return "Stock Count"
|
||||
|
||||
if (PROCUREMENT_TITLES[pathname]) return PROCUREMENT_TITLES[pathname]
|
||||
if (/^\/dashboard\/procurement\/requisitions\/[^/]+$/.test(pathname)) return "Requisition"
|
||||
if (/^\/dashboard\/procurement\/rfqs\/[^/]+$/.test(pathname)) return "RFQ"
|
||||
if (/^\/dashboard\/procurement\/purchase-orders\/[^/]+$/.test(pathname)) return "Purchase Order"
|
||||
|
||||
if (pathname === "/dashboard/warehouse") return "Warehouses"
|
||||
if (/^\/dashboard\/warehouse\/[^/]+$/.test(pathname)) return "Warehouse"
|
||||
|
||||
if (pathname === "/dashboard/vendors") return "Vendors"
|
||||
if (/^\/dashboard\/vendors\/[^/]+$/.test(pathname)) return "View Vendor"
|
||||
|
||||
if (pathname === "/dashboard/products") return "Items"
|
||||
if (pathname === "/dashboard/products/new") return "New Item"
|
||||
if (pathname === "/dashboard/products/uoms") return "Units of Measure"
|
||||
if (pathname === "/dashboard/products/categories") return "Categories"
|
||||
if (/^\/dashboard\/products\/[^/]+$/.test(pathname)) return "Item"
|
||||
|
||||
const segment = pathname.split("/").filter(Boolean).pop() ?? "dashboard"
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { PurchaseOrderStatus, RequisitionStatus, RfqStatus } from "@/types/procurement"
|
||||
|
||||
// Fixed width (not w-fit) so every status pill is the same size regardless of
|
||||
// label length, same convention as components/receiving/status-badges.tsx.
|
||||
const badgeSize = "h-6 w-32 justify-center px-2.5 py-1 text-sm"
|
||||
|
||||
function requisitionClass(status: RequisitionStatus) {
|
||||
return status === "Draft" ? "bg-warning/10 text-warning border-transparent" : "bg-success/10 text-success border-transparent"
|
||||
}
|
||||
|
||||
export function RequisitionStatusBadge({ status }: { status: RequisitionStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${requisitionClass(status)}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function rfqClass(status: RfqStatus) {
|
||||
return status === "Open" ? "bg-success/10 text-success border-transparent" : "bg-muted text-muted-foreground border-transparent"
|
||||
}
|
||||
|
||||
export function RfqStatusBadge({ status }: { status: RfqStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${rfqClass(status)}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function poClass(status: PurchaseOrderStatus) {
|
||||
if (status === "Draft" || status === "PendingApproval") return "bg-warning/10 text-warning border-transparent"
|
||||
if (status === "Cancelled") return "bg-destructive/10 text-destructive border-transparent"
|
||||
if (status === "Closed") return "bg-muted text-muted-foreground border-transparent"
|
||||
return "bg-success/10 text-success border-transparent" // Approved / PartiallyReceived / FullyReceived
|
||||
}
|
||||
|
||||
export function PoStatusBadge({ status }: { status: PurchaseOrderStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${poClass(status)}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { GrnStatus, HoldStatus } from "@/types/grn"
|
||||
|
||||
// Fixed width (not w-fit) so every status pill is the same size regardless of
|
||||
// label length ("Draft" vs "Confirmed" vs "Available").
|
||||
const badgeSize = "h-6 w-28 justify-center px-2.5 py-1 text-sm"
|
||||
|
||||
function grnClass(status: GrnStatus) {
|
||||
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
|
||||
if (status === "Confirmed") return "bg-success/10 text-success border-transparent"
|
||||
return "bg-destructive/10 text-destructive border-transparent" // Closed
|
||||
}
|
||||
|
||||
export function GrnStatusBadge({ status }: { status: GrnStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${grnClass(status)}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function holdClass(status: HoldStatus) {
|
||||
if (status === "OnHold") return "bg-warning/10 text-warning border-transparent"
|
||||
if (status === "Rejected") return "bg-destructive/10 text-destructive border-transparent"
|
||||
return "bg-success/10 text-success border-transparent" // Available
|
||||
}
|
||||
|
||||
export function HoldStatusBadge({ status }: { status: HoldStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${holdClass(status)}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { AdjustmentStatus, CountStatus, TransferStatus } from "@/types/stock"
|
||||
|
||||
// Same fixed-size, red/green/yellow convention as components/receiving/status-badges.tsx.
|
||||
const badgeSize = "h-6 w-28 justify-center px-2.5 py-1 text-sm"
|
||||
|
||||
const warning = "bg-warning/10 text-warning border-transparent"
|
||||
const success = "bg-success/10 text-success border-transparent"
|
||||
const destructive = "bg-destructive/10 text-destructive border-transparent"
|
||||
|
||||
export function TransferStatusBadge({ status }: { status: TransferStatus }) {
|
||||
const tone = status === "Received" ? success : status === "Closed" ? destructive : warning
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${tone}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function CountStatusBadge({ status }: { status: CountStatus }) {
|
||||
const tone = status === "Posted" ? success : warning
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${tone}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdjustmentStatusBadge({ status }: { status: AdjustmentStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${success}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Single typed fetch client for the ERPCore API (docs/20-FRONTEND.md §1: "A single
|
||||
// typed fetch client against NEXT_PUBLIC_API_BASE_URL; all calls go through it. No
|
||||
// scattered fetch() in components."). Per-endpoint methods live in lib/api/*.ts.
|
||||
import { ProblemDetails } from "@/types/common"
|
||||
import { getAccessToken } from "@/lib/auth-token"
|
||||
|
||||
const API_BASE = `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/api/v1`
|
||||
|
||||
/** Normalized RFC 7807 error (docs/11-BACKEND-PHASE1.md §1.8) thrown on any non-2xx response. */
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
code?: string
|
||||
detail?: string
|
||||
errors?: Record<string, string[]>
|
||||
traceId?: string
|
||||
|
||||
constructor(problem: ProblemDetails) {
|
||||
super(problem.title || "Request failed")
|
||||
this.status = problem.status
|
||||
this.code = problem.code
|
||||
this.detail = problem.detail
|
||||
this.errors = problem.errors
|
||||
this.traceId = problem.traceId
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestOptions extends Omit<RequestInit, "body"> {
|
||||
body?: unknown
|
||||
/** Sent as If-Match for concurrency-guarded PUT/PATCH (docs/11 §1.6). */
|
||||
ifMatch?: string
|
||||
/** Sent as Idempotency-Key for transactional POSTs (e.g. GRN confirm, docs/11 §1.6). */
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
data: T
|
||||
/** Response ETag header, when the resource is mutable (docs/11 §1.6). */
|
||||
etag: string | null
|
||||
}
|
||||
|
||||
async function rawRequest(path: string, options: RequestOptions = {}): Promise<Response> {
|
||||
const { body, ifMatch, idempotencyKey, headers, ...rest } = options
|
||||
const token = getAccessToken()
|
||||
|
||||
const finalHeaders: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(ifMatch ? { "If-Match": ifMatch } : {}),
|
||||
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
||||
...((headers as Record<string, string> | undefined) ?? {}),
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...rest,
|
||||
headers: finalHeaders,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let problem: ProblemDetails
|
||||
try {
|
||||
problem = (await response.json()) as ProblemDetails
|
||||
} catch {
|
||||
problem = { title: response.statusText || "Request failed", status: response.status }
|
||||
}
|
||||
if (!problem.status) problem.status = response.status
|
||||
throw new ApiError(problem)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/** Fire a request and decode the JSON body only (no ETag needed). */
|
||||
export async function apiRequest<T>(path: string, options?: RequestOptions): Promise<T> {
|
||||
const response = await rawRequest(path, options)
|
||||
if (response.status === 204) return undefined as T
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
/** Fire a request and also surface the ETag header, for resources that support If-Match. */
|
||||
export async function apiRequestWithETag<T>(path: string, options?: RequestOptions): Promise<ApiResult<T>> {
|
||||
const response = await rawRequest(path, options)
|
||||
const etag = response.headers.get("ETag")
|
||||
const data = response.status === 204 ? (undefined as T) : ((await response.json()) as T)
|
||||
return { data, etag }
|
||||
}
|
||||
|
||||
/** Build a `?a=1&b=2` query string, dropping null/undefined/empty values. */
|
||||
export function buildQuery(params: object): string {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params) as [string, string | number | boolean | null | undefined][]) {
|
||||
if (value === null || value === undefined || value === "") continue
|
||||
search.set(key, String(value))
|
||||
}
|
||||
const qs = search.toString()
|
||||
return qs ? `?${qs}` : ""
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// 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 { 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<PagedResponse<Category>>("/categories")
|
||||
// },
|
||||
// tree() {
|
||||
// return apiRequest<CategoryTreeNode[]>("/categories?tree=true")
|
||||
// },
|
||||
// create(request: CreateCategoryRequest) {
|
||||
// return apiRequest<Category>("/categories", { method: "POST", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
function buildTree(categories: Category[]): CategoryTreeNode[] {
|
||||
const nodes = new Map<number, CategoryTreeNode>(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<PagedResponse<Category>> {
|
||||
const items = [...mockCategories].sort((a, b) => a.name.localeCompare(b.name))
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 200, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
|
||||
tree(): Promise<CategoryTreeNode[]> {
|
||||
return mockDelay(buildTree(mockCategories))
|
||||
},
|
||||
|
||||
create(request: CreateCategoryRequest): Promise<Category> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// 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.
|
||||
import { PagedResponse } from "@/types/common"
|
||||
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<PagedResponse<GrnSummary>>(`/grns${buildQuery(params)}`)
|
||||
// },
|
||||
//
|
||||
// get(grnId: number) {
|
||||
// return apiRequest<Grn>(`/grns/${grnId}`)
|
||||
// },
|
||||
//
|
||||
// async create(request: CreateGrnRequest) {
|
||||
// const { data } = await apiRequestWithETag<Grn>("/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<Grn>(`/grns/${grnId}`, { method: "PUT", body: request, ifMatch })
|
||||
// return data
|
||||
// },
|
||||
//
|
||||
// remove(grnId: number) {
|
||||
// return apiRequest<void>(`/grns/${grnId}`, { method: "DELETE" })
|
||||
// },
|
||||
//
|
||||
// confirm(grnId: number, idempotencyKey?: string) {
|
||||
// return apiRequest<ConfirmGrnResponse>(`/grns/${grnId}/confirm`, {
|
||||
// method: "POST",
|
||||
// body: {},
|
||||
// idempotencyKey,
|
||||
// })
|
||||
// },
|
||||
//
|
||||
// releaseLine(grnId: number, grnLineId: number, action: ReleaseAction) {
|
||||
// return apiRequest<ReleaseGrnLineResponse>(`/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<PagedResponse<GrnSummary>> {
|
||||
const term = params.q?.trim().toLowerCase()
|
||||
|
||||
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)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
const totalItems = filtered.length
|
||||
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems, totalPages },
|
||||
})
|
||||
},
|
||||
|
||||
get(grnId: number): Promise<Grn> {
|
||||
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<Grn> {
|
||||
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<ConfirmGrnResponse> {
|
||||
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<ReleaseGrnLineResponse> {
|
||||
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<Grn> {
|
||||
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<void> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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"
|
||||
import { EntityStatus, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CreateItemRequest,
|
||||
Item,
|
||||
ItemListItem,
|
||||
TrackingMode,
|
||||
UpdateItemReorderRequest,
|
||||
UpdateItemRequest,
|
||||
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<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
|
||||
// },
|
||||
// get(itemId: number) {
|
||||
// return apiRequestWithETag<Item>(`/items/${itemId}`)
|
||||
// },
|
||||
// create(request: CreateItemRequest) {
|
||||
// return apiRequestWithETag<Item>("/items", { method: "POST", body: request })
|
||||
// },
|
||||
// update(itemId: number, request: UpdateItemRequest, ifMatch: string) {
|
||||
// return apiRequestWithETag<Item>(`/items/${itemId}`, { method: "PUT", body: request, ifMatch })
|
||||
// },
|
||||
// updateStatus(itemId: number, status: EntityStatus) {
|
||||
// return apiRequest<void>(`/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<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, { method: "PUT", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface ListItemsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
status?: EntityStatus
|
||||
categoryId?: number
|
||||
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<PagedResponse<ItemListItem>> {
|
||||
const term = params.q?.trim().toLowerCase()
|
||||
const filtered = mockItems
|
||||
.filter((i) => !params.status || i.status === params.status)
|
||||
.filter((i) => !params.categoryId || i.categoryId === params.categoryId)
|
||||
.filter((i) => !params.trackingMode || i.trackingMode === params.trackingMode)
|
||||
.filter((i) => !term || `${i.sku} ${i.name}`.toLowerCase().includes(term))
|
||||
.sort((a, b) => a.sku.localeCompare(b.sku))
|
||||
.map(toListItem)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
|
||||
get(itemId: number): Promise<ApiResult<Item>> {
|
||||
const item = mockItems.find((i) => i.itemId === itemId)
|
||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
||||
return mockDelay({ data: item, etag: String(getItemVersion(itemId)) })
|
||||
},
|
||||
|
||||
create(request: CreateItemRequest): Promise<ApiResult<Item>> {
|
||||
const sku = request.sku.trim()
|
||||
if (!sku) return Promise.reject(new Error("SKU is required."))
|
||||
if (skuTaken(sku)) {
|
||||
return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" }))
|
||||
}
|
||||
const item: Item = {
|
||||
itemId: allocateItemId(),
|
||||
sku,
|
||||
name: request.name.trim(),
|
||||
description: request.description?.trim() || null,
|
||||
categoryId: request.categoryId,
|
||||
baseUomId: request.baseUomId,
|
||||
defaultVendorId: request.defaultVendorId ?? null,
|
||||
itemType: request.itemType,
|
||||
trackingMode: request.trackingMode,
|
||||
taxClass: request.taxClass?.trim() || null,
|
||||
status: "Active",
|
||||
reorder: [],
|
||||
conversions: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
}
|
||||
mockItems.push(item)
|
||||
initItemVersion(item.itemId)
|
||||
return mockDelay({ data: item, etag: "1" })
|
||||
},
|
||||
|
||||
update(itemId: number, request: UpdateItemRequest, ifMatch: string): Promise<ApiResult<Item>> {
|
||||
const item = mockItems.find((i) => i.itemId === itemId)
|
||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
||||
if (String(getItemVersion(itemId)) !== ifMatch) {
|
||||
return Promise.reject(Object.assign(new Error("The item was modified by another request."), { code: "CONCURRENCY_CONFLICT" }))
|
||||
}
|
||||
const sku = request.sku.trim()
|
||||
if (!sku) return Promise.reject(new Error("SKU is required."))
|
||||
if (skuTaken(sku, itemId)) {
|
||||
return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" }))
|
||||
}
|
||||
|
||||
item.sku = sku
|
||||
item.name = request.name.trim()
|
||||
item.description = request.description?.trim() || null
|
||||
item.categoryId = request.categoryId
|
||||
item.baseUomId = request.baseUomId
|
||||
item.defaultVendorId = request.defaultVendorId ?? null
|
||||
item.itemType = request.itemType
|
||||
item.trackingMode = request.trackingMode
|
||||
item.taxClass = request.taxClass?.trim() || null
|
||||
item.updatedAt = new Date().toISOString()
|
||||
|
||||
const next = bumpItemVersion(itemId)
|
||||
return mockDelay({ data: item, etag: String(next) })
|
||||
},
|
||||
|
||||
updateStatus(itemId: number, status: EntityStatus): Promise<void> {
|
||||
const item = mockItems.find((i) => i.itemId === itemId)
|
||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
||||
item.status = status
|
||||
item.updatedAt = new Date().toISOString()
|
||||
bumpItemVersion(itemId)
|
||||
return mockDelay(undefined)
|
||||
},
|
||||
|
||||
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: UpdateItemReorderRequest["settings"] }> {
|
||||
const item = mockItems.find((i) => i.itemId === itemId)
|
||||
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
|
||||
item.reorder = request.settings
|
||||
item.updatedAt = new Date().toISOString()
|
||||
bumpItemVersion(itemId)
|
||||
return mockDelay({ settings: item.reorder })
|
||||
},
|
||||
|
||||
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
|
||||
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 })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
// Temporary in-memory sample data so the GRN screens can be reviewed as pure UI
|
||||
// without a running backend. Shapes mirror docs/11-BACKEND-PHASE1.md exactly.
|
||||
//
|
||||
// This file (and the "MOCK" blocks in the sibling lib/api/*.ts files) is meant
|
||||
// to be deleted once the real GRN backend exists — the original fetch-based
|
||||
// implementations are left commented out in each file for that switch-back.
|
||||
import { Bin, Category, Item, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
import { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement"
|
||||
import { Grn } from "@/types/grn"
|
||||
import {
|
||||
AdjustmentStatus,
|
||||
CountStatus,
|
||||
CountType,
|
||||
LedgerDirection,
|
||||
LedgerEntry,
|
||||
ReasonCode,
|
||||
TransferStatus,
|
||||
} from "@/types/stock"
|
||||
|
||||
export const mockWarehouses: Warehouse[] = [
|
||||
{ warehouseId: 1, code: "WH-MAIN", name: "Main Warehouse - Negombo" },
|
||||
{ warehouseId: 2, code: "WH-COLOMBO", name: "Colombo Distribution Center" },
|
||||
]
|
||||
|
||||
export const mockBins: Bin[] = [
|
||||
{ binId: 1, warehouseId: 1, code: "A-01-01", binType: "Shelf" },
|
||||
{ binId: 2, warehouseId: 1, code: "A-01-02", binType: "Shelf" },
|
||||
{ binId: 3, warehouseId: 1, code: "B-02-01", binType: "Pallet" },
|
||||
{ binId: 4, warehouseId: 2, code: "C-01-01", binType: "Shelf" },
|
||||
{ binId: 5, warehouseId: 2, code: "C-01-02", binType: "Shelf" },
|
||||
]
|
||||
|
||||
let nextWarehouseId = 3
|
||||
let nextBinId = 6
|
||||
|
||||
export function allocateWarehouseId() {
|
||||
return nextWarehouseId++
|
||||
}
|
||||
|
||||
export function allocateBinId() {
|
||||
return nextBinId++
|
||||
}
|
||||
|
||||
export const mockUoms: Uom[] = [
|
||||
{ uomId: 1, name: "EA" },
|
||||
{ uomId: 2, name: "Box-12" },
|
||||
{ uomId: 3, name: "KG" },
|
||||
]
|
||||
|
||||
let nextUomId = 4
|
||||
|
||||
export function allocateUomId() {
|
||||
return nextUomId++
|
||||
}
|
||||
|
||||
export const mockCategories: Category[] = [
|
||||
{ categoryId: 3, name: "Hardware", parentId: null },
|
||||
{ categoryId: 12, name: "Fasteners", parentId: 3 },
|
||||
{ categoryId: 20, name: "Power Tools", parentId: null },
|
||||
]
|
||||
|
||||
let nextCategoryId = 21
|
||||
|
||||
export function allocateCategoryId() {
|
||||
return nextCategoryId++
|
||||
}
|
||||
|
||||
export const mockVendors: Vendor[] = [
|
||||
{
|
||||
vendorId: 5,
|
||||
code: "VN-005",
|
||||
name: "Lanka Steel Traders (Pvt) Ltd",
|
||||
terms: "NET30",
|
||||
taxReg: "134567890-7000",
|
||||
currency: "LKR",
|
||||
status: "Active",
|
||||
createdAt: "2026-06-01T08:00:00Z",
|
||||
updatedAt: null,
|
||||
},
|
||||
{
|
||||
vendorId: 8,
|
||||
code: "VN-008",
|
||||
name: "Ceylon Hardware Supplies",
|
||||
terms: "NET45",
|
||||
taxReg: "198765432-1000",
|
||||
currency: "LKR",
|
||||
status: "Active",
|
||||
createdAt: "2026-06-05T08:00:00Z",
|
||||
updatedAt: null,
|
||||
},
|
||||
]
|
||||
|
||||
// A handful more so the vendors list has something real to paginate/search through.
|
||||
const extraVendorNames = [
|
||||
"Colombo Timber & Plywood Co.",
|
||||
"Kandy Electrical Distributors",
|
||||
"Galle Packaging Solutions",
|
||||
"Jaffna Agro Supplies",
|
||||
"Negombo Fasteners (Pvt) Ltd",
|
||||
"Kurunegala Paints & Coatings",
|
||||
"Trinco Marine Hardware",
|
||||
"Ratnapura Gems & Tools",
|
||||
]
|
||||
for (let i = 0; i < extraVendorNames.length; i++) {
|
||||
const vendorId = 9 + i
|
||||
mockVendors.push({
|
||||
vendorId,
|
||||
code: `VN-${String(vendorId).padStart(3, "0")}`,
|
||||
name: extraVendorNames[i],
|
||||
terms: i % 2 === 0 ? "NET30" : "NET60",
|
||||
taxReg: `1${String(10000000 + vendorId * 137)}-${7000 + i}`,
|
||||
currency: "LKR",
|
||||
status: i % 5 === 0 ? "Inactive" : "Active",
|
||||
createdAt: new Date(Date.UTC(2026, 6, 1 + i, 9, 0, 0)).toISOString(),
|
||||
updatedAt: null,
|
||||
})
|
||||
}
|
||||
|
||||
let nextVendorId = 9 + extraVendorNames.length
|
||||
|
||||
export function allocateVendorId() {
|
||||
return nextVendorId++
|
||||
}
|
||||
|
||||
// Concurrency token per vendor (stands in for the real backend's xmin/RowVersion
|
||||
// ETag, docs/11 §1.6) — kept out-of-band since the public Vendor type has no
|
||||
// version field of its own (it travels as an HTTP ETag header, not a body field).
|
||||
const mockVendorVersions = new Map<number, number>(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<number, number>(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<number, number>(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<T>(value: T, ms = 300): Promise<T> {
|
||||
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<string, { itemId: number; warehouseId: number }>()
|
||||
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++
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// 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 { PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CancelPurchaseOrderRequest,
|
||||
CreatePurchaseOrderRequest,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderStatus,
|
||||
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<PagedResponse<PurchaseOrderSummary>>(`/purchase-orders${buildQuery(params)}`)
|
||||
// },
|
||||
// get(poId: number) {
|
||||
// return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}`)
|
||||
// },
|
||||
// getWithETag(poId: number) {
|
||||
// return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`)
|
||||
// },
|
||||
// create(request: CreatePurchaseOrderRequest) {
|
||||
// return apiRequestWithETag<PurchaseOrder>("/purchase-orders", { method: "POST", body: request })
|
||||
// },
|
||||
// update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string) {
|
||||
// return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch })
|
||||
// },
|
||||
// cancel(poId: number, request: CancelPurchaseOrderRequest) {
|
||||
// return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface ListPurchaseOrdersParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
status?: PurchaseOrderStatus
|
||||
vendorId?: number
|
||||
}
|
||||
|
||||
/** FR-PROC-05 (Option B): freely editable while open — not once fully received/closed/cancelled. */
|
||||
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<PagedResponse<PurchaseOrderSummary>> {
|
||||
const term = params.q?.trim().toLowerCase()
|
||||
const filtered = mockPurchaseOrders
|
||||
.filter((po) => !params.status || po.status === params.status)
|
||||
.filter((po) => !params.vendorId || po.vendorId === params.vendorId)
|
||||
.filter((po) => !term || `${po.docNo} ${po.vendorId}`.toLowerCase().includes(term))
|
||||
.sort((a, b) => b.poId - a.poId)
|
||||
.map(
|
||||
(po): PurchaseOrderSummary => ({
|
||||
poId: po.poId,
|
||||
docNo: po.docNo,
|
||||
vendorId: po.vendorId,
|
||||
status: po.status,
|
||||
approvalRequired: po.approvalRequired,
|
||||
createdAt: po.createdAt,
|
||||
totals: po.totals,
|
||||
})
|
||||
)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
|
||||
get(poId: number): Promise<PurchaseOrder> {
|
||||
const po = mockPurchaseOrders.find((p) => p.poId === poId)
|
||||
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
|
||||
return mockDelay(po)
|
||||
},
|
||||
|
||||
getWithETag(poId: number): Promise<ApiResult<PurchaseOrder>> {
|
||||
const po = mockPurchaseOrders.find((p) => p.poId === poId)
|
||||
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
|
||||
return mockDelay({ data: po, etag: String(getPoVersion(poId)) })
|
||||
},
|
||||
|
||||
create(request: CreatePurchaseOrderRequest): Promise<ApiResult<PurchaseOrder>> {
|
||||
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
|
||||
const poId = allocatePoId()
|
||||
// FR-PROC-04: approvalRequired defaults false → auto-approved on creation.
|
||||
const po: PurchaseOrder = {
|
||||
poId,
|
||||
docNo: `PO-2026-${String(poId).padStart(5, "0")}`,
|
||||
vendorId: request.vendorId,
|
||||
requisitionId: request.requisitionId ?? null,
|
||||
status: "Approved",
|
||||
approvalRequired: false,
|
||||
createdBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
totals: computeTotals(request.lines),
|
||||
lines: request.lines.map((l, i) => ({
|
||||
poLineId: 900 + poId * 10 + i,
|
||||
itemId: l.itemId,
|
||||
uomId: l.uomId,
|
||||
warehouseId: l.warehouseId,
|
||||
qty: l.qty,
|
||||
unitPrice: l.unitPrice,
|
||||
tax: l.tax,
|
||||
qtyReceived: 0,
|
||||
})),
|
||||
}
|
||||
mockPurchaseOrders.push(po)
|
||||
initPoVersion(poId)
|
||||
return mockDelay({ data: po, etag: "1" })
|
||||
},
|
||||
|
||||
update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string): Promise<ApiResult<PurchaseOrder>> {
|
||||
const po = mockPurchaseOrders.find((p) => p.poId === poId)
|
||||
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
|
||||
if (!isPoEditable(po.status)) {
|
||||
return Promise.reject(Object.assign(new Error(`${po.docNo} is ${po.status} and can no longer be edited.`), { code: "PO_NOT_EDITABLE" }))
|
||||
}
|
||||
if (String(getPoVersion(poId)) !== ifMatch) {
|
||||
return Promise.reject(Object.assign(new Error("The purchase order was modified by another request."), { code: "CONCURRENCY_CONFLICT" }))
|
||||
}
|
||||
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
|
||||
|
||||
const priorQtyReceived = new Map(po.lines.map((l) => [l.poLineId, l.qtyReceived]))
|
||||
po.vendorId = request.vendorId
|
||||
po.requisitionId = request.requisitionId ?? null
|
||||
po.totals = computeTotals(request.lines, po.totals.currency)
|
||||
po.lines = request.lines.map((l, i) => {
|
||||
// Preserve qtyReceived for lines that already existed (edit-while-open must not erase receipt progress).
|
||||
const existingLineId = po.lines[i]?.poLineId
|
||||
return {
|
||||
poLineId: existingLineId ?? 900 + poId * 10 + i,
|
||||
itemId: l.itemId,
|
||||
uomId: l.uomId,
|
||||
warehouseId: l.warehouseId,
|
||||
qty: l.qty,
|
||||
unitPrice: l.unitPrice,
|
||||
tax: l.tax,
|
||||
qtyReceived: existingLineId ? (priorQtyReceived.get(existingLineId) ?? 0) : 0,
|
||||
}
|
||||
})
|
||||
po.updatedAt = new Date().toISOString()
|
||||
|
||||
const next = bumpPoVersion(poId)
|
||||
return mockDelay({ data: po, etag: String(next) })
|
||||
},
|
||||
|
||||
cancel(poId: number, request: CancelPurchaseOrderRequest): Promise<PurchaseOrder> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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.
|
||||
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<PagedResponse<PurchaseReturnSummary>>("/purchase-returns")
|
||||
// },
|
||||
// get(returnId: number) {
|
||||
// return apiRequest<PurchaseReturn>(`/purchase-returns/${returnId}`)
|
||||
// },
|
||||
// create(request: CreatePurchaseReturnRequest) {
|
||||
// return apiRequest<PurchaseReturn>("/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<PagedResponse<PurchaseReturnSummary>> {
|
||||
const items = [...mockPurchaseReturns].sort((a, b) => b.returnId - a.returnId).map(toSummary)
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
|
||||
get(returnId: number): Promise<PurchaseReturn> {
|
||||
const r = mockPurchaseReturns.find((x) => x.returnId === returnId)
|
||||
if (!r) return Promise.reject(new Error(`Mock purchase return ${returnId} not found`))
|
||||
return mockDelay(r)
|
||||
},
|
||||
|
||||
create(request: CreatePurchaseReturnRequest): Promise<PurchaseReturn> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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).
|
||||
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<PagedResponse<ReasonCode>>(`/reason-codes${buildQuery({ context })}`)
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export const reasonCodesApi = {
|
||||
list(context?: ReasonCodeContext): Promise<PagedResponse<ReasonCode>> {
|
||||
const items = mockReasonCodes.filter((r) => !context || r.context === context)
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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 { 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<PagedResponse<RequisitionSummary>>(`/requisitions${buildQuery(params)}`)
|
||||
// },
|
||||
// get(requisitionId: number) {
|
||||
// return apiRequest<Requisition>(`/requisitions/${requisitionId}`)
|
||||
// },
|
||||
// create(request: CreateRequisitionRequest) {
|
||||
// return apiRequest<Requisition>("/requisitions", { method: "POST", body: request })
|
||||
// },
|
||||
// submit(requisitionId: number) {
|
||||
// return apiRequest<Requisition>(`/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<PagedResponse<RequisitionSummary>> {
|
||||
const filtered = mockRequisitions
|
||||
.filter((r) => !params.status || r.status === params.status)
|
||||
.map(toSummary)
|
||||
.sort((a, b) => b.requisitionId - a.requisitionId)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
|
||||
get(requisitionId: number): Promise<Requisition> {
|
||||
const r = mockRequisitions.find((x) => x.requisitionId === requisitionId)
|
||||
if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`))
|
||||
return mockDelay(r)
|
||||
},
|
||||
|
||||
create(request: CreateRequisitionRequest): Promise<Requisition> {
|
||||
if (request.lines.length === 0) {
|
||||
return Promise.reject(new Error("A requisition needs at least one line."))
|
||||
}
|
||||
const requisition: Requisition = {
|
||||
requisitionId: allocateRequisitionId(),
|
||||
docNo: "",
|
||||
status: "Draft",
|
||||
requestedBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: request.lines.map((l) => ({ reqLineId: allocateReqLineId(), itemId: l.itemId, qty: l.qty, requiredBy: l.requiredBy })),
|
||||
}
|
||||
requisition.docNo = `PR-2026-${String(requisition.requisitionId).padStart(5, "0")}`
|
||||
mockRequisitions.push(requisition)
|
||||
return mockDelay(requisition)
|
||||
},
|
||||
|
||||
submit(requisitionId: number): Promise<Requisition> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// 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.
|
||||
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<PagedResponse<RfqSummary>>("/rfqs")
|
||||
// },
|
||||
// get(rfqId: number) {
|
||||
// return apiRequest<Rfq>(`/rfqs/${rfqId}`)
|
||||
// },
|
||||
// create(request: CreateRfqRequest) {
|
||||
// return apiRequest<Rfq>("/rfqs", { method: "POST", body: request })
|
||||
// },
|
||||
// addQuotation(rfqId: number, request: CreateQuotationRequest) {
|
||||
// return apiRequest<Quotation>(`/rfqs/${rfqId}/quotations`, { method: "POST", body: request })
|
||||
// },
|
||||
// comparison(rfqId: number) {
|
||||
// return apiRequest<RfqComparison>(`/rfqs/${rfqId}/comparison`)
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
function toSummary(r: Rfq): RfqSummary {
|
||||
return {
|
||||
rfqId: r.rfqId,
|
||||
docNo: r.docNo,
|
||||
requisitionId: r.requisitionId,
|
||||
status: r.status,
|
||||
vendorIds: r.vendorIds,
|
||||
createdAt: r.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
export const rfqsApi = {
|
||||
list(): Promise<PagedResponse<RfqSummary>> {
|
||||
const items = [...mockRfqs].sort((a, b) => b.rfqId - a.rfqId).map(toSummary)
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
|
||||
get(rfqId: number): Promise<Rfq> {
|
||||
const r = mockRfqs.find((x) => x.rfqId === rfqId)
|
||||
if (!r) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
|
||||
return mockDelay(r)
|
||||
},
|
||||
|
||||
create(request: CreateRfqRequest): Promise<Rfq> {
|
||||
if (request.vendorIds.length === 0) return Promise.reject(new Error("Select at least one vendor."))
|
||||
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
|
||||
const rfq: Rfq = {
|
||||
rfqId: allocateRfqId(),
|
||||
docNo: "",
|
||||
requisitionId: request.requisitionId ?? null,
|
||||
status: "Open",
|
||||
vendorIds: request.vendorIds,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: request.lines.map((l) => ({ rfqLineId: allocateRfqLineId(), itemId: l.itemId, qty: l.qty })),
|
||||
}
|
||||
rfq.docNo = `RFQ-2026-${String(rfq.rfqId).padStart(5, "0")}`
|
||||
mockRfqs.push(rfq)
|
||||
return mockDelay(rfq)
|
||||
},
|
||||
|
||||
addQuotation(rfqId: number, request: CreateQuotationRequest): Promise<Quotation> {
|
||||
const rfq = mockRfqs.find((x) => x.rfqId === rfqId)
|
||||
if (!rfq) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
|
||||
if (!rfq.vendorIds.includes(request.vendorId)) {
|
||||
return Promise.reject(new Error("This vendor was not invited to the RFQ."))
|
||||
}
|
||||
const quotation: Quotation = {
|
||||
quotationId: allocateQuotationId(),
|
||||
rfqId,
|
||||
vendorId: request.vendorId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: request.lines,
|
||||
}
|
||||
mockQuotations.push(quotation)
|
||||
return mockDelay(quotation)
|
||||
},
|
||||
|
||||
comparison(rfqId: number): Promise<RfqComparison> {
|
||||
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 })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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
|
||||
// assumed-extension deviation as GRN (see Frontend/PROGRESS.md §5).
|
||||
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"
|
||||
|
||||
// --- 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<PagedResponse<StockAdjustmentSummary>>(`/stock-adjustments${buildQuery(params)}`)
|
||||
// },
|
||||
// get(adjustmentId: number) {
|
||||
// return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}`)
|
||||
// },
|
||||
// create(request: CreateAdjustmentRequest) {
|
||||
// return apiRequest<StockAdjustment>("/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<PagedResponse<StockAdjustmentSummary>> {
|
||||
const filtered = mockStockAdjustments
|
||||
.filter((a) => !params.warehouseId || a.warehouseId === params.warehouseId)
|
||||
.map(toSummary)
|
||||
.sort((a, b) => b.adjustmentId - a.adjustmentId)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
|
||||
get(adjustmentId: number): Promise<StockAdjustment> {
|
||||
const a = mockStockAdjustments.find((x) => x.adjustmentId === adjustmentId)
|
||||
if (!a) return Promise.reject(new Error(`Mock adjustment ${adjustmentId} not found`))
|
||||
return mockDelay(a)
|
||||
},
|
||||
|
||||
create(request: CreateAdjustmentRequest): Promise<StockAdjustment> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 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).
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CountStatus,
|
||||
CreateCountRequest,
|
||||
EnterCountsRequest,
|
||||
EnterCountsResponse,
|
||||
PostCountResponse,
|
||||
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<PagedResponse<StockCountSummary>>(`/stock-counts${buildQuery(params)}`)
|
||||
// },
|
||||
// get(countId: number) {
|
||||
// return apiRequest<StockCount>(`/stock-counts/${countId}`)
|
||||
// },
|
||||
// create(request: CreateCountRequest) {
|
||||
// return apiRequest<StockCount>("/stock-counts", { method: "POST", body: request })
|
||||
// },
|
||||
// enterCounts(countId: number, request: EnterCountsRequest) {
|
||||
// return apiRequest<EnterCountsResponse>(`/stock-counts/${countId}/counts`, { method: "PUT", body: request })
|
||||
// },
|
||||
// post(countId: number) {
|
||||
// return apiRequest<PostCountResponse>(`/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<PagedResponse<StockCountSummary>> {
|
||||
const filtered = mockStockCounts
|
||||
.filter((c) => !params.warehouseId || c.warehouseId === params.warehouseId)
|
||||
.map(toSummary)
|
||||
.sort((a, b) => b.countId - a.countId)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
|
||||
get(countId: number): Promise<StockCount> {
|
||||
const c = mockStockCounts.find((x) => x.countId === countId)
|
||||
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
|
||||
return mockDelay(c)
|
||||
},
|
||||
|
||||
create(request: CreateCountRequest): Promise<StockCount> {
|
||||
const countId = allocateCountId()
|
||||
const count = {
|
||||
countId,
|
||||
docNo: `CNT-2026-${String(countId).padStart(5, "0")}`,
|
||||
warehouseId: request.warehouseId,
|
||||
countType: request.countType,
|
||||
status: "Draft" as CountStatus,
|
||||
createdBy: 17,
|
||||
createdAt: new Date().toISOString(),
|
||||
lines: request.itemIds.map((itemId) => ({
|
||||
countLineId: allocateCountLineId(),
|
||||
itemId,
|
||||
binId: null,
|
||||
systemQty: computeOnHand(itemId, request.warehouseId).onHand,
|
||||
countedQty: null,
|
||||
variance: null,
|
||||
})),
|
||||
}
|
||||
mockStockCounts.push(count)
|
||||
return mockDelay(count)
|
||||
},
|
||||
|
||||
enterCounts(countId: number, request: EnterCountsRequest): Promise<EnterCountsResponse> {
|
||||
const c = mockStockCounts.find((x) => x.countId === countId)
|
||||
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
|
||||
|
||||
for (const input of request.lines) {
|
||||
const line = c.lines.find((l) => l.countLineId === input.countLineId)
|
||||
if (!line) continue
|
||||
line.countedQty = input.countedQty
|
||||
line.variance = Math.round((input.countedQty - line.systemQty) * 100) / 100
|
||||
}
|
||||
return mockDelay({ lines: c.lines })
|
||||
},
|
||||
|
||||
post(countId: number): Promise<PostCountResponse> {
|
||||
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 })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// 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).
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CreateTransferRequest,
|
||||
DispatchTransferResponse,
|
||||
ReceiveTransferLineInput,
|
||||
ReceiveTransferResponse,
|
||||
StockTransfer,
|
||||
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<PagedResponse<StockTransferSummary>>(`/stock-transfers${buildQuery(params)}`)
|
||||
// },
|
||||
// get(transferId: number) {
|
||||
// return apiRequest<StockTransfer>(`/stock-transfers/${transferId}`)
|
||||
// },
|
||||
// create(request: CreateTransferRequest) {
|
||||
// return apiRequest<StockTransfer>("/stock-transfers", { method: "POST", body: request })
|
||||
// },
|
||||
// dispatch(transferId: number) {
|
||||
// return apiRequest<DispatchTransferResponse>(`/stock-transfers/${transferId}/dispatch`, { method: "POST", body: {} })
|
||||
// },
|
||||
// receive(transferId: number, lines: ReceiveTransferLineInput[]) {
|
||||
// return apiRequest<ReceiveTransferResponse>(`/stock-transfers/${transferId}/receive`, {
|
||||
// method: "POST",
|
||||
// body: { lines },
|
||||
// })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface ListTransfersParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: TransferStatus
|
||||
srcWarehouseId?: number
|
||||
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<PagedResponse<StockTransferSummary>> {
|
||||
const filtered = mockStockTransfers
|
||||
.filter((t) => !params.status || t.status === params.status)
|
||||
.filter((t) => !params.srcWarehouseId || t.srcWarehouseId === params.srcWarehouseId)
|
||||
.filter((t) => !params.destWarehouseId || t.destWarehouseId === params.destWarehouseId)
|
||||
.map(toSummary)
|
||||
.sort((a, b) => b.transferId - a.transferId)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
|
||||
})
|
||||
},
|
||||
|
||||
get(transferId: number): Promise<StockTransfer> {
|
||||
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<StockTransfer> {
|
||||
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<DispatchTransferResponse> {
|
||||
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<ReceiveTransferResponse> {
|
||||
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 })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// 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.
|
||||
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"
|
||||
|
||||
// --- 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<OnHand>(`/stock/on-hand${buildQuery({ itemId, warehouseId })}`)
|
||||
// },
|
||||
//
|
||||
// ledger(params: LedgerQuery) {
|
||||
// return apiRequest<PagedResponse<LedgerEntry>>(`/stock/ledger${buildQuery(params)}`)
|
||||
// },
|
||||
//
|
||||
// valuation(itemId: number, warehouseId: number) {
|
||||
// return apiRequest<Valuation>(`/stock/valuation${buildQuery({ itemId, warehouseId })}`)
|
||||
// },
|
||||
//
|
||||
// reorderAlerts(warehouseId?: number) {
|
||||
// return apiRequest<PagedResponse<ReorderAlert>>(`/stock/reorder-alerts${buildQuery({ warehouseId })}`)
|
||||
// },
|
||||
//
|
||||
// createReorderRequisition(itemId: number, warehouseId: number) {
|
||||
// return apiRequest<ReorderRequisitionResponse>(
|
||||
// `/stock/reorder-alerts/${itemId}/requisition${buildQuery({ warehouseId })}`,
|
||||
// { method: "POST", body: {} }
|
||||
// )
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface LedgerQuery {
|
||||
itemId?: number
|
||||
warehouseId?: number
|
||||
from?: string
|
||||
to?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const stockApi = {
|
||||
onHand(itemId: number, warehouseId: number): Promise<OnHand> {
|
||||
const computed = computeOnHand(itemId, warehouseId)
|
||||
return mockDelay({
|
||||
itemId,
|
||||
warehouseId,
|
||||
...computed,
|
||||
asOf: new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
|
||||
/** Every item/warehouse pair currently on record — the Enquiry screen's row source. */
|
||||
onHandList(): Promise<OnHand[]> {
|
||||
const rows = knownStockKeys().map(({ itemId, warehouseId }) => ({
|
||||
itemId,
|
||||
warehouseId,
|
||||
...computeOnHand(itemId, warehouseId),
|
||||
asOf: new Date().toISOString(),
|
||||
}))
|
||||
return mockDelay(rows)
|
||||
},
|
||||
|
||||
ledger(params: LedgerQuery): Promise<PagedResponse<LedgerEntry>> {
|
||||
const from = params.from ? new Date(params.from).getTime() : null
|
||||
const to = params.to ? new Date(params.to).getTime() : null
|
||||
|
||||
const filtered = mockStockLedger
|
||||
.filter((e) => !params.itemId || e.itemId === params.itemId)
|
||||
.filter((e) => !params.warehouseId || e.warehouseId === params.warehouseId)
|
||||
.filter((e) => from === null || new Date(e.createdAt).getTime() >= from)
|
||||
.filter((e) => to === null || new Date(e.createdAt).getTime() <= to)
|
||||
.sort((a, b) => b.ledgerId - a.ledgerId)
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
totalItems: filtered.length,
|
||||
totalPages: pageSize <= 0 ? 0 : Math.ceil(filtered.length / pageSize),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
valuation(itemId: number, warehouseId: number): Promise<Valuation> {
|
||||
const layers = mockStockLayers
|
||||
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId && l.qtyRemaining > 0)
|
||||
.sort((a, b) => new Date(a.receiptDate).getTime() - new Date(b.receiptDate).getTime())
|
||||
.map((l) => ({
|
||||
layerId: l.layerId,
|
||||
qtyRemaining: l.qtyRemaining,
|
||||
unitCost: l.unitCost,
|
||||
value: Math.round(l.qtyRemaining * l.unitCost * 100) / 100,
|
||||
receiptDate: l.receiptDate,
|
||||
}))
|
||||
|
||||
const totalQty = layers.reduce((sum, l) => sum + l.qtyRemaining, 0)
|
||||
const totalValue = Math.round(layers.reduce((sum, l) => sum + l.value, 0) * 100) / 100
|
||||
|
||||
return mockDelay({
|
||||
itemId,
|
||||
warehouseId,
|
||||
layers,
|
||||
totalQty,
|
||||
totalValue,
|
||||
currency: "LKR",
|
||||
costingMethod: "FIFO",
|
||||
})
|
||||
},
|
||||
|
||||
reorderAlerts(warehouseId?: number): Promise<PagedResponse<ReorderAlert>> {
|
||||
const items = mockItemReorders
|
||||
.filter((r) => !warehouseId || r.warehouseId === warehouseId)
|
||||
.map((r) => ({ ...r, available: computeOnHand(r.itemId, r.warehouseId).available }))
|
||||
.filter((r) => r.available <= r.reorderPoint)
|
||||
.map((r) => ({
|
||||
itemId: r.itemId,
|
||||
warehouseId: r.warehouseId,
|
||||
available: r.available,
|
||||
reorderPoint: r.reorderPoint,
|
||||
reorderQty: r.reorderQty,
|
||||
suggestedRequisitionQty: r.reorderQty,
|
||||
}))
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
|
||||
createReorderRequisition(itemId: number, warehouseId: number): Promise<ReorderRequisitionResponse> {
|
||||
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",
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// One typed client method per UOM endpoint (docs/11-BACKEND-PHASE1.md §2.2, FR-MD-02).
|
||||
// `list` also backs the GRN/PO line UOM picker built in earlier sessions.
|
||||
//
|
||||
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
|
||||
// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens
|
||||
// can be reviewed without a running backend. Restore the commented block and
|
||||
// delete the mock block once Backend/PROGRESS.md §1 (Master Data) exists.
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { CreateUomRequest, Uom } from "@/types/master-data"
|
||||
import { allocateUomId, mockDelay, mockUoms } from "@/lib/api/mock-data"
|
||||
|
||||
// --- Real implementation (restore when the backend exists) --------------------
|
||||
// import { apiRequest } from "@/lib/api-client"
|
||||
//
|
||||
// export const uomsApi = {
|
||||
// list() {
|
||||
// return apiRequest<PagedResponse<Uom>>("/uoms")
|
||||
// },
|
||||
// create(request: CreateUomRequest) {
|
||||
// return apiRequest<Uom>("/uoms", { method: "POST", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export const uomsApi = {
|
||||
list(): Promise<PagedResponse<Uom>> {
|
||||
return mockDelay({
|
||||
items: mockUoms,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: mockUoms.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
|
||||
create(request: CreateUomRequest): Promise<Uom> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// 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"
|
||||
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<PagedResponse<Vendor>>(`/vendors${buildQuery(params)}`)
|
||||
// },
|
||||
// get(vendorId: number) {
|
||||
// return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`)
|
||||
// },
|
||||
// create(request: CreateVendorRequest) {
|
||||
// return apiRequestWithETag<Vendor>("/vendors", { method: "POST", body: request })
|
||||
// },
|
||||
// update(vendorId: number, request: UpdateVendorRequest, ifMatch: string) {
|
||||
// return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`, { method: "PUT", body: request, ifMatch })
|
||||
// },
|
||||
// updateStatus(vendorId: number, status: EntityStatus) {
|
||||
// return apiRequest<void>(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
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
|
||||
|
||||
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<PagedResponse<Vendor>> {
|
||||
const term = params.q?.trim().toLowerCase()
|
||||
const filtered = mockVendors
|
||||
.filter((v) => !params.status || v.status === params.status)
|
||||
.filter((v) => !term || `${v.code} ${v.name}`.toLowerCase().includes(term))
|
||||
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const start = (page - 1) * pageSize
|
||||
const items = filtered.slice(start, start + pageSize)
|
||||
const totalItems = filtered.length
|
||||
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
|
||||
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page, pageSize, totalItems, totalPages },
|
||||
})
|
||||
},
|
||||
|
||||
get(vendorId: number): Promise<ApiResult<Vendor>> {
|
||||
const v = mockVendors.find((x) => x.vendorId === vendorId)
|
||||
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
|
||||
return mockDelay({ data: v, etag: String(getVendorVersion(vendorId)) })
|
||||
},
|
||||
|
||||
create(request: CreateVendorRequest): Promise<ApiResult<Vendor>> {
|
||||
const code = request.code.trim()
|
||||
if (!code) return Promise.reject(new Error("Vendor code is required."))
|
||||
if (codeTaken(code)) {
|
||||
return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
|
||||
}
|
||||
const vendor: Vendor = {
|
||||
vendorId: allocateVendorId(),
|
||||
code,
|
||||
name: request.name.trim(),
|
||||
terms: request.terms?.trim() || null,
|
||||
taxReg: request.taxReg?.trim() || null,
|
||||
currency: request.currency.trim().toUpperCase(),
|
||||
status: "Active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
}
|
||||
mockVendors.push(vendor)
|
||||
initVendorVersion(vendor.vendorId)
|
||||
return mockDelay({ data: vendor, etag: "1" })
|
||||
},
|
||||
|
||||
update(vendorId: number, request: UpdateVendorRequest, ifMatch: string): Promise<ApiResult<Vendor>> {
|
||||
const v = mockVendors.find((x) => x.vendorId === vendorId)
|
||||
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
|
||||
|
||||
if (String(getVendorVersion(vendorId)) !== ifMatch) {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error("The vendor was modified by another request."), { code: "CONCURRENCY_CONFLICT" })
|
||||
)
|
||||
}
|
||||
|
||||
const code = request.code.trim()
|
||||
if (!code) return Promise.reject(new Error("Vendor code is required."))
|
||||
if (codeTaken(code, vendorId)) {
|
||||
return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
|
||||
}
|
||||
|
||||
v.code = code
|
||||
v.name = request.name.trim()
|
||||
v.terms = request.terms?.trim() || null
|
||||
v.taxReg = request.taxReg?.trim() || null
|
||||
v.currency = request.currency.trim().toUpperCase()
|
||||
v.updatedAt = new Date().toISOString()
|
||||
|
||||
const next = bumpVendorVersion(vendorId)
|
||||
return mockDelay({ data: v, etag: String(next) })
|
||||
},
|
||||
|
||||
updateStatus(vendorId: number, status: EntityStatus): Promise<void> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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 { 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<PagedResponse<Warehouse>>("/warehouses")
|
||||
// },
|
||||
//
|
||||
// create(request: CreateWarehouseRequest) {
|
||||
// return apiRequest<Warehouse>("/warehouses", { method: "POST", body: request })
|
||||
// },
|
||||
//
|
||||
// listBins(warehouseId: number) {
|
||||
// return apiRequest<PagedResponse<Bin>>(`/warehouses/${warehouseId}/bins`)
|
||||
// },
|
||||
//
|
||||
// createBin(warehouseId: number, request: CreateBinRequest) {
|
||||
// return apiRequest<Bin>(`/warehouses/${warehouseId}/bins`, { method: "POST", body: request })
|
||||
// },
|
||||
// }
|
||||
|
||||
// --- Mock implementation (UI-only review) --------------------------------------
|
||||
export interface CreateWarehouseRequest {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface CreateBinRequest {
|
||||
code: string
|
||||
binType?: string | null
|
||||
}
|
||||
|
||||
export const warehousesApi = {
|
||||
list(): Promise<PagedResponse<Warehouse>> {
|
||||
return mockDelay({
|
||||
items: mockWarehouses,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: mockWarehouses.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
|
||||
get(warehouseId: number): Promise<Warehouse> {
|
||||
const wh = mockWarehouses.find((w) => w.warehouseId === warehouseId)
|
||||
if (!wh) return Promise.reject(new Error(`Mock warehouse ${warehouseId} not found`))
|
||||
return mockDelay(wh)
|
||||
},
|
||||
|
||||
create(request: CreateWarehouseRequest): Promise<Warehouse> {
|
||||
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)
|
||||
},
|
||||
|
||||
listBins(warehouseId: number): Promise<PagedResponse<Bin>> {
|
||||
const items = mockBins.filter((b) => b.warehouseId === warehouseId)
|
||||
return mockDelay({
|
||||
items,
|
||||
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
|
||||
})
|
||||
},
|
||||
|
||||
createBin(warehouseId: number, request: CreateBinRequest): Promise<Bin> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// "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.
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
import { mockDelay, mockReasonCodes, mockStockAdjustments, mockStockLedger } from "@/lib/api/mock-data"
|
||||
import { StockAdjustment } from "@/types/stock"
|
||||
|
||||
/** Reason-code strings treated as "wastage" (loss-type) causes, per docs/8.3. */
|
||||
const WASTAGE_CODES = new Set(["DMG", "LOSS", "EXPWO"])
|
||||
|
||||
export function wastageReasonCodeIds(): number[] {
|
||||
return mockReasonCodes.filter((r) => WASTAGE_CODES.has(r.code)).map((r) => r.reasonCodeId)
|
||||
}
|
||||
|
||||
export interface WastageRecord {
|
||||
adjustmentId: number
|
||||
adjLineId: number
|
||||
docNo: string
|
||||
createdAt: string
|
||||
warehouseId: number
|
||||
itemId: number
|
||||
binId: number | null
|
||||
reasonCodeId: number
|
||||
/** Positive quantity wasted (the underlying adjustment line is a negative qtyDelta). */
|
||||
qty: number
|
||||
/** FIFO cost of the wasted quantity, summed from the matching outbound ledger entries. */
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface ListWastageParams {
|
||||
warehouseId?: number
|
||||
reasonCodeId?: number
|
||||
}
|
||||
|
||||
export interface RecordWastageInput {
|
||||
warehouseId: number
|
||||
itemId: number
|
||||
binId?: number | null
|
||||
qty: number
|
||||
reasonCodeId: number
|
||||
}
|
||||
|
||||
export const wastageApi = {
|
||||
list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
|
||||
const wastageIds = new Set(wastageReasonCodeIds())
|
||||
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 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"
|
||||
)
|
||||
.reduce((sum, l) => sum + l.value, 0)
|
||||
|
||||
records.push({
|
||||
adjustmentId: adj.adjustmentId,
|
||||
adjLineId: line.adjLineId,
|
||||
docNo: adj.docNo,
|
||||
createdAt: adj.createdAt,
|
||||
warehouseId: adj.warehouseId,
|
||||
itemId: line.itemId,
|
||||
binId: line.binId,
|
||||
reasonCodeId: adj.reasonCodeId,
|
||||
qty: Math.abs(line.qtyDelta),
|
||||
value: Math.round(value * 100) / 100,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
records.sort((a, b) => b.adjustmentId - a.adjustmentId)
|
||||
return mockDelay(records)
|
||||
},
|
||||
|
||||
/** Records wastage as a single-line, negative-qtyDelta stock adjustment (FR-STK-07). */
|
||||
record(input: RecordWastageInput): Promise<StockAdjustment> {
|
||||
return stockAdjustmentsApi.create({
|
||||
warehouseId: input.warehouseId,
|
||||
reasonCodeId: input.reasonCodeId,
|
||||
lines: [{ itemId: input.itemId, binId: input.binId ?? null, qtyDelta: -Math.abs(input.qty) }],
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Bearer-token storage for the ERPCore API client (docs/20-FRONTEND.md §1).
|
||||
// Login (POST /auth/login) is not wired yet — reads simply return null until it is,
|
||||
// and the API client omits the Authorization header in that case.
|
||||
|
||||
const STORAGE_KEY = "erpcore.accessToken"
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
if (typeof window === "undefined") return null
|
||||
return window.localStorage.getItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
if (typeof window === "undefined") return
|
||||
if (token) window.localStorage.setItem(STORAGE_KEY, token)
|
||||
else window.localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// code -> user-facing message (docs/20-FRONTEND.md §3.2: "Keep a code -> message
|
||||
// map in lib/ so messages are consistent"). Falls back to the ProblemDetails
|
||||
// title/detail from the server when a code isn't in the map.
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
|
||||
const CODE_MESSAGES: Record<string, string> = {
|
||||
OVER_RECEIPT_TOLERANCE: "This quantity exceeds the purchase order's open quantity beyond the allowed tolerance.",
|
||||
STOCK_NEGATIVE_BLOCKED: "Not enough available stock for this action.",
|
||||
EXPIRED_BATCH_BLOCKED: "This batch has expired and cannot be issued.",
|
||||
ONHOLD_NOT_ISSUABLE: "This stock is on hold/quarantined and cannot be issued.",
|
||||
REASON_CODE_REQUIRED: "A reason code is required for this action.",
|
||||
PO_NOT_EDITABLE: "This purchase order can no longer be edited.",
|
||||
SKU_DUPLICATE: "That SKU already exists.",
|
||||
MASTER_IN_USE: "This record is referenced by other data and cannot be deleted.",
|
||||
CONCURRENCY_CONFLICT: "This record was changed by someone else. Reload and try again.",
|
||||
PRECONDITION_REQUIRED: "This record needs to be reloaded before it can be updated.",
|
||||
IDEMPOTENCY_REPLAY: "This request was already processed; showing the original result.",
|
||||
validation_error: "Please check the highlighted fields.",
|
||||
not_found: "The requested record was not found.",
|
||||
conflict: "This action conflicts with the record's current state.",
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.code && CODE_MESSAGES[error.code]) return CODE_MESSAGES[error.code]
|
||||
return error.detail || error.message || "Something went wrong."
|
||||
}
|
||||
if (error instanceof Error) return error.message
|
||||
return "Something went wrong."
|
||||
}
|
||||
|
||||
export function fieldErrors(error: unknown): Record<string, string> | null {
|
||||
if (error instanceof ApiError && error.errors) {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [field, messages] of Object.entries(error.errors)) {
|
||||
out[field] = messages[0]
|
||||
}
|
||||
return out
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Client-side UX validation only (docs/20-FRONTEND.md §3.1) — required fields,
|
||||
// format/range checks the browser can already see. Server-authoritative rules
|
||||
// (over-receipt tolerance, referential existence, concurrency) are never
|
||||
// re-implemented here; the server's ProblemDetails is the final word (§3.3).
|
||||
import { z } from "zod"
|
||||
import { TrackingMode } from "@/types/master-data"
|
||||
|
||||
export const grnHeaderSchema = z.object({
|
||||
warehouseId: z.number({ error: "Select a warehouse" }).positive("Select a warehouse"),
|
||||
vendorId: z.number().nullable(),
|
||||
poId: z.number().nullable(),
|
||||
})
|
||||
|
||||
export function validateLine(input: {
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
qty: string
|
||||
unitCost: string
|
||||
trackingMode: TrackingMode | null
|
||||
batchNo: string
|
||||
serialNumbersText: string
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
if (!input.itemId) errors.itemId = "Select an item"
|
||||
if (!input.uomId) errors.uomId = "Select a UOM"
|
||||
|
||||
const qty = Number(input.qty)
|
||||
if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0"
|
||||
|
||||
const unitCost = Number(input.unitCost)
|
||||
if (input.unitCost === "" || Number.isNaN(unitCost) || unitCost < 0) errors.unitCost = "Unit cost cannot be negative"
|
||||
|
||||
if (input.trackingMode === "Batch" && !input.batchNo.trim()) {
|
||||
errors.batchNo = "Batch number is required for this item"
|
||||
}
|
||||
|
||||
if (input.trackingMode === "Serial") {
|
||||
const serials = splitSerials(input.serialNumbersText)
|
||||
if (serials.length === 0) {
|
||||
errors.serialNumbers = "Enter one serial number per unit"
|
||||
} else if (!Number.isNaN(qty) && serials.length !== qty) {
|
||||
errors.serialNumbers = `Enter exactly ${qty || 0} serial number(s) — got ${serials.length}`
|
||||
} else if (new Set(serials).size !== serials.length) {
|
||||
errors.serialNumbers = "Serial numbers must be unique"
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export function splitSerials(text: string): string[] {
|
||||
return text
|
||||
.split(/[\n,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Client-side UX validation only (docs/20-FRONTEND.md §3.1) — required fields,
|
||||
// format/range checks the browser can already see. Server-authoritative rules
|
||||
// (SKU uniqueness, master-in-use on delete, concurrency) are never
|
||||
// re-implemented here; the server's ProblemDetails is the final word (§3.3).
|
||||
// Hand-rolled (not zod) to match the plain-hooks posture most screens this
|
||||
// session use — lib/validations/grn.ts is the one deliberate zod exception.
|
||||
|
||||
export function validateItemForm(input: {
|
||||
sku: string
|
||||
name: string
|
||||
categoryId: number | null
|
||||
baseUomId: number | null
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.sku.trim()) errors.sku = "SKU is required"
|
||||
if (!input.name.trim()) errors.name = "Item name is required"
|
||||
if (!input.categoryId) errors.categoryId = "Select a category"
|
||||
if (!input.baseUomId) errors.baseUomId = "Select a base UOM"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateReorderLine(input: { warehouseId: number | null; reorderPoint: string; reorderQty: string }): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.warehouseId) errors.warehouseId = "Select a warehouse"
|
||||
const point = Number(input.reorderPoint)
|
||||
if (input.reorderPoint === "" || Number.isNaN(point) || point < 0) errors.reorderPoint = "Reorder point cannot be negative"
|
||||
const qty = Number(input.reorderQty)
|
||||
if (!input.reorderQty || Number.isNaN(qty) || qty <= 0) errors.reorderQty = "Reorder quantity must be greater than 0"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateConversionLine(input: { fromUom: number | null; toUom: number | null; factor: string }): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.fromUom) errors.fromUom = "Select a UOM"
|
||||
if (!input.toUom) errors.toUom = "Select a UOM"
|
||||
if (input.fromUom && input.toUom && input.fromUom === input.toUom) errors.toUom = "From and to UOM must differ"
|
||||
const factor = Number(input.factor)
|
||||
if (!input.factor || Number.isNaN(factor) || factor <= 0) errors.factor = "Factor must be greater than 0"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateUomName(name: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!name.trim()) errors.name = "UOM name is required"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateCategoryName(name: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!name.trim()) errors.name = "Category name is required"
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Client-side UX validation only (docs/20-FRONTEND.md §3.1) — required fields,
|
||||
// format/range checks the browser can already see. Server-authoritative rules
|
||||
// (referential existence, PO editability, concurrency, stock sufficiency on
|
||||
// returns) are never re-implemented here; the server's ProblemDetails is the
|
||||
// final word (§3.3). Same pattern as lib/validations/grn.ts.
|
||||
|
||||
export function validateRequisitionLine(input: { itemId: number | null; qty: string; requiredBy: string }): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.itemId) errors.itemId = "Select an item"
|
||||
const qty = Number(input.qty)
|
||||
if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0"
|
||||
if (!input.requiredBy) errors.requiredBy = "Required-by date is needed"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateRfqLine(input: { itemId: number | null; qty: string }): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.itemId) errors.itemId = "Select an item"
|
||||
const qty = Number(input.qty)
|
||||
if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateQuotationLine(input: { unitPrice: string; leadDays: string }): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
const unitPrice = Number(input.unitPrice)
|
||||
if (input.unitPrice === "" || Number.isNaN(unitPrice) || unitPrice < 0) errors.unitPrice = "Unit price cannot be negative"
|
||||
const leadDays = Number(input.leadDays)
|
||||
if (input.leadDays === "" || Number.isNaN(leadDays) || leadDays < 0) errors.leadDays = "Lead days cannot be negative"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validatePoLine(input: {
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
warehouseId: number | null
|
||||
qty: string
|
||||
unitPrice: string
|
||||
tax: string
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.itemId) errors.itemId = "Select an item"
|
||||
if (!input.uomId) errors.uomId = "Select a UOM"
|
||||
if (!input.warehouseId) errors.warehouseId = "Select a warehouse"
|
||||
const qty = Number(input.qty)
|
||||
if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0"
|
||||
const unitPrice = Number(input.unitPrice)
|
||||
if (input.unitPrice === "" || Number.isNaN(unitPrice) || unitPrice < 0) errors.unitPrice = "Unit price cannot be negative"
|
||||
const tax = Number(input.tax)
|
||||
if (input.tax === "" || Number.isNaN(tax) || tax < 0) errors.tax = "Tax rate cannot be negative"
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateReturnLine(input: { grnLineId: number | null; qty: string; maxQty: number | null }): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.grnLineId) errors.grnLineId = "Select a received line"
|
||||
const qty = Number(input.qty)
|
||||
if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0"
|
||||
// Client-side sanity bound on the originally received qty — the server is still
|
||||
// authoritative on live available stock (STOCK_NEGATIVE_BLOCKED, docs/20 §3.1).
|
||||
if (input.maxQty !== null && qty > input.maxQty) errors.qty = `Cannot exceed the received quantity (${input.maxQty})`
|
||||
return errors
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: path.join(__dirname),
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --webpack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Shared response envelope + error shapes mirroring docs/11-BACKEND-PHASE1.md §1.4/§1.8.
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface PagedResponse<T> {
|
||||
items: T[]
|
||||
pagination: PaginationMeta
|
||||
}
|
||||
|
||||
/** RFC 7807 ProblemDetails, normalized by lib/api-client.ts. */
|
||||
export interface ProblemDetails {
|
||||
type?: string
|
||||
title: string
|
||||
status: number
|
||||
/** Stable domain code from the catalog in docs/11-BACKEND-PHASE1.md §7 (e.g. OVER_RECEIPT_TOLERANCE). */
|
||||
code?: string
|
||||
detail?: string
|
||||
traceId?: string
|
||||
/** Field -> messages, present on 400 ValidationProblemDetails. */
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export type EntityStatus = "Active" | "Inactive"
|
||||
@@ -0,0 +1,104 @@
|
||||
// Goods Receipt Note types (docs/11-BACKEND-PHASE1.md §4; FR-GRN-01..08).
|
||||
//
|
||||
// Deviation from the documented contract (recorded in Frontend/PROGRESS.md):
|
||||
// the doc only specifies POST /grns, POST /grns/{id}/confirm and
|
||||
// POST /grns/{id}/lines/{id}/release. A usable list/detail screen needs
|
||||
// GET /grns and GET /grns/{id} — these are modelled here as a planned,
|
||||
// not-yet-built backend extension (see Backend/PROGRESS.md when it lands).
|
||||
|
||||
export type GrnStatus = "Draft" | "Confirmed" | "Closed"
|
||||
export type HoldStatus = "OnHold" | "Available" | "Rejected"
|
||||
|
||||
export interface BatchInput {
|
||||
batchNo: string
|
||||
expiryDate?: string | null
|
||||
}
|
||||
|
||||
/** One received line on create (docs/11 §4.1). */
|
||||
export interface CreateGrnLineInput {
|
||||
poLineId?: number | null
|
||||
itemId: number
|
||||
uomId: number
|
||||
binId?: number | null
|
||||
qty: number
|
||||
unitCost: number
|
||||
holdStatus: HoldStatus
|
||||
batch?: BatchInput | null
|
||||
/** FR-GRN-04 — required when the item's trackingMode is "Serial"; length must equal qty. */
|
||||
serialNumbers?: string[] | null
|
||||
}
|
||||
|
||||
export interface CreateGrnRequest {
|
||||
poId?: number | null
|
||||
/** Required when poId is null (direct/emergency receipt, FR-GRN-02). */
|
||||
vendorId?: number | null
|
||||
warehouseId: number
|
||||
lines: CreateGrnLineInput[]
|
||||
}
|
||||
|
||||
export interface GrnLine {
|
||||
grnLineId: number
|
||||
poLineId: number | null
|
||||
itemId: number
|
||||
uomId: number
|
||||
binId: number | null
|
||||
qty: number
|
||||
unitCost: number
|
||||
receivedValue: number
|
||||
holdStatus: HoldStatus
|
||||
batchId: number | null
|
||||
}
|
||||
|
||||
export interface Grn {
|
||||
grnId: number
|
||||
docNo: string
|
||||
poId: number | null
|
||||
vendorId: number
|
||||
warehouseId: number
|
||||
status: GrnStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
lines: GrnLine[]
|
||||
}
|
||||
|
||||
export interface GrnSummary {
|
||||
grnId: number
|
||||
docNo: string
|
||||
poId: number | null
|
||||
vendorId: number
|
||||
warehouseId: number
|
||||
status: GrnStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** docs/11 §4.2 confirm response. */
|
||||
export interface CreatedLayer {
|
||||
layerId: number
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
batchId: number | null
|
||||
qtyReceived: number
|
||||
qtyRemaining: number
|
||||
unitCost: number
|
||||
receiptDate: string
|
||||
}
|
||||
|
||||
export interface ConfirmGrnResponse {
|
||||
grnId: number
|
||||
status: GrnStatus
|
||||
postedAt: string
|
||||
createdLayers: CreatedLayer[]
|
||||
ledgerRefs: number[]
|
||||
poStatus: string | null
|
||||
}
|
||||
|
||||
export type ReleaseAction = "Release" | "Reject"
|
||||
|
||||
export interface ReleaseGrnLineRequest {
|
||||
action: ReleaseAction
|
||||
}
|
||||
|
||||
export interface ReleaseGrnLineResponse {
|
||||
grnLineId: number
|
||||
holdStatus: HoldStatus
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Master Data DTOs (docs/11-BACKEND-PHASE1.md §2; FR-MD-01..08).
|
||||
import { EntityStatus } from "@/types/common"
|
||||
|
||||
export type ItemType = "Stocked" | "NonStocked" | "Service"
|
||||
export type TrackingMode = "None" | "Batch" | "Serial"
|
||||
|
||||
export interface ItemListItem {
|
||||
itemId: number
|
||||
sku: string
|
||||
name: string
|
||||
categoryId: number
|
||||
baseUomId: number
|
||||
defaultVendorId: number | null
|
||||
itemType: ItemType
|
||||
trackingMode: TrackingMode
|
||||
taxClass: string | null
|
||||
status: EntityStatus
|
||||
}
|
||||
|
||||
export interface ItemReorderSetting {
|
||||
warehouseId: number
|
||||
reorderPoint: number
|
||||
reorderQty: number
|
||||
}
|
||||
|
||||
export interface UomConversion {
|
||||
conversionId: number
|
||||
fromUom: number
|
||||
toUom: number
|
||||
factor: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`). `conversions` is a
|
||||
* deviation: the documented example response only shows `reorder` inline, and
|
||||
* conversions are otherwise reachable only via `PUT /items/{itemId}/uom-conversions`
|
||||
* (which returns them but has no matching GET). Embedding them here — like the
|
||||
* assumed `GET /grns`/`GET /grns/{id}` reads elsewhere in this app — lets the Item
|
||||
* detail screen show current conversions before editing; flag this to whoever
|
||||
* implements the real endpoint (docs/10 §4 already lists UOMConversion as its own
|
||||
* entity, so the full resource plausibly carries it).
|
||||
*/
|
||||
export interface Item {
|
||||
itemId: number
|
||||
sku: string
|
||||
name: string
|
||||
description: string | null
|
||||
categoryId: number
|
||||
baseUomId: number
|
||||
defaultVendorId: number | null
|
||||
itemType: ItemType
|
||||
trackingMode: TrackingMode
|
||||
taxClass: string | null
|
||||
status: EntityStatus
|
||||
reorder: ItemReorderSetting[]
|
||||
conversions: UomConversion[]
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateItemRequest {
|
||||
sku: string
|
||||
name: string
|
||||
description?: string | null
|
||||
categoryId: number
|
||||
baseUomId: number
|
||||
defaultVendorId?: number | null
|
||||
itemType: ItemType
|
||||
trackingMode: TrackingMode
|
||||
taxClass?: string | null
|
||||
}
|
||||
|
||||
export type UpdateItemRequest = CreateItemRequest
|
||||
|
||||
export interface UpdateItemReorderRequest {
|
||||
settings: ItemReorderSetting[]
|
||||
}
|
||||
|
||||
export interface UpdateUomConversionsRequest {
|
||||
conversions: { fromUom: number; toUom: number; factor: number }[]
|
||||
}
|
||||
|
||||
export interface UpdateUomConversionsResponse {
|
||||
itemId: number
|
||||
baseUomId: number
|
||||
conversions: UomConversion[]
|
||||
}
|
||||
|
||||
export interface Warehouse {
|
||||
warehouseId: number
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Bin {
|
||||
binId: number
|
||||
warehouseId: number
|
||||
code: string
|
||||
binType: string | null
|
||||
}
|
||||
|
||||
export interface Vendor {
|
||||
vendorId: number
|
||||
code: string
|
||||
name: string
|
||||
terms: string | null
|
||||
taxReg: string | null
|
||||
currency: string
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface Uom {
|
||||
uomId: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface CreateUomRequest {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
categoryId: number
|
||||
name: string
|
||||
parentId: number | null
|
||||
}
|
||||
|
||||
export interface CategoryTreeNode extends Category {
|
||||
children: CategoryTreeNode[]
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
name: string
|
||||
parentId?: number | null
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// 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.
|
||||
|
||||
// --- 3.1 Requisitions --------------------------------------------------------------
|
||||
|
||||
export type RequisitionStatus = "Draft" | "Submitted"
|
||||
|
||||
export interface ReqLine {
|
||||
reqLineId: number
|
||||
itemId: number
|
||||
qty: number
|
||||
requiredBy: string
|
||||
}
|
||||
|
||||
export interface Requisition {
|
||||
requisitionId: number
|
||||
docNo: string
|
||||
status: RequisitionStatus
|
||||
requestedBy: number
|
||||
createdAt: string
|
||||
lines: ReqLine[]
|
||||
}
|
||||
|
||||
export interface RequisitionSummary {
|
||||
requisitionId: number
|
||||
docNo: string
|
||||
status: RequisitionStatus
|
||||
requestedBy: number
|
||||
createdAt: string
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
export interface CreateReqLineInput {
|
||||
itemId: number
|
||||
qty: number
|
||||
requiredBy: string
|
||||
}
|
||||
|
||||
export interface CreateRequisitionRequest {
|
||||
lines: CreateReqLineInput[]
|
||||
}
|
||||
|
||||
// --- 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). */
|
||||
export type RfqStatus = "Open" | "Closed"
|
||||
|
||||
export interface RfqLine {
|
||||
rfqLineId: number
|
||||
itemId: number
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface Rfq {
|
||||
rfqId: number
|
||||
docNo: string
|
||||
requisitionId: number | null
|
||||
status: RfqStatus
|
||||
vendorIds: number[]
|
||||
createdAt: string
|
||||
lines: RfqLine[]
|
||||
}
|
||||
|
||||
export interface RfqSummary {
|
||||
rfqId: number
|
||||
docNo: string
|
||||
requisitionId: number | null
|
||||
status: RfqStatus
|
||||
vendorIds: number[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateRfqLineInput {
|
||||
itemId: number
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface CreateRfqRequest {
|
||||
requisitionId?: number | null
|
||||
vendorIds: number[]
|
||||
lines: CreateRfqLineInput[]
|
||||
}
|
||||
|
||||
export interface QuotationLine {
|
||||
itemId: number
|
||||
unitPrice: number
|
||||
leadDays: number
|
||||
}
|
||||
|
||||
export interface Quotation {
|
||||
quotationId: number
|
||||
rfqId: number
|
||||
vendorId: number
|
||||
createdAt: string
|
||||
lines: QuotationLine[]
|
||||
}
|
||||
|
||||
export interface CreateQuotationRequest {
|
||||
vendorId: number
|
||||
lines: QuotationLine[]
|
||||
}
|
||||
|
||||
export interface RfqComparisonCell {
|
||||
vendorId: number
|
||||
unitPrice: number
|
||||
leadDays: number
|
||||
}
|
||||
|
||||
export interface RfqComparisonLine {
|
||||
itemId: number
|
||||
qty: number
|
||||
cells: RfqComparisonCell[]
|
||||
}
|
||||
|
||||
export interface RfqComparison {
|
||||
rfqId: number
|
||||
vendorIds: number[]
|
||||
lines: RfqComparisonLine[]
|
||||
}
|
||||
|
||||
// --- 3.3 Purchase Orders -------------------------------------------------------------
|
||||
|
||||
export type PurchaseOrderStatus =
|
||||
| "Draft"
|
||||
| "PendingApproval"
|
||||
| "Approved"
|
||||
| "PartiallyReceived"
|
||||
| "FullyReceived"
|
||||
| "Closed"
|
||||
| "Cancelled"
|
||||
|
||||
export interface PoLine {
|
||||
poLineId: number
|
||||
itemId: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
qty: number
|
||||
unitPrice: number
|
||||
tax: number
|
||||
qtyReceived: number
|
||||
}
|
||||
|
||||
export interface PoTotals {
|
||||
subTotal: number
|
||||
tax: number
|
||||
grandTotal: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface PurchaseOrder {
|
||||
poId: number
|
||||
docNo: string
|
||||
vendorId: number
|
||||
requisitionId: number | null
|
||||
status: PurchaseOrderStatus
|
||||
approvalRequired: boolean
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
totals: PoTotals
|
||||
lines: PoLine[]
|
||||
}
|
||||
|
||||
export interface PurchaseOrderSummary {
|
||||
poId: number
|
||||
docNo: string
|
||||
vendorId: number
|
||||
status: PurchaseOrderStatus
|
||||
approvalRequired: boolean
|
||||
createdAt: string
|
||||
totals: PoTotals
|
||||
}
|
||||
|
||||
export interface CreatePoLineInput {
|
||||
itemId: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
qty: number
|
||||
unitPrice: number
|
||||
tax: number
|
||||
}
|
||||
|
||||
export interface CreatePurchaseOrderRequest {
|
||||
vendorId: number
|
||||
requisitionId?: number | null
|
||||
lines: CreatePoLineInput[]
|
||||
}
|
||||
|
||||
/** PUT /purchase-orders/{poId} — edit-while-open, same line shape as create (FR-PROC-05, Option B). */
|
||||
export type UpdatePurchaseOrderRequest = CreatePurchaseOrderRequest
|
||||
|
||||
export interface CancelPurchaseOrderRequest {
|
||||
reason: string
|
||||
}
|
||||
|
||||
// --- 3.4 Purchase Returns -------------------------------------------------------------
|
||||
|
||||
export type PurchaseReturnStatus = "Posted"
|
||||
|
||||
export interface PurchaseReturnLine {
|
||||
returnLineId: number
|
||||
grnLineId: number
|
||||
itemId: number
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface PurchaseReturn {
|
||||
returnId: number
|
||||
docNo: string
|
||||
vendorId: number
|
||||
warehouseId: number
|
||||
reasonCodeId: number
|
||||
status: PurchaseReturnStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
lines: PurchaseReturnLine[]
|
||||
ledgerRefs: number[]
|
||||
}
|
||||
|
||||
export interface PurchaseReturnSummary {
|
||||
returnId: number
|
||||
docNo: string
|
||||
vendorId: number
|
||||
warehouseId: number
|
||||
reasonCodeId: number
|
||||
status: PurchaseReturnStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreatePurchaseReturnLineInput {
|
||||
grnLineId: number
|
||||
itemId: number
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface CreatePurchaseReturnRequest {
|
||||
vendorId: number
|
||||
warehouseId: number
|
||||
reasonCodeId: number
|
||||
lines: CreatePurchaseReturnLineInput[]
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Stock Management types (docs/11-BACKEND-PHASE1.md §5; FR-STK-01..14, FR-WH-*).
|
||||
//
|
||||
// Deviation from the documented contract (recorded in Frontend/PROGRESS.md,
|
||||
// same pattern as the GRN deviation): §5.4/5.5/5.6 document only the
|
||||
// transactional POSTs/PUT for transfers/adjustments/counts, not a list/detail
|
||||
// GET. A usable list+detail UI needs to reload these documents, so this file
|
||||
// (and the matching lib/api/stock-*.ts modules) assume GET list/detail
|
||||
// endpoints will exist once the real Stock Core backend is built.
|
||||
|
||||
export type Currency = "LKR"
|
||||
|
||||
// --- 5.1 On-hand ----------------------------------------------------------------
|
||||
|
||||
export interface OnHand {
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
onHand: number
|
||||
available: number
|
||||
onHold: number
|
||||
inTransit: number
|
||||
reserved: number
|
||||
asOf: string
|
||||
}
|
||||
|
||||
// --- 5.2 Ledger -------------------------------------------------------------------
|
||||
|
||||
export type LedgerDirection = "In" | "Out"
|
||||
|
||||
export interface LedgerEntry {
|
||||
ledgerId: number
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
binId: number | null
|
||||
batchId: number | null
|
||||
serialId: number | null
|
||||
direction: LedgerDirection
|
||||
qtyBase: number
|
||||
unitCost: number
|
||||
value: number
|
||||
runningBalance: number
|
||||
sourceDocType: string
|
||||
sourceDocId: number
|
||||
userId: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// --- 5.3 Valuation ------------------------------------------------------------------
|
||||
|
||||
export interface ValuationLayer {
|
||||
layerId: number
|
||||
qtyRemaining: number
|
||||
unitCost: number
|
||||
value: number
|
||||
receiptDate: string
|
||||
}
|
||||
|
||||
export interface Valuation {
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
layers: ValuationLayer[]
|
||||
totalQty: number
|
||||
totalValue: number
|
||||
currency: Currency
|
||||
costingMethod: "FIFO"
|
||||
}
|
||||
|
||||
// --- 5.4 Transfers ------------------------------------------------------------------
|
||||
|
||||
export type TransferStatus = "Draft" | "InTransit" | "Received" | "Closed"
|
||||
|
||||
export interface TransferLine {
|
||||
transferLineId: number
|
||||
itemId: number
|
||||
srcBinId: number | null
|
||||
destBinId: number | null
|
||||
batchId: number | null
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface StockTransfer {
|
||||
transferId: number
|
||||
docNo: string
|
||||
srcWarehouseId: number
|
||||
destWarehouseId: number
|
||||
status: TransferStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
lines: TransferLine[]
|
||||
}
|
||||
|
||||
export interface StockTransferSummary {
|
||||
transferId: number
|
||||
docNo: string
|
||||
srcWarehouseId: number
|
||||
destWarehouseId: number
|
||||
status: TransferStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateTransferLineInput {
|
||||
itemId: number
|
||||
srcBinId?: number | null
|
||||
destBinId?: number | null
|
||||
batchId?: number | null
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface CreateTransferRequest {
|
||||
srcWarehouseId: number
|
||||
destWarehouseId: number
|
||||
lines: CreateTransferLineInput[]
|
||||
}
|
||||
|
||||
export interface ConsumedLayer {
|
||||
layerId: number
|
||||
qtyConsumed: number
|
||||
unitCost: number
|
||||
}
|
||||
|
||||
export interface DispatchTransferResponse {
|
||||
transferId: number
|
||||
status: TransferStatus
|
||||
consumedLayers: ConsumedLayer[]
|
||||
ledgerRefs: number[]
|
||||
}
|
||||
|
||||
export interface ReceiveTransferLineInput {
|
||||
transferLineId: number
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface CreatedTransferLayer {
|
||||
layerId: number
|
||||
warehouseId: number
|
||||
qtyReceived: number
|
||||
unitCost: number
|
||||
}
|
||||
|
||||
export interface ReceiveTransferResponse {
|
||||
transferId: number
|
||||
status: TransferStatus
|
||||
createdLayers: CreatedTransferLayer[]
|
||||
ledgerRefs: number[]
|
||||
}
|
||||
|
||||
// --- 5.5 Adjustments ------------------------------------------------------------------
|
||||
|
||||
export type AdjustmentStatus = "Posted"
|
||||
|
||||
export interface AdjustmentLine {
|
||||
adjLineId: number
|
||||
itemId: number
|
||||
binId: number | null
|
||||
batchId: number | null
|
||||
qtyDelta: number
|
||||
}
|
||||
|
||||
export interface StockAdjustment {
|
||||
adjustmentId: number
|
||||
docNo: string
|
||||
warehouseId: number
|
||||
reasonCodeId: number
|
||||
status: AdjustmentStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
lines: AdjustmentLine[]
|
||||
ledgerRefs: number[]
|
||||
}
|
||||
|
||||
export interface StockAdjustmentSummary {
|
||||
adjustmentId: number
|
||||
docNo: string
|
||||
warehouseId: number
|
||||
reasonCodeId: number
|
||||
status: AdjustmentStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateAdjustmentLineInput {
|
||||
itemId: number
|
||||
binId?: number | null
|
||||
batchId?: number | null
|
||||
qtyDelta: number
|
||||
}
|
||||
|
||||
export interface CreateAdjustmentRequest {
|
||||
warehouseId: number
|
||||
reasonCodeId: number
|
||||
lines: CreateAdjustmentLineInput[]
|
||||
}
|
||||
|
||||
// --- 5.6 Counts ------------------------------------------------------------------
|
||||
|
||||
export type CountType = "Cycle" | "Full"
|
||||
export type CountStatus = "Draft" | "Posted"
|
||||
|
||||
export interface CountLine {
|
||||
countLineId: number
|
||||
itemId: number
|
||||
binId: number | null
|
||||
systemQty: number
|
||||
countedQty: number | null
|
||||
variance: number | null
|
||||
}
|
||||
|
||||
export interface StockCount {
|
||||
countId: number
|
||||
docNo: string
|
||||
warehouseId: number
|
||||
countType: CountType
|
||||
status: CountStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
lines: CountLine[]
|
||||
}
|
||||
|
||||
export interface StockCountSummary {
|
||||
countId: number
|
||||
docNo: string
|
||||
warehouseId: number
|
||||
countType: CountType
|
||||
status: CountStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateCountRequest {
|
||||
warehouseId: number
|
||||
countType: CountType
|
||||
itemIds: number[]
|
||||
}
|
||||
|
||||
export interface EnterCountLineInput {
|
||||
countLineId: number
|
||||
countedQty: number
|
||||
}
|
||||
|
||||
export interface EnterCountsRequest {
|
||||
lines: EnterCountLineInput[]
|
||||
}
|
||||
|
||||
export interface EnterCountsResponse {
|
||||
lines: CountLine[]
|
||||
}
|
||||
|
||||
export interface PostCountResponse {
|
||||
countId: number
|
||||
status: CountStatus
|
||||
adjustmentId: number
|
||||
ledgerRefs: number[]
|
||||
}
|
||||
|
||||
// --- 5.7 Reorder alerts ------------------------------------------------------------------
|
||||
|
||||
export interface ReorderAlert {
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
available: number
|
||||
reorderPoint: number
|
||||
reorderQty: number
|
||||
suggestedRequisitionQty: number
|
||||
}
|
||||
|
||||
export interface ReorderRequisitionResponse {
|
||||
requisitionId: number
|
||||
docNo: string
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
qty: number
|
||||
status: "Draft"
|
||||
}
|
||||
|
||||
// --- §6 Reference data ------------------------------------------------------------------
|
||||
|
||||
export type ReasonCodeContext = "Adjustment" | "Return" | "CountVariance"
|
||||
|
||||
export interface ReasonCode {
|
||||
reasonCodeId: number
|
||||
code: string
|
||||
description: string
|
||||
context: ReasonCodeContext
|
||||
}
|
||||
Reference in New Issue
Block a user