539 lines
17 KiB
TypeScript
539 lines
17 KiB
TypeScript
import { useEffect } 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 { 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 { Calendar } from "@/components/ui/calendar";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { CalendarIcon, Clock } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
|
|
import {
|
|
AppointmentUncheckedCreateInputObjectSchema,
|
|
PatientUncheckedCreateInputObjectSchema,
|
|
StaffUncheckedCreateInputObjectSchema,
|
|
} from "@repo/db/shared/schemas";
|
|
|
|
import { z } from "zod";
|
|
type Appointment = z.infer<typeof AppointmentUncheckedCreateInputObjectSchema>;
|
|
type Staff = z.infer<typeof StaffUncheckedCreateInputObjectSchema>;
|
|
|
|
const insertAppointmentSchema = (
|
|
AppointmentUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
|
).omit({
|
|
id: true,
|
|
createdAt: true,
|
|
userId: true,
|
|
});
|
|
type InsertAppointment = z.infer<typeof insertAppointmentSchema>;
|
|
|
|
const updateAppointmentSchema = (
|
|
AppointmentUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
|
)
|
|
.omit({
|
|
id: true,
|
|
createdAt: true,
|
|
userId: true,
|
|
})
|
|
.partial();
|
|
type UpdateAppointment = z.infer<typeof updateAppointmentSchema>;
|
|
|
|
const PatientSchema = (
|
|
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
|
).omit({
|
|
appointments: true,
|
|
});
|
|
type Patient = z.infer<typeof PatientSchema>;
|
|
|
|
interface AppointmentFormProps {
|
|
appointment?: Appointment;
|
|
patients: Patient[];
|
|
onSubmit: (data: InsertAppointment | UpdateAppointment) => void;
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
export function AppointmentForm({
|
|
appointment,
|
|
patients,
|
|
onSubmit,
|
|
isLoading = false,
|
|
}: AppointmentFormProps) {
|
|
const { user } = useAuth();
|
|
|
|
const { data: staffMembersRaw = [] as Staff[], isLoading: isLoadingStaff } =
|
|
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",
|
|
}));
|
|
|
|
// Get the stored data from session storage
|
|
const storedDataString = sessionStorage.getItem("newAppointmentData");
|
|
let parsedStoredData = null;
|
|
|
|
// Try to parse it if it exists
|
|
if (storedDataString) {
|
|
try {
|
|
parsedStoredData = JSON.parse(storedDataString);
|
|
} catch (error) {
|
|
console.error("Error parsing stored appointment data:", error);
|
|
}
|
|
}
|
|
|
|
// Format the date and times for the form
|
|
const defaultValues: Partial<Appointment> = appointment
|
|
? {
|
|
patientId: appointment.patientId,
|
|
title: appointment.title,
|
|
date: new Date(appointment.date),
|
|
startTime: appointment.startTime || "09:00", // Default "09:00"
|
|
endTime: appointment.endTime || "09:30", // Default "09:30"
|
|
type: appointment.type,
|
|
notes: appointment.notes || "",
|
|
status: appointment.status || "scheduled",
|
|
staffId:
|
|
typeof appointment.staffId === "number"
|
|
? appointment.staffId
|
|
: undefined,
|
|
}
|
|
: parsedStoredData
|
|
? {
|
|
patientId: Number(parsedStoredData.patientId),
|
|
date: new Date(parsedStoredData.date),
|
|
title: parsedStoredData.title || "",
|
|
startTime: parsedStoredData.startTime,
|
|
endTime: parsedStoredData.endTime,
|
|
type: parsedStoredData.type || "checkup",
|
|
status: parsedStoredData.status || "scheduled",
|
|
notes: parsedStoredData.notes || "",
|
|
staffId:
|
|
typeof parsedStoredData.staff === "number"
|
|
? parsedStoredData.staff
|
|
: (staffMembers?.[0]?.id ?? undefined),
|
|
}
|
|
: {
|
|
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,
|
|
});
|
|
|
|
// Force form field values to update and clean up storage
|
|
useEffect(() => {
|
|
if (parsedStoredData) {
|
|
// Update form field values directly
|
|
if (parsedStoredData.startTime) {
|
|
form.setValue("startTime", parsedStoredData.startTime);
|
|
}
|
|
|
|
if (parsedStoredData.endTime) {
|
|
form.setValue("endTime", parsedStoredData.endTime);
|
|
}
|
|
|
|
if (parsedStoredData.staff) {
|
|
form.setValue("staffId", parsedStoredData.staff);
|
|
}
|
|
|
|
if (parsedStoredData.date) {
|
|
form.setValue("date", new Date(parsedStoredData.date));
|
|
}
|
|
|
|
// Clean up session storage
|
|
sessionStorage.removeItem("newAppointmentData");
|
|
}
|
|
}, [form]);
|
|
|
|
const handleSubmit = (data: InsertAppointment) => {
|
|
// Make sure patientId is a number
|
|
const patientId =
|
|
typeof data.patientId === "string"
|
|
? parseInt(data.patientId, 10)
|
|
: data.patientId;
|
|
|
|
// Get patient name for the title
|
|
const patient = patients.find((p) => p.id === patientId);
|
|
const patientName = patient
|
|
? `${patient.firstName} ${patient.lastName}`
|
|
: "Patient";
|
|
|
|
// 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");
|
|
}
|
|
|
|
let 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; // Handle this case as well
|
|
}
|
|
|
|
// If there's no staff information in the notes, add it
|
|
if (!notes.includes("Appointment with")) {
|
|
notes = notes
|
|
? `${notes}\nAppointment with ${selectedStaff?.name}`
|
|
: `Appointment with ${selectedStaff?.name}`;
|
|
}
|
|
|
|
// 👇 Use current date if none provided
|
|
const appointmentDate = data.date ? new Date(data.date) : new Date();
|
|
|
|
if (isNaN(appointmentDate.getTime())) {
|
|
console.error("Invalid date:", data.date);
|
|
return;
|
|
}
|
|
|
|
onSubmit({
|
|
...data,
|
|
title,
|
|
notes,
|
|
patientId,
|
|
date: format(appointmentDate, "yyyy-MM-dd"),
|
|
startTime: data.startTime,
|
|
endTime: data.endTime,
|
|
});
|
|
};
|
|
|
|
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}
|
|
onValueChange={(val) => field.onChange(Number(val))}
|
|
value={field.value?.toString()}
|
|
defaultValue={field.value?.toString()}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a patient" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{patients.map((patient) => (
|
|
<SelectItem
|
|
key={patient.id}
|
|
value={patient.id.toString()}
|
|
>
|
|
{patient.firstName} {patient.lastName}
|
|
</SelectItem>
|
|
))}
|
|
</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>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="date"
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-col">
|
|
<FormLabel>Date</FormLabel>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<FormControl>
|
|
<Button
|
|
variant={"outline"}
|
|
className={cn(
|
|
"w-full pl-3 text-left font-normal",
|
|
!field.value && "text-muted-foreground"
|
|
)}
|
|
disabled={isLoading}
|
|
>
|
|
{field.value ? (
|
|
format(field.value, "PPP")
|
|
) : (
|
|
<span>Pick a date</span>
|
|
)}
|
|
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
|
</Button>
|
|
</FormControl>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={field.value ? new Date(field.value) : undefined}
|
|
onSelect={field.onChange}
|
|
disabled={(date) =>
|
|
date < new Date(new Date().setHours(0, 0, 0, 0))
|
|
}
|
|
initialFocus
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<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={field.onChange}
|
|
value={field.value}
|
|
defaultValue={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a type" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="checkup">Checkup</SelectItem>
|
|
<SelectItem value="cleaning">Cleaning</SelectItem>
|
|
<SelectItem value="filling">Filling</SelectItem>
|
|
<SelectItem value="extraction">Extraction</SelectItem>
|
|
<SelectItem value="root-canal">Root Canal</SelectItem>
|
|
<SelectItem value="crown">Crown</SelectItem>
|
|
<SelectItem value="dentures">Dentures</SelectItem>
|
|
<SelectItem value="consultation">Consultation</SelectItem>
|
|
<SelectItem value="emergency">Emergency</SelectItem>
|
|
<SelectItem value="other">Other</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<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>
|
|
</form>
|
|
</Form>
|
|
</div>
|
|
);
|
|
}
|