feat(ui): responsividade mobile profissional (320px+) sem alterar desktop
continuous-integration/webhook Deploy concluído (VPS4)
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:
@@ -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,125 +8,146 @@ 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">
|
||||
<div className="sidebar-header">
|
||||
<div className="logo-icon">
|
||||
<i className="fa-solid fa-tooth" />
|
||||
<>
|
||||
{/* 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>
|
||||
<h2>
|
||||
Score Client{' '}
|
||||
<span style={{ fontSize: '10px', opacity: 0.5, fontWeight: 500, marginLeft: 4, verticalAlign: 'middle' }}>
|
||||
{version ? `v${version}` : ''}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<span className="mobile-topbar-spacer" />
|
||||
</header>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<div className="nav-section-title">Menu Principal</div>
|
||||
{/* Backdrop do drawer */}
|
||||
<div
|
||||
className={`sidebar-backdrop${mobileOpen ? ' is-open' : ''}`}
|
||||
onClick={close}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<i className="fa-solid fa-images" /> Galeria
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/patients"
|
||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<i className="fa-solid fa-users" /> Pacientes
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/gtos"
|
||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<i className="fa-solid fa-file-medical" /> GTOs
|
||||
</NavLink>
|
||||
|
||||
<button className="nav-item" onClick={onNewPatient}>
|
||||
<i className="fa-solid fa-user-plus" /> Novo Paciente
|
||||
</button>
|
||||
|
||||
<button className="nav-item" onClick={() => onUpload?.()}>
|
||||
<i className="fa-solid fa-upload" /> Enviar Imagens
|
||||
</button>
|
||||
|
||||
<button className="nav-item" onClick={onSettings}>
|
||||
<i className="fa-solid fa-user-gear" /> Minha Conta
|
||||
</button>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="nav-section-title">Área Administrativa</div>
|
||||
|
||||
<NavLink
|
||||
to="/clients"
|
||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<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' : ''}`}
|
||||
>
|
||||
<i className="fa-solid fa-hospital" /> Clínicas e Acessos
|
||||
</NavLink>
|
||||
|
||||
<button className="nav-item" onClick={onUsers}>
|
||||
<i className="fa-solid fa-users" /> Gerenciar Usuários
|
||||
</button>
|
||||
|
||||
<button className="nav-item" onClick={() => onSync?.('devices')}>
|
||||
<i className="fa-solid fa-rotate" /> Dispositivos / Sync
|
||||
</button>
|
||||
|
||||
<NavLink
|
||||
to="/download"
|
||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||
style={{ color: '#0284c7' }}
|
||||
>
|
||||
<i className="fa-solid fa-download" /> Baixar Cliente (.exe)
|
||||
</NavLink>
|
||||
|
||||
<button className="nav-item" onClick={onPlugins}>
|
||||
<i className="fa-solid fa-plug" /> Plugins / Wasabi
|
||||
</button>
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<i className="fa-solid fa-sliders" /> Configurações
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{user && (
|
||||
<div style={{ textAlign: 'center', fontSize: '11px', color: 'var(--text-secondary)', opacity: 0.6, marginBottom: 6 }}>
|
||||
{user.username}
|
||||
{isAdmin && ' · Admin'}
|
||||
<aside className={`sidebar${mobileOpen ? ' sidebar--mobile-open' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo-icon">
|
||||
<i className="fa-solid fa-tooth" />
|
||||
</div>
|
||||
)}
|
||||
<button className="logout-btn" onClick={handleLogout}>
|
||||
<i className="fa-solid fa-arrow-right-from-bracket" /> Sair
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<h2>
|
||||
Score Client{' '}
|
||||
<span style={{ fontSize: '10px', opacity: 0.5, fontWeight: 500, marginLeft: 4, verticalAlign: 'middle' }}>
|
||||
{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' : ''}`} onClick={close}>
|
||||
<i className="fa-solid fa-images" /> Galeria
|
||||
</NavLink>
|
||||
|
||||
<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' : ''}`} onClick={close}>
|
||||
<i className="fa-solid fa-file-medical" /> GTOs
|
||||
</NavLink>
|
||||
|
||||
<button className="nav-item" onClick={act(onNewPatient)}>
|
||||
<i className="fa-solid fa-user-plus" /> Novo Paciente
|
||||
</button>
|
||||
|
||||
<button className="nav-item" onClick={act(onUpload)}>
|
||||
<i className="fa-solid fa-upload" /> Enviar Imagens
|
||||
</button>
|
||||
|
||||
<button className="nav-item" onClick={act(onSettings)}>
|
||||
<i className="fa-solid fa-user-gear" /> Minha Conta
|
||||
</button>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="nav-section-title">Área Administrativa</div>
|
||||
|
||||
<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' : ''}`} onClick={close}>
|
||||
<i className="fa-solid fa-hospital" /> Clínicas e Acessos
|
||||
</NavLink>
|
||||
|
||||
<button className="nav-item" onClick={act(onUsers)}>
|
||||
<i className="fa-solid fa-users" /> Gerenciar Usuários
|
||||
</button>
|
||||
|
||||
<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' }} onClick={close}>
|
||||
<i className="fa-solid fa-download" /> Baixar Cliente (.exe)
|
||||
</NavLink>
|
||||
|
||||
<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' : ''}`} onClick={close}>
|
||||
<i className="fa-solid fa-sliders" /> Configurações
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{user && (
|
||||
<div style={{ textAlign: 'center', fontSize: '11px', color: 'var(--text-secondary)', opacity: 0.6, marginBottom: 6 }}>
|
||||
{user.username}
|
||||
{isAdmin && ' · Admin'}
|
||||
</div>
|
||||
)}
|
||||
<button className="logout-btn" onClick={handleLogout}>
|
||||
<i className="fa-solid fa-arrow-right-from-bracket" /> Sair
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
<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>
|
||||
{/* 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', 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 }}
|
||||
/>
|
||||
<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>
|
||||
<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', 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"
|
||||
|
||||
Reference in New Issue
Block a user