riseup-squad18/src/components/secretaria/SecretaryDoctorList.tsx
guisilvagomes 3a3e4c1f55 fix: corrige exibição de nomes de médicos em laudos e relatórios
- Adiciona resolução de IDs para nomes nos laudos do painel do paciente
- Implementa dropdown de médicos nos formulários de relatórios
- Corrige API PATCH para retornar dados atualizados (header Prefer)
- Adiciona fallback para buscar relatório após update
- Limpa cache de nomes ao atualizar relatórios
- Trata dados legados (nomes diretos vs UUIDs)
- Exibe 'Médico não cadastrado' para IDs inexistentes
2025-11-05 18:25:13 -03:00

715 lines
26 KiB
TypeScript

import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { Search, Plus, Eye, Calendar, Edit, Trash2, X } from "lucide-react";
import {
doctorService,
userService,
type Doctor,
type CrmUF,
} from "../../services";
import type { CreateDoctorInput } from "../../services/users/types";
interface DoctorFormData {
id?: string;
full_name: string;
cpf: string;
email: string;
phone_mobile: string;
crm: string;
crm_uf: string;
specialty: string;
birth_date?: string;
}
const UF_OPTIONS = [
"AC",
"AL",
"AP",
"AM",
"BA",
"CE",
"DF",
"ES",
"GO",
"MA",
"MT",
"MS",
"MG",
"PA",
"PB",
"PR",
"PE",
"PI",
"RJ",
"RN",
"RS",
"RO",
"RR",
"SC",
"SP",
"SE",
"TO",
];
// Helper para formatar nome do médico sem duplicar "Dr."
const formatDoctorName = (fullName: string): string => {
const name = fullName.trim();
// Verifica se já começa com Dr. ou Dr (case insensitive)
if (/^dr\.?\s/i.test(name)) {
return name;
}
return `Dr. ${name}`;
};
// Função para formatar CPF: XXX.XXX.XXX-XX
const formatCPF = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length === 0) return "";
if (numbers.length <= 3) return numbers;
if (numbers.length <= 6) return `${numbers.slice(0, 3)}.${numbers.slice(3)}`;
if (numbers.length <= 9)
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(6)}`;
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(
6,
9
)}-${numbers.slice(9, 11)}`;
};
// Função para formatar telefone: (XX) XXXXX-XXXX
const formatPhone = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length === 0) return "";
if (numbers.length <= 2) return `(${numbers}`;
if (numbers.length <= 7)
return `(${numbers.slice(0, 2)}) ${numbers.slice(2)}`;
if (numbers.length <= 11)
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
7
)}`;
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
7,
11
)}`;
};
export function SecretaryDoctorList({
onOpenSchedule,
}: {
onOpenSchedule?: (doctorId: string) => void;
}) {
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [specialtyFilter, setSpecialtyFilter] = useState("Todas");
// Modal states
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [formData, setFormData] = useState<DoctorFormData>({
full_name: "",
cpf: "",
email: "",
phone_mobile: "",
crm: "",
crm_uf: "",
specialty: "",
});
const [showViewModal, setShowViewModal] = useState(false);
const [selectedDoctor, setSelectedDoctor] = useState<Doctor | null>(null);
const loadDoctors = async () => {
setLoading(true);
try {
const data = await doctorService.list();
setDoctors(Array.isArray(data) ? data : []);
} catch (error) {
console.error("Erro ao carregar médicos:", error);
toast.error("Erro ao carregar médicos");
setDoctors([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDoctors();
}, []);
// Função de filtro
const filteredDoctors = doctors.filter((doctor) => {
// Filtro de busca por nome, CRM ou especialidade
const searchLower = searchTerm.toLowerCase();
const matchesSearch =
!searchTerm ||
doctor.full_name?.toLowerCase().includes(searchLower) ||
doctor.crm?.includes(searchTerm) ||
doctor.specialty?.toLowerCase().includes(searchLower);
// Filtro de especialidade
const matchesSpecialty =
specialtyFilter === "Todas" || doctor.specialty === specialtyFilter;
return matchesSearch && matchesSpecialty;
});
const handleSearch = () => {
loadDoctors();
};
const handleClear = () => {
setSearchTerm("");
setSpecialtyFilter("Todas");
loadDoctors();
};
const handleNewDoctor = () => {
setModalMode("create");
setFormData({
full_name: "",
cpf: "",
email: "",
phone_mobile: "",
crm: "",
crm_uf: "",
specialty: "",
});
setShowModal(true);
};
const handleEditDoctor = (doctor: Doctor) => {
setModalMode("edit");
setFormData({
id: doctor.id,
full_name: doctor.full_name || "",
cpf: doctor.cpf || "",
email: doctor.email || "",
phone_mobile: doctor.phone_mobile || "",
crm: doctor.crm || "",
crm_uf: doctor.crm_uf || "",
specialty: doctor.specialty || "",
birth_date: doctor.birth_date || "",
});
setShowModal(true);
};
const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
if (modalMode === "edit" && formData.id) {
// Para edição, usa o endpoint antigo (PATCH /doctors/:id)
// Remove formatação de telefone e CPF
const cleanPhone = formData.phone_mobile
? formData.phone_mobile.replace(/\D/g, "")
: undefined;
const cleanCpf = formData.cpf.replace(/\D/g, "");
const doctorData = {
full_name: formData.full_name,
cpf: cleanCpf,
email: formData.email,
phone_mobile: cleanPhone,
crm: formData.crm,
crm_uf: formData.crm_uf as CrmUF,
specialty: formData.specialty,
birth_date: formData.birth_date || null,
};
await doctorService.update(formData.id, doctorData);
toast.success("Médico atualizado com sucesso!");
} else {
// Para criação, usa o novo endpoint create-doctor com validações completas
// Remove formatação de telefone e CPF
const cleanPhone = formData.phone_mobile
? formData.phone_mobile.replace(/\D/g, "")
: undefined;
const cleanCpf = formData.cpf.replace(/\D/g, "");
const createData: CreateDoctorInput = {
email: formData.email,
full_name: formData.full_name,
cpf: cleanCpf,
crm: formData.crm,
crm_uf: formData.crm_uf as CrmUF,
specialty: formData.specialty || undefined,
phone_mobile: cleanPhone,
};
await userService.createDoctor(createData);
toast.success("Médico cadastrado com sucesso!");
}
setShowModal(false);
loadDoctors();
} catch (error) {
console.error("Erro ao salvar médico:", error);
toast.error("Erro ao salvar médico");
} finally {
setLoading(false);
}
};
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const getAvatarColor = (index: number) => {
const colors = [
"bg-red-500",
"bg-green-500",
"bg-blue-500",
"bg-yellow-500",
"bg-purple-500",
"bg-pink-500",
"bg-indigo-500",
"bg-teal-500",
];
return colors[index % colors.length];
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Médicos</h1>
<p className="text-gray-600 mt-1">Gerencie os médicos cadastrados</p>
</div>
<button
onClick={handleNewDoctor}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4" />
Novo Médico
</button>
</div>
{/* Search and Filters */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar médicos por nome ou CRM..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<button
onClick={handleSearch}
className="px-6 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Buscar
</button>
<button
onClick={handleClear}
className="px-6 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Limpar
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Especialidade:</span>
<select
value={specialtyFilter}
onChange={(e) => setSpecialtyFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todas</option>
<option>Cardiologia</option>
<option>Dermatologia</option>
<option>Ortopedia</option>
<option>Pediatria</option>
<option>Psiquiatria</option>
<option>Ginecologia</option>
</select>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Médico
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Especialidade
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
CRM
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Próxima Disponível
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{loading ? (
<tr>
<td
colSpan={5}
className="px-6 py-12 text-center text-gray-500"
>
Carregando médicos...
</td>
</tr>
) : filteredDoctors.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-6 py-12 text-center text-gray-500"
>
{searchTerm || specialtyFilter !== "Todas"
? "Nenhum médico encontrado com esses filtros"
: "Nenhum médico encontrado"}
</td>
</tr>
) : (
filteredDoctors.map((doctor, index) => (
<tr
key={doctor.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-full ${getAvatarColor(
index
)} flex items-center justify-center text-white font-semibold text-sm`}
>
{getInitials(doctor.full_name || "")}
</div>
<div>
<p className="text-sm font-medium text-gray-900">
{formatDoctorName(doctor.full_name)}
</p>
<p className="text-sm text-gray-500">{doctor.email}</p>
<p className="text-sm text-gray-500">
{doctor.phone_mobile}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{doctor.specialty || "—"}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{doctor.crm || "—"}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{/* TODO: Buscar próxima disponibilidade */}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button
onClick={() => {
setSelectedDoctor(doctor);
setShowViewModal(true);
}}
title="Visualizar"
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Eye className="h-4 w-4" />
</button>
<button
onClick={() => {
// Prefer callback from parent to switch tab; fallback to sessionStorage
if (onOpenSchedule) {
onOpenSchedule(doctor.id);
} else {
sessionStorage.setItem(
"selectedDoctorForSchedule",
doctor.id
);
// dispatch a custom event to inform parent (optional)
window.dispatchEvent(
new CustomEvent("open-doctor-schedule")
);
}
}}
title="Gerenciar agenda"
className="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
>
<Calendar className="h-4 w-4" />
</button>
<button
onClick={() => handleEditDoctor(doctor)}
title="Editar"
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
title="Deletar"
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Modal de Formulário */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
{modalMode === "create" ? "Novo Médico" : "Editar Médico"}
</h2>
<button
onClick={() => setShowModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Form Content */}
<div className="flex-1 overflow-y-auto p-6">
<form onSubmit={handleFormSubmit} className="space-y-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nome Completo *
</label>
<input
type="text"
value={formData.full_name}
onChange={(e) =>
setFormData({ ...formData, full_name: e.target.value })
}
className="form-input"
required
placeholder="Dr. João Silva"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
CPF *
</label>
<input
type="text"
value={formData.cpf}
onChange={(e) =>
setFormData({
...formData,
cpf: formatCPF(e.target.value),
})
}
className="form-input"
required
maxLength={14}
placeholder="000.000.000-00"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data de Nascimento
</label>
<input
type="date"
value={formData.birth_date || ""}
onChange={(e) =>
setFormData({
...formData,
birth_date: e.target.value,
})
}
className="form-input"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
CRM *
</label>
<input
type="text"
value={formData.crm}
onChange={(e) =>
setFormData({ ...formData, crm: e.target.value })
}
className="form-input"
required
placeholder="123456"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
UF do CRM *
</label>
<select
value={formData.crm_uf}
onChange={(e) =>
setFormData({ ...formData, crm_uf: e.target.value })
}
className="form-input"
required
>
<option value="">Selecione</option>
{UF_OPTIONS.map((uf) => (
<option key={uf} value={uf}>
{uf}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Especialidade
</label>
<select
value={formData.specialty}
onChange={(e) =>
setFormData({ ...formData, specialty: e.target.value })
}
className="form-input"
>
<option value="">Selecione</option>
<option value="Cardiologia">Cardiologia</option>
<option value="Dermatologia">Dermatologia</option>
<option value="Ortopedia">Ortopedia</option>
<option value="Pediatria">Pediatria</option>
<option value="Psiquiatria">Psiquiatria</option>
<option value="Ginecologia">Ginecologia</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email *
</label>
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className="form-input"
required
placeholder="medico@exemplo.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Telefone
</label>
<input
type="tel"
value={formData.phone_mobile}
onChange={(e) =>
setFormData({
...formData,
phone_mobile: formatPhone(e.target.value),
})
}
className="form-input"
maxLength={15}
placeholder="(11) 98888-8888"
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
>
{loading ? "Salvando..." : "Salvar"}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Modal de Visualizar Médico */}
{showViewModal && selectedDoctor && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
Visualizar Médico
</h2>
<button
onClick={() => setShowViewModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-4">
<div>
<p className="text-sm text-gray-500">Nome</p>
<p className="text-gray-900 font-medium">
{selectedDoctor.full_name}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Especialidade</p>
<p className="text-gray-900">
{selectedDoctor.specialty || "—"}
</p>
</div>
<div>
<p className="text-sm text-gray-500">CRM</p>
<p className="text-gray-900">{selectedDoctor.crm || "—"}</p>
</div>
<div>
<p className="text-sm text-gray-500">Email</p>
<p className="text-gray-900">{selectedDoctor.email || "—"}</p>
</div>
</div>
</div>
<div className="p-6 border-t border-gray-200 flex justify-end gap-3">
<button
onClick={() => setShowViewModal(false)}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Fechar
</button>
</div>
</div>
</div>
)}
</div>
);
}