Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1072 lines
32 KiB
Svelte
1072 lines
32 KiB
Svelte
<script lang="ts">
|
||
import { authStore } from '$lib/stores/auth.svelte';
|
||
import { SvelteDate } from 'svelte/reactivity';
|
||
import { toast } from 'svelte-sonner';
|
||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||
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';
|
||
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,
|
||
shouldApplyLunchProtection,
|
||
timeToMinutes
|
||
} from '$lib/lunchProtection';
|
||
import { formatDuration, formatDateISO } from '$lib/utils/format';
|
||
|
||
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;
|
||
};
|
||
|
||
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 SvelteDate(startTime);
|
||
const end = new SvelteDate(start.getTime() + durationMinutes * 60_000);
|
||
return end.getTime() < Date.now();
|
||
}
|
||
|
||
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(formatDateISO(new SvelteDate()));
|
||
|
||
const weekStartStr = $derived.by(() => {
|
||
const d = new SvelteDate();
|
||
const day = d.getDay();
|
||
const diff = day === 0 ? 6 : day - 1;
|
||
d.setDate(d.getDate() - diff);
|
||
return formatDateISO(d);
|
||
});
|
||
|
||
const weekEndStr = $derived.by(() => {
|
||
const d = new SvelteDate(weekStartStr);
|
||
d.setDate(d.getDate() + 6);
|
||
return formatDateISO(d);
|
||
});
|
||
|
||
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 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 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;
|
||
|
||
if (!shouldApplyLunchProtection(wh.startTime, wh.endTime)) return null;
|
||
|
||
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 ClosingTimeTimelineItem = {
|
||
id: string;
|
||
startMinutes: number;
|
||
type: 'closing';
|
||
data: { label: string };
|
||
};
|
||
|
||
type TimelineItem = AppointmentTimelineItem | BlockerTimelineItem | LunchTimelineItem | ClosingTimeTimelineItem;
|
||
|
||
let timeline = $derived.by(() => {
|
||
const items: TimelineItem[] = [];
|
||
|
||
for (const apt of appointments) {
|
||
const start = new SvelteDate(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 SvelteDate(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
|
||
}
|
||
});
|
||
}
|
||
|
||
if (workingHours?.isOpen && workingHours.endTime) {
|
||
const closeMin = timeToMinutes(workingHours.endTime);
|
||
const t = minutesTo12h(closeMin);
|
||
items.push({
|
||
id: 'closing',
|
||
startMinutes: closeMin,
|
||
type: 'closing',
|
||
data: { label: `${t.hour}:${t.minute} ${t.period}` }
|
||
});
|
||
}
|
||
|
||
return items.sort((a, b) => a.startMinutes - b.startMinutes);
|
||
});
|
||
|
||
let prevAppointmentsJson = $state('');
|
||
let apptsInitialized = $state(false);
|
||
|
||
async function fetchTodayAppointments() {
|
||
if (!apptsInitialized) 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();
|
||
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");
|
||
}
|
||
} catch (err) {
|
||
console.error("Error fetching today's appointments:", err);
|
||
toast.error('Network error loading appointments');
|
||
} finally {
|
||
if (!apptsInitialized) {
|
||
loading = false;
|
||
apptsInitialized = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
let prevBlockersJson = $state('');
|
||
let blockersInitialized = $state(false);
|
||
|
||
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(weekStartStr)}&end=${encodeURIComponent(weekEndStr)}`,
|
||
{
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${authStore.currentToken}`
|
||
}
|
||
}
|
||
),
|
||
fetch(
|
||
`/api/scheduling/available-hours?start=${encodeURIComponent(weekStartStr)}&end=${encodeURIComponent(weekEndStr)}`,
|
||
{
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${authStore.currentToken}`
|
||
}
|
||
}
|
||
)
|
||
]);
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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 SvelteDate(startIso);
|
||
const blockerEnd = new SvelteDate(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}`
|
||
}
|
||
}
|
||
);
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const allBookings: OverlappingBooking[] = data.bookings || [];
|
||
const filtered = allBookings.filter((b) => {
|
||
const bStart = new SvelteDate(b.start_time);
|
||
const bEnd = new SvelteDate(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: ' + sanitizeText(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: ' + sanitizeText(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) {
|
||
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());
|
||
}
|
||
|
||
$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}
|
||
<div class="space-y-3">
|
||
{#each Array(5) as _, i (i)}
|
||
<div class="flex items-center gap-3 rounded-lg border p-3 sm:gap-4">
|
||
<Skeleton class="h-4 w-[45px] sm:w-[60px] lg:w-[80px]" />
|
||
<Skeleton class="h-10 w-1 shrink-0" />
|
||
<div class="min-w-0 flex-1 space-y-2">
|
||
<Skeleton class="h-4 w-32" />
|
||
<Skeleton class="h-3 w-48" />
|
||
</div>
|
||
<Skeleton class="hidden h-6 w-20 shrink-0 sm:block" />
|
||
<Skeleton class="h-8 w-9 shrink-0 sm:w-16" />
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{:else if timeline.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 timeline as item (item.id)}
|
||
{#if item.type === 'appointment'}
|
||
<div
|
||
class="flex items-center gap-3 rounded-lg border p-3 transition-all hover:shadow-md sm:gap-4
|
||
{isPastAppointment(item.data.start_time, item.data.duration_minutes)
|
||
? 'line-through opacity-50'
|
||
: ''}"
|
||
>
|
||
<div
|
||
class="min-w-[45px] shrink-0 text-xs font-semibold text-gray-700 sm:min-w-[60px] sm:text-sm lg:min-w-[80px]"
|
||
>
|
||
{formatTime(item.data.start_time)}
|
||
</div>
|
||
<div class="h-10 w-1 shrink-0 rounded {getStatusBarColor(item.data.status)}"></div>
|
||
<div class="min-w-0 flex-1">
|
||
<button
|
||
type="button"
|
||
class="block max-w-full truncate font-medium hover:text-blue-600 hover:underline"
|
||
onclick={() => openUserModal(item.data.user_id)}
|
||
>
|
||
{item.data.user_name}
|
||
</button>
|
||
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
|
||
<span class="truncate">{(item.data.services ?? []).join(', ')}</span>
|
||
<span
|
||
class="shrink-0 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(item.data.status)} hidden shrink-0 sm:inline-flex">
|
||
{formatStatus(item.data.status)}
|
||
</Badge>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
class="h-9 w-9 shrink-0 sm:w-auto sm:px-3"
|
||
onclick={() => openBookingModal(item.data.id)}
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
class="h-4 w-4 sm:mr-1.5"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||
<circle cx="12" cy="12" r="3" />
|
||
</svg>
|
||
<span class="hidden sm:inline">View</span>
|
||
</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] text-sm font-semibold text-gray-700 sm:min-w-[80px]">
|
||
{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="h-9 w-9 shrink-0 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] text-sm font-semibold text-green-700 sm:min-w-[80px]">
|
||
{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>
|
||
{:else if item.type === 'closing'}
|
||
<div class="flex items-center gap-3 border-t border-gray-100 px-1 pt-3">
|
||
<div
|
||
class="text-xs font-medium text-gray-400"
|
||
>
|
||
{item.data.label}
|
||
</div>
|
||
<div class="h-px flex-1 bg-gray-100"></div>
|
||
<span class="text-[11px] text-gray-400">Close</span>
|
||
</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 (opt.totalMin)}
|
||
<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 (opt.totalMin)}
|
||
<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="mb-2 flex items-center gap-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="text-sm font-medium">{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="mt-1.5 flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="h-6 text-xs"
|
||
onclick={() => openBookingModal(booking.id)}>View Booking</Button
|
||
>
|
||
{#if booking.user?.id}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="h-6 text-xs"
|
||
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>
|