feat(frontend): update admin components
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -2,12 +2,15 @@
|
|||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||||
|
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||||
|
import type { BookingDiscount } from '$lib/types/booking';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
price?: number;
|
price?: number;
|
||||||
duration_minutes?: number;
|
duration_minutes?: number;
|
||||||
}>;
|
}>;
|
||||||
|
discounts?: BookingDiscount[];
|
||||||
};
|
};
|
||||||
onApproved: () => void;
|
onApproved: () => void;
|
||||||
}
|
}
|
||||||
@@ -328,7 +332,7 @@
|
|||||||
onApproved();
|
onApproved();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error('Failed to confirm: ' + text, { id: loadingToast });
|
toast.error('Failed to confirm: ' + sanitizeText(text), { id: loadingToast });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error confirming booking:', err);
|
console.error('Error confirming booking:', err);
|
||||||
@@ -358,7 +362,7 @@
|
|||||||
onApproved();
|
onApproved();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error('Failed to decline: ' + text, { id: loadingToast });
|
toast.error('Failed to decline: ' + sanitizeText(text), { id: loadingToast });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error declining booking:', err);
|
console.error('Error declining booking:', err);
|
||||||
@@ -371,7 +375,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal.Root bind:open>
|
<Modal.Root bind:open>
|
||||||
<Modal.Content class="!z-[70] max-h-[90vh] max-w-2xl overflow-y-auto">
|
<Modal.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-lg md:max-w-2xl">
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title class="text-lg font-semibold">Approve Booking</Modal.Title>
|
<Modal.Title class="text-lg font-semibold">Approve Booking</Modal.Title>
|
||||||
<Modal.Description>
|
<Modal.Description>
|
||||||
@@ -452,35 +456,72 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Customer Contact Info -->
|
<!-- Appointment Details -->
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<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">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Customer Contact
|
Appointment Details
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-2">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Name</div>
|
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
|
||||||
<div class="font-medium">{booking.user?.full_name || '—'}</div>
|
<div class="font-medium">{getBookingDateTime()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
{#if getTotalDuration() > 0}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Phone</div>
|
<div class="text-xs text-gray-500">Duration</div>
|
||||||
<div class="font-medium">{booking.user?.phone || '—'}</div>
|
<div class="font-medium">{formatDuration(getTotalDuration())}</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs text-gray-500">Email</div>
|
|
||||||
<div class="font-medium break-all">{booking.user?.email || '—'}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">Created</div>
|
||||||
|
<div class="text-sm">{formatDateTime(booking.created_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if booking.notes}
|
||||||
|
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||||
|
<div class="mb-1 text-xs font-semibold text-amber-800">Booking Notes</div>
|
||||||
|
<div class="text-sm text-amber-900">{booking.notes}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Booking Date & Time -->
|
<!-- Customer Information -->
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Booking Date & Time
|
Customer Information
|
||||||
</h3>
|
</h3>
|
||||||
<div class="text-lg font-medium">{getBookingDateTime()}</div>
|
|
||||||
|
<div class="mb-4 flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold">{booking.user?.full_name || '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">Email</div>
|
||||||
|
{#if booking.user?.email}
|
||||||
|
<a
|
||||||
|
href="mailto:{booking.user.email}"
|
||||||
|
class="font-medium break-words text-blue-600 hover:underline"
|
||||||
|
>{booking.user.email}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<div class="font-medium">—</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">Phone</div>
|
||||||
|
{#if booking.user?.phone}
|
||||||
|
<a
|
||||||
|
href="tel:{booking.user.phone}"
|
||||||
|
class="font-medium text-blue-600 hover:underline">{booking.user.phone}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<div class="font-medium">—</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Booking Notes -->
|
<!-- Booking Notes -->
|
||||||
@@ -503,8 +544,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Service Overrides -->
|
<!-- Services & Pricing -->
|
||||||
<div>
|
<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">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Services & Pricing
|
Services & Pricing
|
||||||
</h3>
|
</h3>
|
||||||
@@ -530,7 +571,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
value={serviceOverrides[service.service_id].price}
|
value={serviceOverrides[service.service_id].price}
|
||||||
oninput={(e) => handlePriceInput(service.service_id, e.target.value)}
|
oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)}
|
||||||
onblur={() => {
|
onblur={() => {
|
||||||
// Format to 2 decimal places on blur if needed
|
// Format to 2 decimal places on blur if needed
|
||||||
const val = serviceOverrides[service.service_id].price;
|
const val = serviceOverrides[service.service_id].price;
|
||||||
@@ -582,7 +623,7 @@
|
|||||||
min="1"
|
min="1"
|
||||||
step="1"
|
step="1"
|
||||||
value={serviceOverrides[service.service_id].duration}
|
value={serviceOverrides[service.service_id].duration}
|
||||||
oninput={(e) => handleDurationInput(service.service_id, e.target.value)}
|
oninput={(e) => handleDurationInput(service.service_id, e.currentTarget.value)}
|
||||||
class="no-spin w-full"
|
class="no-spin w-full"
|
||||||
placeholder={service.duration_minutes?.toString() || '60'}
|
placeholder={service.duration_minutes?.toString() || '60'}
|
||||||
/>
|
/>
|
||||||
@@ -602,13 +643,25 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if booking.discounts && booking.discounts.length > 0}
|
||||||
|
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
|
||||||
|
<p class="font-medium">Applied Discounts</p>
|
||||||
|
{#each booking.discounts as d}
|
||||||
|
<p class="mt-1">
|
||||||
|
- {d.discount_source === 'loyalty' ? 'Loyalty Stamp Card' : d.campaign_name || 'Promo Campaign'}
|
||||||
|
({d.discount_percent}% off): -£{d.discount_amount.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal.Footer class="flex items-center justify-between gap-2">
|
<Modal.Footer class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-4 text-sm text-gray-600">
|
<div class="flex items-center gap-4 text-sm text-gray-600">
|
||||||
<span class="font-medium">Total: £{getTotalCost().toFixed(2)}</span>
|
<span class="text-lg font-semibold">£{getTotalCost().toFixed(2)}</span>
|
||||||
<span class="text-gray-400">|</span>
|
<span class="text-gray-300">·</span>
|
||||||
<span>{getTotalDuration()} min</span>
|
<span class="text-sm text-gray-500">{getTotalDuration()} min</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -648,16 +701,3 @@
|
|||||||
</AlertDialog.Content>
|
</AlertDialog.Content>
|
||||||
</AlertDialog.Root>
|
</AlertDialog.Root>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Hide number input arrows for all number inputs in the component */
|
|
||||||
: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>
|
|
||||||
|
|||||||
@@ -730,7 +730,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
showCustomCreateForm = false;
|
showCustomCreateForm = false;
|
||||||
newCustomService = { name: '', price: '', duration_minutes: '' };
|
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||||
customServiceErrors = { name: '', price: '', duration_minutes: '' };
|
customServiceErrors = { name: '', price: '', duration_minutes: '' };
|
||||||
toast.success('Custom service created and added');
|
toast.success('Custom service created and added');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||||
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
|
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
|
||||||
import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte';
|
import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte';
|
||||||
import { formatDuration, formatDateTime, calculateAge } from '$lib/utils/format';
|
import { formatDuration, formatDateTime, calculateAge } from '$lib/utils/format';
|
||||||
|
import { POLICY } from '$lib/constants/policy';
|
||||||
import type { Booking, BookingService, BookingDiscount, Payment } from '$lib/types/booking';
|
import type { Booking, BookingService, BookingDiscount, Payment } from '$lib/types/booking';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,6 +23,63 @@
|
|||||||
let selectedBooking = $state<Booking | null>(null);
|
let selectedBooking = $state<Booking | null>(null);
|
||||||
let showApprovalModal = $state(false);
|
let showApprovalModal = $state(false);
|
||||||
let showRescheduleModal = $state(false);
|
let showRescheduleModal = $state(false);
|
||||||
|
let showCancelModal = $state(false);
|
||||||
|
let forgiveFeesCancel = $state(false);
|
||||||
|
let forgiveNoShowCancel = $state(false);
|
||||||
|
let cancelling = $state(false);
|
||||||
|
|
||||||
|
// Derived values for cancel confirmation
|
||||||
|
let totalPaid = $derived(
|
||||||
|
(selectedBooking?.payments ?? [])
|
||||||
|
.filter((p) => p.payment_method !== 'discount' && p.status === 'completed')
|
||||||
|
.reduce((sum, p) => sum + p.amount, 0)
|
||||||
|
);
|
||||||
|
let hoursUntilAppt = $derived(
|
||||||
|
selectedBooking
|
||||||
|
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
||||||
|
: Infinity
|
||||||
|
);
|
||||||
|
let protectedDeposit = $derived(
|
||||||
|
selectedBooking ? Math.min(totalPaid, selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT) : 0
|
||||||
|
);
|
||||||
|
let estimatedRefund = $derived(
|
||||||
|
forgiveFeesCancel
|
||||||
|
? totalPaid
|
||||||
|
: hoursUntilAppt > POLICY.FULL_REFUND_THRESHOLD_HOURS
|
||||||
|
? totalPaid
|
||||||
|
: hoursUntilAppt >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS
|
||||||
|
? Math.max(0, totalPaid - protectedDeposit)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
async function handleAdminCancel() {
|
||||||
|
if (!selectedBooking) return;
|
||||||
|
cancelling = true;
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (forgiveFeesCancel) body.forgive_fees = true;
|
||||||
|
if (forgiveNoShowCancel) body.forgive_noshow = true;
|
||||||
|
const response = await fetch(`/api/admin/bookings/${selectedBooking.id}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success('Booking cancelled');
|
||||||
|
showCancelModal = false;
|
||||||
|
fetchBookingDetails();
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
toast.error('Failed to cancel: ' + text);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Network error');
|
||||||
|
} finally {
|
||||||
|
cancelling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate total duration from services
|
// Calculate total duration from services
|
||||||
// IMPORTANT: Use override_duration_minutes when present — services may have been
|
// IMPORTANT: Use override_duration_minutes when present — services may have been
|
||||||
@@ -242,7 +303,7 @@
|
|||||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Appointment Details
|
Appointment Details
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
|
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
|
||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
@@ -333,7 +394,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Email</div>
|
<div class="text-xs text-gray-500">Email</div>
|
||||||
{#if selectedBooking.user?.email}
|
{#if selectedBooking.user?.email}
|
||||||
@@ -560,7 +621,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||||
{#if selectedBooking && !['completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit'].includes(selectedBooking.status)}
|
{#if selectedBooking && !['completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed'].includes(selectedBooking.status)}
|
||||||
|
<Button variant="destructive" size="sm" onclick={() => (showCancelModal = true)}>
|
||||||
|
Cancel Booking
|
||||||
|
</Button>
|
||||||
<Button variant="outline" onclick={() => (showRescheduleModal = true)}>
|
<Button variant="outline" onclick={() => (showRescheduleModal = true)}>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -606,3 +670,102 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedBooking && showCancelModal}
|
||||||
|
<Modal.Root bind:open={showCancelModal}>
|
||||||
|
<Modal.Content class="!z-[80] max-w-[calc(100%-2rem)] sm:max-w-md">
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>Cancel Booking</Modal.Title>
|
||||||
|
<Modal.Description>
|
||||||
|
{@const apptHours = Math.round(hoursUntilAppt)}
|
||||||
|
{#if totalPaid > 0}
|
||||||
|
<p>
|
||||||
|
This booking has payments totalling <span class="font-semibold">£{totalPaid.toFixed(2)}</span>.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>Are you sure you want to cancel this booking?</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if totalPaid > 0}
|
||||||
|
<div class="mt-3 rounded-md border p-3 text-sm {apptHours < 24
|
||||||
|
? 'border-red-200 bg-red-50 text-red-800'
|
||||||
|
: 'border-amber-200 bg-amber-50 text-amber-800'}">
|
||||||
|
<p class="font-medium">
|
||||||
|
{apptHours > POLICY.FULL_REFUND_THRESHOLD_HOURS ? `Full Refund — Over ${POLICY.FULL_REFUND_THRESHOLD_HOURS}h notice` : apptHours >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS ? `Partial Refund — ${apptHours}h notice` : `No Refund — Under ${POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}h notice`}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
{apptHours > POLICY.FULL_REFUND_THRESHOLD_HOURS ? `You've given over ${POLICY.FULL_REFUND_THRESHOLD_HOURS} hours' notice. £${totalPaid.toFixed(2)} will be refunded in full.` : apptHours >= POLICY.PARTIAL_REFUND_THRESHOLD_HOURS ? `You've paid £${totalPaid.toFixed(2)}. Up to ${POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of the total (£${(selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT).toFixed(2)}) counts as protected deposit. £${protectedDeposit.toFixed(2)} will be retained and £${estimatedRefund.toFixed(2)} will be refunded.` : `Under ${POLICY.PARTIAL_REFUND_THRESHOLD_HOURS}h notice. The full £${totalPaid.toFixed(2)} is retained to cover the lost slot.`}
|
||||||
|
</p>
|
||||||
|
<details class="mt-1 text-xs text-gray-500">
|
||||||
|
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
||||||
|
<p class="mt-1">
|
||||||
|
{apptHours > 72 ? "Over 72 hours' notice means no deposit protection applies — a full refund is given regardless." : apptHours >= 24 ? "Between 24–72 hours' notice, up to 50% of the total (" + '£' + (selectedBooking.total_amount * 0.5).toFixed(2) + ") is treated as a protected deposit to cover the lost slot. The remaining balance above that is refunded." : "Under 24 hours' notice, the full amount paid (" + '£' + totalPaid.toFixed(2) + ") is retained. This also counts as a no-show toward deposit obligations."}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
<label class="mt-2 flex items-center gap-2 cursor-pointer">
|
||||||
|
<Checkbox bind:checked={forgiveFeesCancel} />
|
||||||
|
<span class="text-xs">Forgive fees — refund the <strong>full</strong> amount paid</span>
|
||||||
|
</label>
|
||||||
|
<details class="ml-6 text-xs text-gray-500">
|
||||||
|
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
|
||||||
|
<p class="mt-1">"We've applied a full refund to this booking as a goodwill gesture. No deposit protection will be applied."</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<p class="mt-3 font-medium">No-Show Record</p>
|
||||||
|
<p class="mt-1">This cancellation counts as a no-show toward deposit obligations.</p>
|
||||||
|
<details class="mt-1 text-xs text-gray-500">
|
||||||
|
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
||||||
|
<p class="mt-1">"This cancellation will count as a no-show toward your booking history. Two no-shows within 6 months would require a deposit on future bookings to secure your appointment."</p>
|
||||||
|
</details>
|
||||||
|
<label class="mt-2 flex items-center gap-2 cursor-pointer">
|
||||||
|
<Checkbox bind:checked={forgiveNoShowCancel} />
|
||||||
|
<span class="text-xs">Forgive no-show — this cancellation will <strong>not</strong> count toward deposit obligations</span>
|
||||||
|
</label>
|
||||||
|
<details class="ml-6 text-xs text-gray-500">
|
||||||
|
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
|
||||||
|
<p class="mt-1">"We've waived the no-show record for this cancellation so your deposit obligations remain unaffected."</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{:else if apptHours < 24}
|
||||||
|
<div class="mt-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||||
|
<p class="font-semibold text-red-900">No-Show Warning — {apptHours}h before appointment</p>
|
||||||
|
<p class="mt-1">This booking has no payments but is under 24 hours' notice. Cancelling counts as a no-show toward deposit obligations.</p>
|
||||||
|
<details class="mt-1 text-xs text-gray-500">
|
||||||
|
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
||||||
|
<p class="mt-1">"This cancellation will count as a no-show toward your booking history. Two no-shows within 6 months would require a deposit on future bookings to secure your appointment."</p>
|
||||||
|
</details>
|
||||||
|
<label class="mt-2 flex items-center gap-2 cursor-pointer">
|
||||||
|
<Checkbox bind:checked={forgiveNoShowCancel} />
|
||||||
|
<span class="text-xs">Forgive no-show — this cancellation will <strong>not</strong> count toward deposit obligations</span>
|
||||||
|
</label>
|
||||||
|
<details class="ml-6 mt-1 text-xs text-gray-500">
|
||||||
|
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
|
||||||
|
<p class="mt-1">"We've waived the no-show record for this cancellation. Your deposit obligations will not be affected."</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
|
||||||
|
<p class="font-medium">Clean Cancellation</p>
|
||||||
|
<p class="mt-1">This booking has no payments. The booking will be removed cleanly.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-3 text-xs text-gray-500">
|
||||||
|
<PolicyPopover>
|
||||||
|
{#snippet trigger()}
|
||||||
|
<span class="underline">View full cancellation policy →</span>
|
||||||
|
{/snippet}
|
||||||
|
</PolicyPopover>
|
||||||
|
</p>
|
||||||
|
</Modal.Description>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Footer class="flex gap-2">
|
||||||
|
<Button variant="outline" onclick={() => (showCancelModal = false)} disabled={cancelling}>
|
||||||
|
Keep Booking
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onclick={handleAdminCancel} disabled={cancelling}>
|
||||||
|
{cancelling ? 'Cancelling...' : 'Yes, Cancel'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -167,9 +167,9 @@
|
|||||||
completed: 'bg-green-100 text-green-800',
|
completed: 'bg-green-100 text-green-800',
|
||||||
client_cancelled: 'bg-red-100 text-red-800',
|
client_cancelled: 'bg-red-100 text-red-800',
|
||||||
we_cancelled: 'bg-rose-100 text-rose-800',
|
we_cancelled: 'bg-rose-100 text-rose-800',
|
||||||
're-schedule': 'bg-purple-100 text-purple-800',
|
|
||||||
no_show: 'bg-gray-100 text-gray-800',
|
no_show: 'bg-gray-100 text-gray-800',
|
||||||
no_deposit: 'bg-orange-100 text-orange-800'
|
pending_release: 'bg-orange-100 text-orange-800',
|
||||||
|
deposit_lapsed: 'bg-yellow-100 text-yellow-800'
|
||||||
};
|
};
|
||||||
return `${baseClasses} ${statusMap[status] || 'bg-gray-100 text-gray-800'}`;
|
return `${baseClasses} ${statusMap[status] || 'bg-gray-100 text-gray-800'}`;
|
||||||
}
|
}
|
||||||
@@ -182,9 +182,9 @@
|
|||||||
completed: 'bg-green-600',
|
completed: 'bg-green-600',
|
||||||
client_cancelled: 'bg-red-600',
|
client_cancelled: 'bg-red-600',
|
||||||
we_cancelled: 'bg-rose-600',
|
we_cancelled: 'bg-rose-600',
|
||||||
're-schedule': 'bg-purple-600',
|
|
||||||
no_show: 'bg-gray-600',
|
no_show: 'bg-gray-600',
|
||||||
no_deposit: 'bg-orange-600'
|
pending_release: 'bg-orange-600',
|
||||||
|
deposit_lapsed: 'bg-yellow-600'
|
||||||
};
|
};
|
||||||
return `mr-1 h-1.5 w-1.5 rounded-full ${statusMap[status] || 'bg-gray-600'}`;
|
return `mr-1 h-1.5 w-1.5 rounded-full ${statusMap[status] || 'bg-gray-600'}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
@@ -154,7 +155,7 @@
|
|||||||
await fetchServices();
|
await fetchServices();
|
||||||
} else {
|
} else {
|
||||||
const err = await response.text();
|
const err = await response.text();
|
||||||
toast.error(`Failed: ${err}`);
|
toast.error('Failed: ' + sanitizeText(err));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Network error');
|
toast.error('Network error');
|
||||||
@@ -177,7 +178,8 @@
|
|||||||
} else if (response.status === 409) {
|
} else if (response.status === 409) {
|
||||||
toast.error('A service with this name already exists');
|
toast.error('A service with this name already exists');
|
||||||
} else {
|
} else {
|
||||||
toast.error(`Failed: ${await response.text()}`);
|
const errText = await response.text();
|
||||||
|
toast.error('Failed: ' + sanitizeText(errText));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Network error');
|
toast.error('Network error');
|
||||||
@@ -197,7 +199,8 @@
|
|||||||
} else if (response.status === 409) {
|
} else if (response.status === 409) {
|
||||||
toast.error('Cannot delete: used in bookings. Promote first.');
|
toast.error('Cannot delete: used in bookings. Promote first.');
|
||||||
} else {
|
} else {
|
||||||
toast.error(`Failed: ${await response.text()}`);
|
const errText = await response.text();
|
||||||
|
toast.error('Failed: ' + sanitizeText(errText));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Network error');
|
toast.error('Network error');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
|
||||||
|
|
||||||
type Campaign = {
|
type Campaign = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -282,28 +283,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadge(status: string) {
|
|
||||||
const map: Record<
|
|
||||||
string,
|
|
||||||
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
|
||||||
> = {
|
|
||||||
draft: { label: 'Draft', variant: 'secondary' },
|
|
||||||
active: { label: 'Active', variant: 'default' },
|
|
||||||
completed: { label: 'Completed', variant: 'outline' },
|
|
||||||
cancelled: { label: 'Cancelled', variant: 'destructive' }
|
|
||||||
};
|
|
||||||
const s = map[status] || map.draft;
|
|
||||||
return `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
||||||
s.variant === 'default'
|
|
||||||
? 'bg-emerald-100 text-emerald-800'
|
|
||||||
: s.variant === 'secondary'
|
|
||||||
? 'bg-gray-100 text-gray-800'
|
|
||||||
: s.variant === 'destructive'
|
|
||||||
? 'bg-red-100 text-red-800'
|
|
||||||
: 'bg-blue-100 text-blue-800'
|
|
||||||
}">${s.label}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeLabel(c: Campaign): string {
|
function typeLabel(c: Campaign): string {
|
||||||
if (c.campaign_type === 'time_based') return 'Time-based';
|
if (c.campaign_type === 'time_based') return 'Time-based';
|
||||||
return MILESTONE_LABELS[c.milestone_type] || c.milestone_type;
|
return MILESTONE_LABELS[c.milestone_type] || c.milestone_type;
|
||||||
@@ -361,7 +340,7 @@
|
|||||||
<td class="py-3 font-medium">{c.name}</td>
|
<td class="py-3 font-medium">{c.name}</td>
|
||||||
<td class="py-3 text-gray-600">{typeLabel(c)}</td>
|
<td class="py-3 text-gray-600">{typeLabel(c)}</td>
|
||||||
<td class="py-3">{c.discount_percent}%</td>
|
<td class="py-3">{c.discount_percent}%</td>
|
||||||
<td class="py-3">{@html statusBadge(c.status)}</td>
|
<td class="py-3"><StatusBadge status={c.status} /></td>
|
||||||
<td class="py-3 text-gray-600">{redemptionDisplay(c)}</td>
|
<td class="py-3 text-gray-600">{redemptionDisplay(c)}</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
@@ -412,7 +391,7 @@
|
|||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<div class="mb-2 flex items-start justify-between">
|
<div class="mb-2 flex items-start justify-between">
|
||||||
<div class="font-medium">{c.name}</div>
|
<div class="font-medium">{c.name}</div>
|
||||||
{@html statusBadge(c.status)}
|
<StatusBadge status={c.status} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3 grid grid-cols-2 gap-2 text-xs text-gray-600">
|
<div class="mb-3 grid grid-cols-2 gap-2 text-xs text-gray-600">
|
||||||
<div>Type: {typeLabel(c)}</div>
|
<div>Type: {typeLabel(c)}</div>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
override_price: s.override_price,
|
override_price: s.override_price,
|
||||||
override_duration_minutes: s.override_duration_minutes
|
override_duration_minutes: s.override_duration_minutes
|
||||||
})),
|
})),
|
||||||
payments: (data.payments || []).map((p) => ({
|
payments: (data.payments || []).map((p: any) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
booking_id: p.booking_id,
|
booking_id: p.booking_id,
|
||||||
payment_type: p.payment_type,
|
payment_type: p.payment_type,
|
||||||
@@ -675,7 +675,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
value={overridePrice}
|
value={overridePrice}
|
||||||
oninput={(e) => handlePriceInput(e.target.value)}
|
oninput={(e) => handlePriceInput(e.currentTarget.value)}
|
||||||
onblur={() => {
|
onblur={() => {
|
||||||
if (overridePrice) {
|
if (overridePrice) {
|
||||||
if (!overridePrice.includes('.')) {
|
if (!overridePrice.includes('.')) {
|
||||||
@@ -718,7 +718,7 @@
|
|||||||
min="1"
|
min="1"
|
||||||
step="1"
|
step="1"
|
||||||
value={overrideDuration}
|
value={overrideDuration}
|
||||||
oninput={(e) => handleDurationInput(e.target.value)}
|
oninput={(e: Event) => handleDurationInput((e.target as HTMLInputElement).value)}
|
||||||
class="no-spin w-full"
|
class="no-spin w-full"
|
||||||
placeholder={overrideOriginalDuration.toString()}
|
placeholder={overrideOriginalDuration.toString()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -489,14 +489,14 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function handleEphemeralCardNumberInput(e: Event) {
|
function handleEphemeralCardNumberInput(e: Event) {
|
||||||
const target = e.currentTarget;
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
const clean = target.value.replace(/\D/g, '');
|
const clean = target.value.replace(/\D/g, '');
|
||||||
const formatted = clean.match(/.{1,4}/g)?.join(' ') || clean;
|
const formatted = clean.match(/.{1,4}/g)?.join(' ') || clean;
|
||||||
ephemeralCardNumber = formatted.slice(0, 19);
|
ephemeralCardNumber = formatted.slice(0, 19);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEphemeralExpiryInput(e: Event) {
|
function handleEphemeralExpiryInput(e: Event) {
|
||||||
const target = e.currentTarget;
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
const clean = target.value.replace(/\D/g, '');
|
const clean = target.value.replace(/\D/g, '');
|
||||||
if (clean.length > 2) {
|
if (clean.length > 2) {
|
||||||
ephemeralCardExpiry = clean.slice(0, 2) + '/' + clean.slice(2, 4);
|
ephemeralCardExpiry = clean.slice(0, 2) + '/' + clean.slice(2, 4);
|
||||||
@@ -506,7 +506,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEphemeralCvcInput(e: Event) {
|
function handleEphemeralCvcInput(e: Event) {
|
||||||
const target = e.currentTarget;
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
ephemeralCardCVC = target.value.replace(/\D/g, '').slice(0, 4);
|
ephemeralCardCVC = target.value.replace(/\D/g, '').slice(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||||
|
|
||||||
// shadcn-svelte components
|
// shadcn-svelte components
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -36,29 +37,11 @@
|
|||||||
let exceptionGroupsLoading = $state(true);
|
let exceptionGroupsLoading = $state(true);
|
||||||
let savingHours = $state(false);
|
let savingHours = $state(false);
|
||||||
|
|
||||||
let isFormValid = $derived(
|
|
||||||
exceptionDraft.name.trim() !== '' && exceptionDraft.weekStarts.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
let formErrors = $state({
|
let formErrors = $state({
|
||||||
name: '',
|
name: '',
|
||||||
weeks: ''
|
weeks: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
function validateNameField() {
|
|
||||||
formErrors.name = exceptionDraft.name.trim() === '' ? 'Name is required' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateWeeksField() {
|
|
||||||
formErrors.weeks =
|
|
||||||
exceptionDraft.weekStarts.length === 0 ? 'At least one week must be selected' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAllFields() {
|
|
||||||
validateNameField();
|
|
||||||
validateWeeksField();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exception modal state
|
// Exception modal state
|
||||||
let showExceptionModal = $state(false);
|
let showExceptionModal = $state(false);
|
||||||
let exceptionDraft = $state<ExceptionGroup>({
|
let exceptionDraft = $state<ExceptionGroup>({
|
||||||
@@ -76,6 +59,24 @@
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let isFormValid = $derived(
|
||||||
|
exceptionDraft.name.trim() !== '' && exceptionDraft.weekStarts.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
function validateNameField() {
|
||||||
|
formErrors.name = exceptionDraft.name.trim() === '' ? 'Name is required' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWeeksField() {
|
||||||
|
formErrors.weeks =
|
||||||
|
exceptionDraft.weekStarts.length === 0 ? 'At least one week must be selected' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAllFields() {
|
||||||
|
validateNameField();
|
||||||
|
validateWeeksField();
|
||||||
|
}
|
||||||
|
|
||||||
let weekRangeFrom = $state('');
|
let weekRangeFrom = $state('');
|
||||||
let weekRangeTo = $state('');
|
let weekRangeTo = $state('');
|
||||||
|
|
||||||
@@ -147,13 +148,13 @@
|
|||||||
if (data === null || data.length === 0) {
|
if (data === null || data.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
exceptionGroups = data.map((group) => ({
|
exceptionGroups = data.map((group: any) => ({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
name: group.name,
|
name: group.name,
|
||||||
description: group.description,
|
description: group.description,
|
||||||
weekStarts: group.weekStarts || [],
|
weekStarts: group.weekStarts || [],
|
||||||
hours:
|
hours:
|
||||||
group.hours?.map((h) => ({
|
group.hours?.map((h: any) => ({
|
||||||
id: h.id,
|
id: h.id,
|
||||||
weekday: h.weekday,
|
weekday: h.weekday,
|
||||||
start_time: formatTime(h.startTime),
|
start_time: formatTime(h.startTime),
|
||||||
@@ -213,7 +214,7 @@
|
|||||||
await fetchExceptionGroups();
|
await fetchExceptionGroups();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error('Failed to create: ' + text, { id: loadingToast });
|
toast.error('Failed to create: ' + sanitizeText(text), { id: loadingToast });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating exception group:', err);
|
console.error('Error creating exception group:', err);
|
||||||
@@ -244,7 +245,7 @@
|
|||||||
await fetchExceptionGroups();
|
await fetchExceptionGroups();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error('Failed to delete: ' + text, { id: loadingToast });
|
toast.error('Failed to delete: ' + sanitizeText(text), { id: loadingToast });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error deleting exception group:', err);
|
console.error('Error deleting exception group:', err);
|
||||||
|
|||||||
@@ -50,8 +50,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateNoticeDurationField() {
|
function validateNoticeDurationField() {
|
||||||
const val = formData.notice_duration_hours;
|
const val = formData.notice_duration_hours as number | null | undefined;
|
||||||
if (val === null || val === undefined || val === '') {
|
if (val === null || val === undefined) {
|
||||||
formErrors.notice_duration = 'Must be a positive number';
|
formErrors.notice_duration = 'Must be a positive number';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -64,8 +64,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateExpiryField() {
|
function validateExpiryField() {
|
||||||
const val = formData.expiry_months;
|
const val = formData.expiry_months as number | null | undefined;
|
||||||
if (val === null || val === undefined || val === '') {
|
if (val === null || val === undefined) {
|
||||||
formErrors.expiry = 'Must be at least 1 month';
|
formErrors.expiry = 'Must be at least 1 month';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -90,11 +90,8 @@
|
|||||||
!formErrors.expiry &&
|
!formErrors.expiry &&
|
||||||
formData.notice_duration_hours !== null &&
|
formData.notice_duration_hours !== null &&
|
||||||
formData.notice_duration_hours !== undefined &&
|
formData.notice_duration_hours !== undefined &&
|
||||||
formData.notice_duration_hours !== '' &&
|
(formData.notice_duration_hours as number) >= 0 &&
|
||||||
Number(formData.notice_duration_hours) >= 0 &&
|
|
||||||
formData.expiry_months !== null &&
|
|
||||||
formData.expiry_months !== undefined &&
|
formData.expiry_months !== undefined &&
|
||||||
formData.expiry_months !== '' &&
|
|
||||||
Number(formData.expiry_months) >= 1
|
Number(formData.expiry_months) >= 1
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||||
|
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -65,6 +67,21 @@
|
|||||||
maxDate.getDate()
|
maxDate.getDate()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let hoursUntilAppointment = $derived(
|
||||||
|
(new SvelteDate(booking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
||||||
|
);
|
||||||
|
let hasPayments = $derived(
|
||||||
|
(booking.amount_paid ?? 0) > 0
|
||||||
|
);
|
||||||
|
let showNoticeWarning = $derived(
|
||||||
|
hasPayments ? hoursUntilAppointment < 72 : hoursUntilAppointment < 24
|
||||||
|
);
|
||||||
|
let showNoShowWarning = $derived(
|
||||||
|
!hasPayments && hoursUntilAppointment < 24 && hoursUntilAppointment >= 0
|
||||||
|
);
|
||||||
|
let forgiveFees = $state(false);
|
||||||
|
let forgiveNoShow = $state(false);
|
||||||
|
|
||||||
let bookingDuration = $derived(
|
let bookingDuration = $derived(
|
||||||
booking.services?.reduce(
|
booking.services?.reduce(
|
||||||
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
|
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
|
||||||
@@ -305,13 +322,16 @@
|
|||||||
saving = true;
|
saving = true;
|
||||||
const loadingToast = toast.loading('Rescheduling booking...');
|
const loadingToast = toast.loading('Rescheduling booking...');
|
||||||
try {
|
try {
|
||||||
|
const body: Record<string, unknown> = { start_time: newStartTime };
|
||||||
|
if (forgiveFees) body.forgive_fees = true;
|
||||||
|
if (forgiveNoShow) body.forgive_noshow = true;
|
||||||
const response = await fetch(`/api/admin/bookings/${booking.id}/reschedule`, {
|
const response = await fetch(`/api/admin/bookings/${booking.id}/reschedule`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${authStore.currentToken}`
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ start_time: newStartTime })
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success('Booking rescheduled!', { id: loadingToast });
|
toast.success('Booking rescheduled!', { id: loadingToast });
|
||||||
@@ -408,7 +428,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal.Root bind:open>
|
<Modal.Root bind:open>
|
||||||
<Modal.Content class="!z-[70] max-h-[90vh] max-w-4xl overflow-y-auto">
|
<Modal.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-4xl">
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title class="text-lg font-semibold">Reschedule Booking</Modal.Title>
|
<Modal.Title class="text-lg font-semibold">Reschedule Booking</Modal.Title>
|
||||||
<Modal.Description>
|
<Modal.Description>
|
||||||
@@ -417,6 +437,49 @@
|
|||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
|
|
||||||
<div class="px-6 pb-4">
|
<div class="px-6 pb-4">
|
||||||
|
{#if showNoticeWarning || showNoShowWarning}
|
||||||
|
<div class="mb-4 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
|
<p class="font-medium text-amber-900">Short Notice Reschedule</p>
|
||||||
|
|
||||||
|
{#if hasPayments}
|
||||||
|
<p class="mt-1">Rescheduling may forfeit deposit protection on payments made.</p>
|
||||||
|
<details class="mt-1 text-xs text-gray-500">
|
||||||
|
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
||||||
|
<p class="mt-1">"Rescheduling within {Math.round(hoursUntilAppointment)}h of the original time with payments present means deposit protection applies — up to 50% of the total (up to £{(booking.total_amount * 0.5).toFixed(2)}) could be retained depending on notice period."</p>
|
||||||
|
</details>
|
||||||
|
<label class="mt-2 flex items-center gap-2 cursor-pointer">
|
||||||
|
<Checkbox bind:checked={forgiveFees} />
|
||||||
|
<span class="text-xs">Forgive fees — refund fully (overrides deposit protection)</span>
|
||||||
|
</label>
|
||||||
|
<details class="ml-6 text-xs text-gray-500">
|
||||||
|
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
|
||||||
|
<p class="mt-1">"We've waived deposit protection on this reschedule as a goodwill gesture. The full amount moves to the new appointment instead of having up to 50% retained as deposit."</p>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="mt-1">This time change counts as a no-show toward deposit obligations.</p>
|
||||||
|
<details class="mt-1 text-xs text-gray-500">
|
||||||
|
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
||||||
|
<p class="mt-1">"This time change will count as a no-show toward your booking history. Two no-shows within 6 months would require a deposit on future bookings."</p>
|
||||||
|
</details>
|
||||||
|
<label class="mt-2 flex items-center gap-2 cursor-pointer">
|
||||||
|
<Checkbox bind:checked={forgiveNoShow} />
|
||||||
|
<span class="text-xs">Forgive no-show — this reschedule will <strong>not</strong> count toward deposit obligations</span>
|
||||||
|
</label>
|
||||||
|
<details class="ml-6 text-xs text-gray-500">
|
||||||
|
<summary class="cursor-pointer hover:text-gray-700">What happens with forgiveness</summary>
|
||||||
|
<p class="mt-1">"We've waived the no-show record for this reschedule so your deposit obligations are unaffected."</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
<PolicyPopover>
|
||||||
|
{#snippet trigger()}
|
||||||
|
<span class="underline">View full cancellation policy →</span>
|
||||||
|
{/snippet}
|
||||||
|
</PolicyPopover>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card.Root class="mb-4">
|
<Card.Root class="mb-4">
|
||||||
<Card.Content class="pt-4">
|
<Card.Content class="pt-4">
|
||||||
<div class="text-sm font-medium">
|
<div class="text-sm font-medium">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import { generateUUID } from '$lib/utils/uuid';
|
||||||
|
|
||||||
type CartItem = {
|
type CartItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
existing.qty++;
|
existing.qty++;
|
||||||
} else {
|
} else {
|
||||||
cart = [...cart, { id: crypto.randomUUID(), label, price, qty: 1 }];
|
cart = [...cart, { id: generateUUID(), label, price, qty: 1 }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||||
import { formatDuration } from '$lib/utils/format';
|
import { formatDuration } from '$lib/utils/format';
|
||||||
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -431,7 +432,7 @@
|
|||||||
await fetchBlockers();
|
await fetchBlockers();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error('Failed to create: ' + text, { id: loadingToast });
|
toast.error('Failed to create: ' + sanitizeText(text), { id: loadingToast });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating time blocker:', err);
|
console.error('Error creating time blocker:', err);
|
||||||
@@ -461,7 +462,7 @@
|
|||||||
await fetchBlockers();
|
await fetchBlockers();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error('Failed to delete: ' + text, { id: loadingToast });
|
toast.error('Failed to delete: ' + sanitizeText(text), { id: loadingToast });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error deleting time blocker:', err);
|
console.error('Error deleting time blocker:', err);
|
||||||
|
|||||||
@@ -54,9 +54,9 @@
|
|||||||
| 'completed'
|
| 'completed'
|
||||||
| 'client_cancelled'
|
| 'client_cancelled'
|
||||||
| 'we_cancelled'
|
| 'we_cancelled'
|
||||||
| 're-schedule'
|
|
||||||
| 'no_show'
|
| 'no_show'
|
||||||
| 'no_deposit';
|
| 'pending_release'
|
||||||
|
| 'deposit_lapsed';
|
||||||
deposit_required: boolean;
|
deposit_required: boolean;
|
||||||
deposit_paid: boolean;
|
deposit_paid: boolean;
|
||||||
services: Array<{
|
services: Array<{
|
||||||
@@ -412,7 +412,7 @@
|
|||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800'
|
||||||
: booking.status === 'client_cancelled' ||
|
: booking.status === 'client_cancelled' ||
|
||||||
booking.status === 'we_cancelled' ||
|
booking.status === 'we_cancelled' ||
|
||||||
booking.status === 'no_deposit'
|
booking.status === 'deposit_lapsed'
|
||||||
? 'bg-red-100 text-red-800'
|
? 'bg-red-100 text-red-800'
|
||||||
: 'bg-gray-100 text-gray-800'}"
|
: 'bg-gray-100 text-gray-800'}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { generateUUID } from '$lib/utils/uuid';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { getLocalTimeZone } from '@internationalized/date';
|
import { getLocalTimeZone } from '@internationalized/date';
|
||||||
@@ -386,7 +387,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
showCustomCreateForm = false;
|
showCustomCreateForm = false;
|
||||||
newCustomService = { name: '', price: '', duration_minutes: '' };
|
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||||
customServiceErrors = { name: '', price: '', duration_minutes: '' };
|
customServiceErrors = { name: '', price: '', duration_minutes: '' };
|
||||||
toast.success('Custom service created and added');
|
toast.success('Custom service created and added');
|
||||||
} else {
|
} else {
|
||||||
@@ -407,7 +408,7 @@
|
|||||||
try {
|
try {
|
||||||
// Generate idempotency key if not already set (reused on retry)
|
// Generate idempotency key if not already set (reused on retry)
|
||||||
if (!idempotencyKey) {
|
if (!idempotencyKey) {
|
||||||
idempotencyKey = crypto.randomUUID();
|
idempotencyKey = generateUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate duration doesn't exceed available slot
|
// Validate duration doesn't exceed available slot
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
defaultHours = data.map((hour) => ({
|
defaultHours = data.map((hour: any) => ({
|
||||||
weekday: hour.weekday,
|
weekday: hour.weekday,
|
||||||
start_time: formatTime(hour.startTime),
|
start_time: formatTime(hour.startTime),
|
||||||
end_time: formatTime(hour.endTime),
|
end_time: formatTime(hour.endTime),
|
||||||
|
|||||||
Reference in New Issue
Block a user