- 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
290 lines
9.5 KiB
TypeScript
290 lines
9.5 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { Calendar, Users, UserCheck, Clock, ArrowRight } from "lucide-react";
|
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
|
import { patientService, doctorService, appointmentService } from "../services";
|
|
import { MetricCard } from "../components/MetricCard";
|
|
import { HeroBanner } from "../components/HeroBanner";
|
|
import { i18n } from "../i18n";
|
|
import { useAuth } from "../hooks/useAuth";
|
|
import RecoveryRedirect from "../components/auth/RecoveryRedirect";
|
|
|
|
const Home: React.FC = () => {
|
|
const [stats, setStats] = useState({
|
|
totalPacientes: 0,
|
|
totalMedicos: 0,
|
|
consultasHoje: 0,
|
|
consultasPendentes: 0,
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(false);
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const { user } = useAuth();
|
|
|
|
// Verificar se há parâmetros de magic link e redirecionar para AuthCallback
|
|
useEffect(() => {
|
|
const hash = window.location.hash;
|
|
if (
|
|
hash &&
|
|
(hash.includes("access_token") || hash.includes("type=magiclink"))
|
|
) {
|
|
console.log(
|
|
"[Home] Detectado magic link, redirecionando para /auth/callback"
|
|
);
|
|
navigate(`/auth/callback${hash}`, { replace: true });
|
|
return;
|
|
}
|
|
}, [navigate]);
|
|
|
|
// Limpar cache se houver parâmetro ?clear=true
|
|
useEffect(() => {
|
|
if (searchParams.get("clear") === "true") {
|
|
console.log("🧹 Limpando cache via URL...");
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
// Remove o parâmetro da URL e recarrega
|
|
window.location.href = "/";
|
|
}
|
|
}, [searchParams]);
|
|
|
|
useEffect(() => {
|
|
// Só buscar estatísticas se o usuário estiver autenticado
|
|
if (user) {
|
|
fetchStats();
|
|
} else {
|
|
setLoading(false);
|
|
}
|
|
}, [user]);
|
|
|
|
const fetchStats = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(false);
|
|
|
|
// Silenciar erros 401 (não autenticado) - são esperados na home pública
|
|
const [pacientes, medicos, consultasRaw] = await Promise.all([
|
|
patientService.list().catch((err) => {
|
|
if (err.response?.status !== 401)
|
|
console.error("Erro ao buscar pacientes:", err);
|
|
return [];
|
|
}),
|
|
doctorService.list().catch((err) => {
|
|
if (err.response?.status !== 401)
|
|
console.error("Erro ao buscar médicos:", err);
|
|
return [];
|
|
}),
|
|
appointmentService.list().catch((err) => {
|
|
if (err.response?.status !== 401)
|
|
console.error("Erro ao buscar consultas:", err);
|
|
return [];
|
|
}),
|
|
]);
|
|
|
|
// Ensure consultas is an array
|
|
const consultas = Array.isArray(consultasRaw) ? consultasRaw : [];
|
|
|
|
const hoje = new Date().toISOString().split("T")[0];
|
|
const consultasHoje = consultas.filter((c) =>
|
|
c.scheduled_at?.startsWith(hoje)
|
|
).length;
|
|
|
|
const consultasPendentes = consultas.filter(
|
|
(c) => c.status === "requested" || c.status === "confirmed"
|
|
).length;
|
|
|
|
setStats({
|
|
totalPacientes: pacientes.length,
|
|
totalMedicos: medicos.length,
|
|
consultasHoje,
|
|
consultasPendentes,
|
|
});
|
|
} catch (err) {
|
|
console.error("Erro ao carregar estatísticas:", err);
|
|
setError(true);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCTA = (action: string, destination: string) => {
|
|
console.log(`CTA clicked: ${action} -> ${destination}`);
|
|
navigate(destination);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="space-y-6 sm:space-y-8 px-4 sm:px-6 lg:px-8"
|
|
id="main-content"
|
|
>
|
|
{/* Componente invisível que detecta tokens de recuperação e redireciona */}
|
|
<RecoveryRedirect />
|
|
|
|
{/* Hero Section com Background Rotativo */}
|
|
<HeroBanner />
|
|
|
|
{/* Métricas */}
|
|
<div
|
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6"
|
|
role="region"
|
|
aria-label="Estatísticas do sistema"
|
|
>
|
|
<MetricCard
|
|
title={i18n.t("home.metrics.totalPatients")}
|
|
value={stats.totalPacientes}
|
|
icon={Users}
|
|
iconColor="text-blue-600"
|
|
iconBgColor="bg-blue-100"
|
|
description={i18n.t("home.metrics.totalPatientsDescription")}
|
|
loading={loading}
|
|
error={error}
|
|
ariaLabel={`${i18n.t("home.metrics.totalPatients")}: ${
|
|
stats.totalPacientes
|
|
}`}
|
|
/>
|
|
|
|
<MetricCard
|
|
title={i18n.t("home.metrics.activeDoctors")}
|
|
value={stats.totalMedicos}
|
|
icon={UserCheck}
|
|
iconColor="text-green-500"
|
|
iconBgColor="bg-green-50"
|
|
description={i18n.t("home.metrics.activeDoctorsDescription")}
|
|
loading={loading}
|
|
error={error}
|
|
ariaLabel={`${i18n.t("home.metrics.activeDoctors")}: ${
|
|
stats.totalMedicos
|
|
}`}
|
|
/>
|
|
|
|
<MetricCard
|
|
title={i18n.t("home.metrics.todayAppointments")}
|
|
value={stats.consultasHoje}
|
|
icon={Calendar}
|
|
iconColor="text-yellow-500"
|
|
iconBgColor="bg-yellow-50"
|
|
description={i18n.t("home.metrics.todayAppointmentsDescription")}
|
|
loading={loading}
|
|
error={error}
|
|
ariaLabel={`${i18n.t("home.metrics.todayAppointments")}: ${
|
|
stats.consultasHoje
|
|
}`}
|
|
/>
|
|
|
|
<MetricCard
|
|
title={i18n.t("home.metrics.pendingAppointments")}
|
|
value={stats.consultasPendentes}
|
|
icon={Clock}
|
|
iconColor="text-purple-500"
|
|
iconBgColor="bg-purple-50"
|
|
description={i18n.t("home.metrics.pendingAppointmentsDescription")}
|
|
loading={loading}
|
|
error={error}
|
|
ariaLabel={`${i18n.t("home.metrics.pendingAppointments")}: ${
|
|
stats.consultasPendentes
|
|
}`}
|
|
/>
|
|
</div>
|
|
|
|
{/* Cards de Ação */}
|
|
<div
|
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 md:gap-6"
|
|
role="region"
|
|
aria-label="Ações rápidas"
|
|
>
|
|
<ActionCard
|
|
icon={Calendar}
|
|
iconColor="text-blue-600"
|
|
iconBgColor="bg-gradient-to-br from-blue-700 to-blue-400"
|
|
title={i18n.t("home.actionCards.scheduleAppointment.title")}
|
|
description={i18n.t(
|
|
"home.actionCards.scheduleAppointment.description"
|
|
)}
|
|
ctaLabel={i18n.t("home.actionCards.scheduleAppointment.cta")}
|
|
ctaAriaLabel={i18n.t(
|
|
"home.actionCards.scheduleAppointment.ctaAriaLabel"
|
|
)}
|
|
onAction={() => handleCTA("Card Agendar", "/paciente")}
|
|
/>
|
|
|
|
<ActionCard
|
|
icon={UserCheck}
|
|
iconColor="text-indigo-600"
|
|
iconBgColor="bg-gradient-to-br from-indigo-600 to-indigo-400"
|
|
title={i18n.t("home.actionCards.doctorPanel.title")}
|
|
description={i18n.t("home.actionCards.doctorPanel.description")}
|
|
ctaLabel={i18n.t("home.actionCards.doctorPanel.cta")}
|
|
ctaAriaLabel={i18n.t("home.actionCards.doctorPanel.ctaAriaLabel")}
|
|
onAction={() => handleCTA("Card Médico", "/login-medico")}
|
|
/>
|
|
|
|
<ActionCard
|
|
icon={Users}
|
|
iconColor="text-green-600"
|
|
iconBgColor="bg-gradient-to-br from-green-600 to-green-400"
|
|
title={i18n.t("home.actionCards.patientManagement.title")}
|
|
description={i18n.t("home.actionCards.patientManagement.description")}
|
|
ctaLabel={i18n.t("home.actionCards.patientManagement.cta")}
|
|
ctaAriaLabel={i18n.t(
|
|
"home.actionCards.patientManagement.ctaAriaLabel"
|
|
)}
|
|
onAction={() => handleCTA("Card Secretaria", "/login-secretaria")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Action Card Component
|
|
interface ActionCardProps {
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
iconColor: string;
|
|
iconBgColor: string;
|
|
title: string;
|
|
description: string;
|
|
ctaLabel: string;
|
|
ctaAriaLabel: string;
|
|
onAction: () => void;
|
|
}
|
|
|
|
const ActionCard: React.FC<ActionCardProps> = ({
|
|
icon: Icon,
|
|
iconBgColor,
|
|
title,
|
|
description,
|
|
ctaLabel,
|
|
ctaAriaLabel,
|
|
onAction,
|
|
}) => {
|
|
return (
|
|
<div className="bg-white rounded-lg shadow-md p-4 sm:p-5 md:p-6 hover:shadow-xl transition-all duration-200 group border border-gray-100 focus-within:ring-2 focus-within:ring-blue-500/50 focus-within:ring-offset-2">
|
|
<div
|
|
className={`w-10 h-10 sm:w-12 sm:h-12 ${iconBgColor} rounded-lg flex items-center justify-center mb-3 sm:mb-4 group-hover:scale-110 transition-transform`}
|
|
>
|
|
<Icon
|
|
className={`w-5 h-5 sm:w-6 sm:h-6 text-white`}
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
<h3 className="text-base sm:text-lg font-semibold mb-2 text-gray-900">
|
|
{title}
|
|
</h3>
|
|
<p className="text-xs sm:text-sm text-gray-600 mb-3 sm:mb-4 leading-relaxed">
|
|
{description}
|
|
</p>
|
|
<button
|
|
onClick={onAction}
|
|
className="w-full inline-flex items-center justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-gradient-to-r from-blue-700 to-blue-400 hover:from-blue-800 hover:to-blue-500 hover:scale-105 active:scale-95 text-white rounded-lg text-sm sm:text-base font-medium transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/80 group-hover:shadow-lg"
|
|
aria-label={ctaAriaLabel}
|
|
>
|
|
{ctaLabel}
|
|
<ArrowRight
|
|
className="w-3.5 h-3.5 sm:w-4 sm:h-4 ml-2 group-hover:translate-x-1 transition-transform"
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Home;
|