430 lines
18 KiB
TypeScript
430 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import Link from "next/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Plus, Edit, Trash2, Eye, Calendar, Filter, Loader2 } from "lucide-react";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import ManagerLayout from "@/components/manager-layout";
|
|
import { patientsService } from "@/services/patientsApi.mjs";
|
|
|
|
const formatDate = (dateString: string | null | undefined): string => {
|
|
if (!dateString) {
|
|
return "N/A";
|
|
}
|
|
try {
|
|
const date = new Date(dateString);
|
|
if (isNaN(date.getTime())) {
|
|
return "Data inválida";
|
|
}
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const year = date.getFullYear();
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
|
} catch (error) {
|
|
return "Data inválida";
|
|
}
|
|
};
|
|
|
|
|
|
export default function PacientesPage() {
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [convenioFilter, setConvenioFilter] = useState("all");
|
|
const [vipFilter, setVipFilter] = useState("all");
|
|
const [patients, setPatients] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true); // Alterado para true
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// --- Estados de Paginação (ADICIONADOS) ---
|
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
// ---------------------------------------------
|
|
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [patientToDelete, setPatientToDelete] = useState<string | null>(null);
|
|
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false);
|
|
const [patientDetails, setPatientDetails] = useState<any | null>(null);
|
|
|
|
const openDetailsDialog = async (patientId: string) => {
|
|
setDetailsDialogOpen(true);
|
|
setPatientDetails(null);
|
|
try {
|
|
const res = await patientsService.getById(patientId);
|
|
setPatientDetails(res[0]);
|
|
} catch (e: any) {
|
|
setPatientDetails({ error: e?.message || "Erro ao buscar detalhes" });
|
|
}
|
|
};
|
|
|
|
// --- LÓGICA DE BUSCA DE DADOS (ATUALIZADA) ---
|
|
const fetchPacientes = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await patientsService.list();
|
|
const mapped = res.map((p: any) => ({
|
|
id: String(p.id ?? ""),
|
|
nome: p.full_name ?? "",
|
|
telefone: p.phone_mobile ?? p.phone1 ?? "",
|
|
cidade: p.city ?? "",
|
|
estado: p.state ?? "",
|
|
ultimoAtendimento: p.last_visit_at ?? "",
|
|
proximoAtendimento: p.next_appointment_at ?? "",
|
|
vip: Boolean(p.vip ?? false),
|
|
convenio: p.convenio ?? "",
|
|
status: p.status ?? undefined,
|
|
}));
|
|
setPatients(mapped);
|
|
setCurrentPage(1); // Resetar para a primeira página ao carregar
|
|
} catch (e: any) {
|
|
setError(e?.message || "Erro ao buscar pacientes");
|
|
setPatients([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchPacientes();
|
|
}, [fetchPacientes]);
|
|
|
|
const handleDeletePatient = async (patientId: string) => {
|
|
try {
|
|
await patientsService.delete(patientId);
|
|
// Recarrega a lista para refletir a exclusão
|
|
await fetchPacientes();
|
|
} catch (e: any) {
|
|
setError(e?.message || "Erro ao deletar paciente");
|
|
}
|
|
setDeleteDialogOpen(false);
|
|
setPatientToDelete(null);
|
|
};
|
|
|
|
const openDeleteDialog = (patientId: string) => {
|
|
setPatientToDelete(patientId);
|
|
setDeleteDialogOpen(true);
|
|
};
|
|
|
|
const filteredPatients = patients.filter((patient) => {
|
|
const matchesSearch =
|
|
patient.nome?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
patient.telefone?.includes(searchTerm);
|
|
const matchesConvenio =
|
|
convenioFilter === "all" || (patient.convenio ?? "") === convenioFilter;
|
|
const matchesVip =
|
|
vipFilter === "all" ||
|
|
(vipFilter === "vip" && patient.vip) ||
|
|
(vipFilter === "regular" && !patient.vip);
|
|
|
|
return matchesSearch && matchesConvenio && matchesVip;
|
|
});
|
|
|
|
// --- LÓGICA DE PAGINAÇÃO (ADICIONADA) ---
|
|
const totalPages = Math.ceil(filteredPatients.length / itemsPerPage);
|
|
const indexOfLastItem = currentPage * itemsPerPage;
|
|
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
|
const currentItems = filteredPatients.slice(indexOfFirstItem, indexOfLastItem);
|
|
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
|
|
|
|
const goToPrevPage = () => {
|
|
setCurrentPage((prev) => Math.max(1, prev - 1));
|
|
};
|
|
const goToNextPage = () => {
|
|
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
|
|
};
|
|
|
|
const getVisiblePageNumbers = (totalPages: number, currentPage: number) => {
|
|
const pages: number[] = [];
|
|
const maxVisiblePages = 5;
|
|
const halfRange = Math.floor(maxVisiblePages / 2);
|
|
let startPage = Math.max(1, currentPage - halfRange);
|
|
let endPage = Math.min(totalPages, currentPage + halfRange);
|
|
|
|
if (endPage - startPage + 1 < maxVisiblePages) {
|
|
if (endPage === totalPages) {
|
|
startPage = Math.max(1, totalPages - maxVisiblePages + 1);
|
|
}
|
|
if (startPage === 1) {
|
|
endPage = Math.min(totalPages, maxVisiblePages);
|
|
}
|
|
}
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
pages.push(i);
|
|
}
|
|
return pages;
|
|
};
|
|
|
|
const visiblePageNumbers = getVisiblePageNumbers(totalPages, currentPage);
|
|
|
|
const handleItemsPerPageChange = (value: string) => {
|
|
setItemsPerPage(Number(value));
|
|
setCurrentPage(1); // Resetar para a primeira página
|
|
};
|
|
// ---------------------------------------------
|
|
|
|
|
|
return (
|
|
<ManagerLayout>
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-xl md:text-2xl font-bold text-foreground">
|
|
Pacientes
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm md:text-base">
|
|
Gerencie as informações de seus pacientes
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Link href="/manager/pacientes/novo">
|
|
<Button className="w-full md:w-auto bg-green-600 hover:bg-green-700">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Adicionar Novo
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col md:flex-row flex-wrap gap-4 bg-card p-4 rounded-lg border border-border">
|
|
<div className="flex items-center gap-2 w-full md:w-auto">
|
|
<span className="text-sm font-medium text-foreground">
|
|
Convênio
|
|
</span>
|
|
<Select value={convenioFilter} onValueChange={setConvenioFilter}>
|
|
<SelectTrigger className="w-full md:w-40">
|
|
<SelectValue placeholder="Selecione o Convênio" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Todos</SelectItem>
|
|
<SelectItem value="Particular">Particular</SelectItem>
|
|
<SelectItem value="SUS">SUS</SelectItem>
|
|
<SelectItem value="Unimed">Unimed</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 w-full md:w-auto">
|
|
<span className="text-sm font-medium text-foreground">VIP</span>
|
|
<Select value={vipFilter} onValueChange={setVipFilter}>
|
|
<SelectTrigger className="w-full md:w-32">
|
|
<SelectValue placeholder="Selecione" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Todos</SelectItem>
|
|
<SelectItem value="vip">VIP</SelectItem>
|
|
<SelectItem value="regular">Regular</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* SELETOR DE ITENS POR PÁGINA (ADICIONADO) */}
|
|
<div className="flex items-center gap-2 w-full md:w-auto">
|
|
<span className="text-sm font-medium text-foreground">Itens por página</span>
|
|
<Select onValueChange={handleItemsPerPageChange} defaultValue={String(itemsPerPage)}>
|
|
<SelectTrigger className="w-full md:w-32">
|
|
<SelectValue placeholder="Itens por pág." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="5">5 por página</SelectItem>
|
|
<SelectItem value="10">10 por página</SelectItem>
|
|
<SelectItem value="20">20 por página</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Button variant="outline" className="ml-auto w-full md:w-auto">
|
|
<Filter className="w-4 h-4 mr-2" />
|
|
Filtro avançado
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg border border-gray-200">
|
|
<div className="overflow-x-auto">
|
|
{loading ? (
|
|
<div className="p-8 text-center text-gray-500">
|
|
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-green-600" />
|
|
Carregando pacientes...
|
|
</div>
|
|
) : error ? (
|
|
<div className="p-6 text-red-600">{`Erro ao carregar pacientes: ${error}`}</div>
|
|
) : (
|
|
<table className="w-full min-w-[600px]">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="text-left p-4 font-medium text-gray-700">Nome</th>
|
|
<th className="text-left p-4 font-medium text-gray-700">Telefone</th>
|
|
<th className="text-left p-4 font-medium text-gray-700">Cidade</th>
|
|
<th className="text-left p-4 font-medium text-gray-700">Último Atendimento</th>
|
|
<th className="text-right p-4 font-medium text-gray-700">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredPatients.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="p-8 text-center text-gray-500">
|
|
{patients.length === 0 ? "Nenhum paciente cadastrado." : "Nenhum paciente encontrado com os filtros aplicados."}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
// Mapeando `currentItems` em vez de `filteredPatients`
|
|
currentItems.map((patient) => (
|
|
<tr key={patient.id} className="border-b border-gray-100 hover:bg-gray-50">
|
|
<td className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
|
|
<span className="text-gray-600 font-medium text-sm">{patient.nome?.charAt(0) || "?"}</span>
|
|
</div>
|
|
<span className="font-medium text-gray-900">{patient.nome}</span>
|
|
</div>
|
|
</td>
|
|
<td className="p-4 text-gray-600">{patient.telefone}</td>
|
|
<td className="p-4 text-gray-600">{patient.cidade}</td>
|
|
<td className="p-4 text-gray-600">{formatDate(patient.ultimoAtendimento)}</td>
|
|
<td className="p-4 text-right">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<div className="text-blue-600 cursor-pointer inline-block">Ações</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => openDetailsDialog(String(patient.id))}>
|
|
<Eye className="w-4 h-4 mr-2" />
|
|
Ver detalhes
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem asChild>
|
|
<Link href={`/manager/pacientes/${patient.id}/editar`}>
|
|
<Edit className="w-4 h-4 mr-2" />
|
|
Editar
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
<Calendar className="w-4 h-4 mr-2" />
|
|
Marcar consulta
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem className="text-red-600" onClick={() => openDeleteDialog(String(patient.id))}>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Excluir
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* COMPONENTE DE PAGINAÇÃO (ADICIONADO) */}
|
|
{totalPages > 1 && (
|
|
<div className="flex flex-wrap justify-center items-center gap-2 mt-4 p-4 bg-white rounded-lg border border-gray-200 shadow-md">
|
|
<button
|
|
onClick={goToPrevPage}
|
|
disabled={currentPage === 1}
|
|
className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300"
|
|
>
|
|
{"< Anterior"}
|
|
</button>
|
|
|
|
{visiblePageNumbers.map((number) => (
|
|
<button
|
|
key={number}
|
|
onClick={() => paginate(number)}
|
|
className={`px-4 py-2 rounded-md font-medium transition-colors text-sm border border-gray-300 ${currentPage === number
|
|
? "bg-green-600 text-white shadow-md border-green-600"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
{number}
|
|
</button>
|
|
))}
|
|
|
|
<button
|
|
onClick={goToNextPage}
|
|
disabled={currentPage === totalPages}
|
|
className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300"
|
|
>
|
|
{"Próximo >"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* MODAIS (SEM ALTERAÇÃO) */}
|
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Tem certeza que deseja excluir este paciente? Esta ação não pode
|
|
ser desfeita.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() =>
|
|
patientToDelete && handleDeletePatient(patientToDelete)
|
|
}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
>
|
|
Excluir
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Detalhes do Paciente</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{patientDetails === null ? (
|
|
<div className="text-gray-500">Carregando...</div>
|
|
) : patientDetails?.error ? (
|
|
<div className="text-red-600">{patientDetails.error}</div>
|
|
) : (
|
|
<div className="space-y-2 text-left">
|
|
<p><strong>Nome:</strong> {patientDetails.full_name}</p>
|
|
<p><strong>CPF:</strong> {patientDetails.cpf}</p>
|
|
<p><strong>Email:</strong> {patientDetails.email}</p>
|
|
<p><strong>Telefone:</strong> {patientDetails.phone_mobile ?? patientDetails.phone1 ?? patientDetails.phone2 ?? "-"}</p>
|
|
<p><strong>Endereço:</strong> {`${patientDetails.street ?? "-"}, ${patientDetails.neighborhood ?? "-"}, ${patientDetails.city ?? "-"} - ${patientDetails.state ?? "-"}`}</p>
|
|
<p><strong>Criado em:</strong> {formatDate(patientDetails.created_at)}</p>
|
|
</div>
|
|
)}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Fechar</AlertDialogCancel>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</ManagerLayout>
|
|
);
|
|
} |