Selecting a patient from search results only stored the ID, not the patient object. Closing the dropdown reset the search term and reverted the list to "recent 50" patients, which often didn't include the just selected patient, leaving nothing to render in the trigger. Also removed a redundant prefillPatient reset that nulled the fallback display before the list swap even happened. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
660 lines
22 KiB
TypeScript
Executable File
660 lines
22 KiB
TypeScript
Executable File
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<HTMLInputElement>(null);
|
|
const [prefillPatient, setPrefillPatient] = useState<Patient | null>(null);
|
|
const [otherTypeDesc, setOtherTypeDesc] = useState<string>(() => {
|
|
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<string>(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<Staff[]>({
|
|
queryKey: ["/api/staffs/"],
|
|
queryFn: async () => {
|
|
const res = await apiRequest("GET", "/api/staffs/");
|
|
return res.json();
|
|
},
|
|
enabled: !!user,
|
|
});
|
|
|
|
const colorMap: Record<string, string> = {
|
|
"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> = 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<InsertAppointment>({
|
|
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<Patient[]> => {
|
|
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<Patient[], Error>({
|
|
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 (
|
|
<div className="form-container">
|
|
<Form {...form}>
|
|
<form
|
|
onSubmit={form.handleSubmit(
|
|
(data) => {
|
|
handleSubmit(data);
|
|
},
|
|
(errors) => {
|
|
console.error("Validation failed:", errors);
|
|
}
|
|
)}
|
|
className="space-y-6"
|
|
>
|
|
<FormField
|
|
control={form.control}
|
|
name="patientId"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Patient</FormLabel>
|
|
|
|
<Select
|
|
disabled={isLoading}
|
|
onOpenChange={(open: boolean) => {
|
|
setSelectOpen(open);
|
|
if (!open) {
|
|
setPatientSearchTerm("");
|
|
} else {
|
|
if (!patients || patients.length === 0) refetchPatients();
|
|
}
|
|
}}
|
|
value={
|
|
field.value == null || // null or undefined
|
|
(typeof field.value === "number" &&
|
|
!Number.isFinite(field.value)) || // NaN/Infinity
|
|
(typeof field.value === "string" &&
|
|
field.value.trim() === "") || // empty string
|
|
field.value === "NaN" // defensive check
|
|
? ""
|
|
: String(field.value)
|
|
}
|
|
onValueChange={(val) => {
|
|
field.onChange(val === "" ? undefined : Number(val));
|
|
if (val !== "") {
|
|
const picked = patients.find(
|
|
(p) => Number(p.id) === Number(val)
|
|
);
|
|
if (picked) setPrefillPatient(picked);
|
|
}
|
|
}}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a patient" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
|
|
<SelectContent>
|
|
<div className="p-2" onKeyDown={(e) => e.stopPropagation()}>
|
|
<Input
|
|
placeholder="Search by name..."
|
|
value={patientSearchTerm}
|
|
onChange={(e) => setPatientSearchTerm(e.target.value)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
|
|
{/* Prefill patient only if main list does not already include them */}
|
|
{prefillPatient &&
|
|
!patients.some(
|
|
(p) => Number(p.id) === Number(prefillPatient.id)
|
|
) && (
|
|
<SelectItem
|
|
key={`prefill-${prefillPatient.id}`}
|
|
value={prefillPatient.id?.toString() ?? ""}
|
|
>
|
|
<div className="flex flex-col items-start">
|
|
<span className="font-medium">
|
|
{prefillPatient.firstName}{" "}
|
|
{prefillPatient.lastName}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
DOB:{" "}
|
|
{prefillPatient.dateOfBirth
|
|
? new Date(
|
|
prefillPatient.dateOfBirth
|
|
).toLocaleDateString()
|
|
: ""}{" "}
|
|
• {prefillPatient.phone ?? ""}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
)}
|
|
|
|
<div className="max-h-60 overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/30">
|
|
{isFetchingPatients ? (
|
|
<div className="p-2 text-sm text-muted-foreground">
|
|
Loading...
|
|
</div>
|
|
) : patients && patients.length > 0 ? (
|
|
patients.map((patient) => (
|
|
<SelectItem
|
|
key={patient.id}
|
|
value={patient.id?.toString() ?? ""}
|
|
>
|
|
<div className="flex flex-col items-start">
|
|
<span className="font-medium">
|
|
{patient.firstName} {patient.lastName}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
DOB:{" "}
|
|
{new Date(
|
|
patient.dateOfBirth
|
|
).toLocaleDateString()}{" "}
|
|
• {patient.phone}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))
|
|
) : (
|
|
<div className="p-2 text-muted-foreground text-sm">
|
|
No patients found
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="title"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
Appointment Title{" "}
|
|
<span className="text-muted-foreground text-xs">
|
|
(optional)
|
|
</span>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="Leave blank to auto-fill with date"
|
|
{...field}
|
|
disabled={isLoading}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<DateInputField control={form.control} name="date" label="Date" />
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="startTime"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Start Time</FormLabel>
|
|
<FormControl>
|
|
<div className="relative">
|
|
<Clock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="09:00"
|
|
{...field}
|
|
disabled={isLoading}
|
|
className="pl-10"
|
|
value={
|
|
typeof field.value === "string" ? field.value : ""
|
|
}
|
|
/>
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="endTime"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>End Time</FormLabel>
|
|
<FormControl>
|
|
<div className="relative">
|
|
<Clock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="09:30"
|
|
{...field}
|
|
disabled={isLoading}
|
|
className="pl-10"
|
|
value={
|
|
typeof field.value === "string" ? field.value : ""
|
|
}
|
|
/>
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="type"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Appointment Type</FormLabel>
|
|
<Select
|
|
disabled={isLoading}
|
|
onValueChange={(val) => {
|
|
field.onChange(val);
|
|
if (val !== "other") setOtherTypeDesc("");
|
|
if (val !== originalType.current) setTypeChangedByUser(true);
|
|
}}
|
|
value={field.value}
|
|
defaultValue={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a type" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{APPOINTMENT_TYPES.map((t) => (
|
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{field.value === "other" && (
|
|
<Input
|
|
className="mt-2"
|
|
placeholder="Describe the appointment type…"
|
|
value={otherTypeDesc}
|
|
onChange={(e) => setOtherTypeDesc(e.target.value)}
|
|
disabled={isLoading}
|
|
autoFocus
|
|
/>
|
|
)}
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="status"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Status</FormLabel>
|
|
<Select
|
|
disabled={isLoading}
|
|
onValueChange={field.onChange}
|
|
value={field.value}
|
|
defaultValue={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a status" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="scheduled">Scheduled</SelectItem>
|
|
<SelectItem value="confirmed">Confirmed</SelectItem>
|
|
<SelectItem value="completed">Completed</SelectItem>
|
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
|
<SelectItem value="no-show">No Show</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="staffId"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Doctor/Hygienist</FormLabel>
|
|
<Select
|
|
disabled={isLoading}
|
|
onValueChange={(val) => field.onChange(Number(val))}
|
|
value={field.value ? String(field.value) : undefined}
|
|
defaultValue={field.value ? String(field.value) : undefined}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select staff member" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{staffMembers.map((staff) => (
|
|
<SelectItem
|
|
key={staff.id}
|
|
value={staff.id?.toString() || ""}
|
|
>
|
|
{staff.name} ({staff.role})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="notes"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Notes</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="Enter any notes about the appointment"
|
|
{...field}
|
|
disabled={isLoading}
|
|
className="min-h-24"
|
|
value={field.value ?? ""}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<Button type="submit" disabled={isLoading} className="w-full">
|
|
{appointment ? "Update Appointment" : "Create Appointment"}
|
|
</Button>
|
|
|
|
{appointment?.id && onDelete && (
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
onOpenChange?.(false); // 👈 Close the modal first
|
|
|
|
setTimeout(() => {
|
|
onDelete?.(appointment.id!);
|
|
}, 300); // 300ms is safe for most animations
|
|
}}
|
|
className="bg-red-600 text-white w-full rounded hover:bg-red-700"
|
|
>
|
|
Delete Appointment
|
|
</Button>
|
|
)}
|
|
</form>
|
|
</Form>
|
|
</div>
|
|
);
|
|
}
|