From 7ac30bb454ae69b8cbcbf331e24b061d8eed5717 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Mon, 13 Jul 2026 17:11:40 +0530 Subject: [PATCH] feat: add warehouse and bin management API with mock data implementation - Implemented warehouses API with methods for listing, creating, and managing bins. - Added wastage API to handle stock write-offs and integrate with stock adjustments. - Created auth token management for storing and retrieving access tokens. - Developed error mapping for consistent user-facing error messages. - Introduced client-side validations for GRN, master data, and procurement processes. - Defined common types for pagination, problem details, and various master data entities. - Established procurement and stock management types to support frontend functionality. --- Frontend/PROGRESS.md | 132 ++- Frontend/erp-system/app/dashboard/layout.tsx | 2 + .../app/dashboard/procurement/page.tsx | 64 ++ .../procurement/purchase-orders/[id]/page.tsx | 459 ++++++++++ .../procurement/purchase-orders/new/page.tsx | 411 +++++++++ .../procurement/purchase-orders/page.tsx | 186 ++++ .../procurement/purchase-returns/new/page.tsx | 286 ++++++ .../procurement/purchase-returns/page.tsx | 117 +++ .../procurement/requisitions/[id]/page.tsx | 144 ++++ .../procurement/requisitions/new/page.tsx | 213 +++++ .../procurement/requisitions/page.tsx | 151 ++++ .../dashboard/procurement/rfqs/[id]/page.tsx | 332 +++++++ .../dashboard/procurement/rfqs/new/page.tsx | 280 ++++++ .../app/dashboard/procurement/rfqs/page.tsx | 104 +++ .../app/dashboard/products/[id]/page.tsx | 592 +++++++++++++ .../dashboard/products/categories/page.tsx | 166 ++++ .../app/dashboard/products/new/page.tsx | 220 +++++ .../app/dashboard/products/page.tsx | 249 +++++- .../app/dashboard/products/uoms/page.tsx | 134 +++ .../receiving/grn/[id]/edit/page.tsx | 444 ++++++++++ .../app/dashboard/receiving/grn/[id]/page.tsx | 233 +++++ .../app/dashboard/receiving/grn/new/page.tsx | 555 ++++++++++++ .../app/dashboard/receiving/grn/page.tsx | 313 +++++++ .../dashboard/stock/adjustments/new/page.tsx | 270 ++++++ .../app/dashboard/stock/adjustments/page.tsx | 107 +++ .../app/dashboard/stock/counts/[id]/page.tsx | 170 ++++ .../app/dashboard/stock/counts/new/page.tsx | 162 ++++ .../app/dashboard/stock/counts/page.tsx | 108 +++ .../app/dashboard/stock/enquiry/page.tsx | 164 ++++ .../app/dashboard/stock/ledger/page.tsx | 240 ++++++ .../erp-system/app/dashboard/stock/page.tsx | 98 +++ .../dashboard/stock/reorder-alerts/page.tsx | 140 +++ .../dashboard/stock/transfers/[id]/page.tsx | 170 ++++ .../dashboard/stock/transfers/new/page.tsx | 298 +++++++ .../app/dashboard/stock/transfers/page.tsx | 108 +++ .../app/dashboard/stock/valuation/page.tsx | 180 ++++ .../app/dashboard/stock/wastage/new/page.tsx | 246 ++++++ .../app/dashboard/stock/wastage/page.tsx | 197 +++++ .../app/dashboard/vendors/[id]/page.tsx | 219 +++++ .../erp-system/app/dashboard/vendors/page.tsx | 385 +++++++++ .../app/dashboard/warehouse/[id]/page.tsx | 168 ++++ .../app/dashboard/warehouse/page.tsx | 178 ++++ .../components/Layouts/AppSidebar.tsx | 10 + .../components/Layouts/Breadcrumbs.tsx | 84 ++ .../erp-system/components/Layouts/Header.tsx | 53 ++ .../components/procurement/status-badges.tsx | 45 + .../components/receiving/status-badges.tsx | 34 + .../components/stock/status-badges.tsx | 35 + Frontend/erp-system/components/ui/select.tsx | 201 +++++ Frontend/erp-system/lib/api-client.ts | 98 +++ Frontend/erp-system/lib/api/categories.ts | 64 ++ Frontend/erp-system/lib/api/grns.ts | 282 ++++++ Frontend/erp-system/lib/api/items.ts | 203 +++++ Frontend/erp-system/lib/api/mock-data.ts | 814 ++++++++++++++++++ .../erp-system/lib/api/purchase-orders.ts | 201 +++++ .../erp-system/lib/api/purchase-returns.ts | 113 +++ Frontend/erp-system/lib/api/reason-codes.ts | 27 + Frontend/erp-system/lib/api/requisitions.ts | 103 +++ Frontend/erp-system/lib/api/rfqs.ts | 126 +++ .../erp-system/lib/api/stock-adjustments.ts | 152 ++++ Frontend/erp-system/lib/api/stock-counts.ts | 198 +++++ .../erp-system/lib/api/stock-transfers.ts | 221 +++++ Frontend/erp-system/lib/api/stock.ts | 190 ++++ Frontend/erp-system/lib/api/uoms.ts | 43 + Frontend/erp-system/lib/api/vendors.ts | 165 ++++ Frontend/erp-system/lib/api/warehouses.ts | 98 +++ Frontend/erp-system/lib/api/wastage.ts | 98 +++ Frontend/erp-system/lib/auth-token.ts | 16 + Frontend/erp-system/lib/error-map.ts | 41 + Frontend/erp-system/lib/validations/grn.ts | 57 ++ .../erp-system/lib/validations/master-data.ts | 52 ++ .../erp-system/lib/validations/procurement.ts | 63 ++ Frontend/erp-system/next.config.ts | 10 - Frontend/erp-system/package.json | 2 +- Frontend/erp-system/types/common.ts | 28 + Frontend/erp-system/types/grn.ts | 104 +++ Frontend/erp-system/types/master-data.ts | 136 +++ Frontend/erp-system/types/procurement.ts | 243 ++++++ Frontend/erp-system/types/stock.ts | 281 ++++++ 79 files changed, 14042 insertions(+), 44 deletions(-) create mode 100644 Frontend/erp-system/app/dashboard/procurement/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/products/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/products/categories/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/products/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/products/uoms/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/receiving/grn/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/adjustments/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/counts/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/counts/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/enquiry/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/ledger/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/transfers/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/valuation/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/wastage/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/vendors/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/warehouse/page.tsx create mode 100644 Frontend/erp-system/components/Layouts/Breadcrumbs.tsx create mode 100644 Frontend/erp-system/components/procurement/status-badges.tsx create mode 100644 Frontend/erp-system/components/receiving/status-badges.tsx create mode 100644 Frontend/erp-system/components/stock/status-badges.tsx create mode 100644 Frontend/erp-system/components/ui/select.tsx create mode 100644 Frontend/erp-system/lib/api-client.ts create mode 100644 Frontend/erp-system/lib/api/categories.ts create mode 100644 Frontend/erp-system/lib/api/grns.ts create mode 100644 Frontend/erp-system/lib/api/items.ts create mode 100644 Frontend/erp-system/lib/api/mock-data.ts create mode 100644 Frontend/erp-system/lib/api/purchase-orders.ts create mode 100644 Frontend/erp-system/lib/api/purchase-returns.ts create mode 100644 Frontend/erp-system/lib/api/reason-codes.ts create mode 100644 Frontend/erp-system/lib/api/requisitions.ts create mode 100644 Frontend/erp-system/lib/api/rfqs.ts create mode 100644 Frontend/erp-system/lib/api/stock-adjustments.ts create mode 100644 Frontend/erp-system/lib/api/stock-counts.ts create mode 100644 Frontend/erp-system/lib/api/stock-transfers.ts create mode 100644 Frontend/erp-system/lib/api/stock.ts create mode 100644 Frontend/erp-system/lib/api/uoms.ts create mode 100644 Frontend/erp-system/lib/api/vendors.ts create mode 100644 Frontend/erp-system/lib/api/warehouses.ts create mode 100644 Frontend/erp-system/lib/api/wastage.ts create mode 100644 Frontend/erp-system/lib/auth-token.ts create mode 100644 Frontend/erp-system/lib/error-map.ts create mode 100644 Frontend/erp-system/lib/validations/grn.ts create mode 100644 Frontend/erp-system/lib/validations/master-data.ts create mode 100644 Frontend/erp-system/lib/validations/procurement.ts delete mode 100644 Frontend/erp-system/next.config.ts create mode 100644 Frontend/erp-system/types/common.ts create mode 100644 Frontend/erp-system/types/grn.ts create mode 100644 Frontend/erp-system/types/master-data.ts create mode 100644 Frontend/erp-system/types/procurement.ts create mode 100644 Frontend/erp-system/types/stock.ts diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index cbf7fca..8e8075d 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -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` 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 + +### 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`, 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). diff --git a/Frontend/erp-system/app/dashboard/layout.tsx b/Frontend/erp-system/app/dashboard/layout.tsx index 3072700..49ec7f9 100644 --- a/Frontend/erp-system/app/dashboard/layout.tsx +++ b/Frontend/erp-system/app/dashboard/layout.tsx @@ -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({
+
{children} diff --git a/Frontend/erp-system/app/dashboard/procurement/page.tsx b/Frontend/erp-system/app/dashboard/procurement/page.tsx new file mode 100644 index 0000000..dd9f1ad --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/page.tsx @@ -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 ( +
+
+

Procurement

+

+ Requisition → RFQ (optional) → Purchase Order → Purchase Return (FR-PROC-01..09). +

+
+ +
+ {areas.map((area) => ( + + + +
+
+ +
+ {area.title} +
+
+ +

{area.description}

+
+
+ + ))} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx new file mode 100644 index 0000000..8b0be76 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -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(null) + const [etag, setEtag] = useState(null) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [vendors, setVendors] = useState([]) + const [loadError, setLoadError] = useState(null) + + const [lines, setLines] = useState([]) + const [lineErrors, setLineErrors] = useState>>({}) + const [conflict, setConflict] = useState(false) + const [saveError, setSaveError] = useState(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) { + 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> = {} + 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 ( +
+
{loadError}
+ + Back to purchase orders + +
+ ) + } + + if (!po) { + return ( +
+ + +
+ ) + } + + const editable = isPoEditable(po.status) && !conflict + const hasReceipts = po.lines.some((l) => l.qtyReceived > 0) + + return ( +
+
+
+ + + +
+
+

{po.docNo}

+ +
+

+ Vendor {vendorCode(po.vendorId)} {po.requisitionId ? `— from Requisition #${po.requisitionId}` : ""} — {po.totals.currency} {po.totals.grandTotal.toFixed(2)} +

+
+
+ + {isPoEditable(po.status) && !showCancelForm && ( + + )} +
+ + {showCancelForm && ( +
+

Cancel {po.docNo}

+ setCancelReason(e.target.value)} + placeholder="Reason (e.g. Duplicate order)" + className="h-11 max-w-md text-base" + /> +
+ + +
+
+ )} + + {conflict && ( +
+ +
+

{saveError ?? "This purchase order was changed by someone else."} Reload before retrying.

+ +
+
+ )} + + {saveError && !conflict && ( +
{saveError}
+ )} + +
+
+

Lines

+ {editable && ( + + )} +
+ +
+ + + + Item + UOM + Warehouse + Qty + Received + Unit price + Tax + {editable && } + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + if (!editable) { + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.uomId ? uomName(line.uomId) : "—"} + {line.warehouseId ? warehouseCode(line.warehouseId) : "—"} + {line.qty} + {line.qtyReceived} + {Number(line.unitPrice).toFixed(2)} + {(Number(line.tax) * 100).toFixed(0)}% + + ) + } + return ( + + + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {items.map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + + value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + + + + value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> + + + + + {warehouses.map((w) => ( + + {w.code} + + ))} + + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + {line.qtyReceived} + + updateLine(line.key, { unitPrice: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { tax: e.target.value })} + className="h-11 text-base" + /> + + + + + + + ) + })} + +
+
+
+ + {editable && ( +
+ + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx new file mode 100644 index 0000000..06d58ae --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -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(null) + const [uoms, setUoms] = useState(null) + const [warehouses, setWarehouses] = useState(null) + const [vendors, setVendors] = useState(null) + const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId) + const [loadError, setLoadError] = useState(null) + + const [vendorId, setVendorId] = useState(rfqVendorId) + const [lines, setLines] = useState([emptyLine()]) + + const [headerError, setHeaderError] = useState(null) + const [lineErrors, setLineErrors] = useState>>({}) + const [submitError, setSubmitError] = useState(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) { + 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> = {} + 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 ( +
+
+ + + +
+

New Purchase Order

+

Auto-approved on creation; freely editable while open (FR-PROC-03..05).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+ + value={vendorId} onValueChange={setVendorId}> + + + + + {(vendors ?? []).map((v) => ( + + {v.code} — {v.name} + + ))} + + +
+ {requisitionId && ( +
From Requisition #{requisitionId}
+ )} + {rfqId &&
From RFQ #{rfqId}
} +
+ + {headerError && ( +
{headerError}
+ )} + +
+
+

Lines

+ +
+ + {lines.length > 0 && ( +
+ + + + Item + UOM + Warehouse + Qty + Unit price + Tax + + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + return ( + + + {requisitionId || rfqId ? ( +
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
+ ) : ( + <> + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + )} +
+ + value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> + + + + + {(uoms ?? []).map((u) => ( + + {u.name} + + ))} + + + + + + value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> + + + + + {(warehouses ?? []).map((w) => ( + + {w.code} + + ))} + + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { unitPrice: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { tax: e.target.value })} + className="h-11 text-base" + /> + + + + + +
+ ) + })} +
+
+
+ )} +
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} + +export default function NewPurchaseOrderPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx new file mode 100644 index 0000000..d399fa2 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx @@ -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(null) + const [vendors, setVendors] = useState([]) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [status, setStatus] = useState("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 ( +
+
+
+

Purchase Orders

+

Auto-approved on creation and freely editable while open (FR-PROC-03..05).

+
+ + + New PO + +
+ +
+ 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" + /> + + {hasFilters && ( + + )} +
+ + {error && ( +
{error}
+ )} + + {!error && pos === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && pos !== null && pos.length === 0 && ( +
+ +

{hasFilters ? "No purchase orders match your search/filter." : "No purchase orders yet."}

+
+ )} + + {!error && pos !== null && pos.length > 0 && ( + <> + + + + Doc No + Vendor + Status + Grand total + Created + + + + {pos.map((po) => ( + + + + {po.docNo} + + + {vendorCode(po.vendorId)} + + + + {po.totals.currency} {po.totals.grandTotal.toFixed(2)} + {new Date(po.createdAt).toLocaleString()} + + ))} + +
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} + + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx new file mode 100644 index 0000000..324cfe1 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx @@ -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(null) + const [items, setItems] = useState([]) + const [reasonCodes, setReasonCodes] = useState([]) + const [loadError, setLoadError] = useState(null) + + const [grnId, setGrnId] = useState(presetGrnId) + const [reasonCodeId, setReasonCodeId] = useState(null) + const [lineState, setLineState] = useState>({}) + const [lineErrors, setLineErrors] = useState>>({}) + + const [headerError, setHeaderError] = useState(null) + const [submitError, setSubmitError] = useState(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 = {} + 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> = {} + 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 ( +
+
+ + + +
+

New Purchase Return

+

Return received goods to the vendor; posts an outbound ledger entry immediately (FR-PROC-08).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+ + value={grnId} onValueChange={setGrnId} disabled={!!presetGrnId}> + + + + + {(grns ?? []).map((g) => ( + + {g.docNo} — Vendor #{g.vendorId}, Warehouse #{g.warehouseId} + + ))} + + +
+
+ + value={reasonCodeId} onValueChange={setReasonCodeId}> + + + + + {reasonCodes.map((rc) => ( + + {rc.description} + + ))} + + +
+
+ + {headerError && ( +
{headerError}
+ )} + + {selectedGrn && ( +
+

Lines received on {selectedGrn.docNo}

+ + + + + Item + Received qty + Hold status + Return qty + + + + {selectedGrn.lines.map((line) => { + const item = itemFor(line.itemId) + const state = lineState[line.grnLineId] ?? { selected: false, qty: "" } + const errors = lineErrors[line.grnLineId] ?? {} + return ( + + + toggleLine(line)} /> + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.qty} + + + + + setQty(line.grnLineId, e.target.value)} + className="h-11 text-base" + /> + + + + ) + })} + +
+
+ )} + + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} + +export default function NewPurchaseReturnPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx new file mode 100644 index 0000000..62a0694 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx @@ -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(null) + const [vendors, setVendors] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [reasonCodes, setReasonCodes] = useState([]) + const [error, setError] = useState(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 ( +
+
+
+

Purchase Returns

+

Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).

+
+ + + New Return + +
+ + {error && ( +
{error}
+ )} + + {!error && returns === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && returns !== null && returns.length === 0 && ( +
+ +

No purchase returns yet.

+ + + New Return + +
+ )} + + {!error && returns !== null && returns.length > 0 && ( + + + + Doc No + Vendor + Warehouse + Reason + Status + Created + + + + {returns.map((r) => ( + + {r.docNo} + {vendorCode(r.vendorId)} + {warehouseCode(r.warehouseId)} + {reasonLabel(r.reasonCodeId)} + + + {r.status} + + + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx new file mode 100644 index 0000000..1dbf471 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx @@ -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(null) + const [items, setItems] = useState([]) + const [error, setError] = useState(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
{error}
+ } + + if (!requisition) { + return ( +
+ + +
+ ) + } + + return ( +
+
+
+ + + +
+
+

{requisition.docNo}

+ +
+

Requested by #{requisition.requestedBy} — {new Date(requisition.createdAt).toLocaleString()}

+
+
+ +
+ {requisition.status === "Draft" && ( + + )} + {requisition.status === "Submitted" && ( + <> + + + Create RFQ + + + + Create PO + + + )} +
+
+ + {error && ( +
{error}
+ )} + + + + + Item + Qty + Required by + + + + {requisition.lines.map((line) => { + const item = itemFor(line.itemId) + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.qty} + {line.requiredBy} + + ) + })} + +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx new file mode 100644 index 0000000..a6143d3 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx @@ -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(null) + const [loadError, setLoadError] = useState(null) + const [lines, setLines] = useState([emptyLine()]) + const [lineErrors, setLineErrors] = useState>>({}) + const [submitError, setSubmitError] = useState(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) { + 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> = {} + 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 ( +
+
+ + + +
+

New Requisition

+

Request items for procurement; submit once the lines are ready (FR-PROC-01).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+

Lines

+ +
+ + {lines.length > 0 && ( + + + + Item + Qty + Required by + + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + return ( + + + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { requiredBy: e.target.value })} + className="h-11 text-base" + /> + + + + + + + ) + })} + +
+ )} +
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx new file mode 100644 index 0000000..27d444c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx @@ -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(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [status, setStatus] = useState("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 ( +
+
+
+

Requisitions

+

Raise a purchase requisition and submit it into procurement (FR-PROC-01).

+
+ + + New Requisition + +
+ +
+ +
+ + {error && ( +
{error}
+ )} + + {!error && requisitions === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && requisitions !== null && requisitions.length === 0 && ( +
+ +

No requisitions yet.

+ + + New Requisition + +
+ )} + + {!error && requisitions !== null && requisitions.length > 0 && ( + <> + + + + Doc No + Status + Lines + Requested by + Created + + + + {requisitions.map((r) => ( + + + + {r.docNo} + + + + + + {r.lineCount} + #{r.requestedBy} + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} + + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx new file mode 100644 index 0000000..a60d775 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx @@ -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(null) + const [comparison, setComparison] = useState(null) + const [items, setItems] = useState([]) + const [vendors, setVendors] = useState([]) + const [error, setError] = useState(null) + + const [quoteVendorId, setQuoteVendorId] = useState(null) + const [quoteLines, setQuoteLines] = useState>({}) + const [quoteErrors, setQuoteErrors] = useState>>({}) + const [quoteFormError, setQuoteFormError] = useState(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() + 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 = {} + 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> = {} + 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
{error}
+ } + + if (!rfq || !comparison) { + return ( +
+ + +
+ ) + } + + return ( +
+
+ + + +
+
+

{rfq.docNo}

+ +
+

+ {rfq.requisitionId ? `From Requisition #${rfq.requisitionId} — ` : ""} + Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")} +

+
+
+ + {error && ( +
{error}
+ )} + +
+

Lines

+ + + + Item + Qty + + + + {rfq.lines.map((line) => { + const item = itemFor(line.itemId) + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.qty} + + ) + })} + +
+
+ +
+

Vendor comparison

+ {comparison.lines.every((l) => l.cells.length === 0) ? ( +

No quotations recorded yet.

+ ) : ( +
+ + + + Item + {rfq.vendorIds.map((vid) => ( + {vendorFor(vid)?.code ?? `#${vid}`} + ))} + + + + {comparison.lines.map((line) => { + const item = itemFor(line.itemId) + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {rfq.vendorIds.map((vid) => { + const cell = line.cells.find((c) => c.vendorId === vid) + return ( + + {cell ? ( + + {cell.unitPrice.toFixed(2)} ({cell.leadDays}d) + + ) : ( + + )} + + ) + })} + + ) + })} + +
+
+ )} + + {[...quotedVendorIds].length > 0 && ( +
+ {[...quotedVendorIds].map((vid) => ( + + + Create PO from {vendorFor(vid)?.code ?? `#${vid}`} + + ))} +
+ )} +
+ + {pendingVendors.length > 0 && ( +
+

Record a quotation

+ +
+ + value={quoteVendorId} onValueChange={selectQuoteVendor}> + + + + + {pendingVendors.map((vid) => ( + + {vendorFor(vid)?.code ?? `#${vid}`} — {vendorFor(vid)?.name} + + ))} + + +
+ + {quoteVendorId && ( + + + + Item + Unit price + Lead days + + + + {rfq.lines.map((line) => { + const item = itemFor(line.itemId) + const draft = quoteLines[line.itemId] ?? { unitPrice: "", leadDays: "" } + const errors = quoteErrors[line.itemId] ?? {} + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + + setQuoteLines((prev) => ({ ...prev, [line.itemId]: { ...prev[line.itemId], unitPrice: e.target.value } }))} + className="h-11 text-base" + /> + + + + setQuoteLines((prev) => ({ ...prev, [line.itemId]: { ...prev[line.itemId], leadDays: e.target.value } }))} + className="h-11 text-base" + /> + + + + ) + })} + +
+ )} + + {quoteFormError && ( +
{quoteFormError}
+ )} + +
+ +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx new file mode 100644 index 0000000..ee05136 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx @@ -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(null) + const [vendors, setVendors] = useState(null) + const [loadError, setLoadError] = useState(null) + const [requisitionLoading, setRequisitionLoading] = useState(!!requisitionId) + + const [vendorIds, setVendorIds] = useState>(new Set()) + const [lines, setLines] = useState([emptyLine()]) + const [lineErrors, setLineErrors] = useState>>({}) + const [headerError, setHeaderError] = useState(null) + const [submitError, setSubmitError] = useState(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) { + 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> = {} + 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 ( +
+
+ + + +
+

New RFQ

+

+ {requisitionId ? `Request quotations for Requisition #${requisitionId}` : "Request quotations from one or more vendors (FR-PROC-02)."} +

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+ +
+ {(vendors ?? []).map((v) => ( + + ))} +
+
+ + {headerError && ( +
{headerError}
+ )} + +
+
+

Lines

+ {!requisitionId && ( + + )} +
+ + {lines.length > 0 && ( + + + + Item + Qty + {!requisitionId && } + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + return ( + + + {requisitionId ? ( +
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
+ ) : ( + <> + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + )} +
+ + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + {!requisitionId && ( + + + + )} +
+ ) + })} +
+
+ )} +
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} + +export default function NewRfqPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx new file mode 100644 index 0000000..6cc1f9c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx @@ -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(null) + const [vendors, setVendors] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })]) + .then(([r, v]) => { + setRfqs(r.items) + setVendors(v.items) + }) + .catch((err) => setError(errorMessage(err))) + }, []) + + function vendorNames(vendorIds: number[]) { + return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ") + } + + return ( +
+
+
+

RFQs

+

Request quotations from vendors and compare pricing (FR-PROC-02).

+
+ + + New RFQ + +
+ + {error && ( +
{error}
+ )} + + {!error && rfqs === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && rfqs !== null && rfqs.length === 0 && ( +
+ +

No RFQs yet.

+ + + New RFQ + +
+ )} + + {!error && rfqs !== null && rfqs.length > 0 && ( + + + + Doc No + Requisition + Vendors invited + Status + Created + + + + {rfqs.map((r) => ( + + + + {r.docNo} + + + {r.requisitionId ? `#${r.requisitionId}` : } + {vendorNames(r.vendorIds)} + + + + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx new file mode 100644 index 0000000..a319060 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -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(null) + const [etag, setEtag] = useState(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(null) + + // Basic info form + const [sku, setSku] = useState("") + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [categoryId, setCategoryId] = useState(null) + const [baseUomId, setBaseUomId] = useState(null) + const [defaultVendorId, setDefaultVendorId] = useState(null) + const [itemType, setItemType] = useState("Stocked") + const [trackingMode, setTrackingMode] = useState("None") + const [taxClass, setTaxClass] = useState("") + + const [errors, setErrors] = useState>({}) + const [conflict, setConflict] = useState(false) + const [saveError, setSaveError] = useState(null) + const [saving, setSaving] = useState(false) + const [togglingStatus, setTogglingStatus] = useState(false) + + // Reorder settings + const [reorderLines, setReorderLines] = useState([]) + const [reorderErrors, setReorderErrors] = useState>>({}) + const [reorderSaveError, setReorderSaveError] = useState(null) + const [savingReorder, setSavingReorder] = useState(false) + + // UOM conversions + const [conversionLines, setConversionLines] = useState([]) + const [conversionErrors, setConversionErrors] = useState>>({}) + const [conversionSaveError, setConversionSaveError] = useState(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) { + 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> = {} + 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) { + 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> = {} + 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 ( +
+
{loadError}
+ + Back to items + +
+ ) + } + + if (!item) { + return ( +
+ + +
+ ) + } + + return ( +
+
+
+ + + +
+
+

{item.sku}

+ + {item.status} + +
+

{item.name}

+
+
+ + +
+ + {conflict && ( +
+ +
+

{saveError ?? "This item was changed by someone else."} Reload before retrying.

+ +
+
+ )} + + {saveError && !conflict && ( +
{saveError}
+ )} + +
+

Basic info

+
+
+ + setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} /> + +
+
+ + setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> + +
+
+ + setDescription(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + value={categoryId} onValueChange={setCategoryId} disabled={conflict}> + + + + + {categories.map((c) => ( + + {c.name} + + ))} + + + +
+
+ + value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + +
+
+ + value={defaultVendorId} onValueChange={setDefaultVendorId} disabled={conflict}> + + + + + {vendors.map((v) => ( + + {v.code} — {v.name} + + ))} + + +
+
+ + setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}> + + + + + Stocked + Non-stocked + Service + + +
+
+ + value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict}> + + + + + None + Batch + Serial + + +
+
+
+ + +
+
+ +
+
+
+

Reorder settings

+

Per-warehouse reorder point and quantity (FR-MD-05).

+
+ +
+ + {reorderLines.length > 0 && ( + + + + Warehouse + Reorder point + Reorder qty + + + + + {reorderLines.map((line) => { + const errs = reorderErrors[line.key] ?? {} + return ( + + + value={line.warehouseId} onValueChange={(v) => updateReorderLine(line.key, { warehouseId: v })}> + + + + + {warehouses.map((w) => ( + + {w.code} + + ))} + + + + + + updateReorderLine(line.key, { reorderPoint: e.target.value })} className="h-11 text-base" /> + + + + updateReorderLine(line.key, { reorderQty: e.target.value })} className="h-11 text-base" /> + + + + + + + ) + })} + +
+ )} + + {reorderSaveError && ( +
{reorderSaveError}
+ )} + +
+ +
+
+ +
+
+
+

UOM conversions

+

Purchase/stock UOM → base UOM conversion factors (FR-MD-02/03).

+
+ +
+ + {conversionLines.length > 0 && ( + + + + From UOM + To UOM + Factor + + + + + {conversionLines.map((line) => { + const errs = conversionErrors[line.key] ?? {} + return ( + + + value={line.fromUom} onValueChange={(v) => updateConversionLine(line.key, { fromUom: v })}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + + + + value={line.toUom} onValueChange={(v) => updateConversionLine(line.key, { toUom: v })}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + + + + updateConversionLine(line.key, { factor: e.target.value })} className="h-11 text-base" /> + + + + + + + ) + })} + +
+ )} + + {conversionSaveError && ( +
{conversionSaveError}
+ )} + +
+ +
+
+ +

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

+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx new file mode 100644 index 0000000..19037b4 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -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 ( +
+
+ + {node.name} + #{node.categoryId} +
+ {node.children.map((child) => ( + + ))} +
+ ) +} + +export default function CategoriesPage() { + const [tree, setTree] = useState(null) + const [flat, setFlat] = useState([]) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [parentId, setParentId] = useState(null) + const [errors, setErrors] = useState>({}) + 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 ( +
+
+
+ + + +
+

Categories

+

Hierarchical item category structure (FR-MD-04).

+
+
+ + + New Category} /> + + + New category + Optionally nest it under an existing category. + + + + Name + setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} /> + + + + Parent (optional) + value={parentId} onValueChange={setParentId}> + + + + + {flat.map((c) => ( + + {c.name} + + ))} + + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && tree === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && tree !== null && tree.length === 0 && ( +
+ +

No categories yet.

+
+ )} + + {!error && tree !== null && tree.length > 0 && ( +
+ {tree.map((node) => ( + + ))} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx new file mode 100644 index 0000000..eda1169 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -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(null) + + const [sku, setSku] = useState("") + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [categoryId, setCategoryId] = useState(null) + const [baseUomId, setBaseUomId] = useState(null) + const [defaultVendorId, setDefaultVendorId] = useState(null) + const [itemType, setItemType] = useState("Stocked") + const [trackingMode, setTrackingMode] = useState("None") + const [taxClass, setTaxClass] = useState("STD") + + const [errors, setErrors] = useState>({}) + const [submitError, setSubmitError] = useState(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 ( +
+
+ + + +
+

New Item

+

SKU, category, base UOM, item type, and tracking mode (FR-MD-01).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+ + setSku(e.target.value)} placeholder="ITM-1004" aria-invalid={!!errors.sku} className="h-12 text-base" /> + +
+
+ + setName(e.target.value)} placeholder="Steel Washer M8" aria-invalid={!!errors.name} className="h-12 text-base" /> + +
+
+ + setDescription(e.target.value)} className="h-12 text-base" /> +
+
+ + value={categoryId} onValueChange={setCategoryId}> + + + + + {(categories ?? []).map((c) => ( + + {c.name} + + ))} + + + +
+
+ + value={baseUomId} onValueChange={setBaseUomId}> + + + + + {(uoms ?? []).map((u) => ( + + {u.name} + + ))} + + + +
+
+ + value={defaultVendorId} onValueChange={setDefaultVendorId}> + + + + + {(vendors ?? []).map((v) => ( + + {v.code} — {v.name} + + ))} + + +
+
+ + setTaxClass(e.target.value)} placeholder="STD" className="h-12 text-base" /> +
+
+ + value={itemType} onValueChange={(v) => v && setItemType(v)}> + + + + + Stocked + Non-stocked + Service + + +
+
+ + value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}> + + + + + None + Batch + Serial + + +
+
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/page.tsx b/Frontend/erp-system/app/dashboard/products/page.tsx index 84969ca..ce888b2 100644 --- a/Frontend/erp-system/app/dashboard/products/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/page.tsx @@ -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(null) + const [categories, setCategories] = useState([]) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [status, setStatus] = useState("All") + const [categoryId, setCategoryId] = useState("All") + const [trackingMode, setTrackingMode] = useState("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 ( -
-

Products

+
+
+
+

Items

+

Item master — SKU, tracking mode, category, default vendor (FR-MD-01).

+
+
+ + + UOMs + + + + Categories + + + + New Item + +
+
+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search SKU or name…" + className="h-14 w-full pl-11 text-base" + aria-label="Search items" + /> +
+ value={categoryId} onValueChange={(v) => setCategoryId(v ?? "All")}> + + + + + All categories + {categories.map((c) => ( + + {c.name} + + ))} + + + value={trackingMode} onValueChange={(v) => setTrackingMode(v ?? "All")}> + + + + + All tracking modes + None + Batch + Serial + + + value={status} onValueChange={(v) => setStatus(v ?? "All")}> + + + + + All statuses + Active + Inactive + + +
+ + {error && ( +
{error}
+ )} + + {!error && items === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && items !== null && items.length === 0 && ( +
+ +

{hasFilters ? "No items match your search/filter." : "No items yet."}

+ {!hasFilters && ( + + + New Item + + )} +
+ )} + + {!error && items !== null && items.length > 0 && ( + <> + + + + SKU + Name + Category + Type + Tracking + Status + Actions + + + + {items.map((item) => ( + + + + {item.sku} + + + {item.name} + {categoryName(item.categoryId)} + {item.itemType} + {item.trackingMode} + + + {item.status} + + + + + + + + + ))} + +
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} + + )}
) } diff --git a/Frontend/erp-system/app/dashboard/products/uoms/page.tsx b/Frontend/erp-system/app/dashboard/products/uoms/page.tsx new file mode 100644 index 0000000..70a3ebd --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/uoms/page.tsx @@ -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(null) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + 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 ( +
+
+
+ + + +
+

Units of Measure

+

Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).

+
+
+ + + New UOM} /> + + + New UOM + e.g. EA, KG, Box-12. + + + + Name + setName(e.target.value)} placeholder="Box-12" aria-invalid={!!errors.name} /> + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && uoms === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && uoms !== null && uoms.length === 0 && ( +
+ +

No UOMs yet.

+
+ )} + + {!error && uoms !== null && uoms.length > 0 && ( + + + + Name + + + + {uoms.map((u) => ( + + {u.name} + + ))} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx new file mode 100644 index 0000000..941f7fe --- /dev/null +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx @@ -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(null) + const [warehouses, setWarehouses] = useState(null) + const [items, setItems] = useState(null) + const [uoms, setUoms] = useState(null) + const [bins, setBins] = useState([]) + const [loadError, setLoadError] = useState(null) + + const [warehouseId, setWarehouseId] = useState(null) + const [lines, setLines] = useState([]) + + const [headerError, setHeaderError] = useState(null) + const [lineErrors, setLineErrors] = useState>>({}) + const [submitError, setSubmitError] = useState(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) { + 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> = {} + 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 ( +
+
+ + + +

Edit GRN

+
+
{loadError}
+
+ ) + } + + const loading = !grn || !warehouses || !items || !uoms + + return ( +
+
+ + + +
+

Edit {grn?.docNo ?? "GRN"}

+

Only Draft GRNs can be edited — confirming posts stock layers permanently.

+
+
+ + {loading && } + + {!loading && ( + <> +
+
+ + value={warehouseId} onValueChange={(v) => setWarehouseId(v)}> + + + + + {(warehouses ?? []).map((w) => ( + + {w.code} — {w.name} + + ))} + + +
+
+ + {headerError && ( +
{headerError}
+ )} + +
+
+

Lines

+ +
+ + {lines.length > 0 && ( + + + + Item + UOM + Bin + Qty + Unit cost + Hold status + Batch / Serial + + + + + {lines.map((line) => { + const item = itemFor(line.itemId) + const errors = lineErrors[line.key] ?? {} + return ( + + + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + + value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> + + + + + {(uoms ?? []).map((u) => ( + + {u.name} + + ))} + + + + + + value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}> + + + + + {bins.map((b) => ( + + {b.code} + + ))} + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { unitCost: e.target.value })} + className="h-11 text-base" + /> + + + + value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}> + + + + + Available + On hold (inspection) + + + + + {item?.trackingMode === "Batch" && ( +
+ updateLine(line.key, { batchNo: e.target.value })} + className="h-9 text-sm" + /> + updateLine(line.key, { expiryDate: e.target.value })} + className="h-9 text-sm" + /> + +
+ )} + {item?.trackingMode === "Serial" && ( +
+