Files
ERP-core/docs/20-FRONTEND.md
T
Naveen 149b87bfeb Add backup images for login and background removal
- Added loginerps.png.bak (1134 KB) as a backup for the login image.
- Added whithoutbackground.png.bak (3455 KB) as a backup for the image without background.
2026-09-15 15:24:48 +05:30

222 lines
26 KiB
Markdown

# 20 · FRONTEND — Phase 1 (Inventory & Supply Chain)
> **Authoritative for:** frontend user-flows, the rules to follow the existing architecture, and the validation posture.
> **Navigation:** you arrived from `00-CORE.md`. The API contract this UI consumes is in `11-BACKEND-PHASE1.md`; business rules are in `10-BACKEND-PHASE1.md`. Record work in `Frontend/PROGRESS.md`.
> **Note:** the Frontend project is **already initialized** (Next.js, App Router, TypeScript). This document does **not** scaffold structure — it governs how to build within what exists.
---
## 1. Stack & architecture rules
| Concern | Rule |
|---|---|
| Framework | Next.js **App Router** + **TypeScript**. Work within the existing structure; do not restructure or introduce a competing router. |
| Styling / UI | **Tailwind CSS** for all styling — no CSS modules, styled-components, or inline style objects. UI primitives come from **shadcn/ui** (`components/ui/`, built on `@base-ui/react` + `class-variance-authority`); use/extend existing components there before adding a new one, and generate new primitives via the shadcn CLI to keep the pattern consistent. |
| State / forms | **Plain React hooks** (`useState`, `useReducer`, custom hooks). No form/state library. |
| Validation | **Dependency-free** (hand-rolled helpers). See §3. |
| API access | A single **typed fetch client** (`lib/api-client.ts`) against a **same-origin `/api/v1`**; all calls go through it. No scattered `fetch()` in components. |
| Transport | The API is reached through a **Next `rewrites()` proxy** (`next.config.ts`: `/api/*` → `BACKEND_ORIGIN`, default `http://localhost:5224`). Same-origin by construction, so there is no CORS on the backend and none is needed. `BACKEND_ORIGIN` is **server-side only** — deliberately not `NEXT_PUBLIC_*`, since the browser only ever talks to the Next server. |
| Types | TS types in `types/` **mirror the API DTOs** in `11-BACKEND-PHASE1.md`. When the contract changes, update these first. |
| Auth | **httpOnly cookie session — there is no bearer token to store.** `POST /auth/login` sets `erp_at`/`erp_rt`/`XSRF-TOKEN`; the client just sends `credentials: "include"`. JS cannot read `erp_at` by design. `proxy.ts` guards `/dashboard/*` on the cookie's *presence* only (it cannot validate an RS256 JWT at the edge) — **the API remains the authority**. `lib/auth-session.ts` caches the user *profile* in localStorage for display only, because there is no `GET /auth/me`; it is not a credential. |
Principles:
- **The API contract is the source of truth.** The UI adapts to `11-BACKEND-PHASE1.md`, never the reverse.
- **Keep components thin;** put data-fetching and derived state in hooks, request/response shaping in the API client.
- **One typed client method per endpoint**, returning the DTO type from `types/`.
### 1.1 Design tokens (colour theme)
All colour comes from CSS custom properties in `app/globals.css` (`:root`, `.dark`, `.vibrant`), consumed as Tailwind utilities (`bg-primary`, `text-primary`, `bg-secondary`, …) via the `@theme inline` mapping — **never hardcode a hex/oklch value in a component.** To restyle the brand accent, change the token values in `globals.css`; every component that uses `bg-primary`/`text-primary`/`ring` picks it up automatically across all three themes (light/dark/vibrant).
> **2026-09-10 — brand accent switched to crimson.** `--primary` (and `--ring`, `--chart-1`, `--sidebar-primary`, `--sidebar-accent`) changed from blue/violet to a crimson-red (`oklch(0.586 0.222 17.585)` light/vibrant, `oklch(0.645 0.215 16.439)` dark) to match the reference brand look, purely a token-value change — no component structure, layout, or architecture changed. `components/ui/button.tsx`'s `default` variant now reads `bg-primary`/`text-primary-foreground` (was hardcoded `bg-black`/`text-white`), so this is the first theme change to actually flow through the shared Button rather than being fought by a one-off override. The Login screen (`app/login/page.tsx`) was updated to stop overriding the button with `bg-foreground` and instead take the shared `default` variant, and its "Forgot password?" / "Create an account" links now use `text-primary` instead of `text-foreground` to read as accent links. Same date: `components/ui/select.tsx` (`SelectItem`) and `components/ui/combobox.tsx` had a hardcoded `violet-*` highlight/check-mark color that ignored the theme entirely — fixed to token-driven colours, then aligned with the sidebar look (below).
> **2026-09-10 — dropdown/select/combobox highlight matches the sidebar's active-item colour.** `DropdownMenuItem`/`DropdownMenuSubTrigger`/`DropdownMenuCheckboxItem`/`DropdownMenuRadioItem` (`dropdown-menu.tsx`), `SelectItem` (`select.tsx`), and the combobox item (`combobox.tsx`) all switched their highlighted/focused state from `bg-accent`/`text-accent-foreground` (a separate teal token, unrelated to the brand colour) to `bg-secondary`/`text-secondary-foreground` — the same soft-crimson-tint token the sidebar's active nav item already uses for its background/text. Result: hovering or keyboard-focusing any menu, select, or combobox row now highlights in the same soft-crimson tone as the active sidebar item, in every theme (light/dark/vibrant), since both read the same token pair. `--accent` itself is untouched and still used elsewhere (e.g. `ghost` button hover) as a neutral highlight.
> **2026-09-10 — theme-toggle "Vibrant" segment matches the sidebar/brand colour.** `components/theme-toggle.tsx`'s active "Vibrant" (monitor icon) segment was hardcoded to `bg-linear-to-br from-indigo-500 to-violet-600` — the only spot in the toggle not driven by a token, so it kept showing indigo/violet regardless of the brand colour. Changed to `from-sidebar-primary to-primary` with `text-primary-foreground`, so the active pill now renders a crimson gradient matching the sidebar's active-item colour, and will track any future `--primary`/`--sidebar-primary` change automatically.
> **2026-09-10 — Hexa ERP logo re-coloured, background removed.** `public/images/hexa-erp-logo.png` (used by both `AppSidebar.tsx`'s header and `app/login/page.tsx`) was a raster image with a baked-in near-white background and blue/navy/gray brand colours — not something a CSS/token change can touch, since it's pixels, not markup. Reprocessed with a one-off Node/`sharp` script (not committed — this was an asset edit, not a code change) that made the near-white background fully transparent (with soft alpha at the antialiased edges, not a hard cutout) and remapped the blue/navy hues to the same crimson used by `--primary`/`--sidebar-primary`, while leaving the "ERP" wordmark and its hexagon a neutral gray. The original is kept alongside as `hexa-erp-logo.png.bak` in case the brand mark itself needs to be redone by a designer later. If the logo is ever replaced, prefer sourcing a transparent-background asset directly rather than re-running the extraction script, which is a best-effort colour/alpha heuristic, not a general tool.
> **2026-09-10 — dashboard stat cards sized down.** `components/ui/stat-card.tsx` (the top row of the dashboard: Low Stock Alerts, Stock On-Hand, etc.) had generous spacing (`p-5`, `gap-3`, `text-base` label, `size-9` icon, `text-xl` value) that read as oversized once the cards' third row (`delta`/`trend`, both optional props the dashboard never passes) was empty — most of a card's height was whitespace. Tightened to `p-4`, `gap-2`, `text-sm` label, `size-7` icon, `text-lg` value, `text-xs` delta row — same content, same props/behaviour, just denser. `StatCard` has exactly one call site (`app/dashboard/page.tsx`), so this didn't need reconciling against any other screen. Same date: the grid wrapping those cards went from `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4` (wrapped to two rows) to `grid-cols-2 sm:grid-cols-4 lg:grid-cols-7` (all 7 in one row), then the **"Pending Approval POs" card was removed** from the dashboard entirely (`app/dashboard/page.tsx`) and the grid settled at `grid-cols-2 sm:grid-cols-3 lg:grid-cols-6` for the remaining 6 — a deliberate content + layout call, not a bug fix. `stats.pendingApprovalPurchaseOrders` and the `DashboardStats` type are untouched (backend/API contract, out of scope for a UI-only change) — only the card and its now-unused `Clock` icon import were removed from the page.
> **2026-09-10 — the two dashboard chart panels sit side by side.** "Stock Valuation by Warehouse" (bar chart) and "Stock Movement Trend" (line chart) on `app/dashboard/page.tsx` were two full-width `rounded-2xl` panels stacked vertically. Wrapped both in a `grid grid-cols-1 lg:grid-cols-2 gap-4` container so they sit in one row at `lg` and above, and stack on narrower screens — each panel's own markup/content is unchanged, only the wrapping grid was added.
> **2026-09-10 — "Stock Valuation by Warehouse" bar colour matches the brand crimson.** The bar dataset's `backgroundColor` was a hardcoded indigo `#6366f1`, left over from before the crimson theme change and not updated with it. Changed to `#E11D48` (the same crimson as `--primary`). `components/ui/bar-chart.tsx`/`line-chart.tsx` are thin Chart.js wrappers that take a plain CSS colour string per dataset — Canvas rendering can't reliably resolve the app's `oklch()` custom properties, so (matching the Line chart's existing "In"/"Out" green/red, also literal hex) the call site passes a literal hex kept in sync with the token by hand rather than a `var(--primary)` reference. The Line chart's green ("In") / red ("Out") were left as-is: those are semantic (stock direction), not brand colour, and forcing them to crimson would remove the in/out distinction.
> **2026-09-10 — login-page illustration's UI-mockup panels recoloured to crimson.** `public/images/loginerps.png` (light mode) and `public/images/whithoutbackground.png` (dark mode, `app/login/page.tsx`'s two side-by-side hero images) are photorealistic scenes — two people at a table plus three floating UI-mockup panels ("Inventory Management", "Order Processing", "Integrated Analytics") in blue/teal. Unlike the flat-vector logo, a global hue-shift here would also discolour the people (navy suits/hair share the same blue hue range as the panel chrome — verified by direct HSL sampling, not assumption), so the recolour script is restricted to three hand-verified pixel rectangles covering just the panels, and only remaps hue 180-230° pixels with real saturation/lightness (`spread ≥ 40`, `s ≥ 0.25`, `0.2 ≤ l ≤ 0.85`) to crimson, blending by saturation so text/borders/antialiasing aren't speckled. Greens (the "Delivered" status pill, success ring, checkmarks) are left alone — same semantic-colour reasoning as the dashboard charts, above. `whithoutbackground.png` additionally got the same white-to-transparent treatment as the logo (it turned out to be a flat white background despite the filename, not real alpha) in the same pass. Both originals are kept as `loginerps.png.bak` / `whithoutbackground.png.bak`. This is a best-effort raster edit (not a general tool, and not committed as a script) — if a designer ever redoes this illustration, recolour it at the source rather than re-running the heuristic.
---
## 2. User flows
The flow below is the end-to-end Phase-1 journey: replenishment need → procurement → receiving → QC hold → stock available → stock operations, with the reorder loop closing back. Colour = functional role. Roles now drive real sidebar visibility (`GET /auth/me`'s `navCodes`, see docs/10 C.8/docs/11 §2.0.1, admin screens at `/dashboard/settings/roles` and `/dashboard/settings/users`) but **per-endpoint RBAC is still not enforced** — this remains a UI-level filter only.
> **Roles screen (2026-07-18):** `code` is never typed by an operator — it's derived client-side from `name` (uppercased, non-alphanumeric → `_`) and shown read-only, on both create and edit. The Create Role dialog also includes the permission checkbox tree (`components/auth/RolePermissionTree.tsx`, fed by `GET /nav`), so creating a role and assigning its sidebar permissions is one Save action; the detail page (`/dashboard/settings/roles/[id]`) remains for later edits. The Create User dialog's "User type" is a `<Select>` populated from `GET /users/user-types` (proxying AuthHex's `listUserTypes`), defaulting to the sole existing type — operators never type an AuthHex UserType GUID by hand.
```mermaid
flowchart TD
START([Replenishment need]):::trigger
ALERT[/Reorder alert raised<br/>available at or below reorder point/]:::system
START --> REQ[Create Requisition<br/>Procurement officer]:::proc
ALERT --> REQ
REQ --> SUBREQ[Submit requisition]:::proc
SUBREQ --> RFQDEC{RFQ needed?}:::decision
RFQDEC -->|Yes| RFQ[Create RFQ and send to vendors]:::proc
RFQ --> QUOTE[Record vendor quotations]:::proc
QUOTE --> COMPARE[Compare and select vendor]:::proc
COMPARE --> PO[Create Purchase Order]:::proc
RFQDEC -->|No| PO
PO --> APPRDEC{approvalRequired?}:::decision
APPRDEC -->|No - Phase 1 default| AUTOAPP[[Auto-approved on creation]]:::system
APPRDEC -->|Yes| PENDING[Pending approval]:::proc
PENDING --> APPROVE{Approver decision}:::decision
APPROVE -->|Reject| REJECTED([PO rejected]):::endp
APPROVE -->|Approve| APPROVED[PO Approved and issued to vendor]:::proc
AUTOAPP --> APPROVED
APPROVED --> ARRIVE([Goods arrive from vendor]):::trigger
ARRIVE --> GRN[Create GRN against PO<br/>Storekeeper]:::wh
GRN --> ENTER[Enter qty, bin, batch/serial]:::wh
ENTER --> TOLDEC{Over-receipt beyond tolerance?}:::decision
TOLDEC -->|Yes| BLOCKREC([Blocked / warn]):::endp
TOLDEC -->|No| HOLDDEC{Inspection required?}:::decision
HOLDDEC -->|Yes| RECVHOLD[Receive as OnHold]:::wh
HOLDDEC -->|No| RECVOK[Receive as Available]:::wh
RECVHOLD --> CONFIRM[Confirm GRN]:::wh
RECVOK --> CONFIRM
CONFIRM --> FIFO[[Create FIFO layer + post stock ledger + update PO qtyReceived]]:::system
FIFO --> HELDQ{Line on hold?}:::decision
HELDQ -->|Yes| INSPECT[QC inspection<br/>Inventory controller]:::controller
INSPECT --> INSPDEC{Pass inspection?}:::decision
INSPDEC -->|Release| AVAIL
INSPDEC -->|Reject| PRET[Create Purchase Return]:::proc
PRET --> RETPOST[[Post outbound ledger]]:::system
RETPOST --> RETEND([Returned to vendor]):::endp
HELDQ -->|No| AVAIL([Stock Available]):::stock
AVAIL --> OPS{Stock operation}:::decision
OPS -->|Transfer| TR1[Create transfer<br/>source and destination]:::wh
OPS -->|Adjust| AD1[Create adjustment + reason code<br/>Inventory controller]:::controller
OPS -->|Count| CN1[Create count - cycle or full<br/>Storekeeper]:::wh
TR1 --> TRDISP[Dispatch]:::wh
TRDISP --> TRAVAIL{Available covers qty?}:::decision
TRAVAIL -->|No| TRBLOCK([Negative-stock block]):::endp
TRAVAIL -->|Yes| TRIT[[Consume source layers, status In-Transit]]:::system
TRIT --> TRREC[Receive at destination]:::wh
TRREC --> TRDONE[[Create destination layer at inherited cost - cost-preserving]]:::system
TRDONE --> AVAIL
AD1 --> ADDEC{Decrease exceeds available?}:::decision
ADDEC -->|Yes| ADBLOCK([Negative-stock block]):::endp
ADDEC -->|No| ADPOST[[Auto-post: FIFO consume/create + ledger]]:::system
ADPOST --> AVAIL
CN1 --> CNSNAP[[Snapshot system qty]]:::system
CNSNAP --> CNCOUNT[Enter counted qty]:::wh
CNCOUNT --> CNVAR[[Compute variance]]:::system
CNVAR --> CNPOST[Post count<br/>Inventory controller]:::controller
CNPOST --> CNADJ[[Variance adjustment + ledger]]:::system
CNADJ --> AVAIL
AVAIL -.->|monitor available vs reorder point| ALERT
classDef trigger fill:#fff2cc,stroke:#d6b656,color:#333;
classDef proc fill:#d5e8d4,stroke:#82b366,color:#333;
classDef wh fill:#ffe6cc,stroke:#d79b00,color:#333;
classDef controller fill:#e1d5e7,stroke:#9673a6,color:#333;
classDef system fill:#dae8fc,stroke:#6c8ebf,color:#333;
classDef stock fill:#d5e8d4,stroke:#2d7d2d,color:#173d17;
classDef decision fill:#f8f4d0,stroke:#b0a133,color:#333;
classDef endp fill:#f8cecc,stroke:#b85450,color:#333;
```
### 2.1 Flows → API mapping
Each screen calls the endpoints in `11-BACKEND-PHASE1.md`. System steps (blue) are performed server-side — the UI only triggers them and renders the result.
| Flow | Screens | Key endpoints |
|---|---|---|
| Login | Login | `POST /auth/login` (sets the session cookies), `POST /auth/logout` |
| Replenishment | Reorder alerts | `GET /stock/reorder-alerts`, `POST /stock/reorder-alerts/{itemId}/requisition` |
| Procurement | Requisition, RFQ, PO | `POST /requisitions`, `/rfqs`, `/rfqs/{id}/quotations`, `GET /rfqs/{id}/comparison`, `POST /purchase-orders`, `PUT /purchase-orders/{id}` |
| Receiving | GRN | `POST /grns`, `POST /grns/{id}/confirm`, `POST /grns/{id}/lines/{lineId}/release` |
| Returns | Purchase return | `POST /purchase-returns` |
| Stock enquiry | Stock views | `GET /stock/on-hand`, `/stock/ledger`, `/stock/valuation` |
| Transfer | Transfer | `POST /stock-transfers`, `/dispatch`, `/receive` |
| Adjustment | Adjustment | `POST /stock-adjustments` |
| Count | Count | `POST /stock-counts`, `PUT /stock-counts/{id}/counts`, `POST /stock-counts/{id}/post` |
### 2.2 Master data screens (supporting, outside the core flow)
Vendors, Items, Categories, Subcategories, UOM, Warehouses, Brands, and Item Types are supporting master-data CRUD screens the flow above depends on but doesn't itself route through, so they're intentionally absent from the diagram/table. List screens follow one pagination convention: `page`/`pageSize`/`q`/`sortOrder` params, page size 5, debounced search, Previous/Next controls.
> **2026-07-17 — the frontend is connected to the real API.** `lib/api/mock-data.ts` is **deleted**; every `lib/api/*.ts` module calls the backend. The drift listed here previously has been reconciled — what follows records the decisions so they are not re-litigated.
**Brand** (`app/dashboard/products/brands`), **Item Type** (`app/dashboard/products/item-types`, formerly "Variant Category") and **Subcategory** (`app/dashboard/products/categories/[id]`) are real backend entities (`docs/11 §2.3/2.6/2.7`). The item builder on `/dashboard/products/new` reads the Item Type list live from `GET /item-types`: checking a type (Color, Size, or any custom one added inline from that same page) reveals a value-entry section for it, and one Item is created per combination across however many types are checked, with a client-generated SKU. See `Frontend/PROGRESS.md` for the history.
#### 2.2.1 Resolved contract decisions (backend is authoritative — §1)
- **`variantCategoriesApi` → `itemTypesApi`** (`GET /item-types`); `variantCategoryId` → `itemTypeId`.
- **`Item.itemType` → `stockNature`.** `itemType` now means a Color/Size dimension master — a different concept (`docs/11 §2.7`). The item-detail label reads "Stock nature".
- **Both category FKs travel.** The builder sends `categoryId` **and** `subCategoryId`; the old `subCategoryId ?? categoryId` collapse lost the parent. The server rejects a mismatched pair with 422. Subcategories are their own resource — `Category.parentId` and `?tree=true` are gone.
- **Colour hex-packing stays frontend-only.** There is no value table server-side, so `"Red|#EF4444"` + `encodeColorValue`/`decodeColorValue`/`isColorCategory` have nothing to reconcile against. Kept as-is.
- **SKU generation stays client-side** (`buildVariantSku`) and is the *only* record of which colour/size an item is; the server only uniqueness-checks it. **Nothing can query items by colour** — accepted (`docs/10 Part C.9`).
- **`remove()` → `updateStatus(id, "Inactive")`** everywhere. There are no `DELETE` endpoints on any master (FR-MD-08); the lists show a Status column and Deactivate/Activate.
- **`initialQty` is gone** from the builder — the Item contract has no such field and there is no initial-receipt flow. Stock arrives via a GRN.
- **Sale-price toggle on the builder (2026-07-22).** A "Fixed price / Use stock value" toggle sits on `/dashboard/products/new`. **Use stock value** (default) sends `salePrice: null` on every created item (sold at FIFO value). **Fixed price** reveals a top "fix value" input that pre-fills a per-variant `Sale price` column; each row is editable, and submit is blocked until **every** generated variant has a price `> 0`. The toggle is **frontend-only** — the contract is just the nullable `salePrice` field (`docs/11 §2.1`); it is Sales-only and never affects GRN/costing. The non-transactional create loop still applies — each variant's `salePrice` rides its own `POST /items`.
- **Product Configuration** (`app/dashboard/products/settings`, `GET`/`PUT /product-config`) — only **3** of the original design's ~13 toggles exist. `subcategoriesEnabled`/`brandsEnabled` are server-enforced (`CONFIG_DISABLED`); **`itemTypesEnabled` is advisory** and this app is what honours it (it hides the builder's type section). The UI states that distinction on the screen rather than implying a guarantee.
- **Non-transactional create loop:** the builder's per-row `itemsApi.create()` has no transaction — a `SKU_DUPLICATE` on row 7 of 12 leaves 6 items created. The error message now says how many landed rather than implying nothing happened. A transactional bulk-create endpoint would be the real fix.
- **GRN edit/delete removed** — the API has no `PUT`/`DELETE` for a GRN; receipts are corrected by reversing documents (FR-X-05).
- **GRN off-PO items + inline item create (2026-07-22).** On `/dashboard/receiving/grn/new`, "Add line" is available in **both** PO and direct mode — an added line in PO mode has `poLineId: null` and receives an item not on the PO (editable item/UOM dropdowns, `unitCost` required). A **"New item"** button opens `/dashboard/products/new` in a **new browser tab** (`window.open`, the first such pattern in the app), and a **refresh icon** re-pulls `GET /items?status=Active` so the newly created item is selectable **without** reloading the screen and losing the in-progress GRN. Server treats off-PO lines as direct receipts (no over-receipt check) — `docs/11 §4.1`.
- **RFQ invited-vendors is not persisted** — `POST /rfqs` validates `vendorIds` then discards them, so the list/detail screens show quotations received instead of vendors invited.
- **Known gap — serial numbers:** FR-GRN-04 requires capturing serials on receipt, but `CreateGrnLineInput` has no such field (only `batch`). The UI does not collect them rather than silently discarding them. Needs a backend change to honour the requirement.
---
## 3. Validation posture (read carefully)
**Validate on the client for UX; never trust the client. The server is the authority.** The browser can be bypassed (devtools, direct API calls, replays), so client checks exist only to give fast feedback and reduce round-trips — they are **never** the enforcement point.
### 3.1 Two categories
**Client-side (UX only — safe to check locally):** purely input-level facts the browser already has.
- Required fields present.
- Format: SKU pattern, numeric fields numeric, date format, positive integers.
- Range/bounds: `qty > 0`, `unitPrice >= 0`, `factor > 0`. Item `salePrice` is a client-supplied number — the builder requires `> 0` per variant in fixed mode; the server only checks `>= 0` on a supplied value (`docs/11 §2.1`).
- Simple cross-field input rules: transfer `destWarehouseId != srcWarehouseId`.
- Enum membership via constrained dropdowns (`stockNature` — ex-`itemType`, `trackingMode`, `countType`, `holdStatus`). Note the **Item Type** dropdown is *not* in this category: it's server data (`GET /item-types`), not an enum.
**Server-authoritative (client MUST NOT assume — only the server can judge):** anything depending on current server state.
- **Stock availability / negative-stock block** (depends on live ledger) — `STOCK_NEGATIVE_BLOCKED`.
- **FIFO layer sufficiency** on any issue.
- **Over/under-receipt tolerance** vs PO open quantity — `OVER_RECEIPT_TOLERANCE`.
- **SKU uniqueness** — `SKU_DUPLICATE`.
- **Batch expiry / on-hold issuability** — `EXPIRED_BATCH_BLOCKED`, `ONHOLD_NOT_ISSUABLE`.
- **PO editability** (status-dependent) — `PO_NOT_EDITABLE`.
- **Referential existence/active status** of item/vendor/warehouse/bin.
- **Concurrency** (stale edit) — `CONCURRENCY_CONFLICT` via `ETag`/`If-Match`.
- **Reason code required/valid** — `REASON_CODE_REQUIRED`.
> Rule of thumb: if answering "is this allowed?" requires knowing the **current stock, a document's status, or another user's change**, it is server-authoritative. Do not gate submission on a client-side guess about it, and do not show it as "valid" until the server confirms.
### 3.2 Handling server responses
- Always send the request and handle the outcome; the server response is the truth.
- On `400` `ValidationProblemDetails`, map `errors` to fields.
- On `409` / `422` domain errors, read `code` and show a specific message (e.g. `STOCK_NEGATIVE_BLOCKED` → "Not enough available stock"). Keep a `code → message` map in `lib/` so messages are consistent.
- On `412` (`CONCURRENCY_CONFLICT`), tell the user the record changed and refetch before retrying.
- Never silently swallow a `ProblemDetails`; surface it.
### 3.3 What not to do
- Don't disable the submit button based on a client assumption about stock, availability, or status.
- Don't reimplement FIFO/tolerance/negative-stock logic in the browser — you cannot see the live ledger.
- Don't trust quantities computed client-side for posting; send inputs and let the server compute costed movements.
---
## 4. Error & empty states
- Every list screen handles loading, empty, and error states explicitly.
- Surface the API client's normalized error (from `ProblemDetails`) with the domain `code` message where present.
- For transactional actions (GRN confirm, transfer dispatch/receive, adjustment, count post), show the server's returned side effects (created/consumed layers, ledger refs) as confirmation rather than assuming success.
---
*End of 20-FRONTEND.md. API contract: `11-BACKEND-PHASE1.md`. Record work: `Frontend/PROGRESS.md`.*