@ticketboothapp/booking 1.2.166 → 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.
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingContent.tsx +15 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +274 -94
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +49 -13
- package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
- package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
- package/src/components/booking/AdminChangeReceiptComparison.tsx +172 -147
- package/src/components/booking/ChangeBookingFlow.tsx +6 -3
- package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
- package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
- package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
- package/src/components/booking/NewBookingFlow.tsx +14 -2
- package/src/components/booking/PickupLocationSelector.module.css +22 -0
- package/src/components/booking/PickupLocationSelector.tsx +3 -3
- package/src/components/booking/PriceBreakdown.tsx +66 -15
- package/src/components/booking/PriceSummary.tsx +16 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +52 -14
- package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
- package/src/components/booking/admin-adjustment-components.ts +44 -0
- package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
- package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
- package/src/components/booking/admin-change-provider-payload.ts +12 -1
- package/src/components/booking/admin-change-quote-request-key.ts +41 -1
- package/src/components/booking/admin-change-receipt-lines.ts +49 -0
- package/src/components/booking/admin-change-v2-quote-request.ts +109 -0
- package/src/components/booking/admin-refund-decision-context.ts +122 -0
- package/src/components/booking/admin-refund-disposition.ts +71 -2
- package/src/components/booking/availability-date-selection.ts +16 -0
- package/src/components/booking/booking-flow-types.ts +4 -0
- package/src/components/booking/booking-flow.css +5 -0
- package/src/components/booking/change-booking-error-message.ts +137 -0
- package/src/components/booking/change-booking-flow-helpers.ts +77 -2
- package/src/components/booking/change-booking-quote-guards.ts +4 -1
- package/src/components/booking/change-booking-quote-state.ts +44 -1
- package/src/components/booking/incompatible-add-on-selections.ts +19 -0
- package/src/components/booking/private-shuttle-availability.ts +14 -0
- package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
- package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
- package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
- package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
- package/src/components/booking/provider-dashboard-change-booking.ts +18 -2
- package/src/components/booking/use-standard-booking-availability.ts +5 -2
- package/src/components/booking/useAdminChangeCheckoutController.ts +56 -1
- package/src/components/booking/useAdminChangeProductReset.ts +6 -0
- package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
- package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +8 -6
- package/src/components/booking/useAdminChangeQuotePreview.ts +146 -61
- package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
- package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
- package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
- package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
- package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
- package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
- package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
- package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
- package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
- package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
- package/src/lib/booking/booking-cutoffs.ts +22 -0
- package/src/lib/booking/change-booking-server-preview.ts +92 -5
- package/src/lib/booking/change-flow-pricing.ts +12 -0
- package/src/lib/booking/i18n/messages/en.json +1 -0
- package/src/lib/booking/i18n/messages/fr.json +1 -0
- package/src/lib/booking-api.ts +76 -1
- package/test/change-booking-helpers.test.ts +1319 -7
- package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
} from 'react';
|
|
9
9
|
import {
|
|
10
10
|
quoteChangeBooking,
|
|
11
|
-
quoteAdminChangeBookingV2,
|
|
12
11
|
type AdminAmendmentRefundDisposition,
|
|
12
|
+
type AdminPromoApplicationScope,
|
|
13
13
|
type AdminFeAuthoritativeReceipt,
|
|
14
14
|
type Availability,
|
|
15
15
|
type ItineraryDisplayStep,
|
|
@@ -27,7 +27,16 @@ import {
|
|
|
27
27
|
} from './change-booking-flow-helpers';
|
|
28
28
|
import type { Currency } from './CurrencySwitcher';
|
|
29
29
|
import type { AdminChangeLatestQuote } from './useAdminChangeCheckoutController';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
buildAdminChangeQuoteRequestKey,
|
|
32
|
+
canRequestAdminChangeQuote,
|
|
33
|
+
canReuseCompletedAdminChangeQuote,
|
|
34
|
+
} from './admin-change-quote-request-key';
|
|
35
|
+
import {
|
|
36
|
+
buildAdminChangeV2QuoteRequest,
|
|
37
|
+
quoteAdminChangeV2ForUi,
|
|
38
|
+
} from './admin-change-v2-quote-request';
|
|
39
|
+
import { friendlyChangeBookingError } from './change-booking-error-message';
|
|
31
40
|
import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
|
|
32
41
|
|
|
33
42
|
type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
|
|
@@ -36,7 +45,12 @@ type AdminCustomReceiptLine = {
|
|
|
36
45
|
amountInput: string;
|
|
37
46
|
amountSign?: number;
|
|
38
47
|
calculation: 'FIXED_AMOUNT' | 'PERCENTAGE';
|
|
39
|
-
scope:
|
|
48
|
+
scope:
|
|
49
|
+
| 'POSITIVE_AMENDMENT_VALUE'
|
|
50
|
+
| 'CURRENT_ACTIVE_TICKETS'
|
|
51
|
+
| 'CURRENT_ACTIVE_BOOKING'
|
|
52
|
+
| 'SELECTED_COMPONENTS';
|
|
53
|
+
selectedComponentIds: string[];
|
|
40
54
|
taxBehavior: 'NON_TAXABLE' | 'TAXABLE' | 'PRE_TAX_DISCOUNT' | 'POST_TAX_DISCOUNT';
|
|
41
55
|
reasonCode: string;
|
|
42
56
|
};
|
|
@@ -60,8 +74,12 @@ export interface UseAdminChangeQuotePreviewParams {
|
|
|
60
74
|
quantities: Record<string, number>;
|
|
61
75
|
addOnSelections: AddOnSelection[];
|
|
62
76
|
cancellationPolicyId: string | null;
|
|
77
|
+
appliedPromoCode: string | null;
|
|
78
|
+
promoApplicationScope: AdminPromoApplicationScope;
|
|
63
79
|
adminCustomReceiptLines: AdminCustomReceiptLine[];
|
|
64
80
|
adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
|
|
81
|
+
adminReverseAdjustmentIds: string[];
|
|
82
|
+
needsAdjustmentDiscovery: boolean;
|
|
65
83
|
refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
|
|
66
84
|
useAdminFeAuthoritativeQuote: boolean;
|
|
67
85
|
latestChangeQuote: AdminChangeLatestQuote | null;
|
|
@@ -119,8 +137,12 @@ export function useAdminChangeQuotePreview({
|
|
|
119
137
|
quantities,
|
|
120
138
|
addOnSelections,
|
|
121
139
|
cancellationPolicyId,
|
|
140
|
+
appliedPromoCode,
|
|
141
|
+
promoApplicationScope,
|
|
122
142
|
adminCustomReceiptLines,
|
|
123
143
|
adminStructuredAdjustments,
|
|
144
|
+
adminReverseAdjustmentIds,
|
|
145
|
+
needsAdjustmentDiscovery,
|
|
124
146
|
refundDispositionsByOperationId,
|
|
125
147
|
useAdminFeAuthoritativeQuote,
|
|
126
148
|
latestChangeQuote,
|
|
@@ -156,10 +178,9 @@ export function useAdminChangeQuotePreview({
|
|
|
156
178
|
onPricePreviewChange,
|
|
157
179
|
}: UseAdminChangeQuotePreviewParams): UseAdminChangeQuotePreviewResult {
|
|
158
180
|
const lastChangeFlowPreviewKeyRef = useRef<string | null>(null);
|
|
159
|
-
|
|
160
181
|
const changeFlowNeedsServerPrice =
|
|
161
182
|
isCustomerSelfServeChange &&
|
|
162
|
-
|
|
183
|
+
hasEffectiveChangeSelection &&
|
|
163
184
|
!!initialValues?.bookingReference?.trim() &&
|
|
164
185
|
!!lastName.trim();
|
|
165
186
|
|
|
@@ -167,37 +188,85 @@ export function useAdminChangeQuotePreview({
|
|
|
167
188
|
latestChangeQuote?.canProceed === false &&
|
|
168
189
|
(latestChangeQuote.refundDecisionOperations?.length ?? 0) === 0;
|
|
169
190
|
const refundDecisionRequired = (latestChangeQuote?.refundDecisionOperations?.length ?? 0) > 0;
|
|
191
|
+
const hasPublishedChangeQuote = latestChangeQuote !== null;
|
|
192
|
+
/**
|
|
193
|
+
* Existing admin adjustments must be discoverable before the operator makes a change.
|
|
194
|
+
* Initial selection hydration can briefly leave the live availability/return unset, so use
|
|
195
|
+
* the persisted booking identities for this read-only discovery quote instead of waiting for
|
|
196
|
+
* the editable target controls to become complete.
|
|
197
|
+
*/
|
|
198
|
+
const quoteAvailability = useMemo<Availability | null>(() => {
|
|
199
|
+
if (selectedAvailability) return selectedAvailability;
|
|
200
|
+
if (!needsAdjustmentDiscovery || !initialValues?.dateTime?.trim()) return null;
|
|
201
|
+
const optionId = initialValues.productOptionId?.trim() || initialValues.productId?.trim();
|
|
202
|
+
if (!optionId) return null;
|
|
203
|
+
return {
|
|
204
|
+
dateTime: initialValues.dateTime.trim(),
|
|
205
|
+
availabilityId: initialValues.availabilityId?.trim() || undefined,
|
|
206
|
+
productOptionId: optionId,
|
|
207
|
+
vacancies: Math.max(totalQuantity, 1),
|
|
208
|
+
currency,
|
|
209
|
+
};
|
|
210
|
+
}, [
|
|
211
|
+
selectedAvailability,
|
|
212
|
+
needsAdjustmentDiscovery,
|
|
213
|
+
initialValues?.dateTime,
|
|
214
|
+
initialValues?.availabilityId,
|
|
215
|
+
initialValues?.productOptionId,
|
|
216
|
+
initialValues?.productId,
|
|
217
|
+
totalQuantity,
|
|
218
|
+
currency,
|
|
219
|
+
]);
|
|
220
|
+
const quoteReturnAvailabilityId =
|
|
221
|
+
selectedReturnOption?.returnAvailabilityId ??
|
|
222
|
+
(needsAdjustmentDiscovery ? initialValues?.returnAvailabilityId?.trim() || null : null);
|
|
170
223
|
const changeQuoteInputsKey = useMemo(() => buildAdminChangeQuoteRequestKey({
|
|
171
224
|
bookingReference: initialValues?.bookingReference,
|
|
172
225
|
lastName,
|
|
173
226
|
productId: product.productId,
|
|
174
|
-
selectedAvailability,
|
|
227
|
+
selectedAvailability: quoteAvailability,
|
|
175
228
|
pickupLocationId,
|
|
176
|
-
returnAvailabilityId:
|
|
229
|
+
returnAvailabilityId: quoteReturnAvailabilityId,
|
|
177
230
|
quantities,
|
|
178
231
|
addOnSelections,
|
|
179
232
|
adminCustomReceiptLines,
|
|
180
233
|
cancellationPolicyId,
|
|
234
|
+
promoCode: appliedPromoCode,
|
|
235
|
+
promoApplicationScope,
|
|
181
236
|
structuredAdjustments: adminStructuredAdjustments,
|
|
237
|
+
reverseAdjustmentIds: adminReverseAdjustmentIds,
|
|
182
238
|
refundDispositionsByOperationId,
|
|
183
239
|
useAdminFeAuthoritativeQuote,
|
|
184
240
|
}), [
|
|
185
241
|
initialValues?.bookingReference,
|
|
186
242
|
lastName,
|
|
187
243
|
product.productId,
|
|
188
|
-
|
|
244
|
+
quoteAvailability,
|
|
189
245
|
pickupLocationId,
|
|
190
|
-
|
|
246
|
+
quoteReturnAvailabilityId,
|
|
191
247
|
quantities,
|
|
192
248
|
addOnSelections,
|
|
193
249
|
adminCustomReceiptLines,
|
|
194
250
|
cancellationPolicyId,
|
|
251
|
+
appliedPromoCode,
|
|
252
|
+
promoApplicationScope,
|
|
195
253
|
adminStructuredAdjustments,
|
|
254
|
+
adminReverseAdjustmentIds,
|
|
196
255
|
refundDispositionsByOperationId,
|
|
197
256
|
useAdminFeAuthoritativeQuote,
|
|
198
257
|
]);
|
|
199
258
|
const destinationRequiresReturnSelection = Boolean(selectedAvailability?.returnOptions?.length);
|
|
200
259
|
const missingRequiredReturnSelection = destinationRequiresReturnSelection && !selectedReturnOption;
|
|
260
|
+
const quoteMissingRequiredReturnSelection =
|
|
261
|
+
missingRequiredReturnSelection && !needsAdjustmentDiscovery;
|
|
262
|
+
const canRequestChangeQuote = canRequestAdminChangeQuote({
|
|
263
|
+
isCustomerSelfServeChange,
|
|
264
|
+
hasEffectiveChangeSelection: hasEffectiveChangeSelection || needsAdjustmentDiscovery,
|
|
265
|
+
hasSelectedAvailability: quoteAvailability != null,
|
|
266
|
+
bookingReference: initialValues?.bookingReference,
|
|
267
|
+
lastName,
|
|
268
|
+
missingRequiredReturnSelection: quoteMissingRequiredReturnSelection,
|
|
269
|
+
});
|
|
201
270
|
|
|
202
271
|
const changeFlowSubmitDisabled =
|
|
203
272
|
missingRequiredReturnSelection ||
|
|
@@ -208,6 +277,14 @@ export function useAdminChangeQuotePreview({
|
|
|
208
277
|
const changeCheckoutButtonLabel = (() => {
|
|
209
278
|
if (!hasEffectiveChangeSelection) return undefined;
|
|
210
279
|
if (isProviderDashboardChange) {
|
|
280
|
+
if (changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError)) {
|
|
281
|
+
const tr = t('booking.updatingPrice');
|
|
282
|
+
return tr !== 'booking.updatingPrice' ? tr : 'Updating price\u2026';
|
|
283
|
+
}
|
|
284
|
+
if (changeQuoteFetchError) {
|
|
285
|
+
const tr = t('booking.changeBookingRetry');
|
|
286
|
+
return tr !== 'booking.changeBookingRetry' ? tr : 'Change booking (retry)';
|
|
287
|
+
}
|
|
211
288
|
if (isChangeQuoteBlocked) {
|
|
212
289
|
const tr = t('booking.changeBookingNotAvailable');
|
|
213
290
|
return tr !== 'booking.changeBookingNotAvailable' ? tr : 'Change not available';
|
|
@@ -215,8 +292,8 @@ export function useAdminChangeQuotePreview({
|
|
|
215
292
|
// Prefer authoritative V2 signed due (charge positive / refund negative). Do not use the
|
|
216
293
|
// FE cart estimate once a server quote exists — receipt total deltas can disagree with
|
|
217
294
|
// floored/settlement amounts (DEFECT-PV2-018 follow-up).
|
|
218
|
-
const serverDue = latestChangeQuote?.serverPreview?.amountDue;
|
|
219
|
-
const due = Math.round(
|
|
295
|
+
const serverDue = latestChangeQuote?.serverPreview?.amountDue ?? latestChangeQuote?.priceDiff ?? 0;
|
|
296
|
+
const due = Math.round(serverDue * 100) / 100;
|
|
220
297
|
return due > 0
|
|
221
298
|
? `Change booking (${formatCurrencyAmount(due, currency, locale as 'en' | 'fr')})`
|
|
222
299
|
: due < 0
|
|
@@ -255,20 +332,23 @@ export function useAdminChangeQuotePreview({
|
|
|
255
332
|
: 'Change booking (no charge)';
|
|
256
333
|
})();
|
|
257
334
|
|
|
258
|
-
const
|
|
335
|
+
const rawCheckoutFormError =
|
|
259
336
|
(error || '') ||
|
|
260
337
|
(missingRequiredReturnSelection ? 'Please select a return time for this product.' : '') ||
|
|
261
338
|
(isChangeQuoteBlocked && !refundDecisionRequired
|
|
262
339
|
? (latestChangeQuote?.reasonIfBlocked ?? 'This change cannot be priced right now.')
|
|
263
340
|
: '') ||
|
|
264
341
|
(changeQuoteFetchError ?? '');
|
|
342
|
+
const checkoutFormError = friendlyChangeBookingError(rawCheckoutFormError);
|
|
265
343
|
|
|
266
344
|
const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
|
|
267
345
|
if (!selectedAvailability || totalQuantity <= 0) return null;
|
|
268
346
|
const ticketsLine = formatTicketLineItemsForSummary(
|
|
269
347
|
ticketLineItems.map((l) => ({ category: l.category, qty: l.qty }))
|
|
270
348
|
);
|
|
271
|
-
const itinerary =
|
|
349
|
+
const itinerary =
|
|
350
|
+
latestChangeQuote?.authoritativeItineraryDisplay ??
|
|
351
|
+
computeItineraryDisplay();
|
|
272
352
|
const itinerarySteps =
|
|
273
353
|
itinerary?.map((s) => ({
|
|
274
354
|
time: s.time?.trim() || null,
|
|
@@ -312,6 +392,7 @@ export function useAdminChangeQuotePreview({
|
|
|
312
392
|
latestChangeQuote?.quotedTotal,
|
|
313
393
|
latestChangeQuote?.serverDisplay?.total,
|
|
314
394
|
latestChangeQuote?.serverPreview?.totalNewBooking,
|
|
395
|
+
latestChangeQuote?.authoritativeItineraryDisplay,
|
|
315
396
|
]);
|
|
316
397
|
|
|
317
398
|
useEffect(() => {
|
|
@@ -383,13 +464,7 @@ export function useAdminChangeQuotePreview({
|
|
|
383
464
|
return;
|
|
384
465
|
}
|
|
385
466
|
|
|
386
|
-
if (
|
|
387
|
-
!hasChangeSelection ||
|
|
388
|
-
!selectedAvailability ||
|
|
389
|
-
!initialValues?.bookingReference?.trim() ||
|
|
390
|
-
!lastName.trim() ||
|
|
391
|
-
missingRequiredReturnSelection
|
|
392
|
-
) {
|
|
467
|
+
if (!canRequestChangeQuote || !quoteAvailability || !initialValues?.bookingReference?.trim()) {
|
|
393
468
|
setLatestChangeQuote(null);
|
|
394
469
|
setChangeQuoteLoading(false);
|
|
395
470
|
setChangeQuoteFetchError(null);
|
|
@@ -398,16 +473,20 @@ export function useAdminChangeQuotePreview({
|
|
|
398
473
|
return;
|
|
399
474
|
}
|
|
400
475
|
|
|
401
|
-
if (
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
476
|
+
if (changeQuoteInputsKey && inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (canReuseCompletedAdminChangeQuote(
|
|
481
|
+
changeQuoteInputsKey,
|
|
482
|
+
lastCompletedQuoteInputsKeyRef.current,
|
|
483
|
+
hasPublishedChangeQuote,
|
|
484
|
+
)) {
|
|
406
485
|
setChangeQuoteLoading(false);
|
|
407
486
|
return;
|
|
408
487
|
}
|
|
409
488
|
|
|
410
|
-
const optionId = getAvailabilityOptionId(
|
|
489
|
+
const optionId = getAvailabilityOptionId(quoteAvailability);
|
|
411
490
|
if (!optionId) {
|
|
412
491
|
setLatestChangeQuote(null);
|
|
413
492
|
setChangeQuoteLoading(false);
|
|
@@ -429,44 +508,49 @@ export function useAdminChangeQuotePreview({
|
|
|
429
508
|
.filter(([, count]) => count > 0)
|
|
430
509
|
.map(([category, count]) => ({ category, count }));
|
|
431
510
|
try {
|
|
432
|
-
const quoteRequestBase = {
|
|
511
|
+
const quoteRequestBase = buildAdminChangeV2QuoteRequest({
|
|
433
512
|
bookingReference: bookingReferenceForQuote,
|
|
434
|
-
lastName
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
513
|
+
lastName,
|
|
514
|
+
parentProductId: product.productId,
|
|
515
|
+
optionId,
|
|
516
|
+
selectedAvailability: quoteAvailability,
|
|
517
|
+
pickupLocationId,
|
|
518
|
+
returnAvailabilityId: quoteReturnAvailabilityId,
|
|
519
|
+
bookingItems,
|
|
520
|
+
addOnSelections,
|
|
521
|
+
cancellationPolicyId: cancellationPolicyId ?? null,
|
|
522
|
+
promoCode: appliedPromoCode,
|
|
523
|
+
promoApplicationScope,
|
|
445
524
|
structuredAdjustments: adminStructuredAdjustments,
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
525
|
+
reverseAdjustmentIds: adminReverseAdjustmentIds,
|
|
526
|
+
includeSelectableAdjustmentComponents:
|
|
527
|
+
needsAdjustmentDiscovery || adminCustomReceiptLines.some(
|
|
528
|
+
(line) => line.scope === 'SELECTED_COMPONENTS',
|
|
529
|
+
),
|
|
530
|
+
refundDispositionsByOperationId,
|
|
531
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
532
|
+
previousAvailabilityId: initialValues.availabilityId ?? null,
|
|
533
|
+
previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
|
|
534
|
+
});
|
|
535
|
+
const mergedQuote = useAdminFeAuthoritativeQuote
|
|
536
|
+
? await quoteAdminChangeV2ForUi(
|
|
537
|
+
quoteRequestBase,
|
|
538
|
+
{
|
|
539
|
+
total: changeFlowNewBookingTotal,
|
|
540
|
+
subtotal: effectiveSubtotalForCheckout,
|
|
541
|
+
tax: effectiveTax,
|
|
542
|
+
},
|
|
543
|
+
currency,
|
|
544
|
+
)
|
|
545
|
+
: sliceAndMergeChangeQuoteForUi(await quoteChangeBooking({
|
|
459
546
|
...quoteRequestBase,
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
547
|
+
clientProposedTotal: changeFlowNewBookingTotal,
|
|
548
|
+
}), {
|
|
549
|
+
total: changeFlowNewBookingTotal,
|
|
550
|
+
subtotal: effectiveSubtotalForCheckout,
|
|
551
|
+
tax: effectiveTax,
|
|
552
|
+
}, currency).mergedQuote;
|
|
464
553
|
if (seq !== changeQuoteRequestSeq.current) return;
|
|
465
|
-
const mergedQuote = sliceAndMergeChangeQuoteForUi(quote, {
|
|
466
|
-
total: changeFlowNewBookingTotal,
|
|
467
|
-
subtotal: effectiveSubtotalForCheckout,
|
|
468
|
-
tax: effectiveTax,
|
|
469
|
-
}, currency).mergedQuote;
|
|
470
554
|
// Commit the completed identity before publishing its result. This keeps a
|
|
471
555
|
// render caused by setLatestChangeQuote from being interpreted as a new request.
|
|
472
556
|
lastCompletedQuoteInputsKeyRef.current = changeQuoteInputsKey;
|
|
@@ -494,9 +578,10 @@ export function useAdminChangeQuotePreview({
|
|
|
494
578
|
// Fetching is controlled by the canonical user-input identity. Do not add quote-derived
|
|
495
579
|
// totals, receipt objects, or display lines here: a response must not trigger another quote.
|
|
496
580
|
isCustomerSelfServeChange,
|
|
497
|
-
|
|
581
|
+
canRequestChangeQuote,
|
|
498
582
|
missingRequiredReturnSelection,
|
|
499
583
|
changeQuoteInputsKey,
|
|
584
|
+
hasPublishedChangeQuote,
|
|
500
585
|
]);
|
|
501
586
|
|
|
502
587
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { AdminAmendmentActiveAdjustmentSnapshot } from '../../lib/booking-api';
|
|
2
3
|
|
|
3
4
|
export type AdminCustomReceiptLine = {
|
|
4
5
|
id: string;
|
|
@@ -6,63 +7,223 @@ export type AdminCustomReceiptLine = {
|
|
|
6
7
|
amountInput: string;
|
|
7
8
|
amountSign: 1 | -1;
|
|
8
9
|
calculation: 'FIXED_AMOUNT' | 'PERCENTAGE';
|
|
9
|
-
scope:
|
|
10
|
+
scope:
|
|
11
|
+
| 'POSITIVE_AMENDMENT_VALUE'
|
|
12
|
+
| 'CURRENT_ACTIVE_TICKETS'
|
|
13
|
+
| 'CURRENT_ACTIVE_BOOKING'
|
|
14
|
+
| 'SELECTED_COMPONENTS';
|
|
15
|
+
selectedComponentIds: string[];
|
|
10
16
|
taxBehavior: 'NON_TAXABLE' | 'TAXABLE' | 'PRE_TAX_DISCOUNT' | 'POST_TAX_DISCOUNT';
|
|
11
17
|
reasonCode: string;
|
|
18
|
+
/** Immutable ledger id. Existing lines are edited by reversing this id and adding a replacement. */
|
|
19
|
+
sourceAdjustmentId?: string;
|
|
20
|
+
replacementAdjustmentId?: string;
|
|
21
|
+
isDirty?: boolean;
|
|
12
22
|
};
|
|
13
23
|
|
|
14
24
|
type AdminCustomReceiptLinePatch = Partial<
|
|
15
25
|
Pick<
|
|
16
26
|
AdminCustomReceiptLine,
|
|
17
|
-
|
|
27
|
+
| 'label'
|
|
28
|
+
| 'amountInput'
|
|
29
|
+
| 'amountSign'
|
|
30
|
+
| 'calculation'
|
|
31
|
+
| 'scope'
|
|
32
|
+
| 'selectedComponentIds'
|
|
33
|
+
| 'taxBehavior'
|
|
34
|
+
| 'reasonCode'
|
|
18
35
|
>
|
|
19
36
|
>;
|
|
20
37
|
|
|
21
|
-
export function
|
|
38
|
+
export function lineFromActiveAdjustment(
|
|
39
|
+
adjustment: AdminAmendmentActiveAdjustmentSnapshot,
|
|
40
|
+
replacementAdjustmentId: string,
|
|
41
|
+
): AdminCustomReceiptLine {
|
|
42
|
+
const percentage = adjustment.mode !== 'FIXED_AMOUNT';
|
|
43
|
+
return {
|
|
44
|
+
id: `existing:${adjustment.adjustmentId}`,
|
|
45
|
+
label:
|
|
46
|
+
adjustment.reason?.trim() ||
|
|
47
|
+
adjustment.referenceCode?.trim() ||
|
|
48
|
+
adjustment.reasonCode.replace(/_/g, ' ').toLowerCase(),
|
|
49
|
+
amountInput: percentage
|
|
50
|
+
? String(Math.abs(adjustment.percentageBasisPoints ?? 0) / 100)
|
|
51
|
+
: (Math.abs(adjustment.amountCents) / 100).toFixed(2),
|
|
52
|
+
amountSign: adjustment.amountCents < 0 ? -1 : 1,
|
|
53
|
+
calculation: percentage ? 'PERCENTAGE' : 'FIXED_AMOUNT',
|
|
54
|
+
scope: adjustment.scope,
|
|
55
|
+
selectedComponentIds:
|
|
56
|
+
adjustment.scope === 'SELECTED_COMPONENTS' ? adjustment.affectedComponentIds : [],
|
|
57
|
+
taxBehavior: adjustment.taxBehavior,
|
|
58
|
+
reasonCode: adjustment.reasonCode,
|
|
59
|
+
sourceAdjustmentId: adjustment.adjustmentId,
|
|
60
|
+
replacementAdjustmentId,
|
|
61
|
+
isDirty: false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function reverseAdjustmentIdsForAdminCustomLines(
|
|
66
|
+
lines: AdminCustomReceiptLine[],
|
|
67
|
+
removedExistingAdjustmentIds: string[] = [],
|
|
68
|
+
): string[] {
|
|
69
|
+
return Array.from(new Set([
|
|
70
|
+
...removedExistingAdjustmentIds,
|
|
71
|
+
...lines.flatMap((line) =>
|
|
72
|
+
line.sourceAdjustmentId && line.isDirty ? [line.sourceAdjustmentId] : [],
|
|
73
|
+
),
|
|
74
|
+
])).sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function useAdminCustomReceiptLines(
|
|
78
|
+
resetKey: string | null | undefined,
|
|
79
|
+
reversibleAdjustments: AdminAmendmentActiveAdjustmentSnapshot[] | undefined,
|
|
80
|
+
) {
|
|
22
81
|
const [adminCustomReceiptLines, setAdminCustomReceiptLines] = useState<AdminCustomReceiptLine[]>([]);
|
|
82
|
+
const [appliedAdminCustomReceiptLines, setAppliedAdminCustomReceiptLines] = useState<
|
|
83
|
+
AdminCustomReceiptLine[]
|
|
84
|
+
>([]);
|
|
85
|
+
const [removedExistingAdjustmentIds, setRemovedExistingAdjustmentIds] = useState<string[]>([]);
|
|
86
|
+
const [appliedRemovedExistingAdjustmentIds, setAppliedRemovedExistingAdjustmentIds] = useState<
|
|
87
|
+
string[]
|
|
88
|
+
>([]);
|
|
89
|
+
const [hasHydratedExistingAdjustments, setHasHydratedExistingAdjustments] = useState(false);
|
|
23
90
|
const adminCustomLineIdRef = useRef(0);
|
|
24
91
|
const previousResetKeyRef = useRef(resetKey);
|
|
92
|
+
const hydratedResetKeyRef = useRef<string | null | undefined>(undefined);
|
|
93
|
+
const skipHydrationForResetKeyRef = useRef<string | null | undefined>(undefined);
|
|
25
94
|
|
|
26
|
-
const
|
|
95
|
+
const nextDraftId = useCallback((prefix: 'admin-rcpt' | 'admin-edit') => {
|
|
27
96
|
adminCustomLineIdRef.current += 1;
|
|
97
|
+
return `${prefix}-${Date.now().toString(36)}-${adminCustomLineIdRef.current}`;
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const handleAddAdminCustomReceiptLine = useCallback(() => {
|
|
101
|
+
const id = nextDraftId('admin-rcpt');
|
|
28
102
|
setAdminCustomReceiptLines((prev) => [
|
|
29
103
|
...prev,
|
|
30
104
|
{
|
|
31
|
-
id
|
|
105
|
+
id,
|
|
32
106
|
label: '',
|
|
33
107
|
amountInput: '',
|
|
34
108
|
amountSign: 1,
|
|
35
109
|
calculation: 'FIXED_AMOUNT',
|
|
36
110
|
scope: 'POSITIVE_AMENDMENT_VALUE',
|
|
37
|
-
|
|
111
|
+
selectedComponentIds: [],
|
|
112
|
+
taxBehavior: 'TAXABLE',
|
|
38
113
|
reasonCode: 'STAFF_ADJUSTMENT',
|
|
39
114
|
},
|
|
40
115
|
]);
|
|
41
|
-
|
|
116
|
+
return id;
|
|
117
|
+
}, [nextDraftId]);
|
|
42
118
|
|
|
43
119
|
const handleUpdateAdminCustomReceiptLine = useCallback(
|
|
44
120
|
(id: string, patch: AdminCustomReceiptLinePatch) => {
|
|
45
121
|
setAdminCustomReceiptLines((prev) =>
|
|
46
|
-
prev.map((line) =>
|
|
122
|
+
prev.map((line) =>
|
|
123
|
+
line.id === id
|
|
124
|
+
? { ...line, ...patch, ...(line.sourceAdjustmentId ? { isDirty: true } : {}) }
|
|
125
|
+
: line,
|
|
126
|
+
)
|
|
47
127
|
);
|
|
48
128
|
},
|
|
49
129
|
[],
|
|
50
130
|
);
|
|
51
131
|
|
|
52
132
|
const handleRemoveAdminCustomReceiptLine = useCallback((id: string) => {
|
|
133
|
+
const removed = adminCustomReceiptLines.find((line) => line.id === id);
|
|
134
|
+
if (removed?.sourceAdjustmentId) {
|
|
135
|
+
setRemovedExistingAdjustmentIds((current) =>
|
|
136
|
+
current.includes(removed.sourceAdjustmentId!)
|
|
137
|
+
? current
|
|
138
|
+
: [...current, removed.sourceAdjustmentId!],
|
|
139
|
+
);
|
|
140
|
+
}
|
|
53
141
|
setAdminCustomReceiptLines((prev) => prev.filter((line) => line.id !== id));
|
|
54
|
-
}, []);
|
|
142
|
+
}, [adminCustomReceiptLines]);
|
|
143
|
+
|
|
144
|
+
const handleApplyAdminCustomReceiptLines = useCallback(() => {
|
|
145
|
+
setAppliedAdminCustomReceiptLines(adminCustomReceiptLines);
|
|
146
|
+
setAppliedRemovedExistingAdjustmentIds(removedExistingAdjustmentIds);
|
|
147
|
+
}, [adminCustomReceiptLines, removedExistingAdjustmentIds]);
|
|
55
148
|
|
|
56
149
|
useEffect(() => {
|
|
57
150
|
if (previousResetKeyRef.current === resetKey) return;
|
|
58
151
|
previousResetKeyRef.current = resetKey;
|
|
59
152
|
setAdminCustomReceiptLines([]);
|
|
153
|
+
setAppliedAdminCustomReceiptLines([]);
|
|
154
|
+
setRemovedExistingAdjustmentIds([]);
|
|
155
|
+
setAppliedRemovedExistingAdjustmentIds([]);
|
|
156
|
+
setHasHydratedExistingAdjustments(false);
|
|
157
|
+
hydratedResetKeyRef.current = undefined;
|
|
158
|
+
skipHydrationForResetKeyRef.current =
|
|
159
|
+
reversibleAdjustments === undefined ? undefined : resetKey;
|
|
160
|
+
// `reversibleAdjustments` is intentionally sampled only when the reset key changes.
|
|
161
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
162
|
}, [resetKey]);
|
|
61
163
|
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (reversibleAdjustments === undefined) return;
|
|
166
|
+
// A product-family reset and clearing the previous quote happen in separate effects.
|
|
167
|
+
// Never hydrate the new editor from the previous target's quote during that transition.
|
|
168
|
+
if (skipHydrationForResetKeyRef.current === resetKey) {
|
|
169
|
+
skipHydrationForResetKeyRef.current = undefined;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (hydratedResetKeyRef.current === resetKey) return;
|
|
173
|
+
hydratedResetKeyRef.current = resetKey;
|
|
174
|
+
const hydratedLines = reversibleAdjustments.map((adjustment) =>
|
|
175
|
+
lineFromActiveAdjustment(adjustment, nextDraftId('admin-edit')),
|
|
176
|
+
);
|
|
177
|
+
setAdminCustomReceiptLines(hydratedLines);
|
|
178
|
+
setAppliedAdminCustomReceiptLines(hydratedLines);
|
|
179
|
+
setRemovedExistingAdjustmentIds([]);
|
|
180
|
+
setAppliedRemovedExistingAdjustmentIds([]);
|
|
181
|
+
setHasHydratedExistingAdjustments(true);
|
|
182
|
+
}, [nextDraftId, resetKey, reversibleAdjustments]);
|
|
183
|
+
|
|
184
|
+
const adminReverseAdjustmentIds = reverseAdjustmentIdsForAdminCustomLines(
|
|
185
|
+
appliedAdminCustomReceiptLines,
|
|
186
|
+
appliedRemovedExistingAdjustmentIds,
|
|
187
|
+
);
|
|
188
|
+
const hasPendingAdminCustomReceiptLineChanges = useMemo(
|
|
189
|
+
() =>
|
|
190
|
+
JSON.stringify({
|
|
191
|
+
lines: adminCustomReceiptLines,
|
|
192
|
+
removedIds: [...removedExistingAdjustmentIds].sort(),
|
|
193
|
+
}) !==
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
lines: appliedAdminCustomReceiptLines,
|
|
196
|
+
removedIds: [...appliedRemovedExistingAdjustmentIds].sort(),
|
|
197
|
+
}),
|
|
198
|
+
[
|
|
199
|
+
adminCustomReceiptLines,
|
|
200
|
+
appliedAdminCustomReceiptLines,
|
|
201
|
+
removedExistingAdjustmentIds,
|
|
202
|
+
appliedRemovedExistingAdjustmentIds,
|
|
203
|
+
],
|
|
204
|
+
);
|
|
205
|
+
const pendingAdminCustomReceiptLineIds = useMemo(() => {
|
|
206
|
+
const appliedLinesById = new Map(
|
|
207
|
+
appliedAdminCustomReceiptLines.map((line) => [line.id, line]),
|
|
208
|
+
);
|
|
209
|
+
return adminCustomReceiptLines.flatMap((line) => {
|
|
210
|
+
const appliedLine = appliedLinesById.get(line.id);
|
|
211
|
+
return !appliedLine || JSON.stringify(line) !== JSON.stringify(appliedLine)
|
|
212
|
+
? [line.id]
|
|
213
|
+
: [];
|
|
214
|
+
});
|
|
215
|
+
}, [adminCustomReceiptLines, appliedAdminCustomReceiptLines]);
|
|
216
|
+
|
|
62
217
|
return {
|
|
63
218
|
adminCustomReceiptLines,
|
|
219
|
+
appliedAdminCustomReceiptLines,
|
|
220
|
+
adminReverseAdjustmentIds,
|
|
221
|
+
hasPendingAdminCustomReceiptLineChanges,
|
|
222
|
+
pendingAdminCustomReceiptLineIds,
|
|
223
|
+
hasHydratedExistingAdjustments,
|
|
64
224
|
handleAddAdminCustomReceiptLine,
|
|
65
225
|
handleUpdateAdminCustomReceiptLine,
|
|
66
226
|
handleRemoveAdminCustomReceiptLine,
|
|
227
|
+
handleApplyAdminCustomReceiptLines,
|
|
67
228
|
};
|
|
68
229
|
}
|