@voyantjs/bookings-ui 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +13 -0
  2. package/dist/components/booking-activity-timeline.d.ts +5 -0
  3. package/dist/components/booking-activity-timeline.d.ts.map +1 -0
  4. package/dist/components/booking-activity-timeline.js +83 -0
  5. package/dist/components/booking-cancellation-dialog.d.ts +18 -0
  6. package/dist/components/booking-cancellation-dialog.d.ts.map +1 -0
  7. package/dist/components/booking-cancellation-dialog.js +80 -0
  8. package/dist/components/booking-create-dialog.d.ts +21 -0
  9. package/dist/components/booking-create-dialog.d.ts.map +1 -0
  10. package/dist/components/booking-create-dialog.js +313 -0
  11. package/dist/components/booking-dialog.d.ts +23 -0
  12. package/dist/components/booking-dialog.d.ts.map +1 -0
  13. package/dist/components/booking-dialog.js +108 -0
  14. package/dist/components/booking-document-dialog.d.ts +8 -0
  15. package/dist/components/booking-document-dialog.d.ts.map +1 -0
  16. package/dist/components/booking-document-dialog.js +67 -0
  17. package/dist/components/booking-document-list.d.ts +5 -0
  18. package/dist/components/booking-document-list.d.ts.map +1 -0
  19. package/dist/components/booking-document-list.js +38 -0
  20. package/dist/components/booking-group-link-dialog.d.ts +10 -0
  21. package/dist/components/booking-group-link-dialog.d.ts.map +1 -0
  22. package/dist/components/booking-group-link-dialog.js +68 -0
  23. package/dist/components/booking-group-section.d.ts +17 -0
  24. package/dist/components/booking-group-section.d.ts.map +1 -0
  25. package/dist/components/booking-group-section.js +31 -0
  26. package/dist/components/booking-guarantee-dialog.d.ts +10 -0
  27. package/dist/components/booking-guarantee-dialog.d.ts.map +1 -0
  28. package/dist/components/booking-guarantee-dialog.js +101 -0
  29. package/dist/components/booking-guarantee-list.d.ts +5 -0
  30. package/dist/components/booking-guarantee-list.d.ts.map +1 -0
  31. package/dist/components/booking-guarantee-list.js +45 -0
  32. package/dist/components/booking-item-dialog.d.ts +10 -0
  33. package/dist/components/booking-item-dialog.d.ts.map +1 -0
  34. package/dist/components/booking-item-dialog.js +119 -0
  35. package/dist/components/booking-item-list.d.ts +5 -0
  36. package/dist/components/booking-item-list.d.ts.map +1 -0
  37. package/dist/components/booking-item-list.js +50 -0
  38. package/dist/components/booking-item-travelers.d.ts +6 -0
  39. package/dist/components/booking-item-travelers.d.ts.map +1 -0
  40. package/dist/components/booking-item-travelers.js +50 -0
  41. package/dist/components/booking-list.d.ts +7 -0
  42. package/dist/components/booking-list.d.ts.map +1 -0
  43. package/dist/components/booking-list.js +47 -0
  44. package/dist/components/booking-notes.d.ts +5 -0
  45. package/dist/components/booking-notes.d.ts.map +1 -0
  46. package/dist/components/booking-notes.js +16 -0
  47. package/dist/components/booking-payment-schedule-dialog.d.ts +10 -0
  48. package/dist/components/booking-payment-schedule-dialog.d.ts.map +1 -0
  49. package/dist/components/booking-payment-schedule-dialog.js +77 -0
  50. package/dist/components/booking-payment-schedule-list.d.ts +5 -0
  51. package/dist/components/booking-payment-schedule-list.d.ts.map +1 -0
  52. package/dist/components/booking-payment-schedule-list.js +43 -0
  53. package/dist/components/booking-payments-summary.d.ts +5 -0
  54. package/dist/components/booking-payments-summary.d.ts.map +1 -0
  55. package/dist/components/booking-payments-summary.js +19 -0
  56. package/dist/components/file-dropzone.d.ts +25 -0
  57. package/dist/components/file-dropzone.d.ts.map +1 -0
  58. package/dist/components/file-dropzone.js +92 -0
  59. package/dist/components/passengers-section.d.ts +72 -0
  60. package/dist/components/passengers-section.d.ts.map +1 -0
  61. package/dist/components/passengers-section.js +74 -0
  62. package/dist/components/payment-schedule-section.d.ts +62 -0
  63. package/dist/components/payment-schedule-section.d.ts.map +1 -0
  64. package/dist/components/payment-schedule-section.js +88 -0
  65. package/dist/components/person-picker-section.d.ts +53 -0
  66. package/dist/components/person-picker-section.d.ts.map +1 -0
  67. package/dist/components/person-picker-section.js +71 -0
  68. package/dist/components/price-breakdown-section.d.ts +48 -0
  69. package/dist/components/price-breakdown-section.d.ts.map +1 -0
  70. package/dist/components/price-breakdown-section.js +165 -0
  71. package/dist/components/product-picker-section.d.ts +27 -0
  72. package/dist/components/product-picker-section.d.ts.map +1 -0
  73. package/dist/components/product-picker-section.js +41 -0
  74. package/dist/components/rooms-stepper-section.d.ts +45 -0
  75. package/dist/components/rooms-stepper-section.d.ts.map +1 -0
  76. package/dist/components/rooms-stepper-section.js +60 -0
  77. package/dist/components/shared-room-section.d.ts +37 -0
  78. package/dist/components/shared-room-section.d.ts.map +1 -0
  79. package/dist/components/shared-room-section.js +40 -0
  80. package/dist/components/status-change-dialog.d.ts +10 -0
  81. package/dist/components/status-change-dialog.d.ts.map +1 -0
  82. package/dist/components/status-change-dialog.js +41 -0
  83. package/dist/components/supplier-status-dialog.d.ts +10 -0
  84. package/dist/components/supplier-status-dialog.d.ts.map +1 -0
  85. package/dist/components/supplier-status-dialog.js +77 -0
  86. package/dist/components/supplier-status-list.d.ts +5 -0
  87. package/dist/components/supplier-status-list.d.ts.map +1 -0
  88. package/dist/components/supplier-status-list.js +33 -0
  89. package/dist/components/traveler-dialog.d.ts +10 -0
  90. package/dist/components/traveler-dialog.d.ts.map +1 -0
  91. package/dist/components/traveler-dialog.js +64 -0
  92. package/dist/components/traveler-list.d.ts +5 -0
  93. package/dist/components/traveler-list.d.ts.map +1 -0
  94. package/dist/components/traveler-list.js +32 -0
  95. package/dist/components/voucher-picker-section.d.ts +50 -0
  96. package/dist/components/voucher-picker-section.d.ts.map +1 -0
  97. package/dist/components/voucher-picker-section.js +94 -0
  98. package/dist/index.d.ts +33 -0
  99. package/dist/index.d.ts.map +1 -0
  100. package/dist/index.js +32 -0
  101. package/package.json +76 -0
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @voyantjs/bookings-ui
2
+
3
+ Importable React UI components for Voyant bookings. Bundler-consumed (Vite, Next.js, webpack, etc.).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @voyantjs/bookings-ui @voyantjs/bookings-react @voyantjs/voyant-ui @tanstack/react-query react react-dom
9
+ ```
10
+
11
+ `@voyantjs/voyant-ui` provides the design-system primitives. `@voyantjs/bookings-react` provides the data-layer hooks. Both are required peers.
12
+
13
+ All components accept a `className` prop and merge it with `cn()`. Wrap or compose to extend; use the registry copy-paste path (`npx shadcn add @voyant/...`) for components you want to fork outright.
@@ -0,0 +1,5 @@
1
+ export interface BookingActivityTimelineProps {
2
+ bookingId: string;
3
+ }
4
+ export declare function BookingActivityTimeline({ bookingId }: BookingActivityTimelineProps): import("react/jsx-runtime").JSX.Element;
5
+ //# sourceMappingURL=booking-activity-timeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"booking-activity-timeline.d.ts","sourceRoot":"","sources":["../../src/components/booking-activity-timeline.tsx"],"names":[],"mappings":"AA0BA,MAAM,WAAW,4BAA4B;IAC3C,SAAS,EAAE,MAAM,CAAA;CAClB;AA+CD,wBAAgB,uBAAuB,CAAC,EAAE,SAAS,EAAE,EAAE,4BAA4B,2CAqFlF"}
@@ -0,0 +1,83 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useBookingActivity, useBookingTravelerDocuments } from "@voyantjs/bookings-react";
4
+ import { usePublicBookingPayments } from "@voyantjs/finance-react";
5
+ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, } from "@voyantjs/voyant-ui/components";
6
+ import { Activity, Clock, CreditCard, ExternalLink, FileText, Pencil, Plus, RefreshCw, UserPlus, } from "lucide-react";
7
+ import * as React from "react";
8
+ const activityIcons = {
9
+ booking_created: Plus,
10
+ booking_reserved: Plus,
11
+ booking_converted: RefreshCw,
12
+ booking_confirmed: Clock,
13
+ hold_extended: Clock,
14
+ hold_expired: Clock,
15
+ status_change: Clock,
16
+ item_update: Pencil,
17
+ allocation_released: Clock,
18
+ fulfillment_issued: FileText,
19
+ fulfillment_updated: FileText,
20
+ redemption_recorded: FileText,
21
+ supplier_update: RefreshCw,
22
+ passenger_update: UserPlus,
23
+ note_added: Pencil,
24
+ };
25
+ const sourceLabel = {
26
+ activity: "Activity",
27
+ document: "Document",
28
+ payment: "Payment",
29
+ };
30
+ const sourceVariant = {
31
+ activity: "outline",
32
+ document: "secondary",
33
+ payment: "default",
34
+ };
35
+ export function BookingActivityTimeline({ bookingId }) {
36
+ const [filter, setFilter] = React.useState("all");
37
+ const { data: activityData } = useBookingActivity(bookingId);
38
+ const { data: documentsData } = useBookingTravelerDocuments(bookingId);
39
+ const { data: paymentsData } = usePublicBookingPayments(bookingId);
40
+ const events = React.useMemo(() => {
41
+ const merged = [];
42
+ for (const entry of activityData?.data ?? []) {
43
+ merged.push({
44
+ id: `activity:${entry.id}`,
45
+ source: "activity",
46
+ title: entry.description,
47
+ actorId: entry.actorId,
48
+ timestamp: entry.createdAt,
49
+ icon: activityIcons[entry.activityType] ?? Activity,
50
+ });
51
+ }
52
+ for (const doc of documentsData?.data ?? []) {
53
+ merged.push({
54
+ id: `document:${doc.id}`,
55
+ source: "document",
56
+ title: `${doc.type.replace(/_/g, " ")} uploaded`,
57
+ description: doc.fileName,
58
+ timestamp: doc.createdAt,
59
+ icon: FileText,
60
+ link: { href: doc.fileUrl, label: "View file" },
61
+ });
62
+ }
63
+ for (const payment of paymentsData?.data?.payments ?? []) {
64
+ merged.push({
65
+ id: `payment:${payment.id}`,
66
+ source: "payment",
67
+ title: `Payment ${payment.status} — ${(payment.amountCents / 100).toFixed(2)} ${payment.currency}`,
68
+ description: `Invoice ${payment.invoiceNumber} · ${payment.paymentMethod.replace(/_/g, " ")}`,
69
+ timestamp: payment.paymentDate,
70
+ icon: CreditCard,
71
+ });
72
+ }
73
+ merged.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
74
+ return merged;
75
+ }, [activityData, documentsData, paymentsData]);
76
+ const visible = filter === "all" ? events : events.filter((e) => e.source === filter);
77
+ const filterChips = ["all", "activity", "document", "payment"];
78
+ return (_jsxs(Card, { "data-slot": "booking-activity-timeline", children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(Activity, { className: "h-4 w-4" }), "Activity Timeline"] }), _jsx("div", { className: "flex items-center gap-1", children: filterChips.map((chip) => (_jsx(Button, { variant: filter === chip ? "default" : "ghost", size: "sm", className: "h-7 capitalize", onClick: () => setFilter(chip), children: chip === "all" ? "All" : sourceLabel[chip] }, chip))) })] }), _jsx(CardContent, { children: visible.length === 0 ? (_jsx("p", { className: "py-4 text-center text-sm text-muted-foreground", children: "No events yet." })) : (_jsx("div", { className: "flex flex-col gap-3", children: visible.map((event) => (_jsx(TimelineEventItem, { event: event }, event.id))) })) })] }));
79
+ }
80
+ function TimelineEventItem({ event }) {
81
+ const Icon = event.icon;
82
+ return (_jsxs("div", { className: "flex items-start gap-3 rounded-md border p-3", children: [_jsx(Icon, { className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("p", { className: "text-sm font-medium capitalize", children: event.title }), _jsx(Badge, { variant: sourceVariant[event.source], className: "text-xs", children: sourceLabel[event.source] })] }), event.description && (_jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: event.description })), _jsxs("p", { className: "mt-0.5 text-xs text-muted-foreground", children: [event.actorId && event.actorId !== "system" ? `By ${event.actorId} · ` : "", new Date(event.timestamp).toLocaleString()] }), event.link && (_jsxs("a", { href: event.link.href, target: "_blank", rel: "noopener noreferrer", className: "mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline", children: [event.link.label, _jsx(ExternalLink, { className: "h-3 w-3" })] }))] })] }));
83
+ }
@@ -0,0 +1,18 @@
1
+ import { type BookingRecord } from "@voyantjs/bookings-react";
2
+ export interface BookingCancellationDialogProps {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ booking: BookingRecord;
6
+ /**
7
+ * Product ID used to resolve the applicable cancellation policy.
8
+ *
9
+ * Leave unset (or pass `undefined`) to auto-resolve from the booking's items
10
+ * — this is what you want for single-product bookings. Pass an explicit
11
+ * string or `null` to override (e.g. for multi-product bookings or to force
12
+ * the default non-product-scoped policy).
13
+ */
14
+ productId?: string | null;
15
+ onSuccess?: () => void;
16
+ }
17
+ export declare function BookingCancellationDialog({ open, onOpenChange, booking, productId, onSuccess, }: BookingCancellationDialogProps): import("react/jsx-runtime").JSX.Element;
18
+ //# sourceMappingURL=booking-cancellation-dialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"booking-cancellation-dialog.d.ts","sourceRoot":"","sources":["../../src/components/booking-cancellation-dialog.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,aAAa,EAGnB,MAAM,0BAA0B,CAAA;AA4CjC,MAAM,WAAW,8BAA8B;IAC7C,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,OAAO,EAAE,aAAa,CAAA;IACtB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;CACvB;AAED,wBAAgB,yBAAyB,CAAC,EACxC,IAAI,EACJ,YAAY,EACZ,OAAO,EACP,SAAS,EACT,SAAS,GACV,EAAE,8BAA8B,2CAwMhC"}
@@ -0,0 +1,80 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useBookingCancelMutation, useBookingPrimaryProduct, } from "@voyantjs/bookings-react";
4
+ import { useEvaluateCancellation, useResolvePolicy } from "@voyantjs/legal-react";
5
+ import { Badge, Button, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, Label, Textarea, } from "@voyantjs/voyant-ui/components";
6
+ import { AlertTriangle, Loader2 } from "lucide-react";
7
+ import * as React from "react";
8
+ function formatAmount(cents, currency) {
9
+ return `${(cents / 100).toFixed(2)} ${currency}`;
10
+ }
11
+ function formatPercent(basisPoints) {
12
+ return `${(basisPoints / 100).toFixed(0)}%`;
13
+ }
14
+ function daysBetween(from, to) {
15
+ const diffMs = to.getTime() - from.getTime();
16
+ return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
17
+ }
18
+ const refundTypeLabel = {
19
+ cash: "Cash refund",
20
+ credit: "Credit",
21
+ cash_or_credit: "Cash or credit",
22
+ none: "No refund",
23
+ };
24
+ const refundTypeVariant = {
25
+ cash: "default",
26
+ credit: "secondary",
27
+ cash_or_credit: "secondary",
28
+ none: "destructive",
29
+ };
30
+ export function BookingCancellationDialog({ open, onOpenChange, booking, productId, onSuccess, }) {
31
+ const [reason, setReason] = React.useState("");
32
+ const daysBeforeDeparture = React.useMemo(() => {
33
+ if (!booking.startDate)
34
+ return 0;
35
+ return daysBetween(new Date(), new Date(booking.startDate));
36
+ }, [booking.startDate]);
37
+ // When the caller didn't specify a productId, derive one from the booking's
38
+ // items so the consumer doesn't have to wire up `useBookingItems` just for
39
+ // this. Explicit `null` is respected as an override.
40
+ const shouldAutoResolveProduct = productId === undefined;
41
+ const autoResolved = useBookingPrimaryProduct(booking.id, {
42
+ enabled: open && shouldAutoResolveProduct,
43
+ });
44
+ const effectiveProductId = shouldAutoResolveProduct ? autoResolved.productId : productId;
45
+ const { data: resolved, isLoading: resolveLoading } = useResolvePolicy({ kind: "cancellation", productId: effectiveProductId ?? undefined }, { enabled: open });
46
+ const policy = resolved?.data;
47
+ const evalInput = React.useMemo(() => {
48
+ if (booking.sellAmountCents == null)
49
+ return null;
50
+ return {
51
+ daysBeforeDeparture,
52
+ totalCents: booking.sellAmountCents,
53
+ currency: booking.sellCurrency,
54
+ };
55
+ }, [daysBeforeDeparture, booking.sellAmountCents, booking.sellCurrency]);
56
+ const { data: evaluationData, isFetching: evaluationLoading } = useEvaluateCancellation(policy?.policy.id ?? null, evalInput, { enabled: open && Boolean(policy) });
57
+ const evaluation = evaluationData?.data ?? null;
58
+ const cancelMutation = useBookingCancelMutation(booking.id);
59
+ React.useEffect(() => {
60
+ if (!open) {
61
+ setReason("");
62
+ }
63
+ }, [open]);
64
+ const handleConfirm = async () => {
65
+ if (!reason.trim())
66
+ return;
67
+ await cancelMutation.mutateAsync({ note: reason.trim() });
68
+ onOpenChange(false);
69
+ onSuccess?.();
70
+ };
71
+ const total = booking.sellAmountCents;
72
+ const refund = evaluation?.refundCents ?? 0;
73
+ const penalty = total != null ? Math.max(0, total - refund) : 0;
74
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { size: "lg", children: [_jsx(DialogHeader, { children: _jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(AlertTriangle, { className: "h-4 w-4 text-destructive" }), "Cancel Booking"] }) }), _jsxs(DialogBody, { className: "grid gap-4", children: [_jsxs("div", { className: "grid grid-cols-2 gap-4 rounded-md border bg-muted/30 p-3 text-sm", children: [_jsxs("div", { children: [_jsx("div", { className: "text-xs text-muted-foreground", children: "Booking" }), _jsx("div", { className: "font-mono text-xs", children: booking.bookingNumber })] }), _jsxs("div", { children: [_jsx("div", { className: "text-xs text-muted-foreground", children: "Start date" }), _jsx("div", { children: booking.startDate ?? "TBD" })] }), _jsxs("div", { children: [_jsx("div", { className: "text-xs text-muted-foreground", children: "Total" }), _jsx("div", { className: "font-mono", children: total != null ? formatAmount(total, booking.sellCurrency) : "—" })] }), _jsxs("div", { children: [_jsx("div", { className: "text-xs text-muted-foreground", children: "Days before departure" }), _jsx("div", { children: daysBeforeDeparture })] })] }), resolveLoading ? (_jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), "Resolving cancellation policy..."] })) : !policy ? (_jsx("div", { className: "rounded-md border border-dashed p-3 text-sm text-muted-foreground", children: "No cancellation policy configured for this booking. Proceeding will cancel without a refund preview." })) : (_jsxs("div", { className: "space-y-2 rounded-md border p-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs("div", { children: [_jsx("div", { className: "text-xs text-muted-foreground", children: "Applicable policy" }), _jsx("div", { className: "text-sm font-medium", children: policy.policy.name })] }), evaluation && (_jsx(Badge, { variant: refundTypeVariant[evaluation.refundType] ?? "secondary", children: refundTypeLabel[evaluation.refundType] ?? evaluation.refundType }))] }), evaluationLoading ? (_jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), "Calculating refund..."] })) : evaluation && total != null ? (_jsxs("div", { className: "grid grid-cols-3 gap-3 border-t pt-3 text-sm", children: [_jsxs("div", { children: [_jsx("div", { className: "text-xs text-muted-foreground", children: "Refund" }), _jsx("div", { className: "font-mono font-medium", children: formatAmount(evaluation.refundCents, booking.sellCurrency) }), _jsxs("div", { className: "text-xs text-muted-foreground", children: ["(", formatPercent(evaluation.refundPercent), ")"] })] }), _jsxs("div", { children: [_jsx("div", { className: "text-xs text-muted-foreground", children: "Penalty" }), _jsx("div", { className: "font-mono font-medium text-destructive", children: formatAmount(penalty, booking.sellCurrency) })] }), _jsxs("div", { children: [_jsx("div", { className: "text-xs text-muted-foreground", children: "Rule" }), _jsx("div", { className: "text-xs", children: evaluation.appliedRule?.label ??
75
+ (evaluation.appliedRule?.daysBeforeDeparture != null
76
+ ? `≥ ${evaluation.appliedRule.daysBeforeDeparture} days`
77
+ : "—") })] })] })) : total == null ? (_jsx("p", { className: "border-t pt-3 text-sm text-muted-foreground", children: "Booking has no total amount \u2014 refund cannot be calculated." })) : null] })), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs(Label, { children: ["Reason ", _jsx("span", { className: "text-destructive", children: "*" })] }), _jsx(Textarea, { value: reason, onChange: (e) => setReason(e.target.value), placeholder: "Why is this booking being cancelled?", required: true })] }), cancelMutation.error && (_jsx("p", { className: "text-xs text-destructive", children: cancelMutation.error instanceof Error
78
+ ? cancelMutation.error.message
79
+ : "Cancellation failed" }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onOpenChange(false), disabled: cancelMutation.isPending, children: "Close" }), _jsxs(Button, { type: "button", variant: "destructive", size: "sm", onClick: handleConfirm, disabled: !reason.trim() || cancelMutation.isPending, children: [cancelMutation.isPending && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), "Confirm Cancellation"] })] })] }) }));
80
+ }
@@ -0,0 +1,21 @@
1
+ import { type BookingRecord } from "@voyantjs/bookings-react";
2
+ export interface BookingCreateDialogProps {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ onCreated?: (booking: BookingRecord) => void;
6
+ /** When provided, pre-selects this product and hides the product picker. */
7
+ defaultProductId?: string;
8
+ }
9
+ /**
10
+ * Operator booking-create dialog. Composes the booking-create picker
11
+ * sections — product, departure, rooms, person, shared-room, passengers,
12
+ * price breakdown, voucher, payment schedule — and submits via the atomic
13
+ * `POST /v1/bookings/quick-create` endpoint so partial failures can't
14
+ * leave orphan state.
15
+ *
16
+ * Normally consumed via `BookingDialog` which delegates here when no
17
+ * `booking` prop is passed. Apps that need a bespoke flow can install the
18
+ * sections individually and assemble their own dialog instead of forking.
19
+ */
20
+ export declare function BookingCreateDialog({ open, onOpenChange, onCreated, defaultProductId, }: BookingCreateDialogProps): import("react/jsx-runtime").JSX.Element;
21
+ //# sourceMappingURL=booking-create-dialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"booking-create-dialog.d.ts","sourceRoot":"","sources":["../../src/components/booking-create-dialog.tsx"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,aAAa,EAOnB,MAAM,0BAA0B,CAAA;AAqJjC,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAA;IAC5C,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,gBAAgB,GACjB,EAAE,wBAAwB,2CAoW1B"}
@@ -0,0 +1,313 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useSlots, useSlotUnitAvailability } from "@voyantjs/availability-react";
4
+ import { useBookingQuickCreateMutation, useBookingStatusByIdMutation, } from "@voyantjs/bookings-react";
5
+ import { usePersonMutation } from "@voyantjs/crm-react";
6
+ import { Button, Checkbox, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, } from "@voyantjs/voyant-ui/components";
7
+ import { Loader2 } from "lucide-react";
8
+ import * as React from "react";
9
+ import { emptyPassengerListValue, PassengersSection, } from "./passengers-section";
10
+ import { emptyPaymentScheduleValue, PaymentScheduleSection, } from "./payment-schedule-section";
11
+ import { emptyPersonPickerValue, PersonPickerSection, } from "./person-picker-section";
12
+ import { PriceBreakdownSection } from "./price-breakdown-section";
13
+ import { ProductPickerSection } from "./product-picker-section";
14
+ import { emptyRoomsStepperValue, RoomsStepperSection, } from "./rooms-stepper-section";
15
+ import { emptySharedRoomValue, SharedRoomSection, } from "./shared-room-section";
16
+ import { emptyVoucherPickerValue, VoucherPickerSection, } from "./voucher-picker-section";
17
+ function generateBookingNumber() {
18
+ const now = new Date();
19
+ const y = now.getFullYear().toString().slice(-2);
20
+ const m = String(now.getMonth() + 1).padStart(2, "0");
21
+ const seq = String(Math.floor(Math.random() * 9000) + 1000);
22
+ return `BK-${y}${m}-${seq}`;
23
+ }
24
+ function paymentScheduleToRows(value, currency, totalAmountCents) {
25
+ if (value.mode === "unpaid")
26
+ return [];
27
+ if (value.mode === "full") {
28
+ if (!value.fullDueDate || totalAmountCents === null)
29
+ return [];
30
+ return [
31
+ {
32
+ scheduleType: "balance",
33
+ status: "due",
34
+ dueDate: value.fullDueDate,
35
+ currency,
36
+ amountCents: totalAmountCents,
37
+ },
38
+ ];
39
+ }
40
+ if (value.mode === "advance") {
41
+ if (!value.advanceDueDate || value.advanceAmountCents == null)
42
+ return [];
43
+ const rows = [
44
+ {
45
+ scheduleType: "deposit",
46
+ status: "due",
47
+ dueDate: value.advanceDueDate,
48
+ currency,
49
+ amountCents: value.advanceAmountCents,
50
+ },
51
+ ];
52
+ if (totalAmountCents !== null && totalAmountCents > value.advanceAmountCents) {
53
+ rows.push({
54
+ scheduleType: "balance",
55
+ status: "pending",
56
+ dueDate: value.advanceDueDate,
57
+ currency,
58
+ amountCents: totalAmountCents - value.advanceAmountCents,
59
+ });
60
+ }
61
+ return rows;
62
+ }
63
+ // split
64
+ const rows = [];
65
+ if (value.splitFirstDueDate && value.splitFirstAmountCents != null) {
66
+ rows.push({
67
+ scheduleType: "installment",
68
+ status: "due",
69
+ dueDate: value.splitFirstDueDate,
70
+ currency,
71
+ amountCents: value.splitFirstAmountCents,
72
+ });
73
+ }
74
+ if (value.splitSecondDueDate && value.splitSecondAmountCents != null) {
75
+ rows.push({
76
+ scheduleType: "installment",
77
+ status: "pending",
78
+ dueDate: value.splitSecondDueDate,
79
+ currency,
80
+ amountCents: value.splitSecondAmountCents,
81
+ });
82
+ }
83
+ return rows;
84
+ }
85
+ function passengersToRows(value) {
86
+ return value.passengers.map((p) => ({
87
+ firstName: p.firstName.trim(),
88
+ lastName: p.lastName.trim(),
89
+ email: p.email.trim() || null,
90
+ participantType: "traveler",
91
+ travelerCategory: p.role === "child"
92
+ ? "child"
93
+ : p.role === "infant"
94
+ ? "infant"
95
+ : p.role === "adult"
96
+ ? "adult"
97
+ : null,
98
+ isPrimary: p.role === "lead",
99
+ roomUnitId: p.roomUnitId,
100
+ }));
101
+ }
102
+ /**
103
+ * Operator booking-create dialog. Composes the booking-create picker
104
+ * sections — product, departure, rooms, person, shared-room, passengers,
105
+ * price breakdown, voucher, payment schedule — and submits via the atomic
106
+ * `POST /v1/bookings/quick-create` endpoint so partial failures can't
107
+ * leave orphan state.
108
+ *
109
+ * Normally consumed via `BookingDialog` which delegates here when no
110
+ * `booking` prop is passed. Apps that need a bespoke flow can install the
111
+ * sections individually and assemble their own dialog instead of forking.
112
+ */
113
+ export function BookingCreateDialog({ open, onOpenChange, onCreated, defaultProductId, }) {
114
+ const [product, setProduct] = React.useState({
115
+ productId: defaultProductId ?? "",
116
+ optionId: null,
117
+ });
118
+ const [slotId, setSlotId] = React.useState(null);
119
+ const [rooms, setRooms] = React.useState(emptyRoomsStepperValue);
120
+ const [person, setPerson] = React.useState(emptyPersonPickerValue);
121
+ const [sharedRoom, setSharedRoom] = React.useState(emptySharedRoomValue);
122
+ const [passengers, setPassengers] = React.useState(emptyPassengerListValue);
123
+ const [voucher, setVoucher] = React.useState(emptyVoucherPickerValue);
124
+ const [paymentSchedule, setPaymentSchedule] = React.useState(emptyPaymentScheduleValue);
125
+ const [notes, setNotes] = React.useState("");
126
+ /**
127
+ * Optional post-create transition: set status to `confirmed` right after
128
+ * create succeeds. When the parent app has the notifications module's
129
+ * `autoConfirmAndDispatch` enabled, this fires the doc bundle + traveler
130
+ * email via the `booking.confirmed` subscriber. When it isn't, the
131
+ * booking simply lands in `confirmed` instead of `draft`.
132
+ */
133
+ const [confirmAfterCreate, setConfirmAfterCreate] = React.useState(false);
134
+ const [error, setError] = React.useState(null);
135
+ React.useEffect(() => {
136
+ if (!open) {
137
+ setProduct({ productId: defaultProductId ?? "", optionId: null });
138
+ setSlotId(null);
139
+ setRooms(emptyRoomsStepperValue);
140
+ setPerson(emptyPersonPickerValue);
141
+ setSharedRoom(emptySharedRoomValue);
142
+ setPassengers(emptyPassengerListValue);
143
+ setVoucher(emptyVoucherPickerValue);
144
+ setPaymentSchedule(emptyPaymentScheduleValue);
145
+ setNotes("");
146
+ setConfirmAfterCreate(false);
147
+ setError(null);
148
+ }
149
+ else if (defaultProductId) {
150
+ setProduct((prev) => prev.productId === defaultProductId
151
+ ? prev
152
+ : { productId: defaultProductId, optionId: null });
153
+ }
154
+ }, [open, defaultProductId]);
155
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only resets when product/option changes
156
+ React.useEffect(() => {
157
+ setSlotId(null);
158
+ setRooms(emptyRoomsStepperValue);
159
+ }, [product.productId, product.optionId]);
160
+ const { data: slotsData } = useSlots({
161
+ productId: product.productId || undefined,
162
+ status: "open",
163
+ limit: 100,
164
+ enabled: open && Boolean(product.productId),
165
+ });
166
+ const slots = React.useMemo(() => {
167
+ const nowIso = new Date().toISOString();
168
+ return (slotsData?.data ?? [])
169
+ .filter((slot) => slot.startsAt >= nowIso)
170
+ .filter((slot) => {
171
+ if (!product.optionId)
172
+ return true;
173
+ return slot.optionId === null || slot.optionId === product.optionId;
174
+ })
175
+ .sort((a, b) => a.startsAt.localeCompare(b.startsAt));
176
+ }, [slotsData, product.optionId]);
177
+ const formatSlotLabel = React.useCallback((slot) => {
178
+ const date = new Date(slot.startsAt).toLocaleDateString(undefined, {
179
+ year: "numeric",
180
+ month: "short",
181
+ day: "numeric",
182
+ });
183
+ const remaining = !slot.unlimited && typeof slot.remainingPax === "number" ? ` · ${slot.remainingPax} left` : "";
184
+ return `${date}${remaining}`;
185
+ }, []);
186
+ const slotUnitAvailability = useSlotUnitAvailability({
187
+ slotId: slotId ?? undefined,
188
+ enabled: open && Boolean(slotId),
189
+ });
190
+ const roomUnitOptions = React.useMemo(() => {
191
+ const units = slotUnitAvailability.data?.data ?? [];
192
+ if (units.length === 0)
193
+ return [];
194
+ return units
195
+ .filter((unit) => (rooms.quantities[unit.optionUnitId] ?? 0) > 0)
196
+ .map((unit) => {
197
+ const qty = rooms.quantities[unit.optionUnitId] ?? 0;
198
+ const occupancyMax = 1;
199
+ const seats = qty * occupancyMax;
200
+ const assigned = passengers.passengers.filter((p) => p.roomUnitId === unit.optionUnitId).length;
201
+ return {
202
+ unitId: unit.optionUnitId,
203
+ unitName: unit.unitName,
204
+ remainingCapacity: Math.max(0, seats - assigned),
205
+ };
206
+ });
207
+ }, [slotUnitAvailability.data, rooms.quantities, passengers.passengers]);
208
+ // Currency placeholder — used for voucher + payment schedule display.
209
+ // Consumers hooking in real product data should override this by wrapping
210
+ // the component or swapping in their own currency-aware hook.
211
+ const currency = "EUR";
212
+ const { create: createPerson } = usePersonMutation();
213
+ const quickCreateMutation = useBookingQuickCreateMutation();
214
+ const statusMutation = useBookingStatusByIdMutation();
215
+ const handleSubmit = async () => {
216
+ setError(null);
217
+ if (!product.productId) {
218
+ setError("Select a product");
219
+ return;
220
+ }
221
+ let resolvedPersonId = null;
222
+ try {
223
+ if (person.mode === "existing") {
224
+ if (!person.personId) {
225
+ setError("Select a person or switch to create mode");
226
+ return;
227
+ }
228
+ resolvedPersonId = person.personId;
229
+ }
230
+ else {
231
+ if (!person.newPerson.firstName.trim() || !person.newPerson.lastName.trim()) {
232
+ setError("First and last name are required");
233
+ return;
234
+ }
235
+ const created = await createPerson.mutateAsync({
236
+ firstName: person.newPerson.firstName.trim(),
237
+ lastName: person.newPerson.lastName.trim(),
238
+ email: person.newPerson.email.trim() || null,
239
+ phone: person.newPerson.phone.trim() || null,
240
+ });
241
+ resolvedPersonId = created.id;
242
+ }
243
+ if (sharedRoom.enabled && sharedRoom.mode === "join" && !sharedRoom.groupId) {
244
+ setError("Select a shared-room group to join");
245
+ return;
246
+ }
247
+ const bookingNumber = generateBookingNumber();
248
+ const paymentSchedules = paymentScheduleToRows(paymentSchedule, currency, null);
249
+ const travelers = passengersToRows(passengers);
250
+ const voucherRedemption = voucher.picked && voucher.picked.remainingAmountCents != null
251
+ ? {
252
+ voucherId: voucher.picked.id,
253
+ amountCents: voucher.picked.remainingAmountCents,
254
+ }
255
+ : undefined;
256
+ const groupMembership = sharedRoom.enabled
257
+ ? sharedRoom.mode === "create"
258
+ ? {
259
+ action: "create",
260
+ kind: "shared_room",
261
+ label: `Shared room — ${bookingNumber}`,
262
+ optionUnitId: product.optionId,
263
+ makeBookingPrimary: true,
264
+ }
265
+ : sharedRoom.groupId
266
+ ? { action: "join", groupId: sharedRoom.groupId, role: "shared" }
267
+ : undefined
268
+ : undefined;
269
+ const { booking } = await quickCreateMutation.mutateAsync({
270
+ productId: product.productId,
271
+ bookingNumber,
272
+ optionId: product.optionId,
273
+ slotId,
274
+ personId: resolvedPersonId,
275
+ organizationId: person.organizationId,
276
+ internalNotes: notes.trim() || null,
277
+ travelers: travelers.length > 0 ? travelers : undefined,
278
+ paymentSchedules: paymentSchedules.length > 0 ? paymentSchedules : undefined,
279
+ voucherRedemption,
280
+ groupMembership,
281
+ });
282
+ // Optional post-create confirm. If the app has autoConfirmAndDispatch
283
+ // wired on the notifications module, the status transition triggers
284
+ // the doc bundle + traveler email subscriber. A failed status change
285
+ // doesn't roll back the booking — it exists, operator can confirm
286
+ // manually later.
287
+ let finalBooking = booking;
288
+ if (confirmAfterCreate) {
289
+ try {
290
+ finalBooking = await statusMutation.mutateAsync({
291
+ bookingId: booking.id,
292
+ currentStatus: booking.status,
293
+ status: "confirmed",
294
+ });
295
+ }
296
+ catch (statusErr) {
297
+ setError(statusErr instanceof Error
298
+ ? `Booking created but confirm failed: ${statusErr.message}`
299
+ : "Booking created but confirm failed");
300
+ onCreated?.(booking);
301
+ return;
302
+ }
303
+ }
304
+ onOpenChange(false);
305
+ onCreated?.(finalBooking);
306
+ }
307
+ catch (err) {
308
+ setError(err instanceof Error ? err.message : "Failed to create booking");
309
+ }
310
+ };
311
+ const isSubmitting = quickCreateMutation.isPending || createPerson.isPending || statusMutation.isPending;
312
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { size: "lg", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: "Quick Book" }) }), _jsxs(DialogBody, { className: "grid gap-4", children: [_jsx(ProductPickerSection, { value: product, onChange: setProduct, enabled: open, lockProduct: Boolean(defaultProductId) }), product.productId ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { children: "Departure" }), _jsxs(Select, { value: slotId ?? "__none__", onValueChange: (v) => setSlotId(v === "__none__" ? null : (v ?? null)), children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, { placeholder: "Select a departure..." }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "__none__", children: "No specific departure" }), slots.length === 0 ? (_jsx(SelectItem, { value: "__empty__", disabled: true, children: "No open departures for this product" })) : (slots.map((slot) => (_jsx(SelectItem, { value: slot.id, children: formatSlotLabel(slot) }, slot.id))))] })] })] })) : null, slotId ? (_jsx(RoomsStepperSection, { value: rooms, onChange: setRooms, slotId: slotId, enabled: open })) : null, _jsx(PersonPickerSection, { value: person, onChange: setPerson, enabled: open }), _jsx(SharedRoomSection, { value: sharedRoom, onChange: setSharedRoom, productId: product.productId || undefined, enabled: open }), product.productId ? (_jsx(PassengersSection, { value: passengers, onChange: setPassengers, roomUnits: roomUnitOptions.length > 0 ? roomUnitOptions : undefined })) : null, product.productId ? (_jsx(PriceBreakdownSection, { productId: product.productId, optionId: product.optionId, unitQuantities: rooms.quantities })) : null, _jsx(VoucherPickerSection, { value: voucher, onChange: setVoucher, currency: currency }), _jsx(PaymentScheduleSection, { value: paymentSchedule, onChange: setPaymentSchedule, currency: currency }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "Internal Notes" }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), placeholder: "Quick context for this booking..." })] }), _jsxs("div", { className: "flex items-start gap-2 rounded-md border p-3", children: [_jsx(Checkbox, { id: "quickbook-confirm-after-create", checked: confirmAfterCreate, onCheckedChange: (v) => setConfirmAfterCreate(v === true), className: "mt-0.5" }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { htmlFor: "quickbook-confirm-after-create", className: "cursor-pointer text-sm", children: "Confirm & notify traveler after creating" }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Transitions to confirmed after create. When the notifications module's auto-dispatch is on, this fires the doc bundle + traveler email via the booking.confirmed subscriber." })] })] }), error && _jsx("p", { className: "text-xs text-destructive", children: error })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onOpenChange(false), disabled: isSubmitting, children: "Cancel" }), _jsxs(Button, { type: "button", size: "sm", onClick: handleSubmit, disabled: isSubmitting || !product.productId, children: [isSubmitting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), "Create Draft Booking"] })] })] }) }));
313
+ }
@@ -0,0 +1,23 @@
1
+ import { type BookingRecord } from "@voyantjs/bookings-react";
2
+ export interface BookingDialogProps {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ booking?: BookingRecord;
6
+ onSuccess?: (booking: BookingRecord) => void;
7
+ /**
8
+ * Pre-seeds the product picker in create mode. Useful when opened from
9
+ * a product detail page. Ignored when editing an existing booking.
10
+ */
11
+ defaultProductId?: string;
12
+ }
13
+ /**
14
+ * Single booking dialog that handles both create and edit:
15
+ * - Create (no `booking` prop): renders the rich product → option → person
16
+ * picker flow via `BookingCreateDialog`, so the draft booking inherits
17
+ * pricing, dates, and currency from the catalogue instead of being
18
+ * hand-entered.
19
+ * - Edit (with `booking` prop): renders the flat form below that patches
20
+ * the existing row's metadata (status, amounts, dates, notes).
21
+ */
22
+ export declare function BookingDialog({ open, onOpenChange, booking, onSuccess, defaultProductId, }: BookingDialogProps): import("react/jsx-runtime").JSX.Element;
23
+ //# sourceMappingURL=booking-dialog.d.ts.map
@@ -0,0 +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;AA2CjF,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;AAUD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,YAAY,EACZ,OAAO,EACP,SAAS,EACT,gBAAgB,GACjB,EAAE,kBAAkB,2CAoBpB"}