# Logic & flows — Shop · Booking detail + status timeline + actions

Demo (canonical) = React+TS+Zustand at `/private/tmp/Navagoo_MI_dev/navagoo-app/src`.
Ours = Yii2 shop portal at `frontend/`.

## 1. Shared detail body (the "one body, two hosts" pattern)

**Demo** — `BookingDetailBody` (`portals/bookings/BookingDetailBody.tsx:28`) is the single source
of detail content. It is rendered by BOTH:
- the inline list drawer `BookingDrawer` (`portals/bookings/BookingDrawer.tsx:53`), and
- the calendar booking-detail modal `BookingDetailModal` (`portals/bookings/modals.tsx:1078`).

Layout is a `@container` 3-column grid (`BookingDetailBody.tsx:75`): **details + payment (left)**,
**services booked (middle)**, **status timeline + actions (right)**. Narrow hosts stack to one column.

**Ours** — same pattern: `frontend/views/booking/_detail_modal.php` is the single body, rendered by
- the full page `view.php:32` (inside a Card), and
- the calendar AJAX modal via `BookingController::actionDetailPartial` → `renderAjax('_detail_modal')`
  (`frontend/controllers/BookingController.php:430`).

Our grid is `md:grid-cols-3` (`_detail_modal.php:112`) — viewport breakpoint, not container-query, but
the visual result for the two hosts is equivalent. **Done.**

Difference: demo middle column is **Services booked only**; the **payment summary** lives in the LEFT
column. Ours merges Services + Payment summary into the middle column (`_detail_modal.php:135-190`) and
the left column is details only. Cosmetic reshuffle, content largely present.

## 2. Allowed status transitions (workflow engine)

**Demo** — one source of truth in `lib/status.ts`:
- `DEFAULT_TRANSITIONS` (`status.ts:51`): `scheduled → [in_progress, no_show, cancelled]`,
  `in_progress → [completed]`, and `completed / no_show / cancelled → []` (terminal).
- `allowedNextStatuses(status, config.bookingTransitions)` (`status.ts:64`) — admin-overridable via
  `GlobalConfig.bookingTransitions`.
- The detail body (`BookingDetailBody.tsx:50-56`), the row actions menu (`modals.tsx:62`), AND the store
  guard all read this one function.

In the detail body the next-statuses are split: `statusTargets` = non-cancel targets rendered as solid
status buttons; `canCancel` gates the danger Cancel button; `canReschedule` is a SEPARATE capability
(reschedule changes time, not status — `status.ts:85` `canRescheduleStatus`, default allow-list
`['scheduled']`).

**Ours** — transitions are **hard-coded per status in the view** (`_detail_modal.php:197-238`):
- `SCHEDULED|ACCEPTED` → shows **Start service** (→4 INPROGRESS) + **Reschedule** + **No-show**(→9) + **Cancel**.
- `INPROGRESS` → **Complete** (→3) + **No-show**(→9) + **Cancel**.
- Terminal (`COMPLETED|CANCELED|CANCELED_BY_SHOP|NO_SHOW`) → no action buttons (`_detail_modal.php:46`).

There is **no shared transition map** and **no admin-configurable workflow** — the button set is inlined
in the view and the server allow-list lives independently in `actionTransition`
(`BookingController.php:453` → `[INPROGRESS, COMPLETED, NO_SHOW]`). **Partial** (behaviour matches the
default workflow, but it is duplicated, not centralized, and not configurable).

Divergence: demo allows **No-show only from `scheduled`** (it is NOT in `in_progress`'s allowed set —
`status.ts:52`). Ours offers **No-show from INPROGRESS too** (`_detail_modal.php:226`). That is a real
rule difference (see business.md R4).

## 3. No-show grace window

**Demo** — `canMarkNoShow(appointmentISO, nowISO, graceMin)` (`status.ts:93`): no-show is only eligible
once `now >= appointment + graceMin`. In the detail body the No-show button is **rendered but disabled**
with a tooltip "No-show can be marked N min after the appointment start" (`BookingDetailBody.tsx:59,
224, 242`). The row menu **hides** the item entirely until eligible (`modals.tsx:64`).

**Ours** — **MISSING**. No-show is offered unconditionally for non-terminal statuses with no time gate
and no grace config. `actionTransition` accepts NO_SHOW at any time (`BookingController.php:453`).

## 4. Complete-with-balance hard gate (collect → complete)

**Demo** — `changeStatus` (`BookingDetailBody.tsx:60`): if target is `completed` and
`outstandingBalance(booking) > 0.005`, it does NOT transition — it opens the collect flow `onCollect(b)`.
`CollectPaymentModal` (`modals.tsx:955`) collects cash/card (+ optional card tip) THEN transitions to
completed (`modals.tsx:978-982`). `outstandingBalance` = `bookingValue − amountCollected − inStoreCollected
− refundValue`, floored at 0 (`finance.ts:404`). The row menu enforces the same gate (`modals.tsx:73`).

**Ours** — **MISSING the gate.** Complete (→3) posts straight to `actionTransition`
(`_detail_modal.php:218`). The controller stamps `end_time`, ensures a `Payment` row at `total_amount`,
and creates Earnings (`BookingController.php:463-491`) — i.e. it ASSUMES full payment rather than
collecting an outstanding balance. There is no in-store collect modal, no cash/card choice, no tip
capture, no outstanding-balance computation in the body. **Missing.**

## 5. Cancel flow + refund-zone preview

**Demo** — `CancelModal` (`modals.tsx:160`): segmented **Cancelled by** (customer | shop); live
**refund zone** computed via `refundZoneFor(booking, shop, simNow)` (`finance.ts:318` — full if
`hoursBefore >= cancelFullHours`, partial if `>= cancelPartialHours`, else none); shop-cancel forces
`full`. Shows customer refund (`customerRefund`, `finance.ts:326` — shop=full, deposit=non-refundable,
zone-scaled by `partialRefundPct`) and marketing-fee disposition. Confirm calls
`transitionBooking(id, 'cancelled', {cancelledBy, refundZone, refundReason})` and the engine reverses the
marketing fee per the zone.

**Ours** — `actionCancel` (`BookingController.php:686`): takes an optional free-text `reason`, blocks
cancelling a COMPLETED booking, sets status to `CANCELED_BY_SHOP` (7), fires `shopCancelBooking`
notification, saves. **No refund-zone preview UI, no customer/shop selector, no refund computation in the
flow.** The current UI cancel button just `confirm()`s and POSTs an empty reason (`view.php:75`).

Note: our model DOES have the refund-zone logic — `Booking::getRefundTypeForNow()`
(`common/models/Booking.php:45`) returns FULL/PARTIAL/NO refund using `shop.refund_period_start /
refund_period_end` and a past-appointment guard. **But the cancel action never calls it** and the detail
UI never surfaces it. So the engine exists but is disconnected from this flow. **Partial / Missing UI.**

## 6. Reschedule flow + conflict guard

**Demo** — `RescheduleModal` (`modals.tsx:263`): a `SlotPicker` day picker; pre-flight
`checkPlacement` (overlap / time-off / outside-availability / cannot-perform — `schedule.ts`) BLOCKS
confirm and shows an inline conflict banner (`modals.tsx:295-303, 340`). Confirm calls
`reschedule(id, iso)` (local ISO, never UTC). Reschedule is gated by `canRescheduleStatus` (default only
`scheduled`).

**Ours** — `_reschedule_modal.php` is a real free-slot day picker; trigger button
`[data-reschedule-open]` is rendered in the detail body for SCHEDULED|ACCEPTED only
(`_detail_modal.php:206`). Slots come from `actionSlots` (`BookingController.php:615`) and the move runs
through `moveBooking` → `BookingScheduleService::checkPlacement` with the SAME conflict reasons
(`BookingController.php:569-571`), in a transaction that rewrites the agent_slots mirror. **Done** —
this is the most faithfully ported flow. (Reschedule gating is by hard-coded status, matching the
default `['scheduled']` allow-list.)

## 7. Status timeline construction

**Demo** — `buildTimeline(b, timeFormat)` (`BookingTimeline.tsx:40`): every booking starts at
**Scheduled** (time = appointment slot, detail = "Booked {bookingDate}" + any online payment).
Terminal branches REPLACE the happy path: **cancelled** node (time `cancelledAt`, "Cancelled by …",
refund line from `refundZone`/`refundValue`, reason), or **no_show** node (time `noShowMarkedAt`,
"Customer did not arrive"). Otherwise the happy path **Scheduled → In progress → Completed** with
per-node done/current/future state, real timestamps (`completedAt`, in-store collection line). Only
non-terminal current states pulse; terminal current = solid done, no pulse (`BookingTimeline.tsx:99-124`).

**Ours** — `_timeline.php` builds the same shape: cancelled → [scheduled done, cancelled done];
no_show → [scheduled done, noshow done]; else happy path with `done/current/future` states
(`_timeline.php:56-80`). Same dot/connector/pulse treatment (`_timeline.php:84-131`). **Done structurally.**

Gap: ours renders **NO timestamps or detail sub-lines** in any node (no "Booked on", no `completedAt`,
no "Cancelled by … / refund" line, no "Customer did not arrive"). The demo node carries `time` +
`details[]`; ours renders only the label + a "LIVE" pill. **Partial** (states correct, content thin).

## 8. Drawer header chips (customer context)

**Demo** — `BookingDrawer` header (`BookingDrawer.tsx:34-51`): avatar, customer name, booking id,
**ClassificationBadge** (navagoo-sourced vs shop-owned) and a **New / Returning · N visits** badge
(visit count from non-cancelled bookings). `BookingDetailModal` header
(`modals.tsx:1062-1074`) shows three labeled chips: **Status**, **Collection** (`collectionStatus`),
**Settlement** (`settlementStateFor`).

**Ours** — header (`_detail_modal.php:95-104`) shows avatar/initials, name, formatted id, and a single
status pill. **No classification badge, no new/returning visit badge, no Collection chip, no Settlement
chip.** **Missing.**
