- 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>
1561 lines
54 KiB
TypeScript
Executable File
1561 lines
54 KiB
TypeScript
Executable File
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>
|
||
);
|
||
}
|