220 lines
6.1 KiB
Svelte
220 lines
6.1 KiB
Svelte
<script lang="ts">
|
|
import { authStore } from '$lib/stores/auth.svelte';
|
|
import { SvelteDate } from 'svelte/reactivity';
|
|
import { toast } from 'svelte-sonner';
|
|
import * as Card from '$lib/components/ui/card';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Badge } from '$lib/components/ui/badge';
|
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
|
|
|
interface Props {
|
|
openBookingModal: (bookingId: string) => void;
|
|
openUserModal: (userId: string) => void;
|
|
}
|
|
|
|
let { openBookingModal, openUserModal }: Props = $props();
|
|
|
|
type TodayAppointment = {
|
|
id: string;
|
|
start_time: string;
|
|
status: string;
|
|
user_name: string;
|
|
user_id: string;
|
|
services: string[];
|
|
duration_minutes: number;
|
|
};
|
|
|
|
function isPastAppointment(startTime: string, durationMinutes: number): boolean {
|
|
const start = new Date(startTime);
|
|
const end = new Date(start.getTime() + durationMinutes * 60_000);
|
|
return end.getTime() < Date.now();
|
|
}
|
|
|
|
let appointments = $state<TodayAppointment[]>([]);
|
|
let loading = $state(true);
|
|
|
|
async function fetchTodayAppointments() {
|
|
loading = true;
|
|
try {
|
|
const response = await fetch('/api/admin/today/appointments', {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
appointments = data.appointments || [];
|
|
} else {
|
|
toast.error("Failed to load today's appointments");
|
|
}
|
|
} catch (err) {
|
|
console.error("Error fetching today's appointments:", err);
|
|
toast.error('Network error loading appointments');
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
// 🔁 Refetch on mount, every minute, and on approval
|
|
$effect(() => {
|
|
// Initial fetch
|
|
fetchTodayAppointments();
|
|
|
|
// Timer: refetch every 60 seconds
|
|
const intervalId = setInterval(() => {
|
|
fetchTodayAppointments();
|
|
}, 60_000);
|
|
|
|
// Listener for approval events
|
|
function handleApproval() {
|
|
fetchTodayAppointments();
|
|
}
|
|
window.addEventListener('bookingApproved', handleApproval);
|
|
|
|
// Cleanup
|
|
return () => {
|
|
clearInterval(intervalId);
|
|
window.removeEventListener('bookingApproved', handleApproval);
|
|
};
|
|
});
|
|
|
|
// Fetch on mount
|
|
$effect(() => {
|
|
fetchTodayAppointments();
|
|
});
|
|
|
|
function getStatusColor(status: string): string {
|
|
switch (status) {
|
|
case 'completed':
|
|
return 'bg-green-100 text-green-800 hover:bg-green-100';
|
|
case 'in_progress':
|
|
return 'bg-blue-100 text-blue-800 hover:bg-blue-100';
|
|
case 'confirmed':
|
|
return 'bg-emerald-100 text-emerald-800 hover:bg-emerald-100';
|
|
case 'pending':
|
|
return 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100';
|
|
case 'client_cancelled':
|
|
case 'we_cancelled':
|
|
return 'bg-red-100 text-red-800 hover:bg-red-100';
|
|
case 'no_show':
|
|
return 'bg-gray-100 text-gray-800 hover:bg-gray-100';
|
|
default:
|
|
return 'bg-gray-100 text-gray-800 hover:bg-gray-100';
|
|
}
|
|
}
|
|
|
|
function getStatusBarColor(status: string): string {
|
|
switch (status) {
|
|
case 'completed':
|
|
return 'bg-green-500';
|
|
case 'in_progress':
|
|
return 'bg-blue-500';
|
|
case 'confirmed':
|
|
return 'bg-emerald-500';
|
|
case 'pending':
|
|
return 'bg-yellow-500';
|
|
case 'client_cancelled':
|
|
case 'we_cancelled':
|
|
return 'bg-red-500';
|
|
case 'no_show':
|
|
return 'bg-gray-500';
|
|
default:
|
|
return 'bg-gray-500';
|
|
}
|
|
}
|
|
|
|
function formatTime(dateString: string): string {
|
|
const date = new SvelteDate(dateString);
|
|
return date.toLocaleTimeString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
});
|
|
}
|
|
|
|
function formatStatus(status: string): string {
|
|
return status.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
|
}
|
|
</script>
|
|
|
|
<div class="lg:col-span-2">
|
|
<Card.Root>
|
|
<Card.Header>
|
|
<Card.Title>Today's Appointments</Card.Title>
|
|
<Card.Description>Timeline view of all bookings</Card.Description>
|
|
</Card.Header>
|
|
<Card.Content>
|
|
{#if loading}
|
|
<div class="space-y-3">
|
|
{#each Array(5) as _, i (i)}
|
|
<div class="flex items-center gap-4 rounded-lg border p-3">
|
|
<Skeleton class="h-4 w-20" />
|
|
<Skeleton class="h-10 w-1" />
|
|
<div class="flex-1 space-y-2">
|
|
<Skeleton class="h-4 w-32" />
|
|
<Skeleton class="h-3 w-48" />
|
|
</div>
|
|
<Skeleton class="h-6 w-20" />
|
|
<Skeleton class="h-8 w-16" />
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else if appointments.length === 0}
|
|
<div class="py-12 text-center">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="mx-auto mb-4 h-16 w-16 text-gray-300"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
<p class="text-lg font-medium text-gray-600">No appointments today</p>
|
|
<p class="text-sm text-gray-500">Looks like you have a quiet day</p>
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-3">
|
|
{#each appointments as apt (apt.id)}
|
|
<div
|
|
class="flex items-center gap-4 rounded-lg border p-3 transition-all hover:shadow-md
|
|
{isPastAppointment(apt.start_time, apt.duration_minutes) ? 'line-through opacity-50' : ''}"
|
|
>
|
|
<div class="min-w-[80px] text-sm font-semibold text-gray-700">
|
|
{formatTime(apt.start_time)}
|
|
</div>
|
|
<div class="h-10 w-1 rounded {getStatusBarColor(apt.status)}"></div>
|
|
<div class="flex-1">
|
|
<button
|
|
type="button"
|
|
class="font-medium hover:text-blue-600 hover:underline"
|
|
onclick={() => openUserModal(apt.user_id)}
|
|
>
|
|
{apt.user_name}
|
|
</button>
|
|
<div class="text-sm text-gray-600">
|
|
{apt.services.join(', ')} • {apt.duration_minutes} min
|
|
</div>
|
|
</div>
|
|
<Badge class={getStatusColor(apt.status)}>
|
|
{formatStatus(apt.status)}
|
|
</Badge>
|
|
<Button size="sm" variant="outline" onclick={() => openBookingModal(apt.id)}>
|
|
View
|
|
</Button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</Card.Content>
|
|
</Card.Root>
|
|
</div>
|