initial commit

This commit is contained in:
2026-04-04 22:13:55 -04:00
commit 5d77e207c9
10181 changed files with 522212 additions and 0 deletions

View File

@@ -0,0 +1,785 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Eye, Trash, Download, FolderOpen, FileText } from "lucide-react";
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
import { PatientTable } from "@/components/patients/patient-table";
import { Patient, PdfFile } from "@repo/db/types";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import DocumentsFilePreviewModal from "@/components/documents/file-preview-modal";
import { getPageNumbers } from "@/utils/pageNumberGenerator";
import {
getPatientDocuments,
deleteDocument,
viewDocument,
downloadDocument,
formatFileSize,
type PatientDocument
} from "@/lib/api/documents";
export default function DocumentsPage() {
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [expandedGroupId, setExpandedGroupId] = useState<number | null>(null);
// pagination state for the expanded group
// pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5);
const offset = (currentPage - 1) * limit;
const [totalForExpandedGroup, setTotalForExpandedGroup] = useState<
number | null
>(null);
// Patient documents state
const [patientDocuments, setPatientDocuments] = useState<PatientDocument[]>([]);
const [patientDocumentsLoading, setPatientDocumentsLoading] = useState(false);
const [showPatientDocuments, setShowPatientDocuments] = useState(false);
const [documentThumbnails, setDocumentThumbnails] = useState<{ [key: number]: string }>({});
// Document preview state
const [previewDocumentId, setPreviewDocumentId] = useState<number | null>(null);
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
// Delete document state
const [deleteDocumentId, setDeleteDocumentId] = useState<number | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
// PDF state
const [currentPdf, setCurrentPdf] = useState<PdfFile | null>(null);
// Delete dialog
const [isDeletePdfOpen, setIsDeletePdfOpen] = useState(false);
// reset UI when patient changes
useEffect(() => {
setExpandedGroupId(null);
setLimit(5);
setCurrentPage(1);
setTotalForExpandedGroup(null);
setShowPatientDocuments(false); // Reset documents toggle
// close the preview modal
setIsPreviewModalOpen(false);
setPreviewDocumentId(null);
// Load patient documents when patient is selected
if (selectedPatient?.id) {
console.log("Patient selected, loading documents for:", selectedPatient.id);
loadPatientDocuments(selectedPatient.id);
} else {
console.log("No patient selected, clearing documents");
setPatientDocuments([]);
}
}, [selectedPatient]);
// Load patient documents function
const loadPatientDocuments = async (patientId: number) => {
try {
setPatientDocumentsLoading(true);
console.log("Loading documents for patient:", patientId);
const response = await getPatientDocuments(patientId);
console.log("Loaded documents:", response);
if (response.success) {
setPatientDocuments(response.documents);
// Load thumbnails for image documents
loadDocumentThumbnails(response.documents);
} else {
throw new Error("Failed to load documents");
}
} catch (error) {
console.error("Failed to load patient documents:", error);
toast({
title: "Error",
description: "Failed to load patient documents",
variant: "destructive",
});
} finally {
setPatientDocumentsLoading(false);
}
};
// Load thumbnails for image documents
const loadDocumentThumbnails = async (documents: PatientDocument[]) => {
const thumbnails: { [key: number]: string } = {};
for (const document of documents) {
if (document.mimeType.startsWith('image/')) {
try {
// Use the document's filePath as the thumbnail URL
thumbnails[document.id] = document.filePath;
} catch (error) {
console.error(`Failed to load thumbnail for document ${document.id}:`, error);
}
}
}
setDocumentThumbnails(thumbnails);
};
// Refresh patient documents (for after upload)
const refreshPatientDocuments = async () => {
if (selectedPatient?.id) {
await loadPatientDocuments(selectedPatient.id);
}
};
// Listen for document upload events
useEffect(() => {
const handleDocumentUpload = (event: CustomEvent) => {
console.log('Document upload event received:', event.detail);
refreshPatientDocuments();
};
// Add event listener for document uploads
window.addEventListener('documentUploaded', handleDocumentUpload as EventListener);
// Also listen for storage events (for cross-tab communication)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'documentUploaded' && e.newValue) {
refreshPatientDocuments();
}
};
window.addEventListener('storage', handleStorageChange);
// Cleanup listeners
return () => {
window.removeEventListener('documentUploaded', handleDocumentUpload as EventListener);
window.removeEventListener('storage', handleStorageChange);
};
}, [selectedPatient]);
// Handle document preview
const handlePreviewDocument = (documentId: number) => {
const document = patientDocuments.find(doc => doc.id === documentId);
setPreviewDocumentId(documentId);
setIsPreviewModalOpen(true);
};
// Handle document delete
const handleDeleteDocument = (documentId: number) => {
setDeleteDocumentId(documentId);
setIsDeleteDialogOpen(true);
};
// Confirm document delete
const confirmDeleteDocument = async () => {
if (!deleteDocumentId) return;
try {
const response = await deleteDocument(deleteDocumentId);
if (response.success) {
toast({
title: "Success",
description: "Document deleted successfully",
});
// Reload documents for the current patient
if (selectedPatient?.id) {
await loadPatientDocuments(selectedPatient.id);
}
} else {
throw new Error("Failed to delete document");
}
} catch (error) {
console.error("Failed to delete document:", error);
toast({
title: "Error",
description: "Failed to delete document",
variant: "destructive",
});
} finally {
setIsDeleteDialogOpen(false);
setDeleteDocumentId(null);
}
};
// FETCH GROUPS for patient (includes `category` on each group)
const { data: groups = [], isLoading: isLoadingGroups } = useQuery({
queryKey: ["groups", selectedPatient?.id],
enabled: !!selectedPatient,
queryFn: async () => {
const res = await apiRequest(
"GET",
`/api/documents/pdf-groups/patient/${selectedPatient?.id}`
);
return res.json();
},
});
// Group groups by titleKey (use titleKey only for grouping/ordering; don't display it)
const groupsByTitleKey = useMemo(() => {
const map = new Map<string, any[]>();
for (const g of groups as any[]) {
const key = String(g.titleKey ?? "OTHER");
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(g);
}
// Decide on a stable order for titleKey buckets: prefer enum order then any extras
const preferredOrder = [
"INSURANCE_CLAIM",
"INSURANCE_CLAIM_PREAUTH",
"ELIGIBILITY_STATUS",
"CLAIM_STATUS",
"OTHER",
];
const orderedKeys: string[] = [];
for (const k of preferredOrder) {
if (map.has(k)) orderedKeys.push(k);
}
// append any keys that weren't in preferredOrder
for (const k of map.keys()) {
if (!orderedKeys.includes(k)) orderedKeys.push(k);
}
return { map, orderedKeys };
}, [groups]);
// FETCH PDFs for selected group with pagination (limit & offset)
const {
data: groupPdfsResponse,
refetch: refetchGroupPdfs,
isFetching: isFetchingPdfs,
} = useQuery({
queryKey: ["groupPdfs", expandedGroupId, currentPage, limit],
enabled: !!expandedGroupId,
queryFn: async () => {
// API should accept ?limit & ?offset and also return total count
const res = await apiRequest(
"GET",
`/api/documents/recent-pdf-files/group/${expandedGroupId}?limit=${limit}&offset=${offset}`
);
// expected shape: { data: PdfFile[], total: number }
const json = await res.json();
setTotalForExpandedGroup(json.total ?? null);
return json.data ?? [];
},
});
const groupPdfs: PdfFile[] = groupPdfsResponse ?? [];
// DELETE mutation
const deletePdfMutation = useMutation({
mutationFn: async (id: number) => {
await apiRequest("DELETE", `/api/documents/pdf-files/${id}`);
},
onSuccess: () => {
setIsDeletePdfOpen(false);
setCurrentPdf(null);
queryClient.invalidateQueries({
queryKey: ["groupPdfs", expandedGroupId],
});
toast({ title: "Success", description: "PDF deleted successfully!" });
},
onError: (error: any) => {
toast({
title: "Error",
description: error.message || "Failed to delete PDF",
variant: "destructive",
});
},
});
const handleConfirmDeletePdf = () => {
if (currentPdf) {
deletePdfMutation.mutate(Number(currentPdf.id));
} else {
toast({
title: "Error",
description: "No PDF selected for deletion.",
variant: "destructive",
});
}
};
const handleViewPdf = (pdfId: number, filename?: string) => {
setPreviewDocumentId(pdfId);
setIsPreviewModalOpen(true);
};
const handleDownloadPdf = async (pdfId: number, filename: string) => {
const res = await apiRequest("GET", `/api/documents/pdf-files/${pdfId}`);
const arrayBuffer = await res.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Expand / collapse a group — when expanding reset pagination
const toggleExpandGroup = (groupId: number) => {
if (expandedGroupId === groupId) {
setExpandedGroupId(null);
setCurrentPage(1);
setLimit(5);
setTotalForExpandedGroup(null);
} else {
setExpandedGroupId(groupId);
setCurrentPage(1);
setLimit(5);
setTotalForExpandedGroup(null);
}
};
// pagintaion helper
const totalPages = totalForExpandedGroup
? Math.ceil(totalForExpandedGroup / limit)
: 1;
const startItem = totalForExpandedGroup ? offset + 1 : 0;
const endItem = totalForExpandedGroup
? Math.min(offset + limit, totalForExpandedGroup)
: 0;
return (
<div>
<div className="container mx-auto space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Documents</h1>
<p className="text-muted-foreground">
View and manage recent uploaded claim PDFs
</p>
</div>
</div>
{selectedPatient && (
<Card>
<CardHeader>
<CardTitle>
Document groups of Patient: {selectedPatient.firstName}{" "}
{selectedPatient.lastName}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Existing Groups Section */}
<div>
{/* <h4 className="text-lg font-semibold mb-3">Document Groups</h4> */}
{isLoadingGroups || patientDocumentsLoading ? (
<div>Loading groups</div>
) : (groups as any[]).length === 0 && patientDocuments.length === 0 ? (
<div className="text-sm text-muted-foreground">
No groups found.
</div>
) : (
<div className="flex flex-col gap-3">
{groupsByTitleKey.orderedKeys.map((key, idx) => {
const bucket = groupsByTitleKey.map.get(key) ?? [];
return (
<div key={key}>
{/* subtle divider between buckets (no enum text shown) */}
{idx !== 0 && (
<hr className="my-3 border-t border-gray-200" />
)}
<div className="flex flex-col gap-2">
{bucket.map((group: any) => (
<div key={group.id} className="border rounded p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FolderOpen className="w-5 h-5" />
<div>
{/* Only show the group's title string */}
<div className="font-semibold">
{group.title}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={
expandedGroupId === group.id
? "default"
: "outline"
}
onClick={() => toggleExpandGroup(group.id)}
>
{expandedGroupId === group.id
? "Collapse"
: "Open"}
</Button>
</div>
</div>
{/* expanded content: show paginated PDFs for this group */}
{expandedGroupId === group.id && (
<div className="mt-3 space-y-3">
{isFetchingPdfs ? (
<div>Loading PDFs</div>
) : groupPdfs.length === 0 ? (
<div className="text-muted-foreground">
No PDFs in this group.
</div>
) : (
<div className="flex flex-col gap-2">
{groupPdfs.map((pdf) => (
<div
key={pdf.id}
className="flex justify-between items-center border rounded p-2"
>
<div className="text-sm">
{pdf.filename}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleViewPdf(
Number(pdf.id),
pdf.filename
)
}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDownloadPdf(
Number(pdf.id),
pdf.filename
)
}
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setCurrentPdf(pdf);
setIsDeletePdfOpen(true);
}}
>
<Trash className="w-4 h-4" />
</Button>
</div>
</div>
))}
{/* Pagination */}
{totalPages > 1 && (
<div className="bg-white px-4 py-3 border-t border-gray-200 rounded">
<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{" "}
{totalForExpandedGroup || 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>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
);
})}
{/* Patient Documents Section - moved outside groups mapping */}
{patientDocuments.length > 0 && (
<div>
<div>
{(groups as any[]).length > 0 && (
<hr className="my-3 border-t border-gray-200" />
)}
</div>
<div className="border rounded p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FolderOpen className="w-5 h-5" />
<div>
<div className="font-semibold">
Other Documents
</div>
</div>
</div>
<Button
size="sm"
variant={showPatientDocuments ? "default" : "outline"}
className="flex items-center gap-2"
onClick={() => setShowPatientDocuments(!showPatientDocuments)}
>
{showPatientDocuments ? (
<>
<span className="">Collapse</span>
</>
) : (
<>
<span>Open</span>
</>
)}
</Button>
</div>
{showPatientDocuments && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
{patientDocuments.map((document: PatientDocument) => (
<div
key={document.id}
className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start space-x-3">
{/* Thumbnail */}
<div className="flex-shrink-0">
{document.mimeType.startsWith('image/') && documentThumbnails[document.id] ? (
<img
src={documentThumbnails[document.id]}
alt={document.originalName}
className="w-10 h-10 object-cover rounded-lg border border-gray-200"
/>
) : (
<div className="w-10 h-10 bg-gray-100 rounded-lg border border-gray-200 flex items-center justify-center">
{document.mimeType.startsWith('image/') ? (
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
) : (
<FileText className="h-6 w-6 text-gray-400" />
)}
</div>
)}
</div>
{/* Document Info */}
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 truncate">
{document.originalName}
</h4>
<div className="flex items-center gap-1 mt-1">
<p className="text-xs text-gray-500">
{formatFileSize(document.fileSize)}
</p>
<span className="text-[12px]">|</span>
<div className="flex">
<span className={`inline-flex items-center text-xs font-medium ${document.mimeType.startsWith('image/')
? 'text-green-800'
: document.mimeType === 'application/pdf'
? 'text-red-800'
: 'text-blue-800'
}`}>
{document.mimeType.startsWith('image/') ? 'Image' :
document.mimeType === 'application/pdf' ? 'PDF' :
document.mimeType.split('/')[1]?.toUpperCase() || 'File'}
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
onClick={() => handlePreviewDocument(document.id)}
className="h-8 w-8 text-blue-600 hover:text-blue-800 hover:bg-blue-50"
title="View Document"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={async () => {
const doc = patientDocuments.find(doc => doc.id === document.id);
if (doc?.filePath) {
try {
// Fetch the file as a blob to force download
const response = await fetch(doc.filePath);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = window.document.createElement('a');
link.href = url;
link.download = doc.originalName;
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Download failed:', error);
}
}
}}
className="h-8 w-8 text-green-600 hover:text-green-800 hover:bg-green-50"
title="Download Document"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteDocument(document.id)}
className="h-8 w-8 text-red-600 hover:text-red-800 hover:bg-red-50"
title="Delete Document"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
Select a patient to view document groups
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable
allowView
allowDelete
allowCheckbox
allowEdit
onSelectPatient={setSelectedPatient}
/>
</CardContent>
</Card>
<DeleteConfirmationDialog
isOpen={isDeletePdfOpen}
onConfirm={handleConfirmDeletePdf}
onCancel={() => setIsDeletePdfOpen(false)}
entityName={`PDF #${currentPdf?.id}`}
/>
{/* Document Preview Modal */}
<DocumentsFilePreviewModal
fileId={previewDocumentId}
isOpen={isPreviewModalOpen}
onClose={() => {
setIsPreviewModalOpen(false);
setPreviewDocumentId(null);
}}
isPatientDocument={patientDocuments.some(doc => doc.id === previewDocumentId)}
directImageUrl={patientDocuments.find(doc => doc.id === previewDocumentId)?.filePath}
initialFileName={patientDocuments.find(doc => doc.id === previewDocumentId)?.originalName}
/>
{/* Document Delete Confirmation Dialog */}
<DeleteConfirmationDialog
isOpen={isDeleteDialogOpen}
onConfirm={confirmDeleteDocument}
onCancel={() => {
setIsDeleteDialogOpen(false);
setDeleteDocumentId(null);
}}
entityName={patientDocuments.find(doc => doc.id === deleteDocumentId)?.originalName || `Document #${deleteDocumentId}`}
/>
</div>
</div>
);
}