Files
DentalManagementMH04/apps/Frontend/src/pages/documents-page.tsx

734 lines
32 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 { 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,
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
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5);
const offset = (currentPage - 1) * limit;
const [totalForExpandedGroup, setTotalForExpandedGroup] = useState<
number | null
>(null);
const [showPatientDocuments, setShowPatientDocuments] = useState(false);
// 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);
setIsPreviewModalOpen(false);
setPreviewDocumentId(null);
}, [selectedPatient]);
// Patient documents — React Query for caching (re-selecting same patient shows instantly)
const { data: patientDocuments = [], isLoading: patientDocumentsLoading } =
useQuery<PatientDocument[]>({
queryKey: ["patientDocuments", selectedPatient?.id],
enabled: !!selectedPatient?.id,
staleTime: 2 * 60 * 1000,
queryFn: async () => {
const response = await getPatientDocuments(selectedPatient!.id);
if (!response.success) throw new Error("Failed to load documents");
return response.documents;
},
});
// Derive thumbnails synchronously — no extra async round-trip
const documentThumbnails = useMemo(() => {
const result: { [key: number]: string } = {};
for (const doc of patientDocuments) {
if (doc.mimeType.startsWith("image/")) result[doc.id] = doc.filePath;
}
return result;
}, [patientDocuments]);
// Listen for document upload events — invalidate React Query cache instead of manual refetch
useEffect(() => {
const refresh = () => {
if (selectedPatient?.id) {
queryClient.invalidateQueries({ queryKey: ["patientDocuments", selectedPatient.id] });
}
};
window.addEventListener("documentUploaded", refresh);
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "documentUploaded" && e.newValue) refresh();
};
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener("documentUploaded", refresh);
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,
staleTime: 2 * 60 * 1000,
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 ? (
<div>Loading groups</div>
) : (groups as any[]).length === 0 && patientDocuments.length === 0 && !patientDocumentsLoading ? (
<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>
);
}