@ticketboothapp/booking 1.2.185 → 1.2.186
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/AdminChangeReceiptComparison.tsx +80 -17
- package/src/components/booking/PriceBreakdown.tsx +24 -0
- package/src/components/booking/PriceSummary.tsx +19 -1
- package/src/components/booking/useAdminCustomReceiptLines.ts +21 -0
- package/test/change-booking-helpers.test.ts +25 -0
package/package.json
CHANGED
|
@@ -222,7 +222,7 @@ export function AdminChangeReceiptComparison({
|
|
|
222
222
|
line.overrideComponentId ? [[line.overrideComponentId, line] as const] : [],
|
|
223
223
|
),
|
|
224
224
|
);
|
|
225
|
-
const
|
|
225
|
+
const allSummaryDisplayLines = rawSummaryDisplayLines
|
|
226
226
|
.filter((line) => !(line.kind === 'line' && line.label.startsWith('Override · ')))
|
|
227
227
|
.map((line) => {
|
|
228
228
|
const type = line.kind === 'ticket' ? 'TICKET' : String(line.type ?? '').toUpperCase();
|
|
@@ -235,6 +235,18 @@ export function AdminChangeReceiptComparison({
|
|
|
235
235
|
),
|
|
236
236
|
};
|
|
237
237
|
});
|
|
238
|
+
const isRemovedSummaryLine = (line: PriceSummaryLine) => {
|
|
239
|
+
if (!line.editable || !line.componentId) return false;
|
|
240
|
+
const override = overrideByComponentId.get(line.componentId);
|
|
241
|
+
if (!override) return false;
|
|
242
|
+
const baseAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
243
|
+
const targetAmount = baseAmount + override.amountSign * Number(override.amountInput);
|
|
244
|
+
return Number.isFinite(targetAmount) && Math.abs(targetAmount) < 0.005;
|
|
245
|
+
};
|
|
246
|
+
const summaryDisplayLines = allSummaryDisplayLines.filter(
|
|
247
|
+
(line) => !isRemovedSummaryLine(line),
|
|
248
|
+
);
|
|
249
|
+
const removedSummaryDisplayLines = allSummaryDisplayLines.filter(isRemovedSummaryLine);
|
|
238
250
|
const quotedOverrideAmountInputs = Object.fromEntries(
|
|
239
251
|
summaryDisplayLines.flatMap((line) => {
|
|
240
252
|
if (!line.componentId || !line.lineKey) return [];
|
|
@@ -267,6 +279,34 @@ export function AdminChangeReceiptComparison({
|
|
|
267
279
|
return next;
|
|
268
280
|
});
|
|
269
281
|
};
|
|
282
|
+
const handleOverrideRemove = (lineKey: string) => {
|
|
283
|
+
const line = summaryDisplayLines.find((candidate) => candidate.lineKey === lineKey);
|
|
284
|
+
if (!line?.componentId) return;
|
|
285
|
+
const baseAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
286
|
+
onSetReceiptLineOverride?.({
|
|
287
|
+
componentId: line.componentId,
|
|
288
|
+
lineKey,
|
|
289
|
+
label: line.kind === 'ticket' ? line.category : line.label,
|
|
290
|
+
baseAmount,
|
|
291
|
+
targetAmount: 0,
|
|
292
|
+
});
|
|
293
|
+
setOverrideDrafts((current) => {
|
|
294
|
+
const next = { ...current };
|
|
295
|
+
delete next[lineKey];
|
|
296
|
+
return next;
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
const handleOverrideRestore = (line: PriceSummaryLine) => {
|
|
300
|
+
if (!line.componentId || !line.lineKey) return;
|
|
301
|
+
const baseAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
302
|
+
onSetReceiptLineOverride?.({
|
|
303
|
+
componentId: line.componentId,
|
|
304
|
+
lineKey: line.lineKey,
|
|
305
|
+
label: line.kind === 'ticket' ? line.category : line.label,
|
|
306
|
+
baseAmount,
|
|
307
|
+
targetAmount: baseAmount,
|
|
308
|
+
});
|
|
309
|
+
};
|
|
270
310
|
const summarySubtotal = isProductFamilyConversion
|
|
271
311
|
? familyConversionReceiptLines.reduce((sum, line) => {
|
|
272
312
|
if (line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX') return sum;
|
|
@@ -353,32 +393,55 @@ export function AdminChangeReceiptComparison({
|
|
|
353
393
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
354
394
|
<p className="text-sm font-medium text-stone-700">Loading price change…</p>
|
|
355
395
|
</div>
|
|
356
|
-
) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
|
|
396
|
+
) : summaryDisplayLines.length === 0 && removedSummaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
|
|
357
397
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
358
398
|
<p className="text-sm font-medium text-stone-800">No refund will be created</p>
|
|
359
399
|
<p className="mt-1 text-xs text-stone-600">
|
|
360
400
|
{formatCurrencyAmount(removedWithoutRefund, currency, displayLocale)} of booking value will be removed without customer credit.
|
|
361
401
|
</p>
|
|
362
402
|
</div>
|
|
363
|
-
) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
|
|
403
|
+
) : summaryDisplayLines.length === 0 && removedSummaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
|
|
364
404
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
365
405
|
<p className="text-sm font-medium text-stone-700">No pricing changes</p>
|
|
366
406
|
</div>
|
|
367
407
|
) : (
|
|
368
|
-
<
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
408
|
+
<div>
|
|
409
|
+
<PriceSummary
|
|
410
|
+
lines={summaryDisplayLines}
|
|
411
|
+
total={summaryTotal ?? amountDue}
|
|
412
|
+
totalLabel={summaryTotalLabel}
|
|
413
|
+
showPositiveTotalSign={!isProductFamilyConversion}
|
|
414
|
+
currency={currency}
|
|
415
|
+
locale={displayLocale}
|
|
416
|
+
subtotal={summarySubtotal}
|
|
417
|
+
size="sm"
|
|
418
|
+
t={t}
|
|
419
|
+
lineAmountInputs={{ ...quotedOverrideAmountInputs, ...overrideDrafts }}
|
|
420
|
+
onLineAmountInputChange={handleOverrideChange}
|
|
421
|
+
onLineAmountInputBlur={handleOverrideBlur}
|
|
422
|
+
onLineRemove={handleOverrideRemove}
|
|
423
|
+
/>
|
|
424
|
+
{removedSummaryDisplayLines.length > 0 ? (
|
|
425
|
+
<div className="mt-3 border-t border-stone-200 pt-2 text-xs text-stone-500">
|
|
426
|
+
<span className="font-medium text-stone-600">Removed lines:</span>{' '}
|
|
427
|
+
{removedSummaryDisplayLines.map((line, index) => {
|
|
428
|
+
const label = line.kind === 'ticket' ? line.category : line.label;
|
|
429
|
+
return (
|
|
430
|
+
<span key={line.lineKey ?? `${label}-${index}`}>
|
|
431
|
+
{index > 0 ? ', ' : ''}{label}{' '}
|
|
432
|
+
<button
|
|
433
|
+
type="button"
|
|
434
|
+
className="font-medium text-stone-700 underline underline-offset-2 hover:text-stone-950"
|
|
435
|
+
onClick={() => handleOverrideRestore(line)}
|
|
436
|
+
>
|
|
437
|
+
Undo
|
|
438
|
+
</button>
|
|
439
|
+
</span>
|
|
440
|
+
);
|
|
441
|
+
})}
|
|
442
|
+
</div>
|
|
443
|
+
) : null}
|
|
444
|
+
</div>
|
|
382
445
|
)}
|
|
383
446
|
</section>
|
|
384
447
|
</div>
|
|
@@ -28,6 +28,7 @@ export interface PriceBreakdownProps {
|
|
|
28
28
|
onEditableChange?: (value: string) => void;
|
|
29
29
|
onEditableBlur?: () => void;
|
|
30
30
|
onEditableReset?: () => void;
|
|
31
|
+
onEditableRemove?: () => void;
|
|
31
32
|
editableLabelValue?: string;
|
|
32
33
|
onEditableLabelChange?: (value: string) => void;
|
|
33
34
|
}
|
|
@@ -76,6 +77,7 @@ export function PriceBreakdown({
|
|
|
76
77
|
onEditableChange,
|
|
77
78
|
onEditableBlur,
|
|
78
79
|
onEditableReset,
|
|
80
|
+
onEditableRemove,
|
|
79
81
|
editableLabelValue,
|
|
80
82
|
onEditableLabelChange,
|
|
81
83
|
}: PriceBreakdownProps) {
|
|
@@ -129,6 +131,17 @@ export function PriceBreakdown({
|
|
|
129
131
|
onChange={(e) => onEditableChange(e.target.value)}
|
|
130
132
|
onBlur={onEditableBlur}
|
|
131
133
|
/>
|
|
134
|
+
{onEditableRemove ? (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className="rounded p-0.5 text-stone-400 hover:bg-red-50 hover:text-red-600"
|
|
138
|
+
onClick={onEditableRemove}
|
|
139
|
+
aria-label={`Remove ${category} line`}
|
|
140
|
+
title="Remove line"
|
|
141
|
+
>
|
|
142
|
+
<span className="inline-block h-4 w-4 text-center text-base leading-4">×</span>
|
|
143
|
+
</button>
|
|
144
|
+
) : null}
|
|
132
145
|
</div>
|
|
133
146
|
) : (
|
|
134
147
|
<span className="text-sm font-medium text-stone-700">
|
|
@@ -191,6 +204,17 @@ export function PriceBreakdown({
|
|
|
191
204
|
onChange={(e) => onEditableChange(e.target.value)}
|
|
192
205
|
onBlur={onEditableBlur}
|
|
193
206
|
/>
|
|
207
|
+
{onEditableRemove ? (
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
className="rounded p-0.5 text-stone-400 hover:bg-red-50 hover:text-red-600"
|
|
211
|
+
onClick={onEditableRemove}
|
|
212
|
+
aria-label={`Remove ${category} line`}
|
|
213
|
+
title="Remove line"
|
|
214
|
+
>
|
|
215
|
+
<span className="inline-block h-4 w-4 text-center text-base leading-4">×</span>
|
|
216
|
+
</button>
|
|
217
|
+
) : null}
|
|
194
218
|
</div>
|
|
195
219
|
) : (
|
|
196
220
|
<span
|
|
@@ -98,6 +98,8 @@ export interface PriceSummaryProps {
|
|
|
98
98
|
onLineAmountInputBlur?: (lineKey: string) => void;
|
|
99
99
|
/** Optional reset handler for inline editable line amounts. */
|
|
100
100
|
onLineAmountReset?: (lineKey: string) => void;
|
|
101
|
+
/** Optional remove handler for inline editable lines. */
|
|
102
|
+
onLineRemove?: (lineKey: string) => void;
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
function getLineAmountClass(type: string | undefined, amount: number): string {
|
|
@@ -174,7 +176,6 @@ export function PriceSummary({
|
|
|
174
176
|
locale,
|
|
175
177
|
subtotal,
|
|
176
178
|
taxAmount = 0,
|
|
177
|
-
taxRate: _taxRate,
|
|
178
179
|
discountAmount = 0,
|
|
179
180
|
discountLabel,
|
|
180
181
|
size = 'sm',
|
|
@@ -194,6 +195,7 @@ export function PriceSummary({
|
|
|
194
195
|
onLineLabelInputChange,
|
|
195
196
|
onLineAmountInputBlur,
|
|
196
197
|
onLineAmountReset,
|
|
198
|
+
onLineRemove,
|
|
197
199
|
}: PriceSummaryProps) {
|
|
198
200
|
const textSize = size === 'sm' ? 'text-sm' : 'text-base';
|
|
199
201
|
const totalSize = size === 'sm' ? 'text-xl' : 'text-2xl';
|
|
@@ -247,6 +249,11 @@ export function PriceSummary({
|
|
|
247
249
|
? () => onLineAmountReset(row.lineKey!)
|
|
248
250
|
: undefined
|
|
249
251
|
}
|
|
252
|
+
onEditableRemove={
|
|
253
|
+
row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineRemove
|
|
254
|
+
? () => onLineRemove(row.lineKey!)
|
|
255
|
+
: undefined
|
|
256
|
+
}
|
|
250
257
|
editableLabelValue={row.lineKey ? lineLabelInputs?.[row.lineKey] : undefined}
|
|
251
258
|
onEditableLabelChange={
|
|
252
259
|
row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineLabelInputChange
|
|
@@ -313,6 +320,17 @@ export function PriceSummary({
|
|
|
313
320
|
onChange={(e) => onLineAmountInputChange(lineKey, e.target.value)}
|
|
314
321
|
onBlur={() => onLineAmountInputBlur?.(lineKey)}
|
|
315
322
|
/>
|
|
323
|
+
{isBeforeSubtotalBoundary && onLineRemove ? (
|
|
324
|
+
<button
|
|
325
|
+
type="button"
|
|
326
|
+
className="rounded p-0.5 text-stone-400 hover:bg-red-50 hover:text-red-600"
|
|
327
|
+
onClick={() => onLineRemove(lineKey)}
|
|
328
|
+
aria-label={`Remove ${label} line`}
|
|
329
|
+
title="Remove line"
|
|
330
|
+
>
|
|
331
|
+
<span className="inline-block h-4 w-4 text-center text-base leading-4">×</span>
|
|
332
|
+
</button>
|
|
333
|
+
) : null}
|
|
316
334
|
</div>
|
|
317
335
|
) : (
|
|
318
336
|
<span className={`flex-shrink-0 whitespace-nowrap font-medium ${getLineAmountClass(type, amount)}`}>
|
|
@@ -43,6 +43,10 @@ export function lineFromActiveAdjustment(
|
|
|
43
43
|
replacementAdjustmentId: string,
|
|
44
44
|
): AdminCustomReceiptLine {
|
|
45
45
|
const percentage = adjustment.mode !== 'FIXED_AMOUNT';
|
|
46
|
+
const overrideComponentId =
|
|
47
|
+
adjustment.reasonCode === 'LINE_ITEM_OVERRIDE' && adjustment.affectedComponentIds.length === 1
|
|
48
|
+
? adjustment.affectedComponentIds[0]
|
|
49
|
+
: undefined;
|
|
46
50
|
return {
|
|
47
51
|
id: `existing:${adjustment.adjustmentId}`,
|
|
48
52
|
label:
|
|
@@ -62,6 +66,7 @@ export function lineFromActiveAdjustment(
|
|
|
62
66
|
sourceAdjustmentId: adjustment.adjustmentId,
|
|
63
67
|
replacementAdjustmentId,
|
|
64
68
|
isDirty: false,
|
|
69
|
+
overrideComponentId,
|
|
65
70
|
};
|
|
66
71
|
}
|
|
67
72
|
|
|
@@ -160,6 +165,8 @@ export function useAdminCustomReceiptLines(
|
|
|
160
165
|
const existing = adminCustomReceiptLines.find(
|
|
161
166
|
(line) => line.overrideComponentId === input.componentId,
|
|
162
167
|
);
|
|
168
|
+
const sourceAdjustmentId = existing?.sourceAdjustmentId;
|
|
169
|
+
const restoringQuotedLine = Math.abs(delta) < 0.005;
|
|
163
170
|
const next = Math.abs(delta) < 0.005
|
|
164
171
|
? adminCustomReceiptLines.filter((line) => line.id !== existing?.id)
|
|
165
172
|
: [
|
|
@@ -177,8 +184,22 @@ export function useAdminCustomReceiptLines(
|
|
|
177
184
|
overrideComponentId: input.componentId,
|
|
178
185
|
overrideBaseAmount: input.baseAmount,
|
|
179
186
|
overrideLineKey: input.lineKey,
|
|
187
|
+
...(existing?.sourceAdjustmentId
|
|
188
|
+
? {
|
|
189
|
+
sourceAdjustmentId: existing.sourceAdjustmentId,
|
|
190
|
+
replacementAdjustmentId: existing.replacementAdjustmentId,
|
|
191
|
+
isDirty: true,
|
|
192
|
+
}
|
|
193
|
+
: {}),
|
|
180
194
|
},
|
|
181
195
|
];
|
|
196
|
+
if (sourceAdjustmentId) {
|
|
197
|
+
const updateRemovedIds = (current: string[]) => restoringQuotedLine
|
|
198
|
+
? Array.from(new Set([...current, sourceAdjustmentId]))
|
|
199
|
+
: current.filter((id) => id !== sourceAdjustmentId);
|
|
200
|
+
setRemovedExistingAdjustmentIds(updateRemovedIds);
|
|
201
|
+
setAppliedRemovedExistingAdjustmentIds(updateRemovedIds);
|
|
202
|
+
}
|
|
182
203
|
setAdminCustomReceiptLines(next);
|
|
183
204
|
setAppliedAdminCustomReceiptLines(next);
|
|
184
205
|
}, [adminCustomReceiptLines, nextDraftId]);
|
|
@@ -234,6 +234,31 @@ test('existing admin adjustment hydrates for immutable reverse-and-replace editi
|
|
|
234
234
|
assert.deepEqual(reverseAdjustmentIdsForAdminCustomLines([], ['admin-rcpt-1']), ['admin-rcpt-1']);
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
+
test('saved line-item override hydrates its targeted receipt component', () => {
|
|
238
|
+
const line = lineFromActiveAdjustment({
|
|
239
|
+
adjustmentId: 'override-adult-1',
|
|
240
|
+
operationId: 'op-override-adult-1',
|
|
241
|
+
mode: 'FIXED_AMOUNT',
|
|
242
|
+
scope: 'SELECTED_COMPONENTS',
|
|
243
|
+
basisCents: 19_799,
|
|
244
|
+
amountCents: 19_799,
|
|
245
|
+
taxDeltaCents: 990,
|
|
246
|
+
affectedComponentIds: ['adult-component'],
|
|
247
|
+
taxBehavior: 'TAXABLE',
|
|
248
|
+
reasonCode: 'LINE_ITEM_OVERRIDE',
|
|
249
|
+
reason: 'Override · Refund · ADULT',
|
|
250
|
+
createdAt: '2026-08-09T00:00:00Z',
|
|
251
|
+
}, 'replacement-override-adult-1');
|
|
252
|
+
|
|
253
|
+
assert.equal(line.overrideComponentId, 'adult-component');
|
|
254
|
+
assert.deepEqual(line.selectedComponentIds, ['adult-component']);
|
|
255
|
+
assert.equal(line.sourceAdjustmentId, 'override-adult-1');
|
|
256
|
+
assert.deepEqual(
|
|
257
|
+
reverseAdjustmentIdsForAdminCustomLines([{ ...line, isDirty: true }]),
|
|
258
|
+
['override-adult-1'],
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
237
262
|
test('private resource quantity is not carried into a regular-tour conversion', () => {
|
|
238
263
|
const quantities = deriveCompatibleInitialBookingQuantities(
|
|
239
264
|
[{ category: 'RESOURCE', count: 1 }],
|