- Avatar do paciente agora persiste após reload (adiciona timestamp para evitar cache) - Agendamento usa patient_id correto ao invés de user_id - Botão de download de PDF desbloqueado com logs detalhados
2297 lines
86 KiB
TypeScript
2297 lines
86 KiB
TypeScript
import React, { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
Calendar,
|
|
Clock,
|
|
Mail,
|
|
TrendingUp,
|
|
Video,
|
|
MapPin,
|
|
Phone,
|
|
FileText,
|
|
Settings,
|
|
LogOut,
|
|
Home,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
XCircle,
|
|
HelpCircle,
|
|
Plus,
|
|
Edit,
|
|
Trash2,
|
|
User,
|
|
Save,
|
|
Eye,
|
|
} from "lucide-react";
|
|
import toast from "react-hot-toast";
|
|
import { format, isSameDay } from "date-fns";
|
|
import { ptBR } from "date-fns/locale";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
appointmentService,
|
|
patientService,
|
|
reportService,
|
|
doctorService,
|
|
authService,
|
|
type Appointment,
|
|
type Patient,
|
|
type CreateReportInput,
|
|
} from "../services";
|
|
import type { Report } from "../services/reports/types";
|
|
import { useAuth } from "../hooks/useAuth";
|
|
import DisponibilidadeMedico from "../components/DisponibilidadeMedico";
|
|
import ConsultaModal from "../components/consultas/ConsultaModal";
|
|
import MensagensMedico from "./MensagensMedico";
|
|
import { AvatarUpload } from "../components/ui/AvatarUpload";
|
|
import { avatarService } from "../services/avatars/avatarService";
|
|
|
|
// Type aliases para compatibilidade
|
|
type ServiceConsulta = Appointment;
|
|
type RelatorioCreate = CreateReportInput;
|
|
|
|
interface ConsultaUI {
|
|
id: string;
|
|
pacienteId: string;
|
|
medicoId: string;
|
|
pacienteNome: string;
|
|
medicoNome: string;
|
|
dataHora: string;
|
|
status: string;
|
|
tipo?: string;
|
|
observacoes?: string;
|
|
motivoConsulta?: string; // patient_notes
|
|
}
|
|
|
|
const PainelMedico: React.FC = () => {
|
|
const { user, roles, logout } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
// Auth
|
|
const temAcessoMedico =
|
|
user &&
|
|
(user.role === "medico" ||
|
|
roles.includes("medico") ||
|
|
roles.includes("admin"));
|
|
const medicoNome = user?.nome || "Médico";
|
|
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
|
|
|
// Usar user_id diretamente para perfil e relatórios
|
|
const authUserId = user?.id || "";
|
|
|
|
// Buscar doctor.id da tabela apenas para consultas
|
|
const [doctorTableId, setDoctorTableId] = useState<string | null>(null);
|
|
|
|
// Helpers seguros para formatar data e hora (evitam crash quando value for inválido)
|
|
const formatDateSafe = (iso?: string) => {
|
|
if (!iso) return "—";
|
|
try {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return "—";
|
|
return format(d, "dd/MM/yyyy", { locale: ptBR });
|
|
} catch {
|
|
return "—";
|
|
}
|
|
};
|
|
|
|
const formatTimeSafe = (iso?: string) => {
|
|
if (!iso) return "—";
|
|
try {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return "—";
|
|
return format(d, "HH:mm", { locale: ptBR });
|
|
} catch {
|
|
return "—";
|
|
}
|
|
};
|
|
|
|
// State
|
|
const [activeTab, setActiveTab] = useState("dashboard");
|
|
const [consultas, setConsultas] = useState<ConsultaUI[]>([]);
|
|
const [filtroData, setFiltroData] = useState("todas");
|
|
const [loading, setLoading] = useState(true);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editing, setEditing] = useState<ConsultaUI | null>(null);
|
|
const [viewingConsulta, setViewingConsulta] = useState<ConsultaUI | null>(
|
|
null
|
|
);
|
|
const [viewModalOpen, setViewModalOpen] = useState(false);
|
|
const [viewingReport, setViewingReport] = useState<Report | null>(null);
|
|
const [viewReportModalOpen, setViewReportModalOpen] = useState(false);
|
|
const [relatorioModalOpen, setRelatorioModalOpen] = useState(false);
|
|
const [loadingRelatorio, setLoadingRelatorio] = useState(false);
|
|
const [laudos, setLaudos] = useState<Report[]>([]);
|
|
const [loadingLaudos, setLoadingLaudos] = useState(false);
|
|
const [pacientesDisponiveis, setPacientesDisponiveis] = useState<
|
|
Array<{ id: string; nome: string }>
|
|
>([]);
|
|
const [formRelatorio, setFormRelatorio] = useState({
|
|
patient_id: "",
|
|
order_number: "",
|
|
exam: "",
|
|
diagnosis: "",
|
|
conclusion: "",
|
|
cid_code: "",
|
|
content_html: "",
|
|
status: "draft" as "draft" | "pending" | "completed" | "cancelled",
|
|
requested_by: medicoNome,
|
|
due_at: format(new Date(), "yyyy-MM-dd'T'HH:mm"),
|
|
hide_date: false,
|
|
hide_signature: false,
|
|
});
|
|
|
|
// Estados para perfil do médico
|
|
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
|
const [profileTab, setProfileTab] = useState<
|
|
"personal" | "professional" | "security"
|
|
>("personal");
|
|
const [profileData, setProfileData] = useState({
|
|
full_name: "",
|
|
email: "",
|
|
phone: "",
|
|
cpf: "",
|
|
birth_date: "",
|
|
sex: "",
|
|
street: "",
|
|
number: "",
|
|
complement: "",
|
|
neighborhood: "",
|
|
city: "",
|
|
state: "",
|
|
cep: "",
|
|
crm: "",
|
|
specialty: "",
|
|
});
|
|
|
|
// Estados para alteração de senha na seção de Configurações > Segurança
|
|
const [passwordData, setPasswordData] = useState({
|
|
currentPassword: "",
|
|
newPassword: "",
|
|
confirmPassword: "",
|
|
});
|
|
|
|
// Buscar doctor.id da tabela usando user_id (apenas para consultas)
|
|
useEffect(() => {
|
|
const fetchDoctorTableId = async () => {
|
|
if (!authUserId) return;
|
|
|
|
try {
|
|
console.log(
|
|
"[PainelMedico] 🔍 Buscando doctor.id (tabela) para consultas. Auth user_id:",
|
|
authUserId
|
|
);
|
|
const doctor = await doctorService.getByUserId(authUserId);
|
|
|
|
if (doctor) {
|
|
setDoctorTableId(doctor.id);
|
|
console.log(
|
|
"[PainelMedico] ✅ Doctor.id (tabela) encontrado para consultas:",
|
|
{
|
|
doctorTableId: doctor.id,
|
|
authUserId: authUserId,
|
|
nome: doctor.full_name,
|
|
diferentes: doctor.id !== authUserId,
|
|
}
|
|
);
|
|
} else {
|
|
console.warn(
|
|
"[PainelMedico] ⚠️ Médico não encontrado na tabela doctors"
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("[PainelMedico] ❌ Erro ao buscar doctor.id:", error);
|
|
}
|
|
};
|
|
|
|
fetchDoctorTableId();
|
|
}, [authUserId]);
|
|
|
|
useEffect(() => {
|
|
if (!user) navigate("/login-medico");
|
|
}, [user, navigate]);
|
|
|
|
// Carregar avatar ao montar componente
|
|
useEffect(() => {
|
|
if (user?.id) {
|
|
// Tenta carregar avatar existente (testa png, jpg, webp)
|
|
const extensions = ["png", "jpg", "webp"];
|
|
const testAvatar = async () => {
|
|
// Adiciona timestamp para evitar cache do navegador
|
|
const timestamp = new Date().getTime();
|
|
for (const ext of extensions) {
|
|
try {
|
|
const url = avatarService.getPublicUrl({
|
|
userId: user.id,
|
|
ext: ext as "jpg" | "png" | "webp",
|
|
});
|
|
// Adiciona timestamp como query parameter para forçar reload
|
|
const urlWithTimestamp = `${url}?t=${timestamp}`;
|
|
const response = await fetch(urlWithTimestamp, { method: "HEAD" });
|
|
if (response.ok) {
|
|
setAvatarUrl(urlWithTimestamp);
|
|
console.log(
|
|
`[PainelMedico] Avatar encontrado: ${urlWithTimestamp}`
|
|
);
|
|
break;
|
|
}
|
|
} catch {
|
|
// Continua testando próxima extensão
|
|
}
|
|
}
|
|
};
|
|
testAvatar();
|
|
}
|
|
}, [user?.id]);
|
|
|
|
const fetchConsultas = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
let appointments;
|
|
if (user?.role === "admin" || roles.includes("admin")) {
|
|
// Admin: busca todas as consultas do sistema
|
|
appointments = await appointmentService.list();
|
|
} else {
|
|
// Médico comum: busca todas as consultas usando doctor.id da tabela
|
|
if (!doctorTableId) {
|
|
console.warn(
|
|
"[PainelMedico] ⚠️ Aguardando doctorTableId para carregar consultas..."
|
|
);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
console.log(
|
|
"[PainelMedico] 🔍 Buscando consultas para doctor.id (tabela):",
|
|
doctorTableId
|
|
);
|
|
appointments = await appointmentService.list({
|
|
doctor_id: doctorTableId,
|
|
});
|
|
}
|
|
if (appointments && appointments.length > 0) {
|
|
console.log(
|
|
"[PainelMedico] ✅ Consultas encontradas:",
|
|
appointments.length
|
|
);
|
|
// Buscar nomes dos pacientes
|
|
const consultasComNomes = await Promise.all(
|
|
appointments.map(async (appt: Appointment) => {
|
|
let pacienteNome = "Paciente Desconhecido";
|
|
try {
|
|
const patient = await patientService.getById(appt.patient_id);
|
|
if (patient) {
|
|
pacienteNome = patient.full_name;
|
|
}
|
|
} catch (error) {
|
|
console.error("Erro ao buscar nome do paciente:", error);
|
|
}
|
|
return {
|
|
id: appt.id,
|
|
pacienteId: appt.patient_id,
|
|
medicoId: appt.doctor_id,
|
|
pacienteNome,
|
|
medicoNome: medicoNome,
|
|
dataHora: appt.scheduled_at,
|
|
status: appt.status,
|
|
tipo: appt.appointment_type,
|
|
observacoes: appt.notes || undefined,
|
|
motivoConsulta: appt.patient_notes || undefined,
|
|
};
|
|
})
|
|
);
|
|
setConsultas(consultasComNomes);
|
|
} else {
|
|
setConsultas([]);
|
|
}
|
|
} catch (error) {
|
|
console.error("Erro ao buscar consultas:", error);
|
|
toast.error("Erro ao carregar consultas");
|
|
setConsultas([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [user, roles, doctorTableId, medicoNome]);
|
|
|
|
const fetchLaudos = useCallback(async () => {
|
|
if (!authUserId) {
|
|
console.warn(
|
|
"[PainelMedico] ⚠️ Aguardando authUserId para carregar laudos..."
|
|
);
|
|
return;
|
|
}
|
|
setLoadingLaudos(true);
|
|
try {
|
|
console.log("[PainelMedico] 🔍 Buscando laudos para auth user_id:", {
|
|
authUserId,
|
|
authUserIdType: typeof authUserId,
|
|
});
|
|
|
|
// Buscar todos os laudos e filtrar pelo médico criador
|
|
const allReports = await reportService.list();
|
|
|
|
console.log(
|
|
"[PainelMedico] 📋 Total de laudos retornados:",
|
|
allReports.length
|
|
);
|
|
|
|
// Debug: mostrar alguns relatórios para análise
|
|
allReports.slice(0, 3).forEach((report: Report, index: number) => {
|
|
console.log(`[PainelMedico] Relatório ${index + 1}:`, {
|
|
created_by: report.created_by,
|
|
requested_by: report.requested_by,
|
|
createdByType: typeof report.created_by,
|
|
requestedByType: typeof report.requested_by,
|
|
isCreator: report.created_by === authUserId,
|
|
isRequester: report.requested_by === authUserId,
|
|
match:
|
|
report.created_by === authUserId ||
|
|
report.requested_by === authUserId,
|
|
});
|
|
});
|
|
|
|
// Filtrar apenas laudos criados por este médico usando auth user_id
|
|
const meusLaudos = allReports.filter(
|
|
(report: Report) => report.created_by === authUserId
|
|
);
|
|
|
|
console.log("[PainelMedico] ✅ Laudos criados por este médico:", {
|
|
total: meusLaudos.length,
|
|
laudos: meusLaudos,
|
|
});
|
|
|
|
setLaudos(meusLaudos);
|
|
} catch (error) {
|
|
console.error("[PainelMedico] ❌ Erro ao buscar laudos:", error);
|
|
toast.error("Erro ao carregar laudos");
|
|
setLaudos([]);
|
|
} finally {
|
|
setLoadingLaudos(false);
|
|
}
|
|
}, [authUserId]);
|
|
|
|
useEffect(() => {
|
|
fetchConsultas();
|
|
}, [fetchConsultas]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === "reports") {
|
|
fetchLaudos();
|
|
}
|
|
}, [activeTab, fetchLaudos]);
|
|
|
|
useEffect(() => {
|
|
if (relatorioModalOpen && user?.id) {
|
|
const carregarPacientes = async () => {
|
|
try {
|
|
const patients = await patientService.list();
|
|
if (patients && patients.length > 0) {
|
|
setPacientesDisponiveis(
|
|
patients.map((p: Patient) => ({
|
|
id: p.id || "",
|
|
nome: p.full_name,
|
|
}))
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("Erro ao carregar pacientes:", error);
|
|
toast.error("Erro ao carregar lista de pacientes");
|
|
}
|
|
};
|
|
carregarPacientes();
|
|
}
|
|
}, [relatorioModalOpen, user]);
|
|
|
|
const handleCriarRelatorio = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!formRelatorio.patient_id) {
|
|
toast.error("Selecione um paciente");
|
|
return;
|
|
}
|
|
if (!formRelatorio.exam.trim()) {
|
|
toast.error("Informe o tipo de exame");
|
|
return;
|
|
}
|
|
setLoadingRelatorio(true);
|
|
try {
|
|
const payload: RelatorioCreate = {
|
|
patient_id: formRelatorio.patient_id,
|
|
exam: formRelatorio.exam,
|
|
diagnosis: formRelatorio.diagnosis || undefined,
|
|
conclusion: formRelatorio.conclusion || undefined,
|
|
cid_code: formRelatorio.cid_code || undefined,
|
|
content_html: formRelatorio.content_html || undefined,
|
|
status: formRelatorio.status,
|
|
requested_by: formRelatorio.requested_by || medicoNome,
|
|
due_at: formRelatorio.due_at || undefined,
|
|
hide_date: formRelatorio.hide_date,
|
|
hide_signature: formRelatorio.hide_signature,
|
|
created_by: authUserId || undefined, // Usar auth user_id como criador
|
|
};
|
|
console.log(
|
|
"[PainelMedico] 📝 Criando relatório com auth user_id:",
|
|
authUserId
|
|
);
|
|
const newReport = await reportService.create(payload);
|
|
if (newReport) {
|
|
toast.success("Relatório criado com sucesso!");
|
|
setRelatorioModalOpen(false);
|
|
setFormRelatorio({
|
|
patient_id: "",
|
|
order_number: "",
|
|
exam: "",
|
|
diagnosis: "",
|
|
conclusion: "",
|
|
cid_code: "",
|
|
content_html: "",
|
|
status: "draft",
|
|
requested_by: medicoNome,
|
|
due_at: format(new Date(), "yyyy-MM-dd'T'HH:mm"),
|
|
hide_date: false,
|
|
hide_signature: false,
|
|
});
|
|
} else {
|
|
toast.error("Erro ao criar relatório");
|
|
}
|
|
} catch (error) {
|
|
console.error("Erro ao criar relatório:", error);
|
|
toast.error("Erro ao criar relatório");
|
|
} finally {
|
|
setLoadingRelatorio(false);
|
|
}
|
|
};
|
|
|
|
const handleNovaConsulta = () => {
|
|
setEditing(null);
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleEditConsulta = (consulta: ConsultaUI) => {
|
|
setEditing(consulta);
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleDeleteConsulta = async (id: string) => {
|
|
if (!window.confirm("Deseja realmente excluir esta consulta?")) return;
|
|
try {
|
|
const raw = localStorage.getItem("consultas_local");
|
|
if (raw) {
|
|
const lista: ServiceConsulta[] = JSON.parse(raw);
|
|
const nova = lista.filter((c) => c.id !== id);
|
|
localStorage.setItem("consultas_local", JSON.stringify(nova));
|
|
toast.success("Consulta excluída");
|
|
fetchConsultas();
|
|
}
|
|
} catch (error) {
|
|
console.error("Erro ao excluir consulta:", error);
|
|
toast.error("Erro ao excluir consulta");
|
|
}
|
|
};
|
|
|
|
const handleSaveConsulta = () => {
|
|
setModalOpen(false);
|
|
setEditing(null);
|
|
fetchConsultas();
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status.toLowerCase()) {
|
|
case "confirmada":
|
|
case "confirmed":
|
|
return "bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800";
|
|
case "agendada":
|
|
case "scheduled":
|
|
return "bg-indigo-100 text-indigo-800 border-blue-200 dark:bg-indigo-900/30 dark:text-blue-300 dark:border-indigo-800";
|
|
case "concluida":
|
|
case "completed":
|
|
return "bg-gray-100 text-gray-800 border-gray-200 dark:bg-gray-900/30 dark:text-gray-300 dark:border-gray-800";
|
|
case "cancelada":
|
|
case "cancelled":
|
|
return "bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800";
|
|
default:
|
|
return "bg-gray-100 text-gray-800 border-gray-200 dark:bg-gray-900/30 dark:text-gray-300 dark:border-gray-800";
|
|
}
|
|
};
|
|
|
|
const getStatusLabel = (status: string) => {
|
|
switch (status.toLowerCase()) {
|
|
case "confirmada":
|
|
case "confirmed":
|
|
return "Confirmada";
|
|
case "agendada":
|
|
case "scheduled":
|
|
return "Agendada";
|
|
case "concluida":
|
|
case "completed":
|
|
return "Concluída";
|
|
case "cancelada":
|
|
case "cancelled":
|
|
return "Cancelada";
|
|
default:
|
|
return status;
|
|
}
|
|
};
|
|
|
|
const getStatusIcon = (status: string) => {
|
|
switch (status.toLowerCase()) {
|
|
case "confirmada":
|
|
case "confirmed":
|
|
return <CheckCircle className="h-4 w-4" />;
|
|
case "agendada":
|
|
case "scheduled":
|
|
return <Clock className="h-4 w-4" />;
|
|
case "cancelada":
|
|
case "cancelled":
|
|
return <XCircle className="h-4 w-4" />;
|
|
default:
|
|
return <AlertCircle className="h-4 w-4" />;
|
|
}
|
|
};
|
|
|
|
// Stats
|
|
// Calcula consultas de hoje (filtra por data local)
|
|
const consultasHoje = consultas.filter((c) => {
|
|
if (!c?.dataHora) return false;
|
|
try {
|
|
const d = new Date(c.dataHora);
|
|
if (isNaN(d.getTime())) return false;
|
|
const today = new Date();
|
|
return (
|
|
d.getFullYear() === today.getFullYear() &&
|
|
d.getMonth() === today.getMonth() &&
|
|
d.getDate() === today.getDate()
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
const consultasConfirmadas = consultas.filter(
|
|
(c) =>
|
|
c.status.toLowerCase() === "confirmada" ||
|
|
c.status.toLowerCase() === "confirmed"
|
|
);
|
|
const consultasConcluidas = consultas.filter(
|
|
(c) =>
|
|
c.status.toLowerCase() === "concluida" ||
|
|
c.status.toLowerCase() === "completed"
|
|
);
|
|
|
|
// Sidebar
|
|
const menuItems = [
|
|
{ id: "dashboard", label: "Dashboard", icon: Home },
|
|
{ id: "appointments", label: "Consultas", icon: Clock },
|
|
{ id: "messages", label: "Mensagens", icon: Mail },
|
|
{ id: "availability", label: "Disponibilidade", icon: Calendar },
|
|
{ id: "reports", label: "Relatórios", icon: FileText },
|
|
{ id: "help", label: "Ajuda", icon: HelpCircle },
|
|
{ id: "settings", label: "Meu Perfil", icon: User },
|
|
];
|
|
|
|
const renderSidebar = () => (
|
|
<div className="w-64 h-screen bg-white dark:bg-slate-900 border-r border-gray-200 dark:border-slate-700 flex flex-col">
|
|
{/* Doctor Profile */}
|
|
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
|
|
<div className="flex items-center gap-3">
|
|
<AvatarUpload
|
|
userId={user?.id}
|
|
currentAvatarUrl={avatarUrl}
|
|
name={medicoNome}
|
|
color="green"
|
|
size="lg"
|
|
editable={true}
|
|
userType="doctor"
|
|
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
|
/>
|
|
<div>
|
|
<p className="font-medium text-gray-900 dark:text-white">
|
|
{medicoNome}
|
|
</p>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">Médico</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav className="flex-1 p-4">
|
|
<div className="space-y-1">
|
|
{menuItems.map((item) => {
|
|
const Icon = item.icon;
|
|
const isActive = activeTab === item.id;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => {
|
|
if (item.isLink && item.path) {
|
|
navigate(item.path);
|
|
} else if (item.id === "help") {
|
|
navigate("/ajuda");
|
|
} else {
|
|
setActiveTab(item.id);
|
|
}
|
|
}}
|
|
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 ${
|
|
isActive
|
|
? "bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 dark:text-indigo-400"
|
|
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-800"
|
|
}`}
|
|
>
|
|
<Icon className="h-5 w-5" />
|
|
{item.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Logout */}
|
|
<div className="p-4 border-t border-gray-200 dark:border-slate-700">
|
|
<button
|
|
onClick={() => {
|
|
logout();
|
|
navigate("/login-medico");
|
|
}}
|
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
|
|
>
|
|
<LogOut className="h-5 w-5" />
|
|
Sair
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Stats Cards
|
|
const renderStatCard = (
|
|
title: string,
|
|
value: string | number,
|
|
icon: React.ElementType,
|
|
description?: string,
|
|
trend?: string
|
|
) => {
|
|
const Icon = icon;
|
|
return (
|
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
|
{title}
|
|
</p>
|
|
<Icon className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
|
</div>
|
|
<div className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
|
|
{value}
|
|
</div>
|
|
{description && (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
{description}
|
|
</p>
|
|
)}
|
|
{trend && (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">{trend}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Appointment Card
|
|
const renderAppointmentCard = (consulta: ConsultaUI) => (
|
|
<div
|
|
key={consulta.id}
|
|
className="flex items-start gap-4 p-4 rounded-lg border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800/50 transition-colors"
|
|
>
|
|
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center text-white font-semibold">
|
|
{consulta.pacienteNome
|
|
.split(" ")
|
|
.map((n) => n[0])
|
|
.join("")
|
|
.toUpperCase()
|
|
.slice(0, 2)}
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-2">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="font-medium text-gray-900 dark:text-white">
|
|
{consulta.pacienteNome}
|
|
</p>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
{consulta.observacoes || "Consulta médica"}
|
|
</p>
|
|
</div>
|
|
<div
|
|
className={`flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium border ${getStatusColor(
|
|
consulta.status
|
|
)}`}
|
|
>
|
|
{getStatusIcon(consulta.status)}
|
|
{getStatusLabel(consulta.status)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="h-4 w-4" />
|
|
<div className="flex flex-col leading-tight">
|
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
|
{formatTimeSafe(consulta.dataHora)}
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
{formatDateSafe(consulta.dataHora)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{consulta.tipo === "online" || consulta.tipo === "telemedicina" ? (
|
|
<>
|
|
<Video className="h-4 w-4" />
|
|
<span>Online</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<MapPin className="h-4 w-4" />
|
|
<span>Presencial</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{consulta.status.toLowerCase() === "confirmada" && (
|
|
<>
|
|
<button className="flex items-center gap-1 px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500">
|
|
<Phone className="h-4 w-4" />
|
|
Ligar
|
|
</button>
|
|
{(consulta.tipo === "online" ||
|
|
consulta.tipo === "telemedicina") && (
|
|
<button className="flex items-center gap-1 px-3 py-1 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500">
|
|
<Video className="h-4 w-4" />
|
|
Iniciar Consulta
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={() => {
|
|
setViewingConsulta(consulta);
|
|
setViewModalOpen(true);
|
|
}}
|
|
className="flex items-center gap-1 px-3 py-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
Visualizar
|
|
</button>
|
|
<button
|
|
onClick={() => handleEditConsulta(consulta)}
|
|
className="flex items-center gap-1 px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
Editar
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteConsulta(consulta.id)}
|
|
className="flex items-center gap-1 px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
Excluir
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Content Sections
|
|
const renderDashboard = () => {
|
|
console.log("[PainelMedico] 📊 Renderizando dashboard com consultas:", {
|
|
totalConsultas: consultas.length,
|
|
consultasHoje: consultasHoje.length,
|
|
doctorTableId: doctorTableId,
|
|
authUserId: authUserId,
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{renderStatCard(
|
|
"Consultas Hoje",
|
|
consultasHoje.length,
|
|
Clock,
|
|
`${consultasConfirmadas.length} confirmadas`
|
|
)}
|
|
{renderStatCard(
|
|
"Total Consultas",
|
|
consultas.length,
|
|
Calendar,
|
|
"Este período"
|
|
)}
|
|
{renderStatCard(
|
|
"Concluídas",
|
|
consultasConcluidas.length,
|
|
CheckCircle,
|
|
"Este período"
|
|
)}
|
|
{renderStatCard(
|
|
"Taxa Comparecimento",
|
|
consultas.length > 0
|
|
? `${Math.round(
|
|
(consultasConcluidas.length / consultas.length) * 100
|
|
)}%`
|
|
: "0%",
|
|
TrendingUp,
|
|
"Baseado em consultas concluídas"
|
|
)}
|
|
</div>
|
|
|
|
{/* Today's Appointments */}
|
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
|
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Consultas de Hoje
|
|
</h2>
|
|
<button
|
|
onClick={handleNovaConsulta}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Nova Consulta
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="p-6">
|
|
{loading ? (
|
|
<div className="text-center py-8">
|
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-600 border-r-transparent"></div>
|
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
Carregando consultas...
|
|
</p>
|
|
</div>
|
|
) : consultasHoje.length === 0 ? (
|
|
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
|
Nenhuma consulta agendada para hoje
|
|
</p>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{consultasHoje.map(renderAppointmentCard)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
|
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
|
|
<h3 className="font-semibold text-gray-900 dark:text-white">
|
|
Próximos 7 Dias
|
|
</h3>
|
|
</div>
|
|
<div className="p-6">
|
|
<div className="space-y-3">
|
|
{(() => {
|
|
// Calcula os próximos 7 dias e conta consultas por dia
|
|
const days: Array<{ label: string; count: number }>[] =
|
|
[] as any;
|
|
const today = new Date();
|
|
for (let i = 0; i < 7; i++) {
|
|
const d = new Date(today);
|
|
d.setDate(today.getDate() + i);
|
|
const label = d
|
|
.toLocaleDateString("pt-BR", { weekday: "long" })
|
|
.replace(/(^\w|\s\w)/g, (m) => m.toUpperCase());
|
|
|
|
const count = consultas.filter((c) => {
|
|
if (!c?.dataHora) return false;
|
|
const cd = new Date(c.dataHora);
|
|
if (isNaN(cd.getTime())) return false;
|
|
return (
|
|
cd.getFullYear() === d.getFullYear() &&
|
|
cd.getMonth() === d.getMonth() &&
|
|
cd.getDate() === d.getDate()
|
|
);
|
|
}).length;
|
|
|
|
days.push({ label, count });
|
|
}
|
|
|
|
return days.map((day) => (
|
|
<div
|
|
key={day.label}
|
|
className="flex justify-between items-center"
|
|
>
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
{day.label}
|
|
</span>
|
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
|
{day.count} consulta{day.count !== 1 ? "s" : ""}
|
|
</span>
|
|
</div>
|
|
));
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
|
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
|
|
<h3 className="font-semibold text-gray-900 dark:text-white">
|
|
Tipos de Consulta
|
|
</h3>
|
|
</div>
|
|
<div className="p-6">
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
Presencial
|
|
</span>
|
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
|
{
|
|
consultas.filter(
|
|
(c) => c.tipo !== "online" && c.tipo !== "telemedicina"
|
|
).length
|
|
}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
Online
|
|
</span>
|
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
|
{
|
|
consultas.filter(
|
|
(c) => c.tipo === "online" || c.tipo === "telemedicina"
|
|
).length
|
|
}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Função para filtrar consultas por data
|
|
const filtrarConsultasPorData = (consultas: ConsultaUI[]) => {
|
|
const hoje = new Date();
|
|
hoje.setHours(0, 0, 0, 0);
|
|
|
|
const amanha = new Date(hoje);
|
|
amanha.setDate(amanha.getDate() + 1);
|
|
|
|
const fimDaSemana = new Date(hoje);
|
|
fimDaSemana.setDate(fimDaSemana.getDate() + 7);
|
|
|
|
return consultas.filter((consulta) => {
|
|
const dataConsulta = new Date(consulta.dataHora);
|
|
dataConsulta.setHours(0, 0, 0, 0);
|
|
|
|
switch (filtroData) {
|
|
case "hoje":
|
|
return dataConsulta.getTime() === hoje.getTime();
|
|
case "amanha":
|
|
return dataConsulta.getTime() === amanha.getTime();
|
|
case "semana":
|
|
return dataConsulta >= hoje && dataConsulta <= fimDaSemana;
|
|
case "todas":
|
|
default:
|
|
return true;
|
|
}
|
|
});
|
|
};
|
|
|
|
const renderAppointments = () => {
|
|
const agora = new Date();
|
|
|
|
// Separar consultas futuras e passadas
|
|
const consultasFuturas = consultas.filter((c) => {
|
|
if (!c?.dataHora) return false;
|
|
try {
|
|
const dataConsulta = new Date(c.dataHora);
|
|
if (isNaN(dataConsulta.getTime())) return false;
|
|
return dataConsulta >= agora;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
const consultasPassadas = consultas.filter((c) => {
|
|
if (!c?.dataHora) return false;
|
|
try {
|
|
const dataConsulta = new Date(c.dataHora);
|
|
if (isNaN(dataConsulta.getTime())) return false;
|
|
return dataConsulta < agora;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// Aplicar filtro de data apenas nas consultas futuras
|
|
const consultasFuturasFiltradas = filtrarConsultasPorData(consultasFuturas);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
Consultas
|
|
</h1>
|
|
<button
|
|
onClick={handleNovaConsulta}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Nova Consulta
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filters - apenas para consultas futuras */}
|
|
<div className="flex gap-2">
|
|
{["hoje", "amanha", "semana", "todas"].map((filtro) => (
|
|
<button
|
|
key={filtro}
|
|
onClick={() => setFiltroData(filtro)}
|
|
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
|
|
filtroData === filtro
|
|
? "bg-indigo-600 text-white"
|
|
: "bg-white dark:bg-slate-900 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800"
|
|
}`}
|
|
>
|
|
{filtro === "hoje"
|
|
? "Hoje"
|
|
: filtro === "amanha"
|
|
? "Amanhã"
|
|
: filtro === "semana"
|
|
? "Esta Semana"
|
|
: "Todas"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Consultas Futuras */}
|
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Próximas Consultas ({consultasFuturasFiltradas.length})
|
|
</h2>
|
|
</div>
|
|
<div className="p-6">
|
|
{loading ? (
|
|
<div className="text-center py-8">
|
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-600 border-r-transparent"></div>
|
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
Carregando consultas...
|
|
</p>
|
|
</div>
|
|
) : consultasFuturasFiltradas.length === 0 ? (
|
|
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
|
{filtroData === "hoje"
|
|
? "Nenhuma consulta agendada para hoje"
|
|
: filtroData === "amanha"
|
|
? "Nenhuma consulta agendada para amanhã"
|
|
: filtroData === "semana"
|
|
? "Nenhuma consulta agendada para esta semana"
|
|
: "Nenhuma consulta futura encontrada"}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{consultasFuturasFiltradas.map(renderAppointmentCard)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Histórico de Consultas Passadas */}
|
|
{consultasPassadas.length > 0 && (
|
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Histórico ({consultasPassadas.length})
|
|
</h2>
|
|
</div>
|
|
<div className="p-6">
|
|
<div className="space-y-4">
|
|
{consultasPassadas
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(b.dataHora).getTime() -
|
|
new Date(a.dataHora).getTime()
|
|
)
|
|
.map(renderAppointmentCard)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderAvailability = () => <DisponibilidadeMedico />;
|
|
|
|
const renderReports = () => (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
Meus Laudos
|
|
</h1>
|
|
<button
|
|
onClick={() => setRelatorioModalOpen(true)}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Novo Laudo
|
|
</button>
|
|
</div>
|
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
|
{loadingLaudos ? (
|
|
<div className="p-6">
|
|
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
|
Carregando laudos...
|
|
</p>
|
|
</div>
|
|
) : laudos.length === 0 ? (
|
|
<div className="p-6">
|
|
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
|
Você ainda não criou nenhum laudo.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 dark:bg-slate-800">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Número
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Exame
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Diagnóstico
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Data
|
|
</th>
|
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Ações
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
|
|
{laudos.map((laudo) => (
|
|
<tr
|
|
key={laudo.id}
|
|
className="hover:bg-gray-50 dark:hover:bg-slate-800"
|
|
>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
|
{laudo.order_number}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
|
{laudo.exam || "-"}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
|
{laudo.diagnosis || "-"}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span
|
|
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
|
laudo.status === "completed"
|
|
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
|
: laudo.status === "pending"
|
|
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
|
: laudo.status === "cancelled"
|
|
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
|
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
|
}`}
|
|
>
|
|
{laudo.status === "completed"
|
|
? "Concluído"
|
|
: laudo.status === "pending"
|
|
? "Pendente"
|
|
: laudo.status === "cancelled"
|
|
? "Cancelado"
|
|
: "Rascunho"}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
|
|
{new Date(laudo.created_at).toLocaleDateString("pt-BR")}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
|
<button
|
|
onClick={() => {
|
|
setViewingReport(laudo);
|
|
setViewReportModalOpen(true);
|
|
}}
|
|
className="inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded-md transition-colors"
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
Visualizar
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Carregar dados do perfil do médico usando auth user_id
|
|
const loadDoctorProfile = useCallback(async () => {
|
|
if (!authUserId) return;
|
|
|
|
try {
|
|
console.log(
|
|
"[PainelMedico] 🔍 Carregando perfil do médico para auth user_id:",
|
|
authUserId
|
|
);
|
|
const doctor = await doctorService.getByUserId(authUserId);
|
|
if (!doctor) {
|
|
console.warn("[PainelMedico] ⚠️ Perfil do médico não encontrado");
|
|
return;
|
|
}
|
|
setProfileData({
|
|
full_name: doctor.full_name || "",
|
|
email: doctor.email || "",
|
|
phone: doctor.phone2 || "", // Usar phone2 ao invés de phone
|
|
cpf: doctor.cpf || "",
|
|
birth_date: doctor.birth_date || "",
|
|
sex: "", // Campo sex não existe no tipo Doctor
|
|
street: doctor.street || "",
|
|
number: doctor.number || "",
|
|
complement: doctor.complement || "",
|
|
neighborhood: doctor.neighborhood || "",
|
|
city: doctor.city || "",
|
|
state: doctor.state || "",
|
|
cep: doctor.cep || "",
|
|
crm: doctor.crm || "",
|
|
specialty: doctor.specialty || "",
|
|
});
|
|
console.log("[PainelMedico] ✅ Perfil carregado com sucesso");
|
|
} catch (error) {
|
|
console.error("[PainelMedico] Erro ao carregar perfil:", error);
|
|
toast.error("Erro ao carregar perfil");
|
|
}
|
|
}, [authUserId]);
|
|
|
|
useEffect(() => {
|
|
if (authUserId) {
|
|
loadDoctorProfile();
|
|
}
|
|
}, [authUserId, loadDoctorProfile]);
|
|
|
|
const handleSaveProfile = async () => {
|
|
if (!authUserId) return;
|
|
|
|
try {
|
|
console.log(
|
|
"[PainelMedico] 💾 Salvando perfil do médico para auth user_id:",
|
|
authUserId
|
|
);
|
|
await doctorService.updateByUserId(authUserId, profileData);
|
|
toast.success("Perfil atualizado com sucesso!");
|
|
setIsEditingProfile(false);
|
|
await loadDoctorProfile();
|
|
} catch (error) {
|
|
console.error("[PainelMedico] Erro ao salvar perfil:", error);
|
|
toast.error("Erro ao salvar perfil");
|
|
}
|
|
};
|
|
|
|
const handleProfileChange = (field: string, value: string) => {
|
|
setProfileData((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const handlePasswordChange = async () => {
|
|
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
|
toast.error("As senhas não coincidem");
|
|
return;
|
|
}
|
|
|
|
if (passwordData.newPassword.length < 6) {
|
|
toast.error("A senha deve ter pelo menos 6 caracteres");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const token = authService.getAccessToken();
|
|
if (!token) {
|
|
toast.error("Token de sessão não encontrado. Faça login novamente.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await authService.updatePassword(token, passwordData.newPassword);
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 401) {
|
|
try {
|
|
await authService.refreshToken();
|
|
const newToken = authService.getAccessToken();
|
|
if (newToken) {
|
|
await authService.updatePassword(
|
|
newToken,
|
|
passwordData.newPassword
|
|
);
|
|
} else {
|
|
throw new Error("Falha ao obter novo token após refresh");
|
|
}
|
|
} catch (refreshErr) {
|
|
console.error(
|
|
"Falha ao renovar token e atualizar senha:",
|
|
refreshErr
|
|
);
|
|
throw refreshErr;
|
|
}
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
toast.success("Senha alterada com sucesso!");
|
|
setPasswordData({
|
|
currentPassword: "",
|
|
newPassword: "",
|
|
confirmPassword: "",
|
|
});
|
|
} catch (error) {
|
|
console.error("Erro ao alterar senha:", error);
|
|
toast.error("Erro ao alterar senha");
|
|
}
|
|
};
|
|
|
|
const renderSettings = () => (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
Meu Perfil
|
|
</h1>
|
|
<p className="text-gray-600 dark:text-gray-400">
|
|
Gerencie suas informações pessoais e profissionais
|
|
</p>
|
|
</div>
|
|
{!isEditingProfile ? (
|
|
<button
|
|
onClick={() => setIsEditingProfile(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
Editar Perfil
|
|
</button>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => {
|
|
setIsEditingProfile(false);
|
|
loadDoctorProfile();
|
|
}}
|
|
className="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
onClick={handleSaveProfile}
|
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
|
>
|
|
<Save className="h-4 w-4" />
|
|
Salvar
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Avatar Card */}
|
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
|
|
<h2 className="text-lg font-semibold mb-4 dark:text-white">
|
|
Foto de Perfil
|
|
</h2>
|
|
<div className="flex items-center gap-6">
|
|
<AvatarUpload
|
|
userId={user?.id}
|
|
currentAvatarUrl={avatarUrl}
|
|
name={profileData.full_name || medicoNome}
|
|
color="indigo"
|
|
size="xl"
|
|
editable={true}
|
|
userType="doctor"
|
|
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
|
/>
|
|
<div>
|
|
<p className="font-medium text-gray-900 dark:text-white">
|
|
{profileData.full_name || medicoNome}
|
|
</p>
|
|
<p className="text-gray-500 dark:text-gray-400">
|
|
{profileData.email || user?.email || "Sem email"}
|
|
</p>
|
|
<p className="text-sm text-indigo-600 dark:text-indigo-400 mt-1">
|
|
CRM: {profileData.crm || "Não informado"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
|
<div className="border-b border-gray-200 dark:border-slate-700">
|
|
<nav className="flex -mb-px">
|
|
<button
|
|
onClick={() => setProfileTab("personal")}
|
|
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
|
profileTab === "personal"
|
|
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
|
|
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
Dados Pessoais
|
|
</button>
|
|
<button
|
|
onClick={() => setProfileTab("professional")}
|
|
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
|
profileTab === "professional"
|
|
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
|
|
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
Info. Profissionais
|
|
</button>
|
|
<button
|
|
onClick={() => setProfileTab("security")}
|
|
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
|
profileTab === "security"
|
|
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
|
|
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
Segurança
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
{/* Tab: Dados Pessoais */}
|
|
{profileTab === "personal" && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
|
Informações Pessoais
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Nome Completo
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={profileData.full_name}
|
|
onChange={(e) =>
|
|
handleProfileChange("full_name", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Email
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={profileData.email}
|
|
onChange={(e) =>
|
|
handleProfileChange("email", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Telefone
|
|
</label>
|
|
<input
|
|
type="tel"
|
|
value={profileData.phone}
|
|
onChange={(e) =>
|
|
handleProfileChange("phone", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
CPF
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={profileData.cpf}
|
|
disabled
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Data de Nascimento
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={profileData.birth_date}
|
|
onChange={(e) =>
|
|
handleProfileChange("birth_date", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Sexo
|
|
</label>
|
|
<select
|
|
value={profileData.sex}
|
|
onChange={(e) =>
|
|
handleProfileChange("sex", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
>
|
|
<option value="">Selecione</option>
|
|
<option value="M">Masculino</option>
|
|
<option value="F">Feminino</option>
|
|
<option value="O">Outro</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
|
Endereço
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Rua
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={profileData.street}
|
|
onChange={(e) =>
|
|
handleProfileChange("street", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Número
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={profileData.number}
|
|
onChange={(e) =>
|
|
handleProfileChange("number", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Complemento
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={profileData.complement}
|
|
onChange={(e) =>
|
|
handleProfileChange("complement", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Bairro
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={profileData.neighborhood}
|
|
onChange={(e) =>
|
|
handleProfileChange("neighborhood", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Cidade
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={profileData.city}
|
|
onChange={(e) =>
|
|
handleProfileChange("city", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Estado
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={profileData.state}
|
|
onChange={(e) =>
|
|
handleProfileChange("state", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
maxLength={2}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
CEP
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={profileData.cep}
|
|
onChange={(e) =>
|
|
handleProfileChange("cep", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab: Info Profissionais */}
|
|
{profileTab === "professional" && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
|
Informações Profissionais
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
CRM
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={profileData.crm}
|
|
onChange={(e) =>
|
|
handleProfileChange("crm", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Especialidade
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={profileData.specialty}
|
|
onChange={(e) =>
|
|
handleProfileChange("specialty", e.target.value)
|
|
}
|
|
disabled={!isEditingProfile}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab: Segurança */}
|
|
{profileTab === "security" && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
|
Alteração de Senha
|
|
</h3>
|
|
<p className="text-sm text-gray-500 mb-4">
|
|
Atualize sua senha de acesso. Após a alteração, use a nova
|
|
senha para efetuar login.
|
|
</p>
|
|
|
|
<div className="max-w-md space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Senha Atual
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={passwordData.currentPassword}
|
|
onChange={(e) =>
|
|
setPasswordData({
|
|
...passwordData,
|
|
currentPassword: e.target.value,
|
|
})
|
|
}
|
|
placeholder="Digite sua senha atual"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Nova Senha
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={passwordData.newPassword}
|
|
onChange={(e) =>
|
|
setPasswordData({
|
|
...passwordData,
|
|
newPassword: e.target.value,
|
|
})
|
|
}
|
|
placeholder="Digite a nova senha"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Confirmar Nova Senha
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={passwordData.confirmPassword}
|
|
onChange={(e) =>
|
|
setPasswordData({
|
|
...passwordData,
|
|
confirmPassword: e.target.value,
|
|
})
|
|
}
|
|
placeholder="Confirme a nova senha"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handlePasswordChange}
|
|
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
|
>
|
|
Alterar Senha
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const renderContent = () => {
|
|
switch (activeTab) {
|
|
case "dashboard":
|
|
return renderDashboard();
|
|
case "appointments":
|
|
return renderAppointments();
|
|
case "messages":
|
|
return <MensagensMedico />;
|
|
case "availability":
|
|
return renderAvailability();
|
|
case "reports":
|
|
return renderReports();
|
|
case "settings":
|
|
return renderSettings();
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
if (!temAcessoMedico) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="text-center">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
|
Acesso Negado
|
|
</h1>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
|
Você não tem permissão para acessar esta página.
|
|
</p>
|
|
<button
|
|
onClick={() => navigate("/login-medico")}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors"
|
|
>
|
|
Fazer Login
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col lg:flex-row min-h-screen bg-gray-50 dark:bg-slate-950">
|
|
{renderSidebar()}
|
|
<main className="flex-1 overflow-y-auto">
|
|
<div className="container mx-auto p-4 sm:p-6 lg:p-8">
|
|
{renderContent()}
|
|
</div>
|
|
</main>
|
|
|
|
{/* Modals */}
|
|
{modalOpen && (
|
|
<ConsultaModal
|
|
isOpen={modalOpen}
|
|
onClose={() => {
|
|
setModalOpen(false);
|
|
setEditing(null);
|
|
}}
|
|
onSaved={handleSaveConsulta}
|
|
editing={editing}
|
|
defaultMedicoId={doctorTableId || ""}
|
|
lockMedico={false}
|
|
/>
|
|
)}
|
|
|
|
{/* Modal de Visualização de Consulta */}
|
|
{viewModalOpen && viewingConsulta && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
onClick={() => setViewModalOpen(false)}
|
|
>
|
|
<div
|
|
className="bg-white dark:bg-slate-900 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
Detalhes da Consulta
|
|
</h2>
|
|
<button
|
|
onClick={() => setViewModalOpen(false)}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
<XCircle className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-6">
|
|
{/* Informações do Paciente */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<User className="h-5 w-5" />
|
|
Informações do Paciente
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Nome do Paciente
|
|
</label>
|
|
<p className="text-gray-900 dark:text-white font-medium">
|
|
{viewingConsulta.pacienteNome}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Status
|
|
</label>
|
|
<div
|
|
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-sm font-medium border ${getStatusColor(
|
|
viewingConsulta.status
|
|
)}`}
|
|
>
|
|
{getStatusIcon(viewingConsulta.status)}
|
|
{getStatusLabel(viewingConsulta.status)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Data e Hora */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<Calendar className="h-5 w-5" />
|
|
Data e Hora
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Data
|
|
</label>
|
|
<p className="text-gray-900 dark:text-white font-medium flex items-center gap-2">
|
|
<Calendar className="h-4 w-4 text-gray-400" />
|
|
{formatDateSafe(viewingConsulta.dataHora)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Horário
|
|
</label>
|
|
<p className="text-gray-900 dark:text-white font-medium flex items-center gap-2">
|
|
<Clock className="h-4 w-4 text-gray-400" />
|
|
{formatTimeSafe(viewingConsulta.dataHora)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tipo de Consulta */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
Tipo de Consulta
|
|
</h3>
|
|
<p className="text-gray-900 dark:text-white font-medium flex items-center gap-2">
|
|
{viewingConsulta.tipo === "online" ||
|
|
viewingConsulta.tipo === "telemedicina" ? (
|
|
<>
|
|
<Video className="h-5 w-5 text-indigo-600" />
|
|
Online / Telemedicina
|
|
</>
|
|
) : (
|
|
<>
|
|
<MapPin className="h-5 w-5 text-gray-600" />
|
|
Presencial
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Motivo da Consulta */}
|
|
{viewingConsulta.motivoConsulta && (
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<FileText className="h-5 w-5" />
|
|
Motivo da Consulta
|
|
</h3>
|
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
|
<p className="text-gray-900 dark:text-white whitespace-pre-wrap">
|
|
{viewingConsulta.motivoConsulta}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Observações do Médico */}
|
|
{viewingConsulta.observacoes && (
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<FileText className="h-5 w-5" />
|
|
Observações do Médico
|
|
</h3>
|
|
<div className="bg-gray-50 dark:bg-slate-800 rounded-lg p-4">
|
|
<p className="text-gray-900 dark:text-white whitespace-pre-wrap">
|
|
{viewingConsulta.observacoes}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Ações */}
|
|
<div className="flex gap-3 pt-4 border-t border-gray-200 dark:border-slate-700">
|
|
<button
|
|
onClick={() => {
|
|
setViewModalOpen(false);
|
|
handleEditConsulta(viewingConsulta);
|
|
}}
|
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors"
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
Editar Consulta
|
|
</button>
|
|
<button
|
|
onClick={() => setViewModalOpen(false)}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors"
|
|
>
|
|
Fechar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de Visualização de Relatório */}
|
|
{viewReportModalOpen && viewingReport && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
onClick={() => setViewReportModalOpen(false)}
|
|
>
|
|
<div
|
|
className="bg-white dark:bg-slate-900 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
Detalhes do Relatório
|
|
</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
Número: {viewingReport.order_number}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setViewReportModalOpen(false)}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
<XCircle className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-6">
|
|
{/* Informações Básicas */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<FileText className="h-5 w-5" />
|
|
Informações Básicas
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Tipo de Exame
|
|
</label>
|
|
<p className="text-gray-900 dark:text-white font-medium">
|
|
{viewingReport.exam || "-"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Status
|
|
</label>
|
|
<span
|
|
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${
|
|
viewingReport.status === "completed"
|
|
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
|
: viewingReport.status === "pending"
|
|
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
|
: viewingReport.status === "cancelled"
|
|
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
|
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
|
}`}
|
|
>
|
|
{viewingReport.status === "completed"
|
|
? "Concluído"
|
|
: viewingReport.status === "pending"
|
|
? "Pendente"
|
|
: viewingReport.status === "cancelled"
|
|
? "Cancelado"
|
|
: "Rascunho"}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Data de Criação
|
|
</label>
|
|
<p className="text-gray-900 dark:text-white font-medium flex items-center gap-2">
|
|
<Calendar className="h-4 w-4 text-gray-400" />
|
|
{new Date(viewingReport.created_at).toLocaleDateString(
|
|
"pt-BR"
|
|
)}
|
|
</p>
|
|
</div>
|
|
{viewingReport.cid_code && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Código CID
|
|
</label>
|
|
<p className="text-gray-900 dark:text-white font-medium">
|
|
{viewingReport.cid_code}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Diagnóstico */}
|
|
{viewingReport.diagnosis && (
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
Diagnóstico
|
|
</h3>
|
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
|
<p className="text-gray-900 dark:text-white whitespace-pre-wrap">
|
|
{viewingReport.diagnosis}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conclusão */}
|
|
{viewingReport.conclusion && (
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
Conclusão
|
|
</h3>
|
|
<div className="bg-gray-50 dark:bg-slate-800 rounded-lg p-4">
|
|
<p className="text-gray-900 dark:text-white whitespace-pre-wrap">
|
|
{viewingReport.conclusion}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conteúdo HTML */}
|
|
{viewingReport.content_html && (
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
Detalhes do Relatório
|
|
</h3>
|
|
<div
|
|
className="bg-white dark:bg-slate-800 rounded-lg p-4 border border-gray-200 dark:border-slate-700 prose dark:prose-invert max-w-none"
|
|
dangerouslySetInnerHTML={{
|
|
__html: viewingReport.content_html,
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Botão Fechar */}
|
|
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-slate-700">
|
|
<button
|
|
onClick={() => setViewReportModalOpen(false)}
|
|
className="px-6 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors"
|
|
>
|
|
Fechar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{relatorioModalOpen && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
onClick={() => setRelatorioModalOpen(false)}
|
|
>
|
|
<div
|
|
className="bg-white dark:bg-slate-900 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
|
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
Criar Novo Relatório
|
|
</h2>
|
|
</div>
|
|
<form onSubmit={handleCriarRelatorio} className="p-6 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Paciente *
|
|
</label>
|
|
<select
|
|
value={formRelatorio.patient_id}
|
|
onChange={(e) =>
|
|
setFormRelatorio((p) => ({
|
|
...p,
|
|
patient_id: e.target.value,
|
|
}))
|
|
}
|
|
required
|
|
className="form-input"
|
|
>
|
|
<option value="">Selecione um paciente</option>
|
|
{pacientesDisponiveis.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.nome}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Tipo de Exame *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formRelatorio.exam}
|
|
onChange={(e) =>
|
|
setFormRelatorio((p) => ({ ...p, exam: e.target.value }))
|
|
}
|
|
required
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Diagnóstico
|
|
</label>
|
|
<textarea
|
|
value={formRelatorio.diagnosis}
|
|
onChange={(e) =>
|
|
setFormRelatorio((p) => ({
|
|
...p,
|
|
diagnosis: e.target.value,
|
|
}))
|
|
}
|
|
rows={3}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Conclusão
|
|
</label>
|
|
<textarea
|
|
value={formRelatorio.conclusion}
|
|
onChange={(e) =>
|
|
setFormRelatorio((p) => ({
|
|
...p,
|
|
conclusion: e.target.value,
|
|
}))
|
|
}
|
|
rows={3}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-3 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setRelatorioModalOpen(false)}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={loadingRelatorio}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
|
>
|
|
{loadingRelatorio ? "Criando..." : "Criar Relatório"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PainelMedico;
|