feat: today calendar with time blockers, stats, and pending approvals improvements

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-28 16:34:13 +01:00
co-authored by Sisyphus
parent db5f6ff135
commit f69750da82
5 changed files with 924 additions and 75 deletions
@@ -186,7 +186,7 @@
<Card.Header>
<div class="flex items-start justify-between">
<div>
<Card.Title class="text-2xl">
<Card.Title class="text-xl md:text-2xl">
{isInProgress ? 'Current Appointment' : 'Next Appointment'}
</Card.Title>
{#if activeAppointment}
@@ -245,7 +245,7 @@
{#if loading}
<Card.Content class="space-y-4">
<div class="flex gap-4">
<Skeleton class="h-20 w-20 rounded-full" />
<Skeleton class="h-16 w-16 md:h-20 md:w-20 rounded-full" />
<div class="flex-1 space-y-2">
<Skeleton class="h-6 w-48" />
<Skeleton class="h-4 w-32" />
@@ -284,12 +284,12 @@
<img
src={activeAppointment.user.profile_pic_url}
alt={activeAppointment.user.full_name}
class="h-20 w-20 rounded-full object-cover ring-4 ring-blue-200"
class="h-16 w-16 md:h-20 md:w-20 rounded-full object-cover ring-4 ring-blue-200"
/>
{:else}
{@const initials = activeAppointment.user?.full_name?.split(' ').map(n => n[0]).join('') || '?'}
<div
class="flex h-20 w-20 items-center justify-center rounded-full bg-gray-200 text-2xl font-bold text-gray-600 ring-4 ring-blue-200"
class="flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-full bg-gray-200 text-2xl font-bold text-gray-600 ring-4 ring-blue-200"
>
{initials}
</div>
@@ -11,9 +11,10 @@
interface Props {
openBookingModal?: (bookingId: string) => void;
hasItems?: boolean;
}
let { openBookingModal }: Props = $props();
let { openBookingModal, hasItems = $bindable(false) }: Props = $props();
// Edit request types
interface ServiceItem {
@@ -91,6 +92,9 @@
let showEditRequestModal = $state(false);
let selectedEditRequest = $state<EditRequest | null>(null);
let _hasItems = $derived(pendingApprovals.length > 0 || pendingEditRequests.length > 0);
$effect(() => { hasItems = _hasItems; });
// Helper function to format date nicely
function formatDateTime(dateTimeString: string): string {
const date = new SvelteDate(dateTimeString);
@@ -160,8 +164,12 @@
return names.join(', ') || 'No services';
}
let prevApprovalsJson = $state('');
let prevEditRequestsJson = $state('');
let initialized = $state(false);
async function fetchPendingApprovals() {
loading = true;
if (!initialized) loading = true;
try {
const response = await fetch('/api/admin/today/pending-approvals', {
method: 'GET',
@@ -173,10 +181,15 @@
if (response.ok) {
const data = await response.json();
pendingApprovals = (data.approvals || []).sort(
const newApprovals = (data.approvals || []).sort(
(a: PendingApproval, b: PendingApproval) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
const newJson = JSON.stringify(newApprovals);
if (newJson !== prevApprovalsJson) {
pendingApprovals = newApprovals;
prevApprovalsJson = newJson;
}
} else {
toast.error('Failed to load pending approvals');
}
@@ -184,7 +197,10 @@
console.error('Error fetching pending approvals:', err);
toast.error('Network error loading pending approvals');
} finally {
if (!initialized) {
loading = false;
initialized = true;
}
}
}
@@ -200,10 +216,15 @@
if (response.ok) {
const data = await response.json();
pendingEditRequests = (data.edit_requests || []).sort(
const newEditRequests = (data.edit_requests || []).sort(
(a: EditRequest, b: EditRequest) =>
new Date(a.requested_at).getTime() - new Date(b.requested_at).getTime()
);
const newJson = JSON.stringify(newEditRequests);
if (newJson !== prevEditRequestsJson) {
pendingEditRequests = newEditRequests;
prevEditRequestsJson = newJson;
}
}
} catch (err) {
console.error('Error fetching edit requests:', err);
@@ -6,6 +6,11 @@
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Skeleton } from '$lib/components/ui/skeleton';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { calculateMiddleWindow, extractBookedSlots, findAllLunchGaps } from '$lib/lunchProtection';
interface Props {
openBookingModal: (bookingId: string) => void;
@@ -24,6 +29,38 @@
duration_minutes: number;
};
type TimeBlocker = {
id: string;
start_time: string;
duration_minutes: number;
description: string;
cron_expression: string | null;
created_at: string;
created_by: string | null;
};
type OverlappingBooking = {
id: string;
start_time: string;
duration_minutes: number;
status: string;
user: { id: string; full_name: string; email: string | null; phone: string | null } | null;
services: string[];
};
type DayWorkingHours = {
date: string;
isOpen: boolean;
startTime: string;
endTime: string;
};
type DayAvailableHours = {
date: string;
isOpen: boolean;
slots: Array<{ startTime: string; endTime: string }>;
};
function isPastAppointment(startTime: string, durationMinutes: number): boolean {
const start = new Date(startTime);
const end = new Date(start.getTime() + durationMinutes * 60_000);
@@ -33,8 +70,209 @@
let appointments = $state<TodayAppointment[]>([]);
let loading = $state(true);
// ======== Blocker State ========
let blockers = $state<TimeBlocker[]>([]);
let workingHours = $state<DayWorkingHours | null>(null);
let whLoading = $state(true);
let availableHours = $state<DayAvailableHours | null>(null);
let showCreateModal = $state(false);
let showDeleteAlert = $state(false);
let blockerToDelete = $state<TimeBlocker | null>(null);
let creating = $state(false);
let checkingOverlap = $state(false);
let newDescription = $state('');
let startHour = $state('9');
let startMinute = $state('00');
let startPeriod = $state<'AM' | 'PM'>('AM');
let endHour = $state('10');
let endMinute = $state('00');
let endPeriod = $state<'AM' | 'PM'>('AM');
let overlappingBookings = $state<OverlappingBooking[]>([]);
let hasOverlap = $state(false);
let startSelectValue = $derived(`${startHour}:${startMinute}:${startPeriod}`);
let endSelectValue = $derived(`${endHour}:${endMinute}:${endPeriod}`);
const today = $derived.by(() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
function to24h(hour: string, minute: string, period: 'AM' | 'PM'): string {
let h = parseInt(hour);
if (period === 'PM' && h !== 12) h += 12;
if (period === 'AM' && h === 12) h = 0;
return `${String(h).padStart(2, '0')}:${minute}`;
}
function timeToMinutes(time: string): number {
const [h, m] = time.split(':').map(Number);
return h * 60 + m;
}
function minutesTo12h(totalMin: number): { hour: string; minute: string; period: 'AM' | 'PM' } {
let h = Math.floor(totalMin / 60);
const m = totalMin % 60;
const period: 'AM' | 'PM' = h >= 12 ? 'PM' : 'AM';
if (h > 12) h -= 12;
if (h === 0) h = 12;
return { hour: String(h), minute: String(m).padStart(2, '0'), period };
}
function parseSelectValue(val: string): { hour: string; minute: string; period: 'AM' | 'PM' } {
const [hour, minute, period] = val.split(':') as [string, string, 'AM' | 'PM'];
return { hour, minute, period };
}
function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes}m`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function isAutoGenerated(blocker: TimeBlocker): boolean {
return blocker.description?.startsWith('RESERVATION:') ?? false;
}
let availableStartOptions = $derived.by(() => {
const wh = workingHours;
if (!wh || !wh.isOpen) return [];
const startMin = timeToMinutes(wh.startTime);
const endMin = timeToMinutes(wh.endTime);
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = [];
for (let m = startMin; m < endMin; m += 15) {
const t = minutesTo12h(m);
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
}
return options;
});
let availableEndOptions = $derived.by(() => {
const wh = workingHours;
if (!wh || !wh.isOpen) return [];
const endMin = timeToMinutes(wh.endTime);
const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod));
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = [];
for (let m = Math.max(timeToMinutes(wh.startTime), currentStartMin + 15); m <= endMin; m += 15) {
const t = minutesTo12h(m);
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
}
return options;
});
let suggestedLunch = $derived.by(() => {
if (!workingHours || !availableHours || !workingHours.isOpen) return null;
const wh = workingHours;
const ah = availableHours;
const { windowStart, windowEnd } = calculateMiddleWindow(wh.startTime, wh.endTime);
const existingBookings = extractBookedSlots(wh.startTime, wh.endTime, ah.slots);
const gaps = findAllLunchGaps(windowStart, windowEnd, existingBookings);
if (gaps.length === 0) return null;
const relevantBookings = existingBookings
.filter((b) => {
const s = timeToMinutes(b.startTime);
const e = timeToMinutes(b.endTime);
return s < windowEnd && e > windowStart;
})
.map((b) => ({
startTime: Math.max(timeToMinutes(b.startTime), windowStart),
endTime: Math.min(timeToMinutes(b.endTime), windowEnd)
}))
.sort((a, b) => a.startTime - b.startTime);
const gapInfos: Array<{ start: number; end: number; duration: number }> = [];
let cursor = windowStart;
for (const b of relevantBookings) {
if (b.startTime > cursor) {
gapInfos.push({ start: cursor, end: b.startTime, duration: b.startTime - cursor });
}
cursor = Math.max(cursor, b.endTime);
}
if (cursor < windowEnd) {
gapInfos.push({ start: cursor, end: windowEnd, duration: windowEnd - cursor });
}
gapInfos.sort((a, b) => b.duration - a.duration);
if (gapInfos.length === 0) return null;
const largestGap = gapInfos[0];
const startTimeStr = `${minutesTo12h(largestGap.start).hour}:${minutesTo12h(largestGap.start).minute} ${minutesTo12h(largestGap.start).period}`;
const endTimeStr = `${minutesTo12h(largestGap.end).hour}:${minutesTo12h(largestGap.end).minute} ${minutesTo12h(largestGap.end).period}`;
return { startMin: largestGap.start, endMin: largestGap.end, duration: largestGap.duration, startLabel: startTimeStr, endLabel: endTimeStr };
});
type AppointmentTimelineItem = {
id: string;
startMinutes: number;
endMinutes: number;
type: 'appointment';
data: TodayAppointment;
};
type BlockerTimelineItem = {
id: string;
startMinutes: number;
endMinutes: number;
type: 'blocker';
data: TimeBlocker;
};
type LunchTimelineItem = {
id: string;
startMinutes: number;
endMinutes: number;
type: 'lunch';
data: { duration: number; startLabel: string; endLabel: string };
};
type TimelineItem = AppointmentTimelineItem | BlockerTimelineItem | LunchTimelineItem;
let timeline = $derived.by(() => {
const items: TimelineItem[] = [];
for (const apt of appointments) {
const start = new Date(apt.start_time);
const startM = start.getHours() * 60 + start.getMinutes();
items.push({
id: `apt-${apt.id}`,
startMinutes: startM,
endMinutes: startM + apt.duration_minutes,
type: 'appointment',
data: apt
});
}
for (const b of blockers) {
const start = new Date(b.start_time);
const startM = start.getHours() * 60 + start.getMinutes();
items.push({
id: `blk-${b.id}`,
startMinutes: startM,
endMinutes: startM + b.duration_minutes,
type: 'blocker',
data: b
});
}
if (suggestedLunch) {
items.push({
id: 'lunch',
startMinutes: suggestedLunch.startMin,
endMinutes: suggestedLunch.endMin,
type: 'lunch',
data: { duration: suggestedLunch.duration, startLabel: suggestedLunch.startLabel, endLabel: suggestedLunch.endLabel }
});
}
return items.sort((a, b) => a.startMinutes - b.startMinutes);
});
let prevAppointmentsJson = $state('');
let apptsInitialized = $state(false);
async function fetchTodayAppointments() {
loading = true;
if (!apptsInitialized) loading = true;
try {
const response = await fetch('/api/admin/today/appointments', {
method: 'GET',
@@ -46,7 +284,12 @@
if (response.ok) {
const data = await response.json();
appointments = data.appointments || [];
const newAppointments = data.appointments || [];
const newJson = JSON.stringify(newAppointments);
if (newJson !== prevAppointmentsJson) {
appointments = newAppointments;
prevAppointmentsJson = newJson;
}
} else {
toast.error("Failed to load today's appointments");
}
@@ -54,37 +297,175 @@
console.error("Error fetching today's appointments:", err);
toast.error('Network error loading appointments');
} finally {
if (!apptsInitialized) {
loading = false;
apptsInitialized = true;
}
}
}
// 🔁 Refetch on mount, every minute, and on approval
$effect(() => {
// Initial fetch
fetchTodayAppointments();
let prevBlockersJson = $state('');
let blockersInitialized = $state(false);
// Timer: refetch every 60 seconds
const intervalId = setInterval(() => {
fetchTodayAppointments();
}, 60_000);
async function fetchTodayBlockersData() {
if (!blockersInitialized) whLoading = true;
try {
const dateParam = today;
const [blockersRes, whRes, ahRes] = await Promise.all([
fetch(`/api/admin/time-blockers?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch(`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch(`/api/scheduling/available-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
})
]);
// Listener for approval events
function handleApproval() {
fetchTodayAppointments();
if (blockersRes.ok) {
const data: TimeBlocker[] = await blockersRes.json();
const newBlockers = (data || []).filter((b) => !isAutoGenerated(b));
const newJson = JSON.stringify(newBlockers);
if (newJson !== prevBlockersJson) {
blockers = newBlockers;
prevBlockersJson = newJson;
}
}
window.addEventListener('bookingApproved', handleApproval);
// Cleanup
return () => {
clearInterval(intervalId);
window.removeEventListener('bookingApproved', handleApproval);
};
if (whRes.ok) {
const data: DayWorkingHours[] = await whRes.json();
const newWh = (data || []).find((d) => d.date === dateParam) ?? null;
if (JSON.stringify(newWh) !== JSON.stringify(workingHours)) {
workingHours = newWh;
}
}
if (ahRes.ok) {
const data: DayAvailableHours[] = await ahRes.json();
const newAh = (data || []).find((d) => d.date === dateParam) ?? null;
if (JSON.stringify(newAh) !== JSON.stringify(availableHours)) {
availableHours = newAh;
}
}
} catch (err) {
console.error('Error fetching today data:', err);
} finally {
if (!blockersInitialized) {
whLoading = false;
blockersInitialized = true;
}
}
}
async function checkOverlappingBookings() {
const startIso = buildDateTime(today, startHour, startMinute, startPeriod);
const endIso = buildDateTime(today, endHour, endMinute, endPeriod);
if (!startIso || !endIso) { overlappingBookings = []; hasOverlap = false; return; }
const blockerStart = new Date(startIso);
const blockerEnd = new Date(endIso);
if (blockerEnd.getTime() <= blockerStart.getTime()) { overlappingBookings = []; hasOverlap = false; return; }
checkingOverlap = true;
try {
const response = await fetch(`/api/admin/bookings/by-date-range?start=${encodeURIComponent(today)}&end=${encodeURIComponent(today)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
});
// Fetch on mount
$effect(() => {
fetchTodayAppointments();
if (response.ok) {
const data = await response.json();
const allBookings: OverlappingBooking[] = data.bookings || [];
const filtered = allBookings.filter((b) => {
const bStart = new Date(b.start_time);
const bEnd = new Date(bStart.getTime() + b.duration_minutes * 60000);
return bStart < blockerEnd && bEnd > blockerStart;
});
overlappingBookings = filtered;
hasOverlap = overlappingBookings.length > 0;
} else { overlappingBookings = []; hasOverlap = false; }
} catch (err) {
console.error('Error checking overlapping bookings:', err);
overlappingBookings = []; hasOverlap = false;
} finally { checkingOverlap = false; }
}
function buildDateTime(date: string, hour: string, minute: string, period: 'AM' | 'PM'): string | null {
if (!date) return null;
const t24 = to24h(hour, minute, period);
return `${date}T${t24}:00`;
}
let canCreate = $derived.by(() => !hasOverlap && !checkingOverlap);
async function createBlocker() {
if (!canCreate) return;
const startIso = buildDateTime(today, startHour, startMinute, startPeriod);
const endIso = buildDateTime(today, endHour, endMinute, endPeriod);
if (!startIso || !endIso) return;
const start = new Date(startIso);
const end = new Date(endIso);
const durationMinutes = Math.round((end.getTime() - start.getTime()) / 60000);
creating = true;
const loadingToast = toast.loading('Creating time blocker...');
try {
const response = await fetch('/api/admin/time-blockers', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` },
body: JSON.stringify({ start_time: start.toISOString(), duration_minutes: durationMinutes, description: newDescription.trim() || 'break' })
});
if (response.ok) {
toast.success('Time blocker created!', { id: loadingToast });
showCreateModal = false;
resetCreateForm();
await fetchTodayBlockersData();
} else {
const text = await response.text();
toast.error('Failed to create: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error creating time blocker:', err);
toast.error('Network error creating time blocker', { id: loadingToast });
} finally { creating = false; }
}
async function confirmDeleteBlocker() {
if (!blockerToDelete) return;
const loadingToast = toast.loading('Deleting time blocker...');
try {
const response = await fetch(`/api/admin/time-blockers/${blockerToDelete.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok || response.status === 204) {
toast.success('Time blocker deleted', { id: loadingToast });
showDeleteAlert = false;
blockerToDelete = null;
await fetchTodayBlockersData();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error deleting time blocker:', err);
toast.error('Network error deleting time blocker', { id: loadingToast });
}
}
function resetCreateForm() {
newDescription = '';
startHour = '9'; startMinute = '00'; startPeriod = 'AM';
endHour = '10'; endMinute = '00'; endPeriod = 'AM';
overlappingBookings = []; hasOverlap = false;
}
function openCreateModal() {
resetCreateForm();
if (workingHours && workingHours.isOpen) {
const startOpt = minutesTo12h(timeToMinutes(workingHours.startTime));
const endOpt = minutesTo12h(timeToMinutes(workingHours.startTime) + 60);
startHour = startOpt.hour; startMinute = startOpt.minute; startPeriod = startOpt.period;
endHour = endOpt.hour; endMinute = endOpt.minute; endPeriod = endOpt.period;
}
showCreateModal = true;
}
function getStatusColor(status: string): string {
switch (status) {
@@ -138,13 +519,49 @@
function formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
$effect(() => {
fetchTodayAppointments();
fetchTodayBlockersData();
const intervalId = setInterval(() => {
fetchTodayAppointments();
}, 600_000);
function handleApproval() {
fetchTodayAppointments();
}
window.addEventListener('bookingApproved', handleApproval);
return () => {
clearInterval(intervalId);
window.removeEventListener('bookingApproved', handleApproval);
};
});
$effect(() => {
if (showCreateModal) {
checkOverlappingBookings();
}
});
</script>
<div class="lg:col-span-2">
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Today's Appointments</Card.Title>
<Card.Description>Timeline view of all bookings</Card.Description>
</div>
<Button variant="outline" size="sm" onclick={openCreateModal}>
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 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>
Add Blocker
</Button>
</div>
</Card.Header>
<Card.Content>
{#if loading}
@@ -162,7 +579,7 @@
</div>
{/each}
</div>
{:else if appointments.length === 0}
{:else if timeline.length === 0}
<div class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -183,37 +600,203 @@
</div>
{:else}
<div class="space-y-3">
{#each appointments as apt (apt.id)}
{#each timeline as item (item.id)}
{#if item.type === 'appointment'}
<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' : ''}"
{isPastAppointment(item.data.start_time, item.data.duration_minutes) ? 'line-through opacity-50' : ''}"
>
<div class="min-w-[80px] text-sm font-semibold text-gray-700">
{formatTime(apt.start_time)}
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-gray-700">
{formatTime(item.data.start_time)}
</div>
<div class="h-10 w-1 rounded {getStatusBarColor(apt.status)}"></div>
<div class="h-10 w-1 rounded {getStatusBarColor(item.data.status)}"></div>
<div class="flex-1">
<button
type="button"
class="font-medium hover:text-blue-600 hover:underline"
onclick={() => openUserModal(apt.user_id)}
onclick={() => openUserModal(item.data.user_id)}
>
{apt.user_name}
{item.data.user_name}
</button>
<div class="text-sm text-gray-600">
{apt.services.join(', ')}{apt.duration_minutes} min
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
<span>{item.data.services.join(', ')}</span>
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium">
{formatDuration(item.data.duration_minutes)}
</span>
</div>
</div>
<Badge class={getStatusColor(apt.status)}>
{formatStatus(apt.status)}
<Badge class={getStatusColor(item.data.status)}>
{formatStatus(item.data.status)}
</Badge>
<Button size="sm" variant="outline" onclick={() => openBookingModal(apt.id)}>
<Button size="sm" variant="outline" onclick={() => openBookingModal(item.data.id)}>
View
</Button>
</div>
{:else if item.type === 'blocker'}
<div class="flex items-center gap-4 rounded-lg border border-amber-200 bg-amber-50/40 p-3 transition-all hover:shadow-sm">
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-gray-700">
{formatTime(item.data.start_time)}
</div>
<div class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-amber-100">
<svg class="h-4 w-4 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-1">
<div class="truncate text-sm font-medium text-gray-900">{item.data.description || 'Untitled'}</div>
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
{formatDuration(item.data.duration_minutes)}
</span>
</div>
<Button
variant="destructive" size="sm" class="shrink-0 h-9 w-9 p-0"
onclick={() => { blockerToDelete = item.data; showDeleteAlert = true; }}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</Button>
</div>
{:else if item.type === 'lunch'}
<div class="flex items-center gap-4 rounded-lg border border-green-200 bg-green-50 p-3">
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-green-700">
{item.data.startLabel}
</div>
<div class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-green-100">
<svg class="h-4 w-4 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8h1a4 4 0 0 1 0 8h-1" />
<path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z" />
<line x1="6" y1="1" x2="6" y2="4" />
<line x1="10" y1="1" x2="10" y2="4" />
<line x1="14" y1="1" x2="14" y2="4" />
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-green-800">
Suggested lunch: {formatDuration(item.data.duration)} available
</p>
<p class="text-xs text-green-700">
{item.data.startLabel} {item.data.endLabel}
</p>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Create Blocker Modal -->
<Modal.Root bind:open={showCreateModal}>
<Modal.Content class="max-h-[90vh] max-w-lg overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Add Time Blocker</Modal.Title>
<Modal.Description>Block off a period of time today.</Modal.Description>
</Modal.Header>
<div class="space-y-5 px-4 pb-4">
<div class="space-y-2">
<label for="blocker-desc" class="text-sm font-medium">Description</label>
<Input id="blocker-desc" type="text" placeholder="e.g., Lunch, Appointment, Break" bind:value={newDescription} />
<p class="text-xs text-gray-400">Defaults to "break" if left empty</p>
</div>
<Separator />
<div class="space-y-3">
<h3 class="text-sm font-medium">Start Time</h3>
{#if whLoading}
<Skeleton class="h-9 w-full" />
{:else if !workingHours}
<p class="text-sm text-gray-400">Loading working hours…</p>
{:else if !workingHours.isOpen}
<p class="text-sm text-red-500">Closed today</p>
{:else}
<select class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm" bind:value={startSelectValue} onchange={() => { const p = parseSelectValue(startSelectValue); startHour = p.hour; startMinute = p.minute; startPeriod = p.period; checkOverlappingBookings(); }}>
{#each availableStartOptions as opt}
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
{/each}
</select>
{/if}
</div>
<div class="space-y-3">
<h3 class="text-sm font-medium">End Time</h3>
{#if whLoading}
<Skeleton class="h-9 w-full" />
{:else if !workingHours}
<p class="text-sm text-gray-400">Loading working hours…</p>
{:else if !workingHours.isOpen}
<p class="text-sm text-red-500">Closed today</p>
{:else if availableEndOptions.length === 0}
<p class="text-sm text-gray-400">No available end time after selected start</p>
{:else}
<select class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm" bind:value={endSelectValue} onchange={() => { const p = parseSelectValue(endSelectValue); endHour = p.hour; endMinute = p.minute; endPeriod = p.period; checkOverlappingBookings(); }}>
{#each availableEndOptions as opt}
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
{/each}
</select>
{/if}
</div>
{#if checkingOverlap}
<div class="flex items-center gap-2 text-sm text-gray-500">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Checking for conflicting bookings…
</div>
{:else if hasOverlap}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3">
<div class="flex items-center gap-2 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span class="text-sm font-medium text-amber-800">Booking exists in this timeslot</span>
</div>
<div class="space-y-2">
{#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-2">
<div class="min-w-0">
<div class="font-medium text-sm">{booking.user?.full_name || 'Unknown'}</div>
<div class="text-xs text-gray-500">
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit', hour12: true })}
{' · '}{formatDuration(booking.duration_minutes)}
{#if booking.services?.length} {' · '}{booking.services.join(', ')}{/if}
</div>
</div>
<div class="flex gap-2 mt-1.5">
<Button variant="outline" size="sm" class="text-xs h-6" onclick={() => openBookingModal(booking.id)}>View Booking</Button>
{#if booking.user?.id}
<Button variant="outline" size="sm" class="text-xs h-6" onclick={() => openUserModal(booking.user!.id)}>View Client</Button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => { showCreateModal = false; resetCreateForm(); }} disabled={creating}>Cancel</Button>
<Button onclick={createBlocker} disabled={!canCreate || creating || checkingOverlap}>
{creating ? 'Creating…' : 'Create Blocker'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<AlertDialog.Root bind:open={showDeleteAlert}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
<AlertDialog.Description>This will remove the "{blockerToDelete?.description}" time blocker. This action cannot be undone.</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => { showDeleteAlert = false; blockerToDelete = null; }}>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmDeleteBlocker} class="bg-red-600 hover:bg-red-700">Delete</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,237 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Card from '$lib/components/ui/card';
type TodayAppointment = {
id: string;
start_time: string;
status: string;
user_name: string;
user_id: string;
services: string[];
duration_minutes: number;
};
type DayWorkingHours = {
date: string;
isOpen: boolean;
startTime: string;
endTime: string;
};
type Stats = {
total: number;
completed: number;
inProgress: number;
upcoming: number;
cancelled: number;
noShow: number;
occupancyPercent: number;
bookingsMade: number;
bookingsMadeOverClosed: boolean;
};
let loading = $state(true);
let stats = $state<Stats | null>(null);
let prevJson = $state('');
let initialized = $state(false);
const today = $derived.by(() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
function timeToMinutes(time: string): number {
const [h, m] = time.split(':').map(Number);
return h * 60 + m;
}
async function findLastWorkingDayClose(): Promise<{ cutoff: string; spansClosed: boolean }> {
const now = new Date();
let spansClosed = false;
for (let i = 1; i <= 14; i++) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const res = await fetch(`/api/scheduling/working-hours?start=${encodeURIComponent(dateStr)}&end=${encodeURIComponent(dateStr)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
const data: DayWorkingHours[] = await res.json();
const day = (data || []).find((d) => d.date === dateStr);
if (day?.isOpen) {
const closeTime = day.endTime;
const [h, m] = closeTime.split(':').map(Number);
const closeDate = new Date(d);
closeDate.setHours(h, m, 0, 0);
return { cutoff: closeDate.toISOString(), spansClosed };
} else {
spansClosed = true;
}
}
}
const fallback = new Date(now);
fallback.setDate(fallback.getDate() - 1);
fallback.setHours(17, 0, 0, 0);
return { cutoff: fallback.toISOString(), spansClosed };
}
async function fetchTodayStats() {
if (!initialized) loading = true;
try {
const dateParam = today;
const [apptsRes, whRes] = await Promise.all([
fetch(`/api/admin/today/appointments`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch(`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
})
]);
let appointments: TodayAppointment[] = [];
let workingHours: DayWorkingHours | null = null;
if (apptsRes.ok) {
const data = await apptsRes.json();
appointments = data.appointments || [];
}
if (whRes.ok) {
const data: DayWorkingHours[] = await whRes.json();
workingHours = (data || []).find((d) => d.date === dateParam) ?? null;
}
const total = appointments.length;
const completed = appointments.filter((a) => a.status === 'completed').length;
const inProgress = appointments.filter((a) => a.status === 'in_progress').length;
const upcoming = appointments.filter((a) => a.status === 'confirmed' || a.status === 'pending').length;
const cancelled = appointments.filter((a) => a.status === 'client_cancelled' || a.status === 'we_cancelled').length;
const noShow = appointments.filter((a) => a.status === 'no_show').length;
let occupancyPercent = 0;
if (workingHours?.isOpen) {
const workingMinutes = timeToMinutes(workingHours.endTime) - timeToMinutes(workingHours.startTime);
const bookedMinutes = appointments
.filter((a) => a.status !== 'client_cancelled' && a.status !== 'we_cancelled' && a.status !== 'no_show')
.reduce((sum, a) => sum + a.duration_minutes, 0);
occupancyPercent = workingMinutes > 0 ? Math.round((bookedMinutes / workingMinutes) * 100) : 0;
}
const { cutoff, spansClosed } = await findLastWorkingDayClose();
const nowISO = new Date().toISOString();
let bookingsMade = 0;
try {
const bmRes = await fetch(`/api/admin/bookings/by-created-range?start=${encodeURIComponent(cutoff)}&end=${encodeURIComponent(nowISO)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
});
if (bmRes.ok) {
const data = await bmRes.json();
bookingsMade = (data.bookings || []).length;
}
} catch {
}
const newStats: Stats = { total, completed, inProgress, upcoming, cancelled, noShow, occupancyPercent, bookingsMade, bookingsMadeOverClosed: spansClosed };
const newJson = JSON.stringify(newStats);
if (newJson !== prevJson) {
stats = newStats;
prevJson = newJson;
}
} catch (err) {
console.error('Error fetching today stats:', err);
} finally {
if (!initialized) {
loading = false;
initialized = true;
}
}
}
$effect(() => {
fetchTodayStats();
const intervalId = setInterval(fetchTodayStats, 600_000);
function handleRefresh() {
fetchTodayStats();
}
window.addEventListener('bookingApproved', handleRefresh);
return () => {
clearInterval(intervalId);
window.removeEventListener('bookingApproved', handleRefresh);
};
});
</script>
<Card.Root>
<Card.Header>
<Card.Title>Today's Stats</Card.Title>
</Card.Header>
<Card.Content>
{#if loading && !stats}
<div class="space-y-3">
{#each Array(6) as _, i (i)}
<Skeleton class="h-5 w-full" />
{/each}
</div>
{:else if stats}
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Total Appointments</span>
<span class="text-2xl font-bold">{stats.total}</span>
</div>
<hr class="border-gray-100" />
{#if stats.completed > 0}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Completed</span>
<span class="font-semibold">{stats.completed}</span>
</div>
{/if}
{#if stats.inProgress > 0}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">In Progress</span>
<span class="font-semibold">{stats.inProgress}</span>
</div>
{/if}
{#if stats.upcoming > 0}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Upcoming</span>
<span class="font-semibold">{stats.upcoming}</span>
</div>
{/if}
{#if stats.cancelled > 0}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Cancelled</span>
<span class="font-semibold">{stats.cancelled}</span>
</div>
{/if}
{#if stats.noShow > 0}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">No Show</span>
<span class="font-semibold">{stats.noShow}</span>
</div>
{/if}
<hr class="border-gray-100" />
{#if stats.bookingsMade > 0}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">
Bookings Made{#if stats.bookingsMadeOverClosed}<sup class="text-xs text-gray-400">*</sup>{/if}
</span>
<span class="font-semibold">{stats.bookingsMade}</span>
</div>
{/if}
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">Occupancy</span>
<span class="text-lg font-bold">{stats.occupancyPercent}%</span>
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
+22 -14
View File
@@ -9,6 +9,7 @@
import CurrentAppointment from '$lib/components/today/CurrentAppointment.svelte';
import TodayCalendar from '$lib/components/today/TodayCalendar.svelte';
import PendingApprovals from '$lib/components/today/PendingApprovals.svelte';
import TodayStats from '$lib/components/today/TodayStats.svelte';
import CallInBooking from '$lib/components/admin/CallInBooking.svelte';
import WalkInBooking from '$lib/components/admin/WalkInBooking.svelte';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
@@ -48,6 +49,7 @@
let selectedBookingId = $state<string | null>(null);
let selectedUserId = $state<string | null>(null);
let editBookingNextStart = $state<string | null>(null);
let hasItems = $state(false);
function openBookingModal(bookingId: string) {
selectedBookingId = bookingId;
@@ -76,7 +78,7 @@
</script>
{#if pageState === 'loading'}
<div class="mx-auto max-w-7xl space-y-6 p-6">
<div class="mx-auto max-w-7xl space-y-6 p-4 md:p-6">
<!-- Header Skeleton -->
<div class="mb-6">
<Skeleton class="h-8 w-64" />
@@ -105,10 +107,10 @@
</div>
</div>
{:else if pageState === 'authorized'}
<div class="mx-auto max-w-7xl space-y-6 p-6">
<div class="mx-auto max-w-7xl space-y-6 p-4 md:p-6">
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold">Today's Schedule</h1>
<h1 class="text-2xl font-bold md:text-3xl">Today's Schedule</h1>
<p class="text-gray-600">{dateString}</p>
</div>
@@ -116,29 +118,35 @@
<CurrentAppointment {openBookingModal} {openEditBookingModal} {openUserModal} />
<!-- quick booking Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column: Create a booking for a call in or messaging user (2/3 width on large screens) -->
<div class="lg:col-span-2">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-3 lg:gap-6">
<!-- Left Column: Create a booking for a walk-in customer (2/3 width on large screens, half on mobile) -->
<div class="col-span-1 lg:col-span-2">
<WalkInBooking />
</div>
<!-- Right Column: Similar to the above but immediately block out my next availible working space while I talk over the walk-in users needs and chat etc (1/3 width on large screens) -->
<div class="space-y-6">
<!-- Right Column: Call-in / Social messaging booking (1/3 width on large screens, half on mobile) -->
<div class="col-span-1">
<CallInBooking />
</div>
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column: Today's Calendar (2/3 width on large screens) -->
<div class="lg:col-span-2">
<!-- Today's Calendar (2/3 width on desktop, second on mobile) -->
<div class="lg:col-span-2 order-2 lg:order-1">
<TodayCalendar {openBookingModal} {openUserModal} />
</div>
<!-- Right Column: Quick Info Panels (1/3 width on large screens) -->
<div class="space-y-6">
<!-- 3 oldest pending appointments for approval -->
<PendingApprovals {openBookingModal} />
<!-- Pending Approvals (first on mobile, right column on desktop) -->
<div class="order-1 lg:order-2">
<div class="{hasItems ? '' : 'hidden lg:block'}">
<PendingApprovals {openBookingModal} bind:hasItems />
</div>
</div>
<!-- Today's Stats (bottom on mobile, right column below approvals on desktop) -->
<div class="order-3 lg:order-2">
<TodayStats />
</div>
</div>