@voyantjs/bookings-ui 0.20.0 → 0.21.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.
- package/dist/components/booking-dialog.d.ts.map +1 -1
- package/dist/components/booking-dialog.js +10 -1
- package/dist/components/booking-group-section.d.ts +11 -1
- package/dist/components/booking-group-section.d.ts.map +1 -1
- package/dist/components/booking-group-section.js +16 -2
- package/dist/components/booking-item-list.d.ts.map +1 -1
- package/dist/components/booking-item-list.js +71 -7
- package/dist/components/booking-list.d.ts.map +1 -1
- package/dist/components/booking-list.js +11 -3
- package/dist/components/booking-payments-summary.d.ts +28 -1
- package/dist/components/booking-payments-summary.d.ts.map +1 -1
- package/dist/components/booking-payments-summary.js +66 -11
- package/dist/components/traveler-list.d.ts +2 -1
- package/dist/components/traveler-list.d.ts.map +1 -1
- package/dist/components/traveler-list.js +126 -12
- package/dist/i18n/en.d.ts +12 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +13 -1
- package/dist/i18n/messages.d.ts +14 -1
- package/dist/i18n/messages.d.ts.map +1 -1
- package/dist/i18n/provider.d.ts +24 -0
- package/dist/i18n/provider.d.ts.map +1 -1
- package/dist/i18n/ro.d.ts +12 -0
- package/dist/i18n/ro.d.ts.map +1 -1
- package/dist/i18n/ro.js +13 -1
- package/dist/journey/components/booking-journey.d.ts +3 -0
- package/dist/journey/components/booking-journey.d.ts.map +1 -0
- package/dist/journey/components/booking-journey.js +376 -0
- package/dist/journey/components/contract-preview-dialog.d.ts +47 -0
- package/dist/journey/components/contract-preview-dialog.d.ts.map +1 -0
- package/dist/journey/components/contract-preview-dialog.js +119 -0
- package/dist/journey/components/journey-steps.d.ts +47 -0
- package/dist/journey/components/journey-steps.d.ts.map +1 -0
- package/dist/journey/components/journey-steps.js +582 -0
- package/dist/journey/components/side-panel.d.ts +12 -0
- package/dist/journey/components/side-panel.d.ts.map +1 -0
- package/dist/journey/components/side-panel.js +172 -0
- package/dist/journey/components/step-header.d.ts +7 -0
- package/dist/journey/components/step-header.d.ts.map +1 -0
- package/dist/journey/components/step-header.js +28 -0
- package/dist/journey/index.d.ts +18 -0
- package/dist/journey/index.d.ts.map +1 -0
- package/dist/journey/index.js +17 -0
- package/dist/journey/lib/draft-state.d.ts +34 -0
- package/dist/journey/lib/draft-state.d.ts.map +1 -0
- package/dist/journey/lib/draft-state.js +54 -0
- package/dist/journey/types.d.ts +248 -0
- package/dist/journey/types.d.ts.map +1 -0
- package/dist/journey/types.js +17 -0
- package/package.json +26 -16
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"booking-dialog.d.ts","sourceRoot":"","sources":["../../src/components/booking-dialog.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,aAAa,EAAsB,MAAM,0BAA0B,CAAA;
|
|
1
|
+
{"version":3,"file":"booking-dialog.d.ts","sourceRoot":"","sources":["../../src/components/booking-dialog.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,aAAa,EAAsB,MAAM,0BAA0B,CAAA;AAuDjF,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,OAAO,CAAC,EAAE,aAAa,CAAA;IACvB,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAA;IAC5C;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAWD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,YAAY,EACZ,OAAO,EACP,SAAS,EACT,gBAAgB,GACjB,EAAE,kBAAkB,2CAoBpB"}
|
|
@@ -14,7 +14,16 @@ import { BookingCreateDialog } from "./booking-create-dialog";
|
|
|
14
14
|
function createBookingFormSchema(messages) {
|
|
15
15
|
return z.object({
|
|
16
16
|
bookingNumber: z.string().min(1, messages.bookingDialog.validation.bookingNumberRequired),
|
|
17
|
-
status: z.enum([
|
|
17
|
+
status: z.enum([
|
|
18
|
+
"draft",
|
|
19
|
+
"on_hold",
|
|
20
|
+
"awaiting_payment",
|
|
21
|
+
"confirmed",
|
|
22
|
+
"in_progress",
|
|
23
|
+
"completed",
|
|
24
|
+
"expired",
|
|
25
|
+
"cancelled",
|
|
26
|
+
]),
|
|
18
27
|
sellCurrency: z.string().min(3).max(3, messages.bookingDialog.validation.sellCurrencyInvalid),
|
|
19
28
|
sellAmountCents: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
|
|
20
29
|
costAmountCents: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
|
|
@@ -12,6 +12,16 @@ export interface BookingGroupSectionProps {
|
|
|
12
12
|
* to override.
|
|
13
13
|
*/
|
|
14
14
|
optionUnitId?: string | null;
|
|
15
|
+
/**
|
|
16
|
+
* When true (default), the section hides itself when the booking
|
|
17
|
+
* has no `accommodation` items AND no existing group — shared-room
|
|
18
|
+
* pairing only makes sense for bookings that include a room. Tours,
|
|
19
|
+
* ground-transfer, and inquiry bookings see no Shared-Room card.
|
|
20
|
+
*
|
|
21
|
+
* Set to `false` to always render the section (e.g. legacy
|
|
22
|
+
* dashboards that display it for every booking regardless).
|
|
23
|
+
*/
|
|
24
|
+
hideWithoutAccommodation?: boolean;
|
|
15
25
|
}
|
|
16
|
-
export declare function BookingGroupSection({ bookingId, productId, optionUnitId, }: BookingGroupSectionProps): import("react/jsx-runtime").JSX.Element;
|
|
26
|
+
export declare function BookingGroupSection({ bookingId, productId, optionUnitId, hideWithoutAccommodation, }: BookingGroupSectionProps): import("react/jsx-runtime").JSX.Element | null;
|
|
17
27
|
//# sourceMappingURL=booking-group-section.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"booking-group-section.d.ts","sourceRoot":"","sources":["../../src/components/booking-group-section.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"booking-group-section.d.ts","sourceRoot":"","sources":["../../src/components/booking-group-section.tsx"],"names":[],"mappings":"AAgBA,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B;;;;;;;;OAQG;IACH,wBAAwB,CAAC,EAAE,OAAO,CAAA;CACnC;AAED,wBAAgB,mBAAmB,CAAC,EAClC,SAAS,EACT,SAAS,EACT,YAAY,EACZ,wBAA+B,GAChC,EAAE,wBAAwB,kDA2I1B"}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useBookingGroup, useBookingGroupForBooking, useBookingGroupMemberMutation, useBookingPrimaryProduct, } from "@voyantjs/bookings-react";
|
|
3
|
+
import { useBookingGroup, useBookingGroupForBooking, useBookingGroupMemberMutation, useBookingItems, useBookingPrimaryProduct, } from "@voyantjs/bookings-react";
|
|
4
4
|
import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@voyantjs/ui/components";
|
|
5
5
|
import { Link2, Unlink, Users } from "lucide-react";
|
|
6
6
|
import * as React from "react";
|
|
7
7
|
import { formatMessage, useBookingsUiMessagesOrDefault } from "../i18n/provider";
|
|
8
8
|
import { BookingGroupLinkDialog } from "./booking-group-link-dialog";
|
|
9
|
-
export function BookingGroupSection({ bookingId, productId, optionUnitId, }) {
|
|
9
|
+
export function BookingGroupSection({ bookingId, productId, optionUnitId, hideWithoutAccommodation = true, }) {
|
|
10
10
|
const [linkDialogOpen, setLinkDialogOpen] = React.useState(false);
|
|
11
11
|
const messages = useBookingsUiMessagesOrDefault();
|
|
12
12
|
// Auto-resolve product/option-unit from items when the caller hasn't
|
|
@@ -15,12 +15,26 @@ export function BookingGroupSection({ bookingId, productId, optionUnitId, }) {
|
|
|
15
15
|
const autoResolved = useBookingPrimaryProduct(bookingId, { enabled: shouldAutoResolve });
|
|
16
16
|
const effectiveProductId = productId === undefined ? autoResolved.productId : productId;
|
|
17
17
|
const effectiveOptionUnitId = optionUnitId === undefined ? autoResolved.optionUnitId : optionUnitId;
|
|
18
|
+
// Fetch items to detect whether the booking has accommodation —
|
|
19
|
+
// shared-room pairing is meaningful only for room-style products.
|
|
20
|
+
// The `useBookingItems` query is already in cache from
|
|
21
|
+
// `useBookingPrimaryProduct` above, so this is a free read.
|
|
22
|
+
const { data: itemsData } = useBookingItems(bookingId);
|
|
23
|
+
const items = itemsData?.data ?? [];
|
|
24
|
+
const hasAccommodationItem = items.some((i) => i.itemType === "accommodation");
|
|
18
25
|
const { data: groupForBookingData } = useBookingGroupForBooking(bookingId);
|
|
19
26
|
const group = groupForBookingData?.data ?? null;
|
|
20
27
|
const groupId = group?.id ?? null;
|
|
21
28
|
const { data: groupDetail } = useBookingGroup(groupId, { enabled: Boolean(groupId) });
|
|
22
29
|
const members = groupDetail?.data?.members ?? [];
|
|
23
30
|
const { remove: removeMember } = useBookingGroupMemberMutation();
|
|
31
|
+
// Hide the section entirely when there's nothing to render or
|
|
32
|
+
// pair: no group exists yet AND the booking has no accommodation
|
|
33
|
+
// line item. Operators on tour-only bookings shouldn't see a card
|
|
34
|
+
// they can't usefully act on.
|
|
35
|
+
if (hideWithoutAccommodation && !group && !hasAccommodationItem) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
24
38
|
const handleRemove = async () => {
|
|
25
39
|
if (!groupId)
|
|
26
40
|
return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"booking-item-list.d.ts","sourceRoot":"","sources":["../../src/components/booking-item-list.tsx"],"names":[],"mappings":"AAwBA,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,wBAAgB,eAAe,CAAC,EAAE,SAAS,EAAE,EAAE,oBAAoB,
|
|
1
|
+
{"version":3,"file":"booking-item-list.d.ts","sourceRoot":"","sources":["../../src/components/booking-item-list.tsx"],"names":[],"mappings":"AAwBA,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,wBAAgB,eAAe,CAAC,EAAE,SAAS,EAAE,EAAE,oBAAoB,2CAiLlE"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useBookingItemMutation, useBookingItems, } from "@voyantjs/bookings-react";
|
|
4
4
|
import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@voyantjs/ui/components";
|
|
5
|
-
import { ChevronDown, ChevronRight, Package, Pencil, Plus, Trash2 } from "lucide-react";
|
|
5
|
+
import { Calendar, ChevronDown, ChevronRight, Package, Pencil, Plus, Trash2 } from "lucide-react";
|
|
6
6
|
import * as React from "react";
|
|
7
7
|
import { useBookingsUiI18nOrDefault, useBookingsUiMessagesOrDefault } from "../i18n/provider";
|
|
8
8
|
import { BookingItemDialog } from "./booking-item-dialog";
|
|
@@ -27,19 +27,26 @@ export function BookingItemList({ bookingId }) {
|
|
|
27
27
|
return (_jsxs(Card, { "data-slot": "booking-item-list", children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(Package, { className: "h-4 w-4" }), messages.bookingItemList.title] }), _jsxs(Button, { size: "sm", onClick: () => {
|
|
28
28
|
setEditing(undefined);
|
|
29
29
|
setDialogOpen(true);
|
|
30
|
-
}, children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), messages.bookingItemList.addItem] })] }), _jsx(CardContent, { children: items.length === 0 ? (_jsx("p", { className: "py-4 text-center text-
|
|
30
|
+
}, children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), messages.bookingItemList.addItem] })] }), _jsx(CardContent, { children: items.length === 0 ? (_jsx("p", { className: "py-4 text-center text-muted-foreground text-sm", children: messages.bookingItemList.empty })) : (_jsx("div", { className: "overflow-x-auto rounded border bg-background", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b text-muted-foreground", children: [_jsx("th", { className: "w-8 p-2" }), _jsx("th", { className: "p-2 text-left font-medium", children: messages.bookingItemList.columns.title }), _jsx("th", { className: "p-2 text-left font-medium", children: messages.bookingItemList.columns.type }), _jsx("th", { className: "p-2 text-left font-medium", children: messages.bookingItemList.columns.status }), _jsx("th", { className: "p-2 text-right font-medium", children: messages.bookingItemList.columns.quantity }), _jsx("th", { className: "p-2 text-right font-medium", children: messages.bookingItemList.columns.total }), _jsx("th", { className: "p-2 text-right font-medium", children: messages.bookingItemList.columns.cost }), _jsx("th", { className: "p-2 text-left font-medium", children: messages.bookingItemList.columns.serviceDate }), _jsx("th", { className: "w-20 p-2" })] }) }), _jsx("tbody", { children: items.map((item) => {
|
|
31
31
|
const isExpanded = expandedItemId === item.id;
|
|
32
|
-
return (_jsxs(React.Fragment, { children: [_jsxs("tr", { className: "border-b
|
|
32
|
+
return (_jsxs(React.Fragment, { children: [_jsxs("tr", { className: "cursor-pointer border-b hover:bg-muted/30", onClick: () => setExpandedItemId(isExpanded ? null : item.id), children: [_jsx("td", { className: "p-2", children: _jsx("button", { type: "button", onClick: (e) => {
|
|
33
|
+
e.stopPropagation();
|
|
34
|
+
setExpandedItemId(isExpanded ? null : item.id);
|
|
35
|
+
}, className: "text-muted-foreground hover:text-foreground", "aria-label": isExpanded ? "Collapse item" : "Expand item", children: isExpanded ? (_jsx(ChevronDown, { className: "h-3.5 w-3.5" })) : (_jsx(ChevronRight, { className: "h-3.5 w-3.5" })) }) }), _jsx("td", { className: "p-2 font-medium", children: item.title }), _jsx("td", { className: "p-2", children: messages.bookingItemDialog.itemTypeLabels[item.itemType] }), _jsx("td", { className: "p-2", children: _jsx(Badge, { variant: statusVariant[item.status] ?? "secondary", children: messages.bookingItemDialog.itemStatusLabels[item.status] }) }), _jsx("td", { className: "p-2 text-right font-mono", children: item.quantity }), _jsx("td", { className: "p-2 text-right font-mono", children: item.totalSellAmountCents == null
|
|
33
36
|
? messages.bookingItemList.values.totalUnavailable
|
|
34
|
-
: formatCurrency(item.totalSellAmountCents / 100, item.sellCurrency) }), _jsx("td", { className: "p-2", children: item.
|
|
35
|
-
messages.bookingItemList.values.
|
|
37
|
+
: formatCurrency(item.totalSellAmountCents / 100, item.sellCurrency) }), _jsx("td", { className: "p-2 text-right font-mono text-muted-foreground", children: item.totalCostAmountCents == null || !item.costCurrency
|
|
38
|
+
? messages.bookingItemList.values.costUnavailable
|
|
39
|
+
: formatCurrency(item.totalCostAmountCents / 100, item.costCurrency) }), _jsx("td", { className: "p-2 text-xs", children: formatItemDateRange(item) ??
|
|
40
|
+
messages.bookingItemList.values.serviceDateUnavailable }), _jsx("td", { className: "p-2", children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: (e) => {
|
|
41
|
+
e.stopPropagation();
|
|
36
42
|
setEditing(item);
|
|
37
43
|
setDialogOpen(true);
|
|
38
|
-
}, className: "text-muted-foreground hover:text-foreground", children: _jsx(Pencil, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => {
|
|
44
|
+
}, className: "text-muted-foreground hover:text-foreground", "aria-label": "Edit item", children: _jsx(Pencil, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: (e) => {
|
|
45
|
+
e.stopPropagation();
|
|
39
46
|
if (confirm(messages.bookingItemList.actions.deleteConfirm)) {
|
|
40
47
|
remove.mutate(item.id);
|
|
41
48
|
}
|
|
42
|
-
}, className: "text-muted-foreground hover:text-destructive", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] }) })] }), isExpanded && (
|
|
49
|
+
}, className: "text-muted-foreground hover:text-destructive", "aria-label": "Delete item", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] }) })] }), isExpanded && (_jsxs("tr", { className: "border-b last:border-b-0 bg-muted/10", children: [_jsx("td", {}), _jsx("td", { colSpan: 8, className: "p-3", children: _jsx(ItemDetailPanel, { bookingId: bookingId, item: item }) })] }))] }, item.id));
|
|
43
50
|
}) })] }) })) }), _jsx(BookingItemDialog, { open: dialogOpen, onOpenChange: (nextOpen) => {
|
|
44
51
|
setDialogOpen(nextOpen);
|
|
45
52
|
if (!nextOpen) {
|
|
@@ -49,3 +56,60 @@ export function BookingItemList({ bookingId }) {
|
|
|
49
56
|
setEditing(undefined);
|
|
50
57
|
} })] }));
|
|
51
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Expanded panel for one item — shows the metadata an operator
|
|
61
|
+
* usually needs to act on the line: short description, full date
|
|
62
|
+
* range (timestamps when present, else just the date), cost
|
|
63
|
+
* breakdown (unit × qty), the linked product/snapshot ids, and the
|
|
64
|
+
* per-item travelers list. Compact two-column layout on wide
|
|
65
|
+
* screens, stacks on narrow ones.
|
|
66
|
+
*/
|
|
67
|
+
function ItemDetailPanel({ bookingId, item, }) {
|
|
68
|
+
const messages = useBookingsUiMessagesOrDefault();
|
|
69
|
+
const { formatCurrency } = useBookingsUiI18nOrDefault();
|
|
70
|
+
const dateRange = formatItemDateRange(item);
|
|
71
|
+
return (_jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: [_jsx(DetailBlock, { label: messages.bookingItemList.detail.description, children: item.description ? (_jsx("p", { className: "whitespace-pre-wrap text-sm", children: item.description })) : (_jsx("p", { className: "text-muted-foreground text-xs italic", children: messages.bookingItemList.detail.noDescription })) }), _jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsx(DetailBlock, { label: messages.bookingItemList.detail.dates, children: _jsxs("div", { className: "flex items-baseline gap-1.5 text-sm", children: [_jsx(Calendar, { className: "h-3.5 w-3.5 self-center text-muted-foreground" }), dateRange ?? (_jsx("span", { className: "text-muted-foreground text-xs", children: messages.bookingItemList.values.serviceDateUnavailable }))] }) }), _jsx(DetailBlock, { label: messages.bookingItemList.detail.cost, children: item.totalCostAmountCents != null && item.costCurrency ? (_jsxs("div", { className: "text-sm", children: [_jsx("span", { className: "font-mono", children: formatCurrency(item.totalCostAmountCents / 100, item.costCurrency) }), item.unitCostAmountCents != null && item.quantity > 1 ? (_jsxs("span", { className: "ml-1.5 text-muted-foreground text-xs", children: ["(", formatCurrency(item.unitCostAmountCents / 100, item.costCurrency), " \u00D7", " ", item.quantity, ")"] })) : null] })) : (_jsx("span", { className: "text-muted-foreground text-xs", children: messages.bookingItemList.values.costUnavailable })) })] })] }), _jsx(BookingItemTravelers, { bookingId: bookingId, itemId: item.id })] }));
|
|
72
|
+
}
|
|
73
|
+
function DetailBlock({ label, children, }) {
|
|
74
|
+
return (_jsxs("div", { className: "space-y-1", children: [_jsx("div", { className: "text-muted-foreground text-xs uppercase tracking-wide", children: label }), _jsx("div", { children: children })] }));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Compose the most informative date label we can from the item:
|
|
78
|
+
* - When `startsAt`+`endsAt` differ → "Mar 5 → Mar 8 2026"
|
|
79
|
+
* - When only `serviceDate` is set → "Mar 5 2026"
|
|
80
|
+
* - When everything is null → null (caller renders the unavailable
|
|
81
|
+
* placeholder)
|
|
82
|
+
*
|
|
83
|
+
* Uses Intl date formatting against the runtime locale; the booking
|
|
84
|
+
* detail page renders Romanian by default but the formatter respects
|
|
85
|
+
* whatever the consumer's locale is.
|
|
86
|
+
*/
|
|
87
|
+
function formatItemDateRange(item) {
|
|
88
|
+
const start = item.startsAt ? new Date(item.startsAt) : null;
|
|
89
|
+
const end = item.endsAt ? new Date(item.endsAt) : null;
|
|
90
|
+
if (start && Number.isFinite(start.getTime())) {
|
|
91
|
+
if (end && Number.isFinite(end.getTime()) && end.getTime() !== start.getTime()) {
|
|
92
|
+
return `${formatDate(start)} → ${formatDate(end)}`;
|
|
93
|
+
}
|
|
94
|
+
return formatDate(start);
|
|
95
|
+
}
|
|
96
|
+
if (item.serviceDate) {
|
|
97
|
+
const d = new Date(item.serviceDate);
|
|
98
|
+
if (Number.isFinite(d.getTime()))
|
|
99
|
+
return formatDate(d);
|
|
100
|
+
return item.serviceDate;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
function formatDate(d) {
|
|
105
|
+
try {
|
|
106
|
+
return d.toLocaleDateString(undefined, {
|
|
107
|
+
day: "numeric",
|
|
108
|
+
month: "short",
|
|
109
|
+
year: "numeric",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return d.toISOString().slice(0, 10);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"booking-list.d.ts","sourceRoot":"","sources":["../../src/components/booking-list.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,aAAa,EAGnB,MAAM,0BAA0B,CAAA;AAsBjC,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAA;CACnD;AAED,wBAAgB,WAAW,CAAC,EAAE,QAAa,EAAE,eAAe,EAAE,GAAE,gBAAqB,
|
|
1
|
+
{"version":3,"file":"booking-list.d.ts","sourceRoot":"","sources":["../../src/components/booking-list.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,aAAa,EAGnB,MAAM,0BAA0B,CAAA;AAsBjC,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAA;CACnD;AAED,wBAAgB,WAAW,CAAC,EAAE,QAAa,EAAE,eAAe,EAAE,GAAE,gBAAqB,2CAqKpF"}
|
|
@@ -14,7 +14,7 @@ export function BookingList({ pageSize = 25, onSelectBooking } = {}) {
|
|
|
14
14
|
const [offset, setOffset] = React.useState(0);
|
|
15
15
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
|
16
16
|
const [editing, setEditing] = React.useState(undefined);
|
|
17
|
-
const { formatNumber } = useBookingsUiI18nOrDefault();
|
|
17
|
+
const { formatDateTime, formatNumber } = useBookingsUiI18nOrDefault();
|
|
18
18
|
const messages = useBookingsUiMessagesOrDefault();
|
|
19
19
|
const { data, isPending, isError } = useBookings({
|
|
20
20
|
search: search || undefined,
|
|
@@ -39,12 +39,12 @@ export function BookingList({ pageSize = 25, onSelectBooking } = {}) {
|
|
|
39
39
|
}, className: "pl-9" })] }), _jsx("div", { className: "flex items-center gap-2", children: _jsxs(Button, { onClick: () => {
|
|
40
40
|
setEditing(undefined);
|
|
41
41
|
setDialogOpen(true);
|
|
42
|
-
}, children: [_jsx(Plus, { className: "mr-2 size-4" }), messages.bookingList.newBooking] }) })] }), _jsx("div", { className: "rounded-md border", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: messages.bookingList.columns.bookingNumber }), _jsx(TableHead, { children: messages.bookingList.columns.status }), _jsx(TableHead, { children: messages.bookingList.columns.sellAmount }), _jsx(TableHead, { children: messages.bookingList.columns.pax }), _jsx(TableHead, { children: messages.bookingList.columns.startDate })] }) }), _jsx(TableBody, { children: isPending ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan:
|
|
42
|
+
}, children: [_jsx(Plus, { className: "mr-2 size-4" }), messages.bookingList.newBooking] }) })] }), _jsx("div", { className: "rounded-md border", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: messages.bookingList.columns.bookingNumber }), _jsx(TableHead, { children: messages.bookingList.columns.status }), _jsx(TableHead, { children: messages.bookingList.columns.sellAmount }), _jsx(TableHead, { children: messages.bookingList.columns.pax }), _jsx(TableHead, { children: messages.bookingList.columns.startDate }), _jsx(TableHead, { children: messages.bookingList.columns.endDate })] }) }), _jsx(TableBody, { children: isPending ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 6, className: "h-24 text-center", children: _jsx(Loader2, { className: "mx-auto size-4 animate-spin text-muted-foreground" }) }) })) : isError ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 6, className: "h-24 text-center text-sm text-destructive", children: messages.bookingList.loadingError }) })) : bookings.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 6, className: "h-24 text-center text-sm text-muted-foreground", children: messages.bookingList.empty }) })) : (bookings.map((booking) => (_jsxs(TableRow, { onClick: () => handleSelect(booking), className: "cursor-pointer", children: [_jsx(TableCell, { className: "font-medium", children: booking.bookingNumber }), _jsx(TableCell, { children: _jsx(Badge, { variant: bookingStatusBadgeVariant[booking.status], children: messages.common.bookingStatusLabels[booking.status] }) }), _jsx(TableCell, { children: booking.sellAmountCents == null
|
|
43
43
|
? "—"
|
|
44
44
|
: `${formatNumber(booking.sellAmountCents / 100, {
|
|
45
45
|
minimumFractionDigits: 2,
|
|
46
46
|
maximumFractionDigits: 2,
|
|
47
|
-
})} ${booking.sellCurrency}` }), _jsx(TableCell, { children: booking.pax ?? "—" }), _jsx(TableCell, { children: booking.startDate ??
|
|
47
|
+
})} ${booking.sellCurrency}` }), _jsx(TableCell, { children: booking.pax ?? "—" }), _jsx(TableCell, { children: formatBookingDateTime(booking.startsAt ?? booking.startDate, formatDateTime) }), _jsx(TableCell, { children: formatBookingDateTime(booking.endsAt ?? booking.endDate, formatDateTime) })] }, booking.id)))) })] }) }), _jsxs("div", { className: "flex items-center justify-between text-sm text-muted-foreground", children: [_jsx("span", { children: formatMessage(messages.bookingList.showingSummary, {
|
|
48
48
|
count: bookings.length,
|
|
49
49
|
total,
|
|
50
50
|
}) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "sm", disabled: offset === 0, onClick: () => setOffset((prev) => Math.max(0, prev - pageSize)), children: "Previous" }), _jsx("span", { children: formatMessage(messages.bookingList.pageSummary, {
|
|
@@ -54,3 +54,11 @@ export function BookingList({ pageSize = 25, onSelectBooking } = {}) {
|
|
|
54
54
|
onSelectBooking?.(booking);
|
|
55
55
|
} })] }));
|
|
56
56
|
}
|
|
57
|
+
function formatBookingDateTime(value, formatDateTime) {
|
|
58
|
+
if (!value)
|
|
59
|
+
return "—";
|
|
60
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
61
|
+
return formatDateTime(`${value}T00:00:00`);
|
|
62
|
+
}
|
|
63
|
+
return formatDateTime(value);
|
|
64
|
+
}
|
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
export interface BookingPaymentsSummaryProps {
|
|
2
2
|
bookingId: string;
|
|
3
|
+
/**
|
|
4
|
+
* Which API surface to fetch from. The customer-portal uses
|
|
5
|
+
* `"public"` (default — hits `/v1/public/finance/bookings/:id/payments`).
|
|
6
|
+
* The operator dashboard must pass `"admin"` because the
|
|
7
|
+
* `/v1/public/*` middleware enforces a non-staff actor guard, so
|
|
8
|
+
* staff sessions get blocked from the public endpoint.
|
|
9
|
+
*/
|
|
10
|
+
variant?: "admin" | "public";
|
|
3
11
|
}
|
|
4
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Payment-centric view of the money movements recorded against a
|
|
14
|
+
* booking's invoices. Sister card to `BookingInvoicesCard` (operator
|
|
15
|
+
* template) which is invoice-centric — payments and invoices are
|
|
16
|
+
* different concepts, so each gets its own table with its own lead
|
|
17
|
+
* column.
|
|
18
|
+
*
|
|
19
|
+
* Column order here is operator-tested:
|
|
20
|
+
* 1. **Suma** — what came in. Largest, bold, currency-formatted.
|
|
21
|
+
* 2. **Metoda** — how (icon + label).
|
|
22
|
+
* 3. **Status** — completed/pending/failed/refunded badge.
|
|
23
|
+
* 4. **Data** — when.
|
|
24
|
+
* 5. **Referinta** — provider tx id, capture id, etc.
|
|
25
|
+
* 6. **Pentru** — which invoice this paid (secondary; shown last).
|
|
26
|
+
*
|
|
27
|
+
* The invoice number deliberately appears last as a "for" link, not
|
|
28
|
+
* first as the primary identifier — that's the difference between
|
|
29
|
+
* "list of payments" and "list of invoice line-items".
|
|
30
|
+
*/
|
|
31
|
+
export declare function BookingPaymentsSummary({ bookingId, variant, }: BookingPaymentsSummaryProps): import("react/jsx-runtime").JSX.Element;
|
|
5
32
|
//# sourceMappingURL=booking-payments-summary.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"booking-payments-summary.d.ts","sourceRoot":"","sources":["../../src/components/booking-payments-summary.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"booking-payments-summary.d.ts","sourceRoot":"","sources":["../../src/components/booking-payments-summary.tsx"],"names":[],"mappings":"AAkCA,MAAM,WAAW,2BAA2B;IAC1C,SAAS,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAA;CAC7B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,SAAS,EACT,OAAkB,GACnB,EAAE,2BAA2B,2CAmH7B"}
|
|
@@ -1,22 +1,77 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { usePublicBookingPayments } from "@voyantjs/finance-react";
|
|
3
|
+
import { useAdminBookingPayments, usePublicBookingPayments } from "@voyantjs/finance-react";
|
|
4
4
|
import { Badge, Card, CardContent, CardHeader, CardTitle } from "@voyantjs/ui/components";
|
|
5
|
-
import { CreditCard } from "lucide-react";
|
|
5
|
+
import { Banknote, CreditCard, Receipt, Ticket, Wallet } from "lucide-react";
|
|
6
|
+
import { useBookingsUiI18nOrDefault, useBookingsUiMessagesOrDefault } from "../i18n/provider";
|
|
7
|
+
/**
|
|
8
|
+
* Map payment status to a badge variant — completed/pending visible
|
|
9
|
+
* positively, failed/refunded surface destructive coloring so an
|
|
10
|
+
* operator scanning the row can spot a chargeback or failure
|
|
11
|
+
* without reading the label.
|
|
12
|
+
*/
|
|
6
13
|
const statusVariant = {
|
|
7
14
|
pending: "outline",
|
|
8
15
|
completed: "default",
|
|
9
16
|
failed: "destructive",
|
|
10
|
-
refunded: "
|
|
17
|
+
refunded: "destructive",
|
|
11
18
|
};
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Inline icon for the payment method column. Pure cosmetic — the
|
|
21
|
+
* label still reads alongside, but operators trained on the icons
|
|
22
|
+
* scan rows much faster than a method-string column.
|
|
23
|
+
*/
|
|
24
|
+
const methodIcon = {
|
|
25
|
+
card: CreditCard,
|
|
26
|
+
credit_card: CreditCard,
|
|
27
|
+
bank_transfer: Banknote,
|
|
28
|
+
cash: Wallet,
|
|
29
|
+
voucher: Ticket,
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Payment-centric view of the money movements recorded against a
|
|
33
|
+
* booking's invoices. Sister card to `BookingInvoicesCard` (operator
|
|
34
|
+
* template) which is invoice-centric — payments and invoices are
|
|
35
|
+
* different concepts, so each gets its own table with its own lead
|
|
36
|
+
* column.
|
|
37
|
+
*
|
|
38
|
+
* Column order here is operator-tested:
|
|
39
|
+
* 1. **Suma** — what came in. Largest, bold, currency-formatted.
|
|
40
|
+
* 2. **Metoda** — how (icon + label).
|
|
41
|
+
* 3. **Status** — completed/pending/failed/refunded badge.
|
|
42
|
+
* 4. **Data** — when.
|
|
43
|
+
* 5. **Referinta** — provider tx id, capture id, etc.
|
|
44
|
+
* 6. **Pentru** — which invoice this paid (secondary; shown last).
|
|
45
|
+
*
|
|
46
|
+
* The invoice number deliberately appears last as a "for" link, not
|
|
47
|
+
* first as the primary identifier — that's the difference between
|
|
48
|
+
* "list of payments" and "list of invoice line-items".
|
|
49
|
+
*/
|
|
50
|
+
export function BookingPaymentsSummary({ bookingId, variant = "public", }) {
|
|
51
|
+
const publicQuery = usePublicBookingPayments(bookingId, { enabled: variant === "public" });
|
|
52
|
+
const adminQuery = useAdminBookingPayments(bookingId, { enabled: variant === "admin" });
|
|
53
|
+
const data = variant === "admin" ? adminQuery.data : publicQuery.data;
|
|
54
|
+
const { formatDate } = useBookingsUiI18nOrDefault();
|
|
16
55
|
const messages = useBookingsUiMessagesOrDefault();
|
|
17
56
|
const payments = data?.data?.payments ?? [];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
57
|
+
// Empty-state polish: completed totals across all visible rows so
|
|
58
|
+
// the card carries useful summary information even when there are
|
|
59
|
+
// many small partial payments to scan.
|
|
60
|
+
const totalCompleted = payments
|
|
61
|
+
.filter((p) => p.status === "completed")
|
|
62
|
+
.reduce((sum, p) => sum + p.amountCents, 0);
|
|
63
|
+
const currency = payments[0]?.currency ?? "EUR";
|
|
64
|
+
return (_jsxs(Card, { "data-slot": "booking-payments-summary", children: [_jsx(CardHeader, { className: "pb-3", children: _jsxs(CardTitle, { className: "flex flex-wrap items-center gap-2 text-base", children: [_jsx(CreditCard, { className: "h-4 w-4 text-muted-foreground" }), messages.bookingPaymentsSummary.title, payments.length > 0 ? (_jsx(Badge, { variant: "outline", className: "text-[10px]", children: payments.length })) : null, totalCompleted > 0 ? (_jsxs("span", { className: "ml-auto text-muted-foreground text-xs", children: ["Total received", " ", _jsx("span", { className: "font-medium text-foreground", children: formatMoney(totalCompleted, currency) })] })) : null] }) }), _jsx(CardContent, { className: "overflow-hidden p-0", children: payments.length === 0 ? (_jsx("p", { className: "py-6 text-center text-muted-foreground text-sm", children: messages.bookingPaymentsSummary.empty })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b text-muted-foreground", children: [_jsx("th", { className: "px-4 py-2 text-right font-medium", children: messages.bookingPaymentsSummary.columns.amount }), _jsx("th", { className: "px-4 py-2 text-left font-medium", children: messages.bookingPaymentsSummary.columns.method }), _jsx("th", { className: "px-4 py-2 text-left font-medium", children: messages.bookingPaymentsSummary.columns.status }), _jsx("th", { className: "px-4 py-2 text-left font-medium", children: messages.bookingPaymentsSummary.columns.date }), _jsx("th", { className: "px-4 py-2 text-left font-medium", children: messages.bookingPaymentsSummary.columns.reference }), _jsx("th", { className: "px-4 py-2 text-left font-medium", children: messages.bookingPaymentsSummary.columns.invoice })] }) }), _jsx("tbody", { children: payments.map((payment) => {
|
|
65
|
+
const MethodIcon = methodIcon[payment.paymentMethod] ?? Receipt;
|
|
66
|
+
const methodLabel = messages.bookingPaymentsSummary.paymentMethodLabels[payment.paymentMethod] ?? payment.paymentMethod;
|
|
67
|
+
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: payment.invoiceNumber })] }, payment.id));
|
|
68
|
+
}) })] }) })) })] }));
|
|
69
|
+
}
|
|
70
|
+
function formatMoney(cents, currency) {
|
|
71
|
+
try {
|
|
72
|
+
return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(cents / 100);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return `${(cents / 100).toFixed(2)} ${currency}`;
|
|
76
|
+
}
|
|
22
77
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export interface TravelerListProps {
|
|
2
2
|
bookingId: string;
|
|
3
|
+
autoReveal?: boolean;
|
|
3
4
|
}
|
|
4
|
-
export declare function TravelerList({ bookingId }: TravelerListProps): import("react/jsx-runtime").JSX.Element;
|
|
5
|
+
export declare function TravelerList({ bookingId, autoReveal }: TravelerListProps): import("react/jsx-runtime").JSX.Element;
|
|
5
6
|
//# sourceMappingURL=traveler-list.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"traveler-list.d.ts","sourceRoot":"","sources":["../../src/components/traveler-list.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"traveler-list.d.ts","sourceRoot":"","sources":["../../src/components/traveler-list.tsx"],"names":[],"mappings":"AAkBA,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,UAAkB,EAAE,EAAE,iBAAiB,2CA+HhF"}
|
|
@@ -1,29 +1,60 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useTravelerMutation, useTravelers, } from "@voyantjs/bookings-react";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useBookingTravelerDocuments, useRevealTraveler, useTravelerMutation, useTravelers, } from "@voyantjs/bookings-react";
|
|
4
4
|
import { Button, Card, CardContent, CardHeader, CardTitle } from "@voyantjs/ui/components";
|
|
5
|
-
import { Pencil, Plus, Trash2, Users } from "lucide-react";
|
|
5
|
+
import { Eye, EyeOff, Loader2, Pencil, Plus, Trash2, Users } from "lucide-react";
|
|
6
6
|
import * as React from "react";
|
|
7
7
|
import { useBookingsUiMessagesOrDefault } from "../i18n/provider";
|
|
8
8
|
import { TravelerDialog } from "./traveler-dialog";
|
|
9
|
-
export function TravelerList({ bookingId }) {
|
|
9
|
+
export function TravelerList({ bookingId, autoReveal = false }) {
|
|
10
10
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
|
11
11
|
const [editing, setEditing] = React.useState(undefined);
|
|
12
|
+
const [revealedIds, setRevealedIds] = React.useState(new Set());
|
|
12
13
|
const { data } = useTravelers(bookingId);
|
|
14
|
+
const documentsQuery = useBookingTravelerDocuments(bookingId);
|
|
13
15
|
const { remove } = useTravelerMutation(bookingId);
|
|
14
16
|
const messages = useBookingsUiMessagesOrDefault();
|
|
15
17
|
const travelers = data?.data ?? [];
|
|
18
|
+
const documentsByTraveler = React.useMemo(() => {
|
|
19
|
+
const grouped = new Map();
|
|
20
|
+
for (const document of documentsQuery.data?.data ?? []) {
|
|
21
|
+
if (!document.travelerId)
|
|
22
|
+
continue;
|
|
23
|
+
const bucket = grouped.get(document.travelerId) ?? [];
|
|
24
|
+
bucket.push(document);
|
|
25
|
+
grouped.set(document.travelerId, bucket);
|
|
26
|
+
}
|
|
27
|
+
return grouped;
|
|
28
|
+
}, [documentsQuery.data?.data]);
|
|
29
|
+
// Detect whether the list endpoint already returned unmasked data
|
|
30
|
+
// (caller has bookings-pii:* scope or similar). If so, don't show
|
|
31
|
+
// the reveal button — there's nothing to unmask.
|
|
32
|
+
const allAlreadyRevealed = React.useMemo(() => {
|
|
33
|
+
if (travelers.length === 0)
|
|
34
|
+
return true;
|
|
35
|
+
return travelers.every((t) => !looksRedacted(t));
|
|
36
|
+
}, [travelers]);
|
|
37
|
+
const toggleReveal = React.useCallback((travelerId) => {
|
|
38
|
+
setRevealedIds((prev) => {
|
|
39
|
+
const next = new Set(prev);
|
|
40
|
+
if (next.has(travelerId))
|
|
41
|
+
next.delete(travelerId);
|
|
42
|
+
else
|
|
43
|
+
next.add(travelerId);
|
|
44
|
+
return next;
|
|
45
|
+
});
|
|
46
|
+
}, []);
|
|
16
47
|
return (_jsxs(Card, { "data-slot": "traveler-list", children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(Users, { className: "h-4 w-4" }), messages.travelerList.title] }), _jsxs(Button, { size: "sm", onClick: () => {
|
|
17
48
|
setEditing(undefined);
|
|
18
49
|
setDialogOpen(true);
|
|
19
|
-
}, children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), messages.travelerList.addTraveler] })] }), _jsx(CardContent, { children: travelers.length === 0 ? (_jsx("p", { className: "py-4 text-center text-sm text-muted-foreground", children: messages.travelerList.empty })) : (_jsx("div", { className: "rounded border bg-background", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b text-muted-foreground", children: [_jsx("th", { className: "p-2 text-left font-medium", children: messages.travelerList.columns.name }), _jsx("th", { className: "p-2 text-left font-medium", children: messages.travelerList.columns.email }), _jsx("th", { className: "p-2 text-left font-medium", children: messages.travelerList.columns.phone }), _jsx("th", { className: "
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
50
|
+
}, children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), messages.travelerList.addTraveler] })] }), _jsx(CardContent, { children: travelers.length === 0 ? (_jsx("p", { className: "py-4 text-center text-sm text-muted-foreground", children: messages.travelerList.empty })) : (_jsx("div", { className: "overflow-x-auto rounded border bg-background", children: _jsxs("table", { className: "w-full min-w-[980px] text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b text-muted-foreground", children: [_jsx("th", { className: "p-2 text-left font-medium", children: messages.travelerList.columns.name }), _jsx("th", { className: "p-2 text-left font-medium", children: messages.travelerList.columns.email }), _jsx("th", { className: "p-2 text-left font-medium", children: messages.travelerList.columns.phone }), _jsx("th", { className: "p-2 text-left font-medium", children: "Role" }), _jsx("th", { className: "p-2 text-left font-medium", children: "DOB / age" }), _jsx("th", { className: "p-2 text-left font-medium", children: "Documents" }), _jsx("th", { className: "w-20 p-2" })] }) }), _jsx("tbody", { children: travelers.map((traveler) => (_jsx(TravelerRow, { bookingId: bookingId, traveler: traveler, documents: documentsByTraveler.get(traveler.id) ?? [], revealed: autoReveal || revealedIds.has(traveler.id), onToggleReveal: autoReveal || allAlreadyRevealed ? undefined : () => toggleReveal(traveler.id), emailUnavailable: messages.travelerList.values.emailUnavailable, phoneUnavailable: messages.travelerList.values.phoneUnavailable, onEdit: () => {
|
|
51
|
+
setEditing(traveler);
|
|
52
|
+
setDialogOpen(true);
|
|
53
|
+
}, onDelete: () => {
|
|
54
|
+
if (confirm(messages.travelerList.actions.deleteConfirm)) {
|
|
55
|
+
remove.mutate(traveler.id);
|
|
56
|
+
}
|
|
57
|
+
} }, traveler.id))) })] }) })) }), _jsx(TravelerDialog, { open: dialogOpen, onOpenChange: (nextOpen) => {
|
|
27
58
|
setDialogOpen(nextOpen);
|
|
28
59
|
if (!nextOpen) {
|
|
29
60
|
setEditing(undefined);
|
|
@@ -32,3 +63,86 @@ export function TravelerList({ bookingId }) {
|
|
|
32
63
|
setEditing(undefined);
|
|
33
64
|
} })] }));
|
|
34
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Single traveler row. Calls `useRevealTraveler` lazily — only fires
|
|
68
|
+
* when `revealed=true`. The reveal endpoint audit-logs the access on
|
|
69
|
+
* the server, so toggling the eye button creates a permanent record.
|
|
70
|
+
*/
|
|
71
|
+
function TravelerRow({ bookingId, traveler, documents, revealed, onToggleReveal, emailUnavailable, phoneUnavailable, onEdit, onDelete, }) {
|
|
72
|
+
const reveal = useRevealTraveler(bookingId, traveler.id, { enabled: revealed });
|
|
73
|
+
// Use the revealed copy when available; otherwise fall back to
|
|
74
|
+
// the masked row from the list endpoint. This keeps the UI snappy
|
|
75
|
+
// — the masked row renders instantly, then swaps to unmasked the
|
|
76
|
+
// moment the network returns.
|
|
77
|
+
const revealedTraveler = reveal.data?.data;
|
|
78
|
+
const display = revealed && revealedTraveler ? revealedTraveler : traveler;
|
|
79
|
+
const travelDetails = revealed && revealedTraveler ? revealedTraveler.travelDetails : null;
|
|
80
|
+
const showLoading = revealed && reveal.isLoading;
|
|
81
|
+
const revealError = revealed && reveal.error;
|
|
82
|
+
return (_jsxs(_Fragment, { children: [_jsxs("tr", { className: "border-b", children: [_jsx("td", { className: "p-2", children: showLoading ? (_jsx(RowLoading, {})) : (`${display.firstName ?? ""} ${display.lastName ?? ""}`.trim()) }), _jsx("td", { className: "p-2", children: showLoading ? _jsx(RowLoading, {}) : (display.email ?? emailUnavailable) }), _jsx("td", { className: "p-2", children: showLoading ? _jsx(RowLoading, {}) : (display.phone ?? phoneUnavailable) }), _jsx("td", { className: "p-2", children: _jsxs("div", { className: "flex flex-wrap gap-1.5", children: [display.isPrimary ? _jsx(MiniPill, { children: "Primary" }) : null, travelDetails?.isLeadTraveler ? _jsx(MiniPill, { children: "Lead" }) : null, display.travelerCategory ? _jsx(MiniPill, { children: display.travelerCategory }) : null] }) }), _jsx("td", { className: "p-2", children: showLoading ? _jsx(RowLoading, {}) : formatDobAge(travelDetails?.dateOfBirth) }), _jsx("td", { className: "p-2", children: documents.length > 0 ? (_jsxs("div", { className: "flex flex-wrap gap-1.5", children: [documents.slice(0, 2).map((document) => (_jsx(MiniPill, { children: document.type.replaceAll("_", " ") }, document.id))), documents.length > 2 ? _jsxs(MiniPill, { children: ["+", documents.length - 2] }) : null] })) : (_jsx("span", { className: "text-muted-foreground", children: "-" })) }), _jsxs("td", { className: "p-2", children: [_jsxs("div", { className: "flex items-center gap-1", children: [onToggleReveal ? (_jsx("button", { type: "button", onClick: onToggleReveal, className: "text-muted-foreground hover:text-foreground", title: revealed ? "Hide details" : "Reveal contact details", "aria-label": revealed ? "Hide traveler contact details" : "Reveal traveler contact details", children: revealed ? _jsx(EyeOff, { className: "h-3.5 w-3.5" }) : _jsx(Eye, { className: "h-3.5 w-3.5" }) })) : null, _jsx("button", { type: "button", onClick: onEdit, className: "text-muted-foreground hover:text-foreground", "aria-label": "Edit traveler", children: _jsx(Pencil, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: onDelete, className: "text-muted-foreground hover:text-destructive", "aria-label": "Delete traveler", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] }), revealError ? (_jsx("div", { className: "mt-1 text-[10px] text-destructive", children: revealError instanceof Error ? revealError.message : "Reveal failed" })) : null] })] }), _jsx("tr", { className: "border-b last:border-b-0", children: _jsx("td", { colSpan: 7, className: "bg-muted/20 px-2 py-3", children: _jsx(TravelerContextGrid, { traveler: display, travelDetails: travelDetails, documents: documents, loading: showLoading }) }) })] }));
|
|
83
|
+
}
|
|
84
|
+
function RowLoading() {
|
|
85
|
+
return (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-muted-foreground", children: [_jsx(Loader2, { className: "h-3 w-3 animate-spin" }), _jsx("span", { className: "text-xs", children: "Decrypting\u2026" })] }));
|
|
86
|
+
}
|
|
87
|
+
function TravelerContextGrid({ traveler, travelDetails, documents, loading, }) {
|
|
88
|
+
if (loading)
|
|
89
|
+
return _jsx(RowLoading, {});
|
|
90
|
+
const fields = [
|
|
91
|
+
["Nationality", travelDetails?.nationality],
|
|
92
|
+
["Passport", travelDetails?.passportNumber],
|
|
93
|
+
["Passport expiry", formatDateValue(travelDetails?.passportExpiry)],
|
|
94
|
+
["Language", traveler.preferredLanguage],
|
|
95
|
+
["Dietary", travelDetails?.dietaryRequirements],
|
|
96
|
+
["Accessibility", travelDetails?.accessibilityNeeds],
|
|
97
|
+
["Special requests", traveler.specialRequests],
|
|
98
|
+
["Notes", traveler.notes],
|
|
99
|
+
];
|
|
100
|
+
const visibleFields = fields.filter(([, value]) => Boolean(value));
|
|
101
|
+
if (visibleFields.length === 0 && documents.length === 0) {
|
|
102
|
+
return _jsx("span", { className: "text-xs text-muted-foreground", children: "No additional traveler context" });
|
|
103
|
+
}
|
|
104
|
+
return (_jsxs("div", { className: "grid gap-3 md:grid-cols-4", children: [visibleFields.map(([label, value]) => (_jsx(DetailField, { label: label, value: value ?? "-" }, label))), documents.map((document) => (_jsx(DetailField, { label: `Document · ${document.type.replaceAll("_", " ")}`, value: document.fileName }, document.id)))] }));
|
|
105
|
+
}
|
|
106
|
+
function DetailField({ label, value }) {
|
|
107
|
+
return (_jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "text-[10px] font-medium uppercase text-muted-foreground", children: label }), _jsx("div", { className: "truncate text-xs text-foreground", children: value })] }));
|
|
108
|
+
}
|
|
109
|
+
function MiniPill({ children }) {
|
|
110
|
+
return (_jsx("span", { className: "inline-flex h-5 items-center rounded-full border px-2 text-[11px] capitalize text-muted-foreground", children: children }));
|
|
111
|
+
}
|
|
112
|
+
function formatDobAge(value) {
|
|
113
|
+
if (!value)
|
|
114
|
+
return "-";
|
|
115
|
+
const date = new Date(value);
|
|
116
|
+
if (Number.isNaN(date.getTime()))
|
|
117
|
+
return value;
|
|
118
|
+
const today = new Date();
|
|
119
|
+
let age = today.getFullYear() - date.getFullYear();
|
|
120
|
+
const birthdayPassed = today.getMonth() > date.getMonth() ||
|
|
121
|
+
(today.getMonth() === date.getMonth() && today.getDate() >= date.getDate());
|
|
122
|
+
if (!birthdayPassed)
|
|
123
|
+
age -= 1;
|
|
124
|
+
return `${formatDateValue(value)} · ${age}`;
|
|
125
|
+
}
|
|
126
|
+
function formatDateValue(value) {
|
|
127
|
+
if (!value)
|
|
128
|
+
return null;
|
|
129
|
+
const date = new Date(value);
|
|
130
|
+
if (Number.isNaN(date.getTime()))
|
|
131
|
+
return value;
|
|
132
|
+
return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Heuristic check for redaction markers used by `redactTravelerIdentity`
|
|
136
|
+
* on the API. We can't import the redactor from `@voyantjs/bookings`
|
|
137
|
+
* at the UI layer (would pull in a server dep), so probe for the
|
|
138
|
+
* canonical patterns the redactor produces (`***`, `*@`, `***1234`).
|
|
139
|
+
*
|
|
140
|
+
* If any field on this traveler shows a redaction marker, treat the
|
|
141
|
+
* row as redacted and surface the reveal button. Conservative: false
|
|
142
|
+
* positives (genuine `***` data) just keep the button visible, which
|
|
143
|
+
* is harmless.
|
|
144
|
+
*/
|
|
145
|
+
function looksRedacted(traveler) {
|
|
146
|
+
const fields = [traveler.firstName, traveler.lastName, traveler.email, traveler.phone];
|
|
147
|
+
return fields.some((v) => typeof v === "string" && (v === "***" || /\*\*\*/.test(v) || /\*+@/.test(v)));
|
|
148
|
+
}
|