style: apply prettier formatting to frontend
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
@@ -379,7 +379,9 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<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.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.Title class="text-lg font-semibold">Approve Booking</Modal.Title>
|
||||
<Modal.Description>
|
||||
@@ -413,7 +415,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
{#if oldest?.id === ob.id}
|
||||
<span class="text-amber-600" title="Booked first">★</span>
|
||||
{/if}
|
||||
{formatUserName(ob.user?.full_name || 'Unknown', ob.user?.previous_first_name, ob.user?.previous_last_name)}
|
||||
{formatUserName(
|
||||
ob.user?.full_name || 'Unknown',
|
||||
ob.user?.previous_first_name,
|
||||
ob.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{parseWallClockDate(ob.start_time).toLocaleDateString('en-GB', {
|
||||
@@ -497,7 +503,13 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold">{formatUserName(booking.user?.full_name || '—', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{formatUserName(
|
||||
booking.user?.full_name || '—',
|
||||
booking.user?.previous_first_name,
|
||||
booking.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -517,9 +529,8 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<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
|
||||
<a href="tel:{booking.user.phone}" class="font-medium text-blue-600 hover:underline"
|
||||
>{booking.user.phone}</a
|
||||
>
|
||||
{:else}
|
||||
<div class="font-medium">—</div>
|
||||
@@ -627,7 +638,8 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
min="1"
|
||||
step="1"
|
||||
value={serviceOverrides[service.service_id].duration}
|
||||
oninput={(e) => handleDurationInput(service.service_id, e.currentTarget.value)}
|
||||
oninput={(e) =>
|
||||
handleDurationInput(service.service_id, e.currentTarget.value)}
|
||||
class="no-spin w-full"
|
||||
placeholder={service.duration_minutes?.toString() || '60'}
|
||||
/>
|
||||
@@ -653,7 +665,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<p class="font-medium">Applied Discounts</p>
|
||||
{#each booking.discounts as d}
|
||||
<p class="mt-1">
|
||||
- {d.discount_source === 'loyalty' ? 'Loyalty Stamp Card' : d.discount_source === 'referral' ? 'Referral Discount' : d.campaign_name || 'Promo Campaign'}
|
||||
- {d.discount_source === 'loyalty'
|
||||
? 'Loyalty Stamp Card'
|
||||
: d.discount_source === 'referral'
|
||||
? 'Referral Discount'
|
||||
: d.campaign_name || 'Promo Campaign'}
|
||||
({d.discount_percent}% off): -£{d.discount_amount.toFixed(2)}
|
||||
</p>
|
||||
{/each}
|
||||
@@ -704,4 +720,3 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
|
||||
@@ -676,9 +676,7 @@
|
||||
const startTimeISO = formatLocalDateTime(localDate);
|
||||
|
||||
const serviceIds = selectedServices.filter((s) => !s.is_custom).map((s) => s.id);
|
||||
const customServiceIds = selectedServices
|
||||
.filter((s) => s.is_custom)
|
||||
.map((s) => s.id);
|
||||
const customServiceIds = selectedServices.filter((s) => s.is_custom).map((s) => s.id);
|
||||
|
||||
// Build service overrides payload
|
||||
const overrides = [];
|
||||
@@ -1007,7 +1005,13 @@
|
||||
start_time: string;
|
||||
service_ids: string[];
|
||||
custom_service_ids: string[];
|
||||
service_overrides: Array<{ service_id: string; override_price: number | null; override_duration_minutes: number | null }> | undefined;
|
||||
service_overrides:
|
||||
| Array<{
|
||||
service_id: string;
|
||||
override_price: number | null;
|
||||
override_duration_minutes: number | null;
|
||||
}>
|
||||
| undefined;
|
||||
notes: string | null;
|
||||
out_of_hours: boolean;
|
||||
} = {
|
||||
@@ -1657,7 +1661,9 @@
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-green-800">
|
||||
Slot reserved until {parseWallClockDate(reservationExpiresAt.toISOString()).toLocaleTimeString([], {
|
||||
Slot reserved until {parseWallClockDate(
|
||||
reservationExpiresAt.toISOString()
|
||||
).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
|
||||
@@ -45,11 +45,14 @@
|
||||
let balanceDue = $derived(selectedBooking ? computeBalanceDue(selectedBooking) : 0);
|
||||
let hoursUntilAppt = $derived(
|
||||
selectedBooking
|
||||
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
||||
? (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
|
||||
selectedBooking
|
||||
? Math.min(totalPaid, selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT)
|
||||
: 0
|
||||
);
|
||||
let estimatedRefund = $derived(
|
||||
forgiveFeesCancel
|
||||
@@ -216,14 +219,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getPaymentName(payment: Payment, index: number, payments: Payment[] | undefined, discounts: BookingDiscount[] | undefined): string {
|
||||
function getPaymentName(
|
||||
payment: Payment,
|
||||
index: number,
|
||||
payments: Payment[] | undefined,
|
||||
discounts: BookingDiscount[] | undefined
|
||||
): string {
|
||||
if (payment.payment_method === 'online_square') return 'Online Card';
|
||||
if (payment.payment_method === 'in_person_card') return 'Card Machine';
|
||||
if (payment.payment_method === 'cash') return 'Cash';
|
||||
if (payment.payment_method === 'giftcard') return 'Gift Card';
|
||||
if (payment.payment_method === 'discount') {
|
||||
const discountPaymentsBefore = (payments ?? []).slice(0, index).filter(p => p.payment_method === 'discount').length;
|
||||
const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01);
|
||||
const discountPaymentsBefore = (payments ?? [])
|
||||
.slice(0, index)
|
||||
.filter((p) => p.payment_method === 'discount').length;
|
||||
const discountList = (discounts ?? []).filter((d) => d.discount_amount > 0.01);
|
||||
if (discountList[discountPaymentsBefore]) {
|
||||
const d = discountList[discountPaymentsBefore];
|
||||
if (d.discount_source === 'loyalty') return 'Loyalty Stamp Card (10% Off)';
|
||||
@@ -232,7 +242,9 @@
|
||||
}
|
||||
return 'Discount';
|
||||
}
|
||||
return (payment.payment_method as string).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return (payment.payment_method as string)
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -323,8 +335,17 @@
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-800"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-1 h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Out-of-hours
|
||||
</span>
|
||||
@@ -418,12 +439,22 @@
|
||||
{#if selectedBooking.user?.profile_pic_url}
|
||||
<img
|
||||
src={selectedBooking.user.profile_pic_url}
|
||||
alt={formatUserName(selectedBooking.user.full_name, selectedBooking.user.previous_first_name, selectedBooking.user.previous_last_name)}
|
||||
alt={formatUserName(
|
||||
selectedBooking.user.full_name,
|
||||
selectedBooking.user.previous_first_name,
|
||||
selectedBooking.user.previous_last_name
|
||||
)}
|
||||
class="h-16 w-16 rounded-full object-cover ring-4 ring-blue-200"
|
||||
/>
|
||||
{/if}
|
||||
<div>
|
||||
<div class="text-lg font-semibold">{formatUserName(selectedBooking.user?.full_name || '—', selectedBooking.user?.previous_first_name, selectedBooking.user?.previous_last_name)}</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{formatUserName(
|
||||
selectedBooking.user?.full_name || '—',
|
||||
selectedBooking.user?.previous_first_name,
|
||||
selectedBooking.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
{#if selectedBooking.user?.date_of_birth}
|
||||
<div class="text-sm text-gray-500">
|
||||
{calculateAge(selectedBooking.user.date_of_birth)} years old
|
||||
@@ -541,7 +572,9 @@
|
||||
</div>
|
||||
|
||||
{#if selectedBooking.discounts && selectedBooking.discounts.length > 0}
|
||||
<div class="border-y border-fuchsia-100 bg-fuchsia-50/20 py-2 my-2 space-y-1 rounded-md px-2">
|
||||
<div
|
||||
class="my-2 space-y-1 rounded-md border-y border-fuchsia-100 bg-fuchsia-50/20 px-2 py-2"
|
||||
>
|
||||
{#each selectedBooking.discounts as d}
|
||||
<div class="flex items-center justify-between text-xs text-fuchsia-800">
|
||||
<span class="flex items-center gap-1.5">
|
||||
@@ -562,14 +595,22 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between font-medium text-gray-900">
|
||||
<span class="text-sm">Net Total</span>
|
||||
<span>£{(selectedBooking.total_amount - selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)).toFixed(2)}</span>
|
||||
<span
|
||||
>£{(
|
||||
selectedBooking.total_amount -
|
||||
selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)
|
||||
).toFixed(2)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span>
|
||||
<span class="font-semibold text-green-700">
|
||||
£{(selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0).toFixed(2)}
|
||||
£{(selectedBooking.payments ?? [])
|
||||
.filter((p) => p.payment_method !== 'discount' && p.status === 'completed')
|
||||
.reduce((sum, p) => sum + p.amount, 0)
|
||||
.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -583,9 +624,7 @@
|
||||
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
|
||||
<span class="font-medium text-gray-900">Balance Due</span>
|
||||
<span
|
||||
class="text-lg font-bold {balanceDue > 0.01
|
||||
? 'text-red-600'
|
||||
: 'text-green-600'}"
|
||||
class="text-lg font-bold {balanceDue > 0.01 ? 'text-red-600' : 'text-green-600'}"
|
||||
>
|
||||
£{balanceDue.toFixed(2)}
|
||||
</span>
|
||||
@@ -606,7 +645,12 @@
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">
|
||||
{getPaymentName(payment, index, selectedBooking.payments, selectedBooking.discounts)}
|
||||
{getPaymentName(
|
||||
payment,
|
||||
index,
|
||||
selectedBooking.payments,
|
||||
selectedBooking.discounts
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
@@ -645,9 +689,8 @@
|
||||
<div class="mt-2 text-xs text-gray-600">
|
||||
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
|
||||
<div>
|
||||
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount?.toFixed(
|
||||
2
|
||||
) || '0.00'}
|
||||
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount?.toFixed(2) ||
|
||||
'0.00'}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -668,7 +711,9 @@
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-red-700">Refund</span>
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
|
||||
>
|
||||
{refund.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -751,71 +796,131 @@
|
||||
{@const apptHours = Math.round(hoursUntilAppt)}
|
||||
{#if totalPaid > 0}
|
||||
<p>
|
||||
This booking has payments totalling <span class="font-semibold">£{totalPaid.toFixed(2)}</span>.
|
||||
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'}">
|
||||
<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`}
|
||||
{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.`}
|
||||
{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."}
|
||||
{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">
|
||||
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox bind:checked={forgiveFeesCancel} />
|
||||
<span class="text-xs">Forgive fees — refund the <strong>full</strong> amount paid</span>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox bind:checked={forgiveNoShowCancel} />
|
||||
<span class="text-xs">Forgive no-show — this cancellation will <strong>not</strong> count toward deposit obligations</span>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox bind:checked={forgiveNoShowCancel} />
|
||||
<span class="text-xs">Forgive no-show — this cancellation will <strong>not</strong> count toward deposit obligations</span>
|
||||
<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 class="mt-1 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. 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">
|
||||
<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>
|
||||
|
||||
@@ -284,7 +284,13 @@
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<span>• {formatUserName(b.user?.full_name || 'Unknown User', b.user?.previous_first_name, b.user?.previous_last_name)}</span>
|
||||
<span
|
||||
>• {formatUserName(
|
||||
b.user?.full_name || 'Unknown User',
|
||||
b.user?.previous_first_name,
|
||||
b.user?.previous_last_name
|
||||
)}</span
|
||||
>
|
||||
<span>
|
||||
- {formatServices(b.services)}
|
||||
</span>
|
||||
|
||||
@@ -121,7 +121,8 @@
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) return '';
|
||||
// UK VAT numbers: GB + 9 digits (standard) or GB + 12 digits (branch)
|
||||
if (trimmed.length !== 11 && trimmed.length !== 14) return 'Must be GB followed by 9 or 12 digits';
|
||||
if (trimmed.length !== 11 && trimmed.length !== 14)
|
||||
return 'Must be GB followed by 9 or 12 digits';
|
||||
if (!trimmed.startsWith('GB')) return 'Must start with GB';
|
||||
const digits = trimmed.slice(2);
|
||||
if (!/^\d+$/.test(digits)) return 'Must contain only digits after GB';
|
||||
@@ -156,13 +157,16 @@
|
||||
function validateField(field: string, inputValue?: unknown) {
|
||||
const val = inputValue ?? form[field as keyof typeof form];
|
||||
if (field === 'business_name') formErrors.business_name = validateBusinessName(val);
|
||||
else if (field === 'business_address') formErrors.business_address = validateBusinessAddress(val);
|
||||
else if (field === 'business_address')
|
||||
formErrors.business_address = validateBusinessAddress(val);
|
||||
else if (field === 'business_phone') formErrors.business_phone = validatePhone(val);
|
||||
else if (field === 'business_email') formErrors.business_email = validateEmail(val);
|
||||
else if (field === 'website_url') formErrors.website_url = validateWebsiteUrl(val);
|
||||
else if (field === 'vat_registration_number') formErrors.vat_registration_number = validateVatNumber(val);
|
||||
else if (field === 'vat_registration_number')
|
||||
formErrors.vat_registration_number = validateVatNumber(val);
|
||||
else if (field === 'default_vat_rate') formErrors.default_vat_rate = validateVatRate(val);
|
||||
else if (field === 'gift_card_expiry_months') formErrors.gift_card_expiry_months = validateGiftCardExpiry(val);
|
||||
else if (field === 'gift_card_expiry_months')
|
||||
formErrors.gift_card_expiry_months = validateGiftCardExpiry(val);
|
||||
else if (field === 'voucher_type') formErrors.voucher_type = validateVoucherType(val);
|
||||
}
|
||||
|
||||
@@ -192,7 +196,9 @@
|
||||
let normalisedEmail = form.business_email?.trim() ?? null;
|
||||
if (normalisedEmail) normalisedEmail = normalizeEmail(normalisedEmail);
|
||||
|
||||
const normalisedPhone = form.business_phone ? toE164UK(form.business_phone) ?? normalisePhoneInput(form.business_phone) : null;
|
||||
const normalisedPhone = form.business_phone
|
||||
? (toE164UK(form.business_phone) ?? normalisePhoneInput(form.business_phone))
|
||||
: null;
|
||||
|
||||
const normalisedForm = {
|
||||
...form,
|
||||
@@ -205,9 +211,16 @@
|
||||
};
|
||||
|
||||
const fields: (keyof BusinessSettings)[] = [
|
||||
'business_name', 'business_address', 'business_phone', 'business_email',
|
||||
'vat_registration_number', 'is_vat_registered', 'default_vat_rate',
|
||||
'website_url', 'gift_card_expiry_months', 'voucher_type'
|
||||
'business_name',
|
||||
'business_address',
|
||||
'business_phone',
|
||||
'business_email',
|
||||
'vat_registration_number',
|
||||
'is_vat_registered',
|
||||
'default_vat_rate',
|
||||
'website_url',
|
||||
'gift_card_expiry_months',
|
||||
'voucher_type'
|
||||
];
|
||||
|
||||
for (const field of fields) {
|
||||
@@ -235,7 +248,7 @@
|
||||
|
||||
if (res.ok) {
|
||||
const wasVatEnabled = settings?.is_vat_registered;
|
||||
const updated = await res.json() as BusinessSettings;
|
||||
const updated = (await res.json()) as BusinessSettings;
|
||||
settings = updated;
|
||||
if (!wasVatEnabled && updated.is_vat_registered) {
|
||||
toast.success('VAT enabled — past payments and till sales updated retroactively');
|
||||
@@ -268,9 +281,7 @@
|
||||
Business details, VAT configuration, and gift card defaults.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button onclick={openEditModal} disabled={loading || !settings}>
|
||||
Edit Settings
|
||||
</Button>
|
||||
<Button onclick={openEditModal} disabled={loading || !settings}>Edit Settings</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
@@ -292,7 +303,9 @@
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Business Information -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Business Information</h3>
|
||||
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
|
||||
Business Information
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground">Business Name</span>
|
||||
@@ -332,15 +345,17 @@
|
||||
|
||||
<!-- VAT Configuration -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">VAT & Currency</h3>
|
||||
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
|
||||
VAT & Currency
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground">VAT Registered</span>
|
||||
<p class="text-sm font-medium">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {settings.is_vat_registered
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-gray-50 text-gray-600 border border-gray-200'}"
|
||||
? 'border border-green-200 bg-green-50 text-green-700'
|
||||
: 'border border-gray-200 bg-gray-50 text-gray-600'}"
|
||||
>
|
||||
{settings.is_vat_registered ? 'Registered' : 'Not Registered'}
|
||||
</span>
|
||||
@@ -363,7 +378,9 @@
|
||||
|
||||
<!-- Gift Card Configuration -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Gift Cards</h3>
|
||||
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
|
||||
Gift Cards
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground">Expiry Period</span>
|
||||
@@ -373,9 +390,10 @@
|
||||
<span class="text-xs text-muted-foreground">Voucher Type</span>
|
||||
<p class="text-sm font-medium">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {settings.voucher_type === 'MPV'
|
||||
? 'bg-blue-50 text-blue-700 border border-blue-200'
|
||||
: 'bg-purple-50 text-purple-700 border border-purple-200'}"
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {settings.voucher_type ===
|
||||
'MPV'
|
||||
? 'border border-blue-200 bg-blue-50 text-blue-700'
|
||||
: 'border border-purple-200 bg-purple-50 text-purple-700'}"
|
||||
>
|
||||
{settings.voucher_type === 'MPV' ? 'MPV (0% VAT)' : 'SPV (20% VAT)'}
|
||||
</span>
|
||||
@@ -390,7 +408,7 @@
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<Modal.Root bind:open={showEditModal}>
|
||||
<Modal.Content class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Edit Business Settings</Modal.Title>
|
||||
<Modal.Description>
|
||||
@@ -398,10 +416,20 @@
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<form class="space-y-6 py-4" onsubmit={(e) => { e.preventDefault(); saveSettings(); }}>
|
||||
<form
|
||||
class="space-y-6 py-4"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveSettings();
|
||||
}}
|
||||
>
|
||||
<!-- Business Information Section -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">Business Information</h4>
|
||||
<h4
|
||||
class="border-b pb-1 text-sm font-semibold tracking-wider text-muted-foreground uppercase"
|
||||
>
|
||||
Business Information
|
||||
</h4>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="business_name">Business Name</Label.Root>
|
||||
@@ -493,7 +521,11 @@
|
||||
|
||||
<!-- VAT & Currency Section -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">VAT & Currency</h4>
|
||||
<h4
|
||||
class="border-b pb-1 text-sm font-semibold tracking-wider text-muted-foreground uppercase"
|
||||
>
|
||||
VAT & Currency
|
||||
</h4>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
@@ -502,18 +534,26 @@
|
||||
Enable if your business is registered for UK VAT
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox id="is_vat_registered" checked={form.is_vat_registered ?? false} onCheckedChange={(v: boolean) => {
|
||||
form.is_vat_registered = v;
|
||||
if (!v) {
|
||||
form.vat_registration_number = null;
|
||||
form.default_vat_rate = 20.00;
|
||||
formErrors.vat_registration_number = '';
|
||||
formErrors.default_vat_rate = '';
|
||||
}
|
||||
}} />
|
||||
<Checkbox
|
||||
id="is_vat_registered"
|
||||
checked={form.is_vat_registered ?? false}
|
||||
onCheckedChange={(v: boolean) => {
|
||||
form.is_vat_registered = v;
|
||||
if (!v) {
|
||||
form.vat_registration_number = null;
|
||||
form.default_vat_rate = 20.0;
|
||||
formErrors.vat_registration_number = '';
|
||||
formErrors.default_vat_rate = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 {!form.is_vat_registered ? 'opacity-50 pointer-events-none' : ''}">
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 {!form.is_vat_registered
|
||||
? 'pointer-events-none opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="vat_registration_number">VAT Registration Number</Label.Root>
|
||||
<Input
|
||||
@@ -557,7 +597,11 @@
|
||||
|
||||
<!-- Gift Card Section -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">Gift Card Configuration</h4>
|
||||
<h4
|
||||
class="border-b pb-1 text-sm font-semibold tracking-wider text-muted-foreground uppercase"
|
||||
>
|
||||
Gift Card Configuration
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
@@ -579,7 +623,7 @@
|
||||
<Label.Root for="voucher_type">Voucher Type</Label.Root>
|
||||
<select
|
||||
id="voucher_type"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
bind:value={form.voucher_type}
|
||||
onchange={() => validateField('voucher_type')}
|
||||
>
|
||||
|
||||
@@ -89,16 +89,18 @@
|
||||
function validateField(field: string) {
|
||||
if (field === 'name') serviceErrors.name = validateName(newService.name);
|
||||
if (field === 'price') serviceErrors.price = validatePrice(newService.price);
|
||||
if (field === 'duration_minutes') serviceErrors.duration_minutes = validateDuration(newService.duration_minutes);
|
||||
if (field === 'minimum_age_required') serviceErrors.minimum_age_required = validateMinimumAge(newService.minimum_age_required);
|
||||
if (field === 'duration_minutes')
|
||||
serviceErrors.duration_minutes = validateDuration(newService.duration_minutes);
|
||||
if (field === 'minimum_age_required')
|
||||
serviceErrors.minimum_age_required = validateMinimumAge(newService.minimum_age_required);
|
||||
}
|
||||
|
||||
let isFormValid = $derived(
|
||||
(newService.name ?? '').trim() !== '' &&
|
||||
!serviceErrors.name &&
|
||||
!serviceErrors.price &&
|
||||
!serviceErrors.duration_minutes &&
|
||||
!serviceErrors.minimum_age_required
|
||||
!serviceErrors.name &&
|
||||
!serviceErrors.price &&
|
||||
!serviceErrors.duration_minutes &&
|
||||
!serviceErrors.minimum_age_required
|
||||
);
|
||||
|
||||
async function fetchServices() {
|
||||
@@ -165,7 +167,12 @@
|
||||
}
|
||||
|
||||
async function promoteService(id: string, name: string) {
|
||||
if (!confirm(`Promote "${name}" to a regular catalog service? This will migrate all booking references.`)) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Promote "${name}" to a regular catalog service? This will migrate all booking references.`
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const response = await fetch(`/api/admin/custom-services/${id}/promote`, {
|
||||
method: 'POST',
|
||||
@@ -208,7 +215,13 @@
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
newService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
newService = {
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
};
|
||||
serviceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
}
|
||||
|
||||
@@ -226,8 +239,20 @@
|
||||
One-off services for bridal parties, special requests, and custom bookings.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button onclick={() => { resetForm(); showCreateModal = true; }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<Button
|
||||
onclick={() => {
|
||||
resetForm();
|
||||
showCreateModal = true;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
@@ -241,10 +266,21 @@
|
||||
<Input
|
||||
placeholder="Search custom services..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { page = 1; fetchServices(); } }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
page = 1;
|
||||
fetchServices();
|
||||
}
|
||||
}}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" onclick={() => { page = 1; fetchServices(); }}>Search</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
page = 1;
|
||||
fetchServices();
|
||||
}}>Search</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -278,7 +314,9 @@
|
||||
<td class="py-3 font-medium">{service.name}</td>
|
||||
<td class="py-3 text-gray-600">
|
||||
{#if service.description}
|
||||
<div class="line-clamp-1" title={service.description}>{service.description}</div>
|
||||
<div class="line-clamp-1" title={service.description}>
|
||||
{service.description}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-gray-400">—</span>
|
||||
{/if}
|
||||
@@ -286,14 +324,24 @@
|
||||
<td class="py-3 text-right font-medium">£{service.price.toFixed(2)}</td>
|
||||
<td class="py-3 text-right">{service.duration_minutes} min</td>
|
||||
<td class="py-3 text-right">
|
||||
{service.usage_count}×{service.last_used_at ? ` (last: ${new Date(service.last_used_at).toLocaleDateString('en-GB', { timeZone: 'Europe/London' })})` : ''}
|
||||
{service.usage_count}×{service.last_used_at
|
||||
? ` (last: ${new Date(service.last_used_at).toLocaleDateString('en-GB', { timeZone: 'Europe/London' })})`
|
||||
: ''}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="flex justify-center gap-2">
|
||||
<Button variant="outline" size="sm" onclick={() => promoteService(service.id, service.name)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => promoteService(service.id, service.name)}
|
||||
>
|
||||
Promote
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onclick={() => deleteService(service.id)}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => deleteService(service.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
@@ -320,8 +368,18 @@
|
||||
<div><span class="font-medium">Duration:</span> {service.duration_minutes} min</div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" class="flex-1" onclick={() => promoteService(service.id, service.name)}>Promote</Button>
|
||||
<Button variant="destructive" size="sm" class="flex-1" onclick={() => deleteService(service.id)}>Delete</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
onclick={() => promoteService(service.id, service.name)}>Promote</Button
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
onclick={() => deleteService(service.id)}>Delete</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,8 +392,24 @@
|
||||
Page {page} of {Math.ceil(total / perPage)} ({total} total)
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => { page--; fetchServices(); }}>Previous</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= Math.ceil(total / perPage)} onclick={() => { page++; fetchServices(); }}>Next</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onclick={() => {
|
||||
page--;
|
||||
fetchServices();
|
||||
}}>Previous</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= Math.ceil(total / perPage)}
|
||||
onclick={() => {
|
||||
page++;
|
||||
fetchServices();
|
||||
}}>Next</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -408,11 +482,17 @@
|
||||
id="cs-duration"
|
||||
bind:value={newService.duration_minutes}
|
||||
onchange={() => validateField('duration_minutes')}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {serviceErrors.duration_minutes ? 'border-red-500' : ''}"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {serviceErrors.duration_minutes
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{#each durationOptions as mins (mins)}
|
||||
<option value={mins}>{mins} min{mins >= 60 ? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})` : ''}</option>
|
||||
<option value={mins}
|
||||
>{mins} min{mins >= 60
|
||||
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
|
||||
: ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{#if serviceErrors.duration_minutes}
|
||||
@@ -443,7 +523,14 @@
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onclick={() => { showCreateModal = false; resetForm(); }} disabled={creating}>Cancel</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showCreateModal = false;
|
||||
resetForm();
|
||||
}}
|
||||
disabled={creating}>Cancel</Button
|
||||
>
|
||||
<Button onclick={createService} disabled={creating || !isFormValid}>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
|
||||
@@ -166,11 +166,7 @@
|
||||
if (form.campaign_type === 'time_based') {
|
||||
if (!form.start_date) errors.start_date = 'Required';
|
||||
if (!form.end_date) errors.end_date = 'Required';
|
||||
if (
|
||||
form.start_date &&
|
||||
form.end_date &&
|
||||
new Date(form.end_date) < new Date(form.start_date)
|
||||
) {
|
||||
if (form.start_date && form.end_date && new Date(form.end_date) < new Date(form.start_date)) {
|
||||
errors.end_date = 'Must be after start';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<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 { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatDuration } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -422,7 +422,13 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<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 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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
interface ServiceItem {
|
||||
id: string;
|
||||
@@ -185,7 +185,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Booking Change Request</Modal.Title>
|
||||
<Modal.Description>
|
||||
Review the requested changes to {formatUserName(editRequest.user.full_name, editRequest.user.previous_first_name, editRequest.user.previous_last_name)}'s booking.
|
||||
Review the requested changes to {formatUserName(
|
||||
editRequest.user.full_name,
|
||||
editRequest.user.previous_first_name,
|
||||
editRequest.user.previous_last_name
|
||||
)}'s booking.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
@@ -198,14 +202,22 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Name</div>
|
||||
<div class="font-medium">{formatUserName(editRequest.user.full_name, editRequest.user.previous_first_name, editRequest.user.previous_last_name)}</div>
|
||||
<div class="font-medium">
|
||||
{formatUserName(
|
||||
editRequest.user.full_name,
|
||||
editRequest.user.previous_first_name,
|
||||
editRequest.user.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Phone</div>
|
||||
<div class="font-medium">
|
||||
{#if editRequest.user.phone}
|
||||
<a href="tel:{editRequest.user.phone}" class="text-blue-600 hover:underline">{editRequest.user.phone}</a>
|
||||
<a href="tel:{editRequest.user.phone}" class="text-blue-600 hover:underline"
|
||||
>{editRequest.user.phone}</a
|
||||
>
|
||||
{:else}—{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,7 +225,9 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<div class="text-xs text-gray-500">Email</div>
|
||||
<div class="font-medium break-all">
|
||||
{#if editRequest.user.email}
|
||||
<a href="mailto:{editRequest.user.email}" class="text-blue-600 hover:underline">{editRequest.user.email}</a>
|
||||
<a href="mailto:{editRequest.user.email}" class="text-blue-600 hover:underline"
|
||||
>{editRequest.user.email}</a
|
||||
>
|
||||
{:else}—{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,125 +236,125 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
</div>
|
||||
|
||||
{#if isTimeChanged()}
|
||||
<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">
|
||||
Date & Time Change
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Before</div>
|
||||
<div class="font-medium">
|
||||
{formatDateLine1(editRequest.original.start_time)}
|
||||
<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">
|
||||
Date & Time Change
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Before</div>
|
||||
<div class="font-medium">
|
||||
{formatDateLine1(editRequest.original.start_time)}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{formatDateLine2(
|
||||
editRequest.original.start_time,
|
||||
getDuration(editRequest.original.services)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{formatDateLine2(
|
||||
editRequest.original.start_time,
|
||||
getDuration(editRequest.original.services)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">After</div>
|
||||
<div class="font-medium text-emerald-700">
|
||||
{formatDateLine1(editRequest.proposed.start_time!)}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{formatDateLine2(
|
||||
editRequest.proposed.start_time!,
|
||||
getDuration(editRequest.proposed.services)
|
||||
)}
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">After</div>
|
||||
<div class="font-medium text-emerald-700">
|
||||
{formatDateLine1(editRequest.proposed.start_time!)}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{formatDateLine2(
|
||||
editRequest.proposed.start_time!,
|
||||
getDuration(editRequest.proposed.services)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if areServicesChanged()}
|
||||
<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">
|
||||
Services Change
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Original</div>
|
||||
<div class="space-y-2">
|
||||
{#each editRequest.original.services as service (service.id)}
|
||||
{#if serviceDiff.removed.some((s) => s.id === service.id)}
|
||||
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-red-600">−</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-red-700 line-through">
|
||||
{service.name}
|
||||
</div>
|
||||
<div class="text-xs text-red-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
<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">
|
||||
Services Change
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Original</div>
|
||||
<div class="space-y-2">
|
||||
{#each editRequest.original.services as service (service.id)}
|
||||
{#if serviceDiff.removed.some((s) => s.id === service.id)}
|
||||
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-red-600">−</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-red-700 line-through">
|
||||
{service.name}
|
||||
</div>
|
||||
<div class="text-xs text-red-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{service.name}</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
{:else}
|
||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{service.name}</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Proposed</div>
|
||||
<div class="space-y-2">
|
||||
{#each editRequest.proposed.services as service (service.id)}
|
||||
{#if serviceDiff.added.some((s) => s.id === service.id)}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2"
|
||||
>
|
||||
<span class="mt-0.5 font-mono text-sm text-emerald-600">+</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-emerald-700">{service.name}</div>
|
||||
<div class="text-xs text-emerald-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Proposed</div>
|
||||
<div class="space-y-2">
|
||||
{#each editRequest.proposed.services as service (service.id)}
|
||||
{#if serviceDiff.added.some((s) => s.id === service.id)}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2"
|
||||
>
|
||||
<span class="mt-0.5 font-mono text-sm text-emerald-600">+</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-emerald-700">{service.name}</div>
|
||||
<div class="text-xs text-emerald-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{service.name}</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
{:else}
|
||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{service.name}</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editRequest.proposed.notes !== editRequest.original.notes}
|
||||
<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">
|
||||
Booking Notes Change
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Original</div>
|
||||
<div class="text-sm">{editRequest.original.notes || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Proposed</div>
|
||||
<div class="text-sm">{editRequest.proposed.notes}</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">
|
||||
Booking Notes Change
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Original</div>
|
||||
<div class="text-sm">{editRequest.original.notes || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Proposed</div>
|
||||
<div class="text-sm">{editRequest.proposed.notes}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Request Notes -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,8 +42,14 @@
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => { URL.revokeObjectURL(url); resolve(true); };
|
||||
img.onerror = () => { URL.revokeObjectURL(url); resolve(false); };
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
@@ -123,16 +129,20 @@
|
||||
/** Encode ImageData to AVIF via Web Worker. */
|
||||
function encodeAvif(imageData: ImageData, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/avif-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
const worker = new Worker(new URL('$lib/workers/avif-encoder.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
worker.onmessage = (
|
||||
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
|
||||
) => {
|
||||
worker.terminate();
|
||||
if (e.data.error) return reject(new Error(e.data.error));
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/avif' }));
|
||||
};
|
||||
worker.onerror = (err) => { worker.terminate(); reject(err); };
|
||||
worker.onerror = (err) => {
|
||||
worker.terminate();
|
||||
reject(err);
|
||||
};
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
@@ -140,16 +150,20 @@
|
||||
/** Encode ImageData to WebP via Web Worker. */
|
||||
function encodeWebp(imageData: ImageData, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/webp-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
const worker = new Worker(new URL('$lib/workers/webp-encoder.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
worker.onmessage = (
|
||||
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
|
||||
) => {
|
||||
worker.terminate();
|
||||
if (e.data.error) return reject(new Error(e.data.error));
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/webp' }));
|
||||
};
|
||||
worker.onerror = (err) => { worker.terminate(); reject(err); };
|
||||
worker.onerror = (err) => {
|
||||
worker.terminate();
|
||||
reject(err);
|
||||
};
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
@@ -157,16 +171,20 @@
|
||||
/** Encode ImageData to JPEG via Web Worker. */
|
||||
function encodeJpeg(imageData: ImageData, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/jpeg-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
const worker = new Worker(new URL('$lib/workers/jpeg-encoder.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
worker.onmessage = (
|
||||
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
|
||||
) => {
|
||||
worker.terminate();
|
||||
if (e.data.error) return reject(new Error(e.data.error));
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/jpeg' }));
|
||||
};
|
||||
worker.onerror = (err) => { worker.terminate(); reject(err); };
|
||||
worker.onerror = (err) => {
|
||||
worker.terminate();
|
||||
reject(err);
|
||||
};
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
@@ -174,18 +192,26 @@
|
||||
/** Encode ImageData to JPEG XL via Web Worker. Returns null if unavailable. */
|
||||
function encodeJxl(imageData: ImageData, quality: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/jxl-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
const timeout = setTimeout(() => { worker.terminate(); resolve(null); }, 10000);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
const worker = new Worker(new URL('$lib/workers/jxl-encoder.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
worker.terminate();
|
||||
resolve(null);
|
||||
}, 10000);
|
||||
worker.onmessage = (
|
||||
e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>
|
||||
) => {
|
||||
clearTimeout(timeout);
|
||||
worker.terminate();
|
||||
if (e.data.error) return resolve(null);
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/jxl' }));
|
||||
};
|
||||
worker.onerror = () => { clearTimeout(timeout); worker.terminate(); resolve(null); };
|
||||
worker.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
worker.terminate();
|
||||
resolve(null);
|
||||
};
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
@@ -610,7 +636,9 @@
|
||||
<div class="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<Card.Title>Drop or Select Files</Card.Title>
|
||||
<Card.Description>HEIC, AVIF, WebP, PNG, JPEG and more. Maximum 20MB per file</Card.Description>
|
||||
<Card.Description
|
||||
>HEIC, AVIF, WebP, PNG, JPEG and more. Maximum 20MB per file</Card.Description
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
@@ -634,7 +662,9 @@
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-gray-700">Drop images here or click to browse</p>
|
||||
<p class="mt-1 text-xs text-gray-500">HEIC, AVIF, WebP, PNG, JPEG, GIF & more — max 20MB per file</p>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
HEIC, AVIF, WebP, PNG, JPEG, GIF & more — max 20MB per file
|
||||
</p>
|
||||
</div>
|
||||
</FileDropZone>
|
||||
|
||||
@@ -688,11 +718,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium" class:text-gray-900={!oversized} class:text-red-900={oversized}>
|
||||
<p
|
||||
class="truncate text-sm font-medium"
|
||||
class:text-gray-900={!oversized}
|
||||
class:text-red-900={oversized}
|
||||
>
|
||||
{f.name}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs" class:text-gray-500={!oversized} class:text-red-600={oversized}>
|
||||
<p
|
||||
class="text-xs"
|
||||
class:text-gray-500={!oversized}
|
||||
class:text-red-600={oversized}
|
||||
>
|
||||
{formatFileSize(f.size)}
|
||||
{#if oversized}
|
||||
— exceeds 20MB limit
|
||||
@@ -740,10 +778,10 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Results Section -->
|
||||
<!-- Upload Results Section -->
|
||||
{#if uploadResults.length > 0}
|
||||
<div class="border-t pt-6">
|
||||
<h3 class="mb-4 font-semibold text-gray-900">Upload Results</h3>
|
||||
@@ -895,7 +933,10 @@
|
||||
<!-- Action Buttons -->
|
||||
<div class="border-t pt-6">
|
||||
<div class="flex justify-end">
|
||||
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading || hasOversizedFiles}>
|
||||
<Button
|
||||
onclick={uploadOneOrMany}
|
||||
disabled={!uploadFiles.length || uploading || hasOversizedFiles}
|
||||
>
|
||||
{#if uploading}
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 animate-spin"
|
||||
|
||||
@@ -68,9 +68,7 @@
|
||||
let hoursUntilAppointment = $derived(
|
||||
(new SvelteDate(booking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
||||
);
|
||||
let hasPayments = $derived(
|
||||
(booking.amount_paid ?? 0) > 0
|
||||
);
|
||||
let hasPayments = $derived((booking.amount_paid ?? 0) > 0);
|
||||
let showNoticeWarning = $derived(
|
||||
hasPayments ? hoursUntilAppointment < 72 : hoursUntilAppointment < 24
|
||||
);
|
||||
@@ -439,30 +437,52 @@
|
||||
<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>
|
||||
<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">
|
||||
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox bind:checked={forgiveFees} />
|
||||
<span class="text-xs">Forgive fees — refund fully (overrides deposit protection)</span>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox bind:checked={forgiveNoShow} />
|
||||
<span class="text-xs">Forgive no-show — this reschedule will <strong>not</strong> count toward deposit obligations</span>
|
||||
<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>
|
||||
<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">
|
||||
@@ -489,9 +509,12 @@
|
||||
})}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)} · {booking.services
|
||||
?.map((s) => s.service_name)
|
||||
.join(', ') || 'No services'} · {bookingDuration} min
|
||||
{formatUserName(
|
||||
booking.user?.full_name || 'Unknown',
|
||||
booking.user?.previous_first_name,
|
||||
booking.user?.previous_last_name
|
||||
)} · {booking.services?.map((s) => s.service_name).join(', ') || 'No services'} · {bookingDuration}
|
||||
min
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -570,11 +570,17 @@
|
||||
id="service-duration"
|
||||
bind:value={newService.duration_minutes}
|
||||
onblur={validateDurationField}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {serviceErrors.duration_minutes ? 'border-red-500' : ''}"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {serviceErrors.duration_minutes
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
>
|
||||
<option value={0}>Select...</option>
|
||||
{#each durationOptions as mins (mins)}
|
||||
<option value={mins}>{mins} min{mins >= 60 ? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})` : ''}</option>
|
||||
<option value={mins}
|
||||
>{mins} min{mins >= 60
|
||||
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
|
||||
: ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{#if serviceErrors.duration_minutes}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
|
||||
function addItem(label: string, price: number) {
|
||||
const existing = cart.find(i => i.label === label);
|
||||
const existing = cart.find((i) => i.label === label);
|
||||
if (existing) {
|
||||
existing.qty++;
|
||||
} else {
|
||||
@@ -40,15 +40,17 @@
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
cart = cart.filter(i => i.id !== id);
|
||||
cart = cart.filter((i) => i.id !== id);
|
||||
}
|
||||
|
||||
function updateQty(id: string, delta: number) {
|
||||
cart = cart.map(i => {
|
||||
if (i.id !== id) return i;
|
||||
const next = i.qty + delta;
|
||||
return next <= 0 ? null : { ...i, qty: next };
|
||||
}).filter((i): i is CartItem => i !== null);
|
||||
cart = cart
|
||||
.map((i) => {
|
||||
if (i.id !== id) return i;
|
||||
const next = i.qty + delta;
|
||||
return next <= 0 ? null : { ...i, qty: next };
|
||||
})
|
||||
.filter((i): i is CartItem => i !== null);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -58,38 +60,74 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Cuticle Oil', 8)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => addItem('Cuticle Oil', 8)}
|
||||
>
|
||||
Cuticle Oil - £8
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Nail Files (Pack)', 5)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => addItem('Nail Files (Pack)', 5)}
|
||||
>
|
||||
Nail Files - £5
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Hand Cream', 6)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => addItem('Hand Cream', 6)}
|
||||
>
|
||||
Hand Cream - £6
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Base Coat', 7)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => addItem('Base Coat', 7)}
|
||||
>
|
||||
Base Coat - £7
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Top Coat', 7)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => addItem('Top Coat', 7)}
|
||||
>
|
||||
Top Coat - £7
|
||||
</Button>
|
||||
<div class="relative">
|
||||
{#if showGiftCardInput}
|
||||
<div class="flex gap-1">
|
||||
<div class="relative flex-1">
|
||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-xs text-gray-400">£</span>
|
||||
<span class="absolute top-1/2 left-2 -translate-y-1/2 text-xs text-gray-400"
|
||||
>£</span
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
bind:value={giftCardAmount}
|
||||
class="h-9 pl-5 text-sm"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') addGiftCard(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') addGiftCard();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs">Add</Button>
|
||||
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs"
|
||||
>Add</Button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<Button variant="outline" size="sm" class="w-full justify-start gap-2" onclick={() => showGiftCardInput = true}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full justify-start gap-2"
|
||||
onclick={() => (showGiftCardInput = true)}
|
||||
>
|
||||
Gift Card
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -100,16 +138,20 @@
|
||||
|
||||
<div class="px-4 py-3">
|
||||
{#if cart.length === 0}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">Tap items above to add them to the sale.</p>
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">
|
||||
Tap items above to add them to the sale.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="max-h-48 space-y-1 overflow-y-auto">
|
||||
{#each cart as item (item.id)}
|
||||
<div class="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="font-medium text-card-foreground">{item.label}</span>
|
||||
<span class="ml-2 text-xs text-muted-foreground">{formatCurrency(item.price)} each</span>
|
||||
<span class="ml-2 text-xs text-muted-foreground"
|
||||
>{formatCurrency(item.price)} each</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent"
|
||||
@@ -125,14 +167,22 @@
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span class="w-14 text-right text-sm font-semibold tabular-nums">{formatCurrency(item.price * item.qty)}</span>
|
||||
<span class="w-14 text-right text-sm font-semibold tabular-nums"
|
||||
>{formatCurrency(item.price * item.qty)}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove item"
|
||||
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-xs text-muted-foreground hover:bg-red-50 hover:text-red-600"
|
||||
onclick={() => removeItem(item.id)}
|
||||
>
|
||||
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -153,7 +203,9 @@
|
||||
<Button class="mt-3 w-full" disabled>
|
||||
Charge {formatCurrency(subtotal)}
|
||||
</Button>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Payment flow and backend integration coming soon.</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Payment flow and backend integration coming soon.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -719,7 +719,9 @@
|
||||
size="sm"
|
||||
class="flex-1 text-xs"
|
||||
onclick={() => {
|
||||
newStartDate = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
newStartDate = new Date().toLocaleDateString('en-CA', {
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
onStartDateChange();
|
||||
}}
|
||||
>
|
||||
@@ -731,7 +733,9 @@
|
||||
size="sm"
|
||||
class="flex-1 text-xs"
|
||||
onclick={() => {
|
||||
newStartDate = new Date(Date.now() + 86400000).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
newStartDate = new Date(Date.now() + 86400000).toLocaleDateString('en-CA', {
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
onStartDateChange();
|
||||
}}
|
||||
>
|
||||
@@ -860,7 +864,13 @@
|
||||
{#each overlappingBookings as booking (booking.id)}
|
||||
<div class="rounded-md border border-amber-200 bg-white p-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
|
||||
<div class="text-sm font-medium">
|
||||
{formatUserName(
|
||||
booking.user?.full_name || 'Unknown',
|
||||
booking.user?.previous_first_name,
|
||||
booking.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import PatchTestModal from './PatchTestModal.svelte';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import PatchTestModal from './PatchTestModal.svelte';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -252,7 +252,9 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
const data = await res.json();
|
||||
giftCardBalance = data.balance;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -301,15 +303,21 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
{#if selectedUser}
|
||||
<div class="space-y-6 px-4 pb-4">
|
||||
<!-- Personal Information -->
|
||||
<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">
|
||||
Personal Information
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Full Name</div>
|
||||
<div class="font-medium">{formatUserName(selectedUser.fullName, selectedUser.previousFirstName, selectedUser.previousLastName)}</div>
|
||||
</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">
|
||||
Personal Information
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Full Name</div>
|
||||
<div class="font-medium">
|
||||
{formatUserName(
|
||||
selectedUser.fullName,
|
||||
selectedUser.previousFirstName,
|
||||
selectedUser.previousLastName
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Email</div>
|
||||
<div class="font-medium break-words" title={selectedUser.email}>
|
||||
@@ -637,7 +645,11 @@ import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
<PatchTestModal
|
||||
bind:open={showPatchTestModal}
|
||||
userId={selectedUser.id}
|
||||
userName={formatUserName(selectedUser.fullName, selectedUser.previousFirstName, selectedUser.previousLastName)}
|
||||
userName={formatUserName(
|
||||
selectedUser.fullName,
|
||||
selectedUser.previousFirstName,
|
||||
selectedUser.previousLastName
|
||||
)}
|
||||
onPatchTestAdded={() => {
|
||||
fetchUserDetails();
|
||||
}}
|
||||
|
||||
@@ -192,7 +192,9 @@
|
||||
{#each users as user (user.id)}
|
||||
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
||||
<div>
|
||||
<div class="font-medium">{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}</div>
|
||||
<div class="font-medium">
|
||||
{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{user.email || '—'} • {user.phone || '—'}
|
||||
</div>
|
||||
|
||||
@@ -167,7 +167,9 @@
|
||||
|
||||
// --- Walk-in lunch protection ---
|
||||
// Always block the first 60 minutes of the suggested lunch window from walk-in availability
|
||||
const dayStartMinutesVal = Math.min(...todayData.slots.map((s) => timeToMinutes(s.startTime)));
|
||||
const dayStartMinutesVal = Math.min(
|
||||
...todayData.slots.map((s) => timeToMinutes(s.startTime))
|
||||
);
|
||||
const dayEndMinutesVal = Math.max(...todayData.slots.map((s) => timeToMinutes(s.endTime)));
|
||||
const dayStartTimeVal = minutesToTime(dayStartMinutesVal);
|
||||
const dayEndTimeVal = minutesToTime(dayEndMinutesVal);
|
||||
|
||||
@@ -52,7 +52,15 @@
|
||||
let userType = $state<'member' | 'guest'>('member');
|
||||
let userQuery = $state('');
|
||||
let users = $state<
|
||||
Array<{ id: string; full_name: string; email?: string; phone?: string; account_role: string; previous_first_name?: string | null; previous_last_name?: string | null }>
|
||||
Array<{
|
||||
id: string;
|
||||
full_name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
account_role: string;
|
||||
previous_first_name?: string | null;
|
||||
previous_last_name?: string | null;
|
||||
}>
|
||||
>([]);
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
let guestName = $state('');
|
||||
@@ -70,7 +78,13 @@
|
||||
let customSearchQuery = $state('');
|
||||
let loadingCustomServices = $state(false);
|
||||
let showCustomCreateForm = $state(false);
|
||||
let newCustomService = $state({ name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' });
|
||||
let newCustomService = $state({
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
});
|
||||
let creatingCustomService = $state(false);
|
||||
let customServiceErrors = $state<Record<string, string>>({});
|
||||
|
||||
@@ -112,16 +126,22 @@
|
||||
}
|
||||
let isCustomFormValid = $derived(
|
||||
(newCustomService.name ?? '').trim() !== '' &&
|
||||
!customServiceErrors.name &&
|
||||
!customServiceErrors.price &&
|
||||
!customServiceErrors.duration_minutes &&
|
||||
!customServiceErrors.minimum_age_required
|
||||
!customServiceErrors.name &&
|
||||
!customServiceErrors.price &&
|
||||
!customServiceErrors.duration_minutes &&
|
||||
!customServiceErrors.minimum_age_required
|
||||
);
|
||||
|
||||
function toggleCustomForm(show: boolean) {
|
||||
showCustomCreateForm = show;
|
||||
if (show) {
|
||||
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
newCustomService = {
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
};
|
||||
customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
}
|
||||
}
|
||||
@@ -388,7 +408,13 @@
|
||||
}
|
||||
};
|
||||
showCustomCreateForm = false;
|
||||
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
newCustomService = {
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
};
|
||||
customServiceErrors = { name: '', price: '', duration_minutes: '' };
|
||||
toast.success('Custom service created and added');
|
||||
} else {
|
||||
@@ -467,7 +493,12 @@
|
||||
} else {
|
||||
// Fallback: Calculate immediate start time (rounded to next 15 min)
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const londonTimeStr = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const londonTimeStr = new Date().toLocaleTimeString('en-GB', {
|
||||
timeZone: 'Europe/London',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
const [h, min] = londonTimeStr.split(':').map(Number);
|
||||
const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
|
||||
@@ -502,13 +533,19 @@
|
||||
start_time: string;
|
||||
service_ids: string[];
|
||||
custom_service_ids: string[];
|
||||
service_overrides: Array<{ service_id: string; override_price: number | null; override_duration_minutes: number | null }> | undefined;
|
||||
service_overrides:
|
||||
| Array<{
|
||||
service_id: string;
|
||||
override_price: number | null;
|
||||
override_duration_minutes: number | null;
|
||||
}>
|
||||
| undefined;
|
||||
notes: string | null;
|
||||
} = {
|
||||
user_id: finalUserId,
|
||||
start_time: dateTimeStr,
|
||||
service_ids: selectedServices.filter(s => !s.is_custom).map((s) => s.id),
|
||||
custom_service_ids: selectedServices.filter(s => s.is_custom).map((s) => s.id),
|
||||
service_ids: selectedServices.filter((s) => !s.is_custom).map((s) => s.id),
|
||||
custom_service_ids: selectedServices.filter((s) => s.is_custom).map((s) => s.id),
|
||||
service_overrides: overrides.length > 0 ? overrides : undefined,
|
||||
notes: notes.trim() || null
|
||||
};
|
||||
@@ -720,7 +757,13 @@
|
||||
onclick={() => (selectedUserId = user.id)}
|
||||
>
|
||||
<div>
|
||||
<div class="text-base font-medium">{formatUserName(user.full_name, user.previous_first_name, user.previous_last_name)}</div>
|
||||
<div class="text-base font-medium">
|
||||
{formatUserName(
|
||||
user.full_name,
|
||||
user.previous_first_name,
|
||||
user.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{#if user.email && user.phone}
|
||||
{user.email} • {user.phone}
|
||||
@@ -828,7 +871,9 @@
|
||||
<Input
|
||||
placeholder="Search existing custom services..."
|
||||
bind:value={customSearchQuery}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') fetchCustomServices(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') fetchCustomServices();
|
||||
}}
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onclick={fetchCustomServices}>Search</Button>
|
||||
@@ -861,12 +906,23 @@
|
||||
}}
|
||||
>
|
||||
<span class="font-medium">{cs.name}</span>
|
||||
<span class="text-gray-500">{cs.duration_minutes} min • £{cs.price.toFixed(2)}{cs.usage_count > 0 ? ` (${cs.usage_count}×)` : ''}</span>
|
||||
<span class="text-gray-500"
|
||||
>{cs.duration_minutes} min • £{cs.price.toFixed(2)}{cs.usage_count > 0
|
||||
? ` (${cs.usage_count}×)`
|
||||
: ''}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={() => { showCustomCreateForm = true; }} class="w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
showCustomCreateForm = true;
|
||||
}}
|
||||
class="w-full"
|
||||
>
|
||||
+ Create new custom service
|
||||
</Button>
|
||||
</div>
|
||||
@@ -877,8 +933,10 @@
|
||||
<Input
|
||||
id="walkin-cs-name"
|
||||
bind:value={newCustomService.name}
|
||||
oninput={() => customServiceErrors.name = validateCsName(newCustomService.name)}
|
||||
onblur={() => customServiceErrors.name = validateCsName(newCustomService.name)}
|
||||
oninput={() =>
|
||||
(customServiceErrors.name = validateCsName(newCustomService.name))}
|
||||
onblur={() =>
|
||||
(customServiceErrors.name = validateCsName(newCustomService.name))}
|
||||
placeholder="e.g., Bridal Party French Tips"
|
||||
class={customServiceErrors.name ? 'border-red-500' : ''}
|
||||
/>
|
||||
@@ -904,8 +962,10 @@
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={newCustomService.price}
|
||||
oninput={() => customServiceErrors.price = validateCsPrice(newCustomService.price)}
|
||||
onblur={() => customServiceErrors.price = validateCsPrice(newCustomService.price)}
|
||||
oninput={() =>
|
||||
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
|
||||
onblur={() =>
|
||||
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
|
||||
placeholder="0.00"
|
||||
class={customServiceErrors.price ? 'border-red-500' : ''}
|
||||
/>
|
||||
@@ -918,12 +978,21 @@
|
||||
<select
|
||||
id="walkin-cs-dur"
|
||||
bind:value={newCustomService.duration_minutes}
|
||||
onchange={() => customServiceErrors.duration_minutes = validateCsDuration(newCustomService.duration_minutes)}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {customServiceErrors.duration_minutes ? 'border-red-500' : ''}"
|
||||
onchange={() =>
|
||||
(customServiceErrors.duration_minutes = validateCsDuration(
|
||||
newCustomService.duration_minutes
|
||||
))}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {customServiceErrors.duration_minutes
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{#each durationOptions as mins (mins)}
|
||||
<option value={mins}>{mins} min{mins >= 60 ? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})` : ''}</option>
|
||||
<option value={mins}
|
||||
>{mins} min{mins >= 60
|
||||
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
|
||||
: ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{#if customServiceErrors.duration_minutes}
|
||||
@@ -941,8 +1010,13 @@
|
||||
max="100"
|
||||
placeholder="0"
|
||||
bind:value={newCustomService.minimum_age_required}
|
||||
oninput={() => customServiceErrors.minimum_age_required = validateCsMinimumAge(newCustomService.minimum_age_required)}
|
||||
class="w-full {customServiceErrors.minimum_age_required ? 'border-red-500' : ''}"
|
||||
oninput={() =>
|
||||
(customServiceErrors.minimum_age_required = validateCsMinimumAge(
|
||||
newCustomService.minimum_age_required
|
||||
))}
|
||||
class="w-full {customServiceErrors.minimum_age_required
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
/>
|
||||
{#if customServiceErrors.minimum_age_required}
|
||||
<p class="text-xs text-red-600">{customServiceErrors.minimum_age_required}</p>
|
||||
@@ -950,7 +1024,11 @@
|
||||
<p class="text-xs text-gray-500">0 for no age restriction</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={createCustomService} disabled={creatingCustomService || !isCustomFormValid}>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={createCustomService}
|
||||
disabled={creatingCustomService || !isCustomFormValid}
|
||||
>
|
||||
{creatingCustomService ? 'Creating...' : 'Save & Add'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => toggleCustomForm(false)}>
|
||||
|
||||
Reference in New Issue
Block a user