feat(ui): responsividade mobile profissional (320px+) sem alterar desktop
continuous-integration/webhook Deploy concluído (VPS4)

Todo o tratamento mobile é aditivo e isolado atrás de @media (max-width:1023px/
480px/360px) — o visual desktop (≥1024px) permanece pixel-idêntico.

Navegação:
- Sidebar.jsx: vira drawer deslizante no mobile (top-bar fixo + hambúrguer +
  backdrop + botão fechar). Trava scroll do body, fecha ao navegar/tocar item.
  No desktop continua sidebar fixa idêntica.

Componente novo:
- DropdownMenu.jsx: menu flutuante chevron-down reutilizável (fecha em
  outside-click/ESC). Usado para colapsar ações secundárias no mobile.

Headers (GtosPage, PatientsPage):
- Busca full-width; botões "+ Novo/Atualizar" em linha no desktop (.hide-mobile)
  e colapsados em DropdownMenu no mobile (.show-mobile).

Tabelas → cards empilhados (PatientsPage, GtosPage, AdminClinicsPage):
- classe .responsive-table + data-label em cada td. No mobile thead some e
  cada linha vira card com rótulo:valor. Desktop mantém tabela normal.

CSS (index.css):
- Sistema de breakpoints completo, drawer, dropdown, cards de tabela,
  grid de thumbs reduzido, modais fullscreen, 100dvh, inputs 16px (anti-zoom
  iOS), alvos de toque ≥40px, paginação/stat-cards que quebram linha.

Fix: remove chave 'border' duplicada no style do stat card da GtosPage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-31 16:43:19 +02:00
parent 5f8a4836b5
commit 9f02c41c31
6 changed files with 493 additions and 156 deletions
@@ -0,0 +1,75 @@
import { useState, useRef, useEffect } from 'react';
/**
* Menu flutuante (chevron-down) reutilizável.
* Usado para colapsar ações secundárias em telas pequenas, economizando espaço.
*
* Props:
* - label: texto do botão (default "Ações")
* - icon: ícone opcional antes do label
* - items: array de { label, icon, onClick, danger, active, render }
* (use `render` para inserir um nó custom, ex: um <select>)
* - align: 'right' | 'left' (default 'right')
* - buttonClassName: classe do botão gatilho
*/
export default function DropdownMenu({
label = 'Ações',
icon = null,
items = [],
align = 'right',
buttonClassName = 'btn btn-secondary',
children,
}) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const onDocClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
const onEsc = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onEsc);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onEsc);
};
}, [open]);
return (
<div className="dropdown-menu-wrap" ref={ref}>
<button
type="button"
className={`${buttonClassName} dropdown-trigger`}
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
>
{icon && <span>{icon}</span>}
<span>{label}</span>
<i className="fa-solid fa-chevron-down" style={{ fontSize: '0.7rem', transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
</button>
{open && (
<div className={`dropdown-panel dropdown-panel--${align}`} role="menu">
{children}
{items.map((item, i) => (
item.render ? (
<div key={i} className="dropdown-custom">{item.render}</div>
) : (
<button
key={i}
type="button"
role="menuitem"
className={`dropdown-item${item.danger ? ' dropdown-item--danger' : ''}${item.active ? ' dropdown-item--active' : ''}`}
onClick={() => { setOpen(false); item.onClick?.(); }}
>
{item.icon && <span className="dropdown-item-icon">{item.icon}</span>}
<span>{item.label}</span>
</button>
)
))}
</div>
)}
</div>
);
}
@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useSocket } from '../contexts/SocketContext';
@@ -7,14 +8,53 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o
const { connected } = useSocket();
const navigate = useNavigate();
const location = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
// Fecha o drawer ao trocar de rota
useEffect(() => { setMobileOpen(false); }, [location.pathname]);
// Trava o scroll do body enquanto o drawer está aberto
useEffect(() => {
document.body.style.overflow = mobileOpen ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [mobileOpen]);
const close = () => setMobileOpen(false);
const handleLogout = () => {
logout();
navigate('/login');
};
// Helper: ação de botão do menu também fecha o drawer no mobile
const act = (fn, ...args) => () => { close(); fn?.(...args); };
return (
<aside className="sidebar">
<>
{/* Top bar — visível apenas no mobile (via CSS) */}
<header className="mobile-topbar">
<button
className="mobile-hamburger"
onClick={() => setMobileOpen(true)}
aria-label="Abrir menu"
>
<i className="fa-solid fa-bars" />
</button>
<div className="mobile-topbar-brand">
<span className="mobile-topbar-logo"><i className="fa-solid fa-tooth" /></span>
<span>Score Client</span>
</div>
<span className="mobile-topbar-spacer" />
</header>
{/* Backdrop do drawer */}
<div
className={`sidebar-backdrop${mobileOpen ? ' is-open' : ''}`}
onClick={close}
aria-hidden="true"
/>
<aside className={`sidebar${mobileOpen ? ' sidebar--mobile-open' : ''}`}>
<div className="sidebar-header">
<div className="logo-icon">
<i className="fa-solid fa-tooth" />
@@ -25,42 +65,36 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o
{version ? `v${version}` : ''}
</span>
</h2>
{/* Botão fechar — só aparece no mobile */}
<button className="sidebar-close" onClick={close} aria-label="Fechar menu">
<i className="fa-solid fa-xmark" />
</button>
</div>
<nav className="sidebar-nav">
<div className="nav-section-title">Menu Principal</div>
<NavLink
to="/"
end
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<NavLink to="/" end className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`} onClick={close}>
<i className="fa-solid fa-images" /> Galeria
</NavLink>
<NavLink
to="/patients"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<NavLink to="/patients" className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`} onClick={close}>
<i className="fa-solid fa-users" /> Pacientes
</NavLink>
<NavLink
to="/gtos"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<NavLink to="/gtos" className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`} onClick={close}>
<i className="fa-solid fa-file-medical" /> GTOs
</NavLink>
<button className="nav-item" onClick={onNewPatient}>
<button className="nav-item" onClick={act(onNewPatient)}>
<i className="fa-solid fa-user-plus" /> Novo Paciente
</button>
<button className="nav-item" onClick={() => onUpload?.()}>
<button className="nav-item" onClick={act(onUpload)}>
<i className="fa-solid fa-upload" /> Enviar Imagens
</button>
<button className="nav-item" onClick={onSettings}>
<button className="nav-item" onClick={act(onSettings)}>
<i className="fa-solid fa-user-gear" /> Minha Conta
</button>
@@ -68,47 +102,34 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o
<>
<div className="nav-section-title">Área Administrativa</div>
<NavLink
to="/clients"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<NavLink to="/clients" className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`} onClick={close}>
<i className="fa-solid fa-network-wired" /> Conexões
{connected && (
<span style={{ marginLeft: 'auto', width: 8, height: 8, borderRadius: '50%', background: '#10b981', display: 'inline-block', flexShrink: 0 }} />
)}
</NavLink>
<NavLink
to="/admin-clinics"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<NavLink to="/admin-clinics" className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`} onClick={close}>
<i className="fa-solid fa-hospital" /> Clínicas e Acessos
</NavLink>
<button className="nav-item" onClick={onUsers}>
<button className="nav-item" onClick={act(onUsers)}>
<i className="fa-solid fa-users" /> Gerenciar Usuários
</button>
<button className="nav-item" onClick={() => onSync?.('devices')}>
<button className="nav-item" onClick={act(onSync, 'devices')}>
<i className="fa-solid fa-rotate" /> Dispositivos / Sync
</button>
<NavLink
to="/download"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
style={{ color: '#0284c7' }}
>
<NavLink to="/download" className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`} style={{ color: '#0284c7' }} onClick={close}>
<i className="fa-solid fa-download" /> Baixar Cliente (.exe)
</NavLink>
<button className="nav-item" onClick={onPlugins}>
<button className="nav-item" onClick={act(onPlugins)}>
<i className="fa-solid fa-plug" /> Plugins / Wasabi
</button>
<NavLink
to="/settings"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<NavLink to="/settings" className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`} onClick={close}>
<i className="fa-solid fa-sliders" /> Configurações
</NavLink>
</>
@@ -127,5 +148,6 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o
</button>
</div>
</aside>
</>
);
}
+221 -9
View File
@@ -586,17 +586,229 @@ body {
.clinics-table tr:last-child td { border-bottom: none; }
.clinics-table tr:hover td { background: rgba(79,70,229,0.03); }
/* Responsive */
@media (max-width: 768px) {
.app-container { flex-direction: column; }
.sidebar { width: 100%; height: auto; border-right: none; border-bottom: 1px solid var(--border-color); flex-direction: row; align-items: center; padding: 0 16px; }
.sidebar-header { padding: 16px 0; border-bottom: none; }
.sidebar-nav { flex-direction: row; overflow-x: auto; padding: 0 16px; gap: 16px; }
.nav-item { padding: 12px; }
/* ================================================================
DROPDOWN MENU (chevron-down flutuante) — usado em todas as telas
================================================================ */
.dropdown-menu-wrap { position: relative; display: inline-flex; }
.dropdown-trigger { display: inline-flex; align-items: center; gap: 8px; }
.dropdown-panel {
position: absolute; top: calc(100% + 8px); z-index: 500;
min-width: 220px; max-width: 90vw;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 6px;
display: flex; flex-direction: column; gap: 2px;
animation: dropdownIn 0.16s ease;
}
.dropdown-panel--right { right: 0; }
.dropdown-panel--left { left: 0; }
@keyframes dropdownIn { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
.dropdown-item {
display: flex; align-items: center; gap: 10px;
width: 100%; text-align: left;
padding: 11px 12px; border: none; background: none;
border-radius: 8px; cursor: pointer;
font-family: inherit; font-size: 0.9rem; color: var(--text-primary);
transition: background 0.15s;
}
.dropdown-item:hover { background: var(--bg-color); }
.dropdown-item--active { background: rgba(79,70,229,0.1); color: var(--primary-color); font-weight: 600; }
.dropdown-item--danger { color: var(--danger-color); }
.dropdown-item--danger:hover { background: rgba(239,68,68,0.1); }
.dropdown-item-icon { width: 18px; text-align: center; flex-shrink: 0; }
.dropdown-custom { padding: 6px 8px; }
.dropdown-custom select, .dropdown-custom input { width: 100%; }
/* Helpers de visibilidade por viewport (desktop = padrão) */
.show-mobile { display: none !important; }
.hide-mobile { display: contents; }
/* Elementos de navegação mobile — ocultos no desktop */
.mobile-topbar { display: none; }
.sidebar-backdrop { display: none; }
.sidebar-close { display: none; }
/* ================================================================
RESPONSIVO — Tablet/Mobile (≤ 1023px)
O visual desktop (≥ 1024px) permanece intacto.
================================================================ */
@media (max-width: 1023px) {
/* ---- Altura real em mobile (evita pulo da barra de URL) ---- */
.app-container { height: 100dvh; }
/* ---- Top bar fixo ---- */
.mobile-topbar {
display: flex; align-items: center; gap: 12px;
position: fixed; top: 0; left: 0; right: 0; height: 56px;
padding: 0 14px; z-index: 1000;
background: var(--glass-bg);
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
border-bottom: 1px solid var(--glass-border);
box-shadow: var(--shadow-sm);
}
.mobile-hamburger {
width: 42px; height: 42px; flex-shrink: 0;
border: none; background: transparent; cursor: pointer;
font-size: 1.3rem; color: var(--dark-color);
border-radius: 10px; display: flex; align-items: center; justify-content: center;
transition: background 0.2s;
}
.mobile-hamburger:active { background: rgba(0,0,0,0.06); }
.mobile-topbar-brand {
display: flex; align-items: center; gap: 9px;
font-family: var(--font-heading); font-weight: 700;
font-size: 1.05rem; color: var(--dark-color);
}
.mobile-topbar-logo {
width: 30px; height: 30px; flex-shrink: 0;
background: linear-gradient(135deg, #4cc9f0, var(--primary-color));
border-radius: 8px; color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 15px;
}
.mobile-topbar-spacer { margin-left: auto; }
/* ---- Sidebar vira drawer deslizante ---- */
.app-container { flex-direction: row; }
.sidebar {
position: fixed; top: 0; left: 0; bottom: 0;
width: 280px; max-width: 84vw; height: 100dvh;
transform: translateX(-105%);
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1200; box-shadow: var(--shadow-lg);
border-right: 1px solid var(--glass-border);
}
.sidebar--mobile-open { transform: translateX(0); }
.sidebar-backdrop {
display: block; position: fixed; inset: 0;
background: rgba(15,23,42,0.5);
backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
z-index: 1150; opacity: 0; pointer-events: none;
transition: opacity 0.28s ease;
}
.sidebar-backdrop.is-open { opacity: 1; pointer-events: auto; }
.sidebar-header { position: relative; padding: 18px 20px; }
.sidebar-close {
display: flex; align-items: center; justify-content: center;
position: absolute; top: 14px; right: 12px;
width: 36px; height: 36px; border: none; background: transparent;
font-size: 1.2rem; color: var(--text-secondary); cursor: pointer; border-radius: 8px;
}
.sidebar-close:active { background: rgba(0,0,0,0.06); }
.nav-item { padding: 13px 14px; font-size: 1rem; } /* alvo de toque maior */
.nav-item:hover { transform: none; } /* sem deslocamento em touch */
/* ---- Conteúdo principal abaixo do top bar ---- */
.main-content { padding-top: 56px; }
.content-scroll { padding: 16px; }
.header { padding: 16px; flex-direction: column; gap: 16px; align-items: flex-start; }
.modal-content { width: 90%; height: 98vh; }
/* ---- Header da página: empilha, busca full-width ---- */
.header { padding: 12px 16px; }
.header-content { flex-direction: column; align-items: stretch; gap: 12px; }
.header-left { width: 100%; }
.header h1 { font-size: 1.25rem; }
.header-actions { width: 100%; flex-wrap: wrap; gap: 8px; }
.search-input { min-width: 0; flex: 1 1 100%; width: 100%; font-size: 16px; } /* 16px evita zoom iOS */
.client-filter { flex: 1 1 100%; min-width: 0; width: 100%; }
/* Alterna ações entre desktop (linha) e mobile (dropdown) */
.hide-mobile { display: none !important; }
.show-mobile { display: inline-flex !important; }
/* ---- Grid de cards: thumbnails menores ---- */
.images-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
.image-preview { height: 150px; }
.image-info { padding: 12px; }
.image-patient-name { font-size: 0.95rem; }
/* ================================================================
TABELAS → CARDS EMPILHADOS
Aplicado a tabelas com a classe .responsive-table
================================================================ */
.responsive-table thead { display: none; }
.responsive-table,
.responsive-table tbody,
.responsive-table tr,
.responsive-table td { display: block; width: 100%; }
.responsive-table tr {
background: var(--bg-surface) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--radius) !important;
box-shadow: var(--shadow-sm);
margin-bottom: 12px; padding: 6px 4px; overflow: hidden;
}
.responsive-table tr:hover { background: var(--bg-surface) !important; }
.responsive-table td {
display: flex; align-items: center; justify-content: space-between;
gap: 14px; text-align: right;
padding: 9px 14px !important;
border: none !important;
max-width: none !important; white-space: normal !important;
overflow: visible !important;
}
.responsive-table td::before {
content: attr(data-label);
font-weight: 700; font-size: 0.7rem;
text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text-secondary);
text-align: left; flex-shrink: 0; white-space: nowrap;
}
/* Células sem rótulo (thumb/avatar) ocupam o topo, centralizadas */
.responsive-table td[data-label=""]::before { display: none; }
.responsive-table td[data-label=""] { justify-content: center; padding-top: 12px !important; }
/* Célula de ações: botões espalhados, ocupando largura */
.responsive-table td[data-label="Ações"] { justify-content: flex-end; flex-wrap: wrap; }
/* ---- Stat cards (filtros GTO) encolhem ---- */
.stat-card-row { gap: 10px !important; }
.stat-card-row > * { min-width: 0 !important; flex: 1 1 30% !important; padding: 12px !important; }
/* ---- Paginação: centraliza e quebra linha ---- */
.pagination-bar { flex-direction: column !important; gap: 10px !important; align-items: center !important; }
.pagination-controls { flex-wrap: wrap; justify-content: center; }
/* ---- Modais: tela cheia ---- */
.modal-overlay { align-items: stretch; }
.modal-content, .modal-large {
width: 100% !important; height: 100dvh !important;
max-width: 100% !important; border-radius: 0 !important;
}
.new-transform-layout { flex-direction: column; align-items: center; }
.transform-panel { max-width: 100%; width: 100%; }
.transform-sidebar { width: 100%; flex-direction: row; border-radius: var(--radius-lg); justify-content: space-around; padding: 12px; }
/* ---- Botões maiores para toque ---- */
.btn { min-height: 40px; }
}
/* ================================================================
RESPONSIVO — Celular (≤ 480px)
================================================================ */
@media (max-width: 480px) {
.content-scroll { padding: 12px; }
.images-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.image-preview { height: 130px; }
.image-info { padding: 10px; }
.image-doctor-remark { display: none; } /* economiza espaço no card compacto */
.stat-card-row > * { flex: 1 1 100% !important; }
.header h1 { font-size: 1.15rem; }
.modal-header h2 { font-size: 1.05rem; }
}
/* ================================================================
RESPONSIVO — Celular pequeno (≤ 360px / 320px)
================================================================ */
@media (max-width: 360px) {
.content-scroll { padding: 10px; }
.images-grid { grid-template-columns: 1fr; }
.image-preview { height: 160px; }
.mobile-topbar-brand span:not(.mobile-topbar-logo) { font-size: 0.95rem; }
.responsive-table td { font-size: 0.85rem; }
.btn { font-size: 0.88rem; padding: 9px 14px; }
}
@@ -139,7 +139,7 @@ export default function AdminClinicsPage() {
{/* Table */}
<div style={{ background: 'white', borderRadius: 12, border: '1px solid var(--border-color)', boxShadow: 'var(--shadow-sm)', overflow: 'auto' }}>
<table className="clinics-table" style={{ width: '100%' }}>
<table className="clinics-table responsive-table" style={{ width: '100%' }}>
<thead>
<tr>
<th>Status</th>
@@ -158,23 +158,23 @@ export default function AdminClinicsPage() {
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 30, color: '#a0aec0' }}>Nenhum dispositivo cadastrado.</td></tr>
) : clinics.map((c) => (
<tr key={c.id}>
<td>
<td data-label="Status">
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px', borderRadius: 20, background: c.is_active ? 'rgba(16,185,129,0.1)' : 'rgba(239,68,68,0.1)', color: c.is_active ? '#065f46' : '#991b1b', fontWeight: 700, fontSize: '0.8rem' }}>
{c.is_active ? '✓ Ativo' : '⊘ Bloqueado'}
</span>
</td>
<td><strong>{c.clinic_name}</strong></td>
<td>{c.pc_name || '—'}</td>
<td>{c.email}</td>
<td style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{c.last_ip || 'Nunca conectou'}</td>
<td>
<td data-label="Clínica"><strong>{c.clinic_name}</strong></td>
<td data-label="Nome do PC">{c.pc_name || '—'}</td>
<td data-label="E-mail">{c.email}</td>
<td data-label="Último IP" style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{c.last_ip || 'Nunca conectou'}</td>
<td data-label="Token">
<span
style={{ fontFamily: 'monospace', fontSize: '0.8rem', cursor: 'pointer', color: 'var(--primary-color)', userSelect: 'all' }}
title="Clique para copiar"
onClick={() => { navigator.clipboard.writeText(c.machine_token); showToast('Token copiado!', 'success'); }}
>{c.machine_token?.split('-')[0]}...</span>
</td>
<td>
<td data-label="Ações">
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<label style={{ display: 'inline-flex', cursor: 'pointer', alignItems: 'center', gap: 6, fontSize: '0.85rem' }}>
<input type="checkbox" checked={c.is_active} onChange={(e) => toggleStatus(c.id, e.target.checked)} />
@@ -5,6 +5,7 @@ import { useAuth } from '../contexts/AuthContext';
import Sidebar from '../components/Sidebar';
import ThumbImg from '../components/ThumbImg';
import Modal from '../components/Modal';
import DropdownMenu from '../components/DropdownMenu';
import CreatePatientModal from '../components/CreatePatientModal';
import SettingsModal from '../components/SettingsModal';
import UserManagementModal from '../components/UserManagementModal';
@@ -341,10 +342,24 @@ export default function GtosPage() {
onChange={e => setSearch(e.target.value)}
style={{ height:42, minWidth:240 }}
/>
{/* Desktop: botões em linha */}
<span className="hide-mobile">
<button className="btn btn-primary" style={{ height:42 }} onClick={() => setShowCreate(true)}>
+ Nova GTO
</button>
<button className="btn btn-secondary" style={{ height:42, padding:'10px 14px' }} onClick={reload}>🔄</button>
<button className="btn btn-secondary" style={{ height:42, padding:'10px 14px', marginLeft:8 }} onClick={reload}>🔄</button>
</span>
{/* Mobile: ações em menu flutuante */}
<span className="show-mobile">
<DropdownMenu
label="Ações"
buttonClassName="btn btn-primary"
items={[
{ label:'Nova GTO', icon:'', onClick:() => setShowCreate(true) },
{ label:'Atualizar lista', icon:'🔄', onClick:reload },
]}
/>
</span>
</div>
</div>
</header>
@@ -353,13 +368,13 @@ export default function GtosPage() {
<div style={{ padding:'0 24px 24px', display:'flex', flexDirection:'column', gap:18 }}>
{/* Stats */}
<div style={{ display:'flex', gap:14, flexWrap:'wrap' }}>
<div className="stat-card-row" style={{ display:'flex', gap:14, flexWrap:'wrap' }}>
{FILTERS.map(f => (
<button
key={f.key}
onClick={() => handleFilterChange(f.key)}
style={{
flex:1, minWidth:120, border:'none', cursor:'pointer',
flex:1, minWidth:120, cursor:'pointer',
background: filter === f.key ? 'var(--primary-color)' : 'white',
color: filter === f.key ? '#fff' : 'var(--text-primary)',
borderRadius:12, padding:'16px 20px',
@@ -391,7 +406,7 @@ export default function GtosPage() {
</div>
) : (
<div style={{ overflowX:'auto' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:'0.875rem' }}>
<table className="responsive-table" style={{ width:'100%', borderCollapse:'collapse', fontSize:'0.875rem' }}>
<thead>
<tr style={{ background:'#f8fafc' }}>
{['', 'Número GTO', 'Paciente', 'Imagens', 'Descrição', 'Criado em', 'Status', 'Ações'].map(col => (
@@ -414,7 +429,7 @@ export default function GtosPage() {
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{/* Thumb */}
<td style={{ padding:'10px 8px 10px 14px', width:52 }}>
<td data-label="" style={{ padding:'10px 8px 10px 14px', width:52 }}>
<ThumbImg
src={gto.thumb_url}
style={{ width:40, height:40, borderRadius:7, objectFit:'cover', flexShrink:0 }}
@@ -423,17 +438,17 @@ export default function GtosPage() {
</td>
{/* Número */}
<td style={{ padding:'10px 14px', fontWeight:700, color:'var(--primary-color)', whiteSpace:'nowrap' }}>
<td data-label="Número GTO" style={{ padding:'10px 14px', fontWeight:700, color:'var(--primary-color)', whiteSpace:'nowrap' }}>
#{gto.gto_number}
</td>
{/* Paciente */}
<td style={{ padding:'10px 14px', fontWeight:600, maxWidth:180, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
<td data-label="Paciente" style={{ padding:'10px 14px', fontWeight:600, maxWidth:180, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{gto.patient_name}
</td>
{/* Imagens */}
<td style={{ padding:'10px 14px', textAlign:'center' }}>
<td data-label="Imagens" style={{ padding:'10px 14px', textAlign:'center' }}>
<span style={{
display:'inline-flex', alignItems:'center', justifyContent:'center',
background: gto.image_count > 0 ? 'rgba(79,70,229,0.1)' : 'rgba(148,163,184,0.1)',
@@ -445,22 +460,22 @@ export default function GtosPage() {
</td>
{/* Descrição */}
<td style={{ padding:'10px 14px', color:'var(--text-secondary)', maxWidth:220, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', fontSize:'0.82rem' }}>
<td data-label="Descrição" style={{ padding:'10px 14px', color:'var(--text-secondary)', maxWidth:220, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', fontSize:'0.82rem' }}>
{gto.description || '—'}
</td>
{/* Data */}
<td style={{ padding:'10px 14px', color:'var(--text-secondary)', whiteSpace:'nowrap', fontSize:'0.82rem' }}>
<td data-label="Criado em" style={{ padding:'10px 14px', color:'var(--text-secondary)', whiteSpace:'nowrap', fontSize:'0.82rem' }}>
{fmtDate(gto.created_at)}
</td>
{/* Status */}
<td style={{ padding:'10px 14px' }}>
<td data-label="Status" style={{ padding:'10px 14px' }}>
<StatusBadge sent={gto.sent} imageCount={gto.image_count} />
</td>
{/* Ações */}
<td style={{ padding:'10px 14px' }} onClick={e => e.stopPropagation()}>
<td data-label="Ações" style={{ padding:'10px 14px' }} onClick={e => e.stopPropagation()}>
<div style={{ display:'flex', gap:6 }}>
<button
className="btn btn-small btn-secondary"
@@ -489,11 +504,11 @@ export default function GtosPage() {
{/* Paginação */}
{pages > 1 && (
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'12px 18px', borderTop:'1px solid var(--border-color)', background:'#fafafa' }}>
<div className="pagination-bar" style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'12px 18px', borderTop:'1px solid var(--border-color)', background:'#fafafa' }}>
<span style={{ fontSize:'0.82rem', color:'var(--text-secondary)' }}>
{total} GTO{total !== 1 ? 's' : ''} · Página {page} de {pages}
</span>
<div style={{ display:'flex', gap:6 }}>
<div className="pagination-controls" style={{ display:'flex', gap:6 }}>
<button
className="btn btn-small btn-secondary"
disabled={page <= 1}
@@ -4,6 +4,7 @@ import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import Sidebar from '../components/Sidebar';
import ThumbImg from '../components/ThumbImg';
import DropdownMenu from '../components/DropdownMenu';
import CreatePatientModal from '../components/CreatePatientModal';
import EditPatientModal from '../components/EditPatientModal';
import SettingsModal from '../components/SettingsModal';
@@ -117,12 +118,24 @@ export default function PatientsPage() {
onChange={e => setSearch(e.target.value)}
style={{ height: 42, minWidth: 220 }}
/>
<span className="hide-mobile">
<button className="btn btn-primary" style={{ height: 42 }} onClick={() => setShowCreate(true)}>
+ Novo Paciente
</button>
<button className="btn btn-secondary" style={{ height: 42, padding: '10px 14px' }} onClick={() => loadPatients(true)}>
<button className="btn btn-secondary" style={{ height: 42, padding: '10px 14px', marginLeft: 8 }} onClick={() => loadPatients(true)}>
🔄
</button>
</span>
<span className="show-mobile">
<DropdownMenu
label="Ações"
buttonClassName="btn btn-primary"
items={[
{ label: 'Novo Paciente', icon: '', onClick: () => setShowCreate(true) },
{ label: 'Atualizar lista', icon: '🔄', onClick: () => loadPatients(true) },
]}
/>
</span>
</div>
</div>
</header>
@@ -143,7 +156,7 @@ export default function PatientsPage() {
{!loading && patients.length > 0 && (
<div style={{ padding: '0 24px 24px' }}>
<div style={{ overflowX: 'auto' }}>
<table style={{
<table className="responsive-table" style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.875rem',
@@ -178,7 +191,7 @@ export default function PatientsPage() {
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-hover, rgba(0,0,0,0.025))'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<td style={{ padding: '12px 16px', fontWeight: 600, maxWidth: 200 }}>
<td data-label="Paciente" style={{ padding: '12px 16px', fontWeight: 600, maxWidth: 200 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<ThumbImg
src={p.thumb_url}
@@ -190,10 +203,10 @@ export default function PatientsPage() {
</span>
</div>
</td>
<td style={{ padding: '12px 16px', color: 'var(--text-secondary)', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<td data-label="Dentista" style={{ padding: '12px 16px', color: 'var(--text-secondary)', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.doctor || '—'}
</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
<td data-label="Imagens" style={{ padding: '12px 16px', textAlign: 'center' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(59,130,246,0.1)', color: '#3b82f6',
@@ -202,13 +215,13 @@ export default function PatientsPage() {
{p.image_count || 0}
</span>
</td>
<td style={{ padding: '12px 16px', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
<td data-label="Atualização" style={{ padding: '12px 16px', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
{formatDate(p.last_date)}
</td>
<td style={{ padding: '12px 16px', color: 'var(--text-secondary)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<td data-label="Observações" style={{ padding: '12px 16px', color: 'var(--text-secondary)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.remark || '—'}
</td>
<td style={{ padding: '12px 16px', whiteSpace: 'nowrap' }}>
<td data-label="Ações" style={{ padding: '12px 16px', whiteSpace: 'nowrap' }}>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="btn btn-small btn-secondary"