759 lines
25 KiB
TypeScript
759 lines
25 KiB
TypeScript
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>
|
||
);
|
||
}
|