@ticketboothapp/booking 1.2.167 → 1.2.168

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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +13 -1
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +180 -65
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +43 -13
  5. package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
  6. package/src/components/booking/AdminChangeReceiptComparison.tsx +163 -145
  7. package/src/components/booking/ChangeBookingFlow.tsx +6 -3
  8. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
  9. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
  10. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
  11. package/src/components/booking/PickupLocationSelector.module.css +22 -0
  12. package/src/components/booking/PickupLocationSelector.tsx +3 -3
  13. package/src/components/booking/PriceSummary.tsx +4 -0
  14. package/src/components/booking/PrivateShuttleBookingFlow.tsx +27 -13
  15. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
  16. package/src/components/booking/admin-adjustment-components.ts +44 -0
  17. package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
  18. package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
  19. package/src/components/booking/admin-change-provider-payload.ts +3 -0
  20. package/src/components/booking/admin-change-quote-request-key.ts +27 -0
  21. package/src/components/booking/admin-change-v2-quote-request.ts +10 -2
  22. package/src/components/booking/admin-refund-decision-context.ts +37 -0
  23. package/src/components/booking/admin-refund-disposition.ts +71 -2
  24. package/src/components/booking/booking-flow-types.ts +4 -0
  25. package/src/components/booking/booking-flow.css +5 -0
  26. package/src/components/booking/change-booking-error-message.ts +137 -0
  27. package/src/components/booking/change-booking-flow-helpers.ts +77 -2
  28. package/src/components/booking/change-booking-quote-guards.ts +4 -1
  29. package/src/components/booking/change-booking-quote-state.ts +44 -1
  30. package/src/components/booking/incompatible-add-on-selections.ts +19 -0
  31. package/src/components/booking/private-shuttle-availability.ts +14 -0
  32. package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
  33. package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
  34. package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
  35. package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
  36. package/src/components/booking/provider-dashboard-change-booking.ts +16 -1
  37. package/src/components/booking/useAdminChangeCheckoutController.ts +16 -0
  38. package/src/components/booking/useAdminChangeProductReset.ts +6 -0
  39. package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
  40. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +6 -4
  41. package/src/components/booking/useAdminChangeQuotePreview.ts +76 -20
  42. package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
  43. package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
  44. package/src/components/booking/useBookingAvailabilityAddOns.ts +19 -2
  45. package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
  46. package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
  47. package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
  48. package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
  49. package/src/lib/booking/change-booking-server-preview.ts +26 -9
  50. package/src/lib/booking/change-flow-pricing.ts +12 -0
  51. package/src/lib/booking/i18n/messages/en.json +1 -0
  52. package/src/lib/booking/i18n/messages/fr.json +1 -0
  53. package/src/lib/booking-api.ts +64 -0
  54. package/test/change-booking-helpers.test.ts +853 -3
  55. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
@@ -1,24 +1,29 @@
1
- import type { ReactNode } from 'react';
1
+ import { useState } from "react";
2
2
  import type {
3
3
  ProviderDashboardChangePricingUi,
4
4
  ProviderDashboardPricingLine,
5
- } from './booking-flow-ui';
5
+ } from "./booking-flow-ui";
6
+ import type { AdminAdjustmentComponentOption } from "./admin-adjustment-components";
7
+ import {
8
+ adminAdjustmentTaxBehaviorOptions,
9
+ normalizeAdminAdjustmentScopeForSign,
10
+ normalizeAdminAdjustmentTaxBehavior,
11
+ } from "./admin-adjustment-tax-behavior";
12
+ import type { AdminCustomReceiptLine } from "./useAdminCustomReceiptLines";
6
13
 
7
- export type AdminCustomReceiptLine = {
8
- id: string;
9
- label: string;
10
- amountInput: string;
11
- amountSign: 1 | -1;
12
- calculation: 'FIXED_AMOUNT' | 'PERCENTAGE';
13
- scope: 'POSITIVE_AMENDMENT_VALUE' | 'CURRENT_ACTIVE_TICKETS' | 'CURRENT_ACTIVE_BOOKING';
14
- taxBehavior: 'NON_TAXABLE' | 'TAXABLE' | 'PRE_TAX_DISCOUNT' | 'POST_TAX_DISCOUNT';
15
- reasonCode: string;
16
- };
14
+ export type { AdminCustomReceiptLine } from "./useAdminCustomReceiptLines";
17
15
 
18
16
  type AdminCustomReceiptLinePatch = Partial<
19
17
  Pick<
20
18
  AdminCustomReceiptLine,
21
- 'label' | 'amountInput' | 'amountSign' | 'calculation' | 'scope' | 'taxBehavior' | 'reasonCode'
19
+ | "label"
20
+ | "amountInput"
21
+ | "amountSign"
22
+ | "calculation"
23
+ | "scope"
24
+ | "selectedComponentIds"
25
+ | "taxBehavior"
26
+ | "reasonCode"
22
27
  >
23
28
  >;
24
29
 
@@ -26,31 +31,34 @@ export interface AdminChangePricingMessagesProps {
26
31
  showProviderPricingInlineEditor: boolean;
27
32
  providerPricingUi?: ProviderDashboardChangePricingUi;
28
33
  providerQuotedLines: ProviderDashboardPricingLine[];
29
- debugPanel: ReactNode;
30
34
  }
31
35
 
32
36
  export function AdminChangePricingMessages({
33
37
  showProviderPricingInlineEditor,
34
38
  providerPricingUi,
35
39
  providerQuotedLines,
36
- debugPanel,
37
40
  }: AdminChangePricingMessagesProps) {
38
41
  return (
39
42
  <>
40
43
  {showProviderPricingInlineEditor && providerPricingUi?.error ? (
41
- <div className="mt-2 text-sm text-red-700">{providerPricingUi.error}</div>
44
+ <div className="mt-2 text-sm text-red-700">
45
+ {providerPricingUi.error}
46
+ </div>
42
47
  ) : null}
43
48
  {showProviderPricingInlineEditor &&
44
49
  providerPricingUi?.loading &&
45
50
  providerQuotedLines.length === 0 ? (
46
- <div className="mt-2 text-sm text-stone-500">Loading price lines...</div>
51
+ <div className="mt-2 text-sm text-stone-500">
52
+ Loading price lines...
53
+ </div>
47
54
  ) : null}
48
55
  {showProviderPricingInlineEditor &&
49
56
  providerPricingUi?.helperText &&
50
57
  !providerPricingUi.error ? (
51
- <div className="mt-2 text-xs text-stone-500">{providerPricingUi.helperText}</div>
58
+ <div className="mt-2 text-xs text-stone-500">
59
+ {providerPricingUi.helperText}
60
+ </div>
52
61
  ) : null}
53
- {debugPanel}
54
62
  </>
55
63
  );
56
64
  }
@@ -61,16 +69,38 @@ export interface AdminChangePricingAdjustmentsProps {
61
69
  providerPricingUi?: ProviderDashboardChangePricingUi;
62
70
  showAdminCustomLineEditor: boolean;
63
71
  adminCustomReceiptLines: AdminCustomReceiptLine[];
64
- onAddAdminCustomReceiptLine: () => void;
65
- onUpdateAdminCustomReceiptLine: (id: string, patch: AdminCustomReceiptLinePatch) => void;
72
+ hasPendingAdminCustomReceiptLineChanges: boolean;
73
+ pendingAdminCustomReceiptLineIds: string[];
74
+ adjustmentComponentOptions: AdminAdjustmentComponentOption[];
75
+ onAddAdminCustomReceiptLine: () => string;
76
+ onUpdateAdminCustomReceiptLine: (
77
+ id: string,
78
+ patch: AdminCustomReceiptLinePatch,
79
+ ) => void;
66
80
  onRemoveAdminCustomReceiptLine: (id: string) => void;
81
+ onApplyAdminCustomReceiptLines: () => void;
67
82
  }
68
83
 
69
84
  function normalizeAmountInput(value: string): string {
70
- const digitsAndDots = value.replace(/[^0-9.]/g, '');
71
- const parts = digitsAndDots.split('.');
85
+ const digitsAndDots = value.replace(/[^0-9.]/g, "");
86
+ const parts = digitsAndDots.split(".");
72
87
  if (parts.length <= 1) return digitsAndDots;
73
- return `${parts[0]}.${parts.slice(1).join('').replace(/\./g, '')}`;
88
+ return `${parts[0]}.${parts.slice(1).join("").replace(/\./g, "")}`;
89
+ }
90
+
91
+ function adminCustomLineScopeLabel(
92
+ scope: AdminCustomReceiptLine["scope"],
93
+ ): string {
94
+ switch (scope) {
95
+ case "CURRENT_ACTIVE_TICKETS":
96
+ return "All tickets";
97
+ case "CURRENT_ACTIVE_BOOKING":
98
+ return "Whole booking";
99
+ case "SELECTED_COMPONENTS":
100
+ return "Choose specific items";
101
+ default:
102
+ return "Added by this change";
103
+ }
74
104
  }
75
105
 
76
106
  export function AdminChangePricingAdjustments({
@@ -79,13 +109,33 @@ export function AdminChangePricingAdjustments({
79
109
  providerPricingUi,
80
110
  showAdminCustomLineEditor,
81
111
  adminCustomReceiptLines,
112
+ hasPendingAdminCustomReceiptLineChanges,
113
+ pendingAdminCustomReceiptLineIds,
114
+ adjustmentComponentOptions,
82
115
  onAddAdminCustomReceiptLine,
83
116
  onUpdateAdminCustomReceiptLine,
84
117
  onRemoveAdminCustomReceiptLine,
118
+ onApplyAdminCustomReceiptLines,
85
119
  }: AdminChangePricingAdjustmentsProps) {
86
- if (!showChangeFlowManualPriceLines && !showAdminCustomLineEditor) return null;
120
+ const [
121
+ expandedAdminCustomReceiptLineIds,
122
+ setExpandedAdminCustomReceiptLineIds,
123
+ ] = useState<string[]>([]);
124
+ if (!showChangeFlowManualPriceLines && !showAdminCustomLineEditor)
125
+ return null;
87
126
 
88
127
  const providerAdjustments = providerPricingUi?.additionalAdjustments ?? [];
128
+ const hasIncompleteAdminCustomReceiptLine = adminCustomReceiptLines.some(
129
+ (line) => {
130
+ const amount = Number(line.amountInput.trim());
131
+ return (
132
+ !Number.isFinite(amount) ||
133
+ amount <= 0 ||
134
+ (line.scope === "SELECTED_COMPONENTS" &&
135
+ line.selectedComponentIds.length === 0)
136
+ );
137
+ },
138
+ );
89
139
 
90
140
  return (
91
141
  <>
@@ -94,12 +144,17 @@ export function AdminChangePricingAdjustments({
94
144
  providerAdjustments.length > 0 ? (
95
145
  <div className="space-y-1">
96
146
  {providerAdjustments.map((adj) => (
97
- <div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
147
+ <div
148
+ key={adj.id}
149
+ className="flex items-center justify-between gap-2 text-sm"
150
+ >
98
151
  <div className="flex min-w-0 items-center gap-1">
99
152
  <button
100
153
  type="button"
101
154
  className="rounded border border-stone-300 px-1 text-xs text-stone-600 hover:bg-stone-100"
102
- onClick={() => providerPricingUi?.onRemoveAdditionalAdjustment?.(adj.id)}
155
+ onClick={() =>
156
+ providerPricingUi?.onRemoveAdditionalAdjustment?.(adj.id)
157
+ }
103
158
  >
104
159
  -
105
160
  </button>
@@ -109,7 +164,9 @@ export function AdminChangePricingAdjustments({
109
164
  placeholder="Line description"
110
165
  value={adj.label}
111
166
  onChange={(e) =>
112
- providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, { label: e.target.value })
167
+ providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
168
+ label: e.target.value,
169
+ })
113
170
  }
114
171
  />
115
172
  </div>
@@ -119,7 +176,7 @@ export function AdminChangePricingAdjustments({
119
176
  value={adj.mode}
120
177
  onChange={(e) =>
121
178
  providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
122
- mode: e.target.value as 'DISCOUNT' | 'CHARGE',
179
+ mode: e.target.value as "DISCOUNT" | "CHARGE",
123
180
  })
124
181
  }
125
182
  >
@@ -160,13 +217,22 @@ export function AdminChangePricingAdjustments({
160
217
  ) : null}
161
218
  {showAdminCustomLineEditor ? (
162
219
  <div className="space-y-3">
163
- <div className="flex justify-center">
220
+ <div className="flex justify-start">
164
221
  <button
165
222
  type="button"
166
223
  className="admin-custom-receipt-line-add inline-flex items-center gap-1.5 rounded-full border border-dashed border-stone-300 bg-stone-50/80 text-xs font-medium text-stone-600 hover:border-stone-400 hover:bg-stone-100"
167
- onClick={onAddAdminCustomReceiptLine}
224
+ onClick={() => {
225
+ const id = onAddAdminCustomReceiptLine();
226
+ setExpandedAdminCustomReceiptLineIds((current) => [
227
+ ...current,
228
+ id,
229
+ ]);
230
+ }}
168
231
  >
169
- <span className="text-base font-semibold leading-none text-stone-700" aria-hidden>
232
+ <span
233
+ className="text-base font-semibold leading-none text-stone-700"
234
+ aria-hidden
235
+ >
170
236
  +
171
237
  </span>
172
238
  Add custom line
@@ -174,121 +240,321 @@ export function AdminChangePricingAdjustments({
174
240
  </div>
175
241
  {adminCustomReceiptLines.map((adj) => {
176
242
  const sign = adj.amountSign ?? 1;
243
+ const isPending = pendingAdminCustomReceiptLineIds.includes(adj.id);
244
+ const isExpanded =
245
+ isPending || expandedAdminCustomReceiptLineIds.includes(adj.id);
246
+ const taxBehaviorLabel =
247
+ adminAdjustmentTaxBehaviorOptions(sign).find(
248
+ (option) => option.value === adj.taxBehavior,
249
+ )?.label ?? "Tax treatment";
250
+ const amountSummary = `${sign === -1 ? "\u2212" : "+"}${
251
+ adj.amountInput || "0.00"
252
+ }${adj.calculation === "PERCENTAGE" ? "%" : ""}`;
253
+
254
+ if (!isExpanded) {
255
+ return (
256
+ <div
257
+ key={adj.id}
258
+ className="admin-custom-receipt-line flex items-center justify-between gap-3 rounded-lg border border-stone-200 bg-stone-50/70 px-3 py-2.5 text-sm shadow-sm"
259
+ >
260
+ <div className="min-w-0">
261
+ <div className="truncate font-medium text-stone-800">
262
+ {adj.label.trim() || "Custom line"}
263
+ </div>
264
+ <div className="truncate text-xs text-stone-500">
265
+ {adminCustomLineScopeLabel(adj.scope)} ·{" "}
266
+ {taxBehaviorLabel}
267
+ </div>
268
+ </div>
269
+ <div className="flex shrink-0 items-center gap-2">
270
+ <span
271
+ className={`font-semibold tabular-nums ${
272
+ sign === -1 ? "text-red-700" : "text-emerald-700"
273
+ }`}
274
+ >
275
+ {amountSummary}
276
+ </span>
277
+ <button
278
+ type="button"
279
+ className="rounded-md px-2 py-1 text-xs font-medium text-stone-600 hover:bg-stone-200 hover:text-stone-800"
280
+ onClick={() =>
281
+ setExpandedAdminCustomReceiptLineIds((current) =>
282
+ current.includes(adj.id)
283
+ ? current
284
+ : [...current, adj.id],
285
+ )
286
+ }
287
+ >
288
+ Edit
289
+ </button>
290
+ <button
291
+ type="button"
292
+ className="admin-custom-receipt-remove rounded-md text-stone-400 hover:bg-stone-200 hover:text-stone-700"
293
+ aria-label={`Remove ${adj.label.trim() || "custom line"}`}
294
+ onClick={() => onRemoveAdminCustomReceiptLine(adj.id)}
295
+ >
296
+ <span className="block text-xl leading-none" aria-hidden>
297
+ &times;
298
+ </span>
299
+ </button>
300
+ </div>
301
+ </div>
302
+ );
303
+ }
304
+
177
305
  return (
178
306
  <div
179
307
  key={adj.id}
180
- className="admin-custom-receipt-line flex flex-wrap items-center gap-2 text-sm sm:flex-nowrap"
308
+ className="admin-custom-receipt-line space-y-3 rounded-lg border border-stone-200 bg-stone-50/70 p-3 text-sm shadow-sm"
181
309
  >
182
- <input
183
- type="text"
184
- className="admin-custom-receipt-input min-w-[8rem] flex-1 rounded-md border border-stone-300 bg-white text-sm text-stone-800 placeholder:text-stone-400"
185
- placeholder="Description"
186
- value={adj.label}
187
- onChange={(e) =>
188
- onUpdateAdminCustomReceiptLine(adj.id, { label: e.target.value })
189
- }
190
- />
191
- <select
192
- className="rounded-md border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
193
- aria-label="Adjustment calculation"
194
- value={adj.calculation}
195
- onChange={(e) =>
196
- onUpdateAdminCustomReceiptLine(adj.id, {
197
- calculation: e.target.value as AdminCustomReceiptLine['calculation'],
198
- })
199
- }
200
- >
201
- <option value="FIXED_AMOUNT">Fixed amount</option>
202
- <option value="PERCENTAGE">Percentage</option>
203
- </select>
204
- <div
205
- className="flex shrink-0 rounded-lg border border-stone-300 bg-stone-100/90 p-0.5 shadow-sm"
206
- role="group"
207
- aria-label="Line adds to total or reduces it"
208
- >
209
- <button
210
- type="button"
211
- className={`admin-custom-receipt-segment rounded-md text-xs font-semibold transition-colors sm:text-sm ${
212
- sign === 1
213
- ? 'bg-white text-emerald-800 shadow-sm ring-1 ring-stone-200/80'
214
- : 'text-stone-500 hover:bg-stone-200/60 hover:text-stone-700'
215
- }`}
216
- aria-pressed={sign === 1}
217
- onClick={() => onUpdateAdminCustomReceiptLine(adj.id, { amountSign: 1 })}
218
- >
219
- Charge (+)
220
- </button>
310
+ <div className="flex items-center justify-between gap-3 border-b border-stone-200 pb-2">
311
+ <div className="text-xs font-semibold text-stone-600">
312
+ {adj.sourceAdjustmentId
313
+ ? "Edit custom line"
314
+ : "New custom line"}
315
+ {adj.sourceAdjustmentId && adj.isDirty
316
+ ? " · unapplied changes"
317
+ : ""}
318
+ </div>
221
319
  <button
222
320
  type="button"
223
- className={`admin-custom-receipt-segment rounded-md text-xs font-semibold transition-colors sm:text-sm ${
224
- sign === -1
225
- ? 'bg-white text-red-800 shadow-sm ring-1 ring-stone-200/80'
226
- : 'text-stone-500 hover:bg-stone-200/60 hover:text-stone-700'
227
- }`}
228
- aria-pressed={sign === -1}
229
- onClick={() => onUpdateAdminCustomReceiptLine(adj.id, { amountSign: -1 })}
321
+ className="admin-custom-receipt-remove shrink-0 rounded-md text-stone-400 hover:bg-stone-200 hover:text-stone-700"
322
+ aria-label={`Remove ${adj.label.trim() || "custom line"}`}
323
+ onClick={() => onRemoveAdminCustomReceiptLine(adj.id)}
230
324
  >
231
- Credit (&minus;)
325
+ <span className="block text-xl leading-none" aria-hidden>
326
+ &times;
327
+ </span>
232
328
  </button>
233
329
  </div>
234
- <input
235
- type="text"
236
- inputMode="decimal"
237
- className="admin-custom-receipt-input-amount w-[7rem] shrink-0 rounded-md border border-stone-300 bg-white text-right text-sm font-medium tabular-nums text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/80"
238
- placeholder={adj.calculation === 'PERCENTAGE' ? '10' : '0.00'}
239
- aria-label="Amount"
240
- value={adj.amountInput}
241
- onChange={(e) =>
242
- onUpdateAdminCustomReceiptLine(adj.id, {
243
- amountInput: normalizeAmountInput(e.target.value),
244
- })
245
- }
246
- />
247
- <span className="text-xs text-stone-500" aria-hidden>
248
- {adj.calculation === 'PERCENTAGE' ? '%' : ''}
249
- </span>
250
- <select
251
- className="rounded-md border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
252
- aria-label="Adjustment scope"
253
- value={adj.scope}
254
- onChange={(e) =>
255
- onUpdateAdminCustomReceiptLine(adj.id, {
256
- scope: e.target.value as AdminCustomReceiptLine['scope'],
257
- })
258
- }
259
- >
260
- <option value="POSITIVE_AMENDMENT_VALUE">Added/change value</option>
261
- <option value="CURRENT_ACTIVE_TICKETS">All active tickets</option>
262
- <option value="CURRENT_ACTIVE_BOOKING">Entire active booking</option>
263
- </select>
264
- <select
265
- className="rounded-md border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
266
- aria-label="Tax treatment"
267
- value={adj.taxBehavior}
268
- onChange={(e) =>
269
- onUpdateAdminCustomReceiptLine(adj.id, {
270
- taxBehavior: e.target.value as AdminCustomReceiptLine['taxBehavior'],
271
- })
272
- }
273
- >
274
- <option value="NON_TAXABLE">Non-taxable</option>
275
- <option value="TAXABLE">Taxable</option>
276
- <option value="PRE_TAX_DISCOUNT">Pre-tax discount</option>
277
- <option value="POST_TAX_DISCOUNT">Post-tax discount</option>
278
- </select>
279
- <button
280
- type="button"
281
- className="admin-custom-receipt-remove ml-auto shrink-0 rounded-md text-stone-400 hover:bg-stone-100 hover:text-stone-700 sm:ml-0"
282
- aria-label="Remove this line"
283
- onClick={() => onRemoveAdminCustomReceiptLine(adj.id)}
284
- >
285
- <span className="block text-xl leading-none" aria-hidden>
286
- &times;
330
+ <div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
331
+ <input
332
+ type="text"
333
+ className="admin-custom-receipt-input min-w-[8rem] flex-1 rounded-md border border-stone-300 bg-white text-sm text-stone-800 placeholder:text-stone-400"
334
+ placeholder="Label"
335
+ value={adj.label}
336
+ onChange={(e) =>
337
+ onUpdateAdminCustomReceiptLine(adj.id, {
338
+ label: e.target.value,
339
+ })
340
+ }
341
+ />
342
+ <select
343
+ className="rounded-md border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
344
+ aria-label="Adjustment calculation"
345
+ value={adj.calculation}
346
+ onChange={(e) =>
347
+ onUpdateAdminCustomReceiptLine(adj.id, {
348
+ calculation: e.target
349
+ .value as AdminCustomReceiptLine["calculation"],
350
+ })
351
+ }
352
+ >
353
+ <option value="FIXED_AMOUNT">Fixed amount</option>
354
+ <option value="PERCENTAGE">Percentage</option>
355
+ </select>
356
+ <div
357
+ className="flex shrink-0 rounded-lg border border-stone-300 bg-stone-100/90 p-0.5 shadow-sm"
358
+ role="group"
359
+ aria-label="Line adds to total or reduces it"
360
+ >
361
+ <button
362
+ type="button"
363
+ className={`admin-custom-receipt-segment rounded-md text-xs font-semibold transition-colors sm:text-sm ${
364
+ sign === 1
365
+ ? "bg-white text-emerald-800 shadow-sm ring-1 ring-stone-200/80"
366
+ : "text-stone-500 hover:bg-stone-200/60 hover:text-stone-700"
367
+ }`}
368
+ aria-pressed={sign === 1}
369
+ onClick={() =>
370
+ onUpdateAdminCustomReceiptLine(adj.id, {
371
+ amountSign: 1,
372
+ taxBehavior: normalizeAdminAdjustmentTaxBehavior(
373
+ 1,
374
+ adj.taxBehavior,
375
+ ),
376
+ })
377
+ }
378
+ >
379
+ Charge (+)
380
+ </button>
381
+ <button
382
+ type="button"
383
+ className={`admin-custom-receipt-segment rounded-md text-xs font-semibold transition-colors sm:text-sm ${
384
+ sign === -1
385
+ ? "bg-white text-red-800 shadow-sm ring-1 ring-stone-200/80"
386
+ : "text-stone-500 hover:bg-stone-200/60 hover:text-stone-700"
387
+ }`}
388
+ aria-pressed={sign === -1}
389
+ onClick={() =>
390
+ onUpdateAdminCustomReceiptLine(adj.id, {
391
+ amountSign: -1,
392
+ scope: normalizeAdminAdjustmentScopeForSign(
393
+ -1,
394
+ adj.scope,
395
+ ),
396
+ taxBehavior: normalizeAdminAdjustmentTaxBehavior(
397
+ -1,
398
+ adj.taxBehavior,
399
+ ),
400
+ })
401
+ }
402
+ >
403
+ Credit (&minus;)
404
+ </button>
405
+ </div>
406
+ <input
407
+ type="text"
408
+ inputMode="decimal"
409
+ className="admin-custom-receipt-input-amount w-[7rem] shrink-0 rounded-md border border-stone-300 bg-white text-right text-sm font-medium tabular-nums text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/80"
410
+ placeholder={
411
+ adj.calculation === "PERCENTAGE" ? "10" : "0.00"
412
+ }
413
+ aria-label="Amount"
414
+ value={adj.amountInput}
415
+ onChange={(e) =>
416
+ onUpdateAdminCustomReceiptLine(adj.id, {
417
+ amountInput: normalizeAmountInput(e.target.value),
418
+ })
419
+ }
420
+ />
421
+ <span className="text-xs text-stone-500" aria-hidden>
422
+ {adj.calculation === "PERCENTAGE" ? "%" : ""}
287
423
  </span>
288
- </button>
424
+ </div>
425
+ <div className="flex flex-wrap items-center justify-end gap-2">
426
+ {adj.calculation === "PERCENTAGE" || sign === -1 ? (
427
+ <select
428
+ className="rounded-md border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
429
+ aria-label="Applies to"
430
+ value={adj.scope}
431
+ onChange={(e) =>
432
+ onUpdateAdminCustomReceiptLine(adj.id, {
433
+ scope: e.target
434
+ .value as AdminCustomReceiptLine["scope"],
435
+ ...(e.target.value === "SELECTED_COMPONENTS"
436
+ ? {}
437
+ : { selectedComponentIds: [] }),
438
+ })
439
+ }
440
+ >
441
+ <option value="POSITIVE_AMENDMENT_VALUE">
442
+ Added by this change
443
+ </option>
444
+ <option value="CURRENT_ACTIVE_TICKETS">
445
+ All tickets
446
+ </option>
447
+ <option value="CURRENT_ACTIVE_BOOKING">
448
+ Whole booking
449
+ </option>
450
+ <option value="SELECTED_COMPONENTS">
451
+ Choose specific items
452
+ </option>
453
+ </select>
454
+ ) : null}
455
+ <select
456
+ className="rounded-md border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
457
+ aria-label="Tax treatment"
458
+ value={adj.taxBehavior}
459
+ onChange={(e) =>
460
+ onUpdateAdminCustomReceiptLine(adj.id, {
461
+ taxBehavior: e.target
462
+ .value as AdminCustomReceiptLine["taxBehavior"],
463
+ })
464
+ }
465
+ >
466
+ {adminAdjustmentTaxBehaviorOptions(sign).map((option) => (
467
+ <option key={option.value} value={option.value}>
468
+ {option.label}
469
+ </option>
470
+ ))}
471
+ </select>
472
+ </div>
473
+ {sign === -1 && adj.scope === "POSITIVE_AMENDMENT_VALUE" ? (
474
+ <p className="text-xs text-stone-500">
475
+ Use this scope only when this change adds priced value, such
476
+ as another ticket or a tour upgrade. Otherwise choose active
477
+ tickets, the entire booking, or specific components.
478
+ </p>
479
+ ) : null}
480
+ {adj.scope === "SELECTED_COMPONENTS" ? (
481
+ <fieldset className="rounded-md border border-stone-200 bg-stone-50 px-3 py-2">
482
+ <legend className="px-1 text-xs font-semibold text-stone-700">
483
+ Apply this line to
484
+ </legend>
485
+ {adjustmentComponentOptions.length > 0 ? (
486
+ <div className="grid gap-2 sm:grid-cols-2">
487
+ {adjustmentComponentOptions.map((component) => {
488
+ const checked = adj.selectedComponentIds.includes(
489
+ component.componentId,
490
+ );
491
+ return (
492
+ <label
493
+ key={component.componentId}
494
+ className="flex cursor-pointer items-start gap-2 rounded border border-stone-200 bg-white px-2 py-1.5 text-xs text-stone-700"
495
+ >
496
+ <input
497
+ type="checkbox"
498
+ className="mt-0.5"
499
+ checked={checked}
500
+ onChange={() =>
501
+ onUpdateAdminCustomReceiptLine(adj.id, {
502
+ selectedComponentIds: checked
503
+ ? adj.selectedComponentIds.filter(
504
+ (id) => id !== component.componentId,
505
+ )
506
+ : [
507
+ ...adj.selectedComponentIds,
508
+ component.componentId,
509
+ ],
510
+ })
511
+ }
512
+ />
513
+ <span className="min-w-0">
514
+ <span className="block font-medium">
515
+ {component.label}
516
+ </span>
517
+ <span className="block text-stone-500">
518
+ {component.type}
519
+ </span>
520
+ </span>
521
+ </label>
522
+ );
523
+ })}
524
+ </div>
525
+ ) : (
526
+ <p className="text-xs text-stone-500">
527
+ Loading eligible booking components…
528
+ </p>
529
+ )}
530
+ {adj.selectedComponentIds.length === 0 ? (
531
+ <p className="mt-2 text-xs font-medium text-red-700">
532
+ Choose at least one booking component.
533
+ </p>
534
+ ) : null}
535
+ </fieldset>
536
+ ) : null}
289
537
  </div>
290
538
  );
291
539
  })}
540
+ {hasPendingAdminCustomReceiptLineChanges ? (
541
+ <div className="flex justify-center border-t border-stone-200 px-4 py-4">
542
+ <button
543
+ type="button"
544
+ className="admin-custom-receipt-apply rounded-md bg-emerald-700 text-sm font-semibold text-white shadow-sm hover:bg-emerald-800 disabled:cursor-not-allowed disabled:bg-stone-300 disabled:shadow-none"
545
+ disabled={
546
+ !hasPendingAdminCustomReceiptLineChanges ||
547
+ hasIncompleteAdminCustomReceiptLine
548
+ }
549
+ onClick={() => {
550
+ onApplyAdminCustomReceiptLines();
551
+ setExpandedAdminCustomReceiptLineIds([]);
552
+ }}
553
+ >
554
+ Apply custom line changes
555
+ </button>
556
+ </div>
557
+ ) : null}
292
558
  </div>
293
559
  ) : null}
294
560
  </>