Files
Gitead-DentalManagementMHnewff/apps/Frontend/src/components/patients/patient-table.tsx
Gitead 4505d5db85 feat: DentaQuest eligibility — PLAN_NOT_ACCEPTED status, DOB save, no retries
- Add PLAN_NOT_ACCEPTED to PatientStatus enum (prisma schema + db push)
- Selenium: return "plan not accepted" eligibility text instead of collapsing to inactive
- Backend processor: map "plan not accepted" → PLAN_NOT_ACCEPTED, fix insuranceProvider label
- _shared.ts: save DOB for existing patients when field is currently empty
- Frontend: show amber "Plan Not Accepted" badge in patient table and detail panel
- patient-form.tsx: display "Plan Not Accepted" label in status dropdown
- BullMQ: set attempts=1 (no retry on selenium failure)
- DDMA: remove first/last name from search (member ID + DOB only)
- patient-types.ts: allow alphanumeric insurance IDs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:47:50 -04:00

1561 lines
54 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useMemo, useState, useRef } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Delete, Edit, Eye, FileCheck, Scan, Upload, X, FileText, Download, Trash2, Camera, RefreshCw } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useMutation, useQuery } from "@tanstack/react-query";
import LoadingScreen from "@/components/ui/LoadingScreen";
import { useToast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { AddPatientModal } from "./add-patient-modal";
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
import { useAuth } from "@/hooks/use-auth";
import { PatientSearch, SearchCriteria } from "./patient-search";
import { useDebounce } from "use-debounce";
import { cn } from "@/lib/utils";
import { Checkbox } from "@/components/ui/checkbox";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import { Patient, UpdatePatient } from "@repo/db/types";
import { PatientFinancialsModal } from "./patient-financial-modal";
import { getPageNumbers } from "@/utils/pageNumberGenerator";
import {
uploadDocument,
getPatientDocuments,
deleteDocument,
viewDocument,
downloadDocument,
scanDocument,
formatFileSize,
type PatientDocument
} from "@/lib/api/documents";
interface PatientApiResponse {
patients: Patient[];
totalCount: number;
}
interface PatientTableProps {
allowEdit?: boolean;
allowView?: boolean;
allowDelete?: boolean;
allowCheckbox?: boolean;
allowNewClaim?: boolean;
allowFinancial?: boolean;
onNewClaim?: (patientId: number) => void;
onSelectPatient?: (patient: Patient | null) => void;
onPageChange?: (page: number) => void;
onSearchChange?: (searchTerm: string) => void;
}
// 🔑 exported base key
export const QK_PATIENTS_BASE = ["patients"] as const;
// helper (optional) mirrors your current key structure
export const qkPatients = (page: number, search: string) =>
[...QK_PATIENTS_BASE, { page, search }] as const;
export function PatientTable({
allowEdit,
allowView,
allowDelete,
allowCheckbox,
allowNewClaim,
allowFinancial,
onNewClaim,
onSelectPatient,
onPageChange,
onSearchChange,
}: PatientTableProps) {
const { toast } = useToast();
const { user } = useAuth();
const isAdmin = user?.username === "admin";
const [currentPatient, setCurrentPatient] = useState<Patient | undefined>(
undefined
);
const [isAddPatientOpen, setIsAddPatientOpen] = useState(false);
const [isViewPatientOpen, setIsViewPatientOpen] = useState(false);
const [isDeletePatientOpen, setIsDeletePatientOpen] = useState(false);
const [isFinancialsOpen, setIsFinancialsOpen] = useState(false);
const [modalPatientId, setModalPatientId] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const patientsPerPage = 5;
const offset = (currentPage - 1) * patientsPerPage;
const [isSearchActive, setIsSearchActive] = useState(false);
const [searchCriteria, setSearchCriteria] = useState<SearchCriteria | null>(
null
);
const [debouncedSearchCriteria] = useDebounce(searchCriteria, 500);
const [selectedPatientId, setSelectedPatientId] = useState<number | null>(
null
);
const [expandedPatientId, setExpandedPatientId] = useState<number | null>(null);
// Real document data state
const [patientDocuments, setPatientDocuments] = useState<{ [key: number]: PatientDocument[] }>({});
const [documentsLoading, setDocumentsLoading] = useState<{ [key: number]: boolean }>({});
// Document viewer modal state
const [documentViewerOpen, setDocumentViewerOpen] = useState(false);
const [viewingDocument, setViewingDocument] = useState<{ url: string; name: string } | null>(null);
// Thumbnail cache for image documents
const [documentThumbnails, setDocumentThumbnails] = useState<{ [key: number]: string | null }>({});
// Camera modal state
const [cameraOpen, setCameraOpen] = useState(false);
const [selectedPatientIdForCamera, setSelectedPatientIdForCamera] = useState<number | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [cameraPermission, setCameraPermission] = useState<'granted' | 'denied' | 'prompt' | 'unknown'>('unknown');
const [isRequestingPermission, setIsRequestingPermission] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('environment');
// Cleanup blob URL when modal closes
const handleDocumentViewerClose = () => {
if (viewingDocument && viewingDocument.url.startsWith('blob:')) {
URL.revokeObjectURL(viewingDocument.url);
}
setDocumentViewerOpen(false);
setViewingDocument(null);
};
// Get thumbnail URL for image documents
const getDocumentThumbnail = async (doc: PatientDocument): Promise<string | null> => {
if (!doc.mimeType.startsWith('image/')) {
return null;
}
try {
// Use the full URL from the database directly
const documentUrl = doc.filePath;
// Static files don't need authentication headers
const response = await fetch(documentUrl);
if (!response.ok) {
return null;
}
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (error) {
console.error('Error loading thumbnail:', error);
return null;
}
};
// Load thumbnails for image documents
const loadDocumentThumbnails = async (documents: PatientDocument[]) => {
const imageDocs = documents.filter(doc => doc.mimeType.startsWith('image/'));
for (const doc of imageDocs) {
if (!documentThumbnails[doc.id]) {
const thumbnailUrl = await getDocumentThumbnail(doc);
setDocumentThumbnails(prev => ({
...prev,
[doc.id]: thumbnailUrl
}));
}
}
};
// Check camera permissions
const checkCameraPermission = async () => {
try {
console.log('Checking camera permission...');
const permission = await navigator.permissions.query({ name: 'camera' as PermissionName });
console.log('Permission state:', permission.state);
setCameraPermission(permission.state as 'granted' | 'denied' | 'prompt');
// Listen for permission changes
permission.addEventListener('change', () => {
console.log('Permission changed to:', permission.state);
setCameraPermission(permission.state as 'granted' | 'denied' | 'prompt');
});
return permission.state;
} catch (error) {
console.log('Permission API not supported, will try getUserMedia directly');
return 'unknown';
}
};
// Check if running on localhost vs remote IP
const isLocalhost = () => {
return window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname.includes('localhost');
};
// Check if any camera devices are available
const checkCameraDevices = async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(device => device.kind === 'videoinput');
console.log('Available camera devices:', videoDevices.length);
console.log('Current hostname:', window.location.hostname);
console.log('Is localhost:', isLocalhost());
return videoDevices.length > 0;
} catch (error) {
console.error('Error checking camera devices:', error);
return false;
}
};
// Request camera permission
const requestCameraPermission = async () => {
setIsRequestingPermission(true);
// Check if running on secure context
if (!isLocalhost() && window.location.protocol !== 'https:') {
toast({
title: "HTTPS Required",
description: "Camera access requires HTTPS when accessing from remote devices. Please use https:// or access from localhost.",
variant: "destructive",
});
setIsRequestingPermission(false);
return false;
}
// First check if any camera devices are available
const hasCamera = await checkCameraDevices();
if (!hasCamera) {
toast({
title: "No Camera Found",
description: "No camera device was detected. Please ensure you have a camera connected and try again.",
variant: "destructive",
});
setIsRequestingPermission(false);
return false;
}
try {
console.log('Requesting camera permission...');
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
}
});
console.log('Camera permission granted!');
// Permission granted
setCameraPermission('granted');
setStream(mediaStream);
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
}
// Stop the stream for now, will be started when needed
mediaStream.getTracks().forEach(track => track.stop());
setStream(null);
toast({
title: "Camera Permission Granted",
description: "You can now use the camera to scan documents",
});
return true;
} catch (error: any) {
console.error('Camera permission error:', error);
// Handle different types of camera errors
if (error.name === 'NotFoundError' || error.message.includes('Requested device not found')) {
setCameraPermission('denied');
toast({
title: "No Camera Found",
description: "No camera device was detected. Please ensure you have a camera connected and try again.",
variant: "destructive",
});
} else if (error.name === 'NotAllowedError' || error.message.includes('Permission denied')) {
setCameraPermission('denied');
toast({
title: "Camera Permission Denied",
description: "Please allow camera access in your browser settings to scan documents",
variant: "destructive",
});
} else if (error.name === 'NotReadableError' || error.message.includes('Already in use')) {
setCameraPermission('denied');
toast({
title: "Camera Already in Use",
description: "The camera is being used by another application. Please close other apps using the camera and try again.",
variant: "destructive",
});
} else if (error.name === 'NotSupportedError' || error.message.includes('secure context')) {
setCameraPermission('denied');
toast({
title: "HTTPS Required",
description: "Camera access requires HTTPS. Please use https:// or access from localhost for development.",
variant: "destructive",
});
} else {
setCameraPermission('denied');
toast({
title: "Camera Error",
description: "Unable to access camera. Please check your camera settings and try again.",
variant: "destructive",
});
}
return false;
} finally {
setIsRequestingPermission(false);
}
};
// Camera functions
const startCamera = async () => {
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: facingMode,
width: { ideal: 1920 },
height: { ideal: 1080 }
}
});
setStream(mediaStream);
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
}
} catch (error) {
console.error('Error accessing camera:', error);
toast({
title: "Camera Error",
description: "Unable to access camera. Please check permissions.",
variant: "destructive",
});
}
};
const stopCamera = () => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
};
const capturePhoto = () => {
if (videoRef.current && canvasRef.current) {
const video = videoRef.current;
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (context) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = canvas.toDataURL('image/jpeg', 0.8);
setCapturedImage(imageData);
stopCamera();
}
}
};
const retakePhoto = () => {
setCapturedImage(null);
startCamera();
};
const toggleFacingMode = () => {
setFacingMode(prev => prev === 'user' ? 'environment' : 'user');
if (stream) {
stopCamera();
}
};
// Restart camera when facingMode changes
useEffect(() => {
if (cameraOpen && !capturedImage) {
startCamera();
}
}, [facingMode]);
const handleFinancialsModalChange = (v: boolean) => {
setIsFinancialsOpen(v);
if (!v) setModalPatientId(null);
};
const handleScanDocument = async (patientId: number) => {
console.log('handleScanDocument called with patientId:', patientId);
if (!patientId) {
console.error('No patient ID provided');
toast({
title: "Error",
description: "Please select a patient first",
variant: "destructive",
});
return;
}
setSelectedPatientIdForCamera(patientId);
setCapturedImage(null);
setFacingMode('environment'); // Reset to back camera by default
// Directly request permission which triggers browser prompt
const granted = await requestCameraPermission();
if (granted) {
setCameraOpen(true);
}
};
const handleCameraClose = () => {
stopCamera();
setCameraOpen(false);
setSelectedPatientIdForCamera(null);
setCapturedImage(null);
};
const saveCapturedPhoto = async () => {
if (!capturedImage || !selectedPatientIdForCamera) return;
try {
// Convert data URL to blob
const response = await fetch(capturedImage);
const blob = await response.blob();
const file = new File([blob], `scan_${Date.now()}.jpg`, { type: 'image/jpeg' });
// Upload using existing upload function
await uploadDocument(selectedPatientIdForCamera, file);
// Emit document upload event
window.dispatchEvent(new CustomEvent('documentUploaded', {
detail: { patientId: selectedPatientIdForCamera, fileName: `scan_${Date.now()}.jpg` }
}));
// Also set storage event for cross-tab communication
localStorage.setItem('documentUploaded', Date.now().toString());
// Refresh documents for this patient
await loadPatientDocuments(selectedPatientIdForCamera);
toast({
title: "Success",
description: "Document scanned and uploaded successfully",
variant: "default",
});
handleCameraClose();
} catch (error) {
console.error('Error saving scanned document:', error);
toast({
title: "Error",
description: "Failed to save scanned document",
variant: "destructive",
});
}
};
// Start camera when modal opens
useEffect(() => {
if (cameraOpen && !capturedImage) {
startCamera();
}
return () => {
if (stream) {
stopCamera();
}
};
}, [cameraOpen, capturedImage]);
const handleSelectPatient = (patient: Patient) => {
const isSelected = selectedPatientId === patient.id;
const newSelectedId = isSelected ? null : patient.id;
setSelectedPatientId(newSelectedId ? Number(newSelectedId) : null);
if (onSelectPatient) {
onSelectPatient(isSelected ? null : patient);
}
};
const handleClearSelection = () => {
setSelectedPatientId(null);
if (onSelectPatient) {
onSelectPatient(null);
}
};
const handleUploadFiles = async () => {
if (selectedPatientId) {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = '.pdf,.jpg,.jpeg,.png,.doc,.docx';
input.onchange = async (e) => {
const files = (e.target as HTMLInputElement).files;
if (files && files.length > 0) {
try {
const uploadPromises = Array.from(files).map(file =>
uploadDocument(selectedPatientId, file)
);
const results = await Promise.all(uploadPromises);
// Emit document upload event
window.dispatchEvent(new CustomEvent('documentUploaded', {
detail: { patientId: selectedPatientId, fileCount: files.length, files: Array.from(files).map(f => f.name) }
}));
// Also set storage event for cross-tab communication
localStorage.setItem('documentUploaded', Date.now().toString());
// Refresh documents for this patient
await loadPatientDocuments(selectedPatientId);
toast({
title: "Files Uploaded",
description: `${files.length} file(s) uploaded successfully to patient ID: ${selectedPatientId}`,
variant: "default",
});
} catch (error) {
console.error('Upload error:', error);
toast({
title: "Upload Failed",
description: error instanceof Error ? error.message : "Failed to upload files",
variant: "destructive",
});
}
}
};
input.click();
}
};
// Load documents for a patient
const loadPatientDocuments = async (patientId: number) => {
try {
setDocumentsLoading(prev => ({ ...prev, [patientId]: true }));
const response = await getPatientDocuments(patientId);
setPatientDocuments(prev => ({ ...prev, [patientId]: response.documents }));
// Load thumbnails for image documents
await loadDocumentThumbnails(response.documents);
} catch (error) {
console.error('Error loading documents:', error);
toast({
title: "Error",
description: "Failed to load documents",
variant: "destructive",
});
} finally {
setDocumentsLoading(prev => ({ ...prev, [patientId]: false }));
}
};
const handleToggleDocuments = async (patientId: number) => {
const isExpanding = expandedPatientId !== patientId;
setExpandedPatientId(isExpanding ? patientId : null);
// Load documents if expanding and not already loaded
if (isExpanding && !patientDocuments[patientId]) {
await loadPatientDocuments(patientId);
}
};
const handleViewDocument = async (documentId: number, patientId: number) => {
try {
const doc = patientDocuments[patientId]?.find(doc => doc.id === documentId);
if (!doc) {
console.error('Document not found');
return;
}
// Use the full URL from the database directly
const documentUrl = doc.filePath;
// For images, fetch and convert to blob URL for modal viewing
if (doc.mimeType.startsWith('image/')) {
// Static files don't need authentication headers
const response = await fetch(documentUrl);
if (!response.ok) {
throw new Error('Failed to fetch document');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
setViewingDocument({ url, name: doc.originalName });
setDocumentViewerOpen(true);
} else {
// For PDFs and other files, open in new tab
const link = document.createElement('a');
link.href = documentUrl;
link.target = '_blank';
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
} catch (error) {
console.error('Error viewing document:', error);
toast({
title: "Error",
description: "Failed to view document. Please try again.",
variant: "destructive",
});
}
};
const handleDownloadDocument = async (documentId: number, patientId: number) => {
try {
const doc = patientDocuments[patientId]?.find(doc => doc.id === documentId);
if (!doc) {
toast({
title: "Error",
description: "Document not found",
variant: "destructive",
});
return;
}
// Use the full URL from the database directly
const documentUrl = doc.filePath;
// For better download experience, fetch the file and create blob URL
try {
const response = await fetch(documentUrl);
if (!response.ok) {
throw new Error('Failed to fetch document for download');
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Create a temporary link and trigger download
const link = document.createElement('a');
link.href = blobUrl;
link.download = doc.originalName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up blob URL
URL.revokeObjectURL(blobUrl);
} catch (fetchError) {
// Fallback to direct link if fetch fails
const link = document.createElement('a');
link.href = documentUrl;
link.download = doc.originalName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
toast({
title: "Success",
description: "Document downloaded successfully",
});
} catch (error) {
console.error('Error downloading document:', error);
toast({
title: "Error",
description: "Failed to download document. Please try again.",
variant: "destructive",
});
}
};
const handleDeleteDocument = async (documentId: number, patientId: number) => {
try {
await deleteDocument(documentId);
// Refresh documents for this patient
await loadPatientDocuments(patientId);
toast({
title: "Document Deleted",
description: "Document has been deleted successfully",
variant: "default",
});
} catch (error) {
console.error('Error deleting document:', error);
toast({
title: "Error",
description: error instanceof Error ? error.message : "Failed to delete document",
variant: "destructive",
});
}
};
const searchKeyPart = debouncedSearchCriteria?.searchTerm || "recent";
const {
data: patientsData,
isLoading,
isError,
} = useQuery<PatientApiResponse, Error>({
queryKey: qkPatients(currentPage, searchKeyPart),
queryFn: async () => {
const trimmedTerm = debouncedSearchCriteria?.searchTerm?.trim();
const isSearch = trimmedTerm && trimmedTerm.length > 0;
const rawSearchBy = debouncedSearchCriteria?.searchBy || "name";
const validSearchKeys = [
"name",
"phone",
"insuranceId",
"gender",
"dob",
"all",
];
const searchKey = validSearchKeys.includes(rawSearchBy)
? rawSearchBy
: "name";
let url: string;
if (isSearch) {
const searchParams = new URLSearchParams({
limit: String(patientsPerPage),
offset: String(offset),
});
if (searchKey === "all") {
searchParams.set("term", trimmedTerm!);
} else {
searchParams.set(searchKey, trimmedTerm!);
}
url = `/api/patients/search?${searchParams.toString()}`;
} else {
url = `/api/patients/recent?limit=${patientsPerPage}&offset=${offset}`;
}
const res = await apiRequest("GET", url);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message || "Search failed");
}
return res.json();
},
placeholderData: {
patients: [],
totalCount: 0,
},
});
// 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: async () => {
setIsAddPatientOpen(false);
// this query every page,
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
toast({
title: "Success",
description: "Patient updated successfully!",
variant: "default",
});
},
onError: (error) => {
toast({
title: "Error",
description: `Failed to update patient: ${error.message}`,
variant: "destructive",
});
},
});
const deletePatientMutation = useMutation({
mutationFn: async (id: number) => {
const res = await apiRequest("DELETE", `/api/patients/${id}`);
return;
},
onSuccess: async () => {
setIsDeletePatientOpen(false);
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
toast({
title: "Success",
description: "Patient deleted successfully!",
variant: "default",
});
},
onError: (error) => {
console.log(error);
toast({
title: "Error",
description: `Failed to delete patient: ${error.message}`,
variant: "destructive",
});
},
});
const handleUpdatePatient = (patient: UpdatePatient & { id?: number }) => {
if (currentPatient && user) {
const { id, ...sanitizedPatient } = patient;
updatePatientMutation.mutate({
id: Number(currentPatient.id),
patient: sanitizedPatient,
});
} else {
console.error("No current patient or user found for update");
toast({
title: "Error",
description: "Cannot update patient: No patient or user found",
variant: "destructive",
});
}
};
const handleEditPatient = (patient: Patient) => {
setCurrentPatient(patient);
setIsAddPatientOpen(true);
};
const handleViewPatient = (patient: Patient) => {
setCurrentPatient(patient);
setIsViewPatientOpen(true);
};
const handleDeletePatient = (patient: Patient) => {
setCurrentPatient(patient);
setIsDeletePatientOpen(true);
};
const handleConfirmDeletePatient = async () => {
if (currentPatient) {
deletePatientMutation.mutate(Number(currentPatient.id));
} else {
toast({
title: "Error",
description: "No patient selected for deletion.",
variant: "destructive",
});
}
};
useEffect(() => {
if (onPageChange) onPageChange(currentPage);
}, [currentPage, onPageChange]);
useEffect(() => {
const term = debouncedSearchCriteria?.searchTerm?.trim() || "recent";
if (onSearchChange) onSearchChange(term);
}, [debouncedSearchCriteria, onSearchChange]);
const totalPages = useMemo(
() => Math.ceil((patientsData?.totalCount || 0) / patientsPerPage),
[patientsData]
);
const startItem = offset + 1;
const endItem = Math.min(
offset + patientsPerPage,
patientsData?.totalCount || 0
);
const getInitials = (firstName: string, lastName: string) => {
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase();
};
const getAvatarColor = (id: number) => {
const colorClasses = [
"bg-blue-500",
"bg-teal-500",
"bg-amber-500",
"bg-rose-500",
"bg-indigo-500",
"bg-green-500",
"bg-purple-500",
];
return colorClasses[id % colorClasses.length];
};
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
{/* Selection Panel */}
{!!selectedPatientId && (
<div className="bg-red-50 border border-red-200 rounded-tl-md rounded-tr-md px-4 py-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-red-800">
1 patient selected
</span>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleScanDocument(selectedPatientId)}
className="flex items-center space-x-1 text-white bg-green-600 hover:bg-green-500 hover:text-white transition-all duration-300 ease-in-out"
>
<Camera className="h-4 w-4" />
<span>Scan Document</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleUploadFiles}
className="flex items-center space-x-1 bg-blue-500 text-white hover:bg-blue-400 hover:text-white transition-all duration-300 ease-in-out"
>
<Upload className="h-4 w-4" />
<span>Upload Files</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClearSelection}
className="flex items-center space-x-1 border-red-300 text-red-700 hover:bg-red-100 transition-all duration-300 ease-in-out"
>
<X className="h-4 w-4" />
<span>Clear selection</span>
</Button>
</div>
</div>
)}
<div className="overflow-x-auto">
<PatientSearch
onSearch={(criteria) => {
setSearchCriteria(criteria);
setCurrentPage(1); // reset page on new search
setIsSearchActive(true);
}}
onClearSearch={() => {
setSearchCriteria({ searchTerm: "", searchBy: "name" }); // triggers `recent`
setCurrentPage(1);
setIsSearchActive(false);
}}
isSearchActive={isSearchActive}
/>
<Table>
<TableHeader>
<TableRow>
{allowCheckbox && <TableHead>Select</TableHead>}
<TableHead>Patient</TableHead>
<TableHead>DOB / Gender</TableHead>
{/*<TableHead>Contact</TableHead>*/}
<TableHead>Insurance</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center py-8 text-muted-foreground"
>
<LoadingScreen />
</TableCell>
</TableRow>
) : isError ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center py-8 text-red-500"
>
Error loading patients.
</TableCell>
</TableRow>
) : patientsData?.patients.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center py-8 text-muted-foreground"
>
No patients found.
</TableCell>
</TableRow>
) : (
patientsData?.patients.map((patient) => (
<React.Fragment key={patient.id}>
<TableRow className="hover:bg-gray-50">
{allowCheckbox && (
<TableCell>
<Checkbox
checked={selectedPatientId === patient.id}
onCheckedChange={() => handleSelectPatient(patient)}
/>
</TableCell>
)}
<TableCell>
<div className="flex items-center">
<Avatar
className={`h-10 w-10 ${getAvatarColor(Number(patient.id))}`}
>
<AvatarFallback className="text-white">
{getInitials(patient.firstName, patient.lastName)}
</AvatarFallback>
</Avatar>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{patient.firstName} {patient.lastName}
</div>
<div className="text-sm text-gray-500">
PID-{patient.id?.toString().padStart(4, "0")}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{formatDateToHumanReadable(patient.dateOfBirth)}
</div>
<div className="text-sm text-gray-500 capitalize">
{patient.gender}
</div>
</TableCell>
{/*<TableCell>
<div className="text-sm text-gray-900">{patient.phone}</div>
<div className="text-sm text-gray-500">{patient.email}</div>
</TableCell>*/}
<TableCell>
<div className="text-sm text-gray-900">
{patient.insuranceProvider ?? "Not specified"}
</div>
{patient.insuranceId && (
<div className="text-sm text-gray-500">
ID: {patient.insuranceId}
</div>
)}
</TableCell>
<TableCell>
<div className="col-span-1">
{patient.status === "ACTIVE" && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Active
</span>
)}
{patient.status === "INACTIVE" && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Inactive
</span>
)}
{patient.status === "UNKNOWN" && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-700">
Unknown
</span>
)}
{patient.status === "PLAN_NOT_ACCEPTED" && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
Plan Not Accepted
</span>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end">
{allowDelete && isAdmin && (
<Button
onClick={() => {
handleDeletePatient(patient);
}}
className="text-red-600 hover:text-red-900"
aria-label="Delete Patient"
variant="ghost"
size="icon"
title="Delete Patient"
>
<Delete />
</Button>
)}
{allowFinancial && (
<Button
variant="ghost"
size="icon"
onClick={() => {
setModalPatientId(Number(patient.id));
setIsFinancialsOpen(true);
}}
className="text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50"
title="View financials"
>
<FileCheck className="h-5 w-5" />
</Button>
)}
{allowEdit && (
<Button
variant="ghost"
size="icon"
onClick={() => {
handleEditPatient(patient);
}}
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
title="Edit Patient"
>
<Edit className="h-4 w-4" />
</Button>
)}
{allowNewClaim && (
<Button
variant="ghost"
size="icon"
onClick={() => onNewClaim?.(Number(patient.id))}
className="text-green-600 hover:text-green-800 hover:bg-green-50"
title="New Claim"
>
<FileCheck className="h-5 w-5" />
</Button>
)}
{allowView && (
<Button
variant="ghost"
size="icon"
onClick={() => {
handleViewPatient(patient);
}}
className="text-gray-600 hover:text-gray-800 hover:bg-gray-50"
title="View Patient Info"
>
<Eye className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
</React.Fragment>
))
)}
</TableBody>
</Table>
</div>
{/* 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>{" "}
{formatDateToHumanReadable(currentPatient.dateOfBirth)}
</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={cn(
currentPatient.status === "ACTIVE"
? "text-green-600"
: currentPatient.status === "INACTIVE"
? "text-red-600"
: currentPatient.status === "PLAN_NOT_ACCEPTED"
? "text-amber-600"
: "text-gray-600",
"font-medium"
)}
>
{currentPatient.status === "PLAN_NOT_ACCEPTED"
? "Plan Not Accepted"
: currentPatient.status
? currentPatient.status.charAt(0).toUpperCase() +
currentPatient.status.slice(1).toLowerCase()
: "Unknown"}
</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 Patient Modal */}
<AddPatientModal
open={isAddPatientOpen}
onOpenChange={setIsAddPatientOpen}
onSubmit={handleUpdatePatient}
isLoading={isLoading}
patient={currentPatient}
/>
{/* Financial Modal */}
<PatientFinancialsModal
patientId={modalPatientId}
open={isFinancialsOpen}
onOpenChange={(v) => {
setIsFinancialsOpen(v);
if (!v) setModalPatientId(null);
}}
/>
<DeleteConfirmationDialog
isOpen={isDeletePatientOpen}
onConfirm={handleConfirmDeletePatient}
onCancel={() => setIsDeletePatientOpen(false)}
entityName={currentPatient?.firstName}
/>
{/* Pagination */}
{totalPages > 1 && (
<div className="bg-white px-4 py-3 border-t border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground mb-2 sm:mb-0 whitespace-nowrap">
Showing {startItem}{endItem} of {patientsData?.totalCount || 0}{" "}
results
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage > 1) setCurrentPage(currentPage - 1);
}}
className={
currentPage === 1 ? "pointer-events-none opacity-50" : ""
}
/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map((page, idx) => (
<PaginationItem key={idx}>
{page === "..." ? (
<span className="px-2 text-gray-500">...</span>
) : (
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
setCurrentPage(page as number);
}}
isActive={currentPage === page}
>
{page}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
setCurrentPage(currentPage + 1);
}}
className={
currentPage === totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
)}
{/* Document Viewer Modal */}
<Dialog open={documentViewerOpen} onOpenChange={handleDocumentViewerClose}>
<DialogContent className="sm:max-w-[50vw] sm:max-h-[90vh]">
<DialogHeader className="flex flex-row items-center justify-between">
<div className="flex flex-col">
<DialogTitle>Document Viewer</DialogTitle>
<DialogDescription>
Viewing: {viewingDocument?.name}
</DialogDescription>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleDocumentViewerClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<div className="flex flex-col items-center justify-center min-h-[60vh]">
{viewingDocument && (
<>
{viewingDocument.name.toLowerCase().endsWith('.pdf') ? (
<iframe
src={viewingDocument.url}
className="w-full h-[60vh] border rounded"
title={viewingDocument.name}
/>
) : viewingDocument.name.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/i) ? (
<img
src={viewingDocument.url}
alt={viewingDocument.name}
className="max-w-full max-h-[60vh] object-contain rounded"
/>
) : (
<div className="text-center">
<FileText className="h-16 w-16 mx-auto text-gray-400 mb-4" />
<p className="text-gray-600 mb-4">Preview not available for this file type</p>
<Button
onClick={() => {
const link = document.createElement('a');
link.href = viewingDocument.url;
link.target = '_blank';
link.click();
}}
className="bg-blue-600 hover:bg-blue-700"
>
Open in New Tab
</Button>
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
{/* Camera Modal */}
<Dialog open={cameraOpen} onOpenChange={handleCameraClose}>
<DialogContent className="p-0 border-none max-w-none w-full h-[100dvh] flex flex-col bg-black overflow-hidden sm:rounded-none">
{/* Full screen container */}
<div className="relative flex-1 flex flex-col overflow-hidden">
{/* Close button at top-right */}
<Button
variant="ghost"
size="icon"
onClick={handleCameraClose}
className="absolute top-4 right-4 z-50 h-10 w-10 p-0 text-white bg-black/40 hover:bg-black/60 rounded-full"
>
<X className="h-6 w-6" />
</Button>
{/* Camera/Image View */}
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
{!capturedImage ? (
<>
<video
ref={videoRef}
autoPlay
playsInline
className="w-full h-full object-cover"
/>
<canvas ref={canvasRef} className="hidden" />
</>
) : (
<img
src={capturedImage}
alt="Captured"
className="w-full h-full object-contain"
/>
)}
</div>
{/* Bottom Controls */}
<div className="absolute bottom-10 left-0 right-0 px-8 flex items-center justify-center bg-transparent z-40">
{!capturedImage ? (
<div className="flex items-center justify-between w-full max-w-sm px-4">
{/* Camera Switch */}
<Button
variant="ghost"
size="icon"
onClick={toggleFacingMode}
className="h-12 w-12 text-white bg-white/20 hover:bg-white/40 rounded-full shadow-lg backdrop-blur-sm"
>
<RefreshCw className="h-6 w-6" />
</Button>
{/* Capture Image Icon Button */}
<button
onClick={capturePhoto}
className="h-20 w-20 rounded-full border-4 border-white flex items-center justify-center bg-transparent active:scale-90 transition-all shadow-xl"
>
<div className="h-14 w-14 rounded-full bg-white flex items-center justify-center">
<Camera className="h-8 w-8 text-black" />
</div>
</button>
{/* Spacer for symmetry */}
<div className="w-12" />
</div>
) : (
<div className="flex items-center gap-4 w-full max-w-md justify-center">
<Button
variant="outline"
onClick={retakePhoto}
className="flex bg-white/10 text-white border-white/20 hover:bg-white/20 px-6 py-4 rounded-2xl font-semibold backdrop-blur-md"
>
Retake
</Button>
<Button
onClick={saveCapturedPhoto}
className="flex bg-white text-black hover:bg-gray-100 px-6 py-4 rounded-2xl font-semibold shadow-lg"
>
<Upload className="h-5 w-5 mr-2" />
Save
</Button>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}