1075 lines
46 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.

"use client";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { DoctorRegistrationForm } from "@/components/features/forms/doctor-registration-form";
import AvailabilityForm from '@/components/features/forms/availability-form'
import ExceptionForm from '@/components/features/forms/exception-form'
import { listarDisponibilidades, DoctorAvailability, deletarDisponibilidade, listarExcecoes, DoctorException, deletarExcecao } from '@/lib/api'
import { listarMedicos, excluirMedico, buscarMedicos, buscarMedicoPorId, buscarPacientesPorIds, Medico } from "@/lib/api";
import { listAssignmentsForUser } from '@/lib/assignment';
function normalizeMedico(m: any): Medico {
const normalizeSex = (v: any) => {
if (v === undefined) return null;
const s = String(v || '').trim().toLowerCase();
if (!s) return null;
const male = new Set(['m','masc','male','masculino','homem','h','1','mas']);
const female = new Set(['f','fem','female','feminino','mulher','mul','2','fem']);
const other = new Set(['o','outro','other','3','nb','nonbinary','nao binario','não binário']);
if (male.has(s)) return 'masculino';
if (female.has(s)) return 'feminino';
if (other.has(s)) return 'outro';
if (['masculino','feminino','outro'].includes(s)) return s;
return null;
};
const formatBirth = (v: any) => {
if (!v && typeof v !== 'string') return null;
const s = String(v || '').trim();
if (!s) return null;
const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (iso) {
const [, y, mth, d] = iso;
return `${d.padStart(2,'0')}/${mth.padStart(2,'0')}/${y}`;
}
const ddmmyyyy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (ddmmyyyy) return s;
const parsed = new Date(s);
if (!isNaN(parsed.getTime())) {
const d = String(parsed.getDate()).padStart(2,'0');
const mth = String(parsed.getMonth() + 1).padStart(2,'0');
const y = String(parsed.getFullYear());
return `${d}/${mth}/${y}`;
}
return null;
};
return {
id: String(m.id ?? m.uuid ?? ""),
full_name: m.full_name ?? m.nome ?? "", // 👈 Correção: usar full_name como padrão
nome_social: m.nome_social ?? m.social_name ?? null,
cpf: m.cpf ?? "",
rg: m.rg ?? m.document_number ?? null,
sexo: normalizeSex(m.sexo ?? m.sex ?? m.sexualidade ?? null),
data_nascimento: formatBirth(m.data_nascimento ?? m.birth_date ?? m.birthDate ?? null),
telefone: m.telefone ?? m.phone_mobile ?? "",
celular: m.celular ?? m.phone2 ?? null,
contato_emergencia: m.contato_emergencia ?? null,
email: m.email ?? "",
crm: m.crm ?? "",
estado_crm: m.estado_crm ?? m.crm_state ?? null,
rqe: m.rqe ?? null,
formacao_academica: m.formacao_academica ?? [],
curriculo_url: m.curriculo_url ?? null,
especialidade: m.especialidade ?? m.specialty ?? "",
observacoes: m.observacoes ?? m.notes ?? null,
foto_url: m.foto_url ?? null,
tipo_vinculo: m.tipo_vinculo ?? null,
dados_bancarios: m.dados_bancarios ?? null,
agenda_horario: m.agenda_horario ?? null,
valor_consulta: m.valor_consulta ?? null,
active: m.active ?? true,
cep: m.cep ?? "",
city: m.city ?? "",
complement: m.complement ?? null,
neighborhood: m.neighborhood ?? "",
number: m.number ?? "",
phone2: m.phone2 ?? null,
state: m.state ?? "",
street: m.street ?? "",
created_at: m.created_at ?? null,
created_by: m.created_by ?? null,
updated_at: m.updated_at ?? null,
updated_by: m.updated_by ?? null,
user_id: m.user_id ?? null,
};
}
function translateWeekday(w?: string) {
if (!w) return '';
const key = w.toString().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, '');
const map: Record<string, string> = {
'segunda': 'Segunda',
'terca': 'Terça',
'quarta': 'Quarta',
'quinta': 'Quinta',
'sexta': 'Sexta',
'sabado': 'Sábado',
'domingo': 'Domingo',
'monday': 'Segunda',
'tuesday': 'Terça',
'wednesday': 'Quarta',
'thursday': 'Quinta',
'friday': 'Sexta',
'saturday': 'Sábado',
'sunday': 'Domingo',
};
return map[key] ?? w;
}
export default function DoutoresPage() {
const [doctors, setDoctors] = useState<Medico[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [viewingDoctor, setViewingDoctor] = useState<Medico | null>(null);
const [assignedDialogOpen, setAssignedDialogOpen] = useState(false);
const [assignedPatients, setAssignedPatients] = useState<any[]>([]);
const [assignedLoading, setAssignedLoading] = useState(false);
const [assignedDoctor, setAssignedDoctor] = useState<Medico | null>(null);
const [availabilityOpenFor, setAvailabilityOpenFor] = useState<Medico | null>(null);
const [availabilityViewingFor, setAvailabilityViewingFor] = useState<Medico | null>(null);
const [availabilities, setAvailabilities] = useState<DoctorAvailability[]>([]);
const [availabilitiesForCreate, setAvailabilitiesForCreate] = useState<DoctorAvailability[]>([]);
const [availLoading, setAvailLoading] = useState(false);
const [editingAvailability, setEditingAvailability] = useState<DoctorAvailability | null>(null);
const [exceptions, setExceptions] = useState<DoctorException[]>([]);
const [exceptionsLoading, setExceptionsLoading] = useState(false);
const [exceptionViewingFor, setExceptionViewingFor] = useState<Medico | null>(null);
const [exceptionOpenFor, setExceptionOpenFor] = useState<Medico | null>(null);
const [searchResults, setSearchResults] = useState<Medico[]>([]);
const [searchMode, setSearchMode] = useState(false);
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null);
// Paginação
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// NOVO: Ordenação e filtros
const [sortBy, setSortBy] = useState<"name_asc" | "name_desc" | "recent" | "oldest">("name_asc");
const [stateFilter, setStateFilter] = useState<string>("");
const [cityFilter, setCityFilter] = useState<string>("");
const [specialtyFilter, setSpecialtyFilter] = useState<string>("");
async function load() {
setLoading(true);
try {
const list = await listarMedicos({ limit: 50 });
const normalized = (list ?? []).map(normalizeMedico);
console.log('🏥 Médicos carregados:', normalized);
setDoctors(normalized);
} finally {
setLoading(false);
}
}
// Função para detectar se é um UUID válido
function isValidUUID(str: string): boolean {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
}
// Função para buscar médicos no servidor
async function handleBuscarServidor(termoBusca?: string) {
const termo = (termoBusca || search).trim();
if (!termo) {
setSearchMode(false);
setSearchResults([]);
return;
}
console.log('🔍 Buscando médico por:', termo);
setLoading(true);
try {
// Se parece com UUID, tenta busca direta por ID
if (isValidUUID(termo)) {
console.log('📋 Detectado UUID, buscando por ID...');
try {
const medico = await buscarMedicoPorId(termo);
const normalizado = normalizeMedico(medico);
console.log('✅ Médico encontrado por ID:', normalizado);
setSearchResults([normalizado]);
setSearchMode(true);
return;
} catch (error) {
console.log('❌ Não encontrado por ID, tentando busca geral...');
}
}
// Busca geral
const resultados = await buscarMedicos(termo);
const normalizados = resultados.map(normalizeMedico);
console.log('📋 Resultados da busca geral:', normalizados);
setSearchResults(normalizados);
setSearchMode(true);
} catch (error) {
console.error('❌ Erro na busca:', error);
setSearchResults([]);
setSearchMode(true);
} finally {
setLoading(false);
}
}
// Handler para mudança no campo de busca com busca automática
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
const valor = e.target.value;
setSearch(valor);
// Limpa o timeout anterior se existir
if (searchTimeout) {
clearTimeout(searchTimeout);
}
// Se limpar a busca, volta ao modo normal
if (!valor.trim()) {
setSearchMode(false);
setSearchResults([]);
return;
}
// Busca automática com debounce ajustável
// Para IDs (UUID) longos, faz busca no servidor
// Para busca parcial, usa apenas filtro local
const isLikeUUID = valor.includes('-') && valor.length > 10;
const shouldSearchServer = isLikeUUID || valor.length >= 3;
if (shouldSearchServer) {
const debounceTime = isLikeUUID ? 300 : 500;
const newTimeout = setTimeout(() => {
handleBuscarServidor(valor);
}, debounceTime);
setSearchTimeout(newTimeout);
} else {
// Para termos curtos, apenas usa filtro local
setSearchMode(false);
setSearchResults([]);
}
}
// Handler para Enter no campo de busca
function handleSearchKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
e.preventDefault();
handleBuscarServidor();
}
}
// Handler para o botão de busca
function handleClickBuscar() {
void handleBuscarServidor();
}
useEffect(() => {
load();
}, []);
// Limpa o timeout quando o componente é desmontado
useEffect(() => {
return () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
};
}, [searchTimeout]);
// NOVO: Opções dinâmicas
const stateOptions = useMemo(
() =>
Array.from(
new Set((doctors || []).map((d) => (d.state || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
[doctors],
);
const cityOptions = useMemo(() => {
const base = (doctors || []).filter((d) => !stateFilter || String(d.state) === stateFilter);
return Array.from(
new Set(base.map((d) => (d.city || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
}, [doctors, stateFilter]);
const specialtyOptions = useMemo(
() =>
Array.from(
new Set((doctors || []).map((d) => (d.especialidade || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
[doctors],
);
// NOVO: Índice para ordenação por "tempo" (ordem de carregamento)
const indexById = useMemo(() => {
const map = new Map<string, number>();
(doctors || []).forEach((d, i) => map.set(String(d.id), i));
return map;
}, [doctors]);
// Lista de médicos a exibir com busca + filtros + ordenação
const displayedDoctors = useMemo(() => {
console.log('🔍 Filtro - search:', search, 'searchMode:', searchMode, 'doctors:', doctors.length, 'searchResults:', searchResults.length);
const q = search.toLowerCase().trim();
const qDigits = q.replace(/\D/g, "");
const sourceList = searchMode ? searchResults : doctors;
// 1) Busca
const afterSearch = !q
? sourceList
: sourceList.filter((d) => {
const byName = (d.full_name || "").toLowerCase().includes(q);
const byCrm = qDigits.length >= 3 && (d.crm || "").replace(/\D/g, "").includes(qDigits);
const byId = (d.id || "").toLowerCase().includes(q);
const byEmail = (d.email || "").toLowerCase().includes(q);
const byEspecialidade = (d.especialidade || "").toLowerCase().includes(q);
const match = byName || byCrm || byId || byEmail || byEspecialidade;
if (match) console.log('✅ Match encontrado:', d.full_name, d.id);
return match;
});
// 2) Filtros de localização e especialidade
const afterFilters = afterSearch.filter((d) => {
if (stateFilter && String(d.state) !== stateFilter) return false;
if (cityFilter && String(d.city) !== cityFilter) return false;
if (specialtyFilter && String(d.especialidade) !== specialtyFilter) return false;
return true;
});
// 3) Ordenação
const sorted = [...afterFilters];
if (sortBy === "name_asc" || sortBy === "name_desc") {
sorted.sort((a, b) => {
const an = (a.full_name || "").trim();
const bn = (b.full_name || "").trim();
const cmp = an.localeCompare(bn, "pt-BR", { sensitivity: "base" });
return sortBy === "name_asc" ? cmp : -cmp;
});
} else if (sortBy === "recent" || sortBy === "oldest") {
sorted.sort((a, b) => {
const ia = indexById.get(String(a.id)) ?? 0;
const ib = indexById.get(String(b.id)) ?? 0;
return sortBy === "recent" ? ia - ib : ib - ia;
});
}
console.log('🔍 Resultados filtrados:', sorted.length);
return sorted;
}, [doctors, search, searchMode, searchResults, stateFilter, cityFilter, specialtyFilter, sortBy, indexById]);
// Dados paginados
const paginatedDoctors = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return displayedDoctors.slice(startIndex, endIndex);
}, [displayedDoctors, currentPage, itemsPerPage]);
const totalPages = Math.ceil(displayedDoctors.length / itemsPerPage);
// Reset página ao mudar busca/filtros/ordenação
useEffect(() => {
setCurrentPage(1);
}, [search, itemsPerPage, searchMode, stateFilter, cityFilter, specialtyFilter, sortBy]);
function handleAdd() {
setEditingId(null);
setShowForm(true);
}
function handleEdit(id: string) {
setEditingId(id);
setShowForm(true);
}
function handleView(doctor: Medico) {
setViewingDoctor(doctor);
}
async function handleViewAssignedPatients(doctor: Medico) {
setAssignedDoctor(doctor);
setAssignedLoading(true);
setAssignedPatients([]);
try {
const assigns = await listAssignmentsForUser(String(doctor.user_id ?? doctor.id));
const patientIds = Array.isArray(assigns) ? assigns.map((a:any) => String(a.patient_id)).filter(Boolean) : [];
if (patientIds.length) {
const patients = await buscarPacientesPorIds(patientIds);
setAssignedPatients(patients || []);
} else {
setAssignedPatients([]);
}
} catch (e) {
console.warn('[DoutoresPage] erro ao carregar pacientes atribuídos:', e);
setAssignedPatients([]);
} finally {
setAssignedLoading(false);
setAssignedDialogOpen(true);
}
}
async function reloadAvailabilities(doctorId?: string) {
if (!doctorId) return;
setAvailLoading(true);
try {
const list = await listarDisponibilidades({ doctorId, active: true });
setAvailabilities(list || []);
} catch (e) {
console.warn('Erro ao recarregar disponibilidades:', e);
} finally {
setAvailLoading(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Excluir este médico?")) return;
await excluirMedico(id);
await load();
}
function handleSaved(savedDoctor?: Medico) {
setShowForm(false);
if (savedDoctor) {
const normalized = normalizeMedico(savedDoctor);
setDoctors((prev) => {
const i = prev.findIndex((d) => String(d.id) === String(normalized.id));
if (i < 0) {
// Novo médico → adiciona no topo
return [normalized, ...prev];
} else {
// Médico editado → substitui na lista
const clone = [...prev];
clone[i] = normalized;
return clone;
}
});
} else {
// fallback → recarrega tudo
load();
}
}
if (showForm) {
return (
<div className="space-y-6 p-6 bg-background">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => setShowForm(false)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-bold">{editingId ? "Editar Médico" : "Novo Médico"}</h1>
</div>
<DoctorRegistrationForm
inline
mode={editingId ? "edit" : "create"}
doctorId={editingId}
onSaved={handleSaved}
onClose={() => setShowForm(false)}
/>
</div>
);
}
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold">Médicos</h1>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">Gerencie os médicos da sua clínica</p>
</div>
<Button onClick={handleAdd} disabled={loading} className="w-full sm:w-auto gap-2 text-sm sm:text-base">
<Plus className="h-4 w-4" />
Novo Médico
</Button>
</div>
{/* Filtros e busca - Responsivos */}
<div className="flex flex-col gap-2 sm:gap-3">
{/* Linha 1: Busca + Botão buscar */}
<div className="flex gap-2">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8 w-full text-xs sm:text-sm"
placeholder="ID, nome, CRM…"
value={search}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
/>
</div>
<Button
variant="secondary"
onClick={() => void handleBuscarServidor()}
disabled={loading}
className="hover:bg-primary hover:text-white text-xs sm:text-sm px-2 sm:px-4"
>
Buscar
</Button>
{searchMode && (
<Button
variant="ghost"
onClick={() => {
setSearch("");
setSearchMode(false);
setSearchResults([]);
}}
className="text-xs sm:text-sm"
>
Limpar
</Button>
)}
</div>
{/* Linha 2: Filtros */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
<select
aria-label="Ordenar por"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="name_asc">Nome AZ</option>
<option value="name_desc">Nome ZA</option>
<option value="recent">Recentes</option>
<option value="oldest">Antigos</option>
</select>
<select
aria-label="Filtrar por especialidade"
value={specialtyFilter}
onChange={(e) => setSpecialtyFilter(e.target.value)}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Todas espec.</option>
{specialtyOptions.map((sp) => (
<option key={sp} value={sp}>{sp}</option>
))}
</select>
<select
aria-label="Filtrar por estado"
value={stateFilter}
onChange={(e) => { setStateFilter(e.target.value); setCityFilter(""); }}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Todos UF</option>
{stateOptions.map((uf) => (
<option key={uf} value={uf}>{uf}</option>
))}
</select>
<select
aria-label="Filtrar por cidade"
value={cityFilter}
onChange={(e) => setCityFilter(e.target.value)}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Todas cidades</option>
{cityOptions.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
{/* Tabela para desktop (md+) */}
<div className="hidden md:block border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-primary hover:bg-primary">
<TableHead className="text-primary-foreground text-xs sm:text-sm">Nome</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Especialidade</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">CRM</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Contato</TableHead>
<TableHead className="w-[100px] text-primary-foreground text-xs sm:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground text-xs sm:text-sm">
Carregando
</TableCell>
</TableRow>
) : paginatedDoctors.length > 0 ? (
paginatedDoctors.map((doctor) => (
<TableRow key={doctor.id}>
<TableCell className="font-medium">{doctor.full_name}</TableCell>
<TableCell>
<Badge variant="outline">{doctor.especialidade}</Badge>
</TableCell>
<TableCell>{doctor.crm}</TableCell>
<TableCell>
<div className="flex flex-col">
<span>{doctor.email}</span>
<span className="text-sm text-muted-foreground">{doctor.telefone}</span>
</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Abrir menu</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(doctor)}>
<Eye className="mr-2 h-4 w-4" />
Ver
</DropdownMenuItem>
{/* Ver pacientes atribuídos ao médico */}
<DropdownMenuItem onClick={() => handleViewAssignedPatients(doctor)}>
<Users className="mr-2 h-4 w-4" />
Ver pacientes atribuídos
</DropdownMenuItem>
<DropdownMenuItem onClick={async () => {
try {
const list = await listarDisponibilidades({ doctorId: doctor.id, active: true });
setAvailabilitiesForCreate(list || []);
setAvailabilityOpenFor(doctor);
} catch (e) {
console.warn('Erro ao carregar disponibilidades:', e);
setAvailabilitiesForCreate([]);
setAvailabilityOpenFor(doctor);
}
}}>
<Plus className="mr-2 h-4 w-4" />
Criar disponibilidade
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setExceptionOpenFor(doctor)}>
<Plus className="mr-2 h-4 w-4" />
Criar exceção
</DropdownMenuItem>
<DropdownMenuItem onClick={async () => {
setAvailLoading(true);
try {
const list = await listarDisponibilidades({ doctorId: doctor.id, active: true });
setAvailabilities(list || []);
setAvailabilityViewingFor(doctor);
} catch (e) {
console.warn('Erro ao listar disponibilidades:', e);
} finally {
setAvailLoading(false);
}
}}>
<Users className="mr-2 h-4 w-4" />
Ver disponibilidades
</DropdownMenuItem>
<DropdownMenuItem onClick={async () => {
setExceptionsLoading(true);
try {
const list = await listarExcecoes({ doctorId: doctor.id });
setExceptions(list || []);
setExceptionViewingFor(doctor);
} catch (e) {
console.warn('Erro ao listar exceções:', e);
} finally {
setExceptionsLoading(false);
}
}}>
<Users className="mr-2 h-4 w-4" />
Ver exceções
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(String(doctor.id))}>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(String(doctor.id))} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground text-xs sm:text-sm">
Nenhum médico encontrado
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Cards para mobile (md: hidden) */}
<div className="md:hidden space-y-2">
{loading ? (
<div className="text-center text-xs sm:text-sm text-muted-foreground">Carregando</div>
) : paginatedDoctors.length > 0 ? (
paginatedDoctors.map((doctor) => (
<div key={doctor.id} className="bg-card p-3 sm:p-4 rounded-lg border border-border shadow-sm space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base truncate">{doctor.full_name}</p>
<p className="text-xs sm:text-sm text-muted-foreground truncate">{doctor.crm || "Sem CRM"}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-6 w-6 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors flex-shrink-0">
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs sm:text-sm">
<DropdownMenuItem onClick={() => handleView(doctor)}>
<Eye className="mr-2 h-3 w-3" />
Ver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleViewAssignedPatients(doctor)}>
<Users className="mr-2 h-3 w-3" />
Pacientes
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(String(doctor.id))}>
<Edit className="mr-2 h-3 w-3" />
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(String(doctor.id))} className="text-destructive">
<Trash2 className="mr-2 h-3 w-3" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="grid grid-cols-2 gap-2 text-[10px] sm:text-xs">
<div>
<span className="text-muted-foreground">Espec.:</span> <span className="font-medium">{doctor.especialidade || "—"}</span>
</div>
<div>
<span className="text-muted-foreground">Email:</span> <span className="font-medium truncate">{doctor.email}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground">Tel.:</span> <span className="font-medium">{doctor.telefone || "—"}</span>
</div>
</div>
</div>
))
) : (
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4">
Nenhum médico encontrado
</div>
)}
</div>
{/* Controles de paginação - Responsivos */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 text-xs sm:text-sm">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-muted-foreground">Itens por página:</span>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-muted-foreground text-xs sm:text-sm">
Mostrando {paginatedDoctors.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} a{" "}
{Math.min(currentPage * itemsPerPage, displayedDoctors.length)} de {displayedDoctors.length}
</span>
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-center sm:justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Primeira</span>
<span className="sm:hidden">1ª</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">«</span>
</Button>
<span className="text-muted-foreground text-xs sm:text-sm">
Pág {currentPage} de {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages || totalPages === 0}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">»</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages || totalPages === 0}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Última</span>
<span className="sm:hidden">Últ</span>
</Button>
</div>
</div>
{viewingDoctor && (
<Dialog open={!!viewingDoctor} onOpenChange={() => setViewingDoctor(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Detalhes do Médico</DialogTitle>
<DialogDescription>
Informações detalhadas de {viewingDoctor?.full_name}.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Nome</Label>
<span className="col-span-1 sm:col-span-3 font-medium">{viewingDoctor?.full_name}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Especialidade</Label>
<span className="col-span-1 sm:col-span-3">
<Badge variant="outline">{viewingDoctor?.especialidade}</Badge>
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">CRM</Label>
<span className="col-span-1 sm:col-span-3">{viewingDoctor?.crm}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Email</Label>
<span className="col-span-1 sm:col-span-3">{viewingDoctor?.email}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Telefone</Label>
<span className="col-span-1 sm:col-span-3">{viewingDoctor?.telefone}</span>
</div>
</div>
<DialogFooter>
<Button onClick={() => setViewingDoctor(null)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Availability modal */}
{availabilityOpenFor && (
<AvailabilityForm
open={!!availabilityOpenFor}
onOpenChange={(open) => { if (!open) setAvailabilityOpenFor(null); }}
doctorId={availabilityOpenFor?.id}
existingAvailabilities={availabilitiesForCreate}
onSaved={(saved) => { console.log('Disponibilidade salva', saved); setAvailabilityOpenFor(null); /* optionally reload list */ reloadAvailabilities(availabilityOpenFor?.id); }}
/>
)}
{exceptionOpenFor && (
<ExceptionForm
open={!!exceptionOpenFor}
onOpenChange={(open) => { if (!open) setExceptionOpenFor(null); }}
doctorId={exceptionOpenFor?.id}
onSaved={(saved) => { console.log('Exceção criada', saved); setExceptionOpenFor(null); /* reload availabilities in case a full-day block affects listing */ reloadAvailabilities(exceptionOpenFor?.id); }}
/>
)}
{/* Edit availability modal */}
{editingAvailability && (
<AvailabilityForm
open={!!editingAvailability}
onOpenChange={(open) => { if (!open) setEditingAvailability(null); }}
doctorId={editingAvailability?.doctor_id ?? availabilityViewingFor?.id}
availability={editingAvailability}
mode="edit"
existingAvailabilities={availabilities}
onSaved={(saved) => { console.log('Disponibilidade atualizada', saved); setEditingAvailability(null); reloadAvailabilities(editingAvailability?.doctor_id ?? availabilityViewingFor?.id); }}
/>
)}
{/* Ver disponibilidades dialog */}
{availabilityViewingFor && (
<Dialog open={!!availabilityViewingFor} onOpenChange={(open) => { if (!open) { setAvailabilityViewingFor(null); setAvailabilities([]); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Disponibilidades - {availabilityViewingFor.full_name}</DialogTitle>
<DialogDescription>
Lista de disponibilidades públicas do médico selecionado.
</DialogDescription>
</DialogHeader>
<div className="py-4">
{availLoading ? (
<div>Carregando disponibilidades</div>
) : availabilities && availabilities.length ? (
<div className="space-y-2">
{availabilities
.sort((a, b) => {
// Define a ordem dos dias da semana (Segunda a Domingo)
const weekdayOrder: Record<string, number> = {
'segunda': 1, 'segunda-feira': 1, 'mon': 1, 'monday': 1, '1': 1,
'terca': 2, 'terça': 2, 'terça-feira': 2, 'tue': 2, 'tuesday': 2, '2': 2,
'quarta': 3, 'quarta-feira': 3, 'wed': 3, 'wednesday': 3, '3': 3,
'quinta': 4, 'quinta-feira': 4, 'thu': 4, 'thursday': 4, '4': 4,
'sexta': 5, 'sexta-feira': 5, 'fri': 5, 'friday': 5, '5': 5,
'sabado': 6, 'sábado': 6, 'sat': 6, 'saturday': 6, '6': 6,
'domingo': 7, 'dom': 7, 'sun': 7, 'sunday': 7, '0': 7, '7': 7
};
const getWeekdayOrder = (weekday: any) => {
if (typeof weekday === 'number') return weekday === 0 ? 7 : weekday;
const normalized = String(weekday).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
return weekdayOrder[normalized] || 999;
};
return getWeekdayOrder(a.weekday) - getWeekdayOrder(b.weekday);
})
.map((a) => (
<div key={String(a.id)} className="p-2 border rounded flex justify-between items-start">
<div>
<div className="font-medium">{translateWeekday(a.weekday)} {a.start_time} {a.end_time}</div>
<div className="text-xs text-muted-foreground">Duração: {a.slot_minutes} min Tipo: {a.appointment_type || '—'} {a.active ? 'Ativa' : 'Inativa'}</div>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setEditingAvailability(a)} className="hover:bg-muted hover:text-foreground">Editar</Button>
<Button size="sm" variant="destructive" onClick={async () => {
if (!confirm('Excluir esta disponibilidade?')) return;
try {
await deletarDisponibilidade(String(a.id));
// reload
reloadAvailabilities(availabilityViewingFor?.id ?? a.doctor_id);
} catch (e) {
console.warn('Erro ao deletar disponibilidade:', e);
alert((e as any)?.message || 'Erro ao deletar disponibilidade');
}
}}>Excluir</Button>
</div>
</div>
))}
</div>
) : (
<div>Nenhuma disponibilidade encontrada.</div>
)}
</div>
<DialogFooter>
<Button onClick={() => { setAvailabilityViewingFor(null); setAvailabilities([]); }}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Ver exceções dialog */}
{exceptionViewingFor && (
<Dialog open={!!exceptionViewingFor} onOpenChange={(open) => { if (!open) { setExceptionViewingFor(null); setExceptions([]); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Exceções - {exceptionViewingFor.full_name}</DialogTitle>
<DialogDescription>
Lista de exceções (bloqueios/liberações) do médico selecionado.
</DialogDescription>
</DialogHeader>
<div className="py-4">
{exceptionsLoading ? (
<div>Carregando exceções</div>
) : exceptions && exceptions.length ? (
<div className="space-y-2">
{exceptions.map((ex) => (
<div key={String(ex.id)} className="p-2 border rounded flex justify-between items-start">
<div>
<div className="font-medium">{(() => {
try {
const [y, m, d] = String(ex.date).split('-');
return `${d}/${m}/${y}`;
} catch (e) {
return ex.date;
}
})()} {ex.start_time ? `${ex.start_time}` : ''} {ex.end_time ? `${ex.end_time}` : ''}</div>
<div className="text-xs text-muted-foreground">Tipo: {ex.kind} Motivo: {ex.reason || '—'}</div>
</div>
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={async () => {
if (!confirm('Excluir esta exceção?')) return;
try {
await deletarExcecao(String(ex.id));
const list = await listarExcecoes({ doctorId: exceptionViewingFor?.id });
setExceptions(list || []);
} catch (e) {
console.warn('Erro ao deletar exceção:', e);
alert((e as any)?.message || 'Erro ao deletar exceção');
}
}}>Excluir</Button>
</div>
</div>
))}
</div>
) : (
<div>Nenhuma exceção encontrada.</div>
)}
</div>
<DialogFooter>
<Button onClick={() => { setExceptionViewingFor(null); setExceptions([]); }}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<div className="text-sm text-muted-foreground">
{searchMode ? 'Resultado(s) da busca' : `Total de ${doctors.length} médico(s)`}
</div>
{/* Dialog para pacientes atribuídos */}
<Dialog open={assignedDialogOpen} onOpenChange={(open) => { if (!open) { setAssignedDialogOpen(false); setAssignedPatients([]); setAssignedDoctor(null); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Pacientes atribuídos{assignedDoctor ? ` - ${assignedDoctor.full_name}` : ''}</DialogTitle>
<DialogDescription>
Lista de pacientes atribuídos a este médico.
</DialogDescription>
</DialogHeader>
<div className="py-4">
{assignedLoading ? (
<div>Carregando pacientes...</div>
) : assignedPatients && assignedPatients.length ? (
<div className="space-y-2">
{assignedPatients.map((p:any) => (
<div key={p.id} className="p-2 border rounded">
<div className="font-medium">{p.full_name ?? p.nome ?? p.name ?? '(sem nome)'}</div>
<div className="text-xs text-muted-foreground">ID: {p.id} {p.cpf ? `• CPF: ${p.cpf}` : ''}</div>
</div>
))}
</div>
) : (
<div>Nenhum paciente atribuído encontrado.</div>
)}
</div>
<DialogFooter>
<Button onClick={() => setAssignedDialogOpen(false)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}