@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,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, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle, } from "@voyantjs/ui/components";
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 { personId: null, firstName: "", lastName: "", email: "", role, roomUnitId: null };
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
- onChange({ travelers: [...value.travelers, createBlankTraveler(role)] });
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: [...value.travelers, createTravelerFromPerson(billingPerson.data, role)],
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
- 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, 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, {
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
- }), onClear: () => updateAt(index, { personId: null }) }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(Input, { placeholder: merged.firstName, value: traveler.firstName, onChange: (e) => updateAt(index, { firstName: e.target.value }) }), _jsx(Input, { placeholder: merged.lastName, value: traveler.lastName, onChange: (e) => updateAt(index, { lastName: e.target.value }) })] }), _jsx(Input, { type: "email", placeholder: merged.email, value: traveler.email, onChange: (e) => updateAt(index, { email: e.target.value }) }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.role }), _jsxs(Select, { value: traveler.role, onValueChange: (v) => updateAt(index, { role: (v ?? "adult") }), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: ALL_ROLES.map((role) => (_jsx(SelectItem, { value: role, children: roleLabels[role] }, role))) })] })] }), roomUnits && roomUnits.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.room }), _jsxs(Select, { value: traveler.roomUnitId ?? NO_ROOM, onValueChange: (v) => updateAt(index, { roomUnitId: v === NO_ROOM ? null : (v ?? null) }), 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,
74
- // Only disable other rooms at-capacity — the room the
75
- // traveler is *already* in should stay selectable so
76
- // re-renders don't strip the selection.
77
- disabled: unit.remainingCapacity <= 0 && traveler.roomUnitId !== 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))) }))] }));
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(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7", onClick: () => setSheetOpen(true), 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, itemToStringValue: (id) => formatPerson(peopleMap.get(id)), onInputValueChange: (next) => {
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: { kind: "create" }, onCancel: () => setSheetOpen(false), onSuccess: (saved) => {
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
- role,
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: {
@@ -1 +1 @@
1
- {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/i18n/en.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8sCK,CAAA"}
1
+ {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/i18n/en.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8wCK,CAAA"}