@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.
- 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,17 +1,115 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { usePeople, usePerson } from "@voyantjs/crm-react";
|
|
3
|
+
import { usePeople, usePerson, usePersonRelationships, } from "@voyantjs/crm-react";
|
|
4
4
|
import { PersonForm } from "@voyantjs/crm-ui";
|
|
5
|
-
import { Button,
|
|
5
|
+
import { Button, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle, } from "@voyantjs/ui/components";
|
|
6
6
|
import { Combobox, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, } from "@voyantjs/ui/components/combobox";
|
|
7
|
-
import { Trash2, UserPlus } from "lucide-react";
|
|
7
|
+
import { Pencil, Trash2, UserPlus } from "lucide-react";
|
|
8
8
|
import * as React from "react";
|
|
9
9
|
import { useBookingsUiMessagesOrDefault } from "../i18n/provider.js";
|
|
10
|
-
const ALL_ROLES = ["lead", "adult", "child", "infant"];
|
|
11
10
|
export const emptyTravelerListValue = { travelers: [] };
|
|
12
11
|
/** Factory for a blank row — `role` defaults to `adult` unless the list is empty. */
|
|
13
12
|
export function createBlankTraveler(role = "adult") {
|
|
14
|
-
return {
|
|
13
|
+
return {
|
|
14
|
+
personId: null,
|
|
15
|
+
firstName: "",
|
|
16
|
+
lastName: "",
|
|
17
|
+
email: "",
|
|
18
|
+
phone: "",
|
|
19
|
+
preferredLanguage: "",
|
|
20
|
+
role,
|
|
21
|
+
dateOfBirth: null,
|
|
22
|
+
roomUnitId: null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Compute integer age in full years from an ISO date-of-birth string.
|
|
27
|
+
* Returns null when the DOB is missing or unparseable.
|
|
28
|
+
*/
|
|
29
|
+
export function computeAgeYears(dob, now = new Date()) {
|
|
30
|
+
if (!dob)
|
|
31
|
+
return null;
|
|
32
|
+
const birth = new Date(dob);
|
|
33
|
+
if (Number.isNaN(birth.getTime()))
|
|
34
|
+
return null;
|
|
35
|
+
let age = now.getFullYear() - birth.getFullYear();
|
|
36
|
+
const beforeBirthday = now.getMonth() < birth.getMonth() ||
|
|
37
|
+
(now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate());
|
|
38
|
+
if (beforeBirthday)
|
|
39
|
+
age -= 1;
|
|
40
|
+
return age >= 0 ? age : null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Derive the age-banded traveler role from DOB. Falls back to `adult`
|
|
44
|
+
* when DOB is missing so partial entries still typecheck downstream.
|
|
45
|
+
*
|
|
46
|
+
* Thresholds:
|
|
47
|
+
* - infant: < 2
|
|
48
|
+
* - child: 2 – 17
|
|
49
|
+
* - adult: 18+
|
|
50
|
+
*/
|
|
51
|
+
export function deriveTravelerRoleFromDob(dob) {
|
|
52
|
+
const age = computeAgeYears(dob);
|
|
53
|
+
if (age == null)
|
|
54
|
+
return "adult";
|
|
55
|
+
if (age < 2)
|
|
56
|
+
return "infant";
|
|
57
|
+
if (age < 18)
|
|
58
|
+
return "child";
|
|
59
|
+
return "adult";
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Find the unit whose `[minAge, maxAge]` window contains the given
|
|
63
|
+
* DOB-derived age. Returns the unit id, or null if no match (or DOB
|
|
64
|
+
* unset). Person-typed units are preferred; everything else is
|
|
65
|
+
* ignored. Caller falls back to a default unit when null.
|
|
66
|
+
*/
|
|
67
|
+
function matchUnitByDob(units, dob) {
|
|
68
|
+
if (!dob)
|
|
69
|
+
return null;
|
|
70
|
+
const age = computeAgeYears(dob);
|
|
71
|
+
if (age == null)
|
|
72
|
+
return null;
|
|
73
|
+
const personUnits = units.filter((u) => u.unitType == null || u.unitType === "person");
|
|
74
|
+
const match = personUnits.find((u) => (u.minAge == null || age >= u.minAge) && (u.maxAge == null || age <= u.maxAge));
|
|
75
|
+
return match?.unitId ?? null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The Room dropdown lists one item per option (keyed by the option's
|
|
79
|
+
* primary/ADULT unit id), but a traveler's `roomUnitId` can point at any
|
|
80
|
+
* age-banded unit within that option. Map the traveler's specific unit
|
|
81
|
+
* back to the dropdown's primary key so the Select value matches an
|
|
82
|
+
* existing item — otherwise base-ui falls back to rendering the raw id.
|
|
83
|
+
*/
|
|
84
|
+
function mapUnitIdToGroupPrimary(unitId, roomGroups) {
|
|
85
|
+
if (!unitId)
|
|
86
|
+
return null;
|
|
87
|
+
if (!roomGroups)
|
|
88
|
+
return unitId;
|
|
89
|
+
const group = roomGroups.find((g) => g.primaryUnitId === unitId || g.units.some((u) => u.unitId === unitId));
|
|
90
|
+
return group?.primaryUnitId ?? unitId;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* When the operator changes the Room dropdown, preserve the traveler's
|
|
94
|
+
* current category code (Adult/Child/Senior/…) in the destination option
|
|
95
|
+
* if it offers a matching unit. Falls back to the destination's primary
|
|
96
|
+
* unit when no match exists or the previous unit has no code.
|
|
97
|
+
*/
|
|
98
|
+
function pickUnitForRoomChange(currentUnitId, nextRoomPrimaryId, roomGroups) {
|
|
99
|
+
if (!roomGroups)
|
|
100
|
+
return nextRoomPrimaryId;
|
|
101
|
+
const nextGroup = roomGroups.find((g) => g.primaryUnitId === nextRoomPrimaryId);
|
|
102
|
+
if (!nextGroup)
|
|
103
|
+
return nextRoomPrimaryId;
|
|
104
|
+
if (!currentUnitId)
|
|
105
|
+
return nextGroup.primaryUnitId;
|
|
106
|
+
const prevGroup = roomGroups.find((g) => g.primaryUnitId === currentUnitId || g.units.some((u) => u.unitId === currentUnitId));
|
|
107
|
+
const prevUnit = prevGroup?.units.find((u) => u.unitId === currentUnitId);
|
|
108
|
+
const prevCode = (prevUnit?.unitCode ?? "").toLowerCase();
|
|
109
|
+
if (!prevCode)
|
|
110
|
+
return nextGroup.primaryUnitId;
|
|
111
|
+
const sameCategory = nextGroup.units.find((u) => (u.unitCode ?? "").toLowerCase() === prevCode);
|
|
112
|
+
return sameCategory?.unitId ?? nextGroup.primaryUnitId;
|
|
15
113
|
}
|
|
16
114
|
const NO_ROOM = "__unassigned__";
|
|
17
115
|
/**
|
|
@@ -31,18 +129,12 @@ const NO_ROOM = "__unassigned__";
|
|
|
31
129
|
* here. The UI lets the operator pick whichever layout they want, then
|
|
32
130
|
* the submit handler errors if the invariant isn't met.
|
|
33
131
|
*/
|
|
34
|
-
export function TravelersSection({ value, onChange, roomUnits, billingPersonId, labels, }) {
|
|
132
|
+
export function TravelersSection({ value, onChange, roomUnits, roomGroups, billingPersonId, labels, }) {
|
|
35
133
|
const messages = useBookingsUiMessagesOrDefault();
|
|
36
134
|
const merged = { ...messages.travelersSection.labels, ...labels };
|
|
37
135
|
const billingPerson = usePerson(billingPersonId ?? undefined, {
|
|
38
136
|
enabled: Boolean(billingPersonId),
|
|
39
137
|
});
|
|
40
|
-
const roleLabels = {
|
|
41
|
-
lead: merged.roleLead,
|
|
42
|
-
adult: merged.roleAdult,
|
|
43
|
-
child: merged.roleChild,
|
|
44
|
-
infant: merged.roleInfant,
|
|
45
|
-
};
|
|
46
138
|
const updateAt = (index, patch) => {
|
|
47
139
|
const next = value.travelers.map((traveler, i) => i === index ? { ...traveler, ...patch } : traveler);
|
|
48
140
|
onChange({ travelers: next });
|
|
@@ -50,35 +142,117 @@ export function TravelersSection({ value, onChange, roomUnits, billingPersonId,
|
|
|
50
142
|
const removeAt = (index) => {
|
|
51
143
|
onChange({ travelers: value.travelers.filter((_, i) => i !== index) });
|
|
52
144
|
};
|
|
145
|
+
// Auto-pick a room with seats available so operators don't have to
|
|
146
|
+
// hunt for the dropdown on every traveler — they can still override
|
|
147
|
+
// manually via the Room select. Picks the first option (ordering
|
|
148
|
+
// mirrors the upstream `roomUnits` array, which comes from the
|
|
149
|
+
// stepper in catalog order). When `roomGroups` is wired and the
|
|
150
|
+
// traveler has DOB, also pre-pick the matching age-banded unit
|
|
151
|
+
// within that option so the Category buttons land on the right row.
|
|
152
|
+
const pickRoomUnitIdForNewTraveler = (dateOfBirth = null) => {
|
|
153
|
+
if (!roomUnits || roomUnits.length === 0)
|
|
154
|
+
return null;
|
|
155
|
+
const pickedRoom = roomUnits.find((unit) => unit.remainingCapacity > 0)?.unitId ?? roomUnits[0]?.unitId ?? null;
|
|
156
|
+
if (!pickedRoom || !roomGroups || roomGroups.length === 0)
|
|
157
|
+
return pickedRoom;
|
|
158
|
+
const group = roomGroups.find((g) => g.primaryUnitId === pickedRoom || g.units.some((u) => u.unitId === pickedRoom));
|
|
159
|
+
if (!group)
|
|
160
|
+
return pickedRoom;
|
|
161
|
+
return matchUnitByDob(group.units, dateOfBirth) ?? group.primaryUnitId;
|
|
162
|
+
};
|
|
53
163
|
const addRow = () => {
|
|
54
164
|
// First traveler defaults to `lead` so the operator doesn't have to
|
|
55
165
|
// remember to flip the role on the initial row.
|
|
56
166
|
const role = value.travelers.length === 0 ? "lead" : "adult";
|
|
57
|
-
|
|
167
|
+
const blank = createBlankTraveler(role);
|
|
168
|
+
onChange({
|
|
169
|
+
travelers: [...value.travelers, { ...blank, roomUnitId: pickRoomUnitIdForNewTraveler(null) }],
|
|
170
|
+
});
|
|
58
171
|
};
|
|
59
172
|
const addBillingPerson = () => {
|
|
60
173
|
if (!billingPerson.data)
|
|
61
174
|
return;
|
|
62
175
|
const role = value.travelers.length === 0 ? "lead" : "adult";
|
|
176
|
+
const traveler = createTravelerFromPerson(billingPerson.data, role);
|
|
63
177
|
onChange({
|
|
64
|
-
travelers: [
|
|
178
|
+
travelers: [
|
|
179
|
+
...value.travelers,
|
|
180
|
+
{ ...traveler, roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth) },
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
const addRelatedPersonTraveler = (person) => {
|
|
185
|
+
const role = value.travelers.length === 0 ? "lead" : "adult";
|
|
186
|
+
const traveler = createTravelerFromPerson(person, role);
|
|
187
|
+
onChange({
|
|
188
|
+
travelers: [
|
|
189
|
+
...value.travelers,
|
|
190
|
+
{ ...traveler, roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth) },
|
|
191
|
+
],
|
|
65
192
|
});
|
|
66
193
|
};
|
|
67
194
|
const hasBillingPersonTraveler = Boolean(billingPersonId && value.travelers.some((traveler) => traveler.personId === billingPersonId));
|
|
68
|
-
|
|
195
|
+
// Relationships of the billing person — surfaced as one-click "add as
|
|
196
|
+
// traveler" chips so the operator can populate family/companions
|
|
197
|
+
// without searching for them in the picker.
|
|
198
|
+
const relationshipsQuery = usePersonRelationships(billingPersonId ?? undefined, {
|
|
199
|
+
enabled: Boolean(billingPersonId),
|
|
200
|
+
});
|
|
201
|
+
const alreadyAddedIds = React.useMemo(() => new Set(value.travelers.map((t) => t.personId).filter(Boolean)), [value.travelers]);
|
|
202
|
+
const relatedPersonIds = React.useMemo(() => {
|
|
203
|
+
if (!billingPersonId)
|
|
204
|
+
return [];
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const rel of relationshipsQuery.data?.data ?? []) {
|
|
208
|
+
const otherId = rel.fromPersonId === billingPersonId ? rel.toPersonId : rel.fromPersonId;
|
|
209
|
+
if (seen.has(otherId) || alreadyAddedIds.has(otherId))
|
|
210
|
+
continue;
|
|
211
|
+
seen.add(otherId);
|
|
212
|
+
out.push({ id: otherId, kind: rel.kind });
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}, [billingPersonId, relationshipsQuery.data?.data, alreadyAddedIds]);
|
|
216
|
+
// base-ui's Select reads labels via the `items` prop — without it,
|
|
217
|
+
// <SelectValue /> falls back to the raw value (the unit id). Memoize
|
|
218
|
+
// once for all rows so identity is stable across renders.
|
|
219
|
+
const roomSelectItems = React.useMemo(() => [
|
|
220
|
+
{ label: merged.noRoom, value: NO_ROOM },
|
|
221
|
+
...(roomUnits ?? []).map((unit) => ({ label: unit.unitName, value: unit.unitId })),
|
|
222
|
+
], [roomUnits, merged.noRoom]);
|
|
223
|
+
return (_jsxs("div", { className: "flex flex-col gap-2 rounded-md border p-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Label, { children: merged.heading }), _jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: addRow, children: merged.addTraveler })] }), billingPersonId && !hasBillingPersonTraveler ? (_jsx("div", { children: _jsxs(Button, { type: "button", size: "sm", variant: "outline", onClick: addBillingPerson, disabled: !billingPerson.data, children: [_jsx(UserPlus, { className: "mr-1 h-3.5 w-3.5" }), merged.addBillingPerson] }) })) : null, relatedPersonIds.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: merged.relatedPeopleHeading }), _jsx("div", { className: "flex flex-wrap gap-1.5", children: relatedPersonIds.map((rel) => (_jsx(RelatedPersonChip, { personId: rel.id, kind: rel.kind, addLabel: merged.addRelatedPerson, onAdd: addRelatedPersonTraveler }, rel.id))) })] })) : null, value.travelers.length === 0 ? (_jsx("p", { className: "text-xs text-muted-foreground", children: merged.empty })) : (_jsx("div", { className: "flex flex-col gap-2", children: value.travelers.map((traveler, index) => (_jsxs("div", { className: "flex flex-col gap-2 rounded-md border p-2", children: [_jsx(TravelerPersonPicker, { personId: traveler.personId, labels: merged, pinnedPeople: billingPerson.data ? [billingPerson.data] : [], onSelect: (person) => updateAt(index, {
|
|
69
224
|
personId: person.id,
|
|
70
225
|
firstName: person.firstName,
|
|
71
226
|
lastName: person.lastName,
|
|
72
227
|
email: person.email ?? "",
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
228
|
+
phone: person.phone ?? "",
|
|
229
|
+
preferredLanguage: person.preferredLanguage ?? "",
|
|
230
|
+
dateOfBirth: person.dateOfBirth ?? null,
|
|
231
|
+
}), onClear: () => updateAt(index, {
|
|
232
|
+
personId: null,
|
|
233
|
+
firstName: "",
|
|
234
|
+
lastName: "",
|
|
235
|
+
email: "",
|
|
236
|
+
phone: "",
|
|
237
|
+
preferredLanguage: "",
|
|
238
|
+
dateOfBirth: null,
|
|
239
|
+
}) }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(TravelerCategoryButtons, { traveler: traveler, roomGroups: roomGroups, fallbackLabels: {
|
|
240
|
+
category: merged.category,
|
|
241
|
+
adult: merged.roleAdult,
|
|
242
|
+
child: merged.roleChild,
|
|
243
|
+
infant: merged.roleInfant,
|
|
244
|
+
}, onPickUnit: (unitId, nextRole) => updateAt(index, { roomUnitId: unitId, role: nextRole }) }), roomUnits && roomUnits.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.room }), _jsxs(Select, { items: roomSelectItems, value: mapUnitIdToGroupPrimary(traveler.roomUnitId, roomGroups) ?? NO_ROOM, onValueChange: (v) => updateAt(index, {
|
|
245
|
+
roomUnitId: v === NO_ROOM || !v
|
|
246
|
+
? null
|
|
247
|
+
: pickUnitForRoomChange(traveler.roomUnitId, v, roomGroups),
|
|
248
|
+
}), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: NO_ROOM, children: merged.noRoom }), roomUnits.map((unit) => (_jsx(SelectItem, { value: unit.unitId, children: unit.unitName }, unit.unitId)))] })] })] })) : null] }), _jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 text-destructive", onClick: () => removeAt(index), "aria-label": merged.remove, children: [_jsx(Trash2, { className: "mr-1 h-3.5 w-3.5" }), merged.remove] }) })] }, index))) }))] }));
|
|
78
249
|
}
|
|
79
250
|
function TravelerPersonPicker({ personId, labels, pinnedPeople = [], onSelect, onClear, }) {
|
|
80
251
|
const [search, setSearch] = React.useState("");
|
|
81
252
|
const [inputValue, setInputValue] = React.useState("");
|
|
253
|
+
// One Sheet serves both flows: create when there's no selected person,
|
|
254
|
+
// edit when the operator clicks the Edit button on a selected one.
|
|
255
|
+
const [sheetMode, setSheetMode] = React.useState("create");
|
|
82
256
|
const [sheetOpen, setSheetOpen] = React.useState(false);
|
|
83
257
|
const peopleQuery = usePeople({ search: search || undefined, limit: 20 });
|
|
84
258
|
const selectedPersonQuery = usePerson(personId ?? undefined, { enabled: Boolean(personId) });
|
|
@@ -98,7 +272,18 @@ function TravelerPersonPicker({ personId, labels, pinnedPeople = [], onSelect, o
|
|
|
98
272
|
if (selectedLabel)
|
|
99
273
|
setInputValue(selectedLabel);
|
|
100
274
|
}, [selectedLabel]);
|
|
101
|
-
return (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Label, { className: "text-xs", children: labels.person }), _jsxs(
|
|
275
|
+
return (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Label, { className: "text-xs", children: labels.person }), _jsxs("div", { className: "flex items-center gap-1", children: [personId ? (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7", disabled: !selectedPersonQuery.data, onClick: () => {
|
|
276
|
+
setSheetMode("edit");
|
|
277
|
+
setSheetOpen(true);
|
|
278
|
+
}, children: [_jsx(Pencil, { className: "mr-1 h-3.5 w-3.5" }), labels.editPerson] })) : null, _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7", onClick: () => {
|
|
279
|
+
setSheetMode("create");
|
|
280
|
+
setSheetOpen(true);
|
|
281
|
+
}, children: [_jsx(UserPlus, { className: "mr-1 h-3.5 w-3.5" }), labels.createNewPerson] })] })] }), _jsxs(Combobox, { items: people.map((person) => person.id), value: personId, inputValue: inputValue, autoHighlight: true,
|
|
282
|
+
// `itemToStringLabel` drives BOTH the filter pass and the
|
|
283
|
+
// input display. Without it, base-ui falls back to the raw
|
|
284
|
+
// value (a `pers_…` typeid), so typing "eliza" matches
|
|
285
|
+
// nothing and the trigger shows the id instead of the name.
|
|
286
|
+
itemToStringLabel: (id) => formatPerson(peopleMap.get(id)) || id, itemToStringValue: (id) => id, onInputValueChange: (next) => {
|
|
102
287
|
setInputValue(next);
|
|
103
288
|
setSearch(next);
|
|
104
289
|
if (!next)
|
|
@@ -113,19 +298,30 @@ function TravelerPersonPicker({ personId, labels, pinnedPeople = [], onSelect, o
|
|
|
113
298
|
if (!person)
|
|
114
299
|
return null;
|
|
115
300
|
return (_jsx(ComboboxItem, { value: person.id, children: _jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate font-medium", children: formatPersonName(person) }), person.email ? (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: person.email })) : null] }) }, person.id));
|
|
116
|
-
} }) })] })] }), _jsx(Sheet, { open: sheetOpen, onOpenChange: setSheetOpen, children: _jsxs(SheetContent, { side: "right", size: "lg", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: labels.createPersonSheetTitle }) }), _jsx(SheetBody, { children: _jsx(PersonForm, { mode:
|
|
301
|
+
} }) })] })] }), _jsx(Sheet, { open: sheetOpen, onOpenChange: setSheetOpen, children: _jsxs(SheetContent, { side: "right", size: "lg", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: sheetMode === "edit" ? labels.editPersonSheetTitle : labels.createPersonSheetTitle }) }), _jsx(SheetBody, { children: _jsx(PersonForm, { mode: sheetMode === "edit" && selectedPersonQuery.data
|
|
302
|
+
? { kind: "edit", person: selectedPersonQuery.data }
|
|
303
|
+
: { kind: "create" }, onCancel: () => setSheetOpen(false), onSuccess: (saved) => {
|
|
117
304
|
onSelect(saved);
|
|
118
305
|
setInputValue(formatPerson(saved));
|
|
119
306
|
setSheetOpen(false);
|
|
120
307
|
} }) })] }) })] }));
|
|
121
308
|
}
|
|
122
309
|
function createTravelerFromPerson(person, role) {
|
|
310
|
+
// DOB drives age-banded pricing — hydrate from the person record so
|
|
311
|
+
// the operator doesn't have to re-enter it on every booking. The
|
|
312
|
+
// caller's `role` still wins (e.g. "lead" on the first traveler) so
|
|
313
|
+
// the booking-lead flag isn't clobbered by the age-derived category.
|
|
314
|
+
const dateOfBirth = person.dateOfBirth ?? null;
|
|
315
|
+
const effectiveRole = role === "lead" ? "lead" : deriveTravelerRoleFromDob(dateOfBirth);
|
|
123
316
|
return {
|
|
124
317
|
personId: person.id,
|
|
125
318
|
firstName: person.firstName,
|
|
126
319
|
lastName: person.lastName,
|
|
127
320
|
email: person.email ?? "",
|
|
128
|
-
|
|
321
|
+
phone: person.phone ?? "",
|
|
322
|
+
preferredLanguage: person.preferredLanguage ?? "",
|
|
323
|
+
role: effectiveRole,
|
|
324
|
+
dateOfBirth,
|
|
129
325
|
roomUnitId: null,
|
|
130
326
|
};
|
|
131
327
|
}
|
|
@@ -140,3 +336,74 @@ function formatPerson(person) {
|
|
|
140
336
|
const name = formatPersonName(person);
|
|
141
337
|
return person.email ? `${name} · ${person.email}` : name;
|
|
142
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* Dynamic category-button group. Reads the product's actual
|
|
341
|
+
* person-typed option_units (Adult/Child/Senior, Adult/Child/Infant,
|
|
342
|
+
* Adult/Senior, etc) from `roomGroups` and renders one button per
|
|
343
|
+
* unit in the traveler's currently-assigned option.
|
|
344
|
+
*
|
|
345
|
+
* Falls back to the old static Adult/Child/Infant buttons when the
|
|
346
|
+
* parent hasn't wired `roomGroups` (during the brief window before a
|
|
347
|
+
* product is selected, or in legacy callers that don't pass the prop).
|
|
348
|
+
*/
|
|
349
|
+
function TravelerCategoryButtons({ traveler, roomGroups, fallbackLabels, onPickUnit, }) {
|
|
350
|
+
const group = React.useMemo(() => {
|
|
351
|
+
if (!roomGroups || !traveler.roomUnitId)
|
|
352
|
+
return undefined;
|
|
353
|
+
return roomGroups.find((g) => g.primaryUnitId === traveler.roomUnitId ||
|
|
354
|
+
g.units.some((u) => u.unitId === traveler.roomUnitId));
|
|
355
|
+
}, [roomGroups, traveler.roomUnitId]);
|
|
356
|
+
// Surface only person-typed units (Adult, Child, Senior, Infant,
|
|
357
|
+
// …). Vehicles / rooms / services aren't categories the operator
|
|
358
|
+
// toggles on a per-traveler basis. If the option has only one
|
|
359
|
+
// person-typed unit (e.g. a "per-person" tour with no age bands),
|
|
360
|
+
// there's nothing to choose, so the buttons collapse.
|
|
361
|
+
const categoryUnits = React.useMemo(() => {
|
|
362
|
+
if (!group)
|
|
363
|
+
return [];
|
|
364
|
+
return group.units.filter((u) => u.unitType == null || u.unitType === "person");
|
|
365
|
+
}, [group]);
|
|
366
|
+
if (group && categoryUnits.length <= 1) {
|
|
367
|
+
// Single person-typed unit — no category choice to make.
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
if (!group || categoryUnits.length === 0) {
|
|
371
|
+
// Fallback to the static set so the row still renders before a
|
|
372
|
+
// product/option is selected. Editing here writes the legacy
|
|
373
|
+
// `role` field only; once roomGroups arrive the buttons re-render
|
|
374
|
+
// and bind to actual unit ids.
|
|
375
|
+
return (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: fallbackLabels.category }), _jsx("div", { className: "grid grid-cols-3 gap-1", children: [
|
|
376
|
+
["adult", fallbackLabels.adult],
|
|
377
|
+
["child", fallbackLabels.child],
|
|
378
|
+
["infant", fallbackLabels.infant],
|
|
379
|
+
].map(([category, label]) => {
|
|
380
|
+
const active = traveler.role === category;
|
|
381
|
+
const nextRole = traveler.role === "lead" && category === "adult" ? "lead" : category;
|
|
382
|
+
return (_jsx(Button, { type: "button", size: "sm", variant: active ? "default" : "outline", className: "h-7 text-xs", onClick: () => onPickUnit(traveler.roomUnitId, nextRole), children: label }, category));
|
|
383
|
+
}) })] }));
|
|
384
|
+
}
|
|
385
|
+
return (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: fallbackLabels.category }), _jsx("div", { className: "grid gap-1", style: { gridTemplateColumns: `repeat(${categoryUnits.length}, minmax(0, 1fr))` }, children: categoryUnits.map((unit) => {
|
|
386
|
+
const active = traveler.roomUnitId === unit.unitId;
|
|
387
|
+
// Keep the lead flag when the operator clicks the ADULT-coded
|
|
388
|
+
// unit (or one whose age band covers adults); otherwise the
|
|
389
|
+
// traveler's role tracks the unit code/name for the submit
|
|
390
|
+
// path's travelerCategory mapping.
|
|
391
|
+
const codeLower = (unit.unitCode ?? "").toLowerCase();
|
|
392
|
+
const inferredRole = codeLower === "child" ? "child" : codeLower === "infant" ? "infant" : "adult";
|
|
393
|
+
const nextRole = traveler.role === "lead" && inferredRole === "adult" ? "lead" : inferredRole;
|
|
394
|
+
return (_jsx(Button, { type: "button", size: "sm", variant: active ? "default" : "outline", className: "h-7 text-xs", onClick: () => onPickUnit(unit.unitId, nextRole), title: unit.minAge != null || unit.maxAge != null
|
|
395
|
+
? `${unit.minAge ?? "0"}–${unit.maxAge ?? "∞"}`
|
|
396
|
+
: undefined, children: unit.unitName }, unit.unitId));
|
|
397
|
+
}) })] }));
|
|
398
|
+
}
|
|
399
|
+
function RelatedPersonChip({ personId, kind, addLabel, onAdd, }) {
|
|
400
|
+
const messages = useBookingsUiMessagesOrDefault();
|
|
401
|
+
const kindLabels = messages.travelersSection.relationshipKindLabels;
|
|
402
|
+
const query = usePerson(personId);
|
|
403
|
+
const person = query.data;
|
|
404
|
+
if (!person)
|
|
405
|
+
return null;
|
|
406
|
+
const name = formatPersonName(person) || personId;
|
|
407
|
+
const kindLabel = kindLabels[kind] ?? kind;
|
|
408
|
+
return (_jsxs(Button, { type: "button", size: "sm", variant: "outline", className: "h-7 gap-1.5", onClick: () => onAdd(person), "aria-label": addLabel, children: [_jsx(UserPlus, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-xs", children: name }), _jsx("span", { className: "rounded-sm bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground", children: kindLabel })] }));
|
|
409
|
+
}
|
package/dist/i18n/en.d.ts
CHANGED
|
@@ -357,6 +357,10 @@ export declare const bookingsUiEn: {
|
|
|
357
357
|
createNewOrganization: string;
|
|
358
358
|
createPersonSheetTitle: string;
|
|
359
359
|
createOrganizationSheetTitle: string;
|
|
360
|
+
editPerson: string;
|
|
361
|
+
editOrganization: string;
|
|
362
|
+
editPersonSheetTitle: string;
|
|
363
|
+
editOrganizationSheetTitle: string;
|
|
360
364
|
selectExistingPerson: string;
|
|
361
365
|
personSearchPlaceholder: string;
|
|
362
366
|
personSelectPlaceholder: string;
|
|
@@ -383,6 +387,8 @@ export declare const bookingsUiEn: {
|
|
|
383
387
|
lastName: string;
|
|
384
388
|
email: string;
|
|
385
389
|
role: string;
|
|
390
|
+
category: string;
|
|
391
|
+
dateOfBirth: string;
|
|
386
392
|
roleLead: string;
|
|
387
393
|
roleAdult: string;
|
|
388
394
|
roleChild: string;
|
|
@@ -396,7 +402,24 @@ export declare const bookingsUiEn: {
|
|
|
396
402
|
personEmpty: string;
|
|
397
403
|
createNewPerson: string;
|
|
398
404
|
createPersonSheetTitle: string;
|
|
405
|
+
editPerson: string;
|
|
406
|
+
editPersonSheetTitle: string;
|
|
399
407
|
addBillingPerson: string;
|
|
408
|
+
relatedPeopleHeading: string;
|
|
409
|
+
addRelatedPerson: string;
|
|
410
|
+
};
|
|
411
|
+
relationshipKindLabels: {
|
|
412
|
+
spouse: string;
|
|
413
|
+
partner: string;
|
|
414
|
+
parent: string;
|
|
415
|
+
child: string;
|
|
416
|
+
sibling: string;
|
|
417
|
+
guardian: string;
|
|
418
|
+
ward: string;
|
|
419
|
+
emergency_contact: string;
|
|
420
|
+
friend: string;
|
|
421
|
+
travel_companion: string;
|
|
422
|
+
other: string;
|
|
400
423
|
};
|
|
401
424
|
};
|
|
402
425
|
paymentScheduleSection: {
|
|
@@ -794,6 +817,9 @@ export declare const bookingsUiEn: {
|
|
|
794
817
|
};
|
|
795
818
|
actions: {
|
|
796
819
|
deleteConfirm: string;
|
|
820
|
+
issueDocument: string;
|
|
821
|
+
issueInvoice: string;
|
|
822
|
+
issueProforma: string;
|
|
797
823
|
};
|
|
798
824
|
};
|
|
799
825
|
supplierStatusList: {
|
|
@@ -858,6 +884,24 @@ export declare const bookingsUiEn: {
|
|
|
858
884
|
confirm: string;
|
|
859
885
|
};
|
|
860
886
|
};
|
|
887
|
+
bookingBillingDialog: {
|
|
888
|
+
title: string;
|
|
889
|
+
fields: {
|
|
890
|
+
firstName: string;
|
|
891
|
+
lastName: string;
|
|
892
|
+
email: string;
|
|
893
|
+
phone: string;
|
|
894
|
+
address: string;
|
|
895
|
+
city: string;
|
|
896
|
+
region: string;
|
|
897
|
+
postalCode: string;
|
|
898
|
+
country: string;
|
|
899
|
+
};
|
|
900
|
+
actions: {
|
|
901
|
+
cancel: string;
|
|
902
|
+
save: string;
|
|
903
|
+
};
|
|
904
|
+
};
|
|
861
905
|
bookingGuaranteeDialog: {
|
|
862
906
|
titles: {
|
|
863
907
|
create: string;
|
|
@@ -998,6 +1042,10 @@ export declare const bookingsUiEn: {
|
|
|
998
1042
|
internalNotes: string;
|
|
999
1043
|
confirmAfterCreate: string;
|
|
1000
1044
|
confirmAfterCreateHint: string;
|
|
1045
|
+
createAsDraft: string;
|
|
1046
|
+
createAsDraftHint: string;
|
|
1047
|
+
notifyTraveler: string;
|
|
1048
|
+
notifyTravelerHint: string;
|
|
1001
1049
|
};
|
|
1002
1050
|
placeholders: {
|
|
1003
1051
|
departure: string;
|
|
@@ -1017,6 +1065,8 @@ export declare const bookingsUiEn: {
|
|
|
1017
1065
|
};
|
|
1018
1066
|
actions: {
|
|
1019
1067
|
createDraftBooking: string;
|
|
1068
|
+
createConfirmedBooking: string;
|
|
1069
|
+
createAwaitingPaymentBooking: string;
|
|
1020
1070
|
};
|
|
1021
1071
|
labels: {
|
|
1022
1072
|
currency: string;
|
|
@@ -1025,6 +1075,7 @@ export declare const bookingsUiEn: {
|
|
|
1025
1075
|
createNewPerson: string;
|
|
1026
1076
|
selectExistingPerson: string;
|
|
1027
1077
|
organizationNone: string;
|
|
1078
|
+
billingHeading: string;
|
|
1028
1079
|
addTraveler: string;
|
|
1029
1080
|
travelerHeading: string;
|
|
1030
1081
|
travelerRole: string;
|
|
@@ -1062,6 +1113,15 @@ export declare const bookingsUiEn: {
|
|
|
1062
1113
|
voucherRemainingLabel: string;
|
|
1063
1114
|
voucherInvalidLabel: string;
|
|
1064
1115
|
paymentHeading: string;
|
|
1116
|
+
previewHeading: string;
|
|
1117
|
+
previewEmpty: string;
|
|
1118
|
+
previewProduct: string;
|
|
1119
|
+
previewDeparture: string;
|
|
1120
|
+
previewOptions: string;
|
|
1121
|
+
previewTravelers: string;
|
|
1122
|
+
previewTotal: string;
|
|
1123
|
+
previewLoading: string;
|
|
1124
|
+
previewTravelerUnnamed: string;
|
|
1065
1125
|
paymentModeUnpaid: string;
|
|
1066
1126
|
paymentModeFull: string;
|
|
1067
1127
|
paymentModeAdvance: string;
|
|
@@ -1094,6 +1154,9 @@ export declare const bookingsUiEn: {
|
|
|
1094
1154
|
breakdownOverrideReason: string;
|
|
1095
1155
|
breakdownOverrideReasonPlaceholder: string;
|
|
1096
1156
|
breakdownOverrideReasonRequired: string;
|
|
1157
|
+
breakdownSubtotal: string;
|
|
1158
|
+
breakdownTax: string;
|
|
1159
|
+
breakdownTaxIncluded: string;
|
|
1097
1160
|
};
|
|
1098
1161
|
};
|
|
1099
1162
|
bookingList: {
|
package/dist/i18n/en.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/i18n/en.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY
|
|
1
|
+
{"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/i18n/en.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8wCK,CAAA"}
|