import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { format } from "date-fns"; import { apiRequest } from "@/lib/queryClient"; import { APPOINTMENT_TYPES } from "@/utils/appointmentTypeUtils"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Clock } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useDebounce } from "use-debounce"; import { Appointment, InsertAppointment, insertAppointmentSchema, Patient, Staff, UpdateAppointment, } from "@repo/db/types"; import { DateInputField } from "@/components/ui/dateInputField"; import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils"; import { toast } from "@/hooks/use-toast"; export interface NewAppointmentPrefill { staffId: number; date: string; startTime: string; endTime: string; patientId?: number; type?: string; } interface AppointmentFormProps { appointment?: Appointment; prefillData?: NewAppointmentPrefill | null; onSubmit: (data: InsertAppointment | UpdateAppointment) => void; onDelete?: (id: number) => void; onOpenChange?: (open: boolean) => void; isLoading?: boolean; } export function AppointmentForm({ appointment, prefillData, onSubmit, onDelete, onOpenChange, isLoading = false, }: AppointmentFormProps) { const { user } = useAuth(); const inputRef = useRef(null); const [prefillPatient, setPrefillPatient] = useState(null); const [otherTypeDesc, setOtherTypeDesc] = useState(() => { const t = appointment?.type ?? ""; return t.startsWith("other:") ? t.slice(6) : ""; }); // Track whether the user explicitly changed the type during this edit session. // Used to set typeLocked so the auto-sync won't overwrite a deliberate choice. const originalType = useRef(appointment?.type ?? ""); const [typeChangedByUser, setTypeChangedByUser] = useState(false); useEffect(() => { const timeout = setTimeout(() => { inputRef.current?.focus(); }, 50); // small delay ensures content is mounted return () => clearTimeout(timeout); }, []); const { data: staffMembersRaw = [] as Staff[] } = useQuery({ queryKey: ["/api/staffs/"], queryFn: async () => { const res = await apiRequest("GET", "/api/staffs/"); return res.json(); }, enabled: !!user, }); const colorMap: Record = { "Dr. Kai Gao": "bg-blue-600", "Dr. Jane Smith": "bg-emerald-600", }; const staffMembers = staffMembersRaw.map((staff) => ({ ...staff, color: colorMap[staff.name] || "bg-gray-400", })); // Format the date and times for the form const defaultValues: Partial = appointment ? { userId: user?.id, patientId: appointment.patientId, title: appointment.title, date: parseLocalDate(appointment.date), startTime: appointment.startTime || "09:00", endTime: appointment.endTime || "09:30", type: appointment.type?.startsWith("other:") ? "other" : appointment.type, notes: appointment.notes || "", status: appointment.status || "scheduled", staffId: typeof appointment.staffId === "number" ? appointment.staffId : undefined, } : prefillData ? { userId: user?.id, patientId: prefillData.patientId, date: prefillData.date ? parseLocalDate(prefillData.date) : new Date(), title: "", startTime: prefillData.startTime, endTime: prefillData.endTime, type: prefillData.type || "checkup", status: "scheduled", notes: "", staffId: prefillData.staffId, } : { userId: user?.id ?? 0, date: new Date(), title: "", startTime: "09:00", endTime: "09:30", type: "checkup", status: "scheduled", staffId: staffMembers?.[0]?.id ?? undefined, }; const form = useForm({ resolver: zodResolver(insertAppointmentSchema), defaultValues, }); // ----------------------------- // PATIENT SEARCH (simple inline search) // ----------------------------- const [selectOpen, setSelectOpen] = useState(false); const [patientSearchTerm, setPatientSearchTerm] = useState(""); const [debouncedPatientSearch] = useDebounce(patientSearchTerm, 300); const searchKeyPart = debouncedPatientSearch.trim() || "recent"; const queryFn = async (): Promise => { const trimmed = debouncedPatientSearch.trim(); const url = trimmed ? `/api/patients/search?name=${encodeURIComponent(trimmed)}&limit=50&offset=0` : `/api/patients/recent?limit=50&offset=0`; const res = await apiRequest("GET", url); if (!res.ok) { const err = await res.json().catch(() => ({ message: "Failed to fetch patients" })); throw new Error(err.message || "Failed to fetch patients"); } const payload = await res.json(); return Array.isArray(payload) ? payload : (payload.patients ?? []); }; const { data: patients = [], isFetching: isFetchingPatients, refetch: refetchPatients, } = useQuery({ queryKey: ["patients-dropdown", searchKeyPart], queryFn, enabled: selectOpen || debouncedPatientSearch.trim().length > 0, }); useEffect(() => { if (selectOpen && patients.length === 0) { refetchPatients(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectOpen]); // Prefill form from prefillData prop (new appointment slot click) useEffect(() => { if (!prefillData) return; form.setValue("staffId", prefillData.staffId); form.setValue("startTime", prefillData.startTime); form.setValue("endTime", prefillData.endTime); form.setValue("date", parseLocalDate(prefillData.date)); if (prefillData.type) form.setValue("type", prefillData.type); if (prefillData.patientId) { form.setValue("patientId", prefillData.patientId); (async () => { try { const res = await apiRequest("GET", `/api/patients/${prefillData.patientId}`); if (res.ok) setPrefillPatient(await res.json()); } catch {} })(); } }, [prefillData]); // When editing an appointment, ensure we prefill the patient so SelectValue can render useEffect(() => { if (!appointment?.patientId) return; const pid = Number(appointment.patientId); if (Number.isNaN(pid)) return; // set form value immediately so the select has a value form.setValue("patientId", pid); // fetch the single patient record and set prefill (async () => { try { const res = await apiRequest("GET", `/api/patients/${pid}`); if (res.ok) { const patientRecord = await res.json(); setPrefillPatient(patientRecord); } else { let msg = `Failed to load patient (status ${res.status})`; try { const body = await res.json().catch(() => null); if (body && body.message) msg = body.message; } catch {} toast({ title: "Could not load patient", description: msg, variant: "destructive", }); } } catch (err) { toast({ title: "Error fetching patient", description: (err as Error)?.message || "An unknown error occurred while fetching patient details.", variant: "destructive", }); } })(); // note: we intentionally do NOT remove prefillPatientd here; it will be cleared when dropdown opens and main list contains the patient }, [appointment?.patientId]); const handleSubmit = (data: InsertAppointment) => { // Make sure patientId is a number const patientId = typeof data.patientId === "string" ? parseInt(data.patientId, 10) : data.patientId; // Auto-create title if it's empty let title = data.title; if (!title || title.trim() === "") { // Format: "April 19" - just the date title = format(data.date, "MMMM d"); } const notes = data.notes || ""; const selectedStaff = staffMembers.find((staff) => staff.id?.toString() === data.staffId) || staffMembers[0]; if (!selectedStaff) { console.error("No staff selected and no available staff in the list"); return; } const formattedDate = formatLocalDate(data.date); const resolvedType = data.type === "other" && otherTypeDesc.trim() ? `other:${otherTypeDesc.trim()}` : data.type; onSubmit({ ...data, userId: Number(user?.id), title, notes, patientId, date: formattedDate, startTime: data.startTime, endTime: data.endTime, type: resolvedType, // Lock the type when the user has explicitly changed it on an existing appointment ...(appointment && typeChangedByUser ? { typeLocked: true } : {}), }); }; return (
{ handleSubmit(data); }, (errors) => { console.error("Validation failed:", errors); } )} className="space-y-6" > ( Patient setPatientSearchTerm(e.target.value)} onClick={(e) => e.stopPropagation()} />
{/* Prefill patient only if main list does not already include them */} {prefillPatient && !patients.some( (p) => Number(p.id) === Number(prefillPatient.id) ) && (
{prefillPatient.firstName}{" "} {prefillPatient.lastName} DOB:{" "} {prefillPatient.dateOfBirth ? new Date( prefillPatient.dateOfBirth ).toLocaleDateString() : ""}{" "} • {prefillPatient.phone ?? ""}
)}
{isFetchingPatients ? (
Loading...
) : patients && patients.length > 0 ? ( patients.map((patient) => (
{patient.firstName} {patient.lastName} DOB:{" "} {new Date( patient.dateOfBirth ).toLocaleDateString()}{" "} • {patient.phone}
)) ) : (
No patients found
)}
)} /> ( Appointment Title{" "} (optional) )} />
( Start Time
)} /> ( End Time
)} />
( Appointment Type {field.value === "other" && ( setOtherTypeDesc(e.target.value)} disabled={isLoading} autoFocus /> )} )} /> ( Status )} /> ( Doctor/Hygienist )} /> ( Notes