@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
@@ -0,0 +1,92 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { File as FileIcon, Loader2, Upload, X } from "lucide-react";
4
+ import * as React from "react";
5
+ function formatSize(bytes) {
6
+ if (bytes < 1024)
7
+ return `${bytes} B`;
8
+ if (bytes < 1024 * 1024)
9
+ return `${(bytes / 1024).toFixed(1)} KB`;
10
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
11
+ }
12
+ export function FileDropzone({ uploadUrl = "/api/v1/uploads", accept, maxSize, onUploaded, onError, helperText = "Drag and drop a file here, or click to select", disabled, }) {
13
+ const inputRef = React.useRef(null);
14
+ const [isDragging, setIsDragging] = React.useState(false);
15
+ const [isUploading, setIsUploading] = React.useState(false);
16
+ const [uploaded, setUploaded] = React.useState(null);
17
+ const [error, setError] = React.useState(null);
18
+ const reportError = (message) => {
19
+ setError(message);
20
+ onError?.(message);
21
+ };
22
+ const handleFile = async (file) => {
23
+ setError(null);
24
+ if (maxSize && file.size > maxSize) {
25
+ reportError(`File too large (max ${formatSize(maxSize)})`);
26
+ return;
27
+ }
28
+ setIsUploading(true);
29
+ try {
30
+ const formData = new FormData();
31
+ formData.append("file", file);
32
+ const res = await fetch(uploadUrl, {
33
+ method: "POST",
34
+ body: formData,
35
+ credentials: "include",
36
+ });
37
+ if (!res.ok) {
38
+ const body = await res.text();
39
+ reportError(body || `Upload failed (${res.status})`);
40
+ return;
41
+ }
42
+ const data = (await res.json());
43
+ const result = { ...data, name: file.name };
44
+ setUploaded(result);
45
+ onUploaded(result);
46
+ }
47
+ catch (err) {
48
+ reportError(err instanceof Error ? err.message : "Upload failed");
49
+ }
50
+ finally {
51
+ setIsUploading(false);
52
+ }
53
+ };
54
+ const handleDrop = (e) => {
55
+ e.preventDefault();
56
+ e.stopPropagation();
57
+ setIsDragging(false);
58
+ if (disabled || isUploading)
59
+ return;
60
+ const file = e.dataTransfer.files?.[0];
61
+ if (file) {
62
+ void handleFile(file);
63
+ }
64
+ };
65
+ const handleDragOver = (e) => {
66
+ e.preventDefault();
67
+ e.stopPropagation();
68
+ if (!disabled && !isUploading) {
69
+ setIsDragging(true);
70
+ }
71
+ };
72
+ const handleDragLeave = (e) => {
73
+ e.preventDefault();
74
+ e.stopPropagation();
75
+ setIsDragging(false);
76
+ };
77
+ const handleChange = (e) => {
78
+ const file = e.target.files?.[0];
79
+ if (file) {
80
+ void handleFile(file);
81
+ }
82
+ e.target.value = "";
83
+ };
84
+ const reset = () => {
85
+ setUploaded(null);
86
+ setError(null);
87
+ };
88
+ if (uploaded) {
89
+ return (_jsxs("div", { className: "flex items-center justify-between gap-3 rounded-md border bg-muted/30 p-3", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(FileIcon, { className: "h-4 w-4 shrink-0 text-muted-foreground" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: uploaded.name }), _jsxs("p", { className: "text-xs text-muted-foreground", children: [formatSize(uploaded.size), " \u00B7 ", uploaded.mimeType] })] })] }), _jsx("button", { type: "button", onClick: reset, className: "text-muted-foreground hover:text-destructive", "aria-label": "Remove file", children: _jsx(X, { className: "h-4 w-4" }) })] }));
90
+ }
91
+ return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("button", { type: "button", onClick: () => inputRef.current?.click(), onDrop: handleDrop, onDragOver: handleDragOver, onDragLeave: handleDragLeave, disabled: disabled || isUploading, "data-dragging": isDragging, className: "flex flex-col items-center justify-center gap-2 rounded-md border border-dashed px-4 py-8 text-center transition-colors hover:border-foreground/30 hover:bg-muted/30 data-[dragging=true]:border-primary data-[dragging=true]:bg-primary/5 disabled:cursor-not-allowed disabled:opacity-60", children: isUploading ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "h-6 w-6 animate-spin text-muted-foreground" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Uploading..." })] })) : (_jsxs(_Fragment, { children: [_jsx(Upload, { className: "h-6 w-6 text-muted-foreground" }), _jsx("p", { className: "text-sm text-muted-foreground", children: helperText }), accept && _jsxs("p", { className: "text-xs text-muted-foreground", children: ["Accepted: ", accept] })] })) }), _jsx("input", { ref: inputRef, type: "file", className: "hidden", accept: accept, onChange: handleChange, disabled: disabled || isUploading }), error && _jsx("p", { className: "text-xs text-destructive", children: error })] }));
92
+ }
@@ -0,0 +1,72 @@
1
+ export type PassengerRole = "lead" | "adult" | "child" | "infant";
2
+ export interface PassengerEntry {
3
+ firstName: string;
4
+ lastName: string;
5
+ email: string;
6
+ role: PassengerRole;
7
+ /** option_unit_id the passenger is assigned to (matches RoomsStepper units). */
8
+ roomUnitId: string | null;
9
+ }
10
+ export interface PassengerListValue {
11
+ passengers: PassengerEntry[];
12
+ }
13
+ export declare const emptyPassengerListValue: PassengerListValue;
14
+ /** Factory for a blank row — `role` defaults to `adult` unless the list is empty. */
15
+ export declare function createBlankPassenger(role?: PassengerRole): PassengerEntry;
16
+ export interface RoomUnitOption {
17
+ unitId: string;
18
+ unitName: string;
19
+ /**
20
+ * How many more passengers can be assigned to this unit. Decremented by
21
+ * the parent based on the stepper's quantity × occupancy capacity minus
22
+ * passengers already assigned to that unit.
23
+ */
24
+ remainingCapacity: number;
25
+ }
26
+ export interface PassengersSectionProps {
27
+ value: PassengerListValue;
28
+ onChange: (value: PassengerListValue) => void;
29
+ /**
30
+ * Rooms the operator has selected (from RoomsStepperSection + occupancy).
31
+ * When provided, each passenger gets a room-assignment dropdown.
32
+ */
33
+ roomUnits?: RoomUnitOption[];
34
+ labels?: {
35
+ heading?: string;
36
+ addPassenger?: string;
37
+ firstName?: string;
38
+ lastName?: string;
39
+ email?: string;
40
+ role?: string;
41
+ roleLead?: string;
42
+ roleAdult?: string;
43
+ roleChild?: string;
44
+ roleInfant?: string;
45
+ room?: string;
46
+ noRoom?: string;
47
+ remove?: string;
48
+ empty?: string;
49
+ };
50
+ }
51
+ /**
52
+ * Passenger list for booking-create flows. Each row carries name + optional
53
+ * email + role + optional room assignment. Inline-create only for now —
54
+ * operators who want to pick an existing CRM person can do so from the
55
+ * booking detail page afterwards, consistent with the lead-person picker's
56
+ * edit-after-create story.
57
+ *
58
+ * ### Parent contract
59
+ *
60
+ * At submit time, the parent:
61
+ * 1. Creates a CRM person for each row that doesn't match an existing one
62
+ * (email match + name, or skip when the operator intentionally left
63
+ * email blank).
64
+ * 2. Inserts a `booking_travelers` row per passenger with `participantType`
65
+ * derived from the role (`lead` / `adult` → traveler; `child` / `infant`
66
+ * → traveler with travelerCategory set).
67
+ * 3. Exactly one row should have `role: "lead"` — enforced at submit, not
68
+ * here. The UI lets the operator pick whichever layout they want, then
69
+ * the submit handler errors if the invariant isn't met.
70
+ */
71
+ export declare function PassengersSection({ value, onChange, roomUnits, labels }: PassengersSectionProps): import("react/jsx-runtime").JSX.Element;
72
+ //# sourceMappingURL=passengers-section.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passengers-section.d.ts","sourceRoot":"","sources":["../../src/components/passengers-section.tsx"],"names":[],"mappings":"AAcA,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAA;AAIjE,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,aAAa,CAAA;IACnB,gFAAgF;IAChF,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,cAAc,EAAE,CAAA;CAC7B;AAED,eAAO,MAAM,uBAAuB,EAAE,kBAAuC,CAAA;AAE7E,qFAAqF;AACrF,wBAAgB,oBAAoB,CAAC,IAAI,GAAE,aAAuB,GAAG,cAAc,CAElF;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,iBAAiB,EAAE,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,kBAAkB,CAAA;IACzB,QAAQ,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,CAAA;IAC7C;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,EAAE,CAAA;IAC5B,MAAM,CAAC,EAAE;QACP,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,YAAY,CAAC,EAAE,MAAM,CAAA;QACrB,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,UAAU,CAAC,EAAE,MAAM,CAAA;QACnB,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,KAAK,CAAC,EAAE,MAAM,CAAA;KACf,CAAA;CACF;AAqBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,sBAAsB,2CA2I/F"}
@@ -0,0 +1,74 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@voyantjs/voyant-ui/components";
4
+ import { Trash2 } from "lucide-react";
5
+ const ALL_ROLES = ["lead", "adult", "child", "infant"];
6
+ export const emptyPassengerListValue = { passengers: [] };
7
+ /** Factory for a blank row — `role` defaults to `adult` unless the list is empty. */
8
+ export function createBlankPassenger(role = "adult") {
9
+ return { firstName: "", lastName: "", email: "", role, roomUnitId: null };
10
+ }
11
+ const DEFAULT_LABELS = {
12
+ heading: "Passengers",
13
+ addPassenger: "Add passenger",
14
+ firstName: "First name",
15
+ lastName: "Last name",
16
+ email: "Email",
17
+ role: "Role",
18
+ roleLead: "Lead",
19
+ roleAdult: "Adult",
20
+ roleChild: "Child",
21
+ roleInfant: "Infant",
22
+ room: "Room",
23
+ noRoom: "Unassigned",
24
+ remove: "Remove passenger",
25
+ empty: "No passengers yet. Add at least one.",
26
+ };
27
+ const NO_ROOM = "__unassigned__";
28
+ /**
29
+ * Passenger list for booking-create flows. Each row carries name + optional
30
+ * email + role + optional room assignment. Inline-create only for now —
31
+ * operators who want to pick an existing CRM person can do so from the
32
+ * booking detail page afterwards, consistent with the lead-person picker's
33
+ * edit-after-create story.
34
+ *
35
+ * ### Parent contract
36
+ *
37
+ * At submit time, the parent:
38
+ * 1. Creates a CRM person for each row that doesn't match an existing one
39
+ * (email match + name, or skip when the operator intentionally left
40
+ * email blank).
41
+ * 2. Inserts a `booking_travelers` row per passenger with `participantType`
42
+ * derived from the role (`lead` / `adult` → traveler; `child` / `infant`
43
+ * → traveler with travelerCategory set).
44
+ * 3. Exactly one row should have `role: "lead"` — enforced at submit, not
45
+ * here. The UI lets the operator pick whichever layout they want, then
46
+ * the submit handler errors if the invariant isn't met.
47
+ */
48
+ export function PassengersSection({ value, onChange, roomUnits, labels }) {
49
+ const merged = { ...DEFAULT_LABELS, ...labels };
50
+ const roleLabels = {
51
+ lead: merged.roleLead,
52
+ adult: merged.roleAdult,
53
+ child: merged.roleChild,
54
+ infant: merged.roleInfant,
55
+ };
56
+ const updateAt = (index, patch) => {
57
+ const next = value.passengers.map((p, i) => (i === index ? { ...p, ...patch } : p));
58
+ onChange({ passengers: next });
59
+ };
60
+ const removeAt = (index) => {
61
+ onChange({ passengers: value.passengers.filter((_, i) => i !== index) });
62
+ };
63
+ const addRow = () => {
64
+ // First passenger defaults to `lead` so the operator doesn't have to
65
+ // remember to flip the role on the initial row.
66
+ const role = value.passengers.length === 0 ? "lead" : "adult";
67
+ onChange({ passengers: [...value.passengers, createBlankPassenger(role)] });
68
+ };
69
+ return (_jsxs("div", { className: "flex flex-col gap-2 rounded-md border p-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Label, { children: merged.heading }), _jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: addRow, children: merged.addPassenger })] }), value.passengers.length === 0 ? (_jsx("p", { className: "text-xs text-muted-foreground", children: merged.empty })) : (_jsx("div", { className: "flex flex-col gap-2", children: value.passengers.map((passenger, index) => (_jsxs("div", { className: "flex flex-col gap-2 rounded-md border p-2", children: [_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(Input, { placeholder: merged.firstName, value: passenger.firstName, onChange: (e) => updateAt(index, { firstName: e.target.value }) }), _jsx(Input, { placeholder: merged.lastName, value: passenger.lastName, onChange: (e) => updateAt(index, { lastName: e.target.value }) })] }), _jsx(Input, { type: "email", placeholder: merged.email, value: passenger.email, onChange: (e) => updateAt(index, { email: e.target.value }) }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.role }), _jsxs(Select, { value: passenger.role, onValueChange: (v) => updateAt(index, { role: (v ?? "adult") }), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: ALL_ROLES.map((role) => (_jsx(SelectItem, { value: role, children: roleLabels[role] }, role))) })] })] }), roomUnits && roomUnits.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.room }), _jsxs(Select, { value: passenger.roomUnitId ?? NO_ROOM, onValueChange: (v) => updateAt(index, { roomUnitId: v === NO_ROOM ? null : (v ?? null) }), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: NO_ROOM, children: merged.noRoom }), roomUnits.map((unit) => (_jsx(SelectItem, { value: unit.unitId,
70
+ // Only disable other rooms at-capacity — the room the
71
+ // passenger is *already* in should stay selectable so
72
+ // re-renders don't strip the selection.
73
+ disabled: unit.remainingCapacity <= 0 && passenger.roomUnitId !== unit.unitId, children: unit.unitName }, unit.unitId)))] })] })] })) : null] }), _jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 text-destructive", onClick: () => removeAt(index), "aria-label": merged.remove, children: [_jsx(Trash2, { className: "mr-1 h-3.5 w-3.5" }), merged.remove] }) })] }, index))) }))] }));
74
+ }
@@ -0,0 +1,62 @@
1
+ export type PaymentScheduleMode = "unpaid" | "full" | "advance" | "split";
2
+ export interface PaymentScheduleValue {
3
+ mode: PaymentScheduleMode;
4
+ /** Used when mode === "full" — single due date for the whole amount. */
5
+ fullDueDate: string | null;
6
+ /** Used when mode === "advance" — deposit amount collected up front. */
7
+ advanceAmountCents: number | null;
8
+ advanceDueDate: string | null;
9
+ /** Used when mode === "split" — two installments. */
10
+ splitFirstAmountCents: number | null;
11
+ splitFirstDueDate: string | null;
12
+ splitSecondAmountCents: number | null;
13
+ splitSecondDueDate: string | null;
14
+ }
15
+ export declare const emptyPaymentScheduleValue: PaymentScheduleValue;
16
+ export interface PaymentScheduleSectionProps {
17
+ value: PaymentScheduleValue;
18
+ onChange: (value: PaymentScheduleValue) => void;
19
+ /**
20
+ * Booking total in cents. Enables the 50/50 preset in split mode and the
21
+ * "Use balance" helper in advance mode. When unset the section still works
22
+ * — operator types the amounts.
23
+ */
24
+ totalAmountCents?: number;
25
+ /** Used only for display formatting (e.g., "EUR"). No server-side effect. */
26
+ currency?: string;
27
+ labels?: {
28
+ heading?: string;
29
+ modeUnpaid?: string;
30
+ modeFull?: string;
31
+ modeAdvance?: string;
32
+ modeSplit?: string;
33
+ dueDate?: string;
34
+ amount?: string;
35
+ firstInstallment?: string;
36
+ secondInstallment?: string;
37
+ preset5050?: string;
38
+ unpaidHint?: string;
39
+ };
40
+ }
41
+ /**
42
+ * Payment schedule picker for booking-create flows. Operators choose one of
43
+ * four modes; only the relevant fields render for the selected mode, so the
44
+ * UI stays narrow.
45
+ *
46
+ * The section produces a controlled `PaymentScheduleValue` — actually
47
+ * creating `booking_payment_schedules` rows happens in the parent at submit
48
+ * time, after the booking exists (schedules have a FK to `bookings.id`).
49
+ *
50
+ * ### Mapping guide for the parent
51
+ *
52
+ * - `unpaid` → no schedules created.
53
+ * - `full` → one schedule with `scheduleType: "balance"`, dueDate =
54
+ * fullDueDate, amountCents = bookingTotalAmountCents.
55
+ * - `advance` → two schedules: { type: "deposit", dueDate = advanceDueDate,
56
+ * amountCents = advanceAmountCents } + { type: "balance",
57
+ * dueDate = fullDueDate ?? sensible-default, amountCents =
58
+ * total - advanceAmountCents }.
59
+ * - `split` → two schedules with `scheduleType: "installment"`.
60
+ */
61
+ export declare function PaymentScheduleSection({ value, onChange, totalAmountCents, currency, labels, }: PaymentScheduleSectionProps): import("react/jsx-runtime").JSX.Element;
62
+ //# sourceMappingURL=payment-schedule-section.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payment-schedule-section.d.ts","sourceRoot":"","sources":["../../src/components/payment-schedule-section.tsx"],"names":[],"mappings":"AAIA,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAA;AAEzE,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,mBAAmB,CAAA;IACzB,wEAAwE;IACxE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,wEAAwE;IACxE,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,qDAAqD;IACrD,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAA;IACpC,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAA;CAClC;AAED,eAAO,MAAM,yBAAyB,EAAE,oBASvC,CAAA;AAED,MAAM,WAAW,2BAA2B;IAC1C,KAAK,EAAE,oBAAoB,CAAA;IAC3B,QAAQ,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAA;IAC/C;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE;QACP,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,UAAU,CAAC,EAAE,MAAM,CAAA;QACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,gBAAgB,CAAC,EAAE,MAAM,CAAA;QACzB,iBAAiB,CAAC,EAAE,MAAM,CAAA;QAC1B,UAAU,CAAC,EAAE,MAAM,CAAA;QACnB,UAAU,CAAC,EAAE,MAAM,CAAA;KACpB,CAAA;CACF;AAkCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,KAAK,EACL,QAAQ,EACR,gBAAgB,EAChB,QAAQ,EACR,MAAM,GACP,EAAE,2BAA2B,2CAmI7B"}
@@ -0,0 +1,88 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, Input, Label } from "@voyantjs/voyant-ui/components";
4
+ export const emptyPaymentScheduleValue = {
5
+ mode: "unpaid",
6
+ fullDueDate: null,
7
+ advanceAmountCents: null,
8
+ advanceDueDate: null,
9
+ splitFirstAmountCents: null,
10
+ splitFirstDueDate: null,
11
+ splitSecondAmountCents: null,
12
+ splitSecondDueDate: null,
13
+ };
14
+ const DEFAULT_LABELS = {
15
+ heading: "Payment schedule",
16
+ modeUnpaid: "Unpaid",
17
+ modeFull: "Full",
18
+ modeAdvance: "Advance",
19
+ modeSplit: "Split",
20
+ dueDate: "Due date",
21
+ amount: "Amount",
22
+ firstInstallment: "First installment",
23
+ secondInstallment: "Second installment",
24
+ preset5050: "50 / 50",
25
+ unpaidHint: "No payment schedule will be created. Operator will invoice manually.",
26
+ };
27
+ /**
28
+ * Converts an `<input type="number">` string value to minor units (cents).
29
+ * Accepts `""` / `NaN` → `null`. Multiplies by 100 and rounds to avoid
30
+ * floating-point garbage (`19.99 * 100` → `1999`, not `1998.99999...`).
31
+ */
32
+ function majorStringToCents(value) {
33
+ const trimmed = value.trim();
34
+ if (!trimmed)
35
+ return null;
36
+ const parsed = Number(trimmed);
37
+ if (!Number.isFinite(parsed) || parsed < 0)
38
+ return null;
39
+ return Math.round(parsed * 100);
40
+ }
41
+ function centsToMajorString(cents) {
42
+ if (cents == null)
43
+ return "";
44
+ return (cents / 100).toFixed(2);
45
+ }
46
+ /**
47
+ * Payment schedule picker for booking-create flows. Operators choose one of
48
+ * four modes; only the relevant fields render for the selected mode, so the
49
+ * UI stays narrow.
50
+ *
51
+ * The section produces a controlled `PaymentScheduleValue` — actually
52
+ * creating `booking_payment_schedules` rows happens in the parent at submit
53
+ * time, after the booking exists (schedules have a FK to `bookings.id`).
54
+ *
55
+ * ### Mapping guide for the parent
56
+ *
57
+ * - `unpaid` → no schedules created.
58
+ * - `full` → one schedule with `scheduleType: "balance"`, dueDate =
59
+ * fullDueDate, amountCents = bookingTotalAmountCents.
60
+ * - `advance` → two schedules: { type: "deposit", dueDate = advanceDueDate,
61
+ * amountCents = advanceAmountCents } + { type: "balance",
62
+ * dueDate = fullDueDate ?? sensible-default, amountCents =
63
+ * total - advanceAmountCents }.
64
+ * - `split` → two schedules with `scheduleType: "installment"`.
65
+ */
66
+ export function PaymentScheduleSection({ value, onChange, totalAmountCents, currency, labels, }) {
67
+ const merged = { ...DEFAULT_LABELS, ...labels };
68
+ const set = (patch) => onChange({ ...value, ...patch });
69
+ const currencySuffix = currency ? ` ${currency}` : "";
70
+ const modes = [
71
+ { id: "unpaid", label: merged.modeUnpaid },
72
+ { id: "full", label: merged.modeFull },
73
+ { id: "advance", label: merged.modeAdvance },
74
+ { id: "split", label: merged.modeSplit },
75
+ ];
76
+ const handlePreset5050 = () => {
77
+ if (!totalAmountCents)
78
+ return;
79
+ const half = Math.floor(totalAmountCents / 2);
80
+ // Floor + remainder assignment avoids rounding-off-by-one: a total of
81
+ // 9999 cents splits into 4999 + 5000 rather than 4999 + 4999.
82
+ set({
83
+ splitFirstAmountCents: half,
84
+ splitSecondAmountCents: totalAmountCents - half,
85
+ });
86
+ };
87
+ return (_jsxs("div", { className: "flex flex-col gap-3 rounded-md border p-3", children: [_jsx(Label, { children: merged.heading }), _jsx("div", { className: "flex flex-wrap items-center gap-2", children: modes.map((mode) => (_jsx(Button, { type: "button", size: "sm", variant: value.mode === mode.id ? "default" : "ghost", onClick: () => set({ mode: mode.id }), children: mode.label }, mode.id))) }), value.mode === "unpaid" && (_jsx("p", { className: "text-xs text-muted-foreground", children: merged.unpaidHint })), value.mode === "full" && (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsxs(Label, { className: "text-xs", children: [merged.dueDate, currencySuffix] }), _jsx(Input, { type: "date", value: value.fullDueDate ?? "", onChange: (e) => set({ fullDueDate: e.target.value || null }) })] })), value.mode === "advance" && (_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsxs(Label, { className: "text-xs", children: [merged.amount, currencySuffix] }), _jsx(Input, { type: "number", min: "0", step: "0.01", value: centsToMajorString(value.advanceAmountCents), onChange: (e) => set({ advanceAmountCents: majorStringToCents(e.target.value) }) })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.dueDate }), _jsx(Input, { type: "date", value: value.advanceDueDate ?? "", onChange: (e) => set({ advanceDueDate: e.target.value || null }) })] })] })), value.mode === "split" && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-xs font-medium", children: merged.firstInstallment }), totalAmountCents ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: handlePreset5050, children: merged.preset5050 })) : null] }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(Input, { type: "number", min: "0", step: "0.01", placeholder: merged.amount, value: centsToMajorString(value.splitFirstAmountCents), onChange: (e) => set({ splitFirstAmountCents: majorStringToCents(e.target.value) }) }), _jsx(Input, { type: "date", value: value.splitFirstDueDate ?? "", onChange: (e) => set({ splitFirstDueDate: e.target.value || null }) })] }), _jsx("div", { className: "text-xs font-medium", children: merged.secondInstallment }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(Input, { type: "number", min: "0", step: "0.01", placeholder: merged.amount, value: centsToMajorString(value.splitSecondAmountCents), onChange: (e) => set({ splitSecondAmountCents: majorStringToCents(e.target.value) }) }), _jsx(Input, { type: "date", value: value.splitSecondDueDate ?? "", onChange: (e) => set({ splitSecondDueDate: e.target.value || null }) })] })] }))] }));
88
+ }
@@ -0,0 +1,53 @@
1
+ export type PersonPickerMode = "existing" | "new";
2
+ export interface NewPersonValue {
3
+ firstName: string;
4
+ lastName: string;
5
+ email: string;
6
+ phone: string;
7
+ }
8
+ export interface PersonPickerValue {
9
+ mode: PersonPickerMode;
10
+ /** Set when mode === "existing". */
11
+ personId: string;
12
+ /** Used when mode === "new". */
13
+ newPerson: NewPersonValue;
14
+ /** `null` = no organization attached. */
15
+ organizationId: string | null;
16
+ }
17
+ export declare const emptyNewPerson: NewPersonValue;
18
+ export declare const emptyPersonPickerValue: PersonPickerValue;
19
+ export interface PersonPickerSectionProps {
20
+ value: PersonPickerValue;
21
+ onChange: (value: PersonPickerValue) => void;
22
+ enabled?: boolean;
23
+ showOrganization?: boolean;
24
+ labels?: {
25
+ person?: string;
26
+ createNewPerson?: string;
27
+ selectExistingPerson?: string;
28
+ personSearchPlaceholder?: string;
29
+ personSelectPlaceholder?: string;
30
+ firstName?: string;
31
+ firstNamePlaceholder?: string;
32
+ lastName?: string;
33
+ lastNamePlaceholder?: string;
34
+ email?: string;
35
+ emailPlaceholder?: string;
36
+ phone?: string;
37
+ phonePlaceholder?: string;
38
+ organization?: string;
39
+ organizationSearchPlaceholder?: string;
40
+ organizationNone?: string;
41
+ };
42
+ }
43
+ /**
44
+ * Person picker with inline-create + optional organization attachment.
45
+ *
46
+ * State is fully controlled — the caller owns both existing-person selection
47
+ * and the inline-create form. The section does *not* call any mutation itself;
48
+ * the parent decides when to commit a newly-created person (typically at
49
+ * submit time, so we don't leak orphan CRM records when the dialog is
50
+ * cancelled).
51
+ */
52
+ export declare function PersonPickerSection({ value, onChange, enabled, showOrganization, labels, }: PersonPickerSectionProps): import("react/jsx-runtime").JSX.Element;
53
+ //# sourceMappingURL=person-picker-section.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"person-picker-section.d.ts","sourceRoot":"","sources":["../../src/components/person-picker-section.tsx"],"names":[],"mappings":"AAkBA,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,KAAK,CAAA;AAEjD,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,gBAAgB,CAAA;IACtB,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAA;IAChB,gCAAgC;IAChC,SAAS,EAAE,cAAc,CAAA;IACzB,yCAAyC;IACzC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,eAAO,MAAM,cAAc,EAAE,cAK5B,CAAA;AAED,eAAO,MAAM,sBAAsB,EAAE,iBAKpC,CAAA;AAED,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,iBAAiB,CAAA;IACxB,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAA;IAC5C,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,MAAM,CAAC,EAAE;QACP,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,oBAAoB,CAAC,EAAE,MAAM,CAAA;QAC7B,uBAAuB,CAAC,EAAE,MAAM,CAAA;QAChC,uBAAuB,CAAC,EAAE,MAAM,CAAA;QAChC,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,oBAAoB,CAAC,EAAE,MAAM,CAAA;QAC7B,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,mBAAmB,CAAC,EAAE,MAAM,CAAA;QAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,gBAAgB,CAAC,EAAE,MAAM,CAAA;QACzB,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,gBAAgB,CAAC,EAAE,MAAM,CAAA;QACzB,YAAY,CAAC,EAAE,MAAM,CAAA;QACrB,6BAA6B,CAAC,EAAE,MAAM,CAAA;QACtC,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;CACF;AAqBD;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,KAAK,EACL,QAAQ,EACR,OAAc,EACd,gBAAuB,EACvB,MAAM,GACP,EAAE,wBAAwB,2CA2J1B"}
@@ -0,0 +1,71 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useOrganizations, usePeople } from "@voyantjs/crm-react";
4
+ import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@voyantjs/voyant-ui/components";
5
+ import { UserPlus } from "lucide-react";
6
+ import * as React from "react";
7
+ const ORG_NONE = "__none__";
8
+ export const emptyNewPerson = {
9
+ firstName: "",
10
+ lastName: "",
11
+ email: "",
12
+ phone: "",
13
+ };
14
+ export const emptyPersonPickerValue = {
15
+ mode: "existing",
16
+ personId: "",
17
+ newPerson: emptyNewPerson,
18
+ organizationId: null,
19
+ };
20
+ const DEFAULT_LABELS = {
21
+ person: "Person",
22
+ createNewPerson: "Create new",
23
+ selectExistingPerson: "Select existing",
24
+ personSearchPlaceholder: "Search people by name or email...",
25
+ personSelectPlaceholder: "Select a person...",
26
+ firstName: "First Name",
27
+ firstNamePlaceholder: "John",
28
+ lastName: "Last Name",
29
+ lastNamePlaceholder: "Smith",
30
+ email: "Email",
31
+ emailPlaceholder: "john@example.com",
32
+ phone: "Phone",
33
+ phonePlaceholder: "+44 7911 123456",
34
+ organization: "Organization (optional)",
35
+ organizationSearchPlaceholder: "Search organizations...",
36
+ organizationNone: "No organization",
37
+ };
38
+ /**
39
+ * Person picker with inline-create + optional organization attachment.
40
+ *
41
+ * State is fully controlled — the caller owns both existing-person selection
42
+ * and the inline-create form. The section does *not* call any mutation itself;
43
+ * the parent decides when to commit a newly-created person (typically at
44
+ * submit time, so we don't leak orphan CRM records when the dialog is
45
+ * cancelled).
46
+ */
47
+ export function PersonPickerSection({ value, onChange, enabled = true, showOrganization = true, labels, }) {
48
+ const [personSearch, setPersonSearch] = React.useState("");
49
+ const [orgSearch, setOrgSearch] = React.useState("");
50
+ const merged = { ...DEFAULT_LABELS, ...labels };
51
+ const { data: peopleData } = usePeople({
52
+ search: personSearch || undefined,
53
+ limit: 20,
54
+ enabled: enabled && value.mode === "existing",
55
+ });
56
+ const people = peopleData?.data ?? [];
57
+ const { data: orgsData } = useOrganizations({
58
+ search: orgSearch || undefined,
59
+ limit: 20,
60
+ enabled: enabled && showOrganization,
61
+ });
62
+ const orgs = orgsData?.data ?? [];
63
+ const setPerson = (patch) => onChange({ ...value, ...patch });
64
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs(Label, { children: [merged.person, " ", _jsx("span", { className: "text-destructive", children: "*" })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7", onClick: () => setPerson({ mode: value.mode === "existing" ? "new" : "existing" }), children: value.mode === "existing" ? (_jsxs(_Fragment, { children: [_jsx(UserPlus, { className: "mr-1 h-3.5 w-3.5" }), merged.createNewPerson] })) : (merged.selectExistingPerson) })] }), value.mode === "existing" ? (_jsxs(_Fragment, { children: [_jsx(Input, { placeholder: merged.personSearchPlaceholder, value: personSearch, onChange: (e) => setPersonSearch(e.target.value) }), _jsxs(Select, { items: people.map((p) => ({
65
+ label: `${p.firstName} ${p.lastName}${p.email ? ` · ${p.email}` : ""}`,
66
+ value: p.id,
67
+ })), value: value.personId, onValueChange: (v) => setPerson({ personId: v ?? "" }), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, { placeholder: merged.personSelectPlaceholder }) }), _jsx(SelectContent, { children: people.map((p) => (_jsxs(SelectItem, { value: p.id, children: [p.firstName, " ", p.lastName, p.email ? ` · ${p.email}` : ""] }, p.id))) })] })] })) : (_jsxs("div", { className: "grid grid-cols-2 gap-2 rounded-md border p-3", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.firstName }), _jsx(Input, { value: value.newPerson.firstName, onChange: (e) => setPerson({ newPerson: { ...value.newPerson, firstName: e.target.value } }), placeholder: merged.firstNamePlaceholder })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.lastName }), _jsx(Input, { value: value.newPerson.lastName, onChange: (e) => setPerson({ newPerson: { ...value.newPerson, lastName: e.target.value } }), placeholder: merged.lastNamePlaceholder })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.email }), _jsx(Input, { type: "email", value: value.newPerson.email, onChange: (e) => setPerson({ newPerson: { ...value.newPerson, email: e.target.value } }), placeholder: merged.emailPlaceholder })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.phone }), _jsx(Input, { value: value.newPerson.phone, onChange: (e) => setPerson({ newPerson: { ...value.newPerson, phone: e.target.value } }), placeholder: merged.phonePlaceholder })] })] }))] }), showOrganization && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: merged.organization }), _jsx(Input, { placeholder: merged.organizationSearchPlaceholder, value: orgSearch, onChange: (e) => setOrgSearch(e.target.value) }), _jsxs(Select, { items: [
68
+ { label: merged.organizationNone, value: ORG_NONE },
69
+ ...orgs.map((o) => ({ label: o.name, value: o.id })),
70
+ ], value: value.organizationId ?? ORG_NONE, onValueChange: (v) => setPerson({ organizationId: v === ORG_NONE ? null : (v ?? null) }), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, { placeholder: merged.organizationNone }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: ORG_NONE, children: merged.organizationNone }), orgs.map((o) => (_jsx(SelectItem, { value: o.id, children: o.name }, o.id)))] })] })] }))] }));
71
+ }
@@ -0,0 +1,48 @@
1
+ export interface PriceBreakdownLine {
2
+ unitId: string;
3
+ label: string;
4
+ quantity: number;
5
+ /** Per-unit price for the matched tier/row. `null` = on-request pricing. */
6
+ unitAmountCents: number | null;
7
+ /** `unitAmountCents * quantity` or null when on-request. */
8
+ totalAmountCents: number | null;
9
+ /**
10
+ * Populated when a non-default tier matched — operator-visible "N × 100 EUR
11
+ * — group rate" kind of hint. Null for the default tier / single-price row.
12
+ */
13
+ tierLabel: string | null;
14
+ isGroupRate: boolean;
15
+ }
16
+ export interface PriceBreakdownSectionProps {
17
+ productId?: string;
18
+ optionId?: string | null;
19
+ /** Quantity per option_unit id, typically from RoomsStepperSection. */
20
+ unitQuantities: Record<string, number>;
21
+ /**
22
+ * Force a specific catalog. Defaults to the public catalog the storefront
23
+ * uses — matches what a customer would see.
24
+ */
25
+ catalogId?: string | null;
26
+ labels?: {
27
+ heading?: string;
28
+ total?: string;
29
+ onRequest?: string;
30
+ groupRate?: string;
31
+ empty?: string;
32
+ noPricing?: string;
33
+ };
34
+ }
35
+ /**
36
+ * Live price-breakdown preview for booking-create flows. Read-only — uses
37
+ * `usePricingPreview` (#237) to fetch the catalog-resolved snapshot the
38
+ * storefront also uses, then computes lines against the operator's current
39
+ * unit quantities so the operator sees the same numbers the customer would.
40
+ *
41
+ * ### Pricing mode handling
42
+ *
43
+ * - `per_unit` — multiply the matched tier's `sellAmountCents` by quantity.
44
+ * - `free` / `included` — render 0.00 without an on-request badge.
45
+ * - `on_request` / anything else — render "On request"; total excludes it.
46
+ */
47
+ export declare function PriceBreakdownSection({ productId, optionId, unitQuantities, catalogId, labels, }: PriceBreakdownSectionProps): import("react/jsx-runtime").JSX.Element | null;
48
+ //# sourceMappingURL=price-breakdown-section.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"price-breakdown-section.d.ts","sourceRoot":"","sources":["../../src/components/price-breakdown-section.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,4EAA4E;IAC5E,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B;;;OAGG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,WAAW,EAAE,OAAO,CAAA;CACrB;AAED,MAAM,WAAW,0BAA0B;IACzC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,uEAAuE;IACvE,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACtC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,MAAM,CAAC,EAAE;QACP,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,SAAS,CAAC,EAAE,MAAM,CAAA;KACnB,CAAA;CACF;AAsCD;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,QAAQ,EACR,cAAc,EACd,SAAS,EACT,MAAM,GACP,EAAE,0BAA0B,kDAsK5B"}