Files
Crussell/frontend/src/lib/components/today/TodayCalendar.svelte
T
2026-01-24 22:08:36 +00:00

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>