Booking Wizard
The Booking Wizard is the guided, multi-step flow for creating a new booking. It is the canonical path — every other entry (lead conversion, quotation acceptance, CSV import) ultimately funnels through the same POST /sales/bookings endpoint the wizard calls.
Where it lives
- Route:
/sales/bookings/new— gated bybookings.create(src/App.tsx:161) - Page wrapper:
src/pages/sales/BookingWizard.tsx:4 - Main component:
src/components/bookings/wizard/WizardShell.tsx:29 - State shape + helpers:
src/components/bookings/wizard/wizardTypes.ts - Per-passenger subform:
src/components/bookings/wizard/PassengerForm.tsx
1. Shape
WizardShell renders a 4-step linear form with a step indicator at the top (WizardShell.tsx:425) and a single Back / Next / Confirm footer (WizardShell.tsx:958). Navigation is blocked until canProceed passes for the current step (WizardShell.tsx:223).
State is held in a single WizardState object (wizardTypes.ts:47) — one source of truth for everything the wizard collects. A set(key, value) helper mutates it (WizardShell.tsx:80); any changes to the service-rate fields (ticketRate, visaRate, hotelRate, mealsRate, groundRate) auto-recompute adultRate as their sum.
2. Flow diagram
flowchart TD
Start([User lands on /sales/bookings/new]) --> LoadRef[Load groups, agents, customers, currencies]
LoadRef --> S0[Step 1: Source & Group]
S0 -->|Pick tour type + sub-type| S0a{Source?}
S0a -->|direct| S0c[Pick travel group]
S0a -->|agent| S0b[Pick partner] --> S0c
S0c --> S1[Step 2: Passengers]
S1 --> PaxLookup[Passport lookup against existing customers]
PaxLookup --> PaxValidate{All pax valid?}
PaxValidate -->|no| S1
PaxValidate -->|yes| S2[Step 3: Package & Payment]
S2 --> Sync[Auto-sync rates from group rate sheet silently]
Sync --> RateBreakdown[User edits ticket/visa/hotel/meals/ground rates]
RateBreakdown -->|Click Sync button| SyncOverride[syncGroupRates overwrite=true]
SyncOverride --> RateBreakdown
RateBreakdown --> GST[GST toggle + rate]
GST --> FirstPay{Source agent?}
FirstPay -->|agent| S3[Step 4: Review]
FirstPay -->|direct| FirstPayForm[First payment amount + method + reference] --> S3
S3 --> Confirm[Click Confirm & Create Booking]
Confirm --> Submit[handleSubmit]
Submit --> CreateCust[For each passenger: POST /sales/customers]
CreateCust --> CreateBooking[POST /sales/bookings with passengers array]
CreateBooking --> PaymentCheck{First payment > 0 and direct?}
PaymentCheck -->|yes| Pay[POST /finance/payments verified] --> Done
PaymentCheck -->|no| Done
Done([Toast + navigate to /sales/bookings])
3. Step 1 — Source & Group
Renders the tour category picker, the booking source, and the travel group selector.
| Field | Captures | Validation |
|---|---|---|
| Tour category | PILGRIMAGE / GENERAL (WizardShell.tsx:451) |
Required |
| Sub-type (pilgrimage) | HAJ, HAJ_ZIYARAT, UMRAH, UMRAH_ZIYARAT, ZIYARAT (WizardShell.tsx:474) |
Required if PILGRIMAGE |
| Sub-type (general) | DOMESTIC, INTERNATIONAL (WizardShell.tsx:500) |
Required if GENERAL |
| Booking source | direct or agent (WizardShell.tsx:523) |
Required |
| Business partner | agentId (WizardShell.tsx:535) |
Required when source=agent |
| Travel group | groupId (WizardShell.tsx:552) |
Required; filtered by selected tour type and status in (open, planning) |
Hajj disclosure
Selecting HAJ or HAJ_ZIYARAT flips bookingType to 'hajj' (WizardShell.tsx:93) which surfaces mandatory Blood Group + PAN + Aadhaar fields for every adult/child passenger in Step 2.
Once a group is picked, a compact info strip shows departure, return, duration, capacity, and available seats (WizardShell.tsx:571).
4. Step 2 — Passengers
The passenger count field at the top (WizardShell.tsx:589) controls the array length; handlePassengerCountChange (WizardShell.tsx:187) clamps between 1 and 50 and grows / shrinks passengers[] accordingly.
Each passenger is rendered through PassengerForm, which captures:
- Title (auto-assigned from DOB + gender via
calculateTitle—wizardTypes.ts:125) - First/Last name (uppercased on submit)
- DOB — drives
passengerCategory(adult/child/infant) viacalculateCategory(wizardTypes.ts:113; rule: infant < 2 yrs, child 2–12 yrs, adult ≥ 12 yrs, measured against the group's departure date) - Gender
- Passport No (uppercased, dedup-lookup happens live —
lookupPassport,WizardShell.tsx:198) - Passport Issued Date + Expiry (mandatory except for
GENERAL_DOMESTIC) - Nationality (default
INDIAN) - Phone, email, address block
sameAddressAsFirston passengers 2+ — copies P1's address (WizardShell.tsx:211)- Infant-only: mother/guardian name — one-click chips suggest adult-female names from the same booking (
WizardShell.tsx:260) - Hajj-only (when
bookingType === 'hajj'): Blood Group, PAN, Aadhaar
Passport dedup
When the user types an 8-character passport number, lookupPassport scans the already-fetched customer cache. If a match exists, the form can auto-fill from the existing Customer row — this is the mechanism that prevents duplicate customers on repeat travellers. At submit time, if the server still rejects with HTTP 409 (duplicate), the wizard falls back to the existing ID (WizardShell.tsx:318).
Validation (step 2)
See WizardShell.tsx:230. Required per passenger:
- First name, last name, DOB, gender, passport no
- Passport dates unless tour is
GENERAL_DOMESTIC - Adults:
titlemust be confirmed - Infants:
motherNamemust be filled - Hajj bookings:
bloodGroup,panCard,aadhaarNumberfor every adult/child
5. Step 3 — Package & Payment
This is where the money is shaped. Three zones stacked top-to-bottom:
5.1 Rate Breakdown (per adult) and the Sync button
WizardShell.tsx:628. Five inputs — Ticket, Visa, Hotel, Meals, Ground — feed the adult rate. On any change the set() helper auto-sums them into adultRate (WizardShell.tsx:83).
The Sync button sitting to the right of this section is the recent feature worth calling out:
const syncGroupRates = useCallback(async (opts: { overwrite: boolean; silent?: boolean }) => {
// GET /groups/{groupId}/pricing
// If opts.overwrite, replace every rate; otherwise only fill EMPTY fields.
// If opts.silent, no toast + no spinner on the button.
}, [wizard.groupId]);
Source: WizardShell.tsx:106.
It runs in two modes:
| Trigger | overwrite |
silent |
Behaviour |
|---|---|---|---|
Group is selected (useEffect WizardShell.tsx:146) |
false |
true |
Silently prefills any empty rate cell from the group's rate sheet. Existing user edits are preserved. |
User clicks the Sync button (WizardShell.tsx:640) |
true |
false |
Overwrites every rate, shows a spinner on the button, toasts "Rates synced from group" on success. |
Under the hood it calls GET /groups/{groupId}/pricing and maps lineItems.{flight,visa,hotel,groundServices}.{adult,child,infant} onto the corresponding wizard fields. ticketRate ← flight.adult, visaRate ← visa.adult, hotelRate ← hotel.adult, groundRate ← groundServices.adult. Child and infant rollup rates are the sum of each service's child/infant tier. There is no meals line in the group rate sheet today — mealsRate stays at whatever the user typed.
Why two modes
The silent auto-fill prevents a blank wizard the moment a group is picked. The manual Sync button is the escape hatch for when a group's rates changed after the operator already entered the wizard — one click refreshes every tier. An italic caption ("Prefilled from group rate sheet — override as needed") appears when a sheet is loaded (WizardShell.tsx:633).
5.2 Child / Infant rate overrides
Two inputs; blank or 0 means "same as adult" (WizardShell.tsx:688).
5.3 Room type, currency, payment policy
- Room type — single / double / triple / quad / quint / general (
WizardShell.tsx:704) - Currency — from
/admin/currenciesor default INR (WizardShell.tsx:720) - Payment policy —
partial/full/advance/on_account. Forced toon_accountand disabled whensource==='agent'(WizardShell.tsx:735)
5.4 Breakdown preview
WizardShell.tsx:762 — renders an itemised preview (per-adult service breakdown, child × rate, infant × rate) with subtotal, GST line (if enabled), and grand total. For INR, the Indian-words total is shown below (WizardShell.tsx:811).
5.5 GST
WizardShell.tsx:818. Default on. When disabled, gstAmount is cleared. When enabled, Rate (%) and GST Amount are both optional — blank → server falls back to global finance config.
5.6 First Payment (direct bookings only)
Hidden for source==='agent' (WizardShell.tsx:872). Three fields: amount, method (cash / bank transfer / UPI / card / cheque), reference. When submitted, a POST /finance/payments with status: 'verified' follows the booking create (WizardShell.tsx:394).
Validation (step 3)
totalAmount > 0 — i.e. at least one rate is non-zero and there is at least one paying passenger (WizardShell.tsx:251).
6. Step 4 — Review & Confirm
Three summary cards: Source & Group, Passengers, Package & Payment (WizardShell.tsx:907). Clicking Confirm & Create Booking runs handleSubmit.
7. How travellers get added
The wizard treats passenger and customer as separate concepts:
- Adding — the passenger count input grows the
passengers[]array with a blankemptyPassenger()(wizardTypes.ts:78). - Removing — shrinking the count truncates the array.
- Dedup — passport lookup surfaces existing customers; the wizard links them by ID rather than creating a duplicate.
- Submit — for each passenger without an
existingCustomerId,POST /sales/customerscreates a newCustomerrecord (WizardShell.tsx:284). The resultingcustomerIds[]array is then attached to the booking.
To add passengers after creation, use the Add Passengers dialog from the list or detail view (src/components/bookings/AddPassengersDialog.tsx).
8. Flight linking + multi-city route display
The wizard itself does not link flights — flight assignment is a group-level concern (see Groups §4). After the booking is created and the customer is attached to the group, the per-passenger PassengerFlightsPanel on the detail page (src/components/bookings/PassengerFlightsPanel.tsx) surfaces the group's flights with the multi-city route string built from the block or FIT's legs[] array.
Example display for a 3-leg block: SXR → DEL → JED. See src/pages/groups/Groups.tsx:3579 for the chainLegs() helper that produces the display string.
9. Data flow on submit
handleSubmit (WizardShell.tsx:270) executes in a fixed order. A failure at any step aborts the rest; partial customers may have been created — this is a known trade-off (the customers endpoint is idempotent by passport, so retries are safe).
sequenceDiagram
participant UI as WizardShell
participant API as /lib/api
participant SupaCustomers as POST /sales/customers
participant SupaBookings as POST /sales/bookings
participant SupaFinance as POST /finance/payments
UI->>UI: Build customerIds[] (empty)
loop For each passenger
alt Has existingCustomerId
UI->>UI: Reuse ID
else New
UI->>API: apiFetch('/sales/customers', POST, {...})
API->>SupaCustomers: Insert customer
SupaCustomers-->>API: { id }
API-->>UI: customerIds.push(id)
Note over UI: On 409 duplicate, look up existing passport
end
end
UI->>UI: Build passengers[] with customerIds
UI->>API: apiFetch('/sales/bookings', POST, { customerId, groupId, agentId?, rates, passengers })
API->>SupaBookings: Insert booking + BookingPassenger rows
SupaBookings-->>API: { id: bookingId }
API-->>UI: { id }
alt First payment > 0 AND source != 'agent'
UI->>API: apiFetch('/finance/payments', POST, { bookingId, amount, method, status: 'verified' })
API->>SupaFinance: Insert Payment + post journal
end
UI->>UI: Toast success + navigate('/sales/bookings')
Server-side expectations
POST /sales/bookingsrequiresbookings.create—requirePermission('bookings.create')fires before any work (src/lib/api.ts).- The handler is idempotent for near-duplicate retries within the same second (double-click guard).
- On success,
Booking.statusstarts atdraft. Ops and Finance approval runs through the Approvals module (/approvals).
10. Permissions
| Action | Permission |
|---|---|
| Access the wizard route | bookings.create |
| Create the customer for a new passenger | customers.create |
| Record the first payment | finance.create |
See docs/PERMISSIONS.md §6.4 for the full matrix.