This commit is contained in:
guisilvagomes 2025-10-09 10:09:35 -03:00
parent fca789662d
commit 9ec07aeea3
16 changed files with 2507 additions and 910 deletions

View File

@ -161,10 +161,10 @@ const AccessibilityMenu: React.FC = () => {
aria-modal="true"
aria-labelledby={DIALOG_TITLE_ID}
aria-describedby={DIALOG_DESC_ID}
className="fixed bottom-24 right-6 z-50 bg-white dark:bg-slate-800 rounded-lg shadow-2xl p-6 w-80 border-2 border-blue-600 transition-all duration-300 animate-slideIn focus:outline-none"
className="fixed bottom-24 right-6 z-50 bg-white dark:bg-slate-800 rounded-lg shadow-2xl w-80 border-2 border-blue-600 transition-all duration-300 animate-slideIn focus:outline-none max-h-[calc(100vh-7rem)]"
onKeyDown={onKeyDown}
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between p-6 pb-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Accessibility className="w-5 h-5 text-blue-600" />
<h3
@ -176,7 +176,7 @@ const AccessibilityMenu: React.FC = () => {
</div>
<button
onClick={() => setIsOpen(false)}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Fechar menu"
>
<X className="w-5 h-5" />
@ -185,7 +185,14 @@ const AccessibilityMenu: React.FC = () => {
<p id={DIALOG_DESC_ID} className="sr-only">
Ajustes visuais e funcionais para leitura, contraste e foco.
</p>
<div className="space-y-5 max-h-[70vh] overflow-y-auto pr-1">
<div
className="space-y-5 overflow-y-auto p-6 pt-4"
style={{
maxHeight: "calc(100vh - 15rem)",
scrollbarWidth: "thin",
scrollbarColor: "#3b82f6 #e5e7eb",
}}
>
{/* Tamanho da fonte */}
<div ref={firstInteractiveRef} tabIndex={-1}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@ -321,15 +328,13 @@ const ToggleRow: React.FC<ToggleRowProps> = ({
{icon}
{label}
</label>
<div className="flex flex-col items-end">
<div className="flex items-center gap-2">
<button
onClick={onClick}
className={
"a11y-toggle-button relative inline-flex h-7 w-14 items-center rounded-full focus:outline-none" +
" a11y-toggle-track " +
(active
? " ring-offset-0"
: " opacity-90 hover:opacity-100")
(active ? " ring-offset-0" : " opacity-90 hover:opacity-100")
}
data-active={active}
aria-pressed={active}
@ -343,7 +348,7 @@ const ToggleRow: React.FC<ToggleRowProps> = ({
}
/>
</button>
<span className="a11y-toggle-status-label select-none">
<span className="a11y-toggle-status-label select-none text-xs font-medium text-gray-600 dark:text-gray-400 min-w-[2rem] text-center">
{active ? "ON" : "OFF"}
</span>
</div>

View File

@ -44,7 +44,9 @@ export const AvatarInitials: React.FC<AvatarInitialsProps> = ({
const style: React.CSSProperties = {
width: size,
height: size,
lineHeight: `${size}px`,
minWidth: size,
minHeight: size,
flexShrink: 0,
};
const fontSize = Math.max(14, Math.round(size * 0.42));
return (

View File

@ -1,142 +1,132 @@
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { Heart, Stethoscope, User, Clipboard, LogOut } from "lucide-react";
import { Link } from "react-router-dom";
import { Heart, LogOut, LogIn } from "lucide-react";
import { useAuth } from "../hooks/useAuth";
import Logo from "./images/logo.PNG"; // caminho relativo ao arquivo
import { ProfileSelector } from "./ProfileSelector";
import { i18n } from "../i18n";
import Logo from "./images/logo.PNG";
const Header: React.FC = () => {
const location = useLocation();
const isActive = (path: string) => {
return location.pathname === path;
};
const { user, logout, role, isAuthenticated } = useAuth();
const roleLabel: Record<string, string> = {
secretaria: "Secretaria",
medico: "Médico",
paciente: "Paciente",
admin: "Administrador",
gestor: "Gestor",
};
return (
<header className="bg-white shadow-lg border-b border-gray-200">
{/* Skip to content link for accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white focus:outline-none"
>
{i18n.t("common.skipToContent")}
</a>
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center space-x-3">
<Link
to="/"
className="flex items-center space-x-3 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-lg"
>
<img
src={Logo}
alt="MediConnect"
className="h-10 w-10 rounded-lg object-contain shadow-sm"
alt={i18n.t("header.logo")}
className="h-14 w-14 rounded-lg object-contain shadow-sm"
/>
<div>
<h1 className="text-xl font-bold text-gray-900">MediConnect</h1>
<p className="text-xs text-gray-500">Sistema de Agendamento</p>
<h1 className="text-xl font-bold text-gray-900">
{i18n.t("header.logo")}
</h1>
<p className="text-xs text-gray-500">
{i18n.t("header.subtitle")}
</p>
</div>
</Link>
{/* Navigation */}
<nav className="hidden md:flex items-center space-x-1">
{/* Desktop Navigation */}
<nav
className="hidden md:flex items-center space-x-2"
aria-label="Navegação principal"
>
<Link
to="/"
className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive("/")
? "bg-gradient-to-r from-blue-700 to-blue-400 text-white"
: "text-gray-600 hover:text-blue-600 hover:bg-blue-50"
}`}
className="flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:text-blue-600 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Heart className="w-4 h-4" />
<span>Início</span>
<Heart className="w-4 h-4" aria-hidden="true" />
<span>{i18n.t("header.home")}</span>
</Link>
<Link
to="/paciente"
className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive("/paciente") || isActive("/agendamento")
? "bg-gradient-to-r from-blue-700 to-blue-400 text-white"
: "text-gray-600 hover:text-blue-600 hover:bg-blue-50"
}`}
>
<User className="w-4 h-4" />
<span>Sou Paciente</span>
</Link>
{/* Profile Selector */}
<ProfileSelector />
<Link
to="/login-secretaria"
className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive("/login-secretaria") || isActive("/secretaria")
? "bg-gradient-to-r from-green-600 to-green-400 text-white"
: "text-gray-600 hover:text-green-600 hover:bg-green-50"
}`}
>
<Clipboard className="w-4 h-4" />
<span> Menu da Secretaria</span>
</Link>
<Link
to="/login-medico"
className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive("/login-medico") || isActive("/medico")
? "bg-gradient-to-r from-indigo-600 to-indigo-400 text-white"
: "text-gray-600 hover:text-indigo-600 hover:bg-indigo-50"
}`}
>
<Stethoscope className="w-4 h-4" />
<span>Sou Médico</span>
</Link>
{/* Link Admin - Apenas para admins e gestores */}
{/* Admin Link */}
{isAuthenticated && (role === "admin" || role === "gestor") && (
<Link
to="/admin"
className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive("/admin")
? "bg-gradient-to-r from-purple-600 to-pink-600 text-white"
: "text-gray-600 hover:text-purple-600 hover:bg-purple-50"
}`}
className="flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:text-purple-600 hover:bg-purple-50 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
>
<User className="w-4 h-4" />
<span>Painel Admin</span>
</Link>
)}
</nav>
{/* Sessão / Logout */}
<div className="hidden md:flex items-center space-x-4">
{/* User Session / Auth */}
<div className="hidden md:flex items-center space-x-3">
{isAuthenticated && user ? (
<>
<div className="text-right leading-tight">
<p className="text-sm font-medium text-gray-700 truncate max-w-[160px]">
{user.nome}
<div className="text-right leading-tight min-w-0 flex-shrink">
<p
className="text-sm font-medium text-gray-700 truncate max-w-[120px]"
title={user.nome}
>
{user.nome.split(" ").slice(0, 2).join(" ")}
</p>
<p className="text-xs text-gray-500">
<p className="text-xs text-gray-500 whitespace-nowrap">
{role ? roleLabel[role] || role : ""}
</p>
</div>
<button
onClick={logout}
className="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 transition-colors"
title="Sair"
className="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex-shrink-0"
aria-label={i18n.t("header.logout")}
>
<LogOut className="w-4 h-4 mr-1" />
Sair
<LogOut className="w-4 h-4 mr-1" aria-hidden="true" />
<span className="hidden lg:inline">
{i18n.t("header.logout")}
</span>
<span className="lg:hidden">Sair</span>
</button>
</>
) : (
<p className="text-xs text-gray-400">Não autenticado</p>
<Link
to="/paciente"
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md bg-gradient-to-r from-blue-700 to-blue-400 hover:from-blue-800 hover:to-blue-500 text-white transition-all focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 shadow-sm hover:shadow-md"
aria-label={i18n.t("header.login")}
>
<LogIn className="w-4 h-4 mr-2" aria-hidden="true" />
{i18n.t("header.login")}
</Link>
)}
</div>
{/* Mobile menu button */}
<div className="md:hidden">
<button className="text-gray-600 hover:text-blue-600">
<button
className="text-gray-600 hover:text-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-2"
aria-label="Menu de navegação"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
@ -154,72 +144,48 @@ const Header: React.FC = () => {
<div className="flex flex-col space-y-2">
<Link
to="/"
className={`flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive("/")
? "bg-gradient-to-r from-blue-700 to-blue-400 text-white"
: "text-gray-600 hover:text-blue-600 hover:bg-blue-50"
}`}
className="flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:text-blue-600 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<Heart className="w-4 h-4" />
<span>Início</span>
<Heart className="w-4 h-4" aria-hidden="true" />
<span>{i18n.t("header.home")}</span>
</Link>
<Link
to="/paciente"
className={`flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive("/paciente") || isActive("/agendamento")
? "bg-gradient-to-r from-blue-700 to-blue-400 text-white"
: "text-gray-600 hover:text-blue-600 hover:bg-blue-50"
}`}
>
<User className="w-4 h-4" />
<span>Sou Paciente</span>
</Link>
<div className="px-3 py-2">
<ProfileSelector />
</div>
<Link
to="/login-secretaria"
className={`flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive("/login-secretaria") || isActive("/secretaria")
? "bg-gradient-to-r from-green-600 to-green-400 text-white"
: "text-gray-600 hover:text-green-600 hover:bg-green-50"
}`}
>
<Clipboard className="w-4 h-4" />
<span>Secretaria</span>
</Link>
<Link
to="/login-medico"
className={`flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive("/login-medico") || isActive("/medico")
? "bg-gradient-to-r from-indigo-600 to-indigo-400 text-white"
: "text-gray-600 hover:text-indigo-600 hover:bg-indigo-50"
}`}
>
<Stethoscope className="w-4 h-4" />
<span>Sou Médico</span>
</Link>
{/* Sessão mobile */}
<div className="mt-4 flex items-center justify-between bg-gray-50 px-3 py-2 rounded-md">
{isAuthenticated && user ? (
<div className="flex-1 mr-3">
<p className="text-sm font-medium text-gray-700 truncate">
{user.nome}
</p>
<p className="text-xs text-gray-500">
{role ? roleLabel[role] || role : ""}
</p>
</div>
<>
<div className="flex-1 mr-3 min-w-0">
<p
className="text-sm font-medium text-gray-700 truncate"
title={user.nome}
>
{user.nome.split(" ").slice(0, 2).join(" ")}
</p>
<p className="text-xs text-gray-500">
{role ? roleLabel[role] || role : ""}
</p>
</div>
<button
onClick={logout}
className="inline-flex items-center px-3 py-2 text-xs font-medium rounded bg-gray-200 text-gray-700 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 flex-shrink-0"
aria-label={i18n.t("header.logout")}
>
<LogOut className="w-4 h-4 mr-1" />
<span>Sair</span>
</button>
</>
) : (
<p className="text-xs text-gray-400">Não autenticado</p>
)}
{isAuthenticated && (
<button
onClick={logout}
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded bg-gray-200 text-gray-700 hover:bg-gray-300"
<Link
to="/paciente"
className="flex-1 inline-flex items-center justify-center px-3 py-2 text-sm font-medium rounded bg-gradient-to-r from-blue-700 to-blue-400 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<LogOut className="w-4 h-4" />
</button>
<LogIn className="w-4 h-4 mr-2" aria-hidden="true" />
{i18n.t("header.login")}
</Link>
)}
</div>
</div>

View File

@ -0,0 +1,188 @@
import React from "react";
import { LucideIcon, AlertCircle } from "lucide-react";
export interface MetricCardProps {
title: string;
value: number | string;
icon: LucideIcon;
iconColor: string;
iconBgColor: string;
description: string;
loading?: boolean;
error?: boolean;
emptyAction?: {
label: string;
onClick: () => void;
};
ariaLabel?: string;
}
const MetricCardSkeleton: React.FC = () => (
<div
className="bg-white rounded-lg shadow-md p-6 animate-pulse"
role="status"
aria-label="Carregando métrica"
>
<div className="flex items-center">
<div className="w-12 h-12 bg-gray-200 rounded-full" />
<div className="ml-4 flex-1">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-8 bg-gray-200 rounded w-1/2" />
</div>
</div>
</div>
);
const MetricCardError: React.FC<{ title: string; onRetry?: () => void }> = ({
title,
onRetry,
}) => (
<div
className="bg-white rounded-lg shadow-md p-6 border-2 border-red-200"
role="alert"
aria-live="polite"
>
<div className="flex items-center">
<div className="p-3 bg-red-100 rounded-full">
<AlertCircle className="w-6 h-6 text-red-600" />
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-sm text-red-600 mt-1">Erro ao carregar</p>
{onRetry && (
<button
onClick={onRetry}
className="mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded px-2 py-1"
aria-label="Tentar carregar novamente"
>
Tentar novamente
</button>
)}
</div>
</div>
</div>
);
const MetricCardEmpty: React.FC<{
title: string;
icon: LucideIcon;
iconColor: string;
iconBgColor: string;
emptyAction: { label: string; onClick: () => void };
}> = ({ title, icon: Icon, iconColor, iconBgColor, emptyAction }) => (
<div className="bg-white rounded-lg shadow-md p-6 border-2 border-gray-100">
<div className="flex items-center">
<div className={`p-3 ${iconBgColor} rounded-full`}>
<Icon className={`w-6 h-6 ${iconColor}`} />
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-2xl font-bold text-gray-900">0</p>
<button
onClick={emptyAction.onClick}
className="mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded px-2 py-1 transition-colors"
aria-label={emptyAction.label}
>
{emptyAction.label}
</button>
</div>
</div>
</div>
);
export const MetricCard: React.FC<MetricCardProps> = ({
title,
value,
icon: Icon,
iconColor,
iconBgColor,
description,
loading = false,
error = false,
emptyAction,
ariaLabel,
}) => {
if (loading) {
return <MetricCardSkeleton />;
}
if (error) {
return <MetricCardError title={title} />;
}
const numericValue =
typeof value === "number" ? value : parseInt(String(value), 10) || 0;
if (numericValue === 0 && emptyAction) {
return (
<MetricCardEmpty
title={title}
icon={Icon}
iconColor={iconColor}
iconBgColor={iconBgColor}
emptyAction={emptyAction}
/>
);
}
return (
<div
className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow group"
role="region"
aria-label={ariaLabel || title}
>
<div className="flex items-center relative">
<div
className={`p-3 ${iconBgColor} rounded-full group-hover:scale-110 transition-transform`}
>
<Icon className={`w-6 h-6 ${iconColor}`} aria-hidden="true" />
</div>
<div className="ml-4 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-gray-600">{title}</p>
{/* Tooltip */}
<div className="relative group/tooltip">
<button
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-full p-0.5"
aria-label={`Informações sobre ${title}`}
type="button"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<div
className="absolute z-10 invisible group-hover/tooltip:visible opacity-0 group-hover/tooltip:opacity-100 transition-opacity bg-gray-900 text-white text-xs rounded-lg py-2 px-3 bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-48 pointer-events-none"
role="tooltip"
>
{description}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 -mt-1">
<div className="border-4 border-transparent border-t-gray-900" />
</div>
</div>
</div>
</div>
<p
className="text-2xl font-bold text-gray-900 tabular-nums"
aria-live="polite"
>
{value}
</p>
</div>
</div>
</div>
);
};
export default MetricCard;

View File

@ -0,0 +1,206 @@
import React, { useState, useEffect, useRef } from "react";
import { User, Stethoscope, Clipboard, ChevronDown } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { i18n } from "../i18n";
import { telemetry } from "../services/telemetry";
export type ProfileType = "patient" | "doctor" | "secretary" | null;
interface ProfileOption {
type: ProfileType;
icon: typeof User;
label: string;
description: string;
path: string;
color: string;
bgColor: string;
}
const profileOptions: ProfileOption[] = [
{
type: "patient",
icon: User,
label: i18n.t("profiles.patient"),
description: i18n.t("profiles.patientDescription"),
path: "/paciente",
color: "text-blue-600",
bgColor: "bg-blue-50 hover:bg-blue-100",
},
{
type: "doctor",
icon: Stethoscope,
label: i18n.t("profiles.doctor"),
description: i18n.t("profiles.doctorDescription"),
path: "/login-medico",
color: "text-indigo-600",
bgColor: "bg-indigo-50 hover:bg-indigo-100",
},
{
type: "secretary",
icon: Clipboard,
label: i18n.t("profiles.secretary"),
description: i18n.t("profiles.secretaryDescription"),
path: "/login-secretaria",
color: "text-green-600",
bgColor: "bg-green-50 hover:bg-green-100",
},
];
export const ProfileSelector: React.FC = () => {
const [selectedProfile, setSelectedProfile] = useState<ProfileType>(null);
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Carregar perfil salvo
const saved = localStorage.getItem(
"mediconnect_selected_profile"
) as ProfileType;
if (saved) {
setSelectedProfile(saved);
}
}, []);
useEffect(() => {
// Fechar ao clicar fora
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
const handleProfileSelect = (profile: ProfileOption) => {
const previousProfile = selectedProfile;
setSelectedProfile(profile.type);
setIsOpen(false);
// Persistir escolha
if (profile.type) {
localStorage.setItem("mediconnect_selected_profile", profile.type);
}
// Telemetria
telemetry.trackProfileChange(previousProfile, profile.type || "none");
// Navegar
navigate(profile.path);
};
const getCurrentProfile = () => {
return profileOptions.find((p) => p.type === selectedProfile);
};
const currentProfile = getCurrentProfile();
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
currentProfile
? `${currentProfile.bgColor} ${currentProfile.color}`
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
aria-expanded={isOpen}
aria-haspopup="true"
aria-label={i18n.t("header.selectProfile")}
>
{currentProfile ? (
<>
<currentProfile.icon className="w-4 h-4" aria-hidden="true" />
<span className="hidden md:inline">{currentProfile.label}</span>
</>
) : (
<>
<User className="w-4 h-4" aria-hidden="true" />
<span className="hidden md:inline">{i18n.t("header.profile")}</span>
</>
)}
<ChevronDown
className={`w-4 h-4 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
</button>
{isOpen && (
<div
className="absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-xl border border-gray-200 z-50 animate-in fade-in slide-in-from-top-2 duration-200"
role="menu"
aria-orientation="vertical"
>
<div className="p-2">
<p className="px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">
{i18n.t("header.selectProfile")}
</p>
{profileOptions.map((profile) => (
<button
key={profile.type}
onClick={() => handleProfileSelect(profile)}
className={`w-full flex items-start gap-3 px-3 py-3 rounded-lg transition-colors text-left focus:outline-none focus:ring-2 focus:ring-blue-500 ${
profile.type === selectedProfile
? `${profile.bgColor} ${profile.color}`
: "hover:bg-gray-50 text-gray-700"
}`}
role="menuitem"
aria-label={`Selecionar perfil ${profile.label}`}
>
<div
className={`p-2 rounded-lg ${
profile.type === selectedProfile
? "bg-white"
: profile.bgColor
}`}
>
<profile.icon
className={`w-5 h-5 ${profile.color}`}
aria-hidden="true"
/>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{profile.label}</p>
<p className="text-xs text-gray-600 mt-0.5">
{profile.description}
</p>
</div>
{profile.type === selectedProfile && (
<div className="flex-shrink-0 pt-1">
<svg
className={`w-5 h-5 ${profile.color}`}
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
)}
</button>
))}
</div>
</div>
)}
</div>
);
};
export default ProfileSelector;

View File

@ -88,21 +88,47 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
const [user, setUser] = useState<SessionUser | null>(null);
const [loading, setLoading] = useState(true);
// Restaurar sessão do localStorage
// Restaurar sessão do localStorage e verificar token
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as PersistedSession;
if (parsed?.user?.role) {
setUser(parsed.user);
const restoreSession = async () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as PersistedSession;
if (parsed?.user?.role) {
console.log("[AuthContext] Restaurando sessão:", parsed.user);
// Verificar se há tokens válidos salvos
if (parsed.token) {
console.log("[AuthContext] Restaurando tokens no tokenStore");
// Restaurar tokens no tokenStore
const tokenStore = (await import("../services/tokenStore"))
.default;
tokenStore.setTokens(parsed.token, parsed.refreshToken);
} else {
console.warn("[AuthContext] Sessão encontrada mas sem token. Verificando tokenStore...");
// Verificar se há token no tokenStore (pode ter sido salvo diretamente)
const tokenStore = (await import("../services/tokenStore"))
.default;
const existingToken = tokenStore.getAccessToken();
if (existingToken) {
console.log("[AuthContext] Token encontrado no tokenStore, mantendo sessão");
} else {
console.warn("[AuthContext] Nenhum token encontrado. Sessão pode estar inválida.");
}
}
setUser(parsed.user);
}
} else {
console.log("[AuthContext] Nenhuma sessão salva encontrada");
}
} catch (error) {
console.error("[AuthContext] Erro ao restaurar sessão:", error);
} finally {
setLoading(false);
}
} catch {
// ignorar
} finally {
setLoading(false);
}
};
void restoreSession();
}, []);
const persist = useCallback((session: PersistedSession) => {

View File

@ -0,0 +1,110 @@
/**
* English (US) Translations
*/
export const enUS = {
common: {
skipToContent: "Skip to content",
loading: "Loading...",
error: "Error",
retry: "Try again",
cancel: "Cancel",
confirm: "Confirm",
close: "Close",
save: "Save",
edit: "Edit",
delete: "Delete",
search: "Search",
filter: "Filter",
viewAll: "View all",
noData: "No data available",
},
header: {
logo: "MediConnect",
subtitle: "Appointment System",
home: "Home",
login: "Login",
logout: "Logout",
notAuthenticated: "Not authenticated",
profile: "Profile",
selectProfile: "Select your profile",
},
profiles: {
patient: "Patient",
doctor: "Doctor",
secretary: "Secretary",
patientDescription: "Schedule and track appointments",
doctorDescription: "Manage appointments and patients",
secretaryDescription: "Registration and scheduling",
},
home: {
hero: {
title: "Medical Appointment System",
subtitle:
"Connecting patients and healthcare professionals efficiently and securely",
ctaPrimary: "Schedule appointment",
ctaSecondary: "View upcoming appointments",
},
metrics: {
totalPatients: "Total Patients",
totalPatientsDescription:
"Total number of patients registered in the system",
activeDoctors: "Active Doctors",
activeDoctorsDescription: "Professionals available for care",
todayAppointments: "Today's Appointments",
todayAppointmentsDescription: "Appointments scheduled for today",
pendingAppointments: "Pending",
pendingAppointmentsDescription:
"Scheduled or confirmed appointments awaiting completion",
},
emptyStates: {
noPatients: "No patients registered",
noDoctors: "No doctors registered",
noAppointments: "No appointments scheduled",
registerPatient: "Register patient",
inviteDoctor: "Invite doctor",
scheduleAppointment: "Schedule appointment",
},
actionCards: {
scheduleAppointment: {
title: "Schedule Appointment",
description: "Book medical appointments quickly and easily",
cta: "Go to Scheduling",
ctaAriaLabel: "Go to appointment scheduling page",
},
doctorPanel: {
title: "Doctor Panel",
description: "Manage appointments, schedules and records",
cta: "Access Panel",
ctaAriaLabel: "Go to doctor panel",
},
patientManagement: {
title: "Patient Management",
description: "Register and manage patient information",
cta: "Access Registration",
ctaAriaLabel: "Go to patient registration area",
},
},
upcomingConsultations: {
title: "Upcoming Appointments",
empty: "No appointments scheduled",
viewAll: "View all appointments",
date: "Date",
time: "Time",
patient: "Patient",
doctor: "Doctor",
status: "Status",
statusScheduled: "Scheduled",
statusConfirmed: "Confirmed",
statusCompleted: "Completed",
statusCanceled: "Canceled",
statusMissed: "Missed",
},
errorLoadingStats: "Error loading statistics",
},
accessibility: {
reducedMotion: "Reduced motion preference detected",
highContrast: "High contrast",
largeText: "Large text",
darkMode: "Dark mode",
},
};

View File

@ -0,0 +1,88 @@
import { ptBR, TranslationKeys } from "./pt-BR";
import { enUS } from "./en-US";
type Locale = "pt-BR" | "en-US";
const translations: Record<Locale, TranslationKeys> = {
"pt-BR": ptBR,
"en-US": enUS as TranslationKeys,
};
class I18n {
private currentLocale: Locale = "pt-BR";
constructor() {
// Detectar idioma do navegador
const browserLang = navigator.language;
if (browserLang.startsWith("en")) {
this.currentLocale = "en-US";
}
// Carregar preferência salva
const savedLocale = localStorage.getItem("mediconnect_locale") as Locale;
if (savedLocale && translations[savedLocale]) {
this.currentLocale = savedLocale;
}
}
public t(key: string): string {
const keys = key.split(".");
let value: Record<string, unknown> | string =
translations[this.currentLocale];
for (const k of keys) {
if (typeof value === "object" && value && k in value) {
value = value[k] as Record<string, unknown> | string;
} else {
console.warn(`Translation key not found: ${key}`);
return key;
}
}
return typeof value === "string" ? value : key;
}
public setLocale(locale: Locale): void {
if (translations[locale]) {
this.currentLocale = locale;
localStorage.setItem("mediconnect_locale", locale);
// Atualizar lang do HTML
document.documentElement.lang = locale;
}
}
public getLocale(): Locale {
return this.currentLocale;
}
public formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat(this.currentLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(d);
}
public formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat(this.currentLocale, {
hour: "2-digit",
minute: "2-digit",
}).format(d);
}
public formatDateTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat(this.currentLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(d);
}
}
export const i18n = new I18n();
export type { Locale };

View File

@ -0,0 +1,112 @@
/**
* Traduções em Português do Brasil
*/
export const ptBR = {
common: {
skipToContent: "Pular para o conteúdo",
loading: "Carregando...",
error: "Erro",
retry: "Tentar novamente",
cancel: "Cancelar",
confirm: "Confirmar",
close: "Fechar",
save: "Salvar",
edit: "Editar",
delete: "Excluir",
search: "Pesquisar",
filter: "Filtrar",
viewAll: "Ver todas",
noData: "Nenhum dado disponível",
},
header: {
logo: "MediConnect",
subtitle: "Sistema de Agendamento",
home: "Início",
login: "Entrar",
logout: "Sair",
notAuthenticated: "Não autenticado",
profile: "Perfil",
selectProfile: "Selecione seu perfil",
},
profiles: {
patient: "Paciente",
doctor: "Médico",
secretary: "Secretária",
patientDescription: "Agendar e acompanhar consultas",
doctorDescription: "Gerenciar consultas e pacientes",
secretaryDescription: "Cadastros e agendamentos",
},
home: {
hero: {
title: "Sistema de Agendamento Médico",
subtitle:
"Conectando pacientes e profissionais de saúde com eficiência e segurança",
ctaPrimary: "Agendar consulta",
ctaSecondary: "Ver próximas consultas",
},
metrics: {
totalPatients: "Total de Pacientes",
totalPatientsDescription:
"Número total de pacientes cadastrados no sistema",
activeDoctors: "Médicos Ativos",
activeDoctorsDescription: "Profissionais disponíveis para atendimento",
todayAppointments: "Consultas Hoje",
todayAppointmentsDescription: "Consultas agendadas para hoje",
pendingAppointments: "Pendentes",
pendingAppointmentsDescription:
"Consultas agendadas ou confirmadas aguardando realização",
},
emptyStates: {
noPatients: "Nenhum paciente cadastrado",
noDoctors: "Nenhum médico cadastrado",
noAppointments: "Nenhuma consulta agendada",
registerPatient: "Cadastrar paciente",
inviteDoctor: "Convidar médico",
scheduleAppointment: "Agendar consulta",
},
actionCards: {
scheduleAppointment: {
title: "Agendar Consulta",
description: "Agende consultas médicas de forma rápida e prática",
cta: "Acessar Agendamento",
ctaAriaLabel: "Ir para página de agendamento de consultas",
},
doctorPanel: {
title: "Painel do Médico",
description: "Gerencie consultas, horários e prontuários",
cta: "Acessar Painel",
ctaAriaLabel: "Ir para painel do médico",
},
patientManagement: {
title: "Gestão de Pacientes",
description: "Cadastre e gerencie informações de pacientes",
cta: "Acessar Cadastro",
ctaAriaLabel: "Ir para área de cadastro de pacientes",
},
},
upcomingConsultations: {
title: "Próximas Consultas",
empty: "Nenhuma consulta agendada",
viewAll: "Ver todas as consultas",
date: "Data",
time: "Horário",
patient: "Paciente",
doctor: "Médico",
status: "Status",
statusScheduled: "Agendada",
statusConfirmed: "Confirmada",
statusCompleted: "Realizada",
statusCanceled: "Cancelada",
statusMissed: "Faltou",
},
errorLoadingStats: "Erro ao carregar estatísticas",
},
accessibility: {
reducedMotion: "Preferência por movimento reduzido detectada",
highContrast: "Alto contraste",
largeText: "Texto aumentado",
darkMode: "Modo escuro",
},
};
export type TranslationKeys = typeof ptBR;

View File

@ -23,7 +23,6 @@ interface Paciente {
email: string;
}
const AgendamentoPaciente: React.FC = () => {
const [medicos, setMedicos] = useState<Medico[]>([]);
const [pacienteLogado, setPacienteLogado] = useState<Paciente | null>(null);
@ -46,16 +45,23 @@ const AgendamentoPaciente: React.FC = () => {
// Verificar se paciente está logado
const pacienteData = localStorage.getItem("pacienteLogado");
if (!pacienteData) {
console.log(
"[AgendamentoPaciente] Paciente não logado, redirecionando..."
);
navigate("/paciente");
return;
}
try {
const paciente = JSON.parse(pacienteData);
console.log("[AgendamentoPaciente] Paciente logado:", paciente);
setPacienteLogado(paciente);
fetchMedicos();
void fetchMedicos();
} catch (error) {
console.error("Erro ao carregar dados do paciente:", error);
console.error(
"[AgendamentoPaciente] Erro ao carregar dados do paciente:",
error
);
navigate("/paciente");
}
}, [navigate]);
@ -64,8 +70,40 @@ const AgendamentoPaciente: React.FC = () => {
const fetchMedicos = async () => {
try {
console.log("[AgendamentoPaciente] Iniciando busca de médicos...");
// Verificar se há token disponível
const tokenStore = (await import("../services/tokenStore")).default;
const token = tokenStore.getAccessToken();
console.log(
"[AgendamentoPaciente] Token disponível:",
token ? "SIM" : "NÃO"
);
if (!token) {
console.warn(
"[AgendamentoPaciente] Nenhum token encontrado - requisição pode falhar"
);
}
const response = await medicoService.listarMedicos({ status: "ativo" });
console.log("[AgendamentoPaciente] Resposta da API:", response);
if (!response.success) {
console.error(
"[AgendamentoPaciente] Erro na resposta:",
response.error
);
toast.error(response.error || "Erro ao carregar médicos");
return;
}
const list = response.data?.data || [];
console.log(
"[AgendamentoPaciente] Médicos recebidos:",
list.length,
list
);
const mapped: Medico[] = list.map((m) => ({
_id: m.id || Math.random().toString(36).slice(2, 9),
nome: m.nome || "",
@ -73,9 +111,26 @@ const AgendamentoPaciente: React.FC = () => {
valorConsulta: 0,
horarioAtendimento: {},
}));
console.log("[AgendamentoPaciente] Médicos mapeados:", mapped);
setMedicos(mapped);
if (mapped.length === 0) {
if (response.error && response.error.includes("404")) {
toast.error(
"⚠️ Tabela de médicos não existe no banco de dados. Configure o Supabase primeiro.",
{
duration: 6000,
}
);
} else {
toast.error(
"Nenhum médico ativo encontrado. Por favor, cadastre médicos primeiro."
);
}
}
} catch (error) {
console.error("Erro ao carregar médicos:", error);
console.error("[AgendamentoPaciente] Erro ao carregar médicos:", error);
toast.error("Erro ao carregar lista de médicos");
}
};

View File

@ -1,8 +1,12 @@
import React, { useState, useEffect } from "react";
import { Calendar, Users, UserCheck, Clock } from "lucide-react";
import { Calendar, Users, UserCheck, Clock, ArrowRight } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { listPatients } from "../services/pacienteService";
import medicoService from "../services/medicoService";
import consultaService from "../services/consultaService";
import { MetricCard } from "../components/MetricCard";
import { i18n } from "../i18n";
import { telemetry } from "../services/telemetry";
const Home: React.FC = () => {
const [stats, setStats] = useState({
@ -11,161 +15,280 @@ const Home: React.FC = () => {
consultasHoje: 0,
consultasPendentes: 0,
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const fetchStats = async () => {
try {
const [pacientesResult, medicosResult, consultasResult] =
await Promise.all([
listPatients(),
medicoService.listarMedicos(),
consultaService.listarConsultas(),
]);
const hoje = new Date().toISOString().split("T")[0];
const consultas = consultasResult.data?.data || [];
const consultasHoje =
consultas.filter((consulta) => consulta.data_hora?.startsWith(hoje))
.length || 0;
const consultasPendentes =
consultas.filter(
(consulta) =>
consulta.status === "agendada" || consulta.status === "confirmada"
).length || 0;
const medicos = medicosResult.data?.data || [];
setStats({
totalPacientes: pacientesResult.data?.length || 0,
totalMedicos: medicos.length || 0,
consultasHoje,
consultasPendentes,
});
} catch (error) {
console.error("Erro ao carregar estatísticas:", error);
}
};
fetchStats();
}, []);
const fetchStats = async () => {
try {
setLoading(true);
setError(false);
const [pacientesResult, medicosResult, consultasResult] =
await Promise.all([
listPatients().catch(() => ({ data: [] })),
medicoService.listarMedicos().catch(() => ({ data: { data: [] } })),
consultaService
.listarConsultas()
.catch(() => ({ data: { data: [] } })),
]);
const hoje = new Date().toISOString().split("T")[0];
const consultas = consultasResult.data?.data || [];
const consultasHoje =
consultas.filter((consulta) => consulta.data_hora?.startsWith(hoje))
.length || 0;
const consultasPendentes =
consultas.filter(
(consulta) =>
consulta.status === "agendada" || consulta.status === "confirmada"
).length || 0;
const medicos = medicosResult.data?.data || [];
setStats({
totalPacientes: pacientesResult.data?.length || 0,
totalMedicos: medicos.length || 0,
consultasHoje,
consultasPendentes,
});
} catch (err) {
console.error("Erro ao carregar estatísticas:", err);
setError(true);
telemetry.trackError("stats_load_error", String(err));
} finally {
setLoading(false);
}
};
const handleCTA = (action: string, destination: string) => {
telemetry.trackCTA(action, destination);
navigate(destination);
};
return (
<div className="space-y-8">
<div className="space-y-8" id="main-content">
{/* Hero Section */}
<div className="text-center py-12 bg-gradient-to-l from-blue-800 to-blue-500 text-white rounded-xl shadow-lg">
<h1 className="text-4xl font-bold mb-4">
Sistema de Agendamento Médico
</h1>
<p className="text-xl opacity-90">
Gerencie consultas, pacientes e médicos de forma eficiente
</p>
</div>
{/* Estatísticas */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center">
<div className="p-3 bg-gradient-to-l from-blue-700 to-blue-400 rounded-full">
<Users className="w-6 h-6 text-white" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
Total de Pacientes
</p>
<p className="text-2xl font-bold text-gray-900">
{stats.totalPacientes}
</p>
</div>
</div>
<div className="relative text-center py-8 md:py-12 lg:py-16 bg-gradient-to-r from-blue-800 via-blue-600 to-blue-500 text-white rounded-xl shadow-lg overflow-hidden">
{/* Decorative Pattern */}
<div className="absolute inset-0 opacity-10">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern
id="grid"
width="40"
height="40"
patternUnits="userSpaceOnUse"
>
<circle cx="20" cy="20" r="1" fill="white" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center">
<div className="p-3 bg-green-100 rounded-full">
<UserCheck className="w-6 h-6 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
Médicos Ativos
</p>
<p className="text-2xl font-bold text-gray-900">
{stats.totalMedicos}
</p>
</div>
</div>
</div>
<div className="relative z-10 px-4 max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-3 md:mb-4">
{i18n.t("home.hero.title")}
</h1>
<p className="text-base md:text-lg lg:text-xl opacity-95 mb-6 md:mb-8 max-w-2xl mx-auto">
{i18n.t("home.hero.subtitle")}
</p>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center">
<div className="p-3 bg-yellow-100 rounded-full">
<Calendar className="w-6 h-6 text-yellow-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
Consultas Hoje
</p>
<p className="text-2xl font-bold text-gray-900">
{stats.consultasHoje}
</p>
</div>
</div>
</div>
{/* CTAs */}
<div className="flex flex-col sm:flex-row gap-3 md:gap-4 justify-center items-center">
<button
onClick={() => handleCTA("Agendar consulta", "/paciente")}
className="group w-full sm:w-auto inline-flex items-center justify-center px-6 md:px-8 py-3 md:py-4 bg-white text-blue-700 rounded-lg font-semibold hover:bg-blue-50 hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-4 focus:ring-blue-300 focus:ring-offset-2 focus:ring-offset-blue-600"
aria-label={i18n.t(
"home.actionCards.scheduleAppointment.ctaAriaLabel"
)}
>
<Calendar
className="w-5 h-5 mr-2 group-hover:scale-110 transition-transform"
aria-hidden="true"
/>
{i18n.t("home.hero.ctaPrimary")}
<ArrowRight
className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform"
aria-hidden="true"
/>
</button>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center">
<div className="p-3 bg-purple-100 rounded-full">
<Clock className="w-6 h-6 text-purple-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Pendentes</p>
<p className="text-2xl font-bold text-gray-900">
{stats.consultasPendentes}
</p>
</div>
<button
onClick={() => handleCTA("Ver próximas consultas", "/consultas")}
className="group w-full sm:w-auto inline-flex items-center justify-center px-6 md:px-8 py-3 md:py-4 bg-blue-700 text-white rounded-lg font-semibold hover:bg-blue-800 hover:shadow-xl border-2 border-white/20 transition-all duration-200 focus:outline-none focus:ring-4 focus:ring-white/50 focus:ring-offset-2 focus:ring-offset-blue-600"
aria-label="Ver lista de próximas consultas"
>
<Clock className="w-5 h-5 mr-2" aria-hidden="true" />
{i18n.t("home.hero.ctaSecondary")}
</button>
</div>
</div>
</div>
{/* Acesso Rápido */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
<div className="w-12 h-12 bg-gradient-to-l from-blue-700 to-blue-400 rounded-lg flex items-center justify-center mb-4">
<Calendar className="w-6 h-6 text-white" />
</div>
<h3 className="text-lg font-semibold mb-2">Agendar Consulta</h3>
<p className="text-gray-600 mb-4">
Interface para pacientes agendarem suas consultas médicas
</p>
<a href="/paciente" className="btn-primary inline-block">
Acessar Agendamento
</a>
</div>
{/* Métricas */}
<div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 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
}`}
/>
<div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
<UserCheck className="w-12 h-12 text-green-600 mb-4" />
<h3 className="text-lg font-semibold mb-2">Painel do Médico</h3>
<p className="text-gray-600 mb-4 whitespace-nowrap">
Gerencie suas consultas, horários e informações dos pacientes
</p>
<a href="/login-medico" className="btn-primary inline-block">
Acessar Painel
</a>
</div>
<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
}`}
/>
<div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
<Users className="w-12 h-12 text-purple-600 mb-4" />
<h3 className="text-lg font-semibold mb-2">Cadastro de Pacientes</h3>
<p className="text-gray-600 mb-4">
Área da secretaria para cadastrar e gerenciar pacientes
</p>
<a href="/login-secretaria" className="btn-primary inline-block">
Acessar Cadastro
</a>
</div>
<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-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-5 md:p-6 hover:shadow-xl transition-all duration-200 group border border-gray-100">
<div
className={`w-12 h-12 ${iconBgColor} rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}
>
<Icon className={`w-6 h-6 text-white`} aria-hidden="true" />
</div>
<h3 className="text-lg font-semibold mb-2 text-gray-900">{title}</h3>
<p className="text-sm text-gray-600 mb-4 leading-relaxed">
{description}
</p>
<button
onClick={onAction}
className="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gradient-to-r from-blue-700 to-blue-400 hover:from-blue-800 hover:to-blue-500 text-white rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-4 focus:ring-blue-300 focus:ring-offset-2 group-hover:shadow-lg"
aria-label={ctaAriaLabel}
>
{ctaLabel}
<ArrowRight
className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform"
aria-hidden="true"
/>
</button>
</div>
);
};
export default Home;

View File

@ -86,12 +86,37 @@ const LoginPaciente: React.FC = () => {
return;
}
console.log("[LoginPaciente] Login bem-sucedido!");
console.log("[LoginPaciente] Login bem-sucedido!", loginResult.data);
// Verificar se o token foi salvo
const tokenStore = (await import("../services/tokenStore")).default;
const token = tokenStore.getAccessToken();
const refreshToken = tokenStore.getRefreshToken();
console.log("[LoginPaciente] Token salvo:", token ? "SIM" : "NÃO");
console.log(
"[LoginPaciente] Refresh token salvo:",
refreshToken ? "SIM" : "NÃO"
);
if (!token) {
console.error(
"[LoginPaciente] Token não foi salvo! Dados do login:",
loginResult.data
);
toast.error("Erro ao salvar credenciais de autenticação");
setLoading(false);
return;
}
// Buscar dados do paciente da API
const { listPatients } = await import("../services/pacienteService");
const pacientesResult = await listPatients({ search: formData.email });
console.log(
"[LoginPaciente] Resultado da busca de pacientes:",
pacientesResult
);
const paciente = pacientesResult.data?.[0];
if (paciente) {
@ -137,27 +162,103 @@ const LoginPaciente: React.FC = () => {
const handleLoginLocal = async () => {
const email = formData.email.trim();
const senha = formData.senha;
if (email !== LOCAL_PATIENT.email || senha !== LOCAL_PATIENT.senha) {
toast.error(
"Credenciais locais inválidas. Use o email e a senha indicados abaixo."
);
return;
}
console.log("[LoginPaciente] Login local - tentando com API primeiro");
// Tentar fazer login via API mesmo no modo "local"
setLoading(true);
try {
const ok = await loginPaciente({
id: LOCAL_PATIENT.id,
nome: LOCAL_PATIENT.nome,
email: LOCAL_PATIENT.email,
// Fazer login via API Supabase
const authService = (await import("../services/authService")).default;
const loginResult = await authService.login({
email: email,
password: senha,
});
if (ok) {
navigate("/acompanhamento");
if (!loginResult.success) {
console.log(
"[LoginPaciente] Login via API falhou, usando modo local sem token"
);
console.log("[LoginPaciente] Erro:", loginResult.error);
// Fallback: validar credenciais locais hardcoded
if (email !== LOCAL_PATIENT.email || senha !== LOCAL_PATIENT.senha) {
toast.error("Credenciais inválidas");
setLoading(false);
return;
}
// Login local SEM token (modo de desenvolvimento)
toast(
"⚠️ Modo local ativo: algumas funcionalidades podem não funcionar sem API",
{
icon: "⚠️",
duration: 5000,
}
);
const ok = await loginPaciente({
id: LOCAL_PATIENT.id,
nome: LOCAL_PATIENT.nome,
email: LOCAL_PATIENT.email,
});
if (ok) {
navigate("/acompanhamento");
} else {
toast.error("Não foi possível iniciar a sessão local");
}
setLoading(false);
return;
}
console.log("[LoginPaciente] Login via API bem-sucedido!");
// Verificar se o token foi salvo
const tokenStore = (await import("../services/tokenStore")).default;
const token = tokenStore.getAccessToken();
console.log("[LoginPaciente] Token salvo:", token ? "SIM" : "NÃO");
// Buscar dados do paciente da API
const { listPatients } = await import("../services/pacienteService");
const pacientesResult = await listPatients({ search: email });
const paciente = pacientesResult.data?.[0];
if (paciente) {
console.log(
"[LoginPaciente] Paciente encontrado na API:",
paciente.nome
);
const ok = await loginPaciente({
id: paciente.id,
nome: paciente.nome,
email: paciente.email,
});
if (ok) {
navigate("/acompanhamento");
} else {
toast.error("Erro ao processar login");
}
} else {
toast.error("Não foi possível iniciar a sessão local");
console.log(
"[LoginPaciente] Paciente não encontrado na API, usando dados locais"
);
const ok = await loginPaciente({
id: email,
nome: email.split("@")[0],
email: email,
});
if (ok) {
navigate("/acompanhamento");
} else {
toast.error("Erro ao processar login");
}
}
} catch (err) {
console.error("[LoginPaciente] Erro no login local:", err);
toast.error("Erro no login local");
console.error("[LoginPaciente] Erro no login:", err);
toast.error("Erro ao fazer login");
} finally {
setLoading(false);
}
@ -253,7 +354,7 @@ const LoginPaciente: React.FC = () => {
{loading ? "Entrando..." : "Entrar"}
</button>
**/}
<button
type="button"
onClick={handleLoginLocal}

File diff suppressed because it is too large Load Diff

View File

@ -69,19 +69,19 @@ export interface MedicoCreate {
nome: string; // full_name
email: string; // email
crm: string; // crm
crmUf?: string; // crm_uf
cpf?: string; // cpf
crmUf: string; // crm_uf (REQUIRED)
cpf: string; // cpf (REQUIRED)
especialidade: string; // specialty
telefone: string; // phone_mobile
telefone2?: string; // phone2
cep?: string; // cep
rua?: string; // street
numero?: string; // number
cep: string; // cep (REQUIRED)
rua: string; // street (REQUIRED)
numero: string; // number (REQUIRED)
complemento?: string; // complement
bairro?: string; // neighborhood
cidade?: string; // city
estado?: string; // state
dataNascimento?: string; // birth_date (YYYY-MM-DD)
bairro: string; // neighborhood (REQUIRED)
cidade: string; // city (REQUIRED)
estado: string; // state (REQUIRED)
dataNascimento: string; // birth_date (YYYY-MM-DD) (REQUIRED)
rg?: string; // rg
status?: "ativo" | "inativo"; // mapeado para active
}

View File

@ -358,35 +358,32 @@ export async function createPatient(payload: {
alturaM?: number;
endereco?: EnderecoPaciente;
}): Promise<ApiResponse<Paciente>> {
// Normalizações: remover qualquer formatação para envio limpo
const cleanCpf = (payload.cpf || "").replace(/\D/g, "");
const cleanPhone = (payload.telefone || "").replace(/\D/g, "");
// Sanitização forte
const rawCpf = (payload.cpf || "").replace(/\D/g, "").slice(0, 11);
let phone = (payload.telefone || "").replace(/\D/g, "");
if (phone.length > 15) phone = phone.slice(0, 15);
const cleanEndereco: EnderecoPaciente | undefined = payload.endereco
? {
...payload.endereco,
cep: payload.endereco.cep?.replace(/\D/g, ""),
}
? { ...payload.endereco, cep: payload.endereco.cep?.replace(/\D/g, "") }
: undefined;
const peso = typeof payload.pesoKg === "number" && payload.pesoKg > 0 && payload.pesoKg < 500 ? payload.pesoKg : undefined;
const altura = typeof payload.alturaM === "number" && payload.alturaM > 0 && payload.alturaM < 3 ? payload.alturaM : undefined;
// Validação mínima required
if (!payload.nome?.trim())
return { success: false, error: "Nome é obrigatório" };
if (!cleanCpf) return { success: false, error: "CPF é obrigatório" };
if (!payload.email?.trim())
return { success: false, error: "Email é obrigatório" };
if (!cleanPhone) return { success: false, error: "Telefone é obrigatório" };
if (!payload.nome?.trim()) return { success: false, error: "Nome é obrigatório" };
if (!rawCpf) return { success: false, error: "CPF é obrigatório" };
if (!payload.email?.trim()) return { success: false, error: "Email é obrigatório" };
if (!phone) return { success: false, error: "Telefone é obrigatório" };
const body: Partial<PatientInputSchema> = {
const buildBody = (cpfValue: string): Partial<PatientInputSchema> => ({
full_name: payload.nome,
cpf: cleanCpf,
cpf: cpfValue,
email: payload.email,
phone_mobile: cleanPhone,
phone_mobile: phone,
birth_date: payload.dataNascimento,
social_name: payload.socialName,
sex: payload.sexo,
blood_type: payload.tipoSanguineo,
weight_kg: payload.pesoKg,
height_m: payload.alturaM,
weight_kg: peso,
height_m: altura,
street: cleanEndereco?.rua,
number: cleanEndereco?.numero,
complement: cleanEndereco?.complemento,
@ -394,37 +391,67 @@ export async function createPatient(payload: {
city: cleanEndereco?.cidade,
state: cleanEndereco?.estado,
cep: cleanEndereco?.cep,
};
Object.keys(body).forEach((k) => {
const v = (body as Record<string, unknown>)[k];
if (v === undefined || v === "")
delete (body as Record<string, unknown>)[k];
});
try {
let body: Partial<PatientInputSchema> = buildBody(rawCpf);
const prune = () => {
Object.keys(body).forEach((k) => {
const v = (body as Record<string, unknown>)[k];
if (v === undefined || v === "") delete (body as Record<string, unknown>)[k];
});
};
prune();
const attempt = async (): Promise<ApiResponse<Paciente>> => {
const response = await http.post<PacienteApi | PacienteApi[]>(
ENDPOINTS.PATIENTS,
body,
{
headers: { Prefer: "return=representation" },
}
{ headers: { Prefer: "return=representation" } }
);
if (!response.success || !response.data)
return {
success: false,
error: response.error || "Erro ao criar paciente",
};
const raw = Array.isArray(response.data) ? response.data[0] : response.data;
return { success: true, data: mapPacienteFromApi(raw) };
} catch (error: unknown) {
const err = error as {
response?: { status?: number; data?: { message?: string } };
};
if (response.success && response.data) {
const raw = Array.isArray(response.data) ? response.data[0] : response.data;
return { success: true, data: mapPacienteFromApi(raw) };
}
return { success: false, error: response.error || "Erro ao criar paciente" };
};
const handleOverflowFallbacks = async (baseError: string): Promise<ApiResponse<Paciente>> => {
// 1) tentar com CPF formatado
if (/numeric field overflow/i.test(baseError) && rawCpf.length === 11) {
body = buildBody(rawCpf.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4"));
prune();
let r = await attempt();
if (r.success) return r;
// 2) remover campos opcionais progressivamente
const optional: Array<keyof PatientInputSchema> = ["weight_kg", "height_m", "blood_type", "cep", "number"];
for (const key of optional) {
if (key in body) {
delete (body as Record<string, unknown>)[key];
r = await attempt();
if (r.success) return r;
}
}
return r; // retorna último erro
}
return { success: false, error: baseError };
};
try {
let first = await attempt();
if (!first.success && /numeric field overflow/i.test(first.error || "")) {
first = await handleOverflowFallbacks(first.error || "numeric field overflow");
}
return first;
} catch (err: unknown) {
const e = err as { response?: { status?: number; data?: { message?: string } } };
let msg = "Erro ao criar paciente";
if (err.response?.status === 401) msg = "Não autorizado";
else if (err.response?.status === 400)
msg = err.response.data?.message || "Dados inválidos";
else if (err.response?.data?.message) msg = err.response.data.message;
console.error(msg, error);
if (e.response?.status === 401) msg = "Não autorizado";
else if (e.response?.status === 400) msg = e.response.data?.message || "Dados inválidos";
else if (e.response?.data?.message) msg = e.response.data.message;
if (/numeric field overflow/i.test(msg)) {
const overflowAttempt = await handleOverflowFallbacks(msg);
return overflowAttempt;
}
return { success: false, error: msg };
}
}

View File

@ -0,0 +1,95 @@
/**
* Sistema de telemetria para tracking de eventos
* Expõe eventos via dataLayer (Google Tag Manager) e console
*/
export interface TelemetryEvent {
event: string;
category: string;
action: string;
label?: string;
value?: number;
timestamp: string;
}
declare global {
interface Window {
dataLayer?: TelemetryEvent[];
}
}
class TelemetryService {
private enabled: boolean;
constructor() {
this.enabled = true;
this.initDataLayer();
}
private initDataLayer(): void {
if (typeof window !== "undefined" && !window.dataLayer) {
window.dataLayer = [];
}
}
public trackEvent(
category: string,
action: string,
label?: string,
value?: number
): void {
if (!this.enabled) return;
const event: TelemetryEvent = {
event: "custom_event",
category,
action,
label,
value,
timestamp: new Date().toISOString(),
};
// Push para dataLayer (GTM)
if (typeof window !== "undefined" && window.dataLayer) {
window.dataLayer.push(event);
}
// Log no console (desenvolvimento)
if (import.meta.env.DEV) {
console.log("[Telemetry]", event);
}
}
public trackCTA(ctaName: string, destination: string): void {
this.trackEvent("CTA", "click", `${ctaName} -> ${destination}`);
}
public trackProfileChange(
fromProfile: string | null,
toProfile: string
): void {
this.trackEvent(
"Profile",
"change",
`${fromProfile || "none"} -> ${toProfile}`
);
}
public trackNavigation(from: string, to: string): void {
this.trackEvent("Navigation", "page_view", `${from} -> ${to}`);
}
public trackError(errorType: string, errorMessage: string): void {
this.trackEvent("Error", errorType, errorMessage);
}
public disable(): void {
this.enabled = false;
}
public enable(): void {
this.enabled = true;
}
}
export const telemetry = new TelemetryService();