Files
Crussell/frontend/src/lib/components/admin/EditBookingModal.svelte
T
popertotsandSisyphus 9a12a2d886 fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants
- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

922 lines
29 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { generateUUID } from '$lib/utils/uuid';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import CharCounter from '$lib/components/ui/CharCounter.svelte';
import type { Booking, BookingService, Payment, Service } from '$lib/types/booking';
interface Props {
open: boolean;
bookingId: string;
nextAppointmentStart?: string | null;
onSaved?: () => void;
}
let { open = $bindable(), bookingId, nextAppointmentStart = null, onSaved }: Props = $props();
let booking = $state<Booking | null>(null);
let services = $state<BookingService[]>([]);
let loading = $state(false);
let saving = $state(false);
let notes = $state('');
let showRemoveConfirm = $state(false);
let serviceToRemoveIndex = $state(-1);
let serviceToRemoveName = $state('');
let showOverrideModal = $state(false);
let overrideServiceIndex = $state(-1);
let overridePrice = $state('');
let overrideDuration = $state('');
let overrideOriginalPrice = $state(0);
let overrideOriginalDuration = $state(0);
let showAddServiceModal = $state(false);
let availableServices = $state<Service[]>([]);
let loadingServices = $state(false);
// Refund dialog state
let showRefundModal = $state(false);
let refundPaymentId = $state('');
let refundAmount = $state('');
let refundReason = $state('');
let refundLoading = $state(false);
let refundIdempotencyKey = $state('');
// Pence of the selected payment already returned via completed refunds —
// the refund amount is pre-filled with the residual (amount this).
let refundAlreadyRefundedPence = $state(0);
$effect(() => {
if (open && bookingId) {
fetchBooking();
}
});
async function fetchBooking() {
loading = true;
try {
const response = await apiFetch(`/api/admin/bookings/${bookingId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
booking = {
id: data.id,
start_time: data.start_time,
status: data.status,
notes: data.notes,
created_at: data.created_at,
updated_at: data.updated_at,
created_by: data.created_by,
deposit_required: data.deposit_required ?? false,
deposit_amount: data.deposit_amount,
deposit_paid: data.deposit_paid ?? false,
deposit_deadline: data.deposit_deadline,
user: data.user
? {
id: data.user.id,
first_name: data.user.first_name,
last_name: data.user.last_name,
full_name: data.user.full_name,
email: data.user.email,
phone: data.user.phone,
profile_pic_url: data.user.profile_pic_url,
date_of_birth: data.user.date_of_birth,
account_role: data.user.account_role,
loyalty_stamps: data.user.loyalty_stamps,
referral_code: data.user.referral_code,
referral_code_uses: data.user.referral_code_uses,
created_at: data.user.created_at,
notes: data.user.notes,
previous_first_name: data.user.previous_first_name,
previous_last_name: data.user.previous_last_name
}
: undefined,
services: (data.services || []).map((s: BookingService) => ({
booking_id: s.booking_id,
service_id: s.service_id,
service_name: s.service_name,
service_description: s.service_description,
price: s.price,
duration_minutes: s.duration_minutes,
override_price: s.override_price,
override_duration_minutes: s.override_duration_minutes
})),
payments: (data.payments || []).map((p: Payment) => ({
id: p.id,
booking_id: p.booking_id,
payment_type: p.payment_type,
payment_method: p.payment_method,
vendor_code: p.vendor_code,
invoice_number: p.invoice_number,
status: p.status,
amount: p.amount,
is_vat_applicable: p.is_vat_applicable,
vat_rate: p.vat_rate,
vat_amount: p.vat_amount,
net_amount: p.net_amount,
created_at: p.created_at,
updated_at: p.updated_at,
created_by: p.created_by
})),
total_amount: data.total_amount || 0,
amount_paid: data.amount_paid || 0,
amount_due: data.amount_due || 0,
duration_minutes: data.duration_minutes || 0
};
services = booking.services || [];
notes = booking.notes || '';
} else {
const text = await response.text();
toast.error('Failed to load booking: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error fetching booking:', err);
toast.error('Network error loading booking');
} finally {
loading = false;
}
}
const totalDuration = $derived(
services.reduce((sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0), 0)
);
const totalPrice = $derived(
services.reduce((sum, s) => sum + (s.override_price ?? s.price ?? 0), 0)
);
function confirmRemove(index: number) {
serviceToRemoveIndex = index;
serviceToRemoveName = services[index]?.service_name || 'this service';
showRemoveConfirm = true;
}
function removeService() {
if (serviceToRemoveIndex >= 0 && serviceToRemoveIndex < services.length) {
services = services.filter((_, i) => i !== serviceToRemoveIndex);
}
showRemoveConfirm = false;
serviceToRemoveIndex = -1;
}
function openOverrideModal(index: number) {
overrideServiceIndex = index;
const service = services[index];
overrideOriginalPrice = service.price ?? 0;
overrideOriginalDuration = service.duration_minutes ?? 0;
overridePrice = service.override_price !== undefined ? service.override_price.toFixed(2) : '';
overrideDuration =
service.override_duration_minutes !== undefined
? service.override_duration_minutes.toString()
: '';
showOverrideModal = true;
}
function handlePriceInput(value: string) {
let cleaned = value.replace(/[^\d.]/g, '');
const parts = cleaned.split('.');
if (parts.length > 2) {
cleaned = parts[0] + '.' + parts.slice(1).join('');
}
if (cleaned.includes('.')) {
const [integer, decimal] = cleaned.split('.');
if (decimal.length > 2) {
cleaned = integer + '.' + decimal.substring(0, 2);
}
}
overridePrice = cleaned;
}
function handleDurationInput(value: string) {
overrideDuration = value.replace(/\D/g, '');
}
function saveOverride() {
if (overrideServiceIndex < 0 || overrideServiceIndex >= services.length) return;
const service = services[overrideServiceIndex];
const newServices = [...services];
const updated = { ...service };
if (overridePrice !== '') {
const price = parseFloat(overridePrice);
if (!isNaN(price)) {
updated.override_price = price;
} else {
delete updated.override_price;
}
} else {
delete updated.override_price;
}
if (overrideDuration !== '') {
const duration = parseInt(overrideDuration);
if (!isNaN(duration) && duration > 0) {
updated.override_duration_minutes = duration;
} else {
delete updated.override_duration_minutes;
}
} else {
delete updated.override_duration_minutes;
}
newServices[overrideServiceIndex] = updated;
services = newServices;
showOverrideModal = false;
overrideServiceIndex = -1;
}
async function fetchAvailableServices() {
loadingServices = true;
try {
const response = await apiFetch('/api/services');
if (response.ok) {
const data = await response.json();
availableServices = data.services || data;
}
} catch (err) {
console.error('Error fetching services:', err);
toast.error('Failed to load services');
} finally {
loadingServices = false;
}
}
function openAddServiceModal() {
fetchAvailableServices();
showAddServiceModal = true;
}
function addService(service: Service) {
const newService: BookingService = {
booking_id: bookingId,
service_id: service.id,
service_name: service.name,
service_description: service.description,
price: service.price,
duration_minutes: service.duration_minutes
};
services = [...services, newService];
}
function isServiceAdded(serviceId: string): boolean {
return services.some((s) => s.service_id === serviceId);
}
const maxAvailableDuration = $derived.by(() => {
if (!booking?.start_time || !nextAppointmentStart) return null;
const bookingStart = parseWallClockDate(booking.start_time).getTime();
const currentDurationMs = totalDuration * 60 * 1000;
const bookingEnd = bookingStart + currentDurationMs;
const nextStart = parseWallClockDate(nextAppointmentStart).getTime();
const availableMs = nextStart - bookingEnd;
return Math.max(0, Math.floor(availableMs / (60 * 1000)));
});
const filteredServices = $derived.by(() => {
return availableServices.filter((s) => {
if (isServiceAdded(s.id)) return false;
if (maxAvailableDuration !== null && s.duration_minutes > maxAvailableDuration) return false;
return true;
});
});
async function handleSave() {
saving = true;
try {
const payload: Record<string, unknown> = {
service_ids: services.map((s) => s.service_id),
service_overrides: services
.filter(
(s) => s.override_price !== undefined || s.override_duration_minutes !== undefined
)
.map((s) => ({
service_id: s.service_id,
...(s.override_price !== undefined ? { override_price: s.override_price } : {}),
...(s.override_duration_minutes !== undefined
? { override_duration_minutes: s.override_duration_minutes }
: {})
}))
};
if (notes !== booking?.notes) {
payload.notes = notes || undefined;
}
const response = await apiFetch(`/api/admin/bookings/${bookingId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
toast.success('Booking updated successfully');
open = false;
onSaved?.();
} else {
const text = await response.text();
toast.error('Failed to update booking: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error saving booking:', err);
toast.error('Network error saving booking');
} finally {
saving = false;
}
}
async function openRefundModal(payment: Payment) {
refundPaymentId = payment.id;
// payment.amount is in POUNDS (float64) — no /100 here. Pre-fill with
// the full amount; the payment-summary residual below overrides it.
refundAmount = payment.amount.toFixed(2);
refundAlreadyRefundedPence = 0;
refundReason = '';
// Unique per refund attempt so two equal partial refunds of the same
// payment don't collide on the backend's amount-derived key; reused on
// retry (the backend dedups on it) so a timeout can't double-refund.
refundIdempotencyKey = generateUUID();
showRefundModal = true;
// Pre-fill the refund with the RESIDUAL (payment.amount already
// refunded) and surface the already-refunded total, so a partially
// refunded payment doesn't look fully refundable. The admin booking
// detail doesn't include refunds, so fetch the payment summary.
try {
const res = await apiFetch(`/api/bookings/${bookingId}/payment-summary`);
if (res.ok) {
const data = await res.json();
const alreadyRefunded = (
(data.refunds ?? []) as Array<{
payment_id: string;
amount: number;
status: string;
}>
)
.filter((r) => r.payment_id === payment.id && r.status === 'completed')
.reduce((sum, r) => sum + r.amount, 0);
if (alreadyRefunded > 0) {
// `alreadyRefunded` is PENCE (payment-summary refund rows) while
// payment.amount is POUNDS — convert to pounds before subtracting
// so a partially refunded payment pre-fills the correct residual.
refundAlreadyRefundedPence = alreadyRefunded;
refundAmount = Math.max(0, payment.amount - alreadyRefunded / 100).toFixed(2);
}
}
} catch {
// Non-fatal — the modal stays open with the full amount pre-filled.
}
}
async function processRefund() {
if (!refundAmount || !refundReason.trim()) {
toast.error('Please enter a refund amount and reason');
return;
}
refundLoading = true;
try {
const amountPence = Math.round(parseFloat(refundAmount) * 100);
if (isNaN(amountPence) || amountPence <= 0) {
toast.error('Invalid refund amount');
return;
}
const response = await apiFetch(`/api/admin/payments/${refundPaymentId}/refund`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
amount: amountPence,
reason: refundReason.trim(),
idempotency_key: refundIdempotencyKey
})
});
if (response.ok) {
toast.success('Refund processed');
showRefundModal = false;
fetchBooking();
} else {
const text = await response.text();
toast.error('Failed to process refund: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error processing refund:', err);
toast.error('Network error processing refund');
} finally {
refundLoading = false;
}
}
</script>
<Modal.Root bind:open>
<Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
>
<Modal.Header>
<div class="flex items-center justify-between">
<div>
<Modal.Title class="text-lg font-semibold">Edit Booking</Modal.Title>
{#if booking}
<div class="mt-1 text-sm text-gray-500">ID: {booking.id}</div>
{/if}
</div>
</div>
</Modal.Header>
{#if loading}
<div class="px-4 pb-4 text-center text-sm text-gray-500">Loading booking...</div>
{:else if booking}
<div class="space-y-6 px-4 pb-4">
<!-- Customer Info -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">Customer</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">
{formatUserName(
booking.user?.full_name || '—',
booking.user?.previous_first_name,
booking.user?.previous_last_name
)}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-words" title={booking.user?.email}>
{booking.user?.email || '—'}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{booking.user?.phone || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Time</div>
<div class="font-medium">
{(() => {
const date = parseWallClockDate(booking.start_time);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
})()}
</div>
</div>
</div>
</div>
<!-- Services -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold tracking-wide text-gray-600 uppercase">
Services ({services.length})
</h3>
<Button size="sm" onclick={openAddServiceModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>
Add Service
</Button>
</div>
{#if services.length === 0}
<div
class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500"
>
No services added yet. Click "Add Service" to begin.
</div>
{:else}
<div class="space-y-2">
{#each services as service, index (index)}
<div
class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3"
>
<div class="flex-1">
<div class="font-medium">{service.service_name}</div>
<div class="mt-1 text-sm text-gray-600">
{formatDuration(
service.override_duration_minutes ?? service.duration_minutes ?? 0
)} • £{(service.override_price ?? service.price ?? 0).toFixed(2)}
</div>
{#if service.override_price !== undefined || service.override_duration_minutes !== undefined}
<div class="text-xs text-amber-600">Modified</div>
{/if}
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => openOverrideModal(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"
/>
</svg>
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => confirmRemove(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</Button>
</div>
</div>
{/each}
</div>
<div
class="mt-3 flex items-center justify-between border-t border-gray-200 pt-3 text-sm"
>
<span class="text-gray-600">Total: {formatDuration(totalDuration)}</span>
<span class="font-semibold">£{totalPrice.toFixed(2)}</span>
</div>
{/if}
</div>
<!-- Payments -->
{#if booking?.payments && booking.payments.length > 0}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Payments ({booking.payments.length})
</h3>
<div class="space-y-2">
{#each booking.payments as payment, index (index)}
<div
class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3"
>
<div class="flex-1">
<div class="font-medium">
{payment.payment_type === 'deposit'
? 'Deposit'
: payment.payment_type === 'full'
? 'Full Payment'
: payment.payment_type}
{#if payment.payment_method}
<span class="text-gray-500"> via {payment.payment_method}</span>
{/if}
</div>
<div class="mt-1 text-sm text-gray-600">
<span
class:text-green-600={payment.status === 'completed'}
class:text-amber-600={payment.status === 'pending'}
class:text-red-600={payment.status === 'failed' ||
payment.status === 'refunded'}
>
{payment.status}
</span>
<span class="mx-1">|</span>
£{(payment.amount / 100).toFixed(2)}
{#if payment.invoice_number}
<span class="mx-1">|</span>
<span class="text-gray-500">Inv: {payment.invoice_number}</span>
{/if}
{#if payment.is_vat_applicable && payment.net_amount != null}
<span class="mx-1">|</span>
<span class="text-gray-500">Net: £{payment.net_amount.toFixed(2)}</span>
<span class="mx-1">|</span>
<span class="text-gray-500">
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount?.toFixed(2)}
</span>
{/if}
</div>
<div class="mt-1 text-xs text-gray-400">
{parseWallClockDate(payment.created_at).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</div>
</div>
{#if payment.status === 'completed' && payment.payment_method !== 'discount' && payment.payment_method !== 'on_the_house'}
<Button
size="sm"
variant="outline"
class="text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => openRefundModal(payment)}
>
Refund
</Button>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Notes -->
<div>
<label for="edit-notes" class="mb-2 block text-sm font-medium">Notes</label>
<Textarea
id="edit-notes"
bind:value={notes}
placeholder="Add any notes about this booking..."
rows={3}
class="w-full"
/>
<CharCounter text={notes} />
{#if notes.trim() !== (booking.notes || '')}
<div class="mt-1 text-xs text-emerald-600">Notes will be saved</div>
{/if}
</div>
</div>
{/if}
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button onclick={handleSave} disabled={saving || loading}>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Remove Service Confirmation -->
<AlertDialog.Root bind:open={showRemoveConfirm}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Remove Service?</AlertDialog.Title>
<AlertDialog.Description>
This will remove {serviceToRemoveName} from the booking.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={removeService} class="bg-red-600 hover:bg-red-700">
Remove
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Service Override Submodal -->
<Modal.Root open={showOverrideModal} onOpenChange={(v) => (showOverrideModal = v)}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Edit Service</Modal.Title>
<Modal.Description>
{overrideServiceIndex >= 0 ? services[overrideServiceIndex]?.service_name : ''}
</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label for="override-price" class="mb-1 block text-xs text-gray-600">
Price Override
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span class="text-gray-500">£</span>
</div>
<Input
id="override-price"
type="text"
inputmode="decimal"
value={overridePrice}
oninput={(e) => handlePriceInput(e.currentTarget.value)}
onblur={() => {
if (overridePrice) {
if (!overridePrice.includes('.')) {
const num = parseFloat(overridePrice);
if (!isNaN(num)) {
overridePrice = num.toFixed(2);
}
} else {
const parts = overridePrice.split('.');
if (parts[1].length === 0) {
overridePrice = parts[0] + '.00';
} else if (parts[1].length === 1) {
overridePrice = parts[0] + '.' + parts[1] + '0';
}
}
}
}}
class="no-spin w-full pl-7"
placeholder={overrideOriginalPrice.toFixed(2)}
/>
</div>
<div class="mt-1 flex items-center justify-between">
<div class="text-xs text-gray-500">
Original: £{overrideOriginalPrice.toFixed(2)}
</div>
{#if overridePrice !== '' && parseFloat(overridePrice) !== overrideOriginalPrice}
<div class="text-xs font-medium text-emerald-600">Changed</div>
{/if}
</div>
</div>
<div>
<label for="override-duration" class="mb-1 block text-xs text-gray-600">
Duration Override (min)
</label>
<div class="relative">
<Input
id="override-duration"
type="number"
min="1"
step="1"
value={overrideDuration}
oninput={(e: Event) => handleDurationInput((e.target as HTMLInputElement).value)}
class="no-spin w-full"
placeholder={overrideOriginalDuration.toString()}
/>
</div>
<div class="mt-1 flex items-center justify-between">
<div class="text-xs text-gray-500">
Original: {overrideOriginalDuration} min
</div>
{#if overrideDuration !== '' && parseInt(overrideDuration) !== overrideOriginalDuration}
<div class="text-xs font-medium text-emerald-600">Changed</div>
{/if}
</div>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => (showOverrideModal = false)}>Cancel</Button>
<Button onclick={saveOverride}>Save</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Add Service Submodal -->
<Modal.Root open={showAddServiceModal} onOpenChange={(v) => (showAddServiceModal = v)}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Add Service</Modal.Title>
<Modal.Description>Select a service to add to this booking.</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
{#if maxAvailableDuration !== null}
<div class="rounded-md border border-blue-200 bg-blue-50 p-3 text-sm">
<div class="font-medium text-blue-800">Available time: {maxAvailableDuration} min</div>
<div class="text-xs text-blue-600">
Current total: {totalDuration} min
</div>
</div>
{/if}
{#if loadingServices}
<div class="text-center text-sm text-gray-500">Loading services...</div>
{:else if filteredServices.length === 0}
<div
class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500"
>
{#if availableServices.length === 0}
No services available.
{:else}
All services have been added or exceed available time.
{/if}
</div>
{:else}
<div class="space-y-2">
{#each filteredServices as service (service.id)}
<button
type="button"
class="w-full rounded-md border border-gray-300 bg-white p-3 text-left transition-colors hover:bg-gray-50"
onclick={() => addService(service)}
>
<div class="font-medium">{service.name}</div>
<div class="mt-1 text-sm text-gray-600">
{service.duration_minutes} min • £{service.price.toFixed(2)}
</div>
</button>
{/each}
</div>
{/if}
</div>
<Modal.Footer class="flex items-center justify-end">
<Button onclick={() => (showAddServiceModal = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Refund Dialog -->
<Modal.Root open={showRefundModal} onOpenChange={(v) => (showRefundModal = v)}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Process Refund</Modal.Title>
<Modal.Description>Enter the refund amount and reason.</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<div>
<label for="refund-amount" class="mb-1 block text-xs text-gray-600"> Refund Amount </label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span class="text-gray-500">£</span>
</div>
<Input
id="refund-amount"
type="text"
inputmode="decimal"
bind:value={refundAmount}
class="no-spin w-full pl-7"
placeholder="0.00"
/>
</div>
{#if refundAlreadyRefundedPence > 0}
<p class="mt-1 text-xs text-gray-500">
Already refunded: £{(refundAlreadyRefundedPence / 100).toFixed(2)} — the amount above is the
remaining balance.
</p>
{/if}
</div>
<div>
<label for="refund-reason" class="mb-1 block text-xs text-gray-600">
Reason (required)
</label>
<Textarea
id="refund-reason"
bind:value={refundReason}
placeholder="Enter reason for refund..."
rows={3}
class="w-full"
/>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => (showRefundModal = false)}>Cancel</Button>
<Button
onclick={processRefund}
disabled={refundLoading || !refundAmount || !refundReason.trim()}
>
{refundLoading ? 'Processing...' : 'Confirm Refund'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<style>
:global(input[type='number']) {
-moz-appearance: textfield;
appearance: textfield;
}
:global(input[type='number']::-webkit-outer-spin-button),
:global(input[type='number']::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
</style>