import React, { useState, useEffect } from 'react'; import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react'; import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics'; export interface Appointment { id: string; petId: string; serviceId: string; groomerId: string | null; date: string; time: string; status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show'; petName?: string; serviceName?: string; groomerName?: string; duration?: number; price?: number; notes?: string; customerNotes?: string; addOns?: string[]; confirmationStatus?: 'confirmed' | 'pending' | 'cancelled'; } interface Pet { id: string; name: string; breed: string; weight?: number; photo?: string; imageUrl?: string; } interface Service { id: string; name: string; description?: string; duration: number; price: number; priceRange?: string; isAddOn?: boolean; } interface AppointmentsSectionProps { sessionId: string | null; readOnly: boolean; } interface RescheduleFlowProps { appointment: Appointment; onClose: () => void; sessionId: string | null; } const MAX_CUSTOMER_NOTES = 500; export function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', }); } export function parseTimeTo24Hour(time: string): string { const parts = time.split(' '); const hoursMinutes = parts[0] ?? ''; const period = parts[1] ?? ''; const [hoursStr, minutesStr] = hoursMinutes.split(':'); const hours = parseInt(hoursStr ?? '0', 10); const minutes = parseInt(minutesStr ?? '0', 10); let hours24 = hours; if (period === 'PM' && hours !== 12) hours24 += 12; if (period === 'AM' && hours === 12) hours24 = 0; return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`; } export function isUpcoming(appt: Appointment): boolean { const now = new Date(); const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`); return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed'; } const STATUS_COLORS: Record = { confirmed: 'bg-green-100 text-green-700', pending: 'bg-amber-100 text-amber-600', waitlisted: 'bg-blue-100 text-blue-600', completed: 'bg-stone-100 text-stone-600', cancelled: 'bg-red-100 text-red-600', 'no-show': 'bg-yellow-100 text-yellow-700', scheduled: 'bg-blue-100 text-blue-600', }; const STATUS_LABELS: Record = { confirmed: 'Confirmed', pending: 'Pending', waitlisted: 'Waitlisted', completed: 'Completed', cancelled: 'Cancelled', 'no-show': 'No-show', scheduled: 'Scheduled', }; export function StatusBadge({ status }: { status: string }) { const label = STATUS_LABELS[status] ?? status; const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600'; return ( {label} ); } const CONFIRMATION_STATUS_COLORS: Record = { confirmed: 'bg-green-100 text-green-700', pending: 'bg-amber-100 text-amber-700', cancelled: 'bg-red-100 text-red-600', }; export const AppointmentsSection: React.FC = ({ sessionId, readOnly }) => { const [upcomingAppointments, setUpcomingAppointments] = useState([]); const [pastAppointments, setPastAppointments] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [showBooking, setShowBooking] = useState(false); const [showReschedule, setShowReschedule] = useState(false); const [rescheduleAppointment, setRescheduleAppointment] = useState(null); const [expandedId, setExpandedId] = useState(null); const [tab, setTab] = useState<'upcoming' | 'past'>('upcoming'); useEffect(() => { const fetchAppointments = async () => { if (!sessionId) { setUpcomingAppointments([]); setPastAppointments([]); setIsLoading(false); return; } try { const response = await fetch('/api/portal/appointments', { headers: { "X-Impersonation-Session-Id": sessionId ?? "" }, }); if (response.ok) { const data = await response.json(); const fetchedAppointments: Appointment[] = data.appointments || data || []; const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt)); const past = fetchedAppointments.filter((appt) => !isUpcoming(appt)); setUpcomingAppointments(upcoming); setPastAppointments(past); } else { setError('Failed to load appointments.'); } } catch { setError('Failed to load appointments. Please try again.'); } finally { setIsLoading(false); } }; fetchAppointments(); }, [sessionId]); const handleReschedule = (appointment: Appointment) => { setRescheduleAppointment(appointment); setShowReschedule(true); }; if (isLoading) { return (
Loading appointments...
); } if (error) { return (

{error}

); } return (
{!readOnly && ( )}
{tab === 'upcoming' && (
{upcomingAppointments.map((appt) => ( setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} sessionId={sessionId} onReschedule={handleReschedule} /> ))} {upcomingAppointments.length === 0 && (

No upcoming appointments

)}
)} {tab === 'past' && (
{pastAppointments.map((appt) => ( setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} sessionId={sessionId} onReschedule={handleReschedule} /> ))}
)} {showBooking && ( setShowBooking(false)} sessionId={sessionId} /> )} {showReschedule && rescheduleAppointment && ( { setShowReschedule(false); setRescheduleAppointment(null); }} sessionId={sessionId} /> )}
); }; function AppointmentCard({ appointment: appt, expanded, onToggle, readOnly, sessionId, onReschedule, }: { appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId: string | null; onReschedule: (appt: Appointment) => void; }) { return (
{expanded && (
{appt.duration && (

Duration

{appt.duration} min

)} {appt.price && (

Estimated Price

${appt.price}

)} {appt.addOns && appt.addOns.length > 0 && (

Add-ons

{appt.addOns.join(', ')}

)}
{appt.notes && (

{appt.notes}

)} {isUpcoming(appt) && !readOnly && ( )} {isUpcoming(appt) && } {appt.status !== 'completed' && appt.status !== 'cancelled' && !readOnly && (
)}
)}
); } export function ConfirmationSection({ appointment: appt, sessionId, }: { appointment: Appointment; sessionId: string | null; }) { const [confirming, setConfirming] = useState(false); const [confirmError, setConfirmError] = useState(null); const [confirmSuccess, setConfirmSuccess] = useState(false); const [localStatus, setLocalStatus] = useState(appt.confirmationStatus); async function handleConfirm() { if (!window.confirm('Confirm this appointment?')) return; setConfirming(true); setConfirmError(null); try { const headers: Record = {}; if (sessionId) { headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, { method: 'POST', headers, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Failed to confirm' })); throw new Error(err.error || `HTTP ${res.status}`); } setLocalStatus('confirmed'); setConfirmSuccess(true); setTimeout(() => setConfirmSuccess(false), 2000); } catch (e) { setConfirmError(e instanceof Error ? e.message : 'Failed to confirm'); } finally { setConfirming(false); } } const currentStatus = localStatus ?? appt.confirmationStatus; const statusLabel = currentStatus === 'confirmed' ? 'Confirmed' : currentStatus === 'pending' ? 'Pending confirmation' : 'Cancelled'; return (
{statusLabel}
{!confirmSuccess && currentStatus === 'pending' && ( )} {confirmSuccess && ( Confirmed! )}
{confirmError &&

{confirmError}

}
); } function CancelAppointmentButton({ appointment: appt, sessionId, }: { appointment: Appointment; sessionId: string | null; }) { const [cancelling, setCancelling] = useState(false); const [cancelError, setCancelError] = useState(null); async function handleCancel() { if (!window.confirm('Cancel this appointment? This cannot be undone.')) return; setCancelling(true); setCancelError(null); try { const headers: Record = {}; if (sessionId) { headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, { method: 'POST', headers, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Failed to cancel' })); throw new Error(err.error || `HTTP ${res.status}`); } window.location.reload(); } catch (e) { setCancelError(e instanceof Error ? e.message : 'Failed to cancel'); setCancelling(false); } } return ( <> {cancelError &&

{cancelError}

} ); } export function CustomerNotesSection({ appointment: appt, sessionId, }: { appointment: Appointment; sessionId: string | null; }) { const [notes, setNotes] = useState(appt.customerNotes || ''); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); const isDisabled = appt.status === 'completed' || appt.status === 'cancelled'; async function handleSave() { setSaving(true); setError(null); setSaved(false); try { const headers: Record = { 'Content-Type': 'application/json' }; if (sessionId) { headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, { method: 'PATCH', headers, body: JSON.stringify({ customerNotes: notes }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Failed to save' })); throw new Error(err.error || `HTTP ${res.status}`); } setSaved(true); setTimeout(() => setSaved(false), 2000); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to save'); } finally { setSaving(false); } } return (
MAX_CUSTOMER_NOTES ? 'text-red-500' : 'text-stone-400' }`} > {notes.length}/{MAX_CUSTOMER_NOTES}