booking modal part 1

This commit is contained in:
2025-10-22 23:29:38 +01:00
parent 94404c7b3b
commit 56ff4132b4
6 changed files with 3447 additions and 313 deletions
-1
View File
@@ -18,7 +18,6 @@
} else {
toasterPosition = 'top-center';
}
console.log('Toaster position:', toasterPosition);
}
onMount(() => {
+247 -79
View File
@@ -463,17 +463,7 @@
loyalty_stamps?: number;
};
type Booking = {
id: string;
user_id?: string;
start_time: string;
status: 'Confirmed' | 'Completed' | 'Cancelled';
created_at: string;
services?: { id: string; name: string }[];
};
let userQuery = $state('');
let bookingQuery = $state('');
// DEMO DATA: Users
let users = $state<User[]>([
@@ -499,43 +489,171 @@
}
]);
// DEMO DATA: Bookings
let bookings = $state<Booking[]>([
{
id: 'book1',
user_id: 'user1',
start_time: '2025-10-15T10:00:00Z',
status: 'Confirmed',
created_at: '2025-10-01T09:00:00Z',
services: [{ id: 'svc1', name: 'Haircut' }]
},
{
id: 'book2',
user_id: 'user2',
start_time: '2025-10-14T15:00:00Z',
status: 'Completed',
created_at: '2025-09-28T12:00:00Z',
services: [
{ id: 'svc2', name: 'Color' },
{ id: 'svc1', name: 'Haircut' }
]
},
{
id: 'book3',
user_id: 'user1',
start_time: '2025-10-22T11:00:00Z',
status: 'Confirmed',
created_at: '2025-10-10T12:00:00Z',
services: [{ id: 'svc3', name: 'Blowdry' }]
}
]);
type Booking = {
id: string;
user_id: string;
start_time: string;
status: 'Confirmed' | 'Completed' | 'Cancelled' | 'Pending' | 'In Progress';
notes?: string;
created_at: string;
services?: { id: string; name: string }[];
user?: {
full_name?: string;
email?: string;
phone?: string;
};
};
let bookings = $state<Booking[]>([]);
let bookingQuery = $state('');
let loadingSearch = $state(false);
let selectedUser = $state<User | null>(null);
let selectedBooking = $state<Booking | null>(null);
let showBookingModal = $state(false);
// Fetch bookings from API
async function fetchBookings() {
if (pageState !== 'authorized') return;
loadingSearch = true;
try {
const response = await fetch('/api/admin/bookings', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
if (data.bookings.length === 0) {
return;
}
bookings = data.bookings.map((b: any) => ({
id: b.id,
user_id: b.user_id,
start_time: b.start_time,
status: b.status,
notes: b.notes,
created_at: b.created_at,
services: b.services?.map((s: any) => ({
id: s.service_id,
name: s.service_name || 'Unknown Service'
})),
user: {
full_name: b.user?.full_name,
email: b.user?.email,
phone: b.user?.phone
}
}));
console.log(bookings);
} else {
const text = await response.text();
toast.error('Failed to load bookings: ' + text);
}
} catch (err) {
console.error('Error fetching bookings:', err);
toast.error('Network error loading bookings');
} finally {
loadingSearch = false;
}
}
// Search bookings via API
async function searchBookings() {
if (pageState !== 'authorized') return;
loadingSearch = true;
try {
const response = await fetch(
`/api/admin/bookings/search?q=${encodeURIComponent(bookingQuery)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (response.ok) {
const data = await response.json();
bookings = data.bookings.map((b: any) => ({
id: b.booking.id,
user_id: b.booking.user_id,
start_time: b.booking.start_time,
status: b.booking.status,
notes: b.booking.notes,
created_at: b.booking.created_at,
services: b.services?.map((s: any) => ({
id: s.service_id,
name: s.service_name || 'Unknown Service'
})),
user: {
full_name: b.user?.full_name,
email: b.user?.email,
phone: b.user?.phone
}
}));
} else {
const text = await response.text();
toast.error('Failed to search bookings: ' + text);
}
} catch (err) {
console.error('Error searching bookings:', err);
toast.error('Network error searching bookings');
} finally {
loadingSearch = false;
}
}
// Open booking modal with full details
async function openBookingModal(bookingId: string) {
if (pageState !== 'authorized') return;
try {
const response = await fetch(`/api/admin/bookings/${bookingId}/summary`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
selectedBooking = {
id: data.booking.id,
user_id: data.booking.user_id,
start_time: data.booking.start_time,
status: data.booking.status,
notes: data.booking.notes,
created_at: data.booking.created_at,
services: data.services?.map((s: any) => ({
id: s.service_id,
name: s.service_name || 'Unknown Service'
})),
user: {
full_name: data.user?.full_name,
email: data.user?.email,
phone: data.user?.phone
}
};
showBookingModal = true;
} else {
const text = await response.text();
toast.error('Failed to load booking details: ' + text);
}
} catch (err) {
console.error('Error fetching booking details:', err);
toast.error('Network error loading booking details');
}
}
// Fetch bookings on page load
$effect(() => {
if (pageState === 'authorized') {
fetchBookings();
}
});
let selectedUser = $state<User | null>(null);
let bookingUserHistory = $state<Booking[]>([]);
let showUserModal = $state(false);
let showBookingModal = $state(false);
// Uses DEMO data for search
async function searchUsers() {
@@ -563,23 +681,6 @@
loadingSearch = false;
}
// Uses DEMO data for search
async function searchBookings() {
loadingSearch = true;
await new Promise((r) => setTimeout(r, 500));
const query = bookingQuery.toLowerCase();
bookings = bookings.filter(
(b) =>
b.id.includes(query) ||
b.services?.some((s) => s.name.toLowerCase().includes(query)) ||
users
.find((u) => u.id === b.user_id)
?.fn?.toLowerCase()
.includes(query)
);
loadingSearch = false;
}
async function openUserModal(userId: string) {
selectedUser = users.find((u) => u.id === userId) || null;
if (!selectedUser) return;
@@ -592,13 +693,6 @@
showUserModal = true;
}
async function openBookingModal(bookingId: string) {
selectedBooking = bookings.find((b) => b.id === bookingId) || null;
if (!selectedBooking) return;
showBookingModal = true;
}
// =============== Helpers ===============
function weekdayLabel(i: number) {
return dayNames[i];
@@ -1330,27 +1424,40 @@
<div>
<div class="flex gap-2">
<Input
placeholder="Booking ID or user"
placeholder="Search by name, email, phone, or service"
bind:value={bookingQuery}
onkeyup={(e) => {
if ((e as KeyboardEvent).key === 'Enter') searchBookings();
}}
/>
<Button onclick={searchBookings} disabled={loadingSearch}>Search</Button>
<Button onclick={searchBookings} disabled={loadingSearch}>
{loadingSearch ? 'Searching...' : 'Search'}
</Button>
</div>
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
{#each bookings as b}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">{new Date(b.start_time).toLocaleString()}</div>
<div class="text-xs text-gray-500">
{b.status} • {b.services?.map((s) => s.name).join(', ')}
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
{#if loadingSearch}
<div class="flex items-center justify-center p-4">
<Skeleton class="h-4 w-32" />
</div>
{/each}
{:else if bookings.length === 0}
<div class="text-center text-sm text-gray-500">No bookings found.</div>
{:else}
{#each bookings as b}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">
{new Date(b.start_time).toLocaleString()}
</div>
<div class="text-xs text-gray-500">
{b.status} • {b.user?.full_name || 'Unknown User'} • {b.services
?.map((s) => s.name)
.join(', ')}
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
{/if}
</div>
</div>
</Card.Content>
@@ -2360,6 +2467,67 @@
</Modal.Root>
{/if}
{#if selectedBooking}
<Modal.Root bind:open={showBookingModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">
Booking: {selectedBooking.id}
</Modal.Title>
</Modal.Header>
<!-- Main Booking Details Grid -->
<div class="grid gap-4 px-4 pb-4 md:grid-cols-2">
<!-- Column 1: Customer Details -->
<div>
<div class="text-sm text-gray-500">Customer</div>
<div class="font-medium">
{selectedBooking.user?.full_name || 'Unknown User'}
</div>
<div class="mt-2 text-sm text-gray-500">Email</div>
<div class="font-medium">{selectedBooking.user?.email || '—'}</div>
<div class="mt-2 text-sm text-gray-500">Phone</div>
<div class="font-medium">{selectedBooking.user?.phone || '—'}</div>
<div class="mt-2 text-sm text-gray-500">Status</div>
<div class="font-medium">{selectedBooking.status}</div>
</div>
<!-- Column 2: Appointment Details -->
<div>
<div class="text-sm text-gray-500">Scheduled</div>
<div class="font-medium">
{new Date(selectedBooking.start_time).toLocaleString()}
</div>
<div class="mt-2 text-sm text-gray-500">Services</div>
<div class="mb-4 font-medium">
{selectedBooking.services?.map((s) => s.name).join(', ') || '—'}
</div>
</div>
<!-- Notes Section - now full width and visually emphasized -->
<div class="md:col-span-2">
<div class="mb-1 text-sm text-gray-500">Customer Notes</div>
{#if selectedBooking.notes && selectedBooking.notes.length > 0}
<!-- Applied amber styling to the actual notes content -->
<div
class="rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm font-medium text-amber-900"
>
{selectedBooking.notes || 'No customer notes provided.'}
</div>
{:else}
<div class="mb-4 font-medium"></div>
{/if}
</div>
<!-- END NOTES SECTION -->
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button onclick={() => (showBookingModal = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{/if}
<!-- Booking Modal -->
{#if showServiceModal}
<!-- Add Service Modal -->