@voyantjs/bookings-ui 0.80.17 → 0.81.1

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.
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useAdminBookingPayments, usePublicBookingPayments } from "@voyantjs/finance-react";
4
4
  import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Badge, Button, Card, CardContent, CardHeader, CardTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@voyantjs/ui/components";
5
- import { Banknote, CreditCard, Eye, MoreHorizontal, Pencil, Receipt, Ticket, Trash2, Wallet, } from "lucide-react";
5
+ import { ArrowRightLeft, Banknote, CreditCard, Eye, MoreHorizontal, Pencil, Receipt, Ticket, Trash2, Wallet, } from "lucide-react";
6
6
  import * as React from "react";
7
7
  import { useBookingsUiI18nOrDefault, useBookingsUiMessagesOrDefault } from "../i18n/provider.js";
8
8
  /**
@@ -48,7 +48,7 @@ const methodIcon = {
48
48
  * first as the primary identifier — that's the difference between
49
49
  * "list of payments" and "list of invoice line-items".
50
50
  */
51
- export function BookingPaymentsSummary({ bookingId, variant = "public", getInvoiceHref, onViewPayment, onEditPayment, onDeletePayment, }) {
51
+ export function BookingPaymentsSummary({ bookingId, variant = "public", getInvoiceHref, onViewPayment, onConvertProforma, onEditPayment, onDeletePayment, }) {
52
52
  const publicQuery = usePublicBookingPayments(bookingId, { enabled: variant === "public" });
53
53
  const adminQuery = useAdminBookingPayments(bookingId, { enabled: variant === "admin" });
54
54
  const data = variant === "admin" ? adminQuery.data : publicQuery.data;
@@ -56,9 +56,14 @@ export function BookingPaymentsSummary({ bookingId, variant = "public", getInvoi
56
56
  const messages = useBookingsUiMessagesOrDefault();
57
57
  const card = messages.bookingPaymentsSummary;
58
58
  const payments = data?.data?.payments ?? [];
59
- const showActionsColumn = Boolean(onViewPayment || onEditPayment || onDeletePayment);
59
+ const hasConvertibleProformas = payments.some((payment) => payment.invoiceType === "proforma");
60
+ const showActionsColumn = Boolean(onViewPayment ||
61
+ (onConvertProforma && hasConvertibleProformas) ||
62
+ onEditPayment ||
63
+ onDeletePayment);
60
64
  const [deleteTarget, setDeleteTarget] = React.useState(null);
61
65
  const [deletePending, setDeletePending] = React.useState(false);
66
+ const [convertingInvoiceId, setConvertingInvoiceId] = React.useState(null);
62
67
  const handleDeleteConfirm = async () => {
63
68
  if (!deleteTarget || !onDeletePayment)
64
69
  return;
@@ -71,6 +76,17 @@ export function BookingPaymentsSummary({ bookingId, variant = "public", getInvoi
71
76
  setDeletePending(false);
72
77
  }
73
78
  };
79
+ const handleConvertProforma = async (row) => {
80
+ if (!onConvertProforma || row.invoiceType !== "proforma")
81
+ return;
82
+ setConvertingInvoiceId(row.invoiceId);
83
+ try {
84
+ await onConvertProforma(row);
85
+ }
86
+ finally {
87
+ setConvertingInvoiceId(null);
88
+ }
89
+ };
74
90
  // Empty-state polish: completed totals across all visible rows so
75
91
  // the card carries useful summary information even when there are
76
92
  // many small partial payments to scan.
@@ -88,6 +104,7 @@ export function BookingPaymentsSummary({ bookingId, variant = "public", getInvoi
88
104
  id: payment.id,
89
105
  invoiceId: payment.invoiceId,
90
106
  invoiceNumber: payment.invoiceNumber,
107
+ invoiceType: payment.invoiceType,
91
108
  amountCents: payment.amountCents,
92
109
  currency: payment.currency,
93
110
  status: payment.status,
@@ -97,7 +114,7 @@ export function BookingPaymentsSummary({ bookingId, variant = "public", getInvoi
97
114
  notes: payment.notes,
98
115
  };
99
116
  const invoiceHref = getInvoiceHref?.(row) ?? null;
100
- return (_jsxs("tr", { className: "border-b last:border-b-0", children: [_jsx("td", { className: "px-4 py-2.5 text-right font-mono font-medium", children: formatMoney(payment.amountCents, payment.currency) }), _jsx("td", { className: "px-4 py-2.5", children: _jsxs("span", { className: "inline-flex items-center gap-1.5", children: [_jsx(MethodIcon, { className: "h-3.5 w-3.5 text-muted-foreground" }), methodLabel] }) }), _jsx("td", { className: "px-4 py-2.5", children: _jsx(Badge, { variant: statusVariant[payment.status] ?? "secondary", children: messages.bookingPaymentsSummary.paymentStatusLabels[payment.status] ?? payment.status }) }), _jsx("td", { className: "px-4 py-2.5 text-muted-foreground text-xs", children: formatDate(payment.paymentDate) }), _jsx("td", { className: "px-4 py-2.5", children: _jsx("span", { title: payment.referenceNumber ?? undefined, className: "inline-block max-w-[180px] truncate font-mono text-muted-foreground text-xs", children: payment.referenceNumber ?? "—" }) }), _jsx("td", { className: "px-4 py-2.5 font-mono text-xs", children: invoiceHref ? (_jsx("a", { href: invoiceHref, className: "text-foreground underline-offset-2 hover:underline", children: payment.invoiceNumber })) : (payment.invoiceNumber) }), showActionsColumn ? (_jsx("td", { className: "px-2 py-2.5 text-right", children: _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": card.actions.open }), children: _jsx(MoreHorizontal, { className: "h-4 w-4" }) }), _jsxs(DropdownMenuContent, { align: "end", children: [onViewPayment ? (_jsxs(DropdownMenuItem, { onClick: () => onViewPayment(row), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), card.actions.view] })) : null, onEditPayment ? (_jsxs(DropdownMenuItem, { onClick: () => onEditPayment(row), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), card.actions.edit] })) : null, onDeletePayment ? (_jsxs(_Fragment, { children: [onViewPayment || onEditPayment ? (_jsx(DropdownMenuSeparator, {})) : null, _jsxs(DropdownMenuItem, { variant: "destructive", onClick: () => setDeleteTarget(row), children: [_jsx(Trash2, { className: "mr-2 h-4 w-4" }), card.actions.delete] })] })) : null] })] }) })) : null] }, payment.id));
117
+ return (_jsxs("tr", { className: "border-b last:border-b-0", children: [_jsx("td", { className: "px-4 py-2.5 text-right font-mono font-medium", children: formatMoney(payment.amountCents, payment.currency) }), _jsx("td", { className: "px-4 py-2.5", children: _jsxs("span", { className: "inline-flex items-center gap-1.5", children: [_jsx(MethodIcon, { className: "h-3.5 w-3.5 text-muted-foreground" }), methodLabel] }) }), _jsx("td", { className: "px-4 py-2.5", children: _jsx(Badge, { variant: statusVariant[payment.status] ?? "secondary", children: messages.bookingPaymentsSummary.paymentStatusLabels[payment.status] ?? payment.status }) }), _jsx("td", { className: "px-4 py-2.5 text-muted-foreground text-xs", children: formatDate(payment.paymentDate) }), _jsx("td", { className: "px-4 py-2.5", children: _jsx("span", { title: payment.referenceNumber ?? undefined, className: "inline-block max-w-[180px] truncate font-mono text-muted-foreground text-xs", children: payment.referenceNumber ?? "—" }) }), _jsx("td", { className: "px-4 py-2.5 font-mono text-xs", children: invoiceHref ? (_jsx("a", { href: invoiceHref, className: "text-foreground underline-offset-2 hover:underline", children: payment.invoiceNumber })) : (payment.invoiceNumber) }), showActionsColumn ? (_jsx("td", { className: "px-2 py-2.5 text-right", children: _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": card.actions.open }), children: _jsx(MoreHorizontal, { className: "h-4 w-4" }) }), _jsxs(DropdownMenuContent, { align: "end", children: [onViewPayment ? (_jsxs(DropdownMenuItem, { onClick: () => onViewPayment(row), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), card.actions.view] })) : null, onConvertProforma && row.invoiceType === "proforma" ? (_jsxs(DropdownMenuItem, { disabled: convertingInvoiceId === row.invoiceId, onClick: () => void handleConvertProforma(row), children: [_jsx(ArrowRightLeft, { className: "mr-2 h-4 w-4" }), card.actions.convertToInvoice] })) : null, onEditPayment ? (_jsxs(DropdownMenuItem, { onClick: () => onEditPayment(row), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), card.actions.edit] })) : null, onDeletePayment ? (_jsxs(_Fragment, { children: [onViewPayment || onEditPayment ? (_jsx(DropdownMenuSeparator, {})) : null, _jsxs(DropdownMenuItem, { variant: "destructive", onClick: () => setDeleteTarget(row), children: [_jsx(Trash2, { className: "mr-2 h-4 w-4" }), card.actions.delete] })] })) : null] })] }) })) : null] }, payment.id));
101
118
  }) })] }) })) }), onDeletePayment ? (_jsx(AlertDialog, { open: Boolean(deleteTarget), onOpenChange: (next) => {
102
119
  if (!next && !deletePending)
103
120
  setDeleteTarget(null);
@@ -45,8 +45,10 @@ export interface OptionUnitsStepperSectionProps {
45
45
  remaining?: string;
46
46
  unlimited?: string;
47
47
  fillsSlotCapacity?: string;
48
+ reviewLine?: string;
48
49
  };
49
50
  slotHasFiniteCapacity?: boolean;
51
+ invalidOptionUnitIds?: readonly string[];
50
52
  }
51
53
  /**
52
54
  * Rooms / per-unit stepper for booking-create flows. Drives
@@ -68,7 +70,7 @@ export interface OptionUnitsStepperSectionProps {
68
70
  * disables the "+" button — we don't let the UI submit a request that
69
71
  * would 409 at insert time.
70
72
  */
71
- export declare function OptionUnitsStepperSection({ value, onChange, productId, slotId, optionId, enabled, onUnitsChange, labels, slotHasFiniteCapacity, }: OptionUnitsStepperSectionProps): import("react/jsx-runtime").JSX.Element;
73
+ export declare function OptionUnitsStepperSection({ value, onChange, productId, slotId, optionId, enabled, onUnitsChange, labels, slotHasFiniteCapacity, invalidOptionUnitIds, }: OptionUnitsStepperSectionProps): import("react/jsx-runtime").JSX.Element;
72
74
  export declare function resolveOptionRemainingLabel({ totalRemaining, units, slotHasFiniteCapacity, remaining, unlimited, fillsSlotCapacity, }: {
73
75
  totalRemaining: number | null;
74
76
  units: ReadonlyArray<Pick<OptionUnitsStepperUnit, "unitType">>;
@@ -77,6 +79,7 @@ export declare function resolveOptionRemainingLabel({ totalRemaining, units, slo
77
79
  unlimited: string;
78
80
  fillsSlotCapacity?: string;
79
81
  }): string;
82
+ export declare function optionRowHasInvalidUnit(units: ReadonlyArray<Pick<OptionUnitsStepperUnit, "optionUnitId">>, invalidOptionUnitIds: ReadonlySet<string>): boolean;
80
83
  /**
81
84
  * Returns the `optionId` the slot is bound to, derived from the first
82
85
  * slot-availability row whose `optionUnitId` we can map to a known
@@ -1 +1 @@
1
- {"version":3,"file":"option-units-stepper-section.d.ts","sourceRoot":"","sources":["../../src/components/option-units-stepper-section.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"option-units-stepper-section.d.ts","sourceRoot":"","sources":["../../src/components/option-units-stepper-section.tsx"],"names":[],"mappings":"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"}
@@ -28,7 +28,7 @@ export const emptyOptionUnitsStepperValue = { quantities: {} };
28
28
  * disables the "+" button — we don't let the UI submit a request that
29
29
  * would 409 at insert time.
30
30
  */
31
- export function OptionUnitsStepperSection({ value, onChange, productId, slotId, optionId, enabled = true, onUnitsChange, labels, slotHasFiniteCapacity = false, }) {
31
+ export function OptionUnitsStepperSection({ value, onChange, productId, slotId, optionId, enabled = true, onUnitsChange, labels, slotHasFiniteCapacity = false, invalidOptionUnitIds = [], }) {
32
32
  const productsClient = useVoyantProductsContext();
33
33
  const messages = useBookingsUiMessagesOrDefault();
34
34
  const merged = { ...messages.roomsStepperSection.labels, ...labels };
@@ -106,6 +106,7 @@ export function OptionUnitsStepperSection({ value, onChange, productId, slotId,
106
106
  // the no-slot-rows branch and use the product fallback for everything.
107
107
  // See issue #960.
108
108
  const units = React.useMemo(() => mergeStepperUnits(availabilityUnitRows, optionUnitRows, slotOptionId, Boolean(slotId)), [availabilityUnitRows, optionUnitRows, slotOptionId, slotId]);
109
+ const invalidOptionUnitIdSet = React.useMemo(() => new Set(invalidOptionUnitIds), [invalidOptionUnitIds]);
109
110
  React.useEffect(() => {
110
111
  onUnitsChange?.(units);
111
112
  }, [onUnitsChange, units]);
@@ -173,6 +174,7 @@ export function OptionUnitsStepperSection({ value, onChange, productId, slotId,
173
174
  };
174
175
  return (_jsxs("div", { className: "flex flex-col gap-2 rounded-md border p-3", children: [_jsx(Label, { children: merged.heading }), _jsx("div", { className: "flex flex-col gap-2", children: optionRows.map(({ optionKey, optionName, primary, allUnits, totalRemaining }) => {
175
176
  const qty = value.quantities[primary.optionUnitId] ?? 0;
177
+ const isInvalid = optionRowHasInvalidUnit(allUnits, invalidOptionUnitIdSet);
176
178
  const remainingLabel = resolveOptionRemainingLabel({
177
179
  totalRemaining,
178
180
  units: allUnits,
@@ -182,7 +184,7 @@ export function OptionUnitsStepperSection({ value, onChange, productId, slotId,
182
184
  fillsSlotCapacity: merged.fillsSlotCapacity,
183
185
  });
184
186
  const atMax = totalRemaining !== null && qty >= totalRemaining;
185
- return (_jsxs("div", { className: "flex items-center gap-3 rounded-md border px-3 py-2", children: [_jsxs("div", { className: "flex-1", children: [_jsx("div", { className: "text-sm font-medium", children: optionName }), _jsx("div", { className: "text-xs text-muted-foreground", children: remainingLabel })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 w-7 p-0", onClick: () => setQuantity(primary.optionUnitId, Math.max(0, qty - 1)), disabled: qty <= 0, "aria-label": `${merged.decreaseUnitPrefix} ${optionName}`, children: _jsx(Minus, { className: "h-3.5 w-3.5" }) }), _jsx("span", { className: "min-w-[1.5rem] text-center text-sm tabular-nums", children: qty }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 w-7 p-0", onClick: () => setQuantity(primary.optionUnitId, qty + 1), disabled: atMax, "aria-label": `${merged.increaseUnitPrefix} ${optionName}`, children: _jsx(Plus, { className: "h-3.5 w-3.5" }) })] })] }, optionKey));
187
+ return (_jsxs("div", { className: `flex items-center gap-3 rounded-md border px-3 py-2 ${isInvalid ? "border-destructive/70 bg-destructive/5 ring-1 ring-destructive/20" : ""}`, "aria-invalid": isInvalid ? true : undefined, children: [_jsxs("div", { className: "flex-1", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm font-medium", children: [_jsx("span", { children: optionName }), isInvalid ? (_jsx("span", { className: "rounded-sm bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium text-destructive", children: merged.reviewLine })) : null] }), _jsx("div", { className: "text-xs text-muted-foreground", children: remainingLabel })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 w-7 p-0", onClick: () => setQuantity(primary.optionUnitId, Math.max(0, qty - 1)), disabled: qty <= 0, "aria-label": `${merged.decreaseUnitPrefix} ${optionName}`, children: _jsx(Minus, { className: "h-3.5 w-3.5" }) }), _jsx("span", { className: "min-w-[1.5rem] text-center text-sm tabular-nums", children: qty }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 w-7 p-0", onClick: () => setQuantity(primary.optionUnitId, qty + 1), disabled: atMax, "aria-label": `${merged.increaseUnitPrefix} ${optionName}`, children: _jsx(Plus, { className: "h-3.5 w-3.5" }) })] })] }, optionKey));
186
188
  }) })] }));
187
189
  }
188
190
  export function resolveOptionRemainingLabel({ totalRemaining, units, slotHasFiniteCapacity, remaining, unlimited, fillsSlotCapacity, }) {
@@ -193,6 +195,9 @@ export function resolveOptionRemainingLabel({ totalRemaining, units, slotHasFini
193
195
  }
194
196
  return unlimited;
195
197
  }
198
+ export function optionRowHasInvalidUnit(units, invalidOptionUnitIds) {
199
+ return units.some((unit) => invalidOptionUnitIds.has(unit.optionUnitId));
200
+ }
196
201
  /**
197
202
  * Returns the `optionId` the slot is bound to, derived from the first
198
203
  * slot-availability row whose `optionUnitId` we can map to a known
@@ -13,6 +13,19 @@ export interface TravelerEntry {
13
13
  dateOfBirth: string | null;
14
14
  /** option_unit_id the traveler is assigned to (matches OptionUnitsStepper units). */
15
15
  roomUnitId: string | null;
16
+ /**
17
+ * Operator-intent enum for `roomUnitId`:
18
+ *
19
+ * - `auto`: assignment was system-derived, eligible to be re-derived
20
+ * when units / DOB / role change.
21
+ * - `manual`: operator clicked a category/room control. Resolver
22
+ * respects the value when still valid.
23
+ * - `none`: operator explicitly picked "No room". Stays null
24
+ * through resolver re-runs.
25
+ *
26
+ * Defaults to `auto` when omitted. See voyantjs/voyant#1267.
27
+ */
28
+ roomUnitAssignmentSource?: "auto" | "manual" | "none";
16
29
  }
17
30
  export interface TravelerListValue {
18
31
  travelers: TravelerEntry[];
@@ -20,11 +33,7 @@ export interface TravelerListValue {
20
33
  export declare const emptyTravelerListValue: TravelerListValue;
21
34
  /** Factory for a blank row — `role` defaults to `adult` unless the list is empty. */
22
35
  export declare function createBlankTraveler(role?: TravelerRole): TravelerEntry;
23
- /**
24
- * Compute integer age in full years from an ISO date-of-birth string.
25
- * Returns null when the DOB is missing or unparseable.
26
- */
27
- export declare function computeAgeYears(dob: string | null, now?: Date): number | null;
36
+ export { computeAgeYears } from "@voyantjs/bookings/pricing-assignment";
28
37
  /**
29
38
  * Derive the age-banded traveler role from DOB. Falls back to `adult`
30
39
  * when DOB is missing so partial entries still typecheck downstream.
@@ -1 +1 @@
1
- {"version":3,"file":"travelers-section.d.ts","sourceRoot":"","sources":["../../src/components/travelers-section.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"travelers-section.d.ts","sourceRoot":"","sources":["../../src/components/travelers-section.tsx"],"names":[],"mappings":"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"}
@@ -21,25 +21,14 @@ export function createBlankTraveler(role = "adult") {
21
21
  role,
22
22
  dateOfBirth: null,
23
23
  roomUnitId: null,
24
+ roomUnitAssignmentSource: "auto",
24
25
  };
25
26
  }
26
- /**
27
- * Compute integer age in full years from an ISO date-of-birth string.
28
- * Returns null when the DOB is missing or unparseable.
29
- */
30
- export function computeAgeYears(dob, now = new Date()) {
31
- if (!dob)
32
- return null;
33
- const birth = new Date(dob);
34
- if (Number.isNaN(birth.getTime()))
35
- return null;
36
- let age = now.getFullYear() - birth.getFullYear();
37
- const beforeBirthday = now.getMonth() < birth.getMonth() ||
38
- (now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate());
39
- if (beforeBirthday)
40
- age -= 1;
41
- return age >= 0 ? age : null;
42
- }
27
+ // Re-export `computeAgeYears` from the canonical assignment module so
28
+ // existing consumers of `travelers-section`'s public surface keep
29
+ // working. The implementation lives in `@voyantjs/bookings/pricing-assignment`.
30
+ export { computeAgeYears } from "@voyantjs/bookings/pricing-assignment";
31
+ import { computeAgeYears as _computeAgeYears, matchUnitByDob as matchAssignmentUnitByDob, matchUnitByRoleHint as matchAssignmentUnitByRoleHint, } from "@voyantjs/bookings/pricing-assignment";
43
32
  /**
44
33
  * Derive the age-banded traveler role from DOB. Falls back to `adult`
45
34
  * when DOB is missing so partial entries still typecheck downstream.
@@ -50,7 +39,7 @@ export function computeAgeYears(dob, now = new Date()) {
50
39
  * - adult: 18+
51
40
  */
52
41
  export function deriveTravelerRoleFromDob(dob) {
53
- const age = computeAgeYears(dob);
42
+ const age = _computeAgeYears(dob);
54
43
  if (age == null)
55
44
  return "adult";
56
45
  if (age < 2)
@@ -60,46 +49,27 @@ export function deriveTravelerRoleFromDob(dob) {
60
49
  return "adult";
61
50
  }
62
51
  /**
63
- * Find the unit whose `[minAge, maxAge]` window contains the given
64
- * DOB-derived age. Returns the unit id, or null if no match (or DOB
65
- * unset). Person-typed units are preferred; everything else is
66
- * ignored. Caller falls back to a default unit when null.
52
+ * Adapter from this file's `RoomGroupUnit` shape (UI-side, uses
53
+ * `unitId`) to the canonical `PricingAssignmentUnit` shape (uses
54
+ * `optionUnitId`). Phase 1 of voyantjs/voyant#1267 will collapse these
55
+ * by renaming the UI shape.
67
56
  */
57
+ function roomGroupUnitsAsAssignmentUnits(units) {
58
+ return units.map((u) => ({
59
+ optionId: null,
60
+ optionUnitId: u.unitId,
61
+ unitName: u.unitName,
62
+ unitCode: u.unitCode,
63
+ minAge: u.minAge,
64
+ maxAge: u.maxAge,
65
+ unitType: u.unitType,
66
+ }));
67
+ }
68
68
  function matchUnitByDob(units, dob) {
69
- if (!dob)
70
- return null;
71
- const age = computeAgeYears(dob);
72
- if (age == null)
73
- return null;
74
- const personUnits = units.filter((u) => u.unitType == null || u.unitType === "person");
75
- const match = personUnits.find((u) => (u.minAge == null || age >= u.minAge) && (u.maxAge == null || age <= u.maxAge));
76
- return match?.unitId ?? null;
69
+ return matchAssignmentUnitByDob(roomGroupUnitsAsAssignmentUnits(units), dob);
77
70
  }
78
- /**
79
- * Find the unit matching a role hint when DOB is missing. Maps the
80
- * role to a representative age and matches against `[minAge, maxAge]`.
81
- * Returns null when the role doesn't carry an age signal (e.g. `lead`).
82
- *
83
- * Routes `infant` to whichever band covers ~1y (e.g. `child_0_5`) and
84
- * `child` to whichever covers ~8y (e.g. `child_6_12`), regardless of
85
- * how the product codes the unit names.
86
- */
87
71
  function matchUnitByRoleHint(units, role) {
88
- if (!role || role === "lead")
89
- return null;
90
- const HINT_AGE = {
91
- adult: 30,
92
- child: 8,
93
- infant: 1,
94
- };
95
- const hintAge = HINT_AGE[role];
96
- if (hintAge == null)
97
- return null;
98
- // Only consider units with explicit age bands — units with null
99
- // min/max would all spuriously match any hint age.
100
- const banded = units.filter((u) => (u.unitType == null || u.unitType === "person") && (u.minAge != null || u.maxAge != null));
101
- const match = banded.find((u) => (u.minAge == null || hintAge >= u.minAge) && (u.maxAge == null || hintAge <= u.maxAge));
102
- return match?.unitId ?? null;
72
+ return matchAssignmentUnitByRoleHint(roomGroupUnitsAsAssignmentUnits(units), role);
103
73
  }
104
74
  /**
105
75
  * The Room dropdown lists one item per option (keyed by the option's
@@ -190,25 +160,13 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
190
160
  matchUnitByRoleHint(group.units, role) ??
191
161
  group.primaryUnitId);
192
162
  }, [roomUnits, roomGroups]);
193
- // Race fix: travelers added before the option-units queries resolve
194
- // end up with `roomUnitId: null`. Once units arrive, back-fill any
195
- // missing assignments so the static fallback's role hint (Child /
196
- // Infant) is honored and `redistributeByAge` doesn't silently price
197
- // them as adults.
198
- React.useEffect(() => {
199
- if (!roomUnits || roomUnits.length === 0)
200
- return;
201
- if (!value.travelers.some((t) => !t.roomUnitId))
202
- return;
203
- const next = value.travelers.map((t) => t.roomUnitId ? t : { ...t, roomUnitId: pickRoomUnitIdForNewTraveler(t.dateOfBirth, t.role) });
204
- // Guard against infinite re-runs if hydration can't find a unit
205
- // (e.g. empty roomGroups): no point dispatching an onChange that
206
- // doesn't actually change anything.
207
- const changed = next.some((t, i) => t.roomUnitId !== value.travelers[i]?.roomUnitId);
208
- if (!changed)
209
- return;
210
- onChange({ travelers: next });
211
- }, [roomUnits, value.travelers, onChange, pickRoomUnitIdForNewTraveler]);
163
+ // Note: there is no hydration effect any more. Travelers attached
164
+ // before the option-units queries resolve get a `null` roomUnitId
165
+ // and `roomUnitAssignmentSource: "auto"`; the resolver in
166
+ // `@voyantjs/bookings/pricing-assignment` re-derives them at every
167
+ // preview/submit pass, and respects `"none"` (explicit No room) /
168
+ // `"manual"` (operator click) when set. Operator intent is now
169
+ // declarative on the row, not implicit in a one-shot effect.
212
170
  const addRow = () => {
213
171
  // First traveler defaults to `lead` so the operator doesn't have to
214
172
  // remember to flip the role on the initial row.
@@ -217,7 +175,11 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
217
175
  onChange({
218
176
  travelers: [
219
177
  ...value.travelers,
220
- { ...blank, roomUnitId: pickRoomUnitIdForNewTraveler(null, role) },
178
+ {
179
+ ...blank,
180
+ roomUnitId: pickRoomUnitIdForNewTraveler(null, role),
181
+ roomUnitAssignmentSource: "auto",
182
+ },
221
183
  ],
222
184
  });
223
185
  };
@@ -229,7 +191,11 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
229
191
  onChange({
230
192
  travelers: [
231
193
  ...value.travelers,
232
- { ...traveler, roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth, role) },
194
+ {
195
+ ...traveler,
196
+ roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth, role),
197
+ roomUnitAssignmentSource: "auto",
198
+ },
233
199
  ],
234
200
  });
235
201
  };
@@ -239,7 +205,11 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
239
205
  onChange({
240
206
  travelers: [
241
207
  ...value.travelers,
242
- { ...traveler, roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth, role) },
208
+ {
209
+ ...traveler,
210
+ roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth, role),
211
+ roomUnitAssignmentSource: "auto",
212
+ },
243
213
  ],
244
214
  });
245
215
  };
@@ -280,6 +250,18 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
280
250
  phone: person.phone ?? "",
281
251
  preferredLanguage: person.preferredLanguage ?? "",
282
252
  dateOfBirth: person.dateOfBirth ?? null,
253
+ // Re-derive the unit assignment when the operator
254
+ // swaps the linked CRM person — DOB changes, so
255
+ // the resolver may move the row to a different
256
+ // age band. Skip when the operator has already
257
+ // explicitly picked a room/category/"No room".
258
+ ...(traveler.roomUnitAssignmentSource === "manual" ||
259
+ traveler.roomUnitAssignmentSource === "none"
260
+ ? {}
261
+ : {
262
+ roomUnitId: pickRoomUnitIdForNewTraveler(person.dateOfBirth ?? null, traveler.role),
263
+ roomUnitAssignmentSource: "auto",
264
+ }),
283
265
  }), onClear: () => updateAt(index, {
284
266
  personId: null,
285
267
  firstName: "",
@@ -288,15 +270,31 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
288
270
  phone: "",
289
271
  preferredLanguage: "",
290
272
  dateOfBirth: null,
273
+ ...(traveler.roomUnitAssignmentSource === "manual" ||
274
+ traveler.roomUnitAssignmentSource === "none"
275
+ ? {}
276
+ : {
277
+ roomUnitId: pickRoomUnitIdForNewTraveler(null, traveler.role),
278
+ roomUnitAssignmentSource: "auto",
279
+ }),
291
280
  }) }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(TravelerCategoryButtons, { traveler: traveler, roomGroups: roomGroups, fallbackLabels: {
292
281
  category: merged.category,
293
282
  adult: merged.roleAdult,
294
283
  child: merged.roleChild,
295
284
  infant: merged.roleInfant,
296
- }, 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, {
285
+ }, onPickUnit: (unitId, nextRole, source) => updateAt(index, {
286
+ roomUnitId: unitId,
287
+ role: nextRole,
288
+ // Only freeze as manual when the dynamic button
289
+ // actually picked a unit. Role-only clicks via
290
+ // the static fallback stay `auto` so the
291
+ // resolver can re-derive once real units load.
292
+ roomUnitAssignmentSource: source,
293
+ }) }), 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, {
297
294
  roomUnitId: v === NO_ROOM || !v
298
295
  ? null
299
296
  : pickUnitForRoomChange(traveler.roomUnitId, v, roomGroups),
297
+ roomUnitAssignmentSource: v === NO_ROOM || !v ? "none" : "manual",
300
298
  }), 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))) }))] }));
301
299
  }
302
300
  function TravelerPersonPicker({ personId, labels, pinnedPeople = [], onSelect, onClear, }) {
@@ -375,6 +373,7 @@ function createTravelerFromPerson(person, role) {
375
373
  role: effectiveRole,
376
374
  dateOfBirth,
377
375
  roomUnitId: null,
376
+ roomUnitAssignmentSource: "auto",
378
377
  };
379
378
  }
380
379
  function formatPersonName(person) {
@@ -431,16 +430,21 @@ function TravelerCategoryButtons({ traveler, roomGroups, fallbackLabels, onPickU
431
430
  ].map(([category, label]) => {
432
431
  const { active, nextRole, shouldUpdate } = getStaticTravelerCategoryButtonState(traveler, category);
433
432
  return (_jsx(Button, { type: "button", size: "sm", variant: active ? "default" : "outline", className: "h-7 text-xs", onClick: () => {
433
+ // Static fallback: operator chose a role, not a
434
+ // concrete unit. Pass `source: "auto"` so the
435
+ // resolver re-derives the unit instead of freezing
436
+ // a stale auto-assignment as manual.
434
437
  if (shouldUpdate)
435
- onPickUnit(traveler.roomUnitId, nextRole);
438
+ onPickUnit(traveler.roomUnitId, nextRole, "auto");
436
439
  }, children: label }, category));
437
440
  }) })] }));
438
441
  }
439
442
  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) => {
440
443
  const { active, nextRole, shouldUpdate } = getDynamicTravelerCategoryButtonState(traveler, unit);
441
444
  return (_jsx(Button, { type: "button", size: "sm", variant: active ? "default" : "outline", className: "h-7 text-xs", onClick: () => {
445
+ // Dynamic button: real unit pick, freeze as manual.
442
446
  if (shouldUpdate)
443
- onPickUnit(unit.unitId, nextRole);
447
+ onPickUnit(unit.unitId, nextRole, "manual");
444
448
  }, title: unit.minAge != null || unit.maxAge != null
445
449
  ? `${unit.minAge ?? "0"}–${unit.maxAge ?? "∞"}`
446
450
  : undefined, children: unit.unitName }, unit.unitId));
package/dist/i18n/en.d.ts CHANGED
@@ -487,6 +487,7 @@ export declare const bookingsUiEn: {
487
487
  fillsSlotCapacity: string;
488
488
  decreaseUnitPrefix: string;
489
489
  increaseUnitPrefix: string;
490
+ reviewLine: string;
490
491
  };
491
492
  };
492
493
  sharedRoomSection: {
@@ -1311,6 +1312,7 @@ export declare const bookingsUiEn: {
1311
1312
  actions: {
1312
1313
  open: string;
1313
1314
  view: string;
1315
+ convertToInvoice: string;
1314
1316
  edit: string;
1315
1317
  delete: string;
1316
1318
  };
@@ -1 +1 @@
1
- {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/i18n/en.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA42CK,CAAA"}
1
+ {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/i18n/en.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA82CK,CAAA"}
package/dist/i18n/en.js CHANGED
@@ -487,6 +487,7 @@ export const bookingsUiEn = {
487
487
  fillsSlotCapacity: "fills slot capacity",
488
488
  decreaseUnitPrefix: "Decrease",
489
489
  increaseUnitPrefix: "Increase",
490
+ reviewLine: "Review this line",
490
491
  },
491
492
  },
492
493
  sharedRoomSection: {
@@ -1311,6 +1312,7 @@ export const bookingsUiEn = {
1311
1312
  actions: {
1312
1313
  open: "Open payment menu",
1313
1314
  view: "View payment",
1315
+ convertToInvoice: "Convert to invoice",
1314
1316
  edit: "Edit payment",
1315
1317
  delete: "Delete payment",
1316
1318
  },
@@ -401,6 +401,7 @@ export type BookingsUiMessages = {
401
401
  fillsSlotCapacity: string;
402
402
  decreaseUnitPrefix: string;
403
403
  increaseUnitPrefix: string;
404
+ reviewLine: string;
404
405
  };
405
406
  };
406
407
  sharedRoomSection: {
@@ -1164,6 +1165,7 @@ export type BookingsUiMessages = {
1164
1165
  /** Trigger button screen-reader label. */
1165
1166
  open: string;
1166
1167
  view: string;
1168
+ convertToInvoice: string;
1167
1169
  edit: string;
1168
1170
  delete: string;
1169
1171
  };