import { useState } from "react"; import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Search, Repeat, Loader2 } from "lucide-react"; import { UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, PETS, SERVICES, GROOMERS } from "../mockData.js"; import type { Appointment, Pet, Service, Groomer } from "../mockData.js"; const MAX_CUSTOMER_NOTES = 500; interface Props { readOnly: boolean; sessionId?: string | null; } 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-700", waitlisted: "bg-blue-100 text-blue-700", completed: "bg-stone-100 text-stone-600", cancelled: "bg-red-100 text-red-600", }; 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 function AppointmentsSection({ readOnly, sessionId }: Props) { const [showBooking, setShowBooking] = useState(false); const [expandedId, setExpandedId] = useState(null); const [tab, setTab] = useState<"upcoming" | "past">("upcoming"); return (
{!readOnly && ( )}
{tab === "upcoming" && (
{UPCOMING_APPOINTMENTS.map(appt => ( setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} sessionId={sessionId} /> ))} {UPCOMING_APPOINTMENTS.length === 0 && (

No upcoming appointments

)}
)} {tab === "past" && (
{PAST_APPOINTMENTS.map(appt => ( setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} sessionId={sessionId} /> ))}
)} {showBooking && ( setShowBooking(false)} readOnly={readOnly} /> )}
); } function AppointmentCard({ appointment: appt, expanded, onToggle, readOnly, sessionId, }: { appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId?: string | null; }) { return (
{expanded && (

Duration

{appt.duration} min

Estimated Price

${appt.price}

{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 && (
)} {appt.reportCardId && (
View Report Card โ†’
)}
)}
); } 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); // Local state mirrors confirmationStatus so the badge updates immediately after confirm 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}