Files
DentalManagementMH06/apps/Frontend/src/components/appointments/appointment-form.tsx
ff facf9e79e2 fix: show only user-typed notes on appointment card
Remove auto-appending of "Appointment with [staff name]" to notes on
save, and preserve existing notes when dragging an appointment to a
new slot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:48:17 -04:00

663 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("");
if (
prefillPatient &&
patients &&
patients.some(
(p) => Number(p.id) === Number(prefillPatient.id)
)
) {
setPrefillPatient(null);
}
} 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))
}
>
<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>
);
}