first commit
This commit is contained in:
789
apps/Frontend/src/pages/appointments-page.tsx
Normal file
789
apps/Frontend/src/pages/appointments-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
289
apps/Frontend/src/pages/auth-page.tsx
Normal file
289
apps/Frontend/src/pages/auth-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
512
apps/Frontend/src/pages/dashboard.tsx
Normal file
512
apps/Frontend/src/pages/dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/Frontend/src/pages/not-found.tsx
Normal file
21
apps/Frontend/src/pages/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
425
apps/Frontend/src/pages/patients-page.tsx
Normal file
425
apps/Frontend/src/pages/patients-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user