# Shop · Customers directory + freeze — Logic parity

Demo (canonical): `portals/shop/Customers.tsx` + `store/store.ts` + `store/selectors.ts` + `lib/finance.ts` + `types.ts`.
Ours: `frontend/controllers/CustomersController.php`, `frontend/views/customers/index.php`, `common/models/CustomerFreeze.php`.

## 1. Screen scoping — which customers appear

**Demo** (`Customers.tsx:26-29`): customers = distinct `customerId`s drawn from `bookings` where `b.shopId === shop.id`, then resolved against the global `customers` store. So the directory is **booking-derived**, per active shop.

**Ours** (`CustomersController.php:57-98`): identical idea via SQL. `Booking::find()->select(customer_id, COUNT(*) cnt, MAX(booking_date) last_date)->where(shop_id)->groupBy(customer_id)`, then hydrate `User` + `userProfile`. Shop scoping is `Yii::$app->user->identity->shop_id` (`CustomersController.php:45-48`). Adds `LIMIT 300` and `ORDER BY last_date DESC` — demo has no limit/order (renders all). **Match (with our extra cap/sort).**

## 2. Three-tab structure: Customers / Classifications / Freeze

**Demo** (`Customers.tsx:23,40-52`): a `Segmented` control switches between three views — `customers`, `classifications`, `freeze`. Each is its own table.

**Ours** (`views/customers/index.php`): only **two stacked sections** — directory + freeze list. **No Classifications tab at all**, and no segmented switcher (both sections render simultaneously). **Partial.**

## 3. Per-row columns in the directory

**Demo** (`Customers.tsx:100-137`): Customer (avatar+name), Mobile, **Gender** (`titleCase(c.gender)`), **Bookings** (count), **Classification** badge (`classificationForBooking`).

**Ours** (`index.php:50-88`): Customer (initials+name), Mobile, Bookings, **Last visit**, **Marketing fee** (Frozen badge / Freeze button). We compute `gender` in the controller (`CustomersController.php:93`) but **never render it**. We render `last visit` and an inline freeze action instead of a classification column. So: **Bookings ✅, Gender ✗ (computed, unused), Classification column ✗, Last-visit & inline freeze are NEW on our side.**

## 4. Classification engine (the big logic gap)

**Demo** (`lib/finance.ts:304-313` `classifyCustomer`): priority-ordered attribution computed on the customer's **first booking** at a shop:
1. walk-in → `shop_owned` / `shop_admin_walkin`
2. deep-link → `shop_owned` / `deep_link`
3. on freeze list → `shop_owned` / `freeze_list`
4. via app → `navagoo_sourced` / `app_first_booking`
The result is persisted **immutably** to `classifications[]` on first booking (`store.ts:1264-1294`), reused thereafter (`store.ts:1270-1271`). `classificationForBooking` (`selectors.ts:205-210`) reads it, defaulting to `shop_owned`. Admin can override (`store.ts:1719-1758`), stamping `overriddenBy`/`overrideReason`.

**Ours**: **No classification model, table, or engine anywhere.** Grep for `classif` / `navagoo_sourced` / `deep_link` returns only earnings/charts noise, never a customer-classification concept. The Classifications tab, the directory's Classification badge, and the immutable first-booking attribution are all **MISSING**.

## 5. Grace-window banner logic

**Demo** (`lib/finance.ts:293-294` `isInGraceWindow`): `now <= shop.graceWindowEndsAt`. `Customers.tsx:24,190,229-238` shows a **different banner inside vs after** the grace window (accent "upload now / no fee" vs amber "grace ended").

**Ours**: `Shop` has no `graceWindowEndsAt`, no `isInGraceWindow` helper, and the view has **no grace banner at all**. **MISSING.**

## 6. Freeze list — read

**Demo** (`Customers.tsx:194`): `state.freezeList.filter(f => f.shopId === shopId)`. Columns: Name (`?? '—'`), Mobile, Added (`date(f.addedAt)`), remove button.

**Ours** (`CustomersController.php:100`, `index.php:113-130`): `CustomerFreeze::find()->where(shop_id)->orderBy(id DESC)`. Columns: Name (`?: '—'`), Mobile, Added (`created_at`), Remove. **Match.** We also annotate each directory row with `frozen` via `CustomerFreeze::frozenMobiles()` (`CustomersController.php:79,96`) — NEW vs demo.

## 7. Freeze list — add

**Demo** (`Customers.tsx:264-301`, store `addToFreezeList` `store.ts:1773-1779`): modal with Mobile (required, disables Add when empty) + optional Name; pushes a `FreezeEntry`. **No normalization, no dedupe** in the store.

**Ours** (`CustomersController.php:109-132`): POST `mobile`+`name`. **Normalizes to digits** (`normalizeMobile`, `CustomerFreeze.php:69-72`), rejects empty, and is **idempotent** (existing → success). DB-level `unique(shop_id,mobile)` (`CustomerFreeze.php:49-51`). Two entry points: inline "Freeze" button on a directory row (`index.php:77`) and the modal (`index.php:185-192`). **Match + hardened (normalization/dedupe are improvements over demo).**

## 8. Freeze list — remove

**Demo** (`Customers.tsx:212-223`): `confirm()` dialog ("This number will be able to book again."), then `removeFromFreezeList(f.id)` + toast.

**Ours** (`CustomersController.php:135-145`, `index.php:122-125,183`): POST `id`, shop-scoped `findOne(id,shop_id)` then delete. **No confirmation dialog** — single click removes. **Partial** (functionally present, missing the confirm guard).

## 9. Delivery / persistence model

**Demo**: pure in-memory Zustand; freeze entries hold `addedAt` ISO string. Ours persists to `customer_freeze` (migration `m260622_140000_create_customer_freeze.php`) with TimestampBehavior/BlameableBehavior. Expected divergence (demo is a sim).
