diff --git a/.gitignore b/.gitignore index 3c3629e6..8b4102e5 100755 --- a/.gitignore +++ b/.gitignore @@ -1 +1,22 @@ node_modules + +# Build cache +.turbo/ + +# Generated TypeScript declarations +**/*.d.ts +**/*.d.ts.map + +# Runtime / sensitive backend data (keep folders, ignore contents) +apps/Backend/backups/* +apps/Backend/chat-history/* +apps/Backend/uploads/* +apps/Backend/license.json +apps/Backend/network-backup-key.json +apps/SeleniumService/downloads/* + +# Keep the folders themselves +!apps/Backend/backups/.gitkeep +!apps/Backend/chat-history/.gitkeep +!apps/Backend/uploads/.gitkeep +!apps/SeleniumService/downloads/.gitkeep diff --git a/apps/Backend/backups/.gitkeep b/apps/Backend/backups/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/Backend/chat-history/.gitkeep b/apps/Backend/chat-history/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/Backend/uploads/.gitkeep b/apps/Backend/uploads/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/Frontend/src/components/analytics/appointments-by-day.jsx b/apps/Frontend/src/components/analytics/appointments-by-day.jsx new file mode 100644 index 00000000..e1bc2f24 --- /dev/null +++ b/apps/Frontend/src/components/analytics/appointments-by-day.jsx @@ -0,0 +1,58 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip, } from "recharts"; +export function AppointmentsByDay({ appointments }) { + const daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + const countsByDay = daysOfWeek.map((day) => ({ day, count: 0 })); + // Get current date and set time to start of day (midnight) + const now = new Date(); + now.setHours(0, 0, 0, 0); + // Calculate Monday of the current week + const day = now.getDay(); // 0 = Sunday, 1 = Monday, ... + const diffToMonday = day === 0 ? -6 : 1 - day; // adjust if Sunday + const monday = new Date(now); + monday.setDate(now.getDate() + diffToMonday); + // Sunday of the current week + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + // Filter appointments only from this week (Monday to Sunday) + const appointmentsThisWeek = appointments.filter((appointment) => { + if (!appointment.date) + return false; + const date = new Date(appointment.date); + // Reset time to compare just the date + date.setHours(0, 0, 0, 0); + return date >= monday && date <= sunday; + }); + // Count appointments by day for current week + appointmentsThisWeek.forEach((appointment) => { + const date = new Date(appointment.date); + const dayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, ... + const dayIndex = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Monday=0, Sunday=6 + if (countsByDay[dayIndex]) { + countsByDay[dayIndex].count += 1; + } + }); + return ( + + + Appointments by Day + +

+ Distribution of appointments throughout the week +

+
+ +
+ + + + + + [`${value} appointments`, "Count"]} labelFormatter={(value) => `${value}`}/> + + + +
+
+
); +} diff --git a/apps/Frontend/src/components/analytics/new-patients.jsx b/apps/Frontend/src/components/analytics/new-patients.jsx new file mode 100644 index 00000000..382a623b --- /dev/null +++ b/apps/Frontend/src/components/analytics/new-patients.jsx @@ -0,0 +1,47 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts"; +export function NewPatients({ patients }) { + // Get months for the chart + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + // Process patient data by registration month + const patientsByMonth = months.map(month => ({ name: month, count: 0 })); + // Count new patients by month + patients.forEach(patient => { + const createdDate = new Date(patient.createdAt); + const monthIndex = createdDate.getMonth(); + if (patientsByMonth[monthIndex]) { + patientsByMonth[monthIndex].count += 1; + } + }); + // Add some sample data for visual effect if no patients + if (patients.length === 0) { + // Sample data pattern similar to the screenshot + const sampleData = [17, 12, 22, 16, 15, 17, 22, 28, 20, 16]; + sampleData.forEach((value, index) => { + if (index < patientsByMonth.length) { + if (patientsByMonth[index]) { + patientsByMonth[index].count = value; + } + } + }); + } + return ( + + New Patients +

Monthly trend of new patient registrations

+
+ +
+ + + + + + [`${value} patients`, "Count"]} labelFormatter={(value) => `${value}`}/> + + + +
+
+
); +} diff --git a/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.jsx b/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.jsx new file mode 100644 index 00000000..9d73f3e2 --- /dev/null +++ b/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.jsx @@ -0,0 +1,417 @@ +import { useState, useEffect, useRef } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Trash2, Plus, Save, X } from "lucide-react"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; +import { PROCEDURE_COMBOS } from "@/utils/procedureCombos"; +import { findPriceMismatches, } from "@/utils/procedureCombosMapping"; +import { useLocation } from "wouter"; +import { DeleteConfirmationDialog } from "../ui/deleteDialog"; +import { DirectComboButtons, RegularComboButtons, } from "@/components/procedure/procedure-combo-buttons"; +export function AppointmentProceduresDialog({ open, onOpenChange, appointmentId, patientId, patient, serviceDate, }) { + const { toast } = useToast(); + const [, setLocation] = useLocation(); + // NPI provider state — stored per-appointment on the procedure rows + const [selectedNpiProviderId, setSelectedNpiProviderId] = useState(null); + const emptyRow = () => ({ code: "", label: "", fee: "", tooth: "", surface: "" }); + const [pendingRows, setPendingRows] = useState([emptyRow(), emptyRow(), emptyRow()]); + // reset pending rows when dialog opens + useEffect(() => { + if (open) + setPendingRows([emptyRow(), emptyRow(), emptyRow()]); + }, [open]); + // inline edit state + const [editingId, setEditingId] = useState(null); + const [editRow, setEditRow] = useState({}); + const [clearAllOpen, setClearAllOpen] = useState(false); + // price mismatch dialog + const [priceMismatches, setPriceMismatches] = useState([]); + const pendingAction = useRef(null); + const deriveInsuranceSiteKey = (provider) => { + const p = (provider || "").toLowerCase().trim(); + if (!p) + return ""; + if (p.includes("masshealth") || p === "mh" || p === "mass health") + return "MH"; + if (p.includes("commonwealth care alliance") || p === "cca") + return "CCA"; + if (p.includes("ddma") || p.includes("delta dental ma")) + return "DDMA"; + if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") + return "TuftsSCO"; + if ((p.includes("united") && p.includes("sco")) || p === "unitedsco") + return "UnitedSCO"; + return ""; + }; + const runWithPriceCheck = (procedureCode, fee, action) => { + const siteKey = deriveInsuranceSiteKey(patient?.insuranceProvider); + if (!siteKey || !procedureCode.trim() || !fee) { + action(); + return; + } + const mismatches = findPriceMismatches([{ procedureCode, totalBilled: fee, procedureDate: "" }], siteKey, patient?.dateOfBirth || "", serviceDate ?? new Date().toISOString().slice(0, 10)); + if (mismatches.length === 0) { + action(); + } + else { + pendingAction.current = action; + setPriceMismatches(mismatches); + } + }; + const savePricesToSchedule = async (mismatches) => { + const siteKey = deriveInsuranceSiteKey(patient?.insuranceProvider); + await Promise.all(mismatches.map(m => apiRequest("POST", "/api/fee-schedule/update-price", { + siteKey, + procedureCode: m.procedureCode, + price: m.enteredPrice, + }))); + }; + // ── NPI Providers ────────────────────────────────────────────── + const { data: npiProviders = [] } = useQuery({ + queryKey: ["/api/npiProviders/"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/npiProviders/"); + if (!res.ok) + throw new Error("Failed to fetch NPI providers"); + return res.json(); + }, + enabled: open, + }); + // ── Procedures ───────────────────────────────────────────────── + const { data: procedures = [], isLoading } = useQuery({ + queryKey: ["appointment-procedures", appointmentId], + queryFn: async () => { + const res = await apiRequest("GET", `/api/appointment-procedures/${appointmentId}`); + if (!res.ok) + throw new Error("Failed to load procedures"); + return res.json(); + }, + enabled: open && !!appointmentId, + }); + // Sync NPI provider from saved procedures when they load + useEffect(() => { + if (!procedures.length) + return; + const saved = procedures[0]?.npiProviderId ?? null; + if (saved != null) + setSelectedNpiProviderId(Number(saved)); + }, [procedures]); + // Default NPI provider to Mary Scannell / first when none saved yet + useEffect(() => { + if (selectedNpiProviderId != null || !npiProviders.length) + return; + const mary = npiProviders.find((p) => p.providerName.toLowerCase() === "mary scannell"); + setSelectedNpiProviderId((mary ?? npiProviders[0])?.id ?? null); + }, [npiProviders, selectedNpiProviderId]); + // ── Mutations ────────────────────────────────────────────────── + const setNpiMutation = useMutation({ + mutationFn: async (npiProviderId) => { + const res = await apiRequest("PUT", `/api/appointment-procedures/set-npi-provider/${appointmentId}`, { npiProviderId }); + if (!res.ok) + throw new Error("Failed to update provider"); + }, + onSuccess: () => { + toast({ title: "Rendering provider saved" }); + queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] }); + }, + onError: (err) => { + toast({ title: "Error", description: err.message, variant: "destructive" }); + }, + }); + const bulkAddMutation = useMutation({ + mutationFn: async (rows) => { + const res = await apiRequest("POST", "/api/appointment-procedures/bulk", rows); + if (!res.ok) + throw new Error("Failed to add procedures"); + return res.json(); + }, + onSuccess: () => { + toast({ title: "Procedures saved" }); + setPendingRows([emptyRow(), emptyRow(), emptyRow()]); + queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] }); + }, + }); + const deleteMutation = useMutation({ + mutationFn: async (id) => { + const res = await apiRequest("DELETE", `/api/appointment-procedures/${id}`); + if (!res.ok) + throw new Error("Failed to delete"); + }, + onSuccess: () => { + toast({ title: "Deleted" }); + queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] }); + }, + }); + const clearAllMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest("DELETE", `/api/appointment-procedures/clear/${appointmentId}`); + if (!res.ok) + throw new Error("Failed to clear procedures"); + }, + onSuccess: () => { + toast({ title: "All procedures cleared" }); + queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] }); + setClearAllOpen(false); + }, + onError: (err) => { + toast({ title: "Error", description: err.message ?? "Failed to clear procedures", variant: "destructive" }); + }, + }); + const updateMutation = useMutation({ + mutationFn: async () => { + if (!editingId) + return; + const res = await apiRequest("PUT", `/api/appointment-procedures/${editingId}`, editRow); + if (!res.ok) + throw new Error("Failed to update"); + return res.json(); + }, + onSuccess: () => { + toast({ title: "Updated" }); + setEditingId(null); + setEditRow({}); + queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] }); + }, + }); + // ── Handlers ─────────────────────────────────────────────────── + const handleAddCombo = (comboKey) => { + const combo = PROCEDURE_COMBOS[comboKey]; + if (!combo) + return; + const rows = combo.codes.map((code, idx) => ({ + appointmentId, + patientId, + npiProviderId: selectedNpiProviderId ?? null, + procedureCode: code, + procedureLabel: combo.label, + fee: 0, + source: "COMBO", + comboKey, + toothNumber: combo.toothNumbers?.[idx] ?? null, + })); + bulkAddMutation.mutate(rows); + }; + const startEdit = (row) => { + if (!row.id) + return; + setEditingId(row.id); + setEditRow({ + procedureCode: row.procedureCode, + procedureLabel: row.procedureLabel, + fee: row.fee, + toothNumber: row.toothNumber, + toothSurface: row.toothSurface, + }); + }; + const cancelEdit = () => { setEditingId(null); setEditRow({}); }; + const handleSavePendingRows = () => { + const rows = pendingRows + .filter((r) => r.code.trim()) + .map((r) => ({ + appointmentId, + patientId, + npiProviderId: selectedNpiProviderId ?? null, + procedureCode: r.code.trim().toUpperCase(), + procedureLabel: r.label || null, + fee: r.fee ? Number(r.fee) : 0, + toothNumber: r.tooth || null, + toothSurface: r.surface || null, + source: "MANUAL", + })); + if (!rows.length) + return; + bulkAddMutation.mutate(rows); + }; + const handleDirectClaim = () => { + setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`); + onOpenChange(false); + }; + const handleManualClaim = () => { + setLocation(`/claims?appointmentId=${appointmentId}&mode=manual`); + onOpenChange(false); + }; + const selectedProvider = npiProviders.find((p) => p.id === selectedNpiProviderId); + // ── UI ───────────────────────────────────────────────────────── + return ( + { if (clearAllOpen) + e.preventDefault(); }} onInteractOutside={(e) => { if (clearAllOpen) + e.preventDefault(); }}> + + + Appointment Procedures + {serviceDate && {serviceDate}} + + + + {/* ── Rendering Provider ─────────────────────────────── */} +
+
+ + +
+ + {selectedProvider && ( + ✓ {selectedProvider.providerName} + )} +
+ + {/* ── Combos ─────────────────────────────────────────── */} +
+ + +
+ + {/* ── Pending Lines ───────────────────────────────────── */} +
+
Add Procedures
+ {/* Column headers */} +
+
Code
Label
Fee
Tooth
Surface
+
+ {pendingRows.map((row, i) => (
+ setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, code: e.target.value } : r))}/> + setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, label: e.target.value } : r))}/> + setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, fee: e.target.value } : r))}/> + setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, tooth: e.target.value } : r))}/> + setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, surface: e.target.value } : r))}/> + +
))} +
+ + +
+
+ + {/* ── Procedures List ─────────────────────────────────── */} +
+
+
Saved Procedures ({procedures.length})
+ +
+ +
+
+
Code
Label
Fee
Tooth
Surface
+
Edit
Delete
+
+ + {isLoading &&
Loading...
} + {!isLoading && procedures.length === 0 && (
No procedures added yet
)} + + {procedures.map((p) => (
+ {editingId === p.id ? (<> + setEditRow({ ...editRow, procedureCode: e.target.value })}/> + setEditRow({ ...editRow, procedureLabel: e.target.value })}/> + setEditRow({ ...editRow, fee: Number(e.target.value) })}/> + setEditRow({ ...editRow, toothNumber: e.target.value })}/> + setEditRow({ ...editRow, toothSurface: e.target.value })}/> +
+ +
+
+ +
+ ) : (<> +
{p.procedureCode}
+
{p.procedureLabel}
+
{p.fee !== null && p.fee !== undefined ? String(p.fee) : ""}
+
{p.toothNumber}
+
{p.toothSurface}
+
+ +
+
+ +
+ )} +
))} +
+
+ + {/* ── Footer ─────────────────────────────────────────── */} +
+
+ + +
+ +
+ + + setClearAllOpen(false)} onConfirm={() => { setClearAllOpen(false); clearAllMutation.mutate(); }}/> + + {/* Price mismatch dialog */} + 0} onOpenChange={open => { if (!open) + setPriceMismatches([]); }}> + + + Save new price to the app? + +
+

The following procedure prices differ from the fee schedule:

+
    + {priceMismatches.map(m => (
  • + {m.procedureCode} + Schedule: ${m.schedulePrice.toFixed(2)} + Entered: ${m.enteredPrice.toFixed(2)} +
  • ))} +
+

Do you want to save the new price(s) to the fee schedule for future use?

+
+
+
+ + { + setPriceMismatches([]); + pendingAction.current?.(); + pendingAction.current = null; + }}> + No + + { + await savePricesToSchedule(priceMismatches); + setPriceMismatches([]); + pendingAction.current?.(); + pendingAction.current = null; + }}> + Yes + + +
+
+
); +} diff --git a/apps/Frontend/src/components/appointments/add-appointment-modal.jsx b/apps/Frontend/src/components/appointments/add-appointment-modal.jsx new file mode 100644 index 00000000..21f0b1b7 --- /dev/null +++ b/apps/Frontend/src/components/appointments/add-appointment-modal.jsx @@ -0,0 +1,19 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { AppointmentForm } from "./appointment-form"; +export function AddAppointmentModal({ open, onOpenChange, onSubmit, onDelete, isLoading, appointment, prefillData, }) { + return ( + + + + {appointment ? "Edit Appointment" : "Add New Appointment"} + + +
+ { + onSubmit(data); + onOpenChange(false); + }} isLoading={isLoading} onDelete={onDelete} onOpenChange={onOpenChange}/> +
+
+
); +} diff --git a/apps/Frontend/src/components/appointments/appointment-form.jsx b/apps/Frontend/src/components/appointments/appointment-form.jsx new file mode 100644 index 00000000..478a2440 --- /dev/null +++ b/apps/Frontend/src/components/appointments/appointment-form.jsx @@ -0,0 +1,434 @@ +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 { insertAppointmentSchema, } from "@repo/db/types"; +import { DateInputField } from "@/components/ui/dateInputField"; +import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils"; +import { toast } from "@/hooks/use-toast"; +export function AppointmentForm({ appointment, prefillData, onSubmit, onDelete, onOpenChange, isLoading = false, }) { + 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 = [] } = useQuery({ + queryKey: ["/api/staffs/"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/staffs/"); + return res.json(); + }, + enabled: !!user, + }); + const colorMap = { + "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 = 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 () => { + 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?.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) => { + // 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 + +