@ticketboothapp/booking 1.2.72 → 1.2.75

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.
@@ -84,3 +84,19 @@ Pickers show **catalog** prices by default (same booking **currency** as the ori
84
84
  - **Currency**: align optional maps and line `amount` values with `currency` / receipt currency; change booking stays in the booking’s sold currency end-to-end on FE.
85
85
  - **Cancellation policy on change**: FE sends the booked policy on reserve/confirm paths; it is **not** chosen from product config during change — preserve the booking’s policy server-side unless product rules say otherwise.
86
86
  - **`serverPreview.completeness`**: the package sets this to **`full`** whenever a preview is built (informational); UI does not gate on `totals_only` vs `full`.
87
+
88
+ ---
89
+
90
+ ## Admin / provider manual line items (signed adjustments)
91
+
92
+ **Frontend (this package):** `AdminChangeBookingFlow` lets staff add custom receipt lines (label + signed amount). The same values are:
93
+
94
+ 1. **`POST …/change/quote`** — optional request field **`manualLineAdjustments`**: `Array<{ label: string; amount: number }>` in **major units** (same currency as the booking). Amounts are signed (credits negative). Omitted when there are no custom lines.
95
+ 2. **Provider apply** — `onChangeBooking` payload field **`pricingAdjustment`**, with **`mode: 'MANUAL_LINES'`** and **`additionalAdjustments`** equal to the **provider** inline editor rows **plus** these admin custom lines (concatenated). `newTotalAmount` remains the full proposed total (already includes those lines on the client).
96
+
97
+ **Backend expectations**
98
+
99
+ - **Quote:** When `manualLineAdjustments` is present, add each line to the server’s priced subtotal (before tax) for the change intent, and return quote totals / `amountDue` / line items that **include** these rows so the UI and `clientProposedTotal` stay in tolerance.
100
+ - **Provider change API:** When `pricingAdjustment.additionalAdjustments` includes the same rows, persist them on the new receipt and charge the **same** total as `newTotalAmount` (within your rounding rules).
101
+
102
+ If the server ignores `manualLineAdjustments` or `additionalAdjustments`, the customer will see the FE total but pay a different amount.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # `@ticketboothapp/booking`
2
+
3
+ Published as `@ticketboothapp/booking` (see `package.json`).
4
+
5
+ **Local development in TicketBooth provider-dashboard** (link this package without publishing):
6
+
7
+ - [docs/local-ticketbooth-provider-dashboard.md](./docs/local-ticketbooth-provider-dashboard.md)
8
+
9
+ **In this monorepo:** apps use `workspace:*` / `transpilePackages`; run e.g. `npm run dev:booking` or `npm run dev:staff` from the website repo root.
@@ -0,0 +1,60 @@
1
+ # Test `@ticketboothapp/booking` in TicketBooth provider-dashboard (local link)
2
+
3
+ Use this when developing in **viavia-website** and you want **provider-dashboard** (`ticketbooth-fe`) to load your **workspace package** without publishing to npm.
4
+
5
+ Provider-dashboard already sets `transpilePackages: ['@ticketboothapp/booking']`, so Next will compile the linked package from source.
6
+
7
+ ## Option A — `npm link` (quick toggle)
8
+
9
+ **1. Register this package globally**
10
+
11
+ From this repo’s booking package directory:
12
+
13
+ ```bash
14
+ cd /path/to/viavia-website/packages/viavia-booking
15
+ npm link
16
+ ```
17
+
18
+ **2. Point provider-dashboard at that link**
19
+
20
+ ```bash
21
+ cd /path/to/ticketbooth-fe/apps/provider-dashboard
22
+ npm link @ticketboothapp/booking
23
+ ```
24
+
25
+ **3. Run provider-dashboard**
26
+
27
+ From the TicketBooth frontend monorepo root (typical for that repo):
28
+
29
+ ```bash
30
+ cd /path/to/ticketbooth-fe
31
+ npm run dev:provider
32
+ ```
33
+
34
+ **Undo** (back to the version from the registry):
35
+
36
+ ```bash
37
+ cd /path/to/ticketbooth-fe/apps/provider-dashboard
38
+ npm unlink @ticketboothapp/booking
39
+ npm install
40
+ ```
41
+
42
+ If the workspace layout is odd, run `npm install` once at the `ticketbooth-fe` root after unlink.
43
+
44
+ ---
45
+
46
+ ## Option B — `file:` in `package.json` (stays on until you revert)
47
+
48
+ In `apps/provider-dashboard/package.json`, set:
49
+
50
+ ```json
51
+ "@ticketboothapp/booking": "file:/absolute/path/to/viavia-website/packages/viavia-booking"
52
+ ```
53
+
54
+ Then from `ticketbooth-fe` root: `npm install` and `npm run dev:provider`.
55
+
56
+ ---
57
+
58
+ ## Option C — `overrides` in `ticketbooth-fe` root `package.json`
59
+
60
+ Add an `overrides` entry for `@ticketboothapp/booking` pointing at the same `file:` path, then `npm install` at the monorepo root.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.72",
3
+ "version": "1.2.75",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -726,6 +726,11 @@ export function AdminChangeBookingFlow({
726
726
  );
727
727
  /** Fetched add-ons for the selected product option */
728
728
  const [addOns, setAddOns] = useState<AddOn[]>([]);
729
+ /** Admin receipt-only adjustments (signed amount); affects FE subtotal/tax/total until persisted server-side. */
730
+ const [adminCustomReceiptLines, setAdminCustomReceiptLines] = useState<
731
+ Array<{ id: string; label: string; amountInput: string; amountSign: 1 | -1 }>
732
+ >([]);
733
+ const adminCustomLineIdRef = useRef(0);
729
734
 
730
735
  // Auto-apply promo code when parent page passes one (e.g. partner pages).
731
736
  // Seed input only; validate/apply runs after date/time + tickets exist (debounced + handleApplyPromo).
@@ -2909,6 +2914,22 @@ export function AdminChangeBookingFlow({
2909
2914
  : subtotal;
2910
2915
  const effectiveSubtotal = effectiveSubtotalBeforeAddOns + addOnTotal;
2911
2916
 
2917
+ const adminCustomAdjustmentTotal = useMemo(
2918
+ () =>
2919
+ roundMoney(
2920
+ adminCustomReceiptLines.reduce((sum, line) => {
2921
+ const raw = line.amountInput.trim();
2922
+ if (raw === '' || raw === '+' || raw === '-') return sum;
2923
+ const n = Number(raw);
2924
+ if (!Number.isFinite(n)) return sum;
2925
+ const sign = line.amountSign ?? 1;
2926
+ return sum + sign * Math.abs(n);
2927
+ }, 0)
2928
+ ),
2929
+ [adminCustomReceiptLines]
2930
+ );
2931
+ const effectiveSubtotalForCheckout = roundMoney(effectiveSubtotal + adminCustomAdjustmentTotal);
2932
+
2912
2933
  /** Stable signature for promo discount API (avoid effect re-fire on object identity churn). */
2913
2934
  const quantitiesSignature = useMemo(
2914
2935
  () =>
@@ -3123,7 +3144,7 @@ export function AdminChangeBookingFlow({
3123
3144
  selectedAvailability.availabilityId ?? '',
3124
3145
  currency,
3125
3146
  quantitiesSignature,
3126
- String(Math.round(effectiveSubtotal * 100)),
3147
+ String(Math.round(effectiveSubtotalForCheckout * 100)),
3127
3148
  lockedPromoCode ? String(Math.round((originalReceipt?.promoAmount ?? 0) * 100)) : '',
3128
3149
  ].join('::');
3129
3150
  }, [
@@ -3136,7 +3157,7 @@ export function AdminChangeBookingFlow({
3136
3157
  product.productId,
3137
3158
  currency,
3138
3159
  quantitiesSignature,
3139
- effectiveSubtotal,
3160
+ effectiveSubtotalForCheckout,
3140
3161
  lockedPromoCode,
3141
3162
  originalReceipt?.promoAmount,
3142
3163
  ]);
@@ -3144,7 +3165,7 @@ export function AdminChangeBookingFlow({
3144
3165
  promoDiscountParamsRef.current = {
3145
3166
  selectedAvailability,
3146
3167
  ticketLineItems: ticketLineItems.map((l) => ({ category: l.category, qty: l.qty })),
3147
- effectiveSubtotal,
3168
+ effectiveSubtotal: effectiveSubtotalForCheckout,
3148
3169
  appliedPromoCode,
3149
3170
  changeBookingPromo: lockedPromoCode
3150
3171
  ? { priorAmount: originalReceipt?.promoAmount }
@@ -3225,12 +3246,12 @@ export function AdminChangeBookingFlow({
3225
3246
  : 0;
3226
3247
  const effectivePromoDiscountAmount =
3227
3248
  promoDiscountAmount > 0 ? promoDiscountAmount : lockedPromoFallbackAmount;
3228
- const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotal * (pricingConfig?.taxRate ?? 0);
3249
+ const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotalForCheckout * (pricingConfig?.taxRate ?? 0);
3229
3250
  const effectiveTax =
3230
3251
  effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
3231
- ? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
3252
+ ? (effectiveSubtotalForCheckout - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
3232
3253
  : taxOnSubtotal;
3233
- const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
3254
+ const totalPrice = effectiveSubtotalForCheckout + effectiveTax - effectivePromoDiscountAmount;
3234
3255
  /**
3235
3256
  * FE cart rollup for line math, breakdowns, and `clientProposedTotal` hint to the API. Self-serve **footer** totals
3236
3257
  * prefer `latestChangeQuote` once quote succeeds — this value is not an alternate source of truth for checkout.
@@ -3661,9 +3682,12 @@ export function AdminChangeBookingFlow({
3661
3682
  return currentAmount !== originalAmount || currentLabel !== originalLabel || currentMode !== originalMode;
3662
3683
  });
3663
3684
  const hasEffectiveChangeSelection =
3664
- hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
3685
+ hasChangeSelection ||
3686
+ providerHasEditedLineOverrides ||
3687
+ providerHasAdditionalAdjustments ||
3688
+ Math.abs(adminCustomAdjustmentTotal) >= 0.005;
3665
3689
 
3666
- const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
3690
+ const displayedChangeAmountsRaw = resolveChangeFlowDisplayedAmounts({
3667
3691
  providerPreview: providerTotalsPreview,
3668
3692
  serverQuotePreview:
3669
3693
  isCustomerSelfServeChange && latestChangeQuote?.serverDisplay
@@ -3671,13 +3695,26 @@ export function AdminChangeBookingFlow({
3671
3695
  : null,
3672
3696
  fromCart: {
3673
3697
  total: changeFlowNewBookingTotal,
3674
- subtotal: effectiveSubtotal,
3698
+ subtotal: effectiveSubtotalForCheckout,
3675
3699
  tax: effectiveTax,
3676
3700
  },
3677
3701
  });
3678
- const displayChangeFlowProposedTotal = displayedChangeAmounts.total;
3679
- const displayChangeFlowSubtotal = displayedChangeAmounts.subtotal;
3680
- const displayChangeFlowTax = displayedChangeAmounts.tax;
3702
+ /** When cart drives display, {@link effectiveSubtotalForCheckout} already includes admin adjustments — do not add twice. */
3703
+ const displayLayerUsesExternalPricing =
3704
+ Boolean(providerTotalsPreview) ||
3705
+ (isCustomerSelfServeChange && Boolean(latestChangeQuote?.serverDisplay));
3706
+ const adminTaxDeltaForExternalDisplay =
3707
+ displayLayerUsesExternalPricing && !isTaxIncludedInPrice && Math.abs(adminCustomAdjustmentTotal) >= 0.0005
3708
+ ? roundMoney(adminCustomAdjustmentTotal * (pricingConfig?.taxRate ?? 0))
3709
+ : 0;
3710
+ const displayChangeFlowSubtotal = roundMoney(
3711
+ displayedChangeAmountsRaw.subtotal +
3712
+ (displayLayerUsesExternalPricing ? adminCustomAdjustmentTotal : 0)
3713
+ );
3714
+ const displayChangeFlowTax = roundMoney(displayedChangeAmountsRaw.tax + adminTaxDeltaForExternalDisplay);
3715
+ const displayChangeFlowProposedTotal = roundMoney(
3716
+ displayedChangeAmountsRaw.total + adminCustomAdjustmentTotal + adminTaxDeltaForExternalDisplay
3717
+ );
3681
3718
 
3682
3719
  const changeFlowClientEstimateDue = (() => {
3683
3720
  if (!originalReceipt) return totalPrice;
@@ -3772,6 +3809,25 @@ export function AdminChangeBookingFlow({
3772
3809
  .filter((v): v is { label: string; amount: number } => v != null)
3773
3810
  : [];
3774
3811
 
3812
+ /** Same rows as checkout breakdown — sent on provider apply + optional public change-quote for BE parity. */
3813
+ const adminCustomLinesAsAdditionalAdjustments = useMemo((): Array<{ label: string; amount: number }> => {
3814
+ return adminCustomReceiptLines.flatMap((line) => {
3815
+ const raw = line.amountInput.trim();
3816
+ if (raw === '' || raw === '+' || raw === '-') return [];
3817
+ const n = Number(raw);
3818
+ if (!Number.isFinite(n) || Math.abs(n) < 0.0005) return [];
3819
+ const sign = line.amountSign ?? 1;
3820
+ const amount = roundMoney(sign * Math.abs(n));
3821
+ if (Math.abs(amount) < 0.005) return [];
3822
+ return [{ label: line.label.trim() || 'Adjustment', amount }];
3823
+ });
3824
+ }, [adminCustomReceiptLines]);
3825
+
3826
+ const mergedProviderAdditionalAdjustments = useMemo(
3827
+ () => [...providerAdditionalAdjustments, ...adminCustomLinesAsAdditionalAdjustments],
3828
+ [providerAdditionalAdjustments, adminCustomLinesAsAdditionalAdjustments]
3829
+ );
3830
+
3775
3831
  const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
3776
3832
  if (!selectedAvailability || totalQuantity <= 0) return null;
3777
3833
  const ticketsLine = formatTicketLineItemsForSummary(
@@ -3847,7 +3903,7 @@ export function AdminChangeBookingFlow({
3847
3903
  return;
3848
3904
  }
3849
3905
  onPricePreviewChange({
3850
- subtotal: originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotal,
3906
+ subtotal: originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotalForCheckout,
3851
3907
  tax:
3852
3908
  !isTaxIncludedInPrice
3853
3909
  ? originalReceipt
@@ -3861,7 +3917,7 @@ export function AdminChangeBookingFlow({
3861
3917
  onPricePreviewChange,
3862
3918
  selectedAvailability,
3863
3919
  totalQuantity,
3864
- effectiveSubtotal,
3920
+ effectiveSubtotalForCheckout,
3865
3921
  effectiveTax,
3866
3922
  displayChangeFlowProposedTotal,
3867
3923
  displayChangeFlowSubtotal,
@@ -3926,6 +3982,9 @@ export function AdminChangeBookingFlow({
3926
3982
  newPassengerCounts: bookingItems,
3927
3983
  // Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
3928
3984
  ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
3985
+ ...(adminCustomLinesAsAdditionalAdjustments.length > 0
3986
+ ? { manualLineAdjustments: adminCustomLinesAsAdditionalAdjustments }
3987
+ : {}),
3929
3988
  clientProposedTotal: changeFlowNewBookingTotal,
3930
3989
  capacitySeatCredit: {
3931
3990
  enabled: true,
@@ -3939,7 +3998,7 @@ export function AdminChangeBookingFlow({
3939
3998
  quote,
3940
3999
  {
3941
4000
  total: changeFlowNewBookingTotal,
3942
- subtotal: effectiveSubtotal,
4001
+ subtotal: effectiveSubtotalForCheckout,
3943
4002
  tax: effectiveTax,
3944
4003
  },
3945
4004
  currency
@@ -3947,7 +4006,7 @@ export function AdminChangeBookingFlow({
3947
4006
  setLatestChangeQuote(
3948
4007
  mergeQuoteSliceWithServerPreview(slice, quote, {
3949
4008
  total: changeFlowNewBookingTotal,
3950
- subtotal: effectiveSubtotal,
4009
+ subtotal: effectiveSubtotalForCheckout,
3951
4010
  tax: effectiveTax,
3952
4011
  }, currency),
3953
4012
  );
@@ -3981,13 +4040,14 @@ export function AdminChangeBookingFlow({
3981
4040
  addOnSelections,
3982
4041
  changeFlowInitialTicketCount,
3983
4042
  changeFlowNewBookingTotal,
3984
- effectiveSubtotal,
4043
+ effectiveSubtotalForCheckout,
3985
4044
  effectiveTax,
3986
4045
  totalPrice,
3987
4046
  currency,
3988
4047
  activeOptions,
3989
4048
  initialValues?.availabilityId,
3990
4049
  initialValues?.returnAvailabilityId,
4050
+ adminCustomLinesAsAdditionalAdjustments,
3991
4051
  ]);
3992
4052
 
3993
4053
  // Auto-select product option when date is selected: most popular if set, otherwise first available.
@@ -4518,6 +4578,9 @@ export function AdminChangeBookingFlow({
4518
4578
  newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
4519
4579
  newPassengerCounts: bookingItems,
4520
4580
  ...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
4581
+ ...(adminCustomLinesAsAdditionalAdjustments.length > 0
4582
+ ? { manualLineAdjustments: adminCustomLinesAsAdditionalAdjustments }
4583
+ : {}),
4521
4584
  clientProposedTotal:
4522
4585
  latestChangeQuote?.serverDisplay?.total ?? changeFlowNewBookingTotal,
4523
4586
  });
@@ -4525,7 +4588,7 @@ export function AdminChangeBookingFlow({
4525
4588
  quote,
4526
4589
  {
4527
4590
  total: changeFlowNewBookingTotal,
4528
- subtotal: effectiveSubtotal,
4591
+ subtotal: effectiveSubtotalForCheckout,
4529
4592
  tax: effectiveTax,
4530
4593
  },
4531
4594
  currency
@@ -4536,7 +4599,7 @@ export function AdminChangeBookingFlow({
4536
4599
  setLatestChangeQuote(
4537
4600
  mergeQuoteSliceWithServerPreview(quoteSlice, quote, {
4538
4601
  total: changeFlowNewBookingTotal,
4539
- subtotal: effectiveSubtotal,
4602
+ subtotal: effectiveSubtotalForCheckout,
4540
4603
  tax: effectiveTax,
4541
4604
  }, currency),
4542
4605
  );
@@ -4679,6 +4742,21 @@ export function AdminChangeBookingFlow({
4679
4742
  type: 'FEE' as const,
4680
4743
  quantity: totalQuantity,
4681
4744
  })),
4745
+ ...adminCustomReceiptLines.flatMap((adjLine) => {
4746
+ const raw = adjLine.amountInput.trim();
4747
+ if (raw === '' || raw === '+' || raw === '-') return [];
4748
+ const n = Number(raw);
4749
+ if (!Number.isFinite(n) || Math.abs(n) < 0.0005) return [];
4750
+ const sign = adjLine.amountSign ?? 1;
4751
+ const amount = sign * Math.abs(n);
4752
+ return [
4753
+ {
4754
+ label: adjLine.label.trim() || 'Adjustment',
4755
+ amount,
4756
+ type: 'FEE' as const,
4757
+ },
4758
+ ];
4759
+ }),
4682
4760
  ...(!isTaxIncludedInPrice && taxForBreakdown > 0
4683
4761
  ? [
4684
4762
  {
@@ -4772,7 +4850,7 @@ export function AdminChangeBookingFlow({
4772
4850
  returnPriceAdjustment: checkoutReturnLineAmount,
4773
4851
  cancellationPolicyFee,
4774
4852
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
4775
- subtotal: effectiveSubtotal,
4853
+ subtotal: effectiveSubtotalForCheckout,
4776
4854
  tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
4777
4855
  totalQuantity,
4778
4856
  isTaxIncludedInPrice,
@@ -4833,7 +4911,7 @@ export function AdminChangeBookingFlow({
4833
4911
  returnPriceAdjustment: checkoutReturnLineAmount,
4834
4912
  cancellationPolicyFee,
4835
4913
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
4836
- subtotal: effectiveSubtotal,
4914
+ subtotal: effectiveSubtotalForCheckout,
4837
4915
  tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
4838
4916
  total: amountDueForCheckout,
4839
4917
  totalQuantity,
@@ -4954,11 +5032,13 @@ export function AdminChangeBookingFlow({
4954
5032
  newTotalAmount: displayChangeFlowProposedTotal,
4955
5033
  additionalHoursCount: null,
4956
5034
  pricingAdjustment:
4957
- providerPricingOverrides.length > 0 || providerAdditionalAdjustments.length > 0
5035
+ providerPricingOverrides.length > 0 || mergedProviderAdditionalAdjustments.length > 0
4958
5036
  ? {
4959
5037
  mode: 'MANUAL_LINES',
4960
- lineOverrides: providerPricingOverrides,
4961
- additionalAdjustments: providerAdditionalAdjustments,
5038
+ ...(providerPricingOverrides.length > 0 ? { lineOverrides: providerPricingOverrides } : {}),
5039
+ ...(mergedProviderAdditionalAdjustments.length > 0
5040
+ ? { additionalAdjustments: mergedProviderAdditionalAdjustments }
5041
+ : {}),
4962
5042
  }
4963
5043
  : undefined,
4964
5044
  capacitySeatCredit: {
@@ -5361,73 +5441,188 @@ export function AdminChangeBookingFlow({
5361
5441
  </>
5362
5442
  }
5363
5443
  extraBeforeSubtotal={
5364
- showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
5365
- <div className="space-y-1">
5366
- {providerPricingUi?.additionalAdjustments?.map((adj) => (
5367
- <div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
5368
- <div className="flex min-w-0 items-center gap-1">
5369
- <button
5370
- type="button"
5371
- className="rounded border border-stone-300 px-1 text-xs text-stone-600 hover:bg-stone-100"
5372
- onClick={() => providerPricingUi?.onRemoveAdditionalAdjustment?.(adj.id)}
5373
- >
5374
- -
5375
- </button>
5444
+ <>
5445
+ {showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
5446
+ <div className="space-y-1">
5447
+ {providerPricingUi?.additionalAdjustments?.map((adj) => (
5448
+ <div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
5449
+ <div className="flex min-w-0 items-center gap-1">
5450
+ <button
5451
+ type="button"
5452
+ className="rounded border border-stone-300 px-1 text-xs text-stone-600 hover:bg-stone-100"
5453
+ onClick={() => providerPricingUi?.onRemoveAdditionalAdjustment?.(adj.id)}
5454
+ >
5455
+ -
5456
+ </button>
5457
+ <input
5458
+ type="text"
5459
+ className="w-40 rounded border border-stone-300 px-2 py-0.5 text-sm"
5460
+ placeholder="Line description"
5461
+ value={adj.label}
5462
+ onChange={(e) =>
5463
+ providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, { label: e.target.value })
5464
+ }
5465
+ />
5466
+ </div>
5467
+ <div className="flex items-center gap-1">
5468
+ <select
5469
+ className="rounded border border-stone-300 px-1 py-0.5 text-xs"
5470
+ value={adj.mode}
5471
+ onChange={(e) =>
5472
+ providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
5473
+ mode: e.target.value as 'DISCOUNT' | 'CHARGE',
5474
+ })
5475
+ }
5476
+ >
5477
+ <option value="DISCOUNT">-</option>
5478
+ <option value="CHARGE">+</option>
5479
+ </select>
5480
+ <input
5481
+ type="text"
5482
+ inputMode="decimal"
5483
+ className="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
5484
+ placeholder="0.00"
5485
+ value={adj.amountInput}
5486
+ onChange={(e) =>
5487
+ providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
5488
+ amountInput: e.target.value,
5489
+ })
5490
+ }
5491
+ />
5492
+ </div>
5493
+ </div>
5494
+ ))}
5495
+ <button
5496
+ type="button"
5497
+ className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
5498
+ onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
5499
+ >
5500
+ + add line item
5501
+ </button>
5502
+ </div>
5503
+ ) : showProviderPricingInlineEditor ? (
5504
+ <button
5505
+ type="button"
5506
+ className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
5507
+ onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
5508
+ >
5509
+ + add line item
5510
+ </button>
5511
+ ) : null}
5512
+ <div className="space-y-3">
5513
+ <div className="flex justify-center">
5514
+ <button
5515
+ type="button"
5516
+ 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"
5517
+ onClick={() => {
5518
+ adminCustomLineIdRef.current += 1;
5519
+ setAdminCustomReceiptLines((prev) => [
5520
+ ...prev,
5521
+ {
5522
+ id: `admin-rcpt-${adminCustomLineIdRef.current}`,
5523
+ label: '',
5524
+ amountInput: '',
5525
+ amountSign: 1,
5526
+ },
5527
+ ]);
5528
+ }}
5529
+ >
5530
+ <span className="text-base font-semibold leading-none text-stone-700" aria-hidden>
5531
+ +
5532
+ </span>
5533
+ Add custom line
5534
+ </button>
5535
+ </div>
5536
+ {adminCustomReceiptLines.map((adj) => {
5537
+ const sign = adj.amountSign ?? 1;
5538
+ return (
5539
+ <div
5540
+ key={adj.id}
5541
+ className="admin-custom-receipt-line flex flex-wrap items-center gap-2 text-sm sm:flex-nowrap"
5542
+ >
5376
5543
  <input
5377
5544
  type="text"
5378
- className="w-40 rounded border border-stone-300 px-2 py-0.5 text-sm"
5379
- placeholder="Line description"
5545
+ 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"
5546
+ placeholder="Description"
5380
5547
  value={adj.label}
5381
5548
  onChange={(e) =>
5382
- providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, { label: e.target.value })
5549
+ setAdminCustomReceiptLines((prev) =>
5550
+ prev.map((l) => (l.id === adj.id ? { ...l, label: e.target.value } : l))
5551
+ )
5383
5552
  }
5384
5553
  />
5385
- </div>
5386
- <div className="flex items-center gap-1">
5387
- <select
5388
- className="rounded border border-stone-300 px-1 py-0.5 text-xs"
5389
- value={adj.mode}
5390
- onChange={(e) =>
5391
- providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
5392
- mode: e.target.value as 'DISCOUNT' | 'CHARGE',
5393
- })
5394
- }
5554
+ <div
5555
+ className="flex shrink-0 rounded-lg border border-stone-300 bg-stone-100/90 p-0.5 shadow-sm"
5556
+ role="group"
5557
+ aria-label="Line adds to total or reduces it"
5395
5558
  >
5396
- <option value="DISCOUNT">-</option>
5397
- <option value="CHARGE">+</option>
5398
- </select>
5559
+ <button
5560
+ type="button"
5561
+ className={`admin-custom-receipt-segment rounded-md text-xs font-semibold transition-colors sm:text-sm ${
5562
+ sign === 1
5563
+ ? 'bg-white text-emerald-800 shadow-sm ring-1 ring-stone-200/80'
5564
+ : 'text-stone-500 hover:bg-stone-200/60 hover:text-stone-700'
5565
+ }`}
5566
+ aria-pressed={sign === 1}
5567
+ onClick={() =>
5568
+ setAdminCustomReceiptLines((prev) =>
5569
+ prev.map((l) => (l.id === adj.id ? { ...l, amountSign: 1 } : l))
5570
+ )
5571
+ }
5572
+ >
5573
+ Charge (+)
5574
+ </button>
5575
+ <button
5576
+ type="button"
5577
+ className={`admin-custom-receipt-segment rounded-md text-xs font-semibold transition-colors sm:text-sm ${
5578
+ sign === -1
5579
+ ? 'bg-white text-red-800 shadow-sm ring-1 ring-stone-200/80'
5580
+ : 'text-stone-500 hover:bg-stone-200/60 hover:text-stone-700'
5581
+ }`}
5582
+ aria-pressed={sign === -1}
5583
+ onClick={() =>
5584
+ setAdminCustomReceiptLines((prev) =>
5585
+ prev.map((l) => (l.id === adj.id ? { ...l, amountSign: -1 } : l))
5586
+ )
5587
+ }
5588
+ >
5589
+ Credit (−)
5590
+ </button>
5591
+ </div>
5399
5592
  <input
5400
5593
  type="text"
5401
5594
  inputMode="decimal"
5402
- className="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
5595
+ 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"
5403
5596
  placeholder="0.00"
5597
+ aria-label="Amount"
5404
5598
  value={adj.amountInput}
5405
- onChange={(e) =>
5406
- providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
5407
- amountInput: e.target.value,
5408
- })
5409
- }
5599
+ onChange={(e) => {
5600
+ const v = e.target.value.replace(/[^0-9.]/g, '');
5601
+ const parts = v.split('.');
5602
+ const normalized =
5603
+ parts.length <= 1 ? v : `${parts[0]}.${parts.slice(1).join('').replace(/\./g, '')}`;
5604
+ setAdminCustomReceiptLines((prev) =>
5605
+ prev.map((l) => (l.id === adj.id ? { ...l, amountInput: normalized } : l))
5606
+ );
5607
+ }}
5410
5608
  />
5609
+ <button
5610
+ type="button"
5611
+ 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"
5612
+ aria-label="Remove this line"
5613
+ onClick={() =>
5614
+ setAdminCustomReceiptLines((prev) => prev.filter((l) => l.id !== adj.id))
5615
+ }
5616
+ >
5617
+ <span className="block text-xl leading-none" aria-hidden>
5618
+ ×
5619
+ </span>
5620
+ </button>
5411
5621
  </div>
5412
- </div>
5413
- ))}
5414
- <button
5415
- type="button"
5416
- className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
5417
- onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
5418
- >
5419
- + add line item
5420
- </button>
5622
+ );
5623
+ })}
5421
5624
  </div>
5422
- ) : showProviderPricingInlineEditor ? (
5423
- <button
5424
- type="button"
5425
- className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
5426
- onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
5427
- >
5428
- + add line item
5429
- </button>
5430
- ) : undefined
5625
+ </>
5431
5626
  }
5432
5627
  firstName={firstName}
5433
5628
  lastName={lastName}