- 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)
922 lines
29 KiB
Svelte
922 lines
29 KiB
Svelte
<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>
|