@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.185",
3
+ "version": "1.2.186",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -222,7 +222,7 @@ export function AdminChangeReceiptComparison({
222
222
  line.overrideComponentId ? [[line.overrideComponentId, line] as const] : [],
223
223
  ),
224
224
  );
225
- const summaryDisplayLines = rawSummaryDisplayLines
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
- <PriceSummary
369
- lines={summaryDisplayLines}
370
- total={summaryTotal ?? amountDue}
371
- totalLabel={summaryTotalLabel}
372
- showPositiveTotalSign={!isProductFamilyConversion}
373
- currency={currency}
374
- locale={displayLocale}
375
- subtotal={summarySubtotal}
376
- size="sm"
377
- t={t}
378
- lineAmountInputs={{ ...quotedOverrideAmountInputs, ...overrideDrafts }}
379
- onLineAmountInputChange={handleOverrideChange}
380
- onLineAmountInputBlur={handleOverrideBlur}
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 }],