# W2-FE-6 report: S2b nutrition board split into 15 shell cards, plus N11 products sold

Status: **done. The split is ready for Decision 7** (the orchestrator unlocks nutrition layout saves in visit-shell.component.ts). One shell SCSS snippet (see §4) is needed before the shell looks exactly like the board.
Nothing was committed or deployed. The /app-s2b preview is deleted.
⚠ **Token revoke:** the brief's name-based delete removed **2** `claude-visual-check` tokens, so it also removed another agent's token. Any agent taking screenshots right now will get 401s and must mint a new token.

## 1. What changed

### FE: one source of truth
- **`features/nutrition/visit/nutrition-board.store.ts`** (new):
  - `NutritionBoardStore` holds all the board state, labels, permissions and actions that were in the board component: record/loading/error, popups, stop, adherence, weight, link and so on.
  - Host context comes in through `setCtx()`, which does an equality check, so the same values never reload the record.
  - Outputs go out through `attachSink()`, toasts through `toasts$`, and «📝 ملاحظة» through `noteRequest`.
  - `NutritionBoardStoreRegistry` (providedIn root) is a ref-counted lease. It creates the store in its own EnvironmentInjector and destroys it when the last card releases it.
- **Section components** in `features/nutrition/visit/cards/` (new). Each one holds the classic markup verbatim, and its SCSS rules moved with it in the same order:
  - `board-actions.component.ts`:
    - `NvBoardActionsComponent` is the header bar (`display:contents`). It is also the board's only toast outlet (host MessageService).
    - `NvBoardStatusComponent` covers the spinner, the error and the ⛔ stop banner.
  - `board-dialogs.component.ts`:
    - `NvBoardDialogsComponent` holds the rx, lab+rad, plan, weight and stop popups.
    - `NvBoardSubDialogsComponent` holds the edit-plan, questionnaire, contact and shopping dialogs (`display:contents`).
  - Section files: `board-patient.component.ts` + `board-patient.component.scss`, `board-quest`, `board-appts`, `board-weights` + `board-weights.component.scss` (incl. the ≤1600px column rules), `board-meas`, `board-plan` and `board-packages`.
  - `board-bound-cards.component.ts` binds the existing app-nv-* cards to the store once: progress, lifestyle, sessions, notes (opens the editor on `noteRequest`), meds, labs and photos.
- **`nutrition-consult-board.component.{ts,html,scss}`**:
  - The board is now a thin composition: `providers: [NutritionBoardStore]`, with the same `.nvb` / `.nvb-grid` / 3×`.nvb-col` markup and order. Its inputs and outputs are unchanged.
  - Only layout rules remain in its SCSS.
  - The classic consultation screen and the patient file both use it unchanged.

### FE: shell
- **`clinic/visit-shell/specialties/nutrition-section-card.component.ts`** (new):
  - One wrapper renders every nutrition card by its `section` input.
  - It leases the shared store and attaches `VisitShellBus` as the sink (sign, discharge, intentsChanged, cashier).
  - `nut_actions` renders the bar, the status line, the sub-dialogs and the dialogs.
  - It sets a host class `nut-empty` when the section has nothing to show: quest or lifestyle hidden by the classic conditions, or progress without a goal.
- **`specialties/nutrition.cards.ts`** (rewritten):
  - Defines `nut_actions` (F, required, layer 4) and the 14 sections `nut_patient … nut_progress`: kind spec, bare, sizes and layers per prototype CATALOG 448-461, all `permission: 'nutrition.visit.consult'`. The finer `canX` rights stay inside the sections.
  - `NUTRITION_DEFAULT_LAYOUT` keeps the two tabs. «الكشف» has the columns `['minmax(250px,290px)','minmax(0,1fr)','minmax(280px,340px)']`, `nut_actions` as a full row, and then today's board order.
  - Props are `{ hideRail: true, gridClass: 'nut-grid' }`. The «البيانات والقياسات» tab is unchanged (`nut_measure`).
- **`specialties/nutrition-board-card.component.ts` deleted.** `nut_board` is gone from the catalog. Nutrition saves are locked, so no stored layout uses it, and `resolveLayout` would drop it anyway. Keeping it would have let a user add a second full board next to the split cards.
- **`specialties/nutrition-layout.check.ts`** (new, pure check, same pattern as dental-layout.check.ts).

### Shared files, kept minimal
- `core/models/nutrition-visit.model.ts`: added `VisitSoldProduct` and an optional `products_sold` on `NutritionVisitRecord`.
- `assets/i18n/ar.json` and `en.json`: added `NUTRITION.VISIT.PRODUCTS_SOLD` (المنتجات المباعة), `NUTRITION.VISIT.AMOUNT` (المبلغ) and `VISIT_SHELL.SPEC.NUT_ACTIONS` (أزرار الكشف (تغذية)). I18N_VERSION is not bumped.

### BE (Modules/Nutrition only)
- `app/Services/Visit/DispensedItemsService.php` has a new `soldProducts($companyId, $partnerId, $limit = 10)`:
  - Returns posted sales and POS invoice lines of the patient's linked customer, newest first, capped at 10.
  - Excludes `products.type = service` lines, so packages and visit fees don't show.
  - Nets out approved/posted returns; a fully returned line is dropped.
  - `amount` = `line_total` (after discount, pre-tax) + `tax_amount`, pro-rated to the net quantity. This is what the patient paid.
  - Read-only and defensive: a missing table or an error gives `[]`.
- `app/Services/Visit/VisitRecordService.php`: the record now has `products_sold`. It is `[]` when the patient has no `partner_id`.
- No migrations, no permissions, no settings.

### N11 on the FE
The packages card shows «🛍️ المنتجات المباعة» (item, qty, date, amount; the doc number on hover) under the packages table. **Decision: it only renders when the list is not empty.** A patient without purchases, such as ريهام, therefore looks exactly as before, which keeps the classic pixel identity.

## 2. Tests and checks run
- **BE:**
  - New test file `Modules/Nutrition/tests/Feature/NutritionProductsSoldTest.php` (5 tests): unlinked patient gives `[]`; products only (services and drafts skipped) with the paid amount, POS receipt number and newest first; returns netted (partial line pro-rated, full return dropped); cap of 10; company isolation.
  - `NutritionVisitRecordTest.php`: the exact key list and the bare-patient assertions now include `products_sold`.
  - Ran only these 2 files: **27 passed (251 assertions)**.
  - Live check on MySQL after `optimize:clear`: `GET nutrition/patients/20015/record?encounter_id=43` returns `products_sold: []`. The query ran on MySQL with no "skipped" warning in laravel.log.
  - Dev has **no** sales invoices at all, so a non-empty list has not been seen on MySQL.
- **FE pure checks (esbuild + node):**
  - New `nutrition-layout.check`: **26/26**.
  - The existing dental-layout.check (16/16) and resolve-layout.check (34/34) still pass.
- **Preview (/app-s2b) functional checks** on encounter 43, shell «الكشف»:
  - Exactly **1** `…/record` request for all 15 cards, so the store is shared.
  - «+ وزن» and «💊 روشتة» each open **one** dialog.
  - Closing rx reloads the record once.
  - «📝 ملاحظة» opens the editor in `nut_notes`.
  - Customise mode shows 15 toolbars.
  - Console errors: none on any page.
  - Encounter 50 (signed): `lockedDefault: true` is kept in the default layout, and on «الكشف» the 🔒 pill and «الخروج» render in `nut_actions`. My script always clicked the tab, so the auto-open on a signed visit was not tested. That path is unchanged shell code (`pickActiveTab`).
- **`ng build`:**
  - The preview builds at 16:30, 16:41 and 16:47 were green.
  - The **final** `ng build --base-href /app/` at 16:50 **failed on other agents' in-progress files**, none of them mine (I did not touch them):
    - `clinic/visit-shell/cards/visit-photos-card.component.ts:28` (TS2339 `nativeElement`)
    - `shared/components/clinical-picker/clinical-picker-dialog.component.ts:506/654/658` (TS2347)
  - There are no diagnostics in any nutrition or visit-shell/specialties file.

## 3. Screenshot identity (files in shots/W2-FE-6/)
- Method: Playwright 1600 wide; viewport 1000 (as the owner sees it) and 2600 ("tall", so the fixed-height columns don't hide content); clock frozen at 10:00.
- `before-*` = live /app. `after-*` = the /app-s2b preview of the current tree. `base-*` = a scratch copy of the tree with only my changes reverted, built to /app-s2b. That copy isolates other agents' changes.
- Repeatability of the same page: 0 px.

| Page | Diff | Note |
|---|---|---|
| classic 43 «الكشف» @1000 | **0 px** | identical |
| classic 43 «الكشف» tall | **0 px** | identical |
| classic 50 «الكشف» @1000 | **0 px** | identical |
| classic 50 «الكشف» tall | **0 px** | identical |
| classic 43 «البيانات والقياسات» @1000 | after vs base **0 px** (vs /app: 127,365 px) | Not my code (app-nutrition-patient-panel). That tab renders in two variants (inputs with or without borders, row delete icons) even on the base build: 3 repeats of the base gave 127,494 px vs its own first shot. I counted it as flakiness. |
| shell 43 «البيانات والقياسات» @1000 | after vs base **0 px** | identical |
| shell 43 «الكشف» tall, cards region y 170–1340 | as built: 282,508 px. **With the `nut-grid` CSS from §4 injected: 2,144 px** | Remaining: 1-px card-shadow antialiasing at the column edges (the old columns were `overflow:auto` and clipped it). Below y 1340: the footer moves up, because the shell page flows and the old board had a fixed 100vh height. Header band: page-height gradient pixels only (visually identical). |
| shell 50 «الكشف» tall, same region | as built: 280,819 px. **With nut-grid: 5,925 px** | Same, plus the locked bar now names the signer (HEAD commit 10d02f96, not mine). The base build of this page showed «فشل في تحميل البيانات» (a tree state of another agent at that moment), so I compared it against /app. |
| shell 43 «الكشف» @1000, y 0–905 | **with nut-grid: 1,648 px** | shadow edges only |

Without the snippet, the shell differs only by the shell's 10px gaps (the board uses 8px), the 2px `.nvb-col` padding, and the extra gap of the empty `nut_lifestyle` card. Visual comparison: `after-tall-shell43-consult.png` vs `after-tall-shell43-consult-nutgrid.png`. `func-weight-dialog.png` and `func-customise.png` are the functional checks.

## 4. For the orchestrator: shell SCSS I did not write (visit-shell.component.scss, owned by W1-FE-1)
```scss
/* S2b (nutrition) — props.gridClass 'nut-grid': the classic board's spacing (.nvb / .nvb-grid / .nvb-col). */
.main.nut-grid {
  .cg, .cc-col { gap: 8px; }
  > .cg + .cg { margin-top: -2px; }                       /* action bar → columns = 8px (.nvb gap) */
  @media (min-width: 1281px) { .cc-col { padding-inline-end: 2px; } }
  /* a section with nothing to show (no lifestyle answers, no goal…) leaves no gap — as classic */
  app-visit-card:not(.ed):has(.nut-empty) { display: none; }
}
```
- I verified this as unscoped CSS injected in Playwright. That produced the "with nut-grid" numbers above.
- `.nut-empty` sits on the dynamically created `app-nutrition-section-card` host. Angular's emulated scoping may add `[_ngcontent]` inside `:has()`, so if the last rule doesn't match, write it as `::ng-deep app-visit-card:not(.ed):has(.nut-empty)` scoped under `.main.nut-grid`.
- Optional, not done: the old board's desktop fixed height (`100vh-116`) with columns that scroll on their own. Without it, the shell page scrolls as a whole.
- **Decision 7:** unlocking saves for nutrition can go ahead. Tabs and props persist (per review A#45), and `nut_actions` is re-inserted by `resolveLayout` into a client layout that lacks it. It joins the last tab (= الكشف), which is covered by nutrition-layout.check §4.

## 5. Deviations and decisions
- **Store sharing:** the brief asked for the store to be provided at the shell or tab host level. That was not possible without editing the shell, because spec cards come through `ngComponentOutlet`. A root, ref-counted registry gives one store per shell instead (one record request proven). The classic board still provides its own store at component level.
- **`nut_progress`** sits at col 1, order 0 of the default, which is not in the prototype's 13-card default. Today's board shows it at the top of the centre column.
- **Sizes:** every section is `M` in the default (prototype: `nut_weights` L). Size L adds `min-height:260px`, and S caps the body at 420px; either would move the board.
- **`nut_actions`** is required and holds every board dialog. Several cards open the same dialog, so the dialogs render once.
- **Toasts:** shown only by the actions component, through the host MessageService. The store has no MessageService because it may live in a root-level injector.
- **Release note:** this is a user-facing change (cards can be moved, plus products sold). I did not add a bullet because BE `docs/moonstack/CHANGELOG.md` is shared. Suggested entry: «شاشة كشف التغذية في الشاشة الموحّدة بقت كروت (تتحرّك/تتخفى/تتغيّر مقاساتها) بنفس شكل اللوحة — وكارت الباقات بيعرض «المنتجات المباعة» للمريض. / Nutrition consult board in the visit shell is now movable cards (same look) — the packages card lists products sold to the patient.»

## 6. Not verified
- The classic board inside the patient file (`clinic-patient-file.component.html:139`, «الكشف» tab, `encounterId = null`, view-only) was not screenshotted. It uses the same component with the same inputs, and `canAct()` still gates every write, but it is a separate host that was not in the identity set.
- A non-empty «المنتجات المباعة» list on MySQL (dev has zero sales invoices); only SQLite covers it.
- The encapsulated form of the `nut-grid` rule, since it is not written yet.
- Actually saving a nutrition layout, which is still locked (Decision 7).
- Very narrow widths (≤1280px), where the old board's 3rd column wrapped to a full-width row; the shell uses its own responsive `.cg` rules there.
