Update
This commit is contained in:
parent
fca789662d
commit
9ec07aeea3
@ -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>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
188
MEDICONNECT 2/src/components/MetricCard.tsx
Normal file
188
MEDICONNECT 2/src/components/MetricCard.tsx
Normal 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;
|
||||
206
MEDICONNECT 2/src/components/ProfileSelector.tsx
Normal file
206
MEDICONNECT 2/src/components/ProfileSelector.tsx
Normal 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;
|
||||
@ -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) => {
|
||||
|
||||
110
MEDICONNECT 2/src/i18n/en-US.ts
Normal file
110
MEDICONNECT 2/src/i18n/en-US.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
88
MEDICONNECT 2/src/i18n/index.ts
Normal file
88
MEDICONNECT 2/src/i18n/index.ts
Normal 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 };
|
||||
112
MEDICONNECT 2/src/i18n/pt-BR.ts
Normal file
112
MEDICONNECT 2/src/i18n/pt-BR.ts
Normal 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;
|
||||
@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
95
MEDICONNECT 2/src/services/telemetry.ts
Normal file
95
MEDICONNECT 2/src/services/telemetry.ts
Normal 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();
|
||||
Loading…
x
Reference in New Issue
Block a user