feat(bookings): improve admin booking wizard and user dashboard
Backend: - Enriched GetAllUserBookings response with calculated total_amount, amount_paid, and duration_minutes. - Refactored GetBookingHandler to return a flat booking object matching frontend expectations. - Added account_role to admin user list response and sorted users by booking activity. - Corrected function name oo to AdminCreateBookingForUserHandler. Frontend: - Rebuilt BookingCreateModal into a 4-step wizard supporting guest bookings, service overrides, and real-time availability checks. - Fixed account dashboard logic to correctly identify upcoming vs past bookings and sort unpaid items to the top. - Extracted booking flow into a shared BookingFlow component. - Redirected admin users from home page to /today.
This commit is contained in:
@@ -0,0 +1,718 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { getLocalTimeZone } from '@internationalized/date';
|
||||
|
||||
// UI Components
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
// Booking Components
|
||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
||||
|
||||
// Types
|
||||
import type { Service } from '$lib/types/booking';
|
||||
|
||||
// =============== Props ===============
|
||||
interface Props {
|
||||
open: boolean;
|
||||
maxSlotDuration?: number;
|
||||
availableStartTime?: string; // "HH:MM" or "HH:MM:SS" format from the available slot
|
||||
onBookingCreated?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
maxSlotDuration = 0,
|
||||
availableStartTime,
|
||||
onBookingCreated
|
||||
}: Props = $props();
|
||||
|
||||
// =============== State ===============
|
||||
let currentStep = $state(1);
|
||||
|
||||
// Step 1: Customer Selection
|
||||
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 }>
|
||||
>([]);
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
let guestName = $state('');
|
||||
let guestPhone = $state('');
|
||||
let loadingUsers = $state(false);
|
||||
|
||||
// Step 2: Services
|
||||
let services = $state<Service[]>([]);
|
||||
let selectedServices = $state<Service[]>([]);
|
||||
let loadingServices = $state(true);
|
||||
|
||||
// Step 3: Service Overrides & Notes
|
||||
let notes = $state('');
|
||||
let serviceOverrides = $state<
|
||||
Record<
|
||||
string,
|
||||
{ price: string; duration: string; originalPrice: number; originalDuration: number }
|
||||
>
|
||||
>({});
|
||||
|
||||
let submitting = $state(false);
|
||||
|
||||
// =============== Derived Helpers ===============
|
||||
function getTotalDuration() {
|
||||
return selectedServices.reduce((total, service) => {
|
||||
const override = serviceOverrides[service.id];
|
||||
const duration =
|
||||
override && override.duration ? parseInt(override.duration) : service.duration_minutes;
|
||||
return total + (isNaN(duration) ? 0 : duration);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getTotalPrice() {
|
||||
return selectedServices.reduce((total, service) => {
|
||||
const override = serviceOverrides[service.id];
|
||||
const price = override && override.price ? parseFloat(override.price) : service.price;
|
||||
return total + (isNaN(price) ? 0 : price);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0 && mins > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
|
||||
|
||||
const isOverDuration = $derived(getTotalDuration() > maxSlotDuration);
|
||||
|
||||
const canProceedStep1 = $derived(
|
||||
userType === 'member' ? !!selectedUserId : !!(guestName.trim() && guestPhone.trim())
|
||||
);
|
||||
const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration);
|
||||
|
||||
// =============== Effects ===============
|
||||
let wasOpen = false;
|
||||
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) {
|
||||
resetState();
|
||||
fetchServices();
|
||||
fetchUsers();
|
||||
}
|
||||
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
function resetState() {
|
||||
currentStep = 1;
|
||||
userType = 'member';
|
||||
userQuery = '';
|
||||
users = [];
|
||||
selectedUserId = null;
|
||||
guestName = '';
|
||||
guestPhone = '';
|
||||
selectedServices = [];
|
||||
notes = '';
|
||||
serviceOverrides = {};
|
||||
}
|
||||
|
||||
// =============== Data Fetching ===============
|
||||
async function fetchUsers() {
|
||||
loadingUsers = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/users?page=1&per_page=10&q=${encodeURIComponent(userQuery)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Filter out specific roles
|
||||
const excludedRoles = ['admin', 'guest', 'affiliate'];
|
||||
users = (data.users || []).filter(
|
||||
(user: { account_role: string }) => !excludedRoles.includes(user.account_role)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users', err);
|
||||
toast.error('Failed to load users');
|
||||
} finally {
|
||||
loadingUsers = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchServices() {
|
||||
loadingServices = true;
|
||||
try {
|
||||
const response = await fetch('/api/services', {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
services = await response.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch services', err);
|
||||
toast.error('Failed to load services');
|
||||
} finally {
|
||||
loadingServices = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Logic ===============
|
||||
function toggleService(service: Service) {
|
||||
const index = selectedServices.findIndex((s) => s.id === service.id);
|
||||
if (index >= 0) {
|
||||
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
||||
const newOverrides = { ...serviceOverrides };
|
||||
delete newOverrides[service.id];
|
||||
serviceOverrides = newOverrides;
|
||||
} else {
|
||||
selectedServices = [...selectedServices, service];
|
||||
serviceOverrides = {
|
||||
...serviceOverrides,
|
||||
[service.id]: {
|
||||
price: service.price.toFixed(2),
|
||||
duration: service.duration_minutes.toString(),
|
||||
originalPrice: service.price,
|
||||
originalDuration: service.duration_minutes
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Submission ===============
|
||||
async function submitBooking() {
|
||||
submitting = true;
|
||||
|
||||
try {
|
||||
// Validate duration doesn't exceed available slot
|
||||
if (maxSlotDuration > 0 && getTotalDuration() > maxSlotDuration) {
|
||||
toast.error(
|
||||
`Selected services (${formattedTotalDuration}) exceed available slot (${formatDuration(maxSlotDuration)})`
|
||||
);
|
||||
submitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let finalUserId = selectedUserId;
|
||||
|
||||
if (userType === 'guest') {
|
||||
// TODO: Implement /api/users/guest endpoint
|
||||
// For now, show error
|
||||
toast.error('Guest booking not yet implemented');
|
||||
return;
|
||||
|
||||
// const createRes = await fetch('/api/users/guest', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// Authorization: `Bearer ${authStore.currentToken}`
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// name: guestName,
|
||||
// phone: guestPhone
|
||||
// })
|
||||
// });
|
||||
|
||||
// if (!createRes.ok) {
|
||||
// toast.error('Failed to create guest user');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const guestUser = await createRes.json();
|
||||
// finalUserId = guestUser.id;
|
||||
}
|
||||
|
||||
if (!finalUserId) throw new Error('User ID required');
|
||||
|
||||
// Use the available slot start time from the widget
|
||||
let start: Date;
|
||||
|
||||
if (availableStartTime) {
|
||||
// Parse the time from the widget (format: "HH:MM" or "HH:MM:SS")
|
||||
const [hours, minutes] = availableStartTime.split(':').map(Number);
|
||||
const now = new SvelteDate();
|
||||
start = new SvelteDate(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
hours,
|
||||
minutes,
|
||||
0,
|
||||
0
|
||||
);
|
||||
} else {
|
||||
// Fallback: Calculate immediate start time (rounded to next 15 min)
|
||||
const now = new SvelteDate();
|
||||
start = new SvelteDate(now);
|
||||
const minutes = start.getMinutes();
|
||||
const remainder = 15 - (minutes % 15);
|
||||
if (remainder !== 15 && remainder !== 0) {
|
||||
start.setMinutes(minutes + remainder);
|
||||
}
|
||||
start.setSeconds(0);
|
||||
start.setMilliseconds(0);
|
||||
}
|
||||
|
||||
const dateTimeStr = start.toISOString();
|
||||
|
||||
const overrides = [];
|
||||
for (const [serviceId, data] of Object.entries(serviceOverrides)) {
|
||||
const priceChanged = Math.abs(parseFloat(data.price) - data.originalPrice) > 0.01;
|
||||
const durationChanged = parseInt(data.duration) !== data.originalDuration;
|
||||
|
||||
if (priceChanged || durationChanged) {
|
||||
overrides.push({
|
||||
service_id: serviceId,
|
||||
override_price: priceChanged ? parseFloat(data.price) : null,
|
||||
override_duration_minutes: durationChanged ? parseInt(data.duration) : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
user_id: finalUserId,
|
||||
start_time: dateTimeStr,
|
||||
service_ids: selectedServices.map((s) => s.id),
|
||||
service_overrides: overrides.length > 0 ? overrides : undefined,
|
||||
notes: notes.trim() || null
|
||||
};
|
||||
|
||||
const res = await fetch('/api/admin/bookings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('Booking created successfully!');
|
||||
open = false;
|
||||
onBookingCreated?.();
|
||||
} else {
|
||||
const errorText = await res.text();
|
||||
console.error('Booking creation failed:', errorText);
|
||||
toast.error(`Failed to create booking: ${errorText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Booking submission error:', err);
|
||||
toast.error('An error occurred while creating booking');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Input handlers
|
||||
function handlePriceInput(serviceId: string, value: string) {
|
||||
const override = serviceOverrides[serviceId];
|
||||
if (!override) return;
|
||||
|
||||
let cleaned = value.replace(/[^\d.]/g, '');
|
||||
const parts = cleaned.split('.');
|
||||
if (parts.length > 2) cleaned = parts[0] + '.' + parts.slice(1).join('');
|
||||
if (cleaned.includes('.')) {
|
||||
const [int, dec] = cleaned.split('.');
|
||||
cleaned = int + '.' + dec.substring(0, 2);
|
||||
}
|
||||
|
||||
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, price: cleaned } };
|
||||
}
|
||||
|
||||
function handleDurationInput(serviceId: string, value: string) {
|
||||
const override = serviceOverrides[serviceId];
|
||||
if (!override) return;
|
||||
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, duration: cleaned } };
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Walk-In Booking</Modal.Title>
|
||||
<Modal.Description>
|
||||
Quickly book a walk-in customer with immediate time slot reservation
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="px-6 pb-4">
|
||||
<!-- Step 1: Customer Selection -->
|
||||
{#if currentStep === 1}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Select Customer</Card.Title>
|
||||
<Card.Description>Choose an existing member or create a guest booking</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-6 border-b border-gray-200">
|
||||
<button
|
||||
class="pb-2 text-sm font-medium transition-colors {userType === 'member'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-gray-500 hover:text-gray-700'}"
|
||||
onclick={() => {
|
||||
userType = 'member';
|
||||
selectedUserId = null;
|
||||
}}
|
||||
>
|
||||
Member
|
||||
</button>
|
||||
<button
|
||||
class="pb-2 text-sm font-medium transition-colors {userType === 'guest'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-gray-500 hover:text-gray-700'}"
|
||||
onclick={() => {
|
||||
userType = 'guest';
|
||||
guestName = '';
|
||||
guestPhone = '';
|
||||
}}
|
||||
>
|
||||
Guest / Non-Member
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if userType === 'member'}
|
||||
<!-- Native Input using oninput to prevent reactivity bugs -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="relative flex-1">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-9 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
placeholder="Search by name, email or phone..."
|
||||
value={userQuery}
|
||||
oninput={(e) => {
|
||||
userQuery = e.currentTarget.value;
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
fetchUsers();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button onclick={fetchUsers} disabled={loadingUsers}>
|
||||
{loadingUsers ? '...' : 'Search'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Compact Results List -->
|
||||
<div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200">
|
||||
{#if loadingUsers}
|
||||
<div class="space-y-2 p-2">
|
||||
{#each Array(3) as _}
|
||||
<Skeleton class="h-10 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if users.length === 0}
|
||||
<div class="flex items-center justify-center p-8 text-sm text-gray-500">
|
||||
{userQuery
|
||||
? 'No users found. Try a different search.'
|
||||
: 'Search for a user above to get started.'}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{#each users.slice(0, 4) as user (user.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center justify-between px-4 py-3 text-left transition-colors hover:bg-fuchsia-50 {selectedUserId ===
|
||||
user.id
|
||||
? 'bg-fuchsia-100 font-medium'
|
||||
: ''}"
|
||||
onclick={() => (selectedUserId = user.id)}
|
||||
>
|
||||
<div>
|
||||
<div class="text-base font-medium">{user.full_name}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{#if user.email && user.phone}
|
||||
{user.email} • {user.phone}
|
||||
{:else if user.email}
|
||||
{user.email}
|
||||
{:else if user.phone}
|
||||
{user.phone}
|
||||
{:else}
|
||||
No contact info
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedUserId === user.id}
|
||||
<svg
|
||||
class="h-5 w-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Guest Form - Using Native Input -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="guest-name">Guest Name *</Label>
|
||||
<input
|
||||
id="guest-name"
|
||||
type="text"
|
||||
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"
|
||||
placeholder="Jane Doe"
|
||||
value={guestName}
|
||||
oninput={(e) => (guestName = e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="guest-phone">Phone Number *</Label>
|
||||
<input
|
||||
id="guest-phone"
|
||||
type="tel"
|
||||
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"
|
||||
placeholder="07700 900000"
|
||||
value={guestPhone}
|
||||
oninput={(e) => (guestPhone = e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800">
|
||||
Booking as a guest creates a temporary record. Encourage them to sign up for
|
||||
loyalty benefits.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end">
|
||||
<BookingActions
|
||||
canBack={false}
|
||||
canNext={canProceedStep1}
|
||||
nextLabel="Next: Choose Services"
|
||||
on:next={() => currentStep++}
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 2: Service Selection -->
|
||||
{#if currentStep === 2}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Choose Services</Card.Title>
|
||||
<Card.Description>Select one or more treatments for this appointment</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
{#if loadingServices}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{#each Array(4) as _}
|
||||
<Skeleton class="h-28 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if services.length === 0}
|
||||
<p class="py-8 text-center text-gray-500">No services available.</p>
|
||||
{:else}
|
||||
<ServiceSelector
|
||||
{services}
|
||||
selected={selectedServices}
|
||||
loading={loadingServices}
|
||||
ontoggle={toggleService}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if selectedServices.length > 0}
|
||||
<div class="rounded-lg bg-gray-50 p-4">
|
||||
<h4 class="mb-2 font-semibold">Selected Services</h4>
|
||||
<div class="space-y-2">
|
||||
{#each selectedServices as service (service.id)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>{service.name}</span>
|
||||
<span>{service.duration_minutes} mins • £{service.price}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<Separator class="my-2" />
|
||||
<div class="flex justify-between text-sm font-semibold">
|
||||
<span>Estimated Duration:</span>
|
||||
<span>{formattedTotalDuration}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm font-semibold">
|
||||
<span>Total Cost:</span>
|
||||
<span>£{getTotalPrice()}</span>
|
||||
</div>
|
||||
{#if maxSlotDuration > 0}
|
||||
<Separator class="my-2" />
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>Available Slot Duration:</span>
|
||||
<span class={isOverDuration ? 'font-semibold text-red-600' : ''}>
|
||||
{formatDuration(maxSlotDuration)}
|
||||
</span>
|
||||
</div>
|
||||
{#if isOverDuration}
|
||||
<div class="mt-2 rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
||||
<strong>Warning:</strong> Selected services ({formattedTotalDuration})
|
||||
exceed available slot duration ({formatDuration(maxSlotDuration)}). Please
|
||||
remove services or customize durations.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
|
||||
<Button disabled={!canProceedStep2} onclick={() => currentStep++}>
|
||||
Next: Customize Services
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 3: Service Overrides & Notes -->
|
||||
{#if currentStep === 3}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Customize Services</Card.Title>
|
||||
<Card.Description>
|
||||
Adjust pricing or duration if needed, and add appointment notes
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<div>
|
||||
<h4 class="mb-3 font-semibold">Service Details</h4>
|
||||
<p class="mb-4 text-sm text-gray-600">
|
||||
Override default pricing or duration for special cases (discounts, extended
|
||||
sessions, etc.)
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
{#each selectedServices as service}
|
||||
<!-- Safety check to ensure override exists -->
|
||||
{#if serviceOverrides[service.id]}
|
||||
<div class="rounded-lg border bg-white p-4">
|
||||
<div class="mb-3 font-medium">{service.name}</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="price-{service.id}" class="text-xs text-gray-600"
|
||||
>Price (£)</Label
|
||||
>
|
||||
<!-- Native Input with oninput -->
|
||||
<input
|
||||
id="price-{service.id}"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
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"
|
||||
value={serviceOverrides[service.id]?.price || service.price.toFixed(2)}
|
||||
oninput={(e) => handlePriceInput(service.id, e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="duration-{service.id}" class="text-xs text-gray-600"
|
||||
>Duration (min)</Label
|
||||
>
|
||||
<!-- Native Input with oninput -->
|
||||
<input
|
||||
id="duration-{service.id}"
|
||||
type="number"
|
||||
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"
|
||||
value={serviceOverrides[service.id]?.duration ||
|
||||
service.duration_minutes}
|
||||
oninput={(e) => handleDurationInput(service.id, e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if serviceOverrides[service.id] && (Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 || parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration)}
|
||||
<div class="mt-2 text-xs text-amber-600">
|
||||
{#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01}
|
||||
Price modified from £{serviceOverrides[
|
||||
service.id
|
||||
].originalPrice.toFixed(2)}
|
||||
{/if}
|
||||
|
||||
{#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 && parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration}
|
||||
•
|
||||
{/if}
|
||||
|
||||
{#if parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration}
|
||||
Duration modified from {serviceOverrides[service.id].originalDuration} mins
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-50 p-4">
|
||||
<div class="flex justify-between text-sm font-semibold">
|
||||
<span>Total Duration:</span>
|
||||
<span>{formattedTotalDuration}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between text-sm font-semibold">
|
||||
<span>Total Cost:</span>
|
||||
<span>£{getTotalPrice().toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="notes">Appointment Notes (extras only, client will see this)</Label>
|
||||
<!-- Native Textarea -->
|
||||
<textarea
|
||||
id="notes"
|
||||
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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={notes}
|
||||
placeholder="Any special requirements, preferences, or notes about this booking..."
|
||||
></textarea>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onclick={submitBooking}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{submitting ? 'Creating Booking...' : 'Create Walk-In Booking'}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
Reference in New Issue
Block a user