# Merchandising · Product Experience — Audit + Plan

**Branch:** `merchandising-product-experience` (off `origin/main` at `aab8db3`)
**Scope:** the Add Product flow at `/app/merchandise/add` and the surrounding
catalog management surface (list, edit modal, detail drawer, CSV import, API,
hooks, backend controllers).
**Companion docs:** `docs/retail/MERCHANDISING_AUDIT.md` (read-only audit
that flagged the variant silent-drop), `docs/README_SYSTEM_ARCHITECTURE.md`,
`docs/platform/MODULE_ENTITLEMENT_COMPLETION.md`.

This document is **review + design**. No backend business logic changes in
this branch. Tiny safe UI improvements may be applied alongside this report
— they are listed at the bottom.

---

## 1 · Current UX strengths

The form is genuinely well-considered for a single-page redesign. Things
that work today:

- **Single-page scroll** replaces a 6-step wizard. Form fits on one
  thinkpad screen without horizontal scroll. Section anchors make
  navigating by keyboard usable.
- **Completion pills** (`Essentials`, `Pricing`) in the header turn green
  as the operator fills required fields — passive progress signal without
  modal interruptions.
- **Inline auto-SKU button** (`Auto`) generates a SKU from the EN name and
  current catalog count. Lets non-power-users skip a decision they
  shouldn't be making.
- **Live margin gauge** with three states (≥40 emerald, ≥25 amber, <25
  rose) plus a "Low margin — consider raising the price" warning at <25.
  Profit, net-of-VAT, and markup-ratio shown alongside in equal-weight
  tiles.
- **VAT-inclusive math** is shown live (`previewVAT`, `previewNet`).
  Operator types "100" → sees "VAT 15: 13.04, Net: 86.96, Total: 100" —
  unambiguous which of the three numbers is which.
- **Right-rail ZATCA receipt preview** (desktop) including a real TLV-
  encoded QR. Closest thing the form has to a "what will the receipt
  actually look like" affordance.
- **Bilingual everywhere** — every label, hint, button, placeholder, and
  error has EN + AR. RTL Arabic inputs use `dir="rtl"`.
- **Per-kind specifics** are conditionally rendered. Simple products show
  *no* Specifics card at all; variant/composite/weighed/service/modifier
  each get a focused panel.
- **Extra-barcodes editor** is collapsible. Carton/case kinds reveal a
  `Qty / pack` input; retail/internal/supplier hide it. Operator never
  sees fields that don't apply.
- **Backend `ProductController::index`** pre-fetches inventory state in
  two grouped queries (no N+1). The `stock / reorder / branches` figures
  the list shows are real, not stubbed.
- **`/catalog/scan/{code}`** universal resolver is shared by POS, label
  generator, and the catalog UI — single source of truth for "what is
  this code?".

---

## 2 · Current UX weaknesses

Listed by impact, highest first.

### 2.1 The form lies about saving (HIGH)

The save handler at `AddProductPage.jsx:179-207` ships only
`sku, name_en, name_ar, kind, base_price, cost_price, tax_rate, barcode,
category_id` and (in a follow-up call) extra barcodes. **Everything else
the operator fills in is silently discarded** — see §3 for the full list.

The toast is green, the operator is redirected to the products list, the
new row appears with kind=`variant` (or `composite`, etc.) — looking
exactly like the user wanted. Then POS sees one SKU, reports show one
product, pack pricing was never persisted. This is the worst kind of
UX bug because there's no error to debug.

### 2.2 `ProductEditModal` covers only scalar fields (HIGH)

Even if the create form learned to persist variants, the edit modal
(`ProductEditModal.jsx`, 209 LOC) edits ten flat fields and nothing else.
A product with three variant rows can have its base price changed but
**not its variant rows** — there's no UI for it anywhere in the
Merchandise module.

### 2.3 The form is 990 LOC in one file (MEDIUM)

`AddProductPage.jsx` mixes:

- a 6-kind picker (kind state),
- 5 kind-specific sub-forms (variant axis builder, composite parts,
  weighed unit, service unit, modifier min/max),
- pack-size derived pricing,
- pricing + VAT + margin math,
- extra-barcodes editor,
- advanced collapsible (stock / reorder / branches / status),
- live receipt preview + ZATCA QR.

The variant-discard bug (§2.1) is hard to spot precisely because of this
file size. The save handler is buried at line 179 and the variant UI is
at line 578 — they look unrelated.

### 2.4 Auto-SKU duplication risk (MEDIUM)

`handleAutoSku` does:
```js
update({ sku: slugToSku(form.name_en, (products.data?.items?.length || 0) + 1) });
```

The "count" comes from `products.data?.items?.length`, which is the
**current page** of the product list, not the total. On a tenant with
3,000 products, the count is 50 (per_page default) and the suggested
SKU will collide with whatever's on page 1 with the same name root.
The backend's `Rule::unique('products')->where('company_id')` catches
this with a 422 — operator sees a SKU duplication error, never
realising the Auto button is the source.

### 2.5 No live SKU uniqueness check (LOW)

Operator types a SKU, fills the rest, clicks Save. *Then* discovers the
SKU already exists. A debounced GET to `/catalog/products?search=<sku>`
or to a dedicated `/catalog/sku-availability/{sku}` endpoint would catch
this on blur.

### 2.6 No Unit-of-measure picker (MEDIUM)

The Add form has a Category dropdown but no Unit dropdown. The backend
accepts `unit_id` and `UnitController` is fully wired. Without unit
selection at create time, the product defaults to `unit_id = null` and
the operator has to remember to PATCH it after — usually never. Reports
that group by unit see "no unit" buckets.

### 2.7 No image upload (MEDIUM)

The backend accepts `image_media_id`. The Add form has no upload
affordance. `ProductThumb` renders the first letter of the name as a
fallback. For a merchandising experience that aspires to feel "modern,"
no image upload is the biggest gap below variant persistence.

### 2.8 `cost` is in every list response (HIGH — security)

`ProductController::shape` always returns `cost`. Cashiers and inventory
operators see the margin on every SKU. No `PermissionBoundary` gate.
Flagged as MERCH-3 in the prior audit; mentioned here because the UX
exposure is what makes it noticeable.

### 2.9 Catalog writes gated by `catalog.products.view` (HIGH — security)

`store / update / destroy / bulkUpdate / importCsv` all gate on
`catalog.products.view`. Any role with read access can create products
at arbitrary prices, patch base_price, or fire a 200-row bulk update.
Flagged as MERCH-2 in the prior audit.

### 2.10 No validation surface for the kind-specific fields (LOW)

The Save button enables on `name_en + sku + price > 0`. A variant
product with an empty `values[]` array is "valid." A composite with no
parts is "valid." A weighed product with no unit set is "valid." The
button gives no signal that the kind-specific work is incomplete.

### 2.11 No live preview for non-receipt context (LOW)

The right rail shows a receipt. For a product with three variants, the
operator would benefit from seeing a *picker* preview ("how will the
cashier choose between Small / Medium / Large?"). For a weighed product,
a scale-prompt preview. For a service, a duration prompt. None of these
exist.

### 2.12 No mobile/tablet layout for the right rail (LOW)

The receipt preview is `hidden lg:flex` — invisible below 1024 px. An
operator on an iPad creating a product gets no preview at all. Even a
collapsed peek-tab would help.

---

## 3 · Dangerous fake UX areas

**Concrete list** of every input on `AddProductPage` whose value is
discarded by `onSubmit`:

| Field | Where collected | Sent in POST `/catalog/products`? |
|---|---|---|
| `form.variants.axis` | Specifics → variant builder | **No** |
| `form.variants.values[]` (whole array) | Specifics → variant builder | **No** |
| `form.composite.parts[]` (whole array) | Specifics → composite builder | **No** |
| `form.weight_unit` | Specifics → weighed | **No** |
| `form.service_unit` | Specifics → service | **No** |
| `form.service_duration_min` | Specifics → service | **No** |
| `form.modifier_min` | Specifics → modifier | **No** |
| `form.modifier_max` | Specifics → modifier | **No** |
| `form.stock` | Advanced | **No** |
| `form.reorder` | Advanced | **No** |
| `form.branches` | Advanced | **No** |
| `form.status` | Advanced | **No** *(server hard-codes `active`)* |

So **every input under "Specifics" and every input under "Advanced"
except the kind picker is silently dropped.** The form is a kind-picker
+ four scalar fields wearing the costume of a full product editor.

The receipt preview also lies: it shows base price × 1 line, never
reflecting variant pricing, pack sizes, weighted price-per-kg, etc.
For non-simple kinds the preview is unfaithful by construction.

---

## 4 · Variant architecture status

| Layer | Status | Detail |
|---|---|---|
| FE input UI | **REAL** | `AddProductPage.jsx:578-705` — axis picker (size/color/flavor/pack), per-row labels EN+AR, SKU suffix, price delta, dedicated pack-size mode with derived delta. |
| FE state | **REAL** | `form.variants = { axis, values: [...] }` updates correctly. |
| FE → API | **MISSING** | `onSubmit` does not read `form.variants`. `catalog.api.js` has no `createProductVariant` helper. |
| FE list display | **MISSING** | `ProductsPage` shows kind as a badge but no variant count. |
| FE detail display | **MISSING** | `ProductDetailDrawer` doesn't render variants even though `shapeFull()` returns them. |
| FE edit | **MISSING** | `ProductEditModal` has no variant editor. |
| BE controller | **REAL** | `ProductVariantController` — full index/store/update/destroy with `Rule::unique('product_variants')->where('product_id', $product->id)`. Auto-promotes parent `kind` to `variant` on first variant create. |
| BE storage | **REAL** | `product_variants` table with `axis_values` JSON, `price_override`, `cost_override`, `is_active`. |
| BE response | **REAL** | `ProductController::shapeFull` (called by `show()`) includes `variants` array. |
| BE → POS | **REAL** | `/catalog/scan/{code}` resolves variant barcodes to variant rows. |
| Test coverage | **REAL** (BE only) | `ProductsTest::variants_create_and_promote_kind` — the controller works. The FE-to-BE path is untested because it doesn't exist. |

**Classification: backend-only.** The backend is complete and tested.
The frontend's variant UI is fake — it captures input, validates
locally, then drops the data on submit.

---

## 5 · Pack-size architecture status

| Layer | Status | Detail |
|---|---|---|
| FE input UI | **REAL** | `AddProductPage.jsx:628-700` — qty/pack-price builder with automatic `price_delta` derivation (`pp - basePrice`), per-row auto-labels and SKU suffixes (`6-pack` / `-6PK`), per-unit cost line showing on every row. |
| FE → API | **MISSING** | Pack rows live inside `form.variants.values[]` and are dropped with everything else. |
| BE schema for "pack" axis | **PARTIAL** | `ProductVariant.axis_values` is JSON — could hold `{pack: 6}` — but no controller validation distinguishes pack from other axes. |
| BE per-pack price | **REAL** | `ProductVariant.price_override` is the right field for "this pack costs X SAR total" once the FE ships it. |
| POS pack-aware totalling | **UNKNOWN** | Backend supports it on paper (price_override returns from `/catalog/scan/{barcode}`). FE never persists a pack so the path has no real-world test. |
| CSV pack columns | **MISSING** | `ImportCsvModal.jsx:23-27` template has no pack columns. |
| Carton/case extra-barcode `packing_qty` | **REAL** | Per-barcode multiplier persists via `POST /catalog/products/{id}/barcodes` — separate from variants and works today. |

**Classification: hybrid.** Carton/case barcode multipliers work
end-to-end. Pack as a variant axis is collected in UI and discarded.

---

## 6 · Inventory semantics review

The Add form has three inventory fields in Advanced:

| Field | What the operator thinks | What actually happens |
|---|---|---|
| `Stock target` | Initial on-hand for the new product | Discarded — POST never sends it. To get stock onto the product you must run a goods-receipt or stock adjustment from the Inventory module. |
| `Reorder point` | Threshold the list will warn below | Discarded — no `reorder_levels` row is created. The list KPI "low stock" will never fire for this product unless a reorder is set elsewhere. |
| `Branches` | Number of branches to stock the product in | Discarded — and the field is meaningless without a per-branch UI anyway. |

The hint under `Stock target` says *"Planning target; posted via
Inventory"* — which is accurate but understates the issue. The hint
implies the value is captured for later use; it isn't. The field is
purely cosmetic.

This is the correct **architectural** boundary: catalog should not write
inventory. The FE just needs to stop pretending these fields persist.

Options for §5/Phase 5:
- **A**: Drop the three fields from the form entirely; add a post-create
  card on the products list page that nudges "set initial stock →".
- **B**: Keep the fields but route them through the Inventory module on
  submit (one extra POST to `/inventory/movements` per branch). Out of
  scope for this branch.
- **C**: Add a clear note: "These will be saved as planning hints on
  the product; actual stock is posted via the Inventory module." +
  hide the fields behind a "Set inventory targets" link.

Recommended: **C** as a tiny safe change. **B** later as part of a
proper Inventory-on-create branch.

---

## 7 · Barcode / label review

The primary barcode input and the **Extra barcodes editor** are both
real and end-to-end wired. Codes are persisted after the product is
created via parallel `POST /catalog/products/{id}/barcodes` calls
(`Promise.allSettled` — partial-failure tolerant).

Strengths:

- Five kinds (`retail / carton / case / supplier / internal`) match
  backend `ProductBarcode::KINDS`.
- `packing_qty` only enabled for `carton / case`; auto-hidden for
  other kinds.
- Placeholder copy is kind-aware (`MANN-W712/45` for supplier,
  `INT-OIL-001` for internal, `6281234567890` for retail).
- POS `/catalog/scan/{code}` resolves all of them.

Gaps:

- **No barcode preview.** Operator sees the code as plain text. A
  CODE128 / EAN-13 visual preview (already implemented in
  `LabelPrintPage`) would catch checkdigit errors live.
- **No barcode validation.** EAN-13 checkdigit is not validated. The
  user can type `6281234567899` (bad checksum), the backend accepts it,
  POS scans it and works because the validator is the row, not the
  digit math.
- **No "scan to add" affordance.** The form has no scanner field —
  operator must hand-key codes. ScanField exists elsewhere in the
  app and could be dropped in.
- **`Promise.allSettled` swallows partial failures silently.** If 2 of
  5 extra barcodes fail (e.g. duplicate in the tenant), the product
  saves with 3 of 5 codes and the operator gets no feedback. Should
  surface a "3 of 5 extra barcodes saved" toast.
- **Label preview missing on Add.** `LabelPrintPage` knows how to
  render the label; embedding a peek of it in the right rail would
  make "how will this print on a 30×20 thermal label" a one-glance
  question.

---

## 8 · Product lifecycle review

The form supports three product statuses on the backend
(`active / oos / archived`) and exposes two of them in the Add form
(`active / archived` — `oos` only via the Edit modal). The save
handler discards `status` anyway; the backend hardcodes `active` on
`store`.

Implicit lifecycle the operator never sees:

- **Draft.** No `draft` status exists. An operator who isn't ready to
  list the product either fills it incomplete and saves "archived" (a
  semantic abuse — archived means "was active, now isn't") or doesn't
  save at all.
- **Activation gating.** A `simple` product with no price still saves
  as `active`. POS will ring it at 0 SAR. There's no "needs review"
  intermediate status.
- **Variant promotion.** The backend auto-promotes parent kind to
  `variant` on first variant create. The FE never triggers this path
  so the auto-promote is dead code in practice.
- **OOS automation.** Nothing flips a product to `oos` when stock hits
  zero. The Edit modal has the status but nothing else writes it.

For this branch we accept the current lifecycle and just **make the
save accurate** — if `status: archived` is selected, send it (currently
silently dropped). A later branch can add a real "draft" status.

---

## 9 · Recommended UX structure

Strategic direction: replace the **kind-picker as a mode** with
**modes-as-intent**, framed by what the operator wants to sell.

### 9.1 Mode taxonomy (FE-only — backend `kind` stays as the persistence value)

| Mode (FE) | Maps to BE `kind` | Pitch | Example |
|---|---|---|---|
| **Retail item** | `simple` | "One SKU, one price." | A bottle of olive oil. |
| **Item with variants** | `variant` | "Same product, multiple sizes / colors / flavors / packs." | T-shirt S/M/L; coffee 250g/500g/1kg. |
| **Bundle / combo** | `composite` | "A set of existing SKUs sold as one." | "Breakfast set" = coffee + croissant. |
| **Weighed item** | `weighed` | "Priced per kilogram / per 100g." | Bulk dates, deli cheese. |
| **Service** | `service` | "Time, not stock." | Hair-cut, equipment hire. |
| **Add-on / modifier** | `modifier` | "Attaches to another product at POS." | Extra shot, sauce on the side. |

The kind picker becomes a **mode card grid** with explicit pitch lines
("One SKU, one price" beats "Simple product"). The current copy already
does some of this — push further.

### 9.2 Per-mode field matrix

Required (R), recommended (E), hidden (—), per mode:

| Field                | Retail | Variants | Bundle | Weighed | Service | Modifier |
|----------------------|:------:|:--------:|:------:|:-------:|:-------:|:--------:|
| EN name              | R      | R        | R      | R       | R       | R        |
| AR name              | E      | E        | E      | E       | E       | E        |
| SKU                  | R      | R        | R      | R       | R       | R        |
| Primary barcode      | E      | —¹       | —      | E       | —       | —        |
| Category             | E      | E        | E      | E       | E       | E        |
| Unit                 | E      | E        | —²     | R       | R³      | —        |
| Base price           | R      | R⁴       | R      | R       | R       | R⁵       |
| Cost                 | E      | E        | —⁶     | E       | —       | —        |
| VAT                  | R      | R        | R      | R       | R       | R        |
| Image                | E      | E        | E      | E       | E       | E        |
| Variant rows         | —      | R        | —      | —       | —       | —        |
| Parts (composite)    | —      | —        | R      | —       | —       | —        |
| Weight unit          | —      | —        | —      | R       | —       | —        |
| Service unit/duration| —      | —        | —      | —       | R       | —        |
| Modifier min/max     | —      | —        | —      | —       | —       | R        |
| Initial stock note   | E (route to Inventory) | E | — | E | — | — |

Footnotes:
1. Variants get their own per-row barcodes.
2. Bundle price is set explicitly, not derived from parts.
3. Service "unit" = hour/day/per-service.
4. Variants override base via `price_delta`; base must be present.
5. Modifier price is the add-on charge.
6. Cost auto-computed from sum of part costs (later branch).

### 9.3 Progressive disclosure

Today: `Specifics` card appears for non-simple kinds; everything else
is always visible.

Proposed: a stricter rule:

- **Always**: name, SKU, mode picker.
- **Mode-revealed**: category, unit, price, cost, VAT, primary barcode,
  mode-specific rows (variants / parts / weight unit / etc.).
- **Collapsed under "More"**: image, description, extra barcodes,
  inventory hints (renamed from "Advanced settings" which sounds
  technical — "More details" is friendlier).

This frees the screen for the operator who's filling a one-off retail
item — currently the most common case.

---

## 10 · Safe implementation order

Listed by risk × impact, lowest risk first.

### Tier A — tiny safe UX (this branch, Phase 5)

A1. **Variant warning banner.** Above the variant builder when
`form.kind === 'variant'` and `values.length > 0`: clearly state that
variants are not yet persisted and the product will be saved as a
single SKU. Disable submit unless `values.length === 0` OR operator
acknowledges. (See §2.1.)

A2. **Composite warning banner.** Same approach for
`form.kind === 'composite'` with non-empty `parts`. (See §3 row 4.)

A3. **Inventory-fields hint.** Reword the Advanced card hint to make
clear `Stock / Reorder / Branches` are not saved here; point at the
Inventory module.

A4. **Status field hint.** Note that the Active/Draft toggle is for the
list filter — actual lifecycle work is on the product detail page.

A5. **Auto-SKU robustness.** Use a UUID-suffix or `crypto.randomUUID()`
slice instead of `products.length`. Eliminates the silent-collision
risk in §2.4 without changing the visual format much.

A6. **Receipt preview faithfulness.** Add a small footnote when
`kind !== 'simple'`: "Preview shows base SKU only — variant/pack pricing
not yet supported in preview."

A7. **"Saved N of M extra barcodes" toast** if `Promise.allSettled`
saw any rejection. (Tiny — replace the silent path.)

A8. **A test** that pins each of the above warnings — so a refactor
doesn't silently drop them.

### Tier B — small backend-only fixes (later branch)

B1. **Cost-field redaction** (MERCH-3) — read-only field, role-gated
shape.

B2. **Split `catalog.products.view`** into
`.view / .create / .update / .delete / .bulk_update / .import` (MERCH-2).

B3. **Tighten `store` validator** to reject `variants` / `composite` /
inventory fields with a clear 422, so future FE accidents fail loud
instead of silent.

### Tier C — variant persistence wave (separate branch)

C1. Add `createProductVariant` to `catalog.api.js` and `useCreateProductVariant` hook.

C2. After `useCreateProduct.mutateAsync` resolves on `AddProductPage`,
loop `form.variants.values` → call `useCreateProductVariant` for each
(use `Promise.allSettled`, surface partial failures).

C3. Add a `VariantsEditor` block to `ProductEditModal` so post-create
variant management is possible.

C4. Render `variants[]` in `ProductDetailDrawer`.

C5. Show a variant count column / inline pill in `ProductsPage`.

C6. Backend: add an optional `variants[]` array to
`ProductController::store` validator — atomic create in one transaction.

C7. CSV import: extend template + parser.

### Tier D — UX-intelligence wave (separate branch)

D1. Image upload (single `<input type="file">` → Media API → set
`image_media_id` on save).

D2. Unit picker in Essentials.

D3. Debounced SKU availability check.

D4. Barcode visual preview + EAN-13 checkdigit validation.

D5. Replace `form.composite.parts[]` SKU dropdown with a typeahead
(`useProducts` is already cached) — 3000-SKU tenants can't use a `<select>`.

D6. Mobile right-rail (collapsed peek-tab).

D7. Live variant/pack preview (replaces receipt preview for non-simple
kinds).

### Tier E — lifecycle (separate branch, requires schema)

E1. Add `draft` to `Product::STATUSES`; allow Save as draft.

E2. Auto-flip `oos` when summed `on_hand` hits 0 (or stays at "active"
with a low-stock badge — design decision).

E3. Variant auto-promote review — currently dead code; either remove or
expose via API.

---

## Appendix · Files inspected

Frontend (Merchandising module, 6,445 LOC):

- `AddProductPage.jsx` (990 LOC)
- `ProductEditModal.jsx` (209)
- `ProductsPage.jsx` (472)
- `catalog.api.js` (115)
- `catalog.hooks.js` (318)
- `BarcodesPage.jsx` (268)
- `BatchesPage.jsx` (518)
- `CategoriesPage.jsx` (352)
- `UnitsPage.jsx` (309)
- `PriceListsPage.jsx` (267)
- `PricingResolverPage.jsx` (198)
- `PricingPage.jsx` (120)
- `LabelPrintPage.jsx` (547)
- `ImportCsvModal.jsx` (239)
- `CatalogShell.jsx` (63)
- `ProductKindsPage.jsx` (125)

Frontend (consumers in other modules):

- `app/src/modules/retail/_shared/ProductDetailDrawer.jsx`
- `app/src/modules/retail/_shared/ProductThumb.jsx`
- `app/src/modules/retail/pos/ZatcaQR.jsx`
- `app/src/modules/retail/pos/pos.utils.js`
- `app/src/modules/retail/retail.hooks.js` (`useCreateProduct`, `usePatchProduct`, `useProducts`, `useProductKinds`)

Backend (Catalog controllers, 1,235 LOC):

- `ProductController.php` (345)
- `ProductVariantController.php` (92)
- `ProductBarcodeController.php` (81)
- `ProductBatchController.php` (87)
- `ProductSupplierController.php` (69)
- `CategoryController.php` (187)
- `UnitController.php` (109)
- `PriceListController.php` (163)
- `PricingRuleController.php` (155)
- `CatalogScanController.php` (47)

Routes: `backend/routes/api.php:536-549` (catalog/products + variants).

---

**End of audit.** Phase 5 tiny safe changes that land alongside this
document are tracked in the branch commit history.
