# FABLE UI review — client mtw39 wave 1 (FE)

Reviewer: Fable 5.1 · 2026-10-24 · read-only review of FE commits 323988df, 61b71507, c2719fbb, 8d09bf01, 3d6a63cc, 7dba7139, 014aa5cc, 6a6b46e6 (+ i18n at HEAD ade6421e), compared against `design/client-msg-1..5.png` and `LEDGER.md` decisions. Owner rule applied: easy, in place, Egyptian Arabic, RTL-first, 80 mm prints must fit 72 mm.

No P0. Four P1 (all fixable in < 1 h). Everything else is polish.

## Verdicts

| Area | Commit | Verdict | Why |
|---|---|---|---|
| Booking receipt 80mm + «✎ نص الإيصال» + reprint | 323988df | **APPROVE WITH FIXES** | Field-by-field match with client-msg-1 (see §A). Two P1s on print *reliability*: a blocked popup is silent, and «إضافة + طباعة» opens two popups so the booking receipt is routinely blocked. |
| Pay-before-consult gate + manager approval | 323988df | **APPROVE** | One dialog, one service, three entry points (queue / bookings list / consultation + visit shell). Clear chips «غير مدفوع» / «بموافقة المدير». P2 wording + leave path. |
| Doctor free-visit quota («كشف مجاني») | 323988df | **APPROVE** | Toggle sits next to the fee boxes, shows «متبقي X من N», disabled when exhausted, resets per visit. P2 MSA wording. |
| Nutrition: measurements station, Ramadan print, weights notes | 61b71507 | **APPROVE** | Station = reception worklist → same panel the doctor uses → back. Ramadan labels exactly as asked (سحور/إفطار, dinner dropped). Notes column never hidden, numbers compacted. P2 polish. |
| Revenue split: performer + quick editor + GL card; doctor dues | c2719fbb | **APPROVE** | «نسبة الطبيب / المنفّذ» is a 3-row quick dialog with live amounts and the centre remainder — in place, no settings hunting. Dues panel prints alone. |
| POS blind close + «حساب الوردية» slip + clinic Z | 8d09bf01 | **APPROVE** | Blind flow is right: count → handover → close → result screen with عجز/زيادة. Slip matches client-msg-2 line-for-line (see §B). P2: duplicate hint, dialog width, weekday on dates. |
| Purchases attachments + supplier advance | 3d6a63cc | **APPROVE** | 📎 badge on lists, camera button, PR files read-only on PO, advance dialog «حفظ واعتماد», apply/undo on the bill, post-bill prompt. P2: raw `.toFixed(3)` money in prompts, nested dialogs without `appendTo="body"`. |
| Inventory dashboard card, forecast, lookup, expiry | 7dba7139 | **APPROVE** | Card answers «يبقى ظاهر في الداشبورد»; forecast → draft PR in one click; lookup reachable from 5 places (dashboard, balances header + row, product picker row, POS stock badge, Ctrl+K). P2: undefined `stat-purple`, hard-coded «←», card title. |
| Transfer requests + transit + differences + receive | 014aa5cc | **APPROVE WITH FIXES** | Request → route (per line, per warehouse, splittable) → draft transfers; transit + variances as tabs on the transfers screen; resolve in place. P1: the receive dialog's «منه تالف» box is disabled until the keeper lowers «المستلم سليم» — not discoverable, damaged goods get booked as good. |

## A. Booking receipt vs client-msg-1 (`clinic-booking-receipt.service.ts:107-156`)

| Client's slip | Implementation | OK |
|---|---|---|
| الوزن السابق ( ) / الوزن الحالي ( ) at the top | `.br-weights` only when `is_nutrition`; previous weight filled from last measurement | ✓ |
| التواصل على الأرقام الآتية + 2 numbers | `texts.contact_phones` (setting, editable in place) in `.en` LTR | ✓ |
| Bold line «برجاء عدم تناول الطعام قبل الميزان …» | `texts.note` bold centred | ✓ |
| Table الكود \| الاسم | 2-col bordered table, MRN in LTR bold | ✓ |
| تاريخ الحجز | `booked_at` as YYYY/MM/DD Latin digits | ✓ |
| العيادة: Dr … | doctor name (dept as fallback, dept on a second line when both) | ✓ |
| نوع الكشف: متابعة | كشف / متابعة / كشف مجاني + service name | ✓ |
| Table اليوم \| التاريخ \| الوقت (12:10 م) | `dayName` via `ar-EG` → «السبت», `time12` → «12:10 م»; queue no. fallback when no time | ✓ |
| Table المدفوع \| المتبقي (0.00) | `money.paid` / `money.remaining` | ✓ |
| 3 bullet instructions | `texts.instructions` as `<ul>` (one per line in the editor) | ✓ |
| Barcode | CODE128 of MRN via JsBarcode (already a dependency), data URL | ✓ |
| Width | `@page 80mm auto; margin 3mm` → 74 mm printable; `body 72mm`; tables `width:100%`; barcode `max-width:100%` | ✓ |

Thermal shell (`clinic-print-shell.service.ts:142-179`) is monochrome, Arial/Tahoma 11.5 px, letterhead reused. Good.

## B. Shift slip vs client-msg-2 (`pos-receipt.service.ts:489-645`)

رقم الوردية ✓ · المستخدم ✓ · تاريخ الفتح ✓ · تاريخ الغلق ✓ · الرصيد الافتتاحي ✓ · مبيعات الوردية ✓ · مرتجع المبيعات ✓ · المبلغ المستلم (counted cash, boxed) ✓ · الفيزا (boxed) ✓ · المبلغ بعد خصم المبيعات = received − sales (big box) ✓ · العجز أو الزيادة (big box, after close only) ✓ · plus مبلغ التحويل + الخزنة, per-method table, signatures, caption «إيصال تحويل من الخزنة الفرعية إلى الرئيسية» when a handover exists. Width: `@page margin 3mm 4mm` → 72 mm; body 72 mm; 4-col table `table-layout:fixed` 9 px. Fits. Delta vs photo: no weekday on the dates (P2-11).

## Findings

| # | Sev | Where | What the user sees | Fix |
|---|---|---|---|---|
| P1-1 | P1 | `src/app/features/clinic/services/clinic-print-shell.service.ts:190-202` (`openAndPrint`) + `clinic-booking-receipt.service.ts:67-74` | When the browser blocks the popup (common right after an async save — the receipt is opened *after* the save + fetch round trips, outside the click's activation window), `window.open` returns null, `openAndPrint` returns void, `print()` resolves → **no receipt and no «ماتطبعش» toast**; reception thinks it printed. | **Primary fix: stop depending on popups.** Give `ClinicPrintShellService` a hidden-iframe print path, copied from `PosReceiptService.printViaIframe` (`pos-receipt.service.ts:726-732`): `const f = document.createElement('iframe'); f.style.cssText='position:fixed;inset:0;width:0;height:0;border:0;visibility:hidden'; document.body.appendChild(f); f.contentDocument!.open(); write(html); close(); f.contentWindow!.onafterprint = () => f.remove(); f.contentWindow!.onload = () => f.contentWindow!.print();` and use it for the 80 mm receipts (booking + collection). Keep `window.open` only as the fallback, and make it honest: `openAndPrint(): boolean` returning `false` when `window.open` is null, with `ClinicBookingReceiptService.print()` throwing on `false` so the existing `.catch()` toast fires. |
| P1-2 | P1 | `src/app/features/clinic/booking/create-visit.component.ts:2979-2995` | On «إضافة + طباعة» two prints fire from one click: `printPatientFile()` opens its window synchronously, the booking receipt opens a second window *after* its HTTP fetch → Chrome blocks the second popup → the booking receipt never prints on that path (silently, see P1-1). | **Ordering alone is NOT a fix** — chaining the patient-file print after the receipt's promise still leaves the second `window.open` outside the user activation and it is still blocked. Either (a) route both prints through the iframe path from P1-1 (no popup at all; the two `print()` dialogs then queue naturally), or (b) pre-open the window synchronously in the submit click handler (`const w = window.open('', '_blank')` *before* `createVisit()` is called) and pass it to `ClinicBookingReceiptService.print(apptId, w)` to `document.write` into. (a) is the recommended one — it also fixes the plain-save path. |
| P1-3 | P1 | `src/app/features/warehouse-transfers/warehouse-transfers.component.html:~403` (receive dialog, «منه تالف» `[disabled]="lineDifference(item) <= 0"`) | The keeper who received 10 cartons with 2 damaged cannot type 2 in «منه تالف» — the box is greyed until he first lowers «المستلم سليم» to 8. Nothing tells him that; he leaves 10 → damaged goods are received as good stock and no variance is recorded. | Always enable the box and let it drive the difference: on `damaged_quantity` change, `received_quantity = min(received, shipped − damaged)`; or rename the columns to «المستلم سليم» / «تالف (من الكمية المشحونة)» and drop the `[disabled]`. Also show the per-line hint «الفرق = المشحون − السليم» under the header once. |
| P1-4 | P1 | `src/app/features/clinic/encounter/consultation.component.ts:~484-489`, `visit-shell/visit-shell.component.ts:~856-861` | Blocked doctor presses «تمام» → `window.history.back()`. From a fresh tab / deep link (`history.length` is often > 1 because of the login redirect) this can land on the login page or outside the app instead of the queue. | Always `this.router.navigate(['/clinic/queue'])` (or the layout's default route) in the leave callback; drop the `history.back()` branch. |
| P2-0 | P2 | `src/app/features/pos/components/session-close-dialog/session-close-dialog.component.html:53-72` (result screen) | «مبيعات الوردية» / «مرتجع المبيعات» render `closed.summary?.total_sales ?? 0`. Verified on this BE (`POSSessionController.php:337` — `close()` attaches the full `getSessionSummary`, with `cash_variance`, and `POSSessionResource.php:106` serialises it) so the live install shows real figures; only an older/self-hosted BE without the field would show **0.00**. | Defensive: wrap the two rows in `@if (closed.summary)` and show `POS.SUMMARY_UNAVAILABLE` otherwise (the dialog already has that key). |
| P2-1 | P2 | `src/assets/i18n/ar.json` `CLINIC_VISIT.FREE_VISIT_LEFT` | «متبقي {{left}} من {{total}} هذا الشهر» — MSA in an Egyptian screen. | «فاضل {{left}} من {{total}} الشهر ده». Also `CLINIC_GATE.APPROVE_SHORT` is long for a row chip (`clinic-appointments.component.html:~299-310`, `visit-queue.component.html:~340`): «موافقة بدون دفع». |
| P2-2 | P2 | `src/app/features/clinic/doctors/clinic-doctors.component.html:283-297` | Free-visit number input is placed inside a `.switch-field` (toggle row) with the label *after* the control; reads as an orphan number box. | Use the form's regular `.form-field` with the label above: `<label for="free_visits_per_month">…</label><p-inputNumber …/> <small>hint</small>`. |
| P2-3 | P2 | `clinic-booking-receipt.service.ts:140` | «نوع الكشف: متابعة — كشف متابعة تغذية» repeats the word; the client's slip has one word. | Print the service name only when it is not a plain consultation: `kind + (svcName && !/كشف|متابعة/.test(svcName) ? ' — ' + svcName : '')`, or drop the service name on thermal. |
| P2-4 | P2 | `src/app/features/clinic/services/clinic-receipt-print.service.ts:70-78` | The cashier's collection receipt now defaults to 80 mm for *every* clinic (receipt_paper default) — a behaviour change for existing cashiers printing on A4. | Not a defect (LEDGER #1), but flag to the owner; the «✎ نص الإيصال» popover already lets a clinic switch to A4/A5 in place. |
| P2-5 | P2 | `src/app/features/pos/components/session-close-dialog/session-close-dialog.component.html:176` and `:226` | Under blind close two hints stack: `POS_SHIFT.BLIND_HINT` (top) and the pre-existing `POS.BLIND_COUNT_HINT` (in the count section) say the same thing. | Wrap the second in `@if (!blind())`. |
| P2-6 | P2 | `session-close-dialog.component.html:6` `[style]="{width: '560px'}"` | Dialog is now taller (handover + result) and has no `maxWidth`/breakpoints → horizontal overflow on tablet portrait, where a POS lives. | `[style]="{ width: '560px', maxWidth: '96vw' }" [breakpoints]="{ '640px': '96vw' }"`. |
| P2-7 | P2 | `session-close-dialog.component.scss` `.result-verdict.over` → `--pos-success` | An **overage** is painted green like a success; it is still a discrepancy the supervisor must explain. | Use the warn tone (`--pos-warning-*`) for `.over`, keep red for `.short`, green only for `BALANCED`. |
| P2-8 | P2 | `pos-receipt.service.ts:125` (`fmtDateTime`) used at `:493-494` | Client's slip prints the weekday («Thursday 01/10/2026») on open/close; ours prints date + time only. | For the slip pass `{ weekday: 'long' }` (add an optional flag to `fmtDateTime`). |
| P2-9 | P2 | `src/app/features/purchases/bills/bills.component.ts:~1365` (`POST_PROMPT`), `advances/bill-advances-panel.component.ts:~233` (`UNAPPLY_CONFIRM`), `advances/supplier-advances-panel.component.ts:~133` (`appliedTip`) | Money shown as `10000.000` (raw `.toFixed(3)`) inside Arabic prompts/tooltips while every table cell uses the money pipe. | Inject `MoneyFormatService` and use `this.money.format(v)` in all three. |
| P2-10 | P2 | `advances/supplier-advance-dialog.component.ts:~32` and `advances/bill-advances-panel.component.ts:~78` (`<p-dialog>`) | Both dialogs open *inside* another dialog (partner form / bill view) without `appendTo="body"`; every other new dialog in this wave appends to body. Risk of clipping/z-index under the parent's transform. | Add `appendTo="body"` to both `<p-dialog>`. |
| P2-11 | P2 | `src/app/features/inventory-reports/expiry-tab/expiry-tab.component.html:53` | `stat-purple` is not defined in `inventory-reports.component.scss` → the «إجمالي الكمية» tile renders without its colour (others are tinted). | Use `stat-violet` (exists). |
| P2-12 | P2 | `dashboard/inventory-widget/inventory-dashboard-widget.component.html:27,34,108`; `warehouse-transfers.component.html:~150` («من ← إلى» header) | Hard-coded «←» arrows: correct in RTL, point the wrong way in English. | Replace with `<i class="pi pi-arrow-left rtl-flip">` or a CSS `::after` with `content` chosen per `dir`. |
| P2-13 | P2 | `ar.json` `INV_INSIGHTS.DASH_TITLE` | Card title «المخازن — حد إعادة الطلب» but the card also carries expiry, transit and the GL check. | «المخازن» (subtitle optional: «حد الطلب · الصلاحية · بالطريق · المطابقة»). |
| P2-14 | P2 | `src/app/shared/components/stock-availability-dialog/stock-availability-dialog.component.ts:~205` | A failed lookup (403/500) is shown as «لا توجد نتائج». | Keep an `error` signal and show `COMMON.ERROR` + the server message. |
| P2-15 | P2 | `src/app/features/clinic/stations/measurements-station.component.ts:75` | `.p-input-icon-left` wrapper with no icon (PrimeNG 21 pattern is `<p-iconfield><p-inputicon class="pi pi-search"/>…`). Search box reads as plain input. | Use `p-iconfield` + `p-inputicon`, like other clinic lists. |
| P2-16 | P2 | Measurements station (same file) + `measure-doctor-tab.service.ts` | «يبقى الدكتور يشوف تبويب القياسات ولا لأ» (`nutrition.measurements.doctor_tab`) is only reachable from the settings screen — against the owner's in-place rule. | Add a small «⚙ الدكتور يشوف القياسات» toggle in the station header for `clinic.settings.manage` holders, saving via `SettingService.updateBulk` (same pattern as the receipt text editor). |
| P2-17 | P2 | `measurements-station.component.ts:113-166` table; `transfer-requests` and `warehouse-transfers` sub-tables | Plain 7–10-column tables with no stacking on phones (`stack-table` directive exists in the app). | Add `appStackTable` (as stock-balances does) or hide secondary columns under 640 px. |
| P2-18 | P2 | `src/app/features/clinic/shared/consult-gate/unpaid-start-dialog.component.ts:~24` | Blocked-no-permission state: the amount and «لازم الدفع أو موافقة المدير» are right, but there is no direct «تحويل للكاشير» action; the hint only tells the doctor what to do. | Add a secondary button «افتح الكاشير» → `/clinic/cashier?order=…` when the user has `clinic.payments.*`; otherwise keep the hint. |
| P2-19 | P2 | `src/app/features/inventory-reports/demand-forecast-tab/demand-forecast-tab.component.ts:981` | Every row with a suggestion is pre-selected; with 300 items «إنشاء طلب شراء» silently creates a 300-line draft PR. | Keep the pre-selection but show the count on the button («إنشاء طلب شراء ({{n}})») and confirm above 50 lines. |
| P2-20 | P2 | `src/app/features/transfer-requests/transfer-requests.component.ts:~1033` | Route prefill picks `warehouses[0]` for the whole remaining qty even when `available` is 0 → first thing the manager sees is a red «أكتر من المتاح». | Prefill `quantity = min(remaining, available)`, and when `available <= 0` leave the warehouse unset so the row reads as «اختار المخزن». |
| P2-21 | P2 | `src/app/features/clinic/reports/clinic-reports.component.html` (dues panel) `{{ ap.from }} → {{ ap.to }}` | Raw ISO dates with an LTR arrow inside an Arabic heading. | `<bdi>{{ ap.from }}</bdi> ← <bdi>{{ ap.to }}</bdi>` with Arabic date pipe, or «من … إلى …». |

## Checked and fine (no action)

- Gate wiring: `entry.id` in the queue **is** the appointment id (`visit-queue.component.ts:634,702`) → `approve-unpaid-start` hits the right record; permission string `clinic.appointment.view` matches the existing catalogue (singular).
- i18n: every new key used by the eight commits resolves in both `ar.json` and `en.json` (scan of all changed templates/TS); `I18N_VERSION` bumped in ade6421e.
- RTL: all new layouts use logical props (`margin-inline-*`, `text-align: start/end`), numbers wrapped in `<bdi>`/`.en`, prints set `dir` from the active language; POS slip resolves `start/end` to physical sides for print engines.
- PrimeNG 21 usage consistent (`p-select`, `p-datepicker`, `p-popover`, `p-tabs`, `p-toggleSwitch`, `#header/#body` templates); the receipt-text popover uses decorator `@ViewChild` on an unconditional host (per the viewChild-in-@if memory).
- Empty/loading/error states present on: measurements station, dashboard card (loading, hidden when nothing), forecast/expiry (prompt → loading → empty), lookup dialog (type hint / none), transfer tabs (`@empty`), advances panels (`NONE`), POS close (`SUMMARY_UNAVAILABLE`, unsynced block).
- Ramadan print is purely a paper relabel (`plan-print-fasting.ts`) — stored plan untouched, Ramadan-native plans not double-renamed, dinner dropped as asked.
- Weights notes: column no longer hidden ≤ 1600 px, numeric columns clamped to 64 px, 2-line clamp with tap-to-expand (`board-weights.component.ts/scss`).
- Attachments: files picked before a new PR/PO/bill has an id are flushed after save (`uploadPending`) in all three screens.
