first commit

This commit is contained in:
2025-05-08 21:27:29 +05:30
commit 230d5c89f0
343 changed files with 42391 additions and 0 deletions

View File

@@ -0,0 +1,789 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { format, addDays, startOfToday, addMinutes } from "date-fns";
import { TopAppBar } from "../components/layout/top-app-bar";
import { Sidebar } from "../components/layout/sidebar";
import { AddAppointmentModal } from "../components/appointments/add-appointment-modal";
import { ClaimModal } from "../components/claims/claim-modal";
import { Button } from "../components/ui/button";
import { z } from "zod";
import {
Calendar as CalendarIcon,
Plus,
ChevronLeft,
ChevronRight,
RefreshCw,
Move,
Trash2,
FileText
} from "lucide-react";
import { useToast } from "../hooks/use-toast";
import { Calendar } from "../components/ui/calendar";
import { apiRequest, queryClient } from "../lib/queryClient";
import { useAuth } from "../hooks/use-auth";
import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "../components/ui/card";
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Menu, Item, useContextMenu } from 'react-contexify';
import 'react-contexify/ReactContexify.css';
import { useLocation } from "wouter";
// Define types for scheduling
interface TimeSlot {
time: string;
displayTime: string;
}
interface Staff {
id: string;
name: string;
role: 'doctor' | 'hygienist';
color: string;
}
interface ScheduledAppointment {
id: number;
patientId: number;
patientName: string;
staffId: string;
date: string;
startTime: string;
endTime: string;
status: string | null;
type: string;
}
// Define a unique ID for the appointment context menu
const APPOINTMENT_CONTEXT_MENU_ID = "appointment-context-menu";
export default function AppointmentsPage() {
const { toast } = useToast();
const { user } = useAuth();
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isClaimModalOpen, setIsClaimModalOpen] = useState(false);
const [claimAppointmentId, setClaimAppointmentId] = useState<number | null>(null);
const [claimPatientId, setClaimPatientId] = useState<number | null>(null);
const [editingAppointment, setEditingAppointment] = useState<any | undefined>(undefined);
const [selectedDate, setSelectedDate] = useState<Date>(startOfToday());
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [location] = useLocation();
// Create context menu hook
const { show } = useContextMenu({
id: APPOINTMENT_CONTEXT_MENU_ID,
});
// Staff members (doctors and hygienists)
const staffMembers: Staff[] = [
{ id: "doctor1", name: "Dr. Kai Gao", role: "doctor", color: "bg-blue-600" },
{ id: "doctor2", name: "Dr. Jane Smith", role: "doctor", color: "bg-emerald-600" },
{ id: "hygienist1", name: "Hygienist One", role: "hygienist", color: "bg-purple-600" },
{ id: "hygienist2", name: "Hygienist Two", role: "hygienist", color: "bg-rose-500" },
{ id: "hygienist3", name: "Hygienist Three", role: "hygienist", color: "bg-amber-500" },
];
// Generate time slots from 8:00 AM to 6:00 PM in 30-minute increments
const timeSlots: TimeSlot[] = [];
for (let hour = 8; hour <= 18; hour++) {
for (let minute = 0; minute < 60; minute += 30) {
const hour12 = hour > 12 ? hour - 12 : hour;
const period = hour >= 12 ? 'PM' : 'AM';
const timeStr = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
const displayTime = `${hour12}:${minute.toString().padStart(2, '0')} ${period}`;
timeSlots.push({ time: timeStr, displayTime });
}
}
// Fetch appointments
const {
data: appointments,
isLoading: isLoadingAppointments,
refetch: refetchAppointments,
} = useQuery<any>({
queryKey: ["/api/appointments"],
enabled: !!user,
});
// Fetch patients (needed for the dropdowns)
const {
data: patients = [],
isLoading: isLoadingPatients,
} = useQuery<any>({
queryKey: ["/api/patients"],
enabled: !!user,
});
// Handle creating a new appointment at a specific time slot and for a specific staff member
const handleCreateAppointmentAtSlot = (timeSlot: TimeSlot, staffId: string) => {
// 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 = new Date(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 => s.id === staffId);
console.log(`Creating appointment at time slot: ${timeSlot.time} (${timeSlot.displayTime})`);
// 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
};
console.log('Created appointment data to pass to modal:', newAppointment);
// 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.find((p: { id: number }) => p.id === patientId) ?? undefined;
if (patient) {
toast({
title: "Patient Added",
description: `${patient.firstName} ${patient.lastName} was added successfully. You can now schedule an appointment.`,
});
// 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!, 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
}));
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [patients, user, location]);
// Create appointment mutation
const createAppointmentMutation = useMutation({
mutationFn: async (appointment: any) => {
const res = await apiRequest("POST", "/api/appointments", appointment);
return await res.json();
},
onSuccess: () => {
toast({
title: "Success",
description: "Appointment created successfully.",
});
queryClient.invalidateQueries({ queryKey: ["/api/appointments"] });
setIsAddModalOpen(false);
},
onError: (error: Error) => {
toast({
title: "Error",
description: `Failed to create appointment: ${error.message}`,
variant: "destructive",
});
},
});
// Update appointment mutation
const updateAppointmentMutation = useMutation({
mutationFn: async ({ id, appointment }: { id: number; appointment: any }) => {
const res = await apiRequest("PUT", `/api/appointments/${id}`, appointment);
return await res.json();
},
onSuccess: () => {
toast({
title: "Success",
description: "Appointment updated successfully.",
});
queryClient.invalidateQueries({ queryKey: ["/api/appointments"] });
setEditingAppointment(undefined);
setIsAddModalOpen(false);
},
onError: (error: Error) => {
toast({
title: "Error",
description: `Failed to update appointment: ${error.message}`,
variant: "destructive",
});
},
});
// Delete appointment mutation
const deleteAppointmentMutation = useMutation({
mutationFn: async (id: number) => {
await apiRequest("DELETE", `/api/appointments/${id}`);
},
onSuccess: () => {
toast({
title: "Success",
description: "Appointment deleted successfully.",
});
queryClient.invalidateQueries({ queryKey: ["/api/appointments"] });
},
onError: (error: Error) => {
toast({
title: "Error",
description: `Failed to delete appointment: ${error.message}`,
variant: "destructive",
});
},
});
// Handle appointment submission (create or update)
const handleAppointmentSubmit = (appointmentData: any) => {
// Make sure the date is for the selected date
const updatedData = {
...appointmentData,
date: format(selectedDate, 'yyyy-MM-dd')
};
// Check if we're editing an existing appointment with a valid ID
if (editingAppointment && 'id' in editingAppointment && typeof editingAppointment.id === 'number') {
updateAppointmentMutation.mutate({
id: editingAppointment.id,
appointment: updatedData as any,
});
} else {
// This is a new appointment
if (user) {
createAppointmentMutation.mutate({
...updatedData as any,
userId: user.id
});
}
}
};
// Handle edit appointment
const handleEditAppointment = (appointment: any) => {
setEditingAppointment(appointment);
setIsAddModalOpen(true);
};
// Handle delete appointment
const handleDeleteAppointment = (id: number) => {
if (confirm("Are you sure you want to delete this appointment?")) {
deleteAppointmentMutation.mutate(id);
}
};
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
// Get formatted date string for display
const formattedDate = format(selectedDate, 'MMMM d, yyyy');
// Filter appointments for the selected date
const selectedDateAppointments = appointments.filter((apt: { date: string }) =>
apt.date === format(selectedDate, 'yyyy-MM-dd')
);
// Add debugging logs
console.log("Selected date:", format(selectedDate, 'yyyy-MM-dd'));
console.log("All appointments:", appointments);
console.log("Filtered appointments for selected date:", selectedDateAppointments);
// Process appointments for the scheduler view
const processedAppointments: ScheduledAppointment[] = selectedDateAppointments.map((apt: any) => {
// Find patient name
const patient = patients.find((p: any) => p.id === apt.patientId);
const patientName = patient ? `${patient.firstName} ${patient.lastName}` : 'Unknown Patient';
// Try to determine the staff from the notes or title
let staffId = 'doctor1'; // Default to first doctor if we can't determine
console.log("Processing appointment:", {
id: apt.id,
notes: apt.notes,
title: apt.title
});
// Check notes first
if (apt.notes) {
// Look for "Appointment with Dr. X" or similar patterns
console.log("Checking notes:", apt.notes);
for (const staff of staffMembers) {
console.log(`Checking if notes contains "${staff.name}":`, apt.notes.includes(staff.name));
if (apt.notes.includes(staff.name)) {
staffId = staff.id;
console.log(`Found staff in notes: ${staff.name} (${staffId})`);
break;
}
}
}
// If no match in notes, check title
if (staffId === 'doctor1' && apt.title) {
console.log("Checking title:", apt.title);
for (const staff of staffMembers) {
if (apt.title.includes(staff.name)) {
staffId = staff.id;
console.log(`Found staff in title: ${staff.name} (${staffId})`);
break;
}
}
}
console.log(`Final staffId assigned: ${staffId}`);
const processed = {
...apt,
patientName,
staffId
};
console.log("Processed appointment:", processed);
return processed;
});
// Check if appointment exists at a specific time slot and staff
const getAppointmentAtSlot = (timeSlot: TimeSlot, staffId: string) => {
if (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 = apt.startTime.substring(0, 5); // Get just HH:MM, removing seconds
const timeMatches = dbTime === timeSlot.time;
const staffMatches = apt.staffId === staffId;
return timeMatches && staffMatches;
});
return appointmentsAtSlot.length > 0 ? appointmentsAtSlot[0] : undefined;
};
const isLoading =
isLoadingAppointments ||
isLoadingPatients ||
createAppointmentMutation.isPending ||
updateAppointmentMutation.isPending ||
deleteAppointmentMutation.isPending;
// Define drag item types
const ItemTypes = {
APPOINTMENT: 'appointment',
};
// Handle moving an appointment to a new time slot and staff
const handleMoveAppointment = (appointmentId: number, newTimeSlot: TimeSlot, newStaffId: string) => {
const appointment = appointments.find((a:any) => a.id === appointmentId);
if (!appointment) return;
// Calculate new end time (30 minutes from start)
const startHour = parseInt(newTimeSlot.time.split(':')[0] as string);
const startMinute = parseInt(newTimeSlot.time.split(':')[1] as string);
const startDate = new Date(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 => s.id === newStaffId);
// Update appointment data
// Make sure we handle the time format correctly - backend expects HH:MM but stores as HH:MM:SS
const updatedAppointment: any = {
...appointment,
startTime: newTimeSlot.time, // Already in HH:MM format
endTime: endTime, // Already in HH:MM format
notes: `Appointment with ${staff?.name}`,
};
// Call update mutation
updateAppointmentMutation.mutate({
id: appointmentId,
appointment: updatedAppointment,
});
};
// Function to display context menu
const handleContextMenu = (e: React.MouseEvent, appointmentId: number) => {
// Prevent the default browser context menu
e.preventDefault();
// Show our custom context menu with appointment ID as data
show({
event: e,
props: {
appointmentId,
},
});
};
// Create a draggable appointment component
function DraggableAppointment({ appointment, staff }: { appointment: ScheduledAppointment, staff: Staff }) {
const [{ isDragging }, drag] = useDrag(() => ({
type: ItemTypes.APPOINTMENT,
item: { id: appointment.id },
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
}));
return (
<div
ref={drag as unknown as React.RefObject<HTMLDivElement>} // Type assertion to make TypeScript happy
className={`${staff.color} border border-white shadow-md text-white rounded p-1 text-xs h-full overflow-hidden cursor-move relative ${
isDragging ? 'opacity-50' : 'opacity-100'
}`}
style={{ fontWeight: 500 }}
onClick={(e) => {
// Only allow edit on click if we're not dragging
if (!isDragging) {
const fullAppointment = appointments.find((a:any) => a.id === appointment.id);
if (fullAppointment) {
e.stopPropagation();
handleEditAppointment(fullAppointment);
}
}
}}
onContextMenu={(e) => handleContextMenu(e, appointment.id)}
>
<div className="font-bold truncate flex items-center gap-1">
<Move className="h-3 w-3" />
{appointment.patientName}
</div>
<div className="truncate">{appointment.type}</div>
</div>
);
}
// Create a drop target for appointments
function DroppableTimeSlot({
timeSlot,
staffId,
appointment,
staff
}: {
timeSlot: TimeSlot,
staffId: string,
appointment: ScheduledAppointment | undefined,
staff: Staff
}) {
const [{ isOver, canDrop }, drop] = useDrop(() => ({
accept: ItemTypes.APPOINTMENT,
drop: (item: { id: number }) => {
handleMoveAppointment(item.id, timeSlot, staffId);
},
canDrop: (item: { id: number }) => {
// Prevent dropping if there's already an appointment here
return !appointment;
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}));
return (
<td
ref={drop as unknown as React.RefObject<HTMLTableCellElement>}
key={`${timeSlot.time}-${staffId}`}
className={`px-1 py-1 border relative h-14 ${isOver && canDrop ? 'bg-green-100' : ''}`}
>
{appointment ? (
<DraggableAppointment appointment={appointment} staff={staff} />
) : (
<button
className={`w-full h-full ${isOver && canDrop ? 'bg-green-100' : 'text-gray-400 hover:bg-gray-100'} rounded flex items-center justify-center`}
onClick={() => handleCreateAppointmentAtSlot(timeSlot, staffId)}
>
<Plus className="h-4 w-4" />
</button>
)}
</td>
);
}
return (
<div className="flex h-screen overflow-hidden bg-gray-100">
<Sidebar isMobileOpen={isMobileMenuOpen} setIsMobileOpen={setIsMobileMenuOpen} />
<div className="flex-1 flex flex-col overflow-hidden">
<TopAppBar toggleMobileMenu={toggleMobileMenu} />
<main className="flex-1 overflow-y-auto p-4">
<div className="container mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Appointment Schedule</h1>
<p className="text-muted-foreground">
View and manage the dental practice schedule
</p>
</div>
<Button
onClick={() => {
setEditingAppointment(undefined);
setIsAddModalOpen(true);
}}
className="gap-1"
disabled={isLoading}
>
<Plus className="h-4 w-4" />
New Appointment
</Button>
</div>
{/* Context Menu */}
<Menu id={APPOINTMENT_CONTEXT_MENU_ID} animation="fade">
<Item
onClick={({ props }) => {
const fullAppointment = appointments.find((a:any) => a.id === props.appointmentId);
if (fullAppointment) {
handleEditAppointment(fullAppointment);
}
}}
>
<span className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4" />
Edit Appointment
</span>
</Item>
<Item
onClick={({ props }) => {
const fullAppointment = appointments.find((a:any) => a.id === props.appointmentId);
if (fullAppointment) {
// Set the appointment and patient IDs for the claim modal
setClaimAppointmentId(fullAppointment.id);
setClaimPatientId(fullAppointment.patientId);
// Find the patient name for the toast notification
const patient = patients.find((p:any) => p.id === fullAppointment.patientId);
const patientName = patient ? `${patient.firstName} ${patient.lastName}` : `Patient #${fullAppointment.patientId}`;
// Show a toast notification
toast({
title: "Claim Services Initiated",
description: `Started insurance claim process for ${patientName}`,
});
// Open the claim modal
setIsClaimModalOpen(true);
}
}}
>
<span className="flex items-center gap-2 text-blue-600">
<FileText className="h-4 w-4" />
Claim Services
</span>
</Item>
<Item
onClick={({ props }) => handleDeleteAppointment(props.appointmentId)}
>
<span className="flex items-center gap-2 text-red-600">
<Trash2 className="h-4 w-4" />
Delete Appointment
</span>
</Item>
</Menu>
{/* Main Content - Split into Schedule and Calendar */}
<div className="flex flex-col lg:flex-row gap-6">
{/* Left side - Schedule Grid */}
<div className="w-full lg:w-3/4 overflow-x-auto bg-white rounded-md shadow">
<div className="p-4 border-b">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="icon"
onClick={() => setSelectedDate(addDays(selectedDate, -1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<h2 className="text-xl font-semibold">{formattedDate}</h2>
<Button
variant="outline"
size="icon"
onClick={() => setSelectedDate(addDays(selectedDate, 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Schedule Grid with Drag and Drop */}
<DndProvider backend={HTML5Backend}>
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[800px]">
<thead>
<tr>
<th className="p-2 border bg-gray-50 w-[100px]">Time</th>
{staffMembers.map(staff => (
<th
key={staff.id}
className={`p-2 border bg-gray-50 ${staff.role === 'doctor' ? 'font-bold' : ''}`}
>
{staff.name}
<div className="text-xs text-gray-500">{staff.role}</div>
</th>
))}
</tr>
</thead>
<tbody>
{timeSlots.map((timeSlot) => (
<tr key={timeSlot.time}>
<td className="border px-2 py-1 text-xs text-gray-600 font-medium">
{timeSlot.displayTime}
</td>
{staffMembers.map((staff) => (
<DroppableTimeSlot
key={`${timeSlot.time}-${staff.id}`}
timeSlot={timeSlot}
staffId={staff.id}
appointment={getAppointmentAtSlot(timeSlot, staff.id)}
staff={staff}
/>
))}
</tr>
))}
</tbody>
</table>
</div>
</DndProvider>
</div>
{/* Right side - Calendar and Stats */}
<div className="w-full lg:w-1/4 space-y-6">
{/* Calendar Card */}
<Card>
<CardHeader className="pb-2">
<CardTitle>Calendar</CardTitle>
<CardDescription>Select a date to view or schedule appointments</CardDescription>
</CardHeader>
<CardContent>
<Calendar
mode="single"
selected={selectedDate}
onSelect={(date) => {
if (date) setSelectedDate(date);
}}
className="rounded-md border"
/>
</CardContent>
</Card>
{/* Statistics Card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between">
<span>Appointments</span>
<Button variant="ghost" size="icon" onClick={() => refetchAppointments()}>
<RefreshCw className="h-4 w-4" />
</Button>
</CardTitle>
<CardDescription>Statistics for {formattedDate}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">Total appointments:</span>
<span className="font-semibold">{selectedDateAppointments.length}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">With doctors:</span>
<span className="font-semibold">
{processedAppointments.filter(apt =>
staffMembers.find(s => s.id === apt.staffId)?.role === 'doctor'
).length}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">With hygienists:</span>
<span className="font-semibold">
{processedAppointments.filter(apt =>
staffMembers.find(s => s.id === apt.staffId)?.role === 'hygienist'
).length}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</main>
</div>
{/* Add/Edit Appointment Modal */}
<AddAppointmentModal
open={isAddModalOpen}
onOpenChange={setIsAddModalOpen}
onSubmit={handleAppointmentSubmit}
isLoading={createAppointmentMutation.isPending || updateAppointmentMutation.isPending}
appointment={editingAppointment}
patients={patients}
/>
{/* Claim Services Modal */}
{claimPatientId && claimAppointmentId && (
<ClaimModal
open={isClaimModalOpen}
onClose={() => {
setIsClaimModalOpen(false);
setClaimPatientId(null);
setClaimAppointmentId(null);
}}
patientId={claimPatientId}
appointmentId={claimAppointmentId}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,289 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { UserCreateOneSchema } from "@repo/db/shared";
import { useState } from "react";
import { useAuth } from "../hooks/use-auth";
import { Redirect } from "wouter";
import { Button } from "../components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../components/ui/form";
import { Input } from "../components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Checkbox } from "../components/ui/checkbox";
import { Card, CardContent } from "../components/ui/card";
import { BriefcaseMedical, CheckCircle, Torus } from "lucide-react";
const loginSchema = UserCreateOneSchema.extend({
rememberMe: z.boolean().optional(),
});
const registerSchema = UserCreateOne.extend({
confirmPassword: z.string().min(6, {
message: "Password must be at least 6 characters long",
}),
agreeTerms: z.literal(true, {
errorMap: () => ({ message: "You must agree to the terms and conditions" }),
}),
}).refine((data:any) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type LoginFormValues = z.infer<typeof loginSchema>;
type RegisterFormValues = z.infer<typeof registerSchema>;
export default function AuthPage() {
const [activeTab, setActiveTab] = useState<string>("login");
const { user, loginMutation, registerMutation } = useAuth();
const loginForm = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",
rememberMe: false,
},
});
const registerForm = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
username: "",
password: "",
confirmPassword: "",
agreeTerms: false,
},
});
const onLoginSubmit = (data: LoginFormValues) => {
loginMutation.mutate({
username: data.username,
password: data.password,
});
};
const onRegisterSubmit = (data: RegisterFormValues) => {
registerMutation.mutate({
username: data.username,
password: data.password,
});
};
// Redirect if already logged in
if (user) {
return <Redirect to="/" />;
}
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 shadow-lg rounded-lg overflow-hidden">
{/* Auth Forms */}
<Card className="p-6 bg-white">
<div className="mb-10 text-center">
<h1 className="text-3xl font-medium text-primary mb-2">DentalConnect</h1>
<p className="text-gray-600">Patient Management System</p>
</div>
<Tabs defaultValue="login" value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="login">Login</TabsTrigger>
<TabsTrigger value="register">Register</TabsTrigger>
</TabsList>
<TabsContent value="login">
<Form {...loginForm}>
<form onSubmit={loginForm.handleSubmit(onLoginSubmit)} className="space-y-4">
<FormField
control={loginForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
placeholder="Enter your username"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={loginForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="••••••••"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center justify-between">
<FormField
control={loginForm.control}
name="rememberMe"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="remember-me"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
htmlFor="remember-me"
className="text-sm font-medium text-gray-700"
>
Remember me
</label>
</div>
)}
/>
<a href="#" className="text-sm font-medium text-primary hover:text-primary/80">
Forgot password?
</a>
</div>
<Button type="submit" className="w-full" disabled={loginMutation.isPending}>
{loginMutation.isPending ? "Signing in..." : "Sign in"}
</Button>
</form>
</Form>
</TabsContent>
<TabsContent value="register">
<Form {...registerForm}>
<form onSubmit={registerForm.handleSubmit(onRegisterSubmit)} className="space-y-4">
<FormField
control={registerForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
placeholder="Choose a username"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="••••••••"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
placeholder="••••••••"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="agreeTerms"
render={({ field }) => (
<FormItem className="flex items-start space-x-2 mt-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
I agree to the <a href="#" className="text-primary">Terms and Conditions</a>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={registerMutation.isPending}>
{registerMutation.isPending ? "Creating Account..." : "Create Account"}
</Button>
</form>
</Form>
</TabsContent>
</Tabs>
</Card>
{/* Hero Section */}
<div className="md:block bg-primary p-8 text-white flex flex-col justify-center">
<div className="flex justify-center mb-6">
<div className="w-16 h-16 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<Torus className="h-8 w-8" />
</div>
</div>
<h2 className="text-2xl font-bold mb-4 text-center">Welcome to DentalConnect</h2>
<p className="mb-6 text-center text-white text-opacity-80">
The complete solution for dental practice management. Streamline your patient records, appointments, and more.
</p>
<ul className="space-y-4">
<li className="flex items-center">
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
<span>Easily manage patient records</span>
</li>
<li className="flex items-center">
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
<span>Track patient insurance information</span>
</li>
<li className="flex items-center">
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
<span>Secure and compliant data storage</span>
</li>
<li className="flex items-center">
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
<span>Simple and intuitive interface</span>
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,512 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { format } from "date-fns";
import { TopAppBar } from "../components/layout/top-app-bar";
import { Sidebar } from "../components/layout/sidebar";
import { StatCard } from "../components/ui/stat-card";
import { PatientTable } from "../components/patients/patient-table";
import { AddPatientModal } from "../components/patients/add-patient-modal";
import { AddAppointmentModal } from "../components/appointments/add-appointment-modal";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { useToast } from "../hooks/use-toast";
import { useAuth } from "../hooks/use-auth";
import { apiRequest, queryClient } from "../lib/queryClient";
import { InsertPatient, Patient, UpdatePatient, Appointment, InsertAppointment, UpdateAppointment } from "@repo/db/schema";
import { Users, Calendar, CheckCircle, CreditCard, Plus, Clock } from "lucide-react";
import { Link } from "wouter";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
export default function Dashboard() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isAddPatientOpen, setIsAddPatientOpen] = useState(false);
const [isViewPatientOpen, setIsViewPatientOpen] = useState(false);
const [isAddAppointmentOpen, setIsAddAppointmentOpen] = useState(false);
const [currentPatient, setCurrentPatient] = useState<Patient | undefined>(undefined);
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | undefined>(undefined);
const { toast } = useToast();
const { user } = useAuth();
// Fetch patients
const { data: patients = [], isLoading: isLoadingPatients } = useQuery<Patient[]>({
queryKey: ["/api/patients"],
enabled: !!user,
});
// Fetch appointments
const {
data: appointments = [] as Appointment[],
isLoading: isLoadingAppointments
} = useQuery<Appointment[]>({
queryKey: ["/api/appointments"],
enabled: !!user,
});
// Add patient mutation
const addPatientMutation = useMutation({
mutationFn: async (patient: InsertPatient) => {
const res = await apiRequest("POST", "/api/patients", patient);
return res.json();
},
onSuccess: () => {
setIsAddPatientOpen(false);
queryClient.invalidateQueries({ queryKey: ["/api/patients"] });
toast({
title: "Success",
description: "Patient added successfully!",
variant: "default",
});
},
onError: (error) => {
toast({
title: "Error",
description: `Failed to add patient: ${error.message}`,
variant: "destructive",
});
},
});
// Update patient mutation
const updatePatientMutation = useMutation({
mutationFn: async ({ id, patient }: { id: number; patient: UpdatePatient }) => {
const res = await apiRequest("PUT", `/api/patients/${id}`, patient);
return res.json();
},
onSuccess: () => {
setIsAddPatientOpen(false);
queryClient.invalidateQueries({ queryKey: ["/api/patients"] });
toast({
title: "Success",
description: "Patient updated successfully!",
variant: "default",
});
},
onError: (error) => {
toast({
title: "Error",
description: `Failed to update patient: ${error.message}`,
variant: "destructive",
});
},
});
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const handleAddPatient = (patient: InsertPatient) => {
if (user) {
addPatientMutation.mutate({
...patient,
userId: user.id
});
}
};
const handleUpdatePatient = (patient: UpdatePatient) => {
if (currentPatient) {
updatePatientMutation.mutate({ id: currentPatient.id, patient });
}
};
const handleEditPatient = (patient: Patient) => {
setCurrentPatient(patient);
setIsAddPatientOpen(true);
};
const handleViewPatient = (patient: Patient) => {
setCurrentPatient(patient);
setIsViewPatientOpen(true);
};
// Create appointment mutation
const createAppointmentMutation = useMutation({
mutationFn: async (appointment: InsertAppointment) => {
const res = await apiRequest("POST", "/api/appointments", appointment);
return await res.json();
},
onSuccess: () => {
setIsAddAppointmentOpen(false);
toast({
title: "Success",
description: "Appointment created successfully.",
});
queryClient.invalidateQueries({ queryKey: ["/api/appointments"] });
},
onError: (error: Error) => {
toast({
title: "Error",
description: `Failed to create appointment: ${error.message}`,
variant: "destructive",
});
},
});
// Update appointment mutation
const updateAppointmentMutation = useMutation({
mutationFn: async ({ id, appointment }: { id: number; appointment: UpdateAppointment }) => {
const res = await apiRequest("PUT", `/api/appointments/${id}`, appointment);
return await res.json();
},
onSuccess: () => {
setIsAddAppointmentOpen(false);
toast({
title: "Success",
description: "Appointment updated successfully.",
});
queryClient.invalidateQueries({ queryKey: ["/api/appointments"] });
},
onError: (error: Error) => {
toast({
title: "Error",
description: `Failed to update appointment: ${error.message}`,
variant: "destructive",
});
},
});
// Handle appointment submission (create or update)
const handleAppointmentSubmit = (appointmentData: InsertAppointment | UpdateAppointment) => {
if (selectedAppointment) {
updateAppointmentMutation.mutate({
id: selectedAppointment.id,
appointment: appointmentData as UpdateAppointment,
});
} else {
if (user) {
createAppointmentMutation.mutate({
...appointmentData as InsertAppointment,
userId: user.id,
});
}
}
};
// Since we removed filters, just return all patients
const filteredPatients = patients;
// Get today's date in YYYY-MM-DD format
const today = format(new Date(), 'yyyy-MM-dd');
// Filter appointments for today
const todaysAppointments = appointments.filter(
(appointment) => appointment.date === today
);
// Count completed appointments today
const completedTodayCount = todaysAppointments.filter(
(appointment) => appointment.status === 'completed'
).length;
return (
<div className="flex h-screen overflow-hidden bg-gray-100">
<Sidebar isMobileOpen={isMobileMenuOpen} setIsMobileOpen={setIsMobileMenuOpen} />
<div className="flex-1 flex flex-col overflow-hidden">
<TopAppBar toggleMobileMenu={toggleMobileMenu} />
<main className="flex-1 overflow-y-auto p-4">
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard
title="Total Patients"
value={patients.length}
icon={Users}
color="primary"
/>
<StatCard
title="Today's Appointments"
value={todaysAppointments.length}
icon={Calendar}
color="secondary"
/>
<StatCard
title="Completed Today"
value={completedTodayCount}
icon={CheckCircle}
color="success"
/>
<StatCard
title="Pending Payments"
value={0}
icon={CreditCard}
color="warning"
/>
</div>
{/* Today's Appointments Section */}
<div className="flex flex-col space-y-4 mb-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
<h2 className="text-xl font-medium text-gray-800">Today's Appointments</h2>
<Button
className="mt-2 md:mt-0"
onClick={() => {
setSelectedAppointment(undefined);
setIsAddAppointmentOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
New Appointment
</Button>
</div>
<Card>
<CardContent className="p-0">
{todaysAppointments.length > 0 ? (
<div className="divide-y">
{todaysAppointments.map((appointment) => {
const patient = patients.find(p => p.id === appointment.patientId);
return (
<div key={appointment.id} className="p-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="h-10 w-10 rounded-full bg-primary bg-opacity-10 text-primary flex items-center justify-center">
<Clock className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium">
{patient ? `${patient.firstName} ${patient.lastName}` : 'Unknown Patient'}
</h3>
<div className="text-sm text-gray-500 flex items-center space-x-2">
<span>{appointment.startTime} - {appointment.endTime}</span>
<span>•</span>
<span>{appointment.type.charAt(0).toUpperCase() + appointment.type.slice(1)}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${appointment.status === 'completed' ? 'bg-green-100 text-green-800' :
appointment.status === 'cancelled' ? 'bg-red-100 text-red-800' :
appointment.status === 'confirmed' ? 'bg-blue-100 text-blue-800' :
'bg-yellow-100 text-yellow-800'}`}>
{appointment.status ? appointment.status.charAt(0).toUpperCase() + appointment.status.slice(1) : 'Scheduled'}
</span>
<Link to="/appointments" className="text-primary hover:text-primary/80 text-sm">
View All
</Link>
</div>
</div>
);
})}
</div>
) : (
<div className="p-6 text-center">
<Calendar className="h-12 w-12 mx-auto text-gray-400 mb-2" />
<h3 className="text-lg font-medium text-gray-900">No appointments today</h3>
<p className="mt-1 text-gray-500">
You don't have any appointments scheduled for today.
</p>
<Button
className="mt-4"
onClick={() => {
setSelectedAppointment(undefined);
setIsAddAppointmentOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
Schedule an Appointment
</Button>
</div>
)}
</CardContent>
</Card>
</div>
{/* Patient Management Section */}
<div className="flex flex-col space-y-4">
{/* Patient Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
<h2 className="text-xl font-medium text-gray-800">Patient Management</h2>
<Button
className="mt-2 md:mt-0"
onClick={() => {
setCurrentPatient(undefined);
setIsAddPatientOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
Add Patient
</Button>
</div>
{/* Search and filters removed */}
{/* Patient Table */}
<PatientTable
patients={filteredPatients}
onEdit={handleEditPatient}
onView={handleViewPatient}
/>
</div>
</main>
</div>
{/* Add/Edit Patient Modal */}
<AddPatientModal
open={isAddPatientOpen}
onOpenChange={setIsAddPatientOpen}
onSubmit={currentPatient ? handleUpdatePatient : handleAddPatient}
isLoading={addPatientMutation.isPending || updatePatientMutation.isPending}
patient={currentPatient}
/>
{/* View Patient Modal */}
<Dialog open={isViewPatientOpen} onOpenChange={setIsViewPatientOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Patient Details</DialogTitle>
<DialogDescription>
Complete information about the patient.
</DialogDescription>
</DialogHeader>
{currentPatient && (
<div className="space-y-4">
<div className="flex items-center space-x-4">
<div className="h-16 w-16 rounded-full bg-primary text-white flex items-center justify-center text-xl font-medium">
{currentPatient.firstName.charAt(0)}{currentPatient.lastName.charAt(0)}
</div>
<div>
<h3 className="text-xl font-semibold">{currentPatient.firstName} {currentPatient.lastName}</h3>
<p className="text-gray-500">Patient ID: {currentPatient.id.toString().padStart(4, '0')}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
<div>
<h4 className="font-medium text-gray-900">Personal Information</h4>
<div className="mt-2 space-y-2">
<p>
<span className="text-gray-500">Date of Birth:</span>{' '}
{new Date(currentPatient.dateOfBirth).toLocaleDateString()}
</p>
<p>
<span className="text-gray-500">Gender:</span>{' '}
{currentPatient.gender.charAt(0).toUpperCase() + currentPatient.gender.slice(1)}
</p>
<p>
<span className="text-gray-500">Status:</span>{' '}
<span className={`${
currentPatient.status === 'active'
? 'text-green-600'
: 'text-amber-600'
} font-medium`}>
{currentPatient.status.charAt(0).toUpperCase() + currentPatient.status.slice(1)}
</span>
</p>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900">Contact Information</h4>
<div className="mt-2 space-y-2">
<p>
<span className="text-gray-500">Phone:</span>{' '}
{currentPatient.phone}
</p>
<p>
<span className="text-gray-500">Email:</span>{' '}
{currentPatient.email || 'N/A'}
</p>
<p>
<span className="text-gray-500">Address:</span>{' '}
{currentPatient.address ? (
<>
{currentPatient.address}
{currentPatient.city && `, ${currentPatient.city}`}
{currentPatient.zipCode && ` ${currentPatient.zipCode}`}
</>
) : (
'N/A'
)}
</p>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900">Insurance</h4>
<div className="mt-2 space-y-2">
<p>
<span className="text-gray-500">Provider:</span>{' '}
{currentPatient.insuranceProvider
? currentPatient.insuranceProvider === 'delta'
? 'Delta Dental'
: currentPatient.insuranceProvider === 'metlife'
? 'MetLife'
: currentPatient.insuranceProvider === 'cigna'
? 'Cigna'
: currentPatient.insuranceProvider === 'aetna'
? 'Aetna'
: currentPatient.insuranceProvider
: 'N/A'}
</p>
<p>
<span className="text-gray-500">ID:</span>{' '}
{currentPatient.insuranceId || 'N/A'}
</p>
<p>
<span className="text-gray-500">Group Number:</span>{' '}
{currentPatient.groupNumber || 'N/A'}
</p>
<p>
<span className="text-gray-500">Policy Holder:</span>{' '}
{currentPatient.policyHolder || 'Self'}
</p>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900">Medical Information</h4>
<div className="mt-2 space-y-2">
<p>
<span className="text-gray-500">Allergies:</span>{' '}
{currentPatient.allergies || 'None reported'}
</p>
<p>
<span className="text-gray-500">Medical Conditions:</span>{' '}
{currentPatient.medicalConditions || 'None reported'}
</p>
</div>
</div>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button
variant="outline"
onClick={() => setIsViewPatientOpen(false)}
>
Close
</Button>
<Button
onClick={() => {
setIsViewPatientOpen(false);
handleEditPatient(currentPatient);
}}
>
Edit Patient
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Add/Edit Appointment Modal */}
<AddAppointmentModal
open={isAddAppointmentOpen}
onOpenChange={setIsAddAppointmentOpen}
onSubmit={handleAppointmentSubmit}
isLoading={createAppointmentMutation.isPending || updateAppointmentMutation.isPending}
appointment={selectedAppointment}
patients={patients}
/>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Card, CardContent } from "../components/ui/card";
import { AlertCircle } from "lucide-react";
export default function NotFound() {
return (
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md mx-4">
<CardContent className="pt-6">
<div className="flex mb-4 gap-2">
<AlertCircle className="h-8 w-8 text-red-500" />
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
</div>
<p className="mt-4 text-sm text-gray-600">
Did you forget to add the page to the router?
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,425 @@
import { useState, useMemo, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { TopAppBar } from "../components/layout/top-app-bar";
import { Sidebar } from "../components/layout/sidebar";
import { PatientTable } from "../components/patients/patient-table";
import { AddPatientModal } from "../components/patients/add-patient-modal";
import { PatientSearch, SearchCriteria } from "../components/patients/patient-search";
import { FileUploadZone } from "../components/file-upload/file-upload-zone";
import { Button } from "../components/ui/button";
import { Plus, RefreshCw, File, FilePlus } from "lucide-react";
import { useToast } from "../hooks/use-toast";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Patient, InsertPatient, UpdatePatient } from "@shared/schema";
import { apiRequest, queryClient } from "../lib/queryClient";
import { useAuth } from "../hooks/use-auth";
// Type for the ref to access modal methods
type AddPatientModalRef = {
shouldSchedule: boolean;
navigateToSchedule: (patientId: number) => void;
};
export default function PatientsPage() {
const { toast } = useToast();
const { user } = useAuth();
const [isAddPatientOpen, setIsAddPatientOpen] = useState(false);
const [isViewPatientOpen, setIsViewPatientOpen] = useState(false);
const [currentPatient, setCurrentPatient] = useState<Patient | undefined>(undefined);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [searchCriteria, setSearchCriteria] = useState<SearchCriteria | null>(null);
const addPatientModalRef = useRef<AddPatientModalRef | null>(null);
// File upload states
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isExtracting, setIsExtracting] = useState(false);
const [extractedInfo, setExtractedInfo] = useState<any>(null);
// Fetch patients
const {
data: patients = [],
isLoading: isLoadingPatients,
refetch: refetchPatients,
} = useQuery<Patient[]>({
queryKey: ["/api/patients"],
enabled: !!user,
});
// Add patient mutation
const addPatientMutation = useMutation({
mutationFn: async (patient: InsertPatient) => {
const res = await apiRequest("POST", "/api/patients", patient);
return res.json();
},
onSuccess: (newPatient) => {
setIsAddPatientOpen(false);
queryClient.invalidateQueries({ queryKey: ["/api/patients"] });
toast({
title: "Success",
description: "Patient added successfully!",
variant: "default",
});
// If the add patient modal wants to proceed to scheduling, redirect to appointments page
if (addPatientModalRef.current?.shouldSchedule) {
addPatientModalRef.current.navigateToSchedule(newPatient.id);
}
},
onError: (error) => {
toast({
title: "Error",
description: `Failed to add patient: ${error.message}`,
variant: "destructive",
});
},
});
// Update patient mutation
const updatePatientMutation = useMutation({
mutationFn: async ({ id, patient }: { id: number; patient: UpdatePatient }) => {
const res = await apiRequest("PUT", `/api/patients/${id}`, patient);
return res.json();
},
onSuccess: () => {
setIsAddPatientOpen(false);
queryClient.invalidateQueries({ queryKey: ["/api/patients"] });
toast({
title: "Success",
description: "Patient updated successfully!",
variant: "default",
});
},
onError: (error) => {
toast({
title: "Error",
description: `Failed to update patient: ${error.message}`,
variant: "destructive",
});
},
});
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const handleAddPatient = (patient: InsertPatient) => {
// Add userId to the patient data
if (user) {
addPatientMutation.mutate({
...patient,
userId: user.id
});
}
};
const handleUpdatePatient = (patient: UpdatePatient) => {
if (currentPatient) {
updatePatientMutation.mutate({ id: currentPatient.id, patient });
}
};
const handleEditPatient = (patient: Patient) => {
setCurrentPatient(patient);
setIsAddPatientOpen(true);
};
const handleViewPatient = (patient: Patient) => {
setCurrentPatient(patient);
setIsViewPatientOpen(true);
};
const isLoading = isLoadingPatients || addPatientMutation.isPending || updatePatientMutation.isPending;
// Search handling
const handleSearch = (criteria: SearchCriteria) => {
setSearchCriteria(criteria);
};
const handleClearSearch = () => {
setSearchCriteria(null);
};
// File upload handling
const handleFileUpload = (file: File) => {
setUploadedFile(file);
setIsUploading(false); // In a real implementation, this would be set to true during upload
toast({
title: "File Selected",
description: `${file.name} is ready for processing.`,
variant: "default",
});
};
// Process file and extract patient information
const handleExtractInfo = async () => {
if (!uploadedFile) {
toast({
title: "No file selected",
description: "Please select a file first.",
variant: "destructive",
});
return;
}
setIsExtracting(true);
try {
// Read the file as base64
const reader = new FileReader();
// Set up a Promise to handle file reading
const fileReadPromise = new Promise<string>((resolve, reject) => {
reader.onload = (event) => {
if (event.target && typeof event.target.result === 'string') {
resolve(event.target.result);
} else {
reject(new Error('Failed to read file as base64'));
}
};
reader.onerror = () => {
reject(new Error('Error reading file'));
};
// Read the file as a data URL (base64)
reader.readAsDataURL(uploadedFile);
});
// Get the base64 data
const base64Data = await fileReadPromise;
// Send file to server as base64
const response = await fetch('/api/upload-file', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pdfData: base64Data,
filename: uploadedFile.name
}),
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
// Only keep firstName, lastName, dateOfBirth, and insuranceId from the extracted info
const simplifiedInfo = {
firstName: data.extractedInfo.firstName,
lastName: data.extractedInfo.lastName,
dateOfBirth: data.extractedInfo.dateOfBirth,
insuranceId: data.extractedInfo.insuranceId
};
setExtractedInfo(simplifiedInfo);
// Show success message
toast({
title: "Information Extracted",
description: "Basic patient information (name, DOB, ID) has been extracted successfully.",
variant: "default",
});
// Open patient form pre-filled with extracted data
setCurrentPatient(undefined);
// Pre-fill the form by opening the modal with the extracted information
setTimeout(() => {
setIsAddPatientOpen(true);
}, 500);
} else {
throw new Error(data.message || "Failed to extract information");
}
} catch (error) {
console.error("Error extracting information:", error);
toast({
title: "Error",
description: error instanceof Error ? error.message : "Failed to extract information from file",
variant: "destructive",
});
} finally {
setIsExtracting(false);
}
};
// Filter patients based on search criteria
const filteredPatients = useMemo(() => {
if (!searchCriteria || !searchCriteria.searchTerm) {
return patients;
}
const term = searchCriteria.searchTerm.toLowerCase();
return patients.filter((patient) => {
switch (searchCriteria.searchBy) {
case 'name':
return (
patient.firstName.toLowerCase().includes(term) ||
patient.lastName.toLowerCase().includes(term)
);
case 'phone':
return patient.phone.toLowerCase().includes(term);
case 'insuranceProvider':
return patient.insuranceProvider?.toLowerCase().includes(term);
case 'insuranceId':
return patient.insuranceId?.toLowerCase().includes(term);
case 'all':
default:
return (
patient.firstName.toLowerCase().includes(term) ||
patient.lastName.toLowerCase().includes(term) ||
patient.phone.toLowerCase().includes(term) ||
patient.email?.toLowerCase().includes(term) ||
patient.address?.toLowerCase().includes(term) ||
patient.city?.toLowerCase().includes(term) ||
patient.insuranceProvider?.toLowerCase().includes(term) ||
patient.insuranceId?.toLowerCase().includes(term)
);
}
});
}, [patients, searchCriteria]);
return (
<div className="flex h-screen overflow-hidden bg-gray-100">
<Sidebar isMobileOpen={isMobileMenuOpen} setIsMobileOpen={setIsMobileMenuOpen} />
<div className="flex-1 flex flex-col overflow-hidden">
<TopAppBar toggleMobileMenu={toggleMobileMenu} />
<main className="flex-1 overflow-y-auto p-4">
<div className="container mx-auto space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Patients</h1>
<p className="text-muted-foreground">
Manage patient records and information
</p>
</div>
<div className="flex space-x-2">
<Button
onClick={() => {
setCurrentPatient(undefined);
setIsAddPatientOpen(true);
}}
className="gap-1"
disabled={isLoading}
>
<Plus className="h-4 w-4" />
New Patient
</Button>
<Button
variant="outline"
size="sm"
className="h-9 gap-1"
onClick={() => refetchPatients()}
disabled={isLoading}
>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
</div>
{/* File Upload Zone */}
<div className="grid gap-4 md:grid-cols-4">
<div className="md:col-span-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Upload Patient Document
</CardTitle>
<File className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<FileUploadZone
onFileUpload={handleFileUpload}
isUploading={isUploading}
acceptedFileTypes="application/pdf"
/>
</CardContent>
</Card>
</div>
<div className="md:col-span-1 flex items-end">
<Button
className="w-full h-12 gap-2"
onClick={handleExtractInfo}
disabled={!uploadedFile || isExtracting}
>
{isExtracting ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<FilePlus className="h-4 w-4" />
Extract Info
</>
)}
</Button>
</div>
</div>
{/* Patients Table */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
View and manage all patient information
</CardDescription>
</CardHeader>
<CardContent>
<PatientSearch
onSearch={handleSearch}
onClearSearch={handleClearSearch}
isSearchActive={!!searchCriteria}
/>
{searchCriteria && (
<div className="flex items-center my-4 px-2 py-1 bg-muted rounded-md text-sm">
<p>
Found {filteredPatients.length}
{filteredPatients.length === 1 ? ' patient' : ' patients'}
{searchCriteria.searchBy !== 'all' ? ` with ${searchCriteria.searchBy}` : ''}
matching "{searchCriteria.searchTerm}"
</p>
</div>
)}
<PatientTable
patients={filteredPatients}
onEdit={handleEditPatient}
onView={handleViewPatient}
/>
</CardContent>
</Card>
{/* Add/Edit Patient Modal */}
<AddPatientModal
ref={addPatientModalRef}
open={isAddPatientOpen}
onOpenChange={setIsAddPatientOpen}
onSubmit={currentPatient ? handleUpdatePatient : handleAddPatient}
isLoading={isLoading}
patient={currentPatient}
// Pass extracted info as a separate prop to avoid triggering edit mode
extractedInfo={!currentPatient && extractedInfo ? {
firstName: extractedInfo.firstName || "",
lastName: extractedInfo.lastName || "",
dateOfBirth: extractedInfo.dateOfBirth || "",
insuranceId: extractedInfo.insuranceId || ""
} : undefined}
/>
</div>
</main>
</div>
</div>
);
}