Files
DentalManagement2025/apps/Frontend/src/components/patients/patient-table.tsx
2025-08-10 18:21:38 +05:30

759 lines
25 KiB
TypeScript
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Delete, Edit, Eye, FileCheck } 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 "../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 "../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 "../ui/checkbox";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import { Patient, UpdatePatient } from "@repo/db/types";
interface PatientApiResponse {
patients: Patient[];
totalCount: number;
}
interface PatientTableProps {
allowEdit?: boolean;
allowView?: boolean;
allowDelete?: boolean;
allowCheckbox?: boolean;
allowNewClaim?: boolean;
onNewClaim?: (patientId: number) => void;
onSelectPatient?: (patient: Patient | null) => void;
onPageChange?: (page: number) => void;
onSearchChange?: (searchTerm: string) => void;
}
export function PatientTable({
allowEdit,
allowView,
allowDelete,
allowCheckbox,
allowNewClaim,
onNewClaim,
onSelectPatient,
onPageChange,
onSearchChange,
}: PatientTableProps) {
const { toast } = useToast();
const { user } = useAuth();
const [isAddPatientOpen, setIsAddPatientOpen] = useState(false);
const [isViewPatientOpen, setIsViewPatientOpen] = useState(false);
const [isDeletePatientOpen, setIsDeletePatientOpen] = useState(false);
const [currentPatient, setCurrentPatient] = useState<Patient | undefined>(
undefined
);
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 handleSelectPatient = (patient: Patient) => {
const isSelected = selectedPatientId === patient.id;
const newSelectedId = isSelected ? null : patient.id;
setSelectedPatientId(Number(newSelectedId));
if (onSelectPatient) {
onSelectPatient(isSelected ? null : patient);
}
};
const {
data: patientsData,
isLoading,
isError,
} = useQuery<PatientApiResponse, Error>({
queryKey: [
"patients",
{
page: currentPage,
search: debouncedSearchCriteria?.searchTerm || "recent",
},
],
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: () => {
setIsAddPatientOpen(false);
queryClient.invalidateQueries({
queryKey: [
"patients",
{
page: currentPage,
search: debouncedSearchCriteria?.searchTerm || "recent",
},
],
});
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: () => {
setIsDeletePatientOpen(false);
queryClient.invalidateQueries({
queryKey: [
"patients",
{
page: currentPage,
search: debouncedSearchCriteria?.searchTerm || "recent",
},
],
});
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];
};
function getPageNumbers(current: number, total: number): (number | "...")[] {
const delta = 2;
const range: (number | "...")[] = [];
const left = Math.max(2, current - delta);
const right = Math.min(total - 1, current + delta);
range.push(1);
if (left > 2) range.push("...");
for (let i = left; i <= right; i++) {
range.push(i);
}
if (right < total - 1) range.push("...");
if (total > 1) range.push(total);
return range;
}
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<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) => (
<TableRow key={patient.id} 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">
<span
className={cn(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
patient.status === "active"
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
)}
>
{patient.status === "active" ? "Active" : "Inactive"}
</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{allowDelete && (
<Button
onClick={() => {
handleDeletePatient(patient);
}}
className="text-red-600 hover:text-red-900"
aria-label="Delete Staff"
variant="ghost"
size="icon"
>
<Delete />
</Button>
)}
{allowEdit && (
<Button
variant="ghost"
size="icon"
onClick={() => {
handleEditPatient(patient);
}}
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
>
<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"
aria-label="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"
>
<Eye className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</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={`${
currentPatient.status === "active"
? "text-green-600"
: "text-red-600"
} font-medium`}
>
{currentPatient.status
? currentPatient.status.charAt(0).toUpperCase() +
currentPatient.status.slice(1)
: "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}
/>
<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>
)}
</div>
);
}