@voyantjs/bookings-ui 0.52.1 → 0.52.3
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.
- package/dist/components/booking-billing-dialog.d.ts +16 -0
- package/dist/components/booking-billing-dialog.d.ts.map +1 -0
- package/dist/components/booking-billing-dialog.js +90 -0
- package/dist/components/booking-create-dialog.d.ts.map +1 -1
- package/dist/components/booking-create-dialog.js +512 -151
- package/dist/components/booking-create-page.js +1 -1
- package/dist/components/booking-document-dialog.d.ts.map +1 -1
- package/dist/components/booking-document-dialog.js +16 -14
- package/dist/components/booking-guarantee-dialog.d.ts.map +1 -1
- package/dist/components/booking-guarantee-dialog.js +10 -8
- package/dist/components/booking-item-dialog.d.ts.map +1 -1
- package/dist/components/booking-item-dialog.js +18 -9
- package/dist/components/booking-item-travelers.d.ts.map +1 -1
- package/dist/components/booking-item-travelers.js +9 -7
- package/dist/components/booking-payment-schedule-dialog.d.ts.map +1 -1
- package/dist/components/booking-payment-schedule-dialog.js +10 -8
- package/dist/components/booking-payment-schedule-list.d.ts.map +1 -1
- package/dist/components/booking-payment-schedule-list.js +32 -3
- package/dist/components/{rooms-stepper-section.d.ts → option-units-stepper-section.d.ts} +17 -9
- package/dist/components/option-units-stepper-section.d.ts.map +1 -0
- package/dist/components/option-units-stepper-section.js +172 -0
- package/dist/components/payment-schedule-section.d.ts +1 -1
- package/dist/components/payment-schedule-section.d.ts.map +1 -1
- package/dist/components/payment-schedule-section.js +5 -11
- package/dist/components/person-picker-section.d.ts +4 -0
- package/dist/components/person-picker-section.d.ts.map +1 -1
- package/dist/components/person-picker-section.js +27 -5
- package/dist/components/price-breakdown-section.d.ts +8 -2
- package/dist/components/price-breakdown-section.d.ts.map +1 -1
- package/dist/components/price-breakdown-section.js +17 -5
- package/dist/components/status-change-dialog.d.ts.map +1 -1
- package/dist/components/status-change-dialog.js +6 -5
- package/dist/components/supplier-status-dialog.d.ts.map +1 -1
- package/dist/components/supplier-status-dialog.js +6 -5
- package/dist/components/traveler-list.d.ts.map +1 -1
- package/dist/components/traveler-list.js +12 -1
- package/dist/components/travelers-section.d.ts +62 -3
- package/dist/components/travelers-section.d.ts.map +1 -1
- package/dist/components/travelers-section.js +290 -23
- package/dist/i18n/en.d.ts +63 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +68 -5
- package/dist/i18n/messages.d.ts +63 -0
- package/dist/i18n/messages.d.ts.map +1 -1
- package/dist/i18n/provider.d.ts +126 -0
- package/dist/i18n/provider.d.ts.map +1 -1
- package/dist/i18n/ro.d.ts +63 -0
- package/dist/i18n/ro.d.ts.map +1 -1
- package/dist/i18n/ro.js +68 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +26 -24
- package/dist/components/rooms-stepper-section.d.ts.map +0 -1
- 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,
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
:
|
|
112
|
-
? "
|
|
113
|
-
:
|
|
114
|
-
|
|
115
|
-
|
|
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(
|
|
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
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* `
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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 [
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
const
|
|
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:
|
|
281
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
}))
|
|
337
|
-
const
|
|
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
|
-
|
|
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
|
|
411
|
-
return (_jsxs(
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
} })
|
|
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
|
}
|