@voyantjs/bookings-ui 0.52.1 → 0.52.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/components/booking-billing-dialog.d.ts +16 -0
  2. package/dist/components/booking-billing-dialog.d.ts.map +1 -0
  3. package/dist/components/booking-billing-dialog.js +90 -0
  4. package/dist/components/booking-create-dialog.d.ts.map +1 -1
  5. package/dist/components/booking-create-dialog.js +512 -151
  6. package/dist/components/booking-create-page.js +1 -1
  7. package/dist/components/booking-document-dialog.d.ts.map +1 -1
  8. package/dist/components/booking-document-dialog.js +16 -14
  9. package/dist/components/booking-guarantee-dialog.d.ts.map +1 -1
  10. package/dist/components/booking-guarantee-dialog.js +10 -8
  11. package/dist/components/booking-item-dialog.d.ts.map +1 -1
  12. package/dist/components/booking-item-dialog.js +18 -9
  13. package/dist/components/booking-item-travelers.d.ts.map +1 -1
  14. package/dist/components/booking-item-travelers.js +9 -7
  15. package/dist/components/booking-payment-schedule-dialog.d.ts.map +1 -1
  16. package/dist/components/booking-payment-schedule-dialog.js +10 -8
  17. package/dist/components/booking-payment-schedule-list.d.ts.map +1 -1
  18. package/dist/components/booking-payment-schedule-list.js +32 -3
  19. package/dist/components/{rooms-stepper-section.d.ts → option-units-stepper-section.d.ts} +17 -9
  20. package/dist/components/option-units-stepper-section.d.ts.map +1 -0
  21. package/dist/components/option-units-stepper-section.js +172 -0
  22. package/dist/components/payment-schedule-section.d.ts +1 -1
  23. package/dist/components/payment-schedule-section.d.ts.map +1 -1
  24. package/dist/components/payment-schedule-section.js +5 -11
  25. package/dist/components/person-picker-section.d.ts +4 -0
  26. package/dist/components/person-picker-section.d.ts.map +1 -1
  27. package/dist/components/person-picker-section.js +27 -5
  28. package/dist/components/price-breakdown-section.d.ts +8 -2
  29. package/dist/components/price-breakdown-section.d.ts.map +1 -1
  30. package/dist/components/price-breakdown-section.js +17 -5
  31. package/dist/components/status-change-dialog.d.ts.map +1 -1
  32. package/dist/components/status-change-dialog.js +6 -5
  33. package/dist/components/supplier-status-dialog.d.ts.map +1 -1
  34. package/dist/components/supplier-status-dialog.js +6 -5
  35. package/dist/components/traveler-list.d.ts.map +1 -1
  36. package/dist/components/traveler-list.js +12 -1
  37. package/dist/components/travelers-section.d.ts +62 -3
  38. package/dist/components/travelers-section.d.ts.map +1 -1
  39. package/dist/components/travelers-section.js +290 -23
  40. package/dist/i18n/en.d.ts +63 -0
  41. package/dist/i18n/en.d.ts.map +1 -1
  42. package/dist/i18n/en.js +68 -5
  43. package/dist/i18n/messages.d.ts +63 -0
  44. package/dist/i18n/messages.d.ts.map +1 -1
  45. package/dist/i18n/provider.d.ts +126 -0
  46. package/dist/i18n/provider.d.ts.map +1 -1
  47. package/dist/i18n/ro.d.ts +63 -0
  48. package/dist/i18n/ro.d.ts.map +1 -1
  49. package/dist/i18n/ro.js +68 -5
  50. package/dist/index.d.ts +1 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -1
  53. package/package.json +26 -24
  54. package/dist/components/rooms-stepper-section.d.ts.map +0 -1
  55. package/dist/components/rooms-stepper-section.js +0 -111
@@ -1,19 +1,23 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useSlots, useSlotUnitAvailability } from "@voyantjs/availability-react";
4
- import { useBookingCreateMutation, useBookingStatusByIdMutation, } from "@voyantjs/bookings-react";
5
- import { Button, Checkbox, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, } from "@voyantjs/ui/components";
6
- import { Loader2 } from "lucide-react";
4
+ import { useBookingCreateMutation, useBookingTaxPreview, } from "@voyantjs/bookings-react";
5
+ import { useOrganization, usePerson } from "@voyantjs/crm-react";
6
+ import { useAddresses } from "@voyantjs/identity-react";
7
+ import { useProduct, useProductMedia } from "@voyantjs/products-react";
8
+ import { Button, Checkbox, Dialog, DialogContent, DialogHeader, DialogTitle, Label, Textarea, } from "@voyantjs/ui/components";
9
+ import { AsyncCombobox } from "@voyantjs/ui/components/async-combobox";
10
+ import { ImageIcon, Loader2 } from "lucide-react";
7
11
  import * as React from "react";
8
12
  import { formatMessage, useBookingsUiI18nOrDefault, useBookingsUiMessagesOrDefault, } from "../i18n/provider.js";
9
13
  import { getBookableDepartureSlots, getSelectedSharedRoomUnitId, itemLinesToRows, } from "./booking-create-utils.js";
14
+ import { emptyOptionUnitsStepperValue, OptionUnitsStepperSection, } from "./option-units-stepper-section.js";
10
15
  import { emptyPaymentScheduleValue, PaymentScheduleSection, } from "./payment-schedule-section.js";
11
16
  import { emptyPersonPickerValue, PersonPickerSection, } from "./person-picker-section.js";
12
17
  import { PriceBreakdownSection } from "./price-breakdown-section.js";
13
18
  import { ProductPickerSection } from "./product-picker-section.js";
14
- import { emptyRoomsStepperValue, RoomsStepperSection, } from "./rooms-stepper-section.js";
15
19
  import { emptySharedRoomValue, SharedRoomSection, } from "./shared-room-section.js";
16
- import { emptyTravelerListValue, TravelersSection, } from "./travelers-section.js";
20
+ import { computeAgeYears, emptyTravelerListValue, TravelersSection, } from "./travelers-section.js";
17
21
  import { emptyVoucherPickerValue, VoucherPickerSection, } from "./voucher-picker-section.js";
18
22
  function generateBookingNumber() {
19
23
  const now = new Date();
@@ -23,8 +27,6 @@ function generateBookingNumber() {
23
27
  return `BK-${y}${m}-${seq}`;
24
28
  }
25
29
  function paymentScheduleToRows(value, currency, totalAmountCents) {
26
- if (value.mode === "unpaid")
27
- return [];
28
30
  if (value.mode === "full") {
29
31
  if (!value.fullDueDate || totalAmountCents === null)
30
32
  return [];
@@ -39,30 +41,6 @@ function paymentScheduleToRows(value, currency, totalAmountCents) {
39
41
  },
40
42
  ];
41
43
  }
42
- if (value.mode === "advance") {
43
- if (!value.advanceDueDate || value.advanceAmountCents == null)
44
- return [];
45
- const rows = [
46
- {
47
- scheduleType: "deposit",
48
- status: value.advanceAlreadyPaid ? "paid" : "due",
49
- dueDate: value.advanceDueDate,
50
- currency,
51
- amountCents: value.advanceAmountCents,
52
- notes: paidScheduleNotes(value.advanceAlreadyPaid, value.advancePaymentDate, value.advancePaymentMethod, value.advancePaymentReference),
53
- },
54
- ];
55
- if (totalAmountCents !== null && totalAmountCents > value.advanceAmountCents) {
56
- rows.push({
57
- scheduleType: "balance",
58
- status: "pending",
59
- dueDate: value.advanceDueDate,
60
- currency,
61
- amountCents: totalAmountCents - value.advanceAmountCents,
62
- });
63
- }
64
- return rows;
65
- }
66
44
  // split
67
45
  const rows = [];
68
46
  if (value.splitFirstDueDate && value.splitFirstAmountCents != null) {
@@ -97,23 +75,179 @@ function paidScheduleNotes(alreadyPaid, paymentDate, paymentMethod, paymentRefer
97
75
  paymentReference: paymentReference.trim() || null,
98
76
  });
99
77
  }
78
+ /**
79
+ * Pick the option-unit that matches a given age. Falls back to an
80
+ * ADULT-coded unit when no min/max window matches, then to the first
81
+ * unit in the option. When `age` is null (no DOB), prefer ADULT.
82
+ */
83
+ /**
84
+ * The catalog stepper builds unit names like "Standard double - Adult"
85
+ * when an option has multiple units. The Room dropdown wants the bare
86
+ * option name ("Standard double"), so we trim off the trailing
87
+ * "- <unit>" suffix for display.
88
+ */
89
+ function stripUnitSuffix(name) {
90
+ const idx = name.lastIndexOf(" - ");
91
+ return idx > 0 ? name.slice(0, idx) : name;
92
+ }
93
+ /**
94
+ * Any payment-schedule entry the operator has marked as already
95
+ * paid. Drives the smart-default booking status on submit — if money
96
+ * is in (deposit / full / split installment), the booking lands in
97
+ * `confirmed`; otherwise it lands in `awaiting_payment`.
98
+ */
99
+ function hasAnyPaidPayment(schedule) {
100
+ switch (schedule.mode) {
101
+ case "full":
102
+ return schedule.fullAlreadyPaid;
103
+ case "split":
104
+ return schedule.splitFirstAlreadyPaid || schedule.splitSecondAlreadyPaid;
105
+ default:
106
+ return false;
107
+ }
108
+ }
109
+ /**
110
+ * Inverse of stripUnitSuffix — strip the leading "Option name - " so
111
+ * the per-unit label stands alone for category buttons.
112
+ */
113
+ function stripOptionPrefix(name) {
114
+ const idx = name.indexOf(" - ");
115
+ return idx > 0 ? name.slice(idx + 3) : name;
116
+ }
117
+ /**
118
+ * Pick the unit for a traveler. Priorities:
119
+ * 1. If we have an age (from DOB) and it falls into a unit's
120
+ * `[minAge, maxAge]` window, use that unit.
121
+ * 2. Otherwise honor an explicit role hint (Child / Infant / Adult
122
+ * buttons) by matching unit code or name.
123
+ * 3. Fall back to the ADULT-coded unit, or the first unit when
124
+ * nothing else matches.
125
+ *
126
+ * `roleHint` covers the common case where the operator knows the
127
+ * traveler is a child but doesn't have the exact DOB. Without it, a
128
+ * roleless traveler would silently default to Adult pricing.
129
+ */
130
+ function pickUnitForAge(units, age, roleHint = null) {
131
+ if (units.length === 0)
132
+ return undefined;
133
+ const findByCode = (code) => units.find((u) => (u.unitCode ?? "").toUpperCase() === code) ??
134
+ units.find((u) => new RegExp(`\\b${code}\\b`, "i").test(u.unitName));
135
+ const adult = findByCode("ADULT");
136
+ if (age != null) {
137
+ const match = units.find((u) => (u.minAge == null || age >= u.minAge) && (u.maxAge == null || age <= u.maxAge));
138
+ if (match)
139
+ return match;
140
+ }
141
+ if (roleHint === "child")
142
+ return findByCode("CHILD") ?? adult ?? units[0];
143
+ if (roleHint === "infant")
144
+ return findByCode("INFANT") ?? adult ?? units[0];
145
+ return adult ?? units[0];
146
+ }
147
+ /**
148
+ * Take the operator-picked per-option quantities (which are tracked
149
+ * against each option's primary "Adult" unit by the stepper) plus the
150
+ * travelers list, and redistribute both so that:
151
+ * - each traveler's `roomUnitId` points at the age-banded unit
152
+ * matching their DOB (Adult / Child / Infant / etc.)
153
+ * - `quantities` reflects the per-unit counts after redistribution —
154
+ * a 3-pax "Standard double" with 2 adults + 1 child becomes
155
+ * `{ adultUnit: 2, childUnit: 1 }` instead of `{ adultUnit: 3 }`.
156
+ *
157
+ * Slots without a configured `dateOfBirth` keep the option's adult
158
+ * default so partially-filled bookings still typecheck.
159
+ */
160
+ /**
161
+ * Rebuild stepper quantities from per-traveler unit assignments.
162
+ *
163
+ * Each traveler's `roomUnitId` is now the operator's explicit choice
164
+ * (DOB-pre-picked at attach, overridable via the dynamic category
165
+ * buttons), so we count assignments directly and add any per-option
166
+ * residual on the adult/primary unit when the stepper qty exceeds the
167
+ * number of travelers actually assigned. Unlike the older
168
+ * DOB-driven rewrite, this never moves a traveler off their chosen
169
+ * unit — operator selection always wins.
170
+ */
171
+ function redistributeByAge(quantities, travelers, units) {
172
+ if (units.length === 0)
173
+ return { quantities, travelers };
174
+ const unitsByOption = new Map();
175
+ for (const unit of units) {
176
+ if (!unit.optionId)
177
+ continue;
178
+ const list = unitsByOption.get(unit.optionId);
179
+ if (list)
180
+ list.push(unit);
181
+ else
182
+ unitsByOption.set(unit.optionId, [unit]);
183
+ }
184
+ const unitToOption = new Map(units.map((u) => [u.optionUnitId, u.optionId]));
185
+ // Per-option total from the stepper. This is the count the operator
186
+ // committed to when picking rooms.
187
+ const totalByOption = new Map();
188
+ for (const [unitId, qty] of Object.entries(quantities)) {
189
+ if (qty <= 0)
190
+ continue;
191
+ const optionId = unitToOption.get(unitId);
192
+ if (!optionId)
193
+ continue;
194
+ totalByOption.set(optionId, (totalByOption.get(optionId) ?? 0) + qty);
195
+ }
196
+ // Count actual traveler assignments per unit + per option.
197
+ const next = {};
198
+ const assignedByOption = new Map();
199
+ for (const t of travelers) {
200
+ if (!t.roomUnitId)
201
+ continue;
202
+ const optionId = unitToOption.get(t.roomUnitId);
203
+ if (!optionId)
204
+ continue;
205
+ next[t.roomUnitId] = (next[t.roomUnitId] ?? 0) + 1;
206
+ assignedByOption.set(optionId, (assignedByOption.get(optionId) ?? 0) + 1);
207
+ }
208
+ // Residual = operator picked N rooms but only added M travelers; put
209
+ // the leftover on the option's adult/primary unit so the price total
210
+ // matches the stepper.
211
+ for (const [optionId, total] of totalByOption) {
212
+ const assigned = assignedByOption.get(optionId) ?? 0;
213
+ const residual = Math.max(0, total - assigned);
214
+ if (residual === 0)
215
+ continue;
216
+ const adult = pickUnitForAge(unitsByOption.get(optionId) ?? [], null);
217
+ if (!adult)
218
+ continue;
219
+ next[adult.optionUnitId] = (next[adult.optionUnitId] ?? 0) + residual;
220
+ }
221
+ return { quantities: next, travelers };
222
+ }
100
223
  function travelersToRows(value) {
101
- return value.travelers.map((traveler) => ({
102
- personId: traveler.personId,
103
- firstName: traveler.firstName.trim(),
104
- lastName: traveler.lastName.trim(),
105
- email: traveler.email.trim() || null,
106
- participantType: "traveler",
107
- travelerCategory: traveler.role === "child"
108
- ? "child"
109
- : traveler.role === "infant"
224
+ return value.travelers.map((traveler) => {
225
+ // Age-derived category (DOB-driven). The `role` field still
226
+ // carries the `lead` flag separately for the booking primary; the
227
+ // demographic category comes from age, not from a manual select.
228
+ const age = computeAgeYears(traveler.dateOfBirth);
229
+ const ageCategory = age == null
230
+ ? traveler.role === "child" || traveler.role === "infant" || traveler.role === "adult"
231
+ ? traveler.role
232
+ : null
233
+ : age < 2
110
234
  ? "infant"
111
- : traveler.role === "adult"
112
- ? "adult"
113
- : null,
114
- isPrimary: traveler.role === "lead",
115
- roomUnitId: traveler.roomUnitId,
116
- }));
235
+ : age < 18
236
+ ? "child"
237
+ : "adult";
238
+ return {
239
+ personId: traveler.personId,
240
+ firstName: traveler.firstName.trim(),
241
+ lastName: traveler.lastName.trim(),
242
+ email: traveler.email.trim() || null,
243
+ phone: traveler.phone.trim() || null,
244
+ preferredLanguage: traveler.preferredLanguage.trim() || null,
245
+ participantType: "traveler",
246
+ travelerCategory: ageCategory,
247
+ isPrimary: traveler.role === "lead",
248
+ roomUnitId: traveler.roomUnitId,
249
+ };
250
+ });
117
251
  }
118
252
  function sameRoomUnits(left, right) {
119
253
  if (left.length !== right.length)
@@ -152,7 +286,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
152
286
  optionId: null,
153
287
  });
154
288
  const [slotId, setSlotId] = React.useState(null);
155
- const [rooms, setRooms] = React.useState(emptyRoomsStepperValue);
289
+ const [rooms, setRooms] = React.useState(emptyOptionUnitsStepperValue);
156
290
  const [roomUnits, setRoomUnits] = React.useState([]);
157
291
  const [person, setPerson] = React.useState(emptyPersonPickerValue);
158
292
  const [sharedRoom, setSharedRoom] = React.useState(emptySharedRoomValue);
@@ -164,13 +298,19 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
164
298
  const [generateInvoiceDocument, setGenerateInvoiceDocument] = React.useState(false);
165
299
  const [notes, setNotes] = React.useState("");
166
300
  /**
167
- * Optional post-create transition: set status to `confirmed` right after
168
- * create succeeds. When the parent app has the notifications module's
169
- * `autoConfirmAndDispatch` enabled, this fires the doc bundle + traveler
170
- * email via the `booking.confirmed` subscriber. When it isn't, the
171
- * booking simply lands in `confirmed` instead of `draft`.
301
+ * Operator override that forces the booking to land in `draft`
302
+ * regardless of payment state. Off by default the dialog
303
+ * smart-defaults to `confirmed` (when any payment is marked paid)
304
+ * or `awaiting_payment` (when nothing is paid yet). The override
305
+ * exists so an operator can still capture a half-configured
306
+ * booking without committing it to the lifecycle.
172
307
  */
173
- const [confirmAfterCreate, setConfirmAfterCreate] = React.useState(false);
308
+ const [createAsDraft, setCreateAsDraft] = React.useState(false);
309
+ // Only relevant when the derived status is `confirmed`: when off,
310
+ // the status transition carries `suppressNotifications: true` so
311
+ // the auto-dispatch subscriber skips the customer email + document
312
+ // bundle. Defaults on so the operator opts out, not in.
313
+ const [notifyTraveler, setNotifyTraveler] = React.useState(true);
174
314
  const [error, setError] = React.useState(null);
175
315
  const { formatDate } = useBookingsUiI18nOrDefault();
176
316
  const messages = useBookingsUiMessagesOrDefault();
@@ -178,7 +318,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
178
318
  if (!enabled) {
179
319
  setProduct({ productId: defaultProductId ?? "", optionId: null });
180
320
  setSlotId(null);
181
- setRooms(emptyRoomsStepperValue);
321
+ setRooms(emptyOptionUnitsStepperValue);
182
322
  setRoomUnits([]);
183
323
  setPerson(emptyPersonPickerValue);
184
324
  setSharedRoom(emptySharedRoomValue);
@@ -189,7 +329,8 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
189
329
  setGenerateContractDocument(false);
190
330
  setGenerateInvoiceDocument(false);
191
331
  setNotes("");
192
- setConfirmAfterCreate(false);
332
+ setCreateAsDraft(false);
333
+ setNotifyTraveler(true);
193
334
  setError(null);
194
335
  }
195
336
  else if (defaultProductId) {
@@ -201,7 +342,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
201
342
  // biome-ignore lint/correctness/useExhaustiveDependencies: booking-create intentionally resets transient departure state only when product id changes; option changes are reconciled against the selected departure below.
202
343
  React.useEffect(() => {
203
344
  setSlotId(null);
204
- setRooms(emptyRoomsStepperValue);
345
+ setRooms(emptyOptionUnitsStepperValue);
205
346
  setRoomUnits([]);
206
347
  setSharedRoom(emptySharedRoomValue);
207
348
  }, [product.productId]);
@@ -238,7 +379,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
238
379
  setSlotId(nextSlotId);
239
380
  }, [allOpenSlots, product.optionId]);
240
381
  React.useEffect(() => {
241
- setRooms(emptyRoomsStepperValue);
382
+ setRooms(emptyOptionUnitsStepperValue);
242
383
  setRoomUnits([]);
243
384
  if (!slotId || !product.optionId)
244
385
  return;
@@ -265,24 +406,106 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
265
406
  const handleRoomUnitsChange = React.useCallback((units) => {
266
407
  setRoomUnits((prev) => (sameRoomUnits(prev, units) ? prev : units));
267
408
  }, []);
409
+ // Room choices presented to the traveler row are *options* (e.g.
410
+ // "Standard double", "Junior suite upgrade") — NOT option-units
411
+ // (Adult / Child / Senior). The age-band lives separately on the
412
+ // traveler and only affects pricing; both an adult and a child sit
413
+ // in the same Standard double room. Each entry's `unitId` is set to
414
+ // the option's primary unit so existing `roomUnitId`-keyed plumbing
415
+ // (assignment, redistribution) keeps working — `redistributeByAge`
416
+ // moves the traveler to the matching age-banded unit at submit.
268
417
  const roomUnitOptions = React.useMemo(() => {
269
418
  const units = roomUnits.length > 0 ? roomUnits : (slotUnitAvailability.data?.data ?? []);
270
419
  if (units.length === 0)
271
420
  return [];
272
- return units
273
- .filter((unit) => (rooms.quantities[unit.optionUnitId] ?? 0) > 0)
274
- .map((unit) => {
275
- const qty = rooms.quantities[unit.optionUnitId] ?? 0;
276
- const occupancyMax = Math.max(1, unit.occupancyMax ?? 1);
277
- const seats = qty * occupancyMax;
278
- const assigned = travelers.travelers.filter((traveler) => traveler.roomUnitId === unit.optionUnitId).length;
421
+ const optionGroups = new Map();
422
+ for (const unit of units) {
423
+ const key = unit.optionId ?? unit.optionUnitId;
424
+ // Prefer an ADULT-coded primary; the stepper routes per-option
425
+ // qty through the same unit so seat math stays consistent.
426
+ const isAdult = (unit.unitCode ?? "").toUpperCase() === "ADULT";
427
+ const existing = optionGroups.get(key);
428
+ if (existing) {
429
+ existing.units.push(unit);
430
+ if (isAdult)
431
+ existing.primaryUnitId = unit.optionUnitId;
432
+ }
433
+ else {
434
+ optionGroups.set(key, {
435
+ primaryUnitId: unit.optionUnitId,
436
+ // Strip the trailing " - Adult" / " - Child" suffix the
437
+ // upstream stepper appends when an option has multiple units.
438
+ optionName: stripUnitSuffix(unit.unitName),
439
+ units: [unit],
440
+ });
441
+ }
442
+ }
443
+ return Array.from(optionGroups.values())
444
+ .filter((group) => {
445
+ const totalQty = group.units.reduce((sum, u) => sum + (rooms.quantities[u.optionUnitId] ?? 0), 0);
446
+ return totalQty > 0;
447
+ })
448
+ .map((group) => {
449
+ const totalQty = group.units.reduce((sum, u) => sum + (rooms.quantities[u.optionUnitId] ?? 0), 0);
450
+ const occupancyMax = Math.max(1, ...group.units.map((u) => u.occupancyMax ?? 1));
451
+ const seats = totalQty * occupancyMax;
452
+ const optionUnitIds = new Set(group.units.map((u) => u.optionUnitId));
453
+ const assigned = travelers.travelers.filter((traveler) => traveler.roomUnitId && optionUnitIds.has(traveler.roomUnitId)).length;
279
454
  return {
280
- unitId: unit.optionUnitId,
281
- unitName: unit.unitName,
455
+ unitId: group.primaryUnitId,
456
+ unitName: group.optionName,
282
457
  remainingCapacity: Math.max(0, seats - assigned),
283
458
  };
284
459
  });
285
460
  }, [roomUnits, slotUnitAvailability.data, rooms.quantities, travelers.travelers]);
461
+ // Per-option breakdown of all configured units, with the
462
+ // attributes the TravelersSection's dynamic category buttons need
463
+ // (unitCode/min-max/unitType). Mirrors the grouping logic in
464
+ // `roomUnitOptions` but exposes every unit instead of collapsing
465
+ // to one primary.
466
+ const roomGroups = React.useMemo(() => {
467
+ if (roomUnits.length === 0)
468
+ return [];
469
+ const groups = new Map();
470
+ for (const u of roomUnits) {
471
+ if (!u.optionId)
472
+ continue;
473
+ const groupKey = u.optionId;
474
+ const isAdultCoded = (u.unitCode ?? "").toUpperCase() === "ADULT";
475
+ const unit = {
476
+ unitId: u.optionUnitId,
477
+ // Strip the "Option name - " prefix the stepper applies when
478
+ // an option has multiple units; the per-unit label is enough
479
+ // for a category button.
480
+ unitName: stripOptionPrefix(u.unitName),
481
+ unitCode: u.unitCode ?? null,
482
+ minAge: u.minAge ?? null,
483
+ maxAge: u.maxAge ?? null,
484
+ unitType: (u.unitType ?? null),
485
+ };
486
+ const existing = groups.get(groupKey);
487
+ if (existing) {
488
+ existing.units.push(unit);
489
+ if (isAdultCoded)
490
+ existing.primaryUnitId = u.optionUnitId;
491
+ }
492
+ else {
493
+ groups.set(groupKey, {
494
+ optionId: groupKey,
495
+ optionName: stripUnitSuffix(u.unitName),
496
+ primaryUnitId: u.optionUnitId,
497
+ units: [unit],
498
+ });
499
+ }
500
+ }
501
+ return Array.from(groups.values());
502
+ }, [roomUnits]);
503
+ // Apply the same age-banded redistribution we use at submit so the
504
+ // live price preview matches what the operator will actually be
505
+ // billed. Without this, the breakdown sees only the option's primary
506
+ // (Adult) unit qty from the stepper, missing the per-traveler split
507
+ // between adult / child / infant tiers.
508
+ const displayQuantities = React.useMemo(() => redistributeByAge(rooms.quantities, travelers.travelers, roomUnits).quantities, [rooms.quantities, travelers.travelers, roomUnits]);
286
509
  // Currency placeholder — used for voucher + payment schedule display.
287
510
  // Consumers hooking in real product data should override this by wrapping
288
511
  // the component or swapping in their own currency-aware hook.
@@ -291,7 +514,34 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
291
514
  const pricingTotalAmountCents = pricing?.confirmedAmountCents ?? undefined;
292
515
  const roomUnitLabels = React.useMemo(() => Object.fromEntries(roomUnits.map((unit) => [unit.optionUnitId, unit.unitName])), [roomUnits]);
293
516
  const createBookingMutation = useBookingCreateMutation();
294
- const statusMutation = useBookingStatusByIdMutation();
517
+ // Resolve the billing person/org once at the dialog level so we can
518
+ // snapshot their contact details into the booking row at create time.
519
+ // The booking row's `contact_*` columns are the source of truth for
520
+ // billing on the detail page — the linked CRM record can change (or
521
+ // be deleted) later without retroactively rewriting history.
522
+ const billingPersonRecord = usePerson((person.billTo ?? "person") === "person" ? (person.personId ?? undefined) : undefined, { enabled: (person.billTo ?? "person") === "person" && Boolean(person.personId) }).data;
523
+ const billingOrganizationRecord = useOrganization(person.billTo === "organization" ? (person.organizationId ?? undefined) : undefined, { enabled: person.billTo === "organization" && Boolean(person.organizationId) }).data;
524
+ // Primary address for whichever billing record was picked. Filter by
525
+ // `entityType` + `entityId` to keep the response small; the first
526
+ // address with `isPrimary` wins (the server returns at most one).
527
+ const billingPrimaryAddressKind = (person.billTo ?? "person") === "person" && person.personId
528
+ ? "person"
529
+ : person.billTo === "organization" && person.organizationId
530
+ ? "organization"
531
+ : null;
532
+ const billingPrimaryAddressEntityId = billingPrimaryAddressKind === "person"
533
+ ? (person.personId ?? undefined)
534
+ : billingPrimaryAddressKind === "organization"
535
+ ? (person.organizationId ?? undefined)
536
+ : undefined;
537
+ const billingAddressQuery = useAddresses({
538
+ entityType: billingPrimaryAddressKind ?? undefined,
539
+ entityId: billingPrimaryAddressEntityId,
540
+ isPrimary: true,
541
+ limit: 1,
542
+ enabled: Boolean(billingPrimaryAddressKind && billingPrimaryAddressEntityId),
543
+ });
544
+ const billingPrimaryAddress = billingAddressQuery.data?.data?.[0] ?? null;
295
545
  const handleSubmit = async () => {
296
546
  setError(null);
297
547
  if (!product.productId) {
@@ -328,13 +578,18 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
328
578
  return;
329
579
  }
330
580
  const paymentSchedules = paymentScheduleToRows(paymentSchedule, pricingCurrency, confirmedSellAmountCents);
331
- const itemLines = itemLinesToRows(rooms.quantities, roomUnits.length > 0
581
+ // Age-banded redistribution: turn the operator's per-option
582
+ // quantities + raw traveler list into per-unit quantities + each
583
+ // traveler's matching unit assignment, driven by DOB.
584
+ const submitUnits = roomUnits.length > 0
332
585
  ? roomUnits
333
586
  : (slotUnitAvailability.data?.data ?? []).map((unit) => ({
334
587
  ...unit,
335
588
  optionId: product.optionId,
336
- })), pricing);
337
- const travelerRows = travelersToRows(travelers);
589
+ }));
590
+ const redistributed = redistributeByAge(rooms.quantities, travelers.travelers, submitUnits);
591
+ const itemLines = itemLinesToRows(redistributed.quantities, submitUnits, pricing);
592
+ const travelerRows = travelersToRows({ travelers: redistributed.travelers });
338
593
  const voucherRedemption = voucher.picked && voucher.picked.remainingAmountCents != null
339
594
  ? {
340
595
  voucherId: voucher.picked.id,
@@ -356,6 +611,51 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
356
611
  ? { action: "join", groupId: sharedRoom.groupId, role: "shared" }
357
612
  : undefined
358
613
  : undefined;
614
+ // Smart-default status from payment state — any payment marked
615
+ // "Already paid" implies the booking is effectively confirmed,
616
+ // otherwise it lands in `awaiting_payment` so the operator can
617
+ // dispatch a payment link. Override available via the explicit
618
+ // "Create as draft" checkbox. The server commits this status in
619
+ // the create transaction and emits `booking.confirmed`
620
+ // post-commit when applicable — no second roundtrip.
621
+ const initialStatus = createAsDraft
622
+ ? undefined
623
+ : hasAnyPaidPayment(paymentSchedule)
624
+ ? "confirmed"
625
+ : "awaiting_payment";
626
+ // Build the billing-contact snapshot from whichever CRM record
627
+ // the operator picked, plus the primary identity address when
628
+ // present. Falls back to nulls when a record is missing — the
629
+ // server stores nulls and the detail page hydrates from the live
630
+ // CRM record at read time.
631
+ const addressSnapshot = billingPrimaryAddress
632
+ ? {
633
+ contactAddressLine1: billingPrimaryAddress.line1,
634
+ contactCity: billingPrimaryAddress.city,
635
+ contactRegion: billingPrimaryAddress.region,
636
+ contactPostalCode: billingPrimaryAddress.postalCode,
637
+ contactCountry: billingPrimaryAddress.country,
638
+ }
639
+ : {};
640
+ const contactSnapshot = billingPersonRecord
641
+ ? {
642
+ contactFirstName: billingPersonRecord.firstName,
643
+ contactLastName: billingPersonRecord.lastName,
644
+ contactEmail: billingPersonRecord.email,
645
+ contactPhone: billingPersonRecord.phone,
646
+ contactPreferredLanguage: billingPersonRecord.preferredLanguage,
647
+ ...addressSnapshot,
648
+ }
649
+ : billingOrganizationRecord
650
+ ? {
651
+ contactFirstName: billingOrganizationRecord.name,
652
+ contactLastName: null,
653
+ contactEmail: null,
654
+ contactPhone: null,
655
+ contactPreferredLanguage: billingOrganizationRecord.preferredLanguage,
656
+ ...addressSnapshot,
657
+ }
658
+ : {};
359
659
  const { booking } = await createBookingMutation.mutateAsync({
360
660
  productId: product.productId,
361
661
  bookingNumber,
@@ -376,98 +676,82 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
376
676
  contractDocument: generateContractDocument,
377
677
  invoiceDocument: generateInvoiceDocument,
378
678
  },
679
+ initialStatus,
680
+ // Suppression only matters when transitioning to `confirmed` —
681
+ // `awaiting_payment` doesn't trigger the auto-dispatch
682
+ // subscriber today.
683
+ suppressNotifications: initialStatus === "confirmed" && !notifyTraveler ? true : undefined,
684
+ ...contactSnapshot,
379
685
  });
380
- // Optional post-create confirm. If the app has autoConfirmAndDispatch
381
- // wired on the notifications module, the status transition triggers
382
- // the doc bundle + traveler email subscriber. A failed status change
383
- // doesn't roll back the booking — it exists, operator can confirm
384
- // manually later.
385
- let finalBooking = booking;
386
- if (confirmAfterCreate) {
387
- try {
388
- finalBooking = await statusMutation.mutateAsync({
389
- bookingId: booking.id,
390
- currentStatus: booking.status,
391
- status: "confirmed",
392
- });
393
- }
394
- catch (statusErr) {
395
- setError(statusErr instanceof Error
396
- ? formatMessage(messages.bookingCreateDialog.validation.confirmFailedPrefix, {
397
- message: statusErr.message,
398
- })
399
- : messages.bookingCreateDialog.validation.confirmFailed);
400
- onCreated?.(booking);
401
- return;
402
- }
403
- }
404
- onCreated?.(finalBooking);
686
+ onCreated?.(booking);
405
687
  }
406
688
  catch (err) {
407
689
  setError(err instanceof Error ? err.message : messages.bookingCreateDialog.validation.createFailed);
408
690
  }
409
691
  };
410
- const isSubmitting = createBookingMutation.isPending || statusMutation.isPending;
411
- return (_jsxs(_Fragment, { children: [_jsxs(DialogBody, { className: "grid gap-4", children: [_jsx(ProductPickerSection, { value: product, onChange: setProduct, enabled: enabled, lockProduct: Boolean(defaultProductId), labels: {
412
- optionNone: messages.bookingCreateDialog.labels.noSpecificOption,
413
- }, showOptionPicker: false }), product.productId ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { children: messages.bookingCreateDialog.fields.departure }), _jsxs(Select, { value: slotId ?? "__none__", onValueChange: (v) => setSelectedSlot(v === "__none__" ? null : (v ?? null)), children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, { placeholder: messages.bookingCreateDialog.placeholders.departure }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "__none__", children: messages.bookingCreateDialog.placeholders.departureNone }), slots.length === 0 ? (_jsx(SelectItem, { value: "__empty__", disabled: true, children: messages.bookingCreateDialog.placeholders.departureEmpty })) : (slots.map((slot) => (_jsx(SelectItem, { value: slot.id, children: formatSlotLabel(slot) }, slot.id))))] })] })] })) : null, product.productId ? (_jsx(RoomsStepperSection, { value: rooms, onChange: setRooms, productId: product.productId, slotId: slotId ?? undefined, optionId: product.optionId, enabled: enabled, onUnitsChange: handleRoomUnitsChange, labels: {
414
- heading: messages.bookingCreateDialog.labels.roomsHeading,
415
- noOption: messages.bookingCreateDialog.labels.roomsNoOption,
416
- noSlot: messages.bookingCreateDialog.labels.roomsNoSlot,
417
- noUnits: messages.bookingCreateDialog.labels.roomsNoUnits,
418
- remaining: messages.bookingCreateDialog.labels.roomsRemaining,
419
- unlimited: messages.bookingCreateDialog.labels.roomsUnlimited,
420
- } })) : null, _jsx(PersonPickerSection, { value: person, onChange: setPerson, enabled: enabled, labels: {
421
- createNewPerson: messages.bookingCreateDialog.labels.createNewPerson,
422
- selectExistingPerson: messages.bookingCreateDialog.labels.selectExistingPerson,
423
- organizationNone: messages.bookingCreateDialog.labels.organizationNone,
424
- } }), _jsx(SharedRoomSection, { value: sharedRoom, onChange: setSharedRoom, productId: product.productId || undefined, enabled: enabled, labels: {
425
- toggle: messages.bookingCreateDialog.labels.sharedRoomToggle,
426
- createMode: messages.bookingCreateDialog.labels.sharedRoomCreateMode,
427
- joinMode: messages.bookingCreateDialog.labels.sharedRoomJoinMode,
428
- selectPlaceholder: messages.bookingCreateDialog.labels.sharedRoomSelectPlaceholder,
429
- noGroups: messages.bookingCreateDialog.labels.sharedRoomNoGroups,
430
- createHint: messages.bookingCreateDialog.labels.sharedRoomCreateHint,
431
- remove: messages.bookingCreateDialog.labels.sharedRoomRemove,
432
- } }), product.productId ? (_jsx(TravelersSection, { value: travelers, onChange: setTravelers, roomUnits: roomUnitOptions.length > 0 ? roomUnitOptions : undefined, billingPersonId: (person.billTo ?? "person") === "person" ? person.personId : null, labels: {
433
- heading: messages.bookingCreateDialog.labels.travelerHeading,
434
- addTraveler: messages.bookingCreateDialog.labels.addTraveler,
435
- person: messages.bookingCreateDialog.labels.travelerPerson,
436
- personSearchPlaceholder: messages.bookingCreateDialog.labels.travelerPersonSearchPlaceholder,
437
- personEmpty: messages.bookingCreateDialog.labels.travelerPersonEmpty,
438
- createNewPerson: messages.bookingCreateDialog.labels.createNewPerson,
439
- createPersonSheetTitle: messages.bookingCreateDialog.labels.createPersonSheetTitle,
440
- addBillingPerson: messages.bookingCreateDialog.labels.addBillingPersonAsTraveler,
441
- role: messages.bookingCreateDialog.labels.travelerRole,
442
- roleLead: messages.bookingCreateDialog.labels.travelerLead,
443
- roleAdult: messages.bookingCreateDialog.labels.travelerAdult,
444
- roleChild: messages.bookingCreateDialog.labels.travelerChild,
445
- roleInfant: messages.bookingCreateDialog.labels.travelerInfant,
446
- room: messages.bookingCreateDialog.labels.travelerRoom,
447
- noRoom: messages.bookingCreateDialog.labels.travelerNoRoom,
448
- remove: messages.bookingCreateDialog.labels.travelerRemove,
449
- empty: messages.bookingCreateDialog.labels.travelerEmpty,
450
- } })) : null, product.productId ? (_jsx(PriceBreakdownSection, { productId: product.productId, optionId: product.optionId, unitQuantities: rooms.quantities, unitLabels: roomUnitLabels, labels: {
451
- heading: messages.bookingCreateDialog.labels.breakdownHeading,
452
- total: messages.bookingCreateDialog.labels.breakdownTotal,
453
- onRequest: messages.bookingCreateDialog.labels.breakdownOnRequest,
454
- groupRate: messages.bookingCreateDialog.labels.breakdownGroupRate,
455
- empty: messages.bookingCreateDialog.labels.breakdownEmpty,
456
- noPricing: messages.bookingCreateDialog.labels.breakdownNoPricing,
457
- confirmedTotal: messages.bookingCreateDialog.labels.breakdownConfirmedTotal,
458
- manualTotal: messages.bookingCreateDialog.labels.breakdownManualTotal,
459
- useCatalogTotal: messages.bookingCreateDialog.labels.breakdownUseCatalogTotal,
460
- overrideReason: messages.bookingCreateDialog.labels.breakdownOverrideReason,
461
- overrideReasonPlaceholder: messages.bookingCreateDialog.labels.breakdownOverrideReasonPlaceholder,
462
- overrideReasonRequired: messages.bookingCreateDialog.labels.breakdownOverrideReasonRequired,
463
- }, onChange: setPricing })) : null, _jsx(VoucherPickerSection, { value: voucher, onChange: setVoucher, currency: currency, labels: {
692
+ const isSubmitting = createBookingMutation.isPending;
693
+ return (_jsxs("div", { className: "grid min-h-0 flex-1 gap-6 lg:grid-cols-12", children: [_jsxs("div", { className: "flex min-h-0 min-w-0 flex-col lg:col-span-8", children: [_jsxs("div", { className: "flex flex-1 flex-col gap-4 overflow-y-auto px-1 pb-2", children: [_jsx(ProductPickerSection, { value: product, onChange: setProduct, enabled: enabled, lockProduct: Boolean(defaultProductId), labels: {
694
+ optionNone: messages.bookingCreateDialog.labels.noSpecificOption,
695
+ }, showOptionPicker: false }), product.productId ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { children: messages.bookingCreateDialog.fields.departure }), _jsx(AsyncCombobox, { value: slotId, onChange: (v) => setSelectedSlot(v), items: slots, selectedItem: slots.find((s) => s.id === slotId) ?? null, getKey: (slot) => slot.id, getLabel: (slot) => formatSlotLabel(slot), placeholder: messages.bookingCreateDialog.placeholders.departure, emptyText: messages.bookingCreateDialog.placeholders.departureEmpty, triggerClassName: "w-full", clearable: true })] })) : null, product.productId && slotId ? (_jsx(OptionUnitsStepperSection, { value: rooms, onChange: setRooms, productId: product.productId, slotId: slotId, optionId: product.optionId, enabled: enabled, onUnitsChange: handleRoomUnitsChange, labels: {
696
+ heading: messages.bookingCreateDialog.labels.roomsHeading,
697
+ noOption: messages.bookingCreateDialog.labels.roomsNoOption,
698
+ noSlot: messages.bookingCreateDialog.labels.roomsNoSlot,
699
+ noUnits: messages.bookingCreateDialog.labels.roomsNoUnits,
700
+ remaining: messages.bookingCreateDialog.labels.roomsRemaining,
701
+ unlimited: messages.bookingCreateDialog.labels.roomsUnlimited,
702
+ } })) : null, product.productId && slotId ? (_jsxs("div", { className: "flex flex-col gap-2 rounded-md border p-3", children: [_jsx(Label, { children: messages.bookingCreateDialog.labels.billingHeading }), _jsx(PersonPickerSection, { value: person, onChange: setPerson, enabled: enabled, labels: {
703
+ createNewPerson: messages.bookingCreateDialog.labels.createNewPerson,
704
+ selectExistingPerson: messages.bookingCreateDialog.labels.selectExistingPerson,
705
+ organizationNone: messages.bookingCreateDialog.labels.organizationNone,
706
+ } })] })) : null, product.productId && slotId ? (_jsx(SharedRoomSection, { value: sharedRoom, onChange: setSharedRoom, productId: product.productId || undefined, enabled: enabled, labels: {
707
+ toggle: messages.bookingCreateDialog.labels.sharedRoomToggle,
708
+ createMode: messages.bookingCreateDialog.labels.sharedRoomCreateMode,
709
+ joinMode: messages.bookingCreateDialog.labels.sharedRoomJoinMode,
710
+ selectPlaceholder: messages.bookingCreateDialog.labels.sharedRoomSelectPlaceholder,
711
+ noGroups: messages.bookingCreateDialog.labels.sharedRoomNoGroups,
712
+ createHint: messages.bookingCreateDialog.labels.sharedRoomCreateHint,
713
+ remove: messages.bookingCreateDialog.labels.sharedRoomRemove,
714
+ } })) : null, product.productId && slotId ? (_jsx(TravelersSection, { value: travelers, onChange: setTravelers, roomUnits: roomUnitOptions.length > 0 ? roomUnitOptions : undefined, roomGroups: roomGroups.length > 0 ? roomGroups : undefined, billingPersonId: (person.billTo ?? "person") === "person" ? person.personId : null, labels: {
715
+ heading: messages.bookingCreateDialog.labels.travelerHeading,
716
+ addTraveler: messages.bookingCreateDialog.labels.addTraveler,
717
+ person: messages.bookingCreateDialog.labels.travelerPerson,
718
+ personSearchPlaceholder: messages.bookingCreateDialog.labels.travelerPersonSearchPlaceholder,
719
+ personEmpty: messages.bookingCreateDialog.labels.travelerPersonEmpty,
720
+ createNewPerson: messages.bookingCreateDialog.labels.createNewPerson,
721
+ createPersonSheetTitle: messages.bookingCreateDialog.labels.createPersonSheetTitle,
722
+ addBillingPerson: messages.bookingCreateDialog.labels.addBillingPersonAsTraveler,
723
+ role: messages.bookingCreateDialog.labels.travelerRole,
724
+ roleLead: messages.bookingCreateDialog.labels.travelerLead,
725
+ roleAdult: messages.bookingCreateDialog.labels.travelerAdult,
726
+ roleChild: messages.bookingCreateDialog.labels.travelerChild,
727
+ roleInfant: messages.bookingCreateDialog.labels.travelerInfant,
728
+ room: messages.bookingCreateDialog.labels.travelerRoom,
729
+ noRoom: messages.bookingCreateDialog.labels.travelerNoRoom,
730
+ remove: messages.bookingCreateDialog.labels.travelerRemove,
731
+ empty: messages.bookingCreateDialog.labels.travelerEmpty,
732
+ } })) : null, product.productId && slotId ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: messages.bookingCreateDialog.fields.internalNotes }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), placeholder: messages.bookingCreateDialog.placeholders.internalNotes })] })) : null, product.productId && slotId ? (_jsxs("div", { className: "flex flex-col gap-3 rounded-md border p-3", children: [_jsx(Label, { children: messages.bookingCreateDialog.labels.documentGenerationHeading }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "new-booking-generate-contract-document", checked: generateContractDocument, onCheckedChange: (value) => setGenerateContractDocument(value === true) }), _jsx(Label, { htmlFor: "new-booking-generate-contract-document", className: "cursor-pointer", children: messages.bookingCreateDialog.labels.generateContractDocument })] }), _jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "new-booking-generate-invoice-document", checked: generateInvoiceDocument, onCheckedChange: (value) => setGenerateInvoiceDocument(value === true) }), _jsx(Label, { htmlFor: "new-booking-generate-invoice-document", className: "cursor-pointer", children: messages.bookingCreateDialog.labels.generateInvoiceDocument })] }), _jsx("div", { className: "flex flex-col gap-2 border-t pt-2 text-sm", children: (() => {
733
+ const wouldBeConfirmed = hasAnyPaidPayment(paymentSchedule);
734
+ const derivedStatusLabel = createAsDraft
735
+ ? messages.common.bookingStatusLabels.draft
736
+ : wouldBeConfirmed
737
+ ? messages.common.bookingStatusLabels.confirmed
738
+ : messages.common.bookingStatusLabels.awaiting_payment;
739
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx(Checkbox, { id: "new-booking-create-as-draft", checked: createAsDraft, onCheckedChange: (v) => setCreateAsDraft(v === true), className: "mt-0.5" }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { htmlFor: "new-booking-create-as-draft", className: "cursor-pointer text-sm", children: messages.bookingCreateDialog.fields.createAsDraft }), _jsx("p", { className: "text-xs text-muted-foreground", children: formatMessage(messages.bookingCreateDialog.fields.createAsDraftHint, { status: derivedStatusLabel }) })] })] }), !createAsDraft && wouldBeConfirmed ? (_jsxs("div", { className: "flex items-start gap-2 pl-6", children: [_jsx(Checkbox, { id: "new-booking-notify-traveler", checked: notifyTraveler, onCheckedChange: (v) => setNotifyTraveler(v === true), className: "mt-0.5" }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { htmlFor: "new-booking-notify-traveler", className: "cursor-pointer text-sm", children: messages.bookingCreateDialog.fields.notifyTraveler }), _jsx("p", { className: "text-xs text-muted-foreground", children: messages.bookingCreateDialog.fields.notifyTravelerHint })] })] })) : null] }));
740
+ })() })] })] })) : null] }), error ? (_jsx("div", { role: "alert", className: "mt-3 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive", children: error })) : null, _jsxs("div", { className: "mt-4 flex items-center justify-end gap-2 border-t px-1 pt-3", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, disabled: isSubmitting, children: messages.common.cancel }), _jsxs(Button, { type: "button", size: "sm", onClick: handleSubmit, disabled: isSubmitting || !product.productId, children: [isSubmitting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), createAsDraft
741
+ ? messages.bookingCreateDialog.actions.createDraftBooking
742
+ : hasAnyPaidPayment(paymentSchedule)
743
+ ? messages.bookingCreateDialog.actions.createConfirmedBooking
744
+ : messages.bookingCreateDialog.actions.createAwaitingPaymentBooking] })] })] }), _jsxs("div", { className: "flex flex-col gap-4 lg:col-span-4", children: [_jsx(BookingPreviewCard, { productId: product.productId, optionId: product.optionId, slotId: slotId, slotLabel: (() => {
745
+ const slot = slots.find((s) => s.id === slotId);
746
+ return slot ? formatSlotLabel(slot) : null;
747
+ })(), unitQuantities: displayQuantities, unitLabels: roomUnitLabels, travelers: travelers.travelers, messages: messages, onPricingChange: setPricing }), product.productId && slotId ? (_jsx(VoucherPickerSection, { value: voucher, onChange: setVoucher, currency: currency, labels: {
464
748
  heading: messages.bookingCreateDialog.labels.voucherHeading,
465
749
  codePlaceholder: messages.bookingCreateDialog.labels.voucherCodePlaceholder,
466
750
  apply: messages.bookingCreateDialog.labels.voucherApply,
467
751
  clear: messages.bookingCreateDialog.labels.voucherClear,
468
752
  remainingLabel: messages.bookingCreateDialog.labels.voucherRemainingLabel,
469
753
  invalidLabel: messages.bookingCreateDialog.labels.voucherInvalidLabel,
470
- } }), _jsx(PaymentScheduleSection, { value: paymentSchedule, onChange: setPaymentSchedule, currency: pricingCurrency, totalAmountCents: pricingTotalAmountCents, labels: {
754
+ } })) : null, product.productId && slotId ? (_jsx(PaymentScheduleSection, { value: paymentSchedule, onChange: setPaymentSchedule, currency: pricingCurrency, totalAmountCents: pricingTotalAmountCents, labels: {
471
755
  heading: messages.bookingCreateDialog.labels.paymentHeading,
472
756
  modeUnpaid: messages.bookingCreateDialog.labels.paymentModeUnpaid,
473
757
  modeFull: messages.bookingCreateDialog.labels.paymentModeFull,
@@ -486,5 +770,82 @@ export function BookingCreateForm({ onCreated, defaultProductId, enabled = true,
486
770
  paymentDate: messages.bookingCreateDialog.labels.paymentDate,
487
771
  paymentMethod: messages.bookingCreateDialog.labels.paymentMethod,
488
772
  paymentReference: messages.bookingCreateDialog.labels.paymentReference,
489
- } }), _jsxs("div", { className: "flex flex-col gap-2 rounded-md border p-3", children: [_jsx(Label, { children: messages.bookingCreateDialog.labels.documentGenerationHeading }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "new-booking-generate-contract-document", checked: generateContractDocument, onCheckedChange: (value) => setGenerateContractDocument(value === true) }), _jsx(Label, { htmlFor: "new-booking-generate-contract-document", className: "cursor-pointer", children: messages.bookingCreateDialog.labels.generateContractDocument })] }), _jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "new-booking-generate-invoice-document", checked: generateInvoiceDocument, onCheckedChange: (value) => setGenerateInvoiceDocument(value === true) }), _jsx(Label, { htmlFor: "new-booking-generate-invoice-document", className: "cursor-pointer", children: messages.bookingCreateDialog.labels.generateInvoiceDocument })] })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: messages.bookingCreateDialog.fields.internalNotes }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), placeholder: messages.bookingCreateDialog.placeholders.internalNotes })] }), _jsxs("div", { className: "flex items-start gap-2 rounded-md border p-3", children: [_jsx(Checkbox, { id: "new-booking-confirm-after-create", checked: confirmAfterCreate, onCheckedChange: (v) => setConfirmAfterCreate(v === true), className: "mt-0.5" }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { htmlFor: "new-booking-confirm-after-create", className: "cursor-pointer text-sm", children: messages.bookingCreateDialog.fields.confirmAfterCreate }), _jsx("p", { className: "text-xs text-muted-foreground", children: messages.bookingCreateDialog.fields.confirmAfterCreateHint })] })] }), error && _jsx("p", { className: "text-xs text-destructive", children: error })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, disabled: isSubmitting, children: messages.common.cancel }), _jsxs(Button, { type: "button", size: "sm", onClick: handleSubmit, disabled: isSubmitting || !product.productId, children: [isSubmitting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), messages.bookingCreateDialog.actions.createDraftBooking] })] })] }));
773
+ } })) : null] })] }));
774
+ }
775
+ /**
776
+ * Right-rail live preview for the booking-create dialog. Mirrors the
777
+ * operator's in-progress selections — product (with thumbnail),
778
+ * departure, options + quantities, travelers, and the current
779
+ * confirmed price — so the operator gets a "what am I about to book"
780
+ * summary without scrolling back through the form.
781
+ */
782
+ function BookingPreviewCard({ productId, optionId, slotId, slotLabel, unitQuantities, unitLabels, travelers, messages, onPricingChange, }) {
783
+ const { formatCurrency, formatNumber } = useBookingsUiI18nOrDefault();
784
+ const productQuery = useProduct(productId || undefined, { enabled: Boolean(productId) });
785
+ const mediaQuery = useProductMedia(productId, { limit: 1, enabled: Boolean(productId) });
786
+ const product = productQuery.data ?? null;
787
+ const cover = (mediaQuery.data?.data ?? []).find((m) => m.isCover) ?? mediaQuery.data?.data?.[0];
788
+ const labels = messages.bookingCreateDialog.labels;
789
+ // Mirror the breakdown locally so we can drive the tax preview hook
790
+ // off the same `confirmedAmountCents` the parent receives via
791
+ // onPricingChange. Manual overrides flow through the same field, so
792
+ // the tax line follows whatever the operator decides to charge.
793
+ const [breakdown, setBreakdown] = React.useState(null);
794
+ const handlePricingChange = React.useCallback((value) => {
795
+ setBreakdown(value);
796
+ onPricingChange(value);
797
+ }, [onPricingChange]);
798
+ const taxSubtotalCents = breakdown?.confirmedAmountCents ?? breakdown?.catalogAmountCents ?? 0;
799
+ const taxCurrency = breakdown?.currency ?? "EUR";
800
+ const taxPreview = useBookingTaxPreview({
801
+ productId,
802
+ subtotalCents: taxSubtotalCents,
803
+ currency: taxCurrency,
804
+ enabled: Boolean(productId) && taxSubtotalCents > 0,
805
+ });
806
+ const previewMessages = {
807
+ heading: labels.previewHeading,
808
+ empty: labels.previewEmpty,
809
+ product: labels.previewProduct,
810
+ departure: labels.previewDeparture,
811
+ travelers: labels.previewTravelers,
812
+ loading: labels.previewLoading,
813
+ travelerUnnamed: labels.previewTravelerUnnamed,
814
+ };
815
+ const showPriceBreakdown = Boolean(productId && slotId);
816
+ const hasContent = Boolean(productId) || slotLabel != null || travelers.length > 0 || showPriceBreakdown;
817
+ return (_jsx("aside", { children: _jsxs("div", { className: "flex flex-col gap-4 rounded-md border bg-muted/10 p-4", children: [_jsx("div", { className: "text-[11px] font-medium uppercase tracking-wider text-muted-foreground", children: previewMessages.heading }), !hasContent ? (_jsx("p", { className: "text-xs text-muted-foreground", children: previewMessages.empty })) : null, productId ? (_jsxs("div", { className: "flex gap-3", children: [cover?.url ? (_jsx("img", { src: cover.url, alt: product?.name ?? "", className: "h-14 w-14 shrink-0 rounded-md object-cover ring-1 ring-border", loading: "lazy" })) : (_jsx("div", { className: "flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground", children: _jsx(ImageIcon, { className: "h-5 w-5" }) })), _jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "text-[11px] font-medium uppercase tracking-wider text-muted-foreground", children: previewMessages.product }), _jsx("span", { className: "truncate text-sm font-medium", children: product?.name ?? previewMessages.loading })] })] })) : null, slotLabel ? (_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "text-[11px] font-medium uppercase tracking-wider text-muted-foreground", children: previewMessages.departure }), _jsx("span", { className: "text-sm", children: slotLabel })] })) : null, travelers.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[11px] font-medium uppercase tracking-wider text-muted-foreground", children: previewMessages.travelers }), _jsx("ul", { className: "flex flex-col gap-0.5 text-sm", children: travelers.map((traveler, idx) => {
818
+ const name = [traveler.firstName, traveler.lastName]
819
+ .filter((part) => part.trim().length > 0)
820
+ .join(" ")
821
+ .trim();
822
+ return (_jsxs("li", { className: "flex items-center justify-between gap-3", children: [_jsx("span", { className: "truncate text-muted-foreground", children: name || previewMessages.travelerUnnamed }), _jsx("span", { className: "shrink-0 text-xs uppercase tracking-wider text-muted-foreground", children: traveler.role })] }, traveler.personId ?? `traveler-${idx}`));
823
+ }) })] })) : null, showPriceBreakdown ? (_jsxs("div", { className: "border-t pt-3", children: [_jsx(PriceBreakdownSection, { flat: true, productId: productId, optionId: optionId, unitQuantities: unitQuantities, unitLabels: unitLabels, labels: {
824
+ heading: labels.breakdownHeading,
825
+ total: labels.breakdownTotal,
826
+ onRequest: labels.breakdownOnRequest,
827
+ groupRate: labels.breakdownGroupRate,
828
+ empty: labels.breakdownEmpty,
829
+ noPricing: labels.breakdownNoPricing,
830
+ confirmedTotal: labels.breakdownConfirmedTotal,
831
+ manualTotal: labels.breakdownManualTotal,
832
+ useCatalogTotal: labels.breakdownUseCatalogTotal,
833
+ overrideReason: labels.breakdownOverrideReason,
834
+ overrideReasonPlaceholder: labels.breakdownOverrideReasonPlaceholder,
835
+ overrideReasonRequired: labels.breakdownOverrideReasonRequired,
836
+ }, onChange: handlePricingChange }), taxPreview.data?.data && taxPreview.data.data.taxCents > 0 ? (_jsx(TaxPreviewRows, { snapshot: taxPreview.data.data, labels: {
837
+ subtotal: labels.breakdownSubtotal,
838
+ tax: labels.breakdownTax,
839
+ taxIncluded: labels.breakdownTaxIncluded,
840
+ total: labels.breakdownTotal,
841
+ }, formatAmount: (cents, currency) => formatCurrency(cents / 100, currency), formatRate: (basisPoints) => formatNumber(basisPoints / 100, {
842
+ maximumFractionDigits: 2,
843
+ minimumFractionDigits: 0,
844
+ }) })) : null] })) : null] }) }));
845
+ }
846
+ function TaxPreviewRows({ snapshot, labels, formatAmount, formatRate, }) {
847
+ const inclusive = snapshot.taxRate?.priceMode === "inclusive";
848
+ const ratePart = snapshot.taxRate ? ` (${formatRate(snapshot.taxRate.rateBasisPoints)}%)` : "";
849
+ const inclTag = inclusive ? ` · ${labels.taxIncluded}` : "";
850
+ return (_jsxs("div", { className: "mt-3 flex flex-col gap-1 border-t pt-3 text-sm", children: [_jsxs("div", { className: "flex items-center justify-between text-muted-foreground", children: [_jsx("span", { children: labels.subtotal }), _jsx("span", { children: formatAmount(snapshot.subtotalCents, snapshot.currency) })] }), _jsxs("div", { className: "flex items-center justify-between text-muted-foreground", children: [_jsxs("span", { children: [snapshot.taxRate?.label ?? labels.tax, ratePart, inclTag] }), _jsx("span", { children: formatAmount(snapshot.taxCents, snapshot.currency) })] }), _jsxs("div", { className: "flex items-center justify-between font-medium", children: [_jsx("span", { children: labels.total }), _jsx("span", { children: formatAmount(snapshot.totalCents, snapshot.currency) })] })] }));
490
851
  }