diff --git a/apps/Frontend/src/components/appointments/add-appointment-modal.tsx b/apps/Frontend/src/components/appointments/add-appointment-modal.tsx index ae1810b..ec9f641 100644 --- a/apps/Frontend/src/components/appointments/add-appointment-modal.tsx +++ b/apps/Frontend/src/components/appointments/add-appointment-modal.tsx @@ -8,7 +8,6 @@ import { AppointmentForm } from "./appointment-form"; import { Appointment, InsertAppointment, - Patient, UpdateAppointment, } from "@repo/db/types"; @@ -19,7 +18,6 @@ interface AddAppointmentModalProps { onDelete?: (id: number) => void; isLoading: boolean; appointment?: Appointment; - patients: Patient[]; } export function AddAppointmentModal({ @@ -29,7 +27,6 @@ export function AddAppointmentModal({ onDelete, isLoading, appointment, - patients, }: AddAppointmentModalProps) { return ( @@ -42,7 +39,6 @@ export function AddAppointmentModal({
{ onSubmit(data); onOpenChange(false); diff --git a/apps/Frontend/src/components/appointments/appointment-form.tsx b/apps/Frontend/src/components/appointments/appointment-form.tsx index 5e43337..4bad01f 100644 --- a/apps/Frontend/src/components/appointments/appointment-form.tsx +++ b/apps/Frontend/src/components/appointments/appointment-form.tsx @@ -1,8 +1,8 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, 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 { apiRequest, queryClient } from "@/lib/queryClient"; import { Button } from "@/components/ui/button"; import { Form, @@ -22,7 +22,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Clock } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useDebounce } from "use-debounce"; import { @@ -34,10 +34,16 @@ import { UpdateAppointment, } from "@repo/db/types"; import { DateInputField } from "@/components/ui/dateInputField"; +import { parseLocalDate } from "@/utils/dateUtils"; +import { + PatientSearch, + SearchCriteria, +} from "@/components/patients/patient-search"; +import { QK_PATIENTS_BASE } from "../patients/patient-table"; +import { toast } from "@/hooks/use-toast"; interface AppointmentFormProps { appointment?: Appointment; - patients: Patient[]; onSubmit: (data: InsertAppointment | UpdateAppointment) => void; onDelete?: (id: number) => void; onOpenChange?: (open: boolean) => void; @@ -46,7 +52,6 @@ interface AppointmentFormProps { export function AppointmentForm({ appointment, - patients, onSubmit, onDelete, onOpenChange, @@ -54,6 +59,9 @@ export function AppointmentForm({ }: AppointmentFormProps) { const { user } = useAuth(); const inputRef = useRef(null); + const queryClient = useQueryClient(); + const [prefillPatient, setPrefillPatient] = useState(null); + useEffect(() => { const timeout = setTimeout(() => { inputRef.current?.focus(); @@ -82,23 +90,6 @@ export function AppointmentForm({ color: colorMap[staff.name] || "bg-gray-400", })); - function parseLocalDate(dateString: string): Date { - const parts = dateString.split("-"); - if (parts.length !== 3) { - return new Date(); - } - const year = parseInt(parts[0] ?? "", 10); - const month = parseInt(parts[1] ?? "", 10); - const day = parseInt(parts[2] ?? "", 10); - - if (isNaN(year) || isNaN(month) || isNaN(day)) { - return new Date(); - } - - // Create date at UTC midnight instead of local midnight - return new Date(year, month - 1, day); - } - // Get the stored data from session storage const storedDataString = sessionStorage.getItem("newAppointmentData"); let parsedStoredData = null; @@ -169,50 +160,159 @@ export function AppointmentForm({ defaultValues, }); - const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearchTerm] = useDebounce(searchTerm, 200); // 1 seconds - const [filteredPatients, setFilteredPatients] = useState(patients); + // ----------------------------- + // PATIENT SEARCH (reuse PatientSearch) + // ----------------------------- + const [selectOpen, setSelectOpen] = useState(false); - useEffect(() => { - if (!debouncedSearchTerm.trim()) { - setFilteredPatients(patients); + // search criteria state (reused from patient page) + const [searchCriteria, setSearchCriteria] = useState( + null + ); + const [isSearchActive, setIsSearchActive] = useState(false); + + // debounce search criteria so we don't hammer the backend + const [debouncedSearchCriteria] = useDebounce(searchCriteria, 300); + + const limit = 50; // dropdown size + const offset = 0; // always first page for dropdown + + // compute key used in patient page: recent or trimmed term + const searchKeyPart = useMemo( + () => debouncedSearchCriteria?.searchTerm?.trim() || "recent", + [debouncedSearchCriteria] + ); + + // Query function mirrors PatientTable logic (so backend contract is identical) + const queryFn = async (): Promise => { + const trimmedTerm = debouncedSearchCriteria?.searchTerm?.trim(); + const isSearch = !!trimmedTerm && trimmedTerm.length > 0; + const rawSearchBy = debouncedSearchCriteria?.searchBy || "name"; + const validSearchKeys = [ + "name", + "phone", + "insuranceId", + "gender", + "dob", + "all", + ]; + const searchKey = validSearchKeys.includes(rawSearchBy) + ? rawSearchBy + : "name"; + + let url: string; + if (isSearch) { + const searchParams = new URLSearchParams({ + limit: String(limit), + offset: String(offset), + }); + + if (searchKey === "all") { + searchParams.set("term", trimmedTerm!); + } else { + searchParams.set(searchKey, trimmedTerm!); + } + + url = `/api/patients/search?${searchParams.toString()}`; } else { - const term = debouncedSearchTerm.toLowerCase(); - setFilteredPatients( - patients.filter((p) => - `${p.firstName} ${p.lastName} ${p.phone} ${p.dateOfBirth}` - .toLowerCase() - .includes(term) - ) - ); + url = `/api/patients/recent?limit=${limit}&offset=${offset}`; } - }, [debouncedSearchTerm, patients]); + + 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(); + // Expect payload to be { patients: Patient[], totalCount: number } or just an array. + // Normalize: if payload.patients exists, return it; otherwise assume array of patients. + return Array.isArray(payload) ? payload : (payload.patients ?? []); + }; + + const { + data: patients = [], + isFetching: isFetchingPatients, + refetch: refetchPatients, + } = useQuery({ + queryKey: ["patients-dropdown", searchKeyPart], + queryFn, + enabled: selectOpen || !!debouncedSearchCriteria?.searchTerm, + }); + + // If select opened and no patients loaded, fetch + useEffect(() => { + if (selectOpen && (!patients || patients.length === 0)) { + refetchPatients(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectOpen]); // 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) return; - if (parsedStoredData.endTime) { - form.setValue("endTime", parsedStoredData.endTime); - } + // set times/staff/date as before + 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) { + const parsedDate = + typeof parsedStoredData.date === "string" + ? parseLocalDate(parsedStoredData.date) + : new Date(parsedStoredData.date); + form.setValue("date", parsedDate); + } - if (parsedStoredData.staff) { - form.setValue("staffId", parsedStoredData.staff); - } + // ---- patient prefill: check main cache, else fetch once ---- + if (parsedStoredData.patientId) { + const pid = Number(parsedStoredData.patientId); + if (!Number.isNaN(pid)) { + // ensure the form value is set + form.setValue("patientId", pid); - if (parsedStoredData.date) { - const parsedDate = - typeof parsedStoredData.date === "string" - ? parseLocalDate(parsedStoredData.date) - : new Date(parsedStoredData.date); - form.setValue("date", parsedDate); + // fetch single patient record (preferred) + (async () => { + try { + const res = await apiRequest("GET", `/api/patients/${pid}`); + if (res.ok) { + const patientRecord = await res.json(); + setPrefillPatient(patientRecord); + } else { + // non-OK response: show toast with status / message + 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", + }); + } finally { + // remove the one-time transport + sessionStorage.removeItem("newAppointmentData"); + } + })(); } - - // Clean up session storage + } else { + // no patientId in storage — still remove to avoid stale state sessionStorage.removeItem("newAppointmentData"); } }, [form]); @@ -224,12 +324,6 @@ export function AppointmentForm({ ? 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() === "") { @@ -289,41 +383,109 @@ export function AppointmentForm({ render={({ field }) => ( Patient + setSearchTerm(e.target.value)} - onKeyDown={(e) => { - const navKeys = ["ArrowDown", "ArrowUp", "Enter"]; - if (!navKeys.includes(e.key)) { - e.stopPropagation(); // Only stop keys that affect select state - } + { + setSearchCriteria(criteria); + setIsSearchActive(true); }} + onClearSearch={() => { + setSearchCriteria({ + searchTerm: "", + searchBy: "name", + }); + setIsSearchActive(false); + }} + isSearchActive={isSearchActive} />
+ + {/* 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 ?? ""} + +
+
+ )} +
- {filteredPatients.length > 0 ? ( - filteredPatients.map((patient) => ( + {isFetchingPatients ? ( +
+ Loading... +
+ ) : patients && patients.length > 0 ? ( + patients.map((patient) => ( -
+
{patient.firstName} {patient.lastName} @@ -345,6 +507,7 @@ export function AppointmentForm({
+ )} diff --git a/apps/Frontend/src/components/appointments/appointment-table.tsx b/apps/Frontend/src/components/appointments/appointment-table.tsx deleted file mode 100644 index 13e8ec7..0000000 --- a/apps/Frontend/src/components/appointments/appointment-table.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { format } from "date-fns"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - MoreHorizontal, - Edit, - Trash2, - Eye, - Calendar, - Clock, -} from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Appointment, Patient } from "@repo/db/types"; - -interface AppointmentTableProps { - appointments: Appointment[]; - patients: Patient[]; - onEdit: (appointment: Appointment) => void; - onDelete: (id: number) => void; -} - -export function AppointmentTable({ - appointments, - patients, - onEdit, - onDelete, -}: AppointmentTableProps) { - // Helper function to get patient name - const getPatientName = (patientId: number) => { - const patient = patients.find((p) => p.id === patientId); - return patient - ? `${patient.firstName} ${patient.lastName}` - : "Unknown Patient"; - }; - - // Helper function to get status badge - const getStatusBadge = (status: string) => { - const statusConfig: Record< - string, - { - variant: - | "default" - | "secondary" - | "destructive" - | "outline" - | "success"; - label: string; - } - > = { - scheduled: { variant: "default", label: "Scheduled" }, - confirmed: { variant: "secondary", label: "Confirmed" }, - completed: { variant: "success", label: "Completed" }, - cancelled: { variant: "destructive", label: "Cancelled" }, - "no-show": { variant: "outline", label: "No Show" }, - }; - - const config = statusConfig[status] || { - variant: "default", - label: status, - }; - - return {config.label}; - }; - - // Sort appointments by date and time (newest first) - const sortedAppointments = [...appointments].sort((a, b) => { - const dateComparison = - new Date(b.date).getTime() - new Date(a.date).getTime(); - if (dateComparison !== 0) return dateComparison; - return a.startTime.toString().localeCompare(b.startTime.toString()); - }); - - return ( -
- - - - Patient - Date - Time - Type - Status - Actions - - - - {sortedAppointments.length === 0 ? ( - - - No appointments found. - - - ) : ( - sortedAppointments.map((appointment) => ( - - - {getPatientName(appointment.patientId)} - - -
- - {format(new Date(appointment.date), "MMM d, yyyy")} -
-
- -
- - {appointment.startTime.slice(0, 5)} -{" "} - {appointment.endTime.slice(0, 5)} -
-
- - {appointment.type.replace("-", " ")} - - {getStatusBadge(appointment.status!)} - - - - - - - Actions - onEdit(appointment)}> - - Edit - - - { - if (typeof appointment.id === "number") { - onDelete(appointment.id); - } else { - console.error("Invalid appointment ID"); - } - }} - className="text-destructive focus:text-destructive" - > - - Delete - - - - -
- )) - )} -
-
-
- ); -} diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index 9fe4395..0df7943 100644 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -1,7 +1,11 @@ import { useState, useEffect } from "react"; import { useQuery, useMutation, keepPreviousData } from "@tanstack/react-query"; -import { format, addDays, startOfToday, addMinutes } from "date-fns"; -import { parseLocalDate, formatLocalDate } from "@/utils/dateUtils"; +import { addDays, startOfToday, addMinutes } from "date-fns"; +import { + parseLocalDate, + formatLocalDate, + formatLocalTime, +} from "@/utils/dateUtils"; import { AddAppointmentModal } from "@/components/appointments/add-appointment-modal"; import { Button } from "@/components/ui/button"; import { @@ -9,7 +13,6 @@ import { Plus, ChevronLeft, ChevronRight, - RefreshCw, Move, Trash2, } from "lucide-react"; @@ -65,6 +68,12 @@ interface ScheduledAppointment { // Define a unique ID for the appointment context menu const APPOINTMENT_CONTEXT_MENU_ID = "appointment-context-menu"; +// 🔑 exported base key +export const QK_APPOINTMENTS_BASE = ["appointments", "day"] as const; +// helper (optional) – mirrors the query key structure +export const qkAppointmentsDay = (date: string) => + [...QK_APPOINTMENTS_BASE, date] as const; + export default function AppointmentsPage() { const { toast } = useToast(); const { user } = useAuth(); @@ -77,7 +86,6 @@ export default function AppointmentsPage() { const [confirmDeleteState, setConfirmDeleteState] = useState<{ open: boolean; appointmentId?: number; - appointmentTitle?: string; }>({ open: false }); // Create context menu hook @@ -85,6 +93,42 @@ export default function AppointmentsPage() { id: APPOINTMENT_CONTEXT_MENU_ID, }); + // ---------------------- + // Day-level fetch: appointments + patients for selectedDate (lightweight) + // ---------------------- + const formattedSelectedDate = formatLocalDate(selectedDate); + type DayPayload = { appointments: Appointment[]; patients: Patient[] }; + const { + data: dayPayload = { + appointments: [] as Appointment[], + patients: [] as Patient[], + }, + isLoading: isLoadingAppointments, + refetch: refetchAppointments, + } = useQuery< + DayPayload, + Error, + DayPayload, + readonly [string, string, string] + >({ + queryKey: qkAppointmentsDay(formattedSelectedDate), + queryFn: async () => { + const res = await apiRequest( + "GET", + `/api/appointments/day?date=${formattedSelectedDate}` + ); + if (!res.ok) { + throw new Error("Failed to load appointments for date"); + } + return res.json(); + }, + enabled: !!user && !!formattedSelectedDate, + // placeholderData: keepPreviousData, + }); + + const appointments = dayPayload.appointments ?? []; + const patientsFromDay = dayPayload.patients ?? []; + // Staff memebers const { data: staffMembersRaw = [] as Staff[] } = useQuery({ queryKey: ["/api/staffs/"], @@ -123,150 +167,61 @@ export default function AppointmentsPage() { } } - // ---------------------- - // Day-level fetch: appointments + patients for selectedDate (lightweight) - // ---------------------- - const formattedSelectedDate = formatLocalDate(selectedDate); - type DayPayload = { appointments: Appointment[]; patients: Patient[] }; - const queryKey = ["appointments", "day", formattedSelectedDate] as const; - - const { - data: dayPayload = { - appointments: [] as Appointment[], - patients: [] as Patient[], - }, - isLoading: isLoadingAppointments, - refetch: refetchAppointments, - } = useQuery< - DayPayload, - Error, - DayPayload, - readonly [string, string, string] - >({ - queryKey, - queryFn: async () => { - const res = await apiRequest( - "GET", - `/api/appointments/day?date=${formattedSelectedDate}` - ); - if (!res.ok) { - throw new Error("Failed to load appointments for date"); - } - return res.json(); - }, - enabled: !!user && !!formattedSelectedDate, - placeholderData: keepPreviousData, - }); - - const appointments = dayPayload.appointments ?? []; - const patientsFromDay = dayPayload.patients ?? []; - - // Fetch patients (needed for the dropdowns) - const { data: patients = [], isLoading: isLoadingPatients } = useQuery< - Patient[] - >({ - queryKey: ["/api/patients/"], - queryFn: async () => { - const res = await apiRequest("GET", "/api/patients/"); - return res.json(); - }, - enabled: !!user, - }); - - // Handle creating a new appointment at a specific time slot and for a specific staff member - const handleCreateAppointmentAtSlot = ( - timeSlot: TimeSlot, - staffId: number - ) => { - // Calculate end time (30 minutes after start time) - const startHour = parseInt(timeSlot.time.split(":")[0] as string); - const startMinute = parseInt(timeSlot.time.split(":")[1] as string); - const startDate = parseLocalDate(selectedDate); - startDate.setHours(startHour, startMinute, 0); - - const endDate = addMinutes(startDate, 30); - const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`; - - // Find staff member - const staff = staffMembers.find((s) => Number(s.id) === Number(staffId)); - - // Pre-fill appointment form with default values - const newAppointment = { - date: format(selectedDate, "yyyy-MM-dd"), - startTime: timeSlot.time, // This is in "HH:MM" format - endTime: endTime, - type: staff?.role === "doctor" ? "checkup" : "cleaning", - status: "scheduled", - title: `Appointment with ${staff?.name}`, // Add staff name in title for easier display - notes: `Appointment with ${staff?.name}`, // Store staff info in notes for processing - staff: staffId, // This matches the 'staff' field in the appointment form schema - }; - - // For new appointments, set editingAppointment to undefined - // This will ensure we go to the "create" branch in handleAppointmentSubmit - setEditingAppointment(undefined); - - // But still pass the pre-filled data to the modal - setIsAddModalOpen(true); - - // Store the prefilled values in state or sessionStorage to access in the modal - // Clear any existing data first to ensure we're not using old data - sessionStorage.removeItem("newAppointmentData"); - sessionStorage.setItem( - "newAppointmentData", - JSON.stringify(newAppointment) - ); - }; - // Check for newPatient parameter in URL useEffect(() => { - if (patients.length === 0 || !user) return; - // Parse URL search params to check for newPatient const params = new URLSearchParams(window.location.search); const newPatientId = params.get("newPatient"); if (newPatientId) { const patientId = parseInt(newPatientId); - // Find the patient in our list - const patient = (patients as Patient[]).find((p) => p.id === patientId); - - if (patient) { + // Choose first available staff safely (fallback to 1 if none) + const firstStaff = + staffMembers && staffMembers.length > 0 ? staffMembers[0] : undefined; + const staffId = firstStaff ? Number(firstStaff.id) : 1; + // Find first time slot today (9:00 AM is a common starting time) + const defaultTimeSlot = + timeSlots.find((slot) => slot.time === "09:00") || timeSlots[0]; + if (!defaultTimeSlot) { toast({ - title: "Patient Added", - description: `${patient.firstName} ${patient.lastName} was added successfully. You can now schedule an appointment.`, + title: "Unable to schedule", + description: + "No available time slots to schedule the new patient right now.", + variant: "destructive", }); - - // Select first available staff member - const staffId = staffMembers[0]!.id; - - // Find first time slot today (9:00 AM is a common starting time) - const defaultTimeSlot = - timeSlots.find((slot) => slot.time === "09:00") || timeSlots[0]; - - // Open appointment modal with prefilled patient - handleCreateAppointmentAtSlot(defaultTimeSlot!, Number(staffId)); - - // Pre-select the patient in the appointment form - const patientData = { - patientId: patient.id, - }; - - // Store info in session storage for the modal to pick up - const existingData = sessionStorage.getItem("newAppointmentData"); - if (existingData) { - const parsedData = JSON.parse(existingData); - sessionStorage.setItem( - "newAppointmentData", - JSON.stringify({ - ...parsedData, - ...patientData, - }) - ); - } + return; } + + // Merge any existing "newAppointmentData" with the patient info BEFORE opening modal + try { + const existingRaw = sessionStorage.getItem("newAppointmentData"); + const existing = existingRaw ? JSON.parse(existingRaw) : {}; + const newAppointmentData = { + ...existing, + patientId: patientId, + }; + sessionStorage.setItem( + "newAppointmentData", + JSON.stringify(newAppointmentData) + ); + } catch (err) { + // If sessionStorage parsing fails, overwrite with a fresh object + sessionStorage.setItem( + "newAppointmentData", + JSON.stringify({ patientId: patientId }) + ); + } + + // Open/create the appointment modal (will read sessionStorage in the modal) + handleCreateAppointmentAtSlot(defaultTimeSlot, Number(staffId)); + + // Remove the query param from the URL so this doesn't re-run on navigation/refresh + params.delete("newPatient"); + const newSearch = params.toString(); + const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ""}${window.location.hash || ""}`; + window.history.replaceState({}, "", newUrl); } - }, [patients, user, location]); + }, [location]); // Create/upsert appointment mutation const createAppointmentMutation = useMutation({ @@ -284,8 +239,9 @@ export default function AppointmentsPage() { description: "Appointment created successfully.", }); // Invalidate both appointments and patients queries - queryClient.invalidateQueries({ queryKey: ["/api/appointments/all"] }); - queryClient.invalidateQueries({ queryKey: ["/api/patients/"] }); + queryClient.invalidateQueries({ + queryKey: qkAppointmentsDay(formattedSelectedDate), + }); setIsAddModalOpen(false); }, onError: (error: Error) => { @@ -318,8 +274,9 @@ export default function AppointmentsPage() { title: "Success", description: "Appointment updated successfully.", }); - queryClient.invalidateQueries({ queryKey: ["/api/appointments/all"] }); - queryClient.invalidateQueries({ queryKey: ["/api/patients/"] }); + queryClient.invalidateQueries({ + queryKey: qkAppointmentsDay(formattedSelectedDate), + }); setEditingAppointment(undefined); setIsAddModalOpen(false); }, @@ -343,8 +300,9 @@ export default function AppointmentsPage() { description: "Appointment deleted successfully.", }); // Invalidate both appointments and patients queries - queryClient.invalidateQueries({ queryKey: ["/api/appointments/all"] }); - queryClient.invalidateQueries({ queryKey: ["/api/patients/"] }); + queryClient.invalidateQueries({ + queryKey: qkAppointmentsDay(formattedSelectedDate), + }); setConfirmDeleteState({ open: false }); }, onError: (error: Error) => { @@ -402,75 +360,58 @@ export default function AppointmentsPage() { }; const handleDeleteAppointment = (id: number) => { - const appointment = appointments.find((a) => a.id === id); - if (!appointment) return; - - // Find patient by patientId - const patient = patients.find((p) => p.id === appointment.patientId); - setConfirmDeleteState({ open: true, appointmentId: id, - appointmentTitle: `${patient?.firstName ?? "Appointment"}`, }); }; - // Get formatted date string for display - - const selectedDateAppointments = appointments.filter((appointment) => { - const dateObj = parseLocalDate(appointment.date); - return formatLocalDate(dateObj) === formatLocalDate(selectedDate); - }); - // Process appointments for the scheduler view - const processedAppointments: ScheduledAppointment[] = - selectedDateAppointments.map((apt) => { - // Find patient name - const patient = patients.find((p) => p.id === apt.patientId); - const patientName = patient - ? `${patient.firstName} ${patient.lastName}` - : "Unknown Patient"; + const processedAppointments: ScheduledAppointment[] = ( + appointments ?? [] + ).map((apt) => { + // Find patient name + const patient = patientsFromDay.find((p) => p.id === apt.patientId); + const patientName = patient + ? `${patient.firstName} ${patient.lastName}` + : "Unknown Patient"; - let staffId: number; + const staffId = Number(apt.staffId ?? 1); - if ( - staffMembers && - staffMembers.length > 0 && - staffMembers[0] !== undefined && - staffMembers[0].id !== undefined - ) { - staffId = Number(apt.staffId); - } else { - staffId = 1; - } + const normalizedStart = + typeof apt.startTime === "string" + ? apt.startTime.substring(0, 5) + : formatLocalTime(apt.startTime); + const normalizedEnd = + typeof apt.endTime === "string" + ? apt.endTime.substring(0, 5) + : formatLocalTime(apt.endTime); - const processed = { - ...apt, - patientName, - staffId, - status: apt.status ?? null, - date: formatLocalDate(parseLocalDate(apt.date)), - }; + const processed = { + ...apt, + patientName, + staffId, + status: apt.status ?? null, + date: formatLocalDate(parseLocalDate(apt.date)), + startTime: normalizedStart, + endTime: normalizedEnd, + } as ScheduledAppointment; - return processed; - }); + return processed; + }); // Check if appointment exists at a specific time slot and staff const getAppointmentAtSlot = (timeSlot: TimeSlot, staffId: number) => { - if (processedAppointments.length === 0) { + if (!processedAppointments || processedAppointments.length === 0) return undefined; - } // In appointments for a given time slot, we'll just display the first one // In a real application, you might want to show multiple or stack them const appointmentsAtSlot = processedAppointments.filter((apt) => { - // Fix time format comparison - the database adds ":00" seconds to the time const dbTime = typeof apt.startTime === "string" ? apt.startTime.substring(0, 5) : ""; - const timeMatches = dbTime === timeSlot.time; const staffMatches = apt.staffId === staffId; - return timeMatches && staffMatches; }); @@ -479,7 +420,6 @@ export default function AppointmentsPage() { const isLoading = isLoadingAppointments || - isLoadingPatients || createAppointmentMutation.isPending || updateAppointmentMutation.isPending || deleteAppointmentMutation.isPending; @@ -489,6 +429,76 @@ export default function AppointmentsPage() { APPOINTMENT: "appointment", }; + // Handle creating a new appointment at a specific time slot and for a specific staff member + const handleCreateAppointmentAtSlot = ( + timeSlot: TimeSlot, + staffId: number + ) => { + // Calculate end time (30 minutes after start time) + const startHour = parseInt(timeSlot.time.split(":")[0] as string); + const startMinute = parseInt(timeSlot.time.split(":")[1] as string); + const startDate = parseLocalDate(selectedDate); + startDate.setHours(startHour, startMinute, 0); + + const endDate = addMinutes(startDate, 30); + const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`; + + // Find staff member + const staff = staffMembers.find((s) => Number(s.id) === Number(staffId)); + + // Try to read any existing prefill data (may include patientId from the URL handler) + let existingStored: any = null; + try { + const raw = sessionStorage.getItem("newAppointmentData"); + existingStored = raw ? JSON.parse(raw) : null; + } catch (e) { + // ignore parse errors and treat as no existing stored data + existingStored = null; + } + + // Build the prefill appointment object and merge existing stored data + const newAppointment = { + // base defaults + date: formatLocalDate(selectedDate), + startTime: timeSlot.time, // This is in "HH:MM" format + endTime: endTime, + type: staff?.role === "doctor" ? "checkup" : "cleaning", + status: "scheduled", + title: `Appointment with ${staff?.name}`, + notes: `Appointment with ${staff?.name}`, + staff: Number(staffId), // consistent field name that matches update mutation + // if existingStored has patientId (or other fields) merge them below + ...(existingStored || {}), + }; + + // Ensure explicit values from this function override stale values from storage + // (for example, prefer current slot and staff) + const mergedAppointment = { + ...newAppointment, + date: newAppointment.date, + startTime: newAppointment.startTime, + endTime: newAppointment.endTime, + staff: Number(staffId), + }; + + // Persist merged prefill so the modal/form can read it + try { + sessionStorage.setItem( + "newAppointmentData", + JSON.stringify(mergedAppointment) + ); + } catch (e) { + // ignore sessionStorage write failures + console.error("Failed to write newAppointmentData to sessionStorage", e); + } + + // For new appointments, set editingAppointment to undefined + setEditingAppointment(undefined); + + // Open modal + setIsAddModalOpen(true); + }; + // Handle moving an appointment to a new time slot and staff const handleMoveAppointment = ( appointmentId: number, @@ -781,65 +791,6 @@ export default function AppointmentsPage() { /> - - {/* Statistics Card */} - - - - Appointments - - - - Statistics for {formattedSelectedDate} - - - -
-
- - Total appointments: - - - {selectedDateAppointments.length} - -
-
- With doctors: - - { - processedAppointments.filter( - (apt) => - staffMembers.find( - (s) => Number(s.id) === apt.staffId - )?.role === "doctor" - ).length - } - -
-
- - With hygienists: - - - { - processedAppointments.filter( - (apt) => - staffMembers.find( - (s) => Number(s.id) === apt.staffId - )?.role === "hygienist" - ).length - } - -
-
-
-
@@ -854,7 +805,6 @@ export default function AppointmentsPage() { updateAppointmentMutation.isPending } appointment={editingAppointment} - patients={patients} onDelete={handleDeleteAppointment} /> @@ -862,7 +812,7 @@ export default function AppointmentsPage() { isOpen={confirmDeleteState.open} onConfirm={handleConfirmDelete} onCancel={() => setConfirmDeleteState({ open: false })} - entityName={confirmDeleteState.appointmentTitle} + entityName={String(confirmDeleteState.appointmentId)} /> ); diff --git a/apps/Frontend/src/utils/dateUtils.ts b/apps/Frontend/src/utils/dateUtils.ts index 8eb6bfa..54e8ab6 100644 --- a/apps/Frontend/src/utils/dateUtils.ts +++ b/apps/Frontend/src/utils/dateUtils.ts @@ -139,3 +139,59 @@ export function convertOCRDate(input: string | number | null | undefined): Date return new Date(year, month, day); } + + +/** + * Format a Date or date string into "HH:mm" (24-hour) string. + * + * Options: + * - By default, hours/minutes are taken in local time. + * - Pass { asUTC: true } to format using UTC hours/minutes. + * + * Examples: + * formatLocalTime(new Date(2025, 6, 15, 9, 5)) → "09:05" + * formatLocalTime("2025-07-15") → "00:00" + * formatLocalTime("2025-07-15T14:30:00Z") → "20:30" (in +06:00) + * formatLocalTime("2025-07-15T14:30:00Z", { asUTC:true }) → "14:30" + */ +export function formatLocalTime( + d: Date | string | undefined, + opts: { asUTC?: boolean } = {} +): string { + if (!d) return ""; + + const { asUTC = false } = opts; + const pad2 = (n: number) => n.toString().padStart(2, "0"); + + let dateObj: Date; + + if (d instanceof Date) { + if (isNaN(d.getTime())) return ""; + dateObj = d; + } else if (typeof d === "string") { + const raw = d.trim(); + const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(raw); + + if (isDateOnly) { + // Parse yyyy-MM-dd safely as local midnight + try { + dateObj = parseLocalDate(raw); + } catch { + dateObj = new Date(raw); // fallback + } + } else { + // For full ISO/timestamp strings, let Date handle TZ + dateObj = new Date(raw); + } + + if (isNaN(dateObj.getTime())) return ""; + } else { + return ""; + } + + const hours = asUTC ? dateObj.getUTCHours() : dateObj.getHours(); + const minutes = asUTC ? dateObj.getUTCMinutes() : dateObj.getMinutes(); + + return `${pad2(hours)}:${pad2(minutes)}`; +} +