Update
This commit is contained in:
parent
fca789662d
commit
9ec07aeea3
@ -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>
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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 [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) => {
|
||||||
|
|||||||
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;
|
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");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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