@voyantjs/bookings-ui 0.80.18 → 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":"AAgBA,iEAAiE;AACjE,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACnC;AAED,eAAO,MAAM,4BAA4B,EAAE,uBAA4C,CAAA;AAEvF,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;IAChB,yFAAyF;IACzF,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,IAAI,CAAA;IAC/E,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CACzB;AAED,MAAM,WAAW,8BAA8B;IAC7C,KAAK,EAAE,uBAAuB,CAAA;IAC9B,QAAQ,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,IAAI,CAAA;IAClD,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,sBAAsB,EAAE,KAAK,IAAI,CAAA;IACzD,MAAM,CAAC,EAAE;QACP,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,iBAAiB,CAAC,EAAE,MAAM,CAAA;QAC1B,UAAU,CAAC,EAAE,MAAM,CAAA;KACpB,CAAA;IACD,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B,oBAAoB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACzC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,yBAAyB,CAAC,EACxC,KAAK,EACL,QAAQ,EACR,SAAS,EACT,MAAM,EACN,QAAQ,EACR,OAAc,EACd,aAAa,EACb,MAAM,EACN,qBAA6B,EAC7B,oBAAyB,GAC1B,EAAE,8BAA8B,2CA+OhC;AAED,wBAAgB,2BAA2B,CAAC,EAC1C,cAAc,EACd,KAAK,EACL,qBAAqB,EACrB,SAAS,EACT,SAAS,EACT,iBAAiB,GAClB,EAAE;IACD,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,KAAK,EAAE,aAAa,CAAC,IAAI,CAAC,sBAAsB,EAAE,UAAU,CAAC,CAAC,CAAA;IAC9D,qBAAqB,EAAE,OAAO,CAAA;IAC9B,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B,GAAG,MAAM,CAMT;AAED,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,aAAa,CAAC,IAAI,CAAC,sBAAsB,EAAE,cAAc,CAAC,CAAC,EAClE,oBAAoB,EAAE,WAAW,CAAC,MAAM,CAAC,WAG1C;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,aAAa,CAAC;IAAE,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC,EACjD,cAAc,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EAC3C,gBAAgB,EAAE,MAAM,GAAG,IAAI,GAC9B,MAAM,GAAG,IAAI,CAMf;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,aAAa,CAAC,sBAAsB,CAAC,EAC/C,WAAW,EAAE,aAAa,CAAC,sBAAsB,CAAC,EAClD,YAAY,EAAE,MAAM,GAAG,IAAI,EAC3B,OAAO,EAAE,OAAO,GACf,sBAAsB,EAAE,CAM1B"}
@@ -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,34 +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. Runs exactly once per units-load transition — after
198
- // that, `roomUnitId: null` is treated as the operator's explicit
199
- // "No room" choice from the Room select and left alone. Reset on
200
- // empty `roomUnits` so the next load (e.g. product change) rehydrates.
201
- const hasHydratedNullsRef = React.useRef(false);
202
- React.useEffect(() => {
203
- if (!roomUnits || roomUnits.length === 0) {
204
- hasHydratedNullsRef.current = false;
205
- return;
206
- }
207
- if (hasHydratedNullsRef.current)
208
- return;
209
- hasHydratedNullsRef.current = true;
210
- if (!value.travelers.some((t) => !t.roomUnitId))
211
- return;
212
- const next = value.travelers.map((t) => t.roomUnitId ? t : { ...t, roomUnitId: pickRoomUnitIdForNewTraveler(t.dateOfBirth, t.role) });
213
- // Guard against a stray onChange if hydration can't find a unit
214
- // (e.g. empty roomGroups): no point dispatching an update that
215
- // doesn't actually change anything.
216
- const changed = next.some((t, i) => t.roomUnitId !== value.travelers[i]?.roomUnitId);
217
- if (!changed)
218
- return;
219
- onChange({ travelers: next });
220
- }, [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.
221
170
  const addRow = () => {
222
171
  // First traveler defaults to `lead` so the operator doesn't have to
223
172
  // remember to flip the role on the initial row.
@@ -226,7 +175,11 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
226
175
  onChange({
227
176
  travelers: [
228
177
  ...value.travelers,
229
- { ...blank, roomUnitId: pickRoomUnitIdForNewTraveler(null, role) },
178
+ {
179
+ ...blank,
180
+ roomUnitId: pickRoomUnitIdForNewTraveler(null, role),
181
+ roomUnitAssignmentSource: "auto",
182
+ },
230
183
  ],
231
184
  });
232
185
  };
@@ -238,7 +191,11 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
238
191
  onChange({
239
192
  travelers: [
240
193
  ...value.travelers,
241
- { ...traveler, roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth, role) },
194
+ {
195
+ ...traveler,
196
+ roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth, role),
197
+ roomUnitAssignmentSource: "auto",
198
+ },
242
199
  ],
243
200
  });
244
201
  };
@@ -248,7 +205,11 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
248
205
  onChange({
249
206
  travelers: [
250
207
  ...value.travelers,
251
- { ...traveler, roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth, role) },
208
+ {
209
+ ...traveler,
210
+ roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth, role),
211
+ roomUnitAssignmentSource: "auto",
212
+ },
252
213
  ],
253
214
  });
254
215
  };
@@ -289,6 +250,18 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
289
250
  phone: person.phone ?? "",
290
251
  preferredLanguage: person.preferredLanguage ?? "",
291
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
+ }),
292
265
  }), onClear: () => updateAt(index, {
293
266
  personId: null,
294
267
  firstName: "",
@@ -297,15 +270,31 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
297
270
  phone: "",
298
271
  preferredLanguage: "",
299
272
  dateOfBirth: null,
273
+ ...(traveler.roomUnitAssignmentSource === "manual" ||
274
+ traveler.roomUnitAssignmentSource === "none"
275
+ ? {}
276
+ : {
277
+ roomUnitId: pickRoomUnitIdForNewTraveler(null, traveler.role),
278
+ roomUnitAssignmentSource: "auto",
279
+ }),
300
280
  }) }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(TravelerCategoryButtons, { traveler: traveler, roomGroups: roomGroups, fallbackLabels: {
301
281
  category: merged.category,
302
282
  adult: merged.roleAdult,
303
283
  child: merged.roleChild,
304
284
  infant: merged.roleInfant,
305
- }, 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, {
306
294
  roomUnitId: v === NO_ROOM || !v
307
295
  ? null
308
296
  : pickUnitForRoomChange(traveler.roomUnitId, v, roomGroups),
297
+ roomUnitAssignmentSource: v === NO_ROOM || !v ? "none" : "manual",
309
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))) }))] }));
310
299
  }
311
300
  function TravelerPersonPicker({ personId, labels, pinnedPeople = [], onSelect, onClear, }) {
@@ -384,6 +373,7 @@ function createTravelerFromPerson(person, role) {
384
373
  role: effectiveRole,
385
374
  dateOfBirth,
386
375
  roomUnitId: null,
376
+ roomUnitAssignmentSource: "auto",
387
377
  };
388
378
  }
389
379
  function formatPersonName(person) {
@@ -440,16 +430,21 @@ function TravelerCategoryButtons({ traveler, roomGroups, fallbackLabels, onPickU
440
430
  ].map(([category, label]) => {
441
431
  const { active, nextRole, shouldUpdate } = getStaticTravelerCategoryButtonState(traveler, category);
442
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.
443
437
  if (shouldUpdate)
444
- onPickUnit(traveler.roomUnitId, nextRole);
438
+ onPickUnit(traveler.roomUnitId, nextRole, "auto");
445
439
  }, children: label }, category));
446
440
  }) })] }));
447
441
  }
448
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) => {
449
443
  const { active, nextRole, shouldUpdate } = getDynamicTravelerCategoryButtonState(traveler, unit);
450
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.
451
446
  if (shouldUpdate)
452
- onPickUnit(unit.unitId, nextRole);
447
+ onPickUnit(unit.unitId, nextRole, "manual");
453
448
  }, title: unit.minAge != null || unit.maxAge != null
454
449
  ? `${unit.minAge ?? "0"}–${unit.maxAge ?? "∞"}`
455
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
  };