Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
841 lines
26 KiB
Svelte
841 lines
26 KiB
Svelte
<script lang="ts">
|
|
import { authStore } from '$lib/stores/auth.svelte';
|
|
import { SvelteDate } from 'svelte/reactivity';
|
|
import { toast } from 'svelte-sonner';
|
|
import { formatDuration } from '$lib/utils/format';
|
|
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 type { Booking, BookingService, 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);
|
|
|
|
$effect(() => {
|
|
if (open && bookingId) {
|
|
fetchBooking();
|
|
}
|
|
});
|
|
|
|
async function fetchBooking() {
|
|
loading = true;
|
|
try {
|
|
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
}
|
|
});
|
|
|
|
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
|
|
}
|
|
: 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) => ({
|
|
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: ' + text);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching booking:', err);
|
|
toast.error('Network error loading booking');
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
let totalDuration = $derived(
|
|
services.reduce((sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0), 0)
|
|
);
|
|
|
|
let 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 fetch('/api/services', {
|
|
headers: {
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
let maxAvailableDuration = $derived.by(() => {
|
|
if (!booking?.start_time || !nextAppointmentStart) return null;
|
|
|
|
const bookingStart = new SvelteDate(booking.start_time).getTime();
|
|
const currentDurationMs = totalDuration * 60 * 1000;
|
|
const bookingEnd = bookingStart + currentDurationMs;
|
|
const nextStart = new SvelteDate(nextAppointmentStart).getTime();
|
|
|
|
const availableMs = nextStart - bookingEnd;
|
|
return Math.max(0, Math.floor(availableMs / (60 * 1000)));
|
|
});
|
|
|
|
let 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 fetch(`/api/admin/bookings/${bookingId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
},
|
|
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: ' + text);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error saving booking:', err);
|
|
toast.error('Network error saving booking');
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
function openRefundModal(paymentId: string, amountPence: number) {
|
|
refundPaymentId = paymentId;
|
|
refundAmount = (amountPence / 100).toFixed(2);
|
|
refundReason = '';
|
|
showRefundModal = true;
|
|
}
|
|
|
|
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 fetch(`/api/admin/payments/${refundPaymentId}/refund`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
amount: amountPence,
|
|
reason: refundReason.trim()
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast.success('Refund processed');
|
|
showRefundModal = false;
|
|
fetchBooking();
|
|
} else {
|
|
const text = await response.text();
|
|
toast.error('Failed to process refund: ' + 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">{booking.user?.full_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 = new SvelteDate(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}
|
|
</div>
|
|
<div class="mt-1 text-xs text-gray-400">
|
|
{new SvelteDate(payment.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
|
</div>
|
|
</div>
|
|
{#if payment.status === 'completed'}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
class="text-red-600 hover:bg-red-50 hover:text-red-700"
|
|
onclick={() => openRefundModal(payment.id, payment.amount)}
|
|
>
|
|
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"
|
|
/>
|
|
{#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 class="z-[60]">
|
|
<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.target.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) => handleDurationInput(e.target.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>
|
|
</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>
|