plans/customer-booking-chain-refactor.md

Customer Booking — Chain Refactor (industry-standard)

Status: 🚧 Chưa bắt đầu — scoped 2026-05-28 Mục tiêu: Đại tu UX khách hàng đặt multi-service: bỏ per-service time picker, dùng 1 start time duy nhất, service tự nối tiếp sequential. Ship dưới dạng v2 song song với v1, switch qua tenant setting (super-admin only). Effort: ~4 ngày dev (thêm 0.5d cho setting + migration + super-admin UI; admin drawer giữ nguyên) Liên quan: docs/flows/booking-flow.md, backend BookingItem.startTime nullable đã sẵn sàng từ multi-service refactor.


Motivation

Vấn đề user phản ánh (2026-05-28):

"Với mỗi service, khách lại phải tự chọn thời gian. Trong khi bình thường thì khách sẽ chỉ chọn thời điểm đến (booking đầu tiên), còn lại các service khác sẽ tuần tự lần lượt."

Hiện trạng (lệch pha FE/BE):

  • Backend đã sẵn sàng: BookingItem.startTime nullable; null = sequential từ Booking.startTime (hoặc end của item trước). AvailabilityService đã hiểu cả parent + per-item resource conflict.
  • Frontend lệch: Mỗi ServiceItem (booking-web/src/app/(customer)/b/[slug]/book/components/ServiceItem.tsx) có TimeSlotGrid riêng → khách pick time N lần cho N service.
  • Test gap: E2E 1.2 multi-service chỉ assert tổng tiền, không cover time flow. 1.3 unassigned booking đang .fixme().

Benchmark: Treatwell, Booksy, Vagaro — tất cả dùng pattern 1 start time + sequential.


Decisions đã chốt

Topic Decision Note
Gap giữa service Strict sequential, không gap Service B bắt đầu ngay sau A. 95% salon dùng pattern này.
Per-service staff Giữ per-service Default "Bất kỳ" (resourceId = null). Khách override từng service nếu muốn.
Re-validate khi đổi service Auto re-validate, giữ slot nếu vẫn fit Refetch chain availability; nếu selectedStartTime vẫn trong list mới → keep; else reset + toast.
Admin drawer Giữ nguyên (per-item time) Out of scope sprint này. Admin cần flexibility cao.
allowDoubleBooking Backend skip conflict, FE render full Tenant setting bật → backend trả tất cả slot trong business hours (chỉ check lead time + closure). FE không decide.
Versioning PlatformSetting singleton bookingUiVersion: 'v1' | 'v2' Global flag — apply cho toàn hệ thống, không per-tenant. Super-admin toggle qua radio group ở /admin/superadmin/settings (tab Branding). Default 'v1' cho cả existing + new install. Flip 'v2' khi UI chain ổn định.

Kiến trúc

Customer page layout mới (/b/[slug]/book)

Step 1: Service picker (multi-add)
  ▸ Cắt tóc · 45' · 200k · [Staff: Bất kỳ ▾]
  ▸ Cạo râu · 20' · 80k  · [Staff: Bất kỳ ▾]
  [+ Thêm dịch vụ]
  Tổng: 65 phút · 280k

Step 2: Chọn ngày (DateStrip giữ nguyên)

Step 3: Chọn giờ bắt đầu (1 TimeSlotGrid, slot cho TOÀN CHAIN)
  10:00  10:15  10:30  10:45  11:00  ...

Step 4: Preview timeline
  10:00 → Cắt tóc · Lan · 45'
  10:45 → Cạo râu · Bất kỳ · 20'
  11:05  Kết thúc

Step 5: Thông tin khách + Submit

State shape mới

interface BookingDraft {
  items: Array<{
    service: PublicService;
    resourceId: string | null;  // null = "Bất kỳ"
    // KHÔNG còn selectedSlot per item
  }>;
  date: string;
  selectedStartTime: string | null;  // ← single chain start
}

Submit payload (backward-compat)

createBooking(slug, {
  startTime: draft.selectedStartTime,
  items: draft.items.map(it => ({
    serviceId: it.service.id,
    ...(it.resourceId ? { resourceId: it.resourceId } : {}),
    // KHÔNG gửi startTime per item → backend tự sequential
  })),
  // customer details...
});

File structure mới

booking-web/src/app/(customer)/b/[slug]/book/
├── page.tsx                       ← server: fetch tenant settings, decide v1/v2
├── components/                    ← V1 (giữ nguyên, không sửa)
│   ├── BookingPage.tsx
│   ├── ServiceItem.tsx
│   ├── ServicePicker.tsx
│   ├── DateStrip.tsx
│   ├── TimeSlotGrid.tsx
│   └── StickyBookingHeader.tsx
└── components-v2/                 ← V2 (mới)
    ├── BookingPageV2.tsx
    ├── ServiceItemV2.tsx           (bỏ TimeSlotGrid, giữ staff dropdown)
    ├── ChainTimePicker.tsx         (single time picker cho cả chain)
    ├── ChainPreview.tsx            (timeline preview)
    └── (reuse) DateStrip, ServicePicker, StickyBookingHeader từ components/

Switch logic (page.tsx):

const tenant = await fetchPublicTenant(slug);
const version = tenant.settings.bookingUiVersion;  // 'v1' | 'v2'

return version === 'v2'
  ? <BookingPageV2 {...props} />
  : <BookingPage {...props} />;

Public tenant API (GET /public/:slug) phải expose bookingUiVersion để FE biết render gì.


Phase breakdown

Phase 0 — PlatformSetting global flag + super-admin UI ✅ shipped 2026-05-29

File:

  • booking-api/prisma/schema.prisma — thêm bookingUiVersion String @default("v1") vào model PlatformSetting
  • booking-api/prisma/migrations/20260528195501_add_platform_booking_ui_version/migration.sql — ALTER + CHECK constraint
  • booking-api/src/core/platform-settings/dto/platform-settings.dto.tsUpdatePlatformSettingsDto + PlatformSettingsAdminDto + PlatformSettingsPublicDto
  • booking-api/src/core/platform-settings/platform-settings.service.tsparseBookingUiVersion() helper, wire vào getAdmin/getPublic/update
  • booking-api/src/core/platform-settings/platform-settings.service.spec.ts + controller.spec.ts — test fixtures + flip-to-v2 test
  • booking-web/src/components/superadmin/settings/BrandingSection.tsx — radio group cuối form (Section "Booking page version")
  • booking-web/messages/{en,nb}.json — i18n strings dưới superadmin.settings.bookingUiVersion.*
  • booking-web/src/lib/platform-settings-server.tsDEFAULT_FALLBACK thêm bookingUiVersion: 'v1'

Migration:

ALTER TABLE "platform_settings"
  ADD COLUMN "booking_ui_version" TEXT NOT NULL DEFAULT 'v1';
ALTER TABLE "platform_settings"
  ADD CONSTRAINT "platform_settings_booking_ui_version_check"
  CHECK ("booking_ui_version" IN ('v1', 'v2'));

Super-admin UI:

  • Radio group ở /admin/superadmin/settings (tab Branding, Section "Booking page version" — cuối form, trước Save)
  • 2 options: V1 (Legacy) / V2 (Chain industry-standard)
  • Submit cùng PATCH /superadmin/platform-settings với các field branding khác

Public API:

  • GET /public/platform-settings đã có sẵn — chỉ thêm field bookingUiVersion vào response shape
  • FE booking page router đọc field này để switch component

Verification:

  • Backend: 2125/2127 tests pass (2 skipped pre-existing), lint 0 errors
  • Web: lint 0 errors, build pass
  • GitNexus impact MEDIUM, no high-risk paths

Estimate: 0.5d (actual ~0.5d)


Phase 1 — Backend getChainedSlots

File:

  • booking-api/src/core/booking/availability.service.ts (+1 method)
  • booking-api/src/core/booking/availability.controller.ts (+1 endpoint)
  • booking-api/src/core/booking/dto/availability-chain.dto.ts (new)

Endpoint: GET /public/:slug/availability/chain

Query:

date=2026-05-30
items[0][serviceId]=svc1&items[0][resourceId]=stf1
items[1][serviceId]=svc2

Response:

{
  "success": true,
  "data": {
    "totalDuration": 65,
    "slots": [
      { "startTime": "2026-05-30T08:00:00.000Z", "endTime": "2026-05-30T09:05:00.000Z" }
    ]
  }
}

Logic:

  1. Resolve services + durations → totalDuration = sum(durations)
  2. Generate candidate startTimes theo business hours - lead time - closure (reuse helper hiện có)
  3. If shouldSkipConflict(tenant.settings) → return all candidates (allowDoubleBooking case)
  4. Else: chain walker — với mỗi candidate slot:
    • Walk N legs: legStart_i = candidate + sum(durations[0..i-1])
    • Cho mỗi leg, check items[i].resourceId (specific staff hoặc "any of skilled") free trong [legStart_i, legStart_i + duration_i]
    • Reuse bookingMap đã tính 1 lần (per-resource intervals)
  5. Slot pass nếu mọi leg đều free

Tests:

  • Unit: availability.service.spec.ts thêm describe getChainedSlots
    • Happy path: 2 services, cùng staff, slot fit cả 2 leg
    • Mix staff: leg 1 staff A, leg 2 staff B, slot phải free cả 2
    • "Any" staff: leg 2 ko chỉ định, pick from pool of skilled
    • Slot fit A nhưng không fit B (B đang busy) → loại
    • allowDoubleBooking = true → trả full grid bất chấp conflict
    • Closure / lead time / business hours edge

Estimate: 0.5d


Phase 2 — FE API client + types

File:

  • booking-web/src/lib/booking-api.ts (+1 function fetchChainAvailability)
  • booking-web/src/types/api.generated.ts (regen sau khi BE xong)

Workflow:

# Trong booking-api:
yarn generate:openapi
# Trong booking-web:
yarn generate:types

Estimate: 1h


Phase 3 — FE tạo V2 components (song song V1)

File mới (folder components-v2/):

  • booking-web/src/app/(customer)/b/[slug]/book/components-v2/BookingPageV2.tsx
  • booking-web/src/app/(customer)/b/[slug]/book/components-v2/ServiceItemV2.tsx (bỏ TimeSlotGrid, giữ staff dropdown + duration)
  • booking-web/src/app/(customer)/b/[slug]/book/components-v2/ChainTimePicker.tsx
  • booking-web/src/app/(customer)/b/[slug]/book/components-v2/ChainPreview.tsx

File sửa (router):

  • booking-web/src/app/(customer)/b/[slug]/book/page.tsx — đọc tenant.settings.bookingUiVersion, switch component

File KHÔNG đụng (V1):

  • booking-web/src/app/(customer)/b/[slug]/book/components/** — giữ y nguyên

Changes:

BookingPage.tsx:

  • Bỏ selectedSlot khỏi BookingItemData, thêm top-level selectedStartTime
  • Thêm useQuery cho availability/chain với queryKey: ['avail-chain', slug, date, itemsKey]
  • enabled: items.length > 0 && date != null
  • Validate trong handler: nếu selectedStartTime không có trong slots mới → reset

ServiceItem.tsx:

  • Giữ: service info, staff SearchSelect, remove button
  • Bỏ: TimeSlotGrid, fetchAvailability per item

ChainTimePicker.tsx:

  • Render TimeSlotGrid của chain
  • Empty state: "Chưa có dịch vụ — vui lòng chọn ít nhất 1 dịch vụ"
  • Loading / error states

ChainPreview.tsx:

  • Hiển thị timeline sau khi pick time:
    10:00 → Cắt tóc · Lan · 45'
    10:45 → Cạo râu · Bất kỳ · 20'
    11:05  Kết thúc
    
  • Reuse formatTime, formatDuration từ lib/utils.ts + lib/formatters.ts

Estimate: 1.5d


Phase 4 — Auto re-validate + edge cases

Logic:

// Khi items / date / staff thay đổi
const chainQuery = useQuery({
  queryKey: ['avail-chain', slug, date, itemsKey],
  queryFn: () => fetchChainAvailability(slug, date, items),
  enabled: items.length > 0,
});

useEffect(() => {
  // React Compiler không thích setState trong effect → dùng derived state pattern
  // Nếu chainQuery.data thay đổi và selectedStartTime không còn trong slots → reset
}, [chainQuery.data]);

→ Đúng hơn là derived state: tính isSelectedSlotValid = chainQuery.data?.slots.some(s => s.startTime === selectedStartTime). Nếu user submit mà invalid → block + toast.

Đồng thời, mỗi lần onItemsChange:

const handleItemsChange = (next) => {
  setItems(next);
  // Không reset selectedStartTime ngay; chờ chainQuery refetch → derived check.
  // Nếu invalid → toast trong onSuccess của chainQuery? KHÔNG, dùng useMemo + previous ref.
};

Implementation chi tiết: dùng useEffect với guard kiểm tra cũ-mới của chainQuery.data (vẫn compatible nếu setState ở event chứ không phải render).

Hoặc gọn hơn: Toast inline ngay khi user click bị-disabled-slot. Không auto-reset, user thấy slot grey/disabled → tự click slot khác.

Quyết định: chọn pattern slot grey-out: chain query trả slots, slot nào không trong list → render disabled. Không cần "auto reset" logic — UI tự thể hiện trạng thái.

Edge cases:

  • Items empty → ChainTimePicker show empty state
  • Date đổi → chainQuery refetch tự động (queryKey change)
  • Staff đổi giữa các service → chainQuery refetch tự động
  • Khách add service AFTER pick time → slot có thể không còn fit → grey out

Estimate: 0.5d


Phase 5 — E2E tests

File: booking-web/e2e/public-booking.spec.ts

Setup mới: Test fixture phải set tenant.settings.bookingUiVersion = 'v2' qua seed/API trước khi chạy v2 tests.

Tests V1 (giữ nguyên):

  • 1.1 single service happy path
  • 1.2 multi-service totals aggregate — không sửa, vẫn assert v1 behavior

Tests V2 (mới):

  • 2.1 v2 chain multi-service: 2 services → 1 time picker → sequential preview
  • 2.2 v2 chain mix staff: service A staff X, service B staff Y → slot phải free cả 2
  • 2.3 v2 chain re-validate: pick time → add service → slot vanish hoặc preview update
  • 2.4 v2 allowDoubleBooking ON: slot grid hiển thị cả slot đang conflict
  • 2.5 version switch: tenant.bookingUiVersion=v2 → render BookingPageV2; =v1 → render BookingPage

Unblock 1.3 unassigned booking nếu có thời gian (out of scope sprint này nếu phức tạp).

Estimate: 0.5d


Phase 6 — Docs

File:

  • docs/flows/booking-flow.md — thêm section "Multi-service customer flow" với sequence diagram chain
  • docs/progress/features.md — flip Multi-service booking UX → done date
  • docs/progress/changelog.md — entry mới

Estimate: 1h


Rủi ro & mitigation

Rủi ro Mitigation
getChainedSlots chậm khi N × M lớn Reuse bookingMap (1 query/ngày). N ≈ 96 slots, M ≤ 5 services → 480 ops, không vấn đề. React Query cache 30s.
allowDoubleBooking bật + staff khác cho mỗi service → vẫn check business hours? Có. shouldSkipConflict chỉ skip booking overlap; business hours / schedule / closure vẫn enforce.
Backward compat link ?serviceId=xxx&from=yyy Logic resolve giữ; chỉ khác pre-selected items không có selectedSlot. Khách pick time 1 lần.
Khách cố tình gọi API với per-item startTime Backend không thay đổi — DTO vẫn accept. BookingService.create validation cũ vẫn run. Customer page chỉ không TẠO ra payload kiểu đó.
Admin drawer vẫn chạy code cũ Tách biệt — admin gọi /bookings/availability (per-service), customer gọi /public/:slug/availability/chain. Không đụng nhau.

Out of scope (defer)

  • Admin drawer refactor — giữ per-item time, sau này nếu muốn đồng bộ thì làm sprint riêng
  • Reorder services UI — Nhiều platform không cho khách reorder; admin có thể qua drag handle (sprint sau)
  • Gap giữa services (multi-stage with intentional break) — không có salon nào yêu cầu, defer indefinitely
  • Service dependencies (service B yêu cầu service A trước đó) — out of scope hoàn toàn

Tracking

  • P0 — PlatformSetting global flag bookingUiVersion + super-admin radio group (shipped 2026-05-29)
  • P1 — Backend getChainedSlots + endpoint + tests
  • P2 — FE API client + types regen
  • P3 — FE V2 components song song V1 (components-v2/) + router switch
  • P4 — Auto re-validate edge cases (V2 only)
  • P5 — E2E tests (V2 happy path + version switch)
  • P6 — Docs sync (booking-flow.md + features.md + changelog.md)

V1 deprecation strategy (sau khi v2 stable)

Pattern feature flag chuẩn:

  1. V2 ship + super-admin enable từng tenant rollout
  2. Sau ~2-4 tuần stable + ≥80% tenant đã chuyển → flip default 'v1''v2' trong DEFAULT_TENANT_SETTINGS
  3. Tenant còn lại tự rollout
  4. Sau khi 100% tenant trên v2 + ≥1 tháng không issue → xoá V1 (components/ folder) + field bookingUiVersion + migration drop field
  5. Cập nhật docs + remove plan doc này

Không để code v1/v2 song song quá 3 tháng — dead code rot nhanh.