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-modal="true"
aria-labelledby={DIALOG_TITLE_ID} aria-labelledby={DIALOG_TITLE_ID}
aria-describedby={DIALOG_DESC_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} 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"> <div className="flex items-center gap-2">
<Accessibility className="w-5 h-5 text-blue-600" /> <Accessibility className="w-5 h-5 text-blue-600" />
<h3 <h3
@ -176,7 +176,7 @@ const AccessibilityMenu: React.FC = () => {
</div> </div>
<button <button
onClick={() => setIsOpen(false)} 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" aria-label="Fechar menu"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
@ -185,7 +185,14 @@ const AccessibilityMenu: React.FC = () => {
<p id={DIALOG_DESC_ID} className="sr-only"> <p id={DIALOG_DESC_ID} className="sr-only">
Ajustes visuais e funcionais para leitura, contraste e foco. Ajustes visuais e funcionais para leitura, contraste e foco.
</p> </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 */} {/* Tamanho da fonte */}
<div ref={firstInteractiveRef} tabIndex={-1}> <div ref={firstInteractiveRef} tabIndex={-1}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <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} {icon}
{label} {label}
</label> </label>
<div className="flex flex-col items-end"> <div className="flex items-center gap-2">
<button <button
onClick={onClick} onClick={onClick}
className={ className={
"a11y-toggle-button relative inline-flex h-7 w-14 items-center rounded-full focus:outline-none" + "a11y-toggle-button relative inline-flex h-7 w-14 items-center rounded-full focus:outline-none" +
" a11y-toggle-track " + " a11y-toggle-track " +
(active (active ? " ring-offset-0" : " opacity-90 hover:opacity-100")
? " ring-offset-0"
: " opacity-90 hover:opacity-100")
} }
data-active={active} data-active={active}
aria-pressed={active} aria-pressed={active}
@ -343,7 +348,7 @@ const ToggleRow: React.FC<ToggleRowProps> = ({
} }
/> />
</button> </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"} {active ? "ON" : "OFF"}
</span> </span>
</div> </div>

View File

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

View File

@ -1,142 +1,132 @@
import React from "react"; import React from "react";
import { Link, useLocation } from "react-router-dom"; import { Link } from "react-router-dom";
import { Heart, Stethoscope, User, Clipboard, LogOut } from "lucide-react"; import { Heart, LogOut, LogIn } from "lucide-react";
import { useAuth } from "../hooks/useAuth"; 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 Header: React.FC = () => {
const location = useLocation();
const isActive = (path: string) => {
return location.pathname === path;
};
const { user, logout, role, isAuthenticated } = useAuth(); const { user, logout, role, isAuthenticated } = useAuth();
const roleLabel: Record<string, string> = { const roleLabel: Record<string, string> = {
secretaria: "Secretaria", secretaria: "Secretaria",
medico: "Médico", medico: "Médico",
paciente: "Paciente", paciente: "Paciente",
admin: "Administrador",
gestor: "Gestor",
}; };
return ( return (
<header className="bg-white shadow-lg border-b border-gray-200"> <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="container mx-auto px-4">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
{/* Logo */} {/* 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 <img
src={Logo} src={Logo}
alt="MediConnect" alt={i18n.t("header.logo")}
className="h-10 w-10 rounded-lg object-contain shadow-sm" className="h-14 w-14 rounded-lg object-contain shadow-sm"
/> />
<div> <div>
<h1 className="text-xl font-bold text-gray-900">MediConnect</h1> <h1 className="text-xl font-bold text-gray-900">
<p className="text-xs text-gray-500">Sistema de Agendamento</p> {i18n.t("header.logo")}
</h1>
<p className="text-xs text-gray-500">
{i18n.t("header.subtitle")}
</p>
</div> </div>
</Link> </Link>
{/* Navigation */} {/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-1"> <nav
className="hidden md:flex items-center space-x-2"
aria-label="Navegação principal"
>
<Link <Link
to="/" to="/"
className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ 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"
isActive("/")
? "bg-gradient-to-r from-blue-700 to-blue-400 text-white"
: "text-gray-600 hover:text-blue-600 hover:bg-blue-50"
}`}
> >
<Heart className="w-4 h-4" /> <Heart className="w-4 h-4" aria-hidden="true" />
<span>Início</span> <span>{i18n.t("header.home")}</span>
</Link> </Link>
<Link {/* Profile Selector */}
to="/paciente" <ProfileSelector />
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>
<Link {/* Admin 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 */}
{isAuthenticated && (role === "admin" || role === "gestor") && ( {isAuthenticated && (role === "admin" || role === "gestor") && (
<Link <Link
to="/admin" to="/admin"
className={`flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ 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"
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"
}`}
> >
<User className="w-4 h-4" />
<span>Painel Admin</span> <span>Painel Admin</span>
</Link> </Link>
)} )}
</nav> </nav>
{/* Sessão / Logout */} {/* User Session / Auth */}
<div className="hidden md:flex items-center space-x-4"> <div className="hidden md:flex items-center space-x-3">
{isAuthenticated && user ? ( {isAuthenticated && user ? (
<> <>
<div className="text-right leading-tight"> <div className="text-right leading-tight min-w-0 flex-shrink">
<p className="text-sm font-medium text-gray-700 truncate max-w-[160px]"> <p
{user.nome} className="text-sm font-medium text-gray-700 truncate max-w-[120px]"
title={user.nome}
>
{user.nome.split(" ").slice(0, 2).join(" ")}
</p> </p>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500 whitespace-nowrap">
{role ? roleLabel[role] || role : ""} {role ? roleLabel[role] || role : ""}
</p> </p>
</div> </div>
<button <button
onClick={logout} 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" 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"
title="Sair" aria-label={i18n.t("header.logout")}
> >
<LogOut className="w-4 h-4 mr-1" /> <LogOut className="w-4 h-4 mr-1" aria-hidden="true" />
Sair <span className="hidden lg:inline">
{i18n.t("header.logout")}
</span>
<span className="lg:hidden">Sair</span>
</button> </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> </div>
{/* Mobile menu button */} {/* Mobile menu button */}
<div className="md:hidden"> <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 <svg
className="w-6 h-6" className="w-6 h-6"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
aria-hidden="true"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
@ -154,72 +144,48 @@ const Header: React.FC = () => {
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<Link <Link
to="/" to="/"
className={`flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ 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"
isActive("/")
? "bg-gradient-to-r from-blue-700 to-blue-400 text-white"
: "text-gray-600 hover:text-blue-600 hover:bg-blue-50"
}`}
> >
<Heart className="w-4 h-4" /> <Heart className="w-4 h-4" aria-hidden="true" />
<span>Início</span> <span>{i18n.t("header.home")}</span>
</Link> </Link>
<Link <div className="px-3 py-2">
to="/paciente" <ProfileSelector />
className={`flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ </div>
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>
<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 */} {/* Sessão mobile */}
<div className="mt-4 flex items-center justify-between bg-gray-50 px-3 py-2 rounded-md"> <div className="mt-4 flex items-center justify-between bg-gray-50 px-3 py-2 rounded-md">
{isAuthenticated && user ? ( {isAuthenticated && user ? (
<div className="flex-1 mr-3"> <>
<p className="text-sm font-medium text-gray-700 truncate"> <div className="flex-1 mr-3 min-w-0">
{user.nome} <p
className="text-sm font-medium text-gray-700 truncate"
title={user.nome}
>
{user.nome.split(" ").slice(0, 2).join(" ")}
</p> </p>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{role ? roleLabel[role] || role : ""} {role ? roleLabel[role] || role : ""}
</p> </p>
</div> </div>
) : (
<p className="text-xs text-gray-400">Não autenticado</p>
)}
{isAuthenticated && (
<button <button
onClick={logout} 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" 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" /> <LogOut className="w-4 h-4 mr-1" />
<span>Sair</span>
</button> </button>
</>
) : (
<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"
>
<LogIn className="w-4 h-4 mr-2" aria-hidden="true" />
{i18n.t("header.login")}
</Link>
)} )}
</div> </div>
</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 [user, setUser] = useState<SessionUser | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Restaurar sessão do localStorage // Restaurar sessão do localStorage e verificar token
useEffect(() => { useEffect(() => {
const restoreSession = async () => {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (raw) { if (raw) {
const parsed = JSON.parse(raw) as PersistedSession; const parsed = JSON.parse(raw) as PersistedSession;
if (parsed?.user?.role) { 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); setUser(parsed.user);
} }
} else {
console.log("[AuthContext] Nenhuma sessão salva encontrada");
} }
} catch { } catch (error) {
// ignorar console.error("[AuthContext] Erro ao restaurar sessão:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
};
void restoreSession();
}, []); }, []);
const persist = useCallback((session: PersistedSession) => { 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; email: string;
} }
const AgendamentoPaciente: React.FC = () => { const AgendamentoPaciente: React.FC = () => {
const [medicos, setMedicos] = useState<Medico[]>([]); const [medicos, setMedicos] = useState<Medico[]>([]);
const [pacienteLogado, setPacienteLogado] = useState<Paciente | null>(null); const [pacienteLogado, setPacienteLogado] = useState<Paciente | null>(null);
@ -46,16 +45,23 @@ const AgendamentoPaciente: React.FC = () => {
// Verificar se paciente está logado // Verificar se paciente está logado
const pacienteData = localStorage.getItem("pacienteLogado"); const pacienteData = localStorage.getItem("pacienteLogado");
if (!pacienteData) { if (!pacienteData) {
console.log(
"[AgendamentoPaciente] Paciente não logado, redirecionando..."
);
navigate("/paciente"); navigate("/paciente");
return; return;
} }
try { try {
const paciente = JSON.parse(pacienteData); const paciente = JSON.parse(pacienteData);
console.log("[AgendamentoPaciente] Paciente logado:", paciente);
setPacienteLogado(paciente); setPacienteLogado(paciente);
fetchMedicos(); void fetchMedicos();
} catch (error) { } catch (error) {
console.error("Erro ao carregar dados do paciente:", error); console.error(
"[AgendamentoPaciente] Erro ao carregar dados do paciente:",
error
);
navigate("/paciente"); navigate("/paciente");
} }
}, [navigate]); }, [navigate]);
@ -64,8 +70,40 @@ const AgendamentoPaciente: React.FC = () => {
const fetchMedicos = async () => { const fetchMedicos = async () => {
try { 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" }); 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 || []; const list = response.data?.data || [];
console.log(
"[AgendamentoPaciente] Médicos recebidos:",
list.length,
list
);
const mapped: Medico[] = list.map((m) => ({ const mapped: Medico[] = list.map((m) => ({
_id: m.id || Math.random().toString(36).slice(2, 9), _id: m.id || Math.random().toString(36).slice(2, 9),
nome: m.nome || "", nome: m.nome || "",
@ -73,9 +111,26 @@ const AgendamentoPaciente: React.FC = () => {
valorConsulta: 0, valorConsulta: 0,
horarioAtendimento: {}, horarioAtendimento: {},
})); }));
console.log("[AgendamentoPaciente] Médicos mapeados:", mapped);
setMedicos(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) { } 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"); toast.error("Erro ao carregar lista de médicos");
} }
}; };

View File

@ -1,8 +1,12 @@
import React, { useState, useEffect } from "react"; 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 { listPatients } from "../services/pacienteService";
import medicoService from "../services/medicoService"; import medicoService from "../services/medicoService";
import consultaService from "../services/consultaService"; import consultaService from "../services/consultaService";
import { MetricCard } from "../components/MetricCard";
import { i18n } from "../i18n";
import { telemetry } from "../services/telemetry";
const Home: React.FC = () => { const Home: React.FC = () => {
const [stats, setStats] = useState({ const [stats, setStats] = useState({
@ -11,15 +15,26 @@ const Home: React.FC = () => {
consultasHoje: 0, consultasHoje: 0,
consultasPendentes: 0, consultasPendentes: 0,
}); });
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
fetchStats();
}, []);
const fetchStats = async () => { const fetchStats = async () => {
try { try {
setLoading(true);
setError(false);
const [pacientesResult, medicosResult, consultasResult] = const [pacientesResult, medicosResult, consultasResult] =
await Promise.all([ await Promise.all([
listPatients(), listPatients().catch(() => ({ data: [] })),
medicoService.listarMedicos(), medicoService.listarMedicos().catch(() => ({ data: { data: [] } })),
consultaService.listarConsultas(), consultaService
.listarConsultas()
.catch(() => ({ data: { data: [] } })),
]); ]);
const hoje = new Date().toISOString().split("T")[0]; const hoje = new Date().toISOString().split("T")[0];
@ -42,130 +57,238 @@ const Home: React.FC = () => {
consultasHoje, consultasHoje,
consultasPendentes, consultasPendentes,
}); });
} catch (error) { } catch (err) {
console.error("Erro ao carregar estatísticas:", error); console.error("Erro ao carregar estatísticas:", err);
setError(true);
telemetry.trackError("stats_load_error", String(err));
} finally {
setLoading(false);
} }
}; };
fetchStats(); const handleCTA = (action: string, destination: string) => {
}, []); telemetry.trackCTA(action, destination);
navigate(destination);
};
return ( return (
<div className="space-y-8"> <div className="space-y-8" id="main-content">
{/* Hero Section */} {/* Hero Section */}
<div className="text-center py-12 bg-gradient-to-l from-blue-800 to-blue-500 text-white rounded-xl shadow-lg"> <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">
<h1 className="text-4xl font-bold mb-4"> {/* Decorative Pattern */}
Sistema de Agendamento Médico <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="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> </h1>
<p className="text-xl opacity-90"> <p className="text-base md:text-lg lg:text-xl opacity-95 mb-6 md:mb-8 max-w-2xl mx-auto">
Gerencie consultas, pacientes e médicos de forma eficiente {i18n.t("home.hero.subtitle")}
</p> </p>
</div>
{/* Estatísticas */} {/* CTAs */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="flex flex-col sm:flex-row gap-3 md:gap-4 justify-center items-center">
<div className="bg-white rounded-lg shadow-md p-6"> <button
<div className="flex items-center"> onClick={() => handleCTA("Agendar consulta", "/paciente")}
<div className="p-3 bg-gradient-to-l from-blue-700 to-blue-400 rounded-full"> 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"
<Users className="w-6 h-6 text-white" /> aria-label={i18n.t(
</div> "home.actionCards.scheduleAppointment.ctaAriaLabel"
<div className="ml-4"> )}
<p className="text-sm font-medium text-gray-600"> >
Total de Pacientes <Calendar
</p> className="w-5 h-5 mr-2 group-hover:scale-110 transition-transform"
<p className="text-2xl font-bold text-gray-900"> aria-hidden="true"
{stats.totalPacientes} />
</p> {i18n.t("home.hero.ctaPrimary")}
<ArrowRight
className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform"
aria-hidden="true"
/>
</button>
<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> </div>
</div> </div>
<div className="bg-white rounded-lg shadow-md p-6"> {/* Métricas */}
<div className="flex items-center"> <div
<div className="p-3 bg-green-100 rounded-full"> className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6"
<UserCheck className="w-6 h-6 text-green-600" /> role="region"
</div> aria-label="Estatísticas do sistema"
<div className="ml-4"> >
<p className="text-sm font-medium text-gray-600"> <MetricCard
Médicos Ativos title={i18n.t("home.metrics.totalPatients")}
</p> value={stats.totalPacientes}
<p className="text-2xl font-bold text-gray-900"> icon={Users}
{stats.totalMedicos} iconColor="text-blue-600"
</p> iconBgColor="bg-blue-100"
</div> description={i18n.t("home.metrics.totalPatientsDescription")}
</div> 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> </div>
<div className="bg-white rounded-lg shadow-md p-6"> {/* Cards de Ação */}
<div className="flex items-center"> <div
<div className="p-3 bg-yellow-100 rounded-full"> className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6"
<Calendar className="w-6 h-6 text-yellow-600" /> role="region"
</div> aria-label="Ações rápidas"
<div className="ml-4"> >
<p className="text-sm font-medium text-gray-600"> <ActionCard
Consultas Hoje icon={Calendar}
</p> iconColor="text-blue-600"
<p className="text-2xl font-bold text-gray-900"> iconBgColor="bg-gradient-to-br from-blue-700 to-blue-400"
{stats.consultasHoje} title={i18n.t("home.actionCards.scheduleAppointment.title")}
</p> description={i18n.t(
</div> "home.actionCards.scheduleAppointment.description"
</div> )}
</div> ctaLabel={i18n.t("home.actionCards.scheduleAppointment.cta")}
ctaAriaLabel={i18n.t(
"home.actionCards.scheduleAppointment.ctaAriaLabel"
)}
onAction={() => handleCTA("Card Agendar", "/paciente")}
/>
<div className="bg-white rounded-lg shadow-md p-6"> <ActionCard
<div className="flex items-center"> icon={UserCheck}
<div className="p-3 bg-purple-100 rounded-full"> iconColor="text-indigo-600"
<Clock className="w-6 h-6 text-purple-600" /> iconBgColor="bg-gradient-to-br from-indigo-600 to-indigo-400"
</div> title={i18n.t("home.actionCards.doctorPanel.title")}
<div className="ml-4"> description={i18n.t("home.actionCards.doctorPanel.description")}
<p className="text-sm font-medium text-gray-600">Pendentes</p> ctaLabel={i18n.t("home.actionCards.doctorPanel.cta")}
<p className="text-2xl font-bold text-gray-900"> ctaAriaLabel={i18n.t("home.actionCards.doctorPanel.ctaAriaLabel")}
{stats.consultasPendentes} onAction={() => handleCTA("Card Médico", "/login-medico")}
</p> />
</div>
</div>
</div>
</div>
{/* Acesso Rápido */} <ActionCard
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> icon={Users}
<div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"> iconColor="text-green-600"
<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"> iconBgColor="bg-gradient-to-br from-green-600 to-green-400"
<Calendar className="w-6 h-6 text-white" /> title={i18n.t("home.actionCards.patientManagement.title")}
</div> description={i18n.t("home.actionCards.patientManagement.description")}
<h3 className="text-lg font-semibold mb-2">Agendar Consulta</h3> ctaLabel={i18n.t("home.actionCards.patientManagement.cta")}
<p className="text-gray-600 mb-4"> ctaAriaLabel={i18n.t(
Interface para pacientes agendarem suas consultas médicas "home.actionCards.patientManagement.ctaAriaLabel"
</p> )}
<a href="/paciente" className="btn-primary inline-block"> onAction={() => handleCTA("Card Secretaria", "/login-secretaria")}
Acessar Agendamento />
</a>
</div>
<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>
<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>
</div> </div>
</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; export default Home;

View File

@ -86,12 +86,37 @@ const LoginPaciente: React.FC = () => {
return; 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 // Buscar dados do paciente da API
const { listPatients } = await import("../services/pacienteService"); const { listPatients } = await import("../services/pacienteService");
const pacientesResult = await listPatients({ search: formData.email }); const pacientesResult = await listPatients({ search: formData.email });
console.log(
"[LoginPaciente] Resultado da busca de pacientes:",
pacientesResult
);
const paciente = pacientesResult.data?.[0]; const paciente = pacientesResult.data?.[0];
if (paciente) { if (paciente) {
@ -137,27 +162,103 @@ const LoginPaciente: React.FC = () => {
const handleLoginLocal = async () => { const handleLoginLocal = async () => {
const email = formData.email.trim(); const email = formData.email.trim();
const senha = formData.senha; const senha = formData.senha;
if (email !== LOCAL_PATIENT.email || senha !== LOCAL_PATIENT.senha) {
toast.error( console.log("[LoginPaciente] Login local - tentando com API primeiro");
"Credenciais locais inválidas. Use o email e a senha indicados abaixo."
); // Tentar fazer login via API mesmo no modo "local"
return;
}
setLoading(true); setLoading(true);
try { try {
// Fazer login via API Supabase
const authService = (await import("../services/authService")).default;
const loginResult = await authService.login({
email: email,
password: senha,
});
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({ const ok = await loginPaciente({
id: LOCAL_PATIENT.id, id: LOCAL_PATIENT.id,
nome: LOCAL_PATIENT.nome, nome: LOCAL_PATIENT.nome,
email: LOCAL_PATIENT.email, email: LOCAL_PATIENT.email,
}); });
if (ok) { if (ok) {
navigate("/acompanhamento"); navigate("/acompanhamento");
} else { } else {
toast.error("Não foi possível iniciar a sessão local"); 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 {
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) { } catch (err) {
console.error("[LoginPaciente] Erro no login local:", err); console.error("[LoginPaciente] Erro no login:", err);
toast.error("Erro no login local"); toast.error("Erro ao fazer login");
} finally { } finally {
setLoading(false); setLoading(false);
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

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