feat: layout mobile responsivo completo
continuous-integration/webhook Deploy concluído (VPS4)

This commit is contained in:
VPS 4 Deploy Agent
2026-05-31 19:57:15 +02:00
parent 0df3cb41d0
commit bcad7c4720
8 changed files with 450 additions and 113 deletions
@@ -1 +1 @@
2.1.29 2.1.46
@@ -0,0 +1,40 @@
import { useState } from 'react';
/**
* Seção colapsável com chevron-down.
* Quando fechada, o conteúdo NÃO é renderizado → zero espaço em tela.
*
* Props:
* - title: string — título da seção
* - defaultOpen: boolean (default false)
* - children: ReactNode
* - className: string (opcional) — classe extra no container
* - compact: boolean (default false) — estilo menor para uso inline
*/
export default function CollapsibleSection({
title,
defaultOpen = false,
children,
className = '',
compact = false,
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`collapsible-section${compact ? ' collapsible-compact' : ''} ${className}`}>
<button
type="button"
className="collapsible-header"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
<span className="collapsible-title">{title}</span>
<i
className="fa-solid fa-chevron-down collapsible-chevron"
style={{ transform: open ? 'rotate(180deg)' : 'none' }}
/>
</button>
{open && <div className="collapsible-body">{children}</div>}
</div>
);
}
@@ -1,8 +1,23 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Modal from './Modal'; import Modal from './Modal';
import CollapsibleSection from './CollapsibleSection';
import api from '../api/client'; import api from '../api/client';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
/** Detecta viewport mobile (<= 1023px) */
function useIsMobile() {
const [mobile, setMobile] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth <= 1023 : false
);
useEffect(() => {
const mql = window.matchMedia('(max-width: 1023px)');
const handler = (e) => setMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return mobile;
}
function formatDate(dateString) { function formatDate(dateString) {
if (!dateString) return '—'; if (!dateString) return '—';
return new Date(dateString).toLocaleString('pt-BR', { return new Date(dateString).toLocaleString('pt-BR', {
@@ -13,6 +28,7 @@ function formatDate(dateString) {
export default function TransformModal({ isOpen, onClose, image, onSaved }) { export default function TransformModal({ isOpen, onClose, image, onSaved }) {
const { showToast } = useToast(); const { showToast } = useToast();
const isMobile = useIsMobile();
const [activeView, setActiveView] = useState('transform'); // 'transform' | 'gto' const [activeView, setActiveView] = useState('transform'); // 'transform' | 'gto'
const [step, setStep] = useState('choose'); // 'choose' (4 posições) | 'finetune' const [step, setStep] = useState('choose'); // 'choose' (4 posições) | 'finetune'
const [choosePage, setChoosePage] = useState(0); // 0=rotações, 1=flip H, 2=flip V const [choosePage, setChoosePage] = useState(0); // 0=rotações, 1=flip H, 2=flip V
@@ -338,13 +354,15 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
{activeView === 'transform' && step === 'finetune' && ( {activeView === 'transform' && step === 'finetune' && (
<div className="transform-view-wrapper"> <div className="transform-view-wrapper">
<div className="new-transform-layout"> <div className="new-transform-layout">
{/* Original */} {/* Original — escondido no mobile, mostrado via CollapsibeSection */}
{!isMobile && (
<div className="transform-panel panel-original"> <div className="transform-panel panel-original">
<h3 className="panel-title">Original</h3> <h3 className="panel-title">Original</h3>
<div className="img-container"> <div className="img-container">
<img src={originalUrl || imageUrl} alt="Original" /> <img src={originalUrl || imageUrl} alt="Original" />
</div> </div>
</div> </div>
)}
{/* Preview (orientação escolhida + ajuste fino) */} {/* Preview (orientação escolhida + ajuste fino) */}
<div className="transform-panel panel-preview"> <div className="transform-panel panel-preview">
@@ -362,8 +380,10 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
{/* Controles de rotação */} {/* Controles de rotação */}
<div className="transform-sidebar"> <div className="transform-sidebar">
{!isMobile && (
<h3 className="sidebar-title" style={{ fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: 1, color: 'var(--text-muted)', marginBottom: 12 }}>Ajuste</h3> <h3 className="sidebar-title" style={{ fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: 1, color: 'var(--text-muted)', marginBottom: 12 }}>Ajuste</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', width: '100%' }}> )}
<div style={{ display: 'flex', flexDirection: isMobile ? 'row' : 'column', gap: 8, alignItems: 'center', width: isMobile ? 'auto' : '100%' }}>
{[ {[
{ label: '-5°', delta: -5, icon: '↺' }, { label: '-5°', delta: -5, icon: '↺' },
{ label: '-1°', delta: -1, icon: '↺' }, { label: '-1°', delta: -1, icon: '↺' },
@@ -374,7 +394,7 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
</button> </button>
))} ))}
<div className="fine-tune-display" style={{ margin: '8px 0' }}> <div className="fine-tune-display" style={{ margin: isMobile ? '0 4px' : '8px 0' }}>
{fineTuneAngle > 0 ? '+' : ''}{fineTuneAngle}° {fineTuneAngle > 0 ? '+' : ''}{fineTuneAngle}°
</div> </div>
@@ -389,7 +409,7 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
))} ))}
{fineTuneAngle !== 0 && ( {fineTuneAngle !== 0 && (
<button type="button" className="btn btn-danger btn-small" onClick={resetAngle} style={{ marginTop: 12, width: '100%' }}> <button type="button" className="btn btn-danger btn-small" onClick={resetAngle} style={{ marginTop: isMobile ? 0 : 12, width: isMobile ? 'auto' : '100%' }}>
Reset Reset
</button> </button>
)} )}
@@ -397,7 +417,58 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
</div> </div>
</div> </div>
{/* Original colapsável — apenas no mobile */}
{isMobile && (
<CollapsibleSection title="Ver Original" compact>
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
<img
src={originalUrl || imageUrl}
alt="Original"
style={{ maxWidth: '80%', maxHeight: 200, objectFit: 'contain', borderRadius: 'var(--radius-sm)' }}
/>
</div>
</CollapsibleSection>
)}
{/* Área de ação */} {/* Área de ação */}
{isMobile ? (
<CollapsibleSection title="Ações e Observação">
<div className="form-group" style={{ marginBottom: 12 }}>
<label htmlFor="transform-remark">Observação (Opcional):</label>
<textarea
id="transform-remark"
name="remark"
className="form-control"
rows={2}
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder="Ex: Raio-X Dente 45..."
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
className="btn btn-primary"
onClick={saveTransform}
disabled={saving}
style={{ width: '100%' }}
>
{saving ? 'Salvando...' : '💾 Salvar imagem e orientação'}
</button>
<button className="btn btn-secondary" onClick={() => setActiveView('gto')} style={{ width: '100%' }}>
📋 Vincular à GTO
</button>
<button className="btn btn-secondary" onClick={() => downloadImage(true)} style={{ width: '100%' }}>
Baixar orientada
</button>
<button className="btn btn-secondary" onClick={() => downloadImage(false)} style={{ width: '100%' }}>
Baixar original
</button>
<button className="btn btn-secondary" onClick={onClose} style={{ width: '100%' }}>
Cancelar
</button>
</div>
</CollapsibleSection>
) : (
<div className="transform-action-area"> <div className="transform-action-area">
<div className="form-group"> <div className="form-group">
<label htmlFor="transform-remark">Nova Observação / Detalhes (Opcional):</label> <label htmlFor="transform-remark">Nova Observação / Detalhes (Opcional):</label>
@@ -431,6 +502,7 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
</button> </button>
</div> </div>
</div> </div>
)}
</div> </div>
)} )}
@@ -438,7 +510,7 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
{activeView === 'gto' && ( {activeView === 'gto' && (
<div> <div>
{/* Baixar imagem atual */} {/* Baixar imagem atual */}
<div className="form-actions-inline" style={{ marginBottom: 12, gap: 8, flexWrap: 'wrap' }}> <div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-secondary btn-small" onClick={() => downloadImage(false)} title="Baixar a imagem original"> <button className="btn btn-secondary btn-small" onClick={() => downloadImage(false)} title="Baixar a imagem original">
Baixar imagem Baixar imagem
</button> </button>
@@ -449,7 +521,28 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
)} )}
</div> </div>
{/* Criar GTO */} {/* Criar GTO — colapsável no mobile */}
{isMobile ? (
<CollapsibleSection title="Cadastrar Nova GTO">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input
id="gto-number" name="gtoNumber"
type="text" className="form-control"
placeholder="Número/ID da GTO"
value={gtoNumber}
onChange={(e) => setGtoNumber(e.target.value)}
/>
<input
id="gto-desc" name="gtoDesc"
type="text" className="form-control"
placeholder="Descrição (Opcional)"
value={gtoDesc}
onChange={(e) => setGtoDesc(e.target.value)}
/>
<button className="btn btn-primary" onClick={createGto} style={{ width: '100%' }}>Cadastrar</button>
</div>
</CollapsibleSection>
) : (
<div className="gto-create-section"> <div className="gto-create-section">
<h3 className="section-title">Cadastrar Nova GTO</h3> <h3 className="section-title">Cadastrar Nova GTO</h3>
<div className="form-actions-inline" style={{ marginTop: 12 }}> <div className="form-actions-inline" style={{ marginTop: 12 }}>
@@ -472,9 +565,10 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
<button className="btn btn-primary" onClick={createGto}>Cadastrar</button> <button className="btn btn-primary" onClick={createGto}>Cadastrar</button>
</div> </div>
</div> </div>
)}
{/* Lista de GTOs */} {/* Lista de GTOs */}
<div className="gto-list-container"> <div className="gto-list-container" style={{ marginTop: 12 }}>
{loadingGtos ? ( {loadingGtos ? (
<div style={{ padding: 20, textAlign: 'center', color: '#888' }}>Carregando GTOs...</div> <div style={{ padding: 20, textAlign: 'center', color: '#888' }}>Carregando GTOs...</div>
) : gtos.length === 0 ? ( ) : gtos.length === 0 ? (
@@ -482,9 +576,21 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
) : ( ) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}> <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{gtos.map((gto, idx) => ( {gtos.map((gto, idx) => (
<li key={gto.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 15, background: idx % 2 === 0 ? '#fff' : '#f9f9f9', borderBottom: '1px solid #eee' }}> <li
key={gto.id}
style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center',
padding: isMobile ? 12 : 15,
background: idx % 2 === 0 ? '#fff' : '#f9f9f9',
borderBottom: '1px solid #eee',
gap: isMobile ? 10 : 0,
}}
>
<div> <div>
<div style={{ fontWeight: 600, fontSize: '1.05rem', display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ fontWeight: 600, fontSize: isMobile ? '0.95rem' : '1.05rem', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{gto.gto_number} {gto.gto_number}
{gto.sent && ( {gto.sent && (
<span style={{ fontSize: '0.7rem', fontWeight: 700, color: '#fff', background: '#10b981', padding: '2px 8px', borderRadius: 999 }}> <span style={{ fontSize: '0.7rem', fontWeight: 700, color: '#fff', background: '#10b981', padding: '2px 8px', borderRadius: 999 }}>
@@ -492,19 +598,22 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
</span> </span>
)} )}
</div> </div>
<div style={{ color: '#666', fontSize: '0.9rem' }}>{gto.description || 'Sem descrição'}</div> <div style={{ color: '#666', fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: isMobile ? '70vw' : 'none' }}>
<div style={{ color: '#999', fontSize: '0.8rem', marginTop: 4 }}>📅 {formatDate(gto.created_at)}</div> {gto.description || 'Sem descrição'}
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ color: '#999', fontSize: '0.75rem', marginTop: 3 }}>📅 {formatDate(gto.created_at)}</div>
<button className="btn btn-primary btn-small" onClick={() => linkToGto(gto.id)}> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-primary btn-small" onClick={() => linkToGto(gto.id)} style={{ flex: isMobile ? 1 : 'none' }}>
Vincular Vincular
</button> </button>
<button <button
className={`btn btn-small ${gto.sent ? 'btn-secondary' : 'btn-success'}`} className={`btn btn-small ${gto.sent ? 'btn-secondary' : 'btn-success'}`}
onClick={() => markGtoSent(gto.id, !gto.sent)} onClick={() => markGtoSent(gto.id, !gto.sent)}
title={gto.sent ? 'Desmarcar envio' : 'Marcar GTO como enviada'} title={gto.sent ? 'Desmarcar envio' : 'Marcar GTO como enviada'}
style={{ flex: isMobile ? 1 : 'none' }}
> >
{gto.sent ? '↩️ Desmarcar' : '📤 Imagem enviada'} {gto.sent ? '↩️ Desmarcar' : '📤 Enviada'}
</button> </button>
</div> </div>
</li> </li>
+159 -5
View File
@@ -621,6 +621,60 @@ body {
.dropdown-custom { padding: 6px 8px; } .dropdown-custom { padding: 6px 8px; }
.dropdown-custom select, .dropdown-custom input { width: 100%; } .dropdown-custom select, .dropdown-custom input { width: 100%; }
/* ================================================================
COLLAPSIBLE SECTION — Seção colapsável com chevron-down
================================================================ */
.collapsible-section {
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-color);
overflow: hidden;
}
.collapsible-header {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 12px 14px;
background: none; border: none;
cursor: pointer; font-family: inherit;
font-size: 0.85rem; font-weight: 700;
color: var(--text-secondary); text-align: left;
text-transform: uppercase; letter-spacing: 0.04em;
transition: background 0.15s;
}
.collapsible-header:hover { background: rgba(0,0,0,0.03); }
.collapsible-header:active { background: rgba(0,0,0,0.06); }
.collapsible-chevron {
font-size: 0.65rem; color: var(--text-muted);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.collapsible-body {
padding: 0 14px 14px;
animation: collapsibleIn 0.15s ease;
}
.collapsible-compact {
border: none; background: none;
border-radius: 0;
}
.collapsible-compact .collapsible-header {
padding: 8px 0;
font-size: 0.8rem;
}
.collapsible-compact .collapsible-body {
padding: 0 0 8px;
}
@keyframes collapsibleIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Helpers de visibilidade por viewport (desktop = padrão) */ /* Helpers de visibilidade por viewport (desktop = padrão) */
.show-mobile { display: none !important; } .show-mobile { display: none !important; }
.hide-mobile { display: contents; } .hide-mobile { display: contents; }
@@ -779,12 +833,97 @@ body {
width: 100% !important; height: 100dvh !important; width: 100% !important; height: 100dvh !important;
max-width: 100% !important; border-radius: 0 !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%; } /* ---- Modal header compacto ---- */
.transform-sidebar { width: 100%; flex-direction: row; border-radius: var(--radius-lg); justify-content: space-around; padding: 12px; } .modal-header { padding: 12px 16px; }
.modal-header h2 {
font-size: 0.95rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 55vw;
}
.modal-title-group {
flex-wrap: wrap; gap: 8px;
width: 100%;
}
.modal-actions-group { gap: 4px; }
.modal-actions-group .btn { width: 36px; height: 36px; font-size: 1rem; }
.modal-body { padding: 12px; }
/* ---- TransformModal: layout mobile otimizado ---- */
.new-transform-layout {
flex-direction: column; align-items: center;
gap: 12px; padding: 8px 0; min-height: auto;
}
.transform-panel {
max-width: 100%; width: 100%;
padding: 8px; border-radius: var(--radius-sm);
}
.transform-panel .img-container {
max-height: none; aspect-ratio: auto;
min-height: 200px;
}
.transform-panel img {
max-width: 90%; max-height: 90%;
}
/* Painel original oculto por padrão no mobile (mostrado via CollapsibleSection) */
.transform-panel.panel-original.mobile-collapsible {
display: none;
}
.panel-title { font-size: 0.85rem; margin-bottom: 8px; }
/* Barra de controles de rotação — horizontal fixa no bottom */
.transform-sidebar {
width: 100%; flex-direction: row;
border-radius: var(--radius-lg);
justify-content: space-around;
padding: 10px; gap: 6px;
position: sticky; bottom: 0;
z-index: 10;
background: var(--bg-surface);
border: 1px solid var(--border-color);
box-shadow: 0 -4px 12px rgba(0,0,0,0.08);
}
.sidebar-btn { width: 48px; height: 48px; }
.sidebar-btn .icon { font-size: 1rem; }
.sidebar-btn .label { font-size: 0.6rem; }
.fine-tune-display { margin: 0 !important; min-width: 50px; }
/* Área de ação do transform — compacta */
.transform-action-area {
margin-top: 12px; padding: 12px;
border-radius: var(--radius-sm);
}
/* Escolha de orientação — thumbnails */
.transform-choose-grid {
grid-template-columns: repeat(4, 1fr) !important;
gap: 8px !important;
}
/* ---- GTO dentro do TransformModal: cards compactos ---- */
.gto-create-section { padding: 14px; }
.gto-create-section .form-actions-inline {
flex-direction: column;
}
.gto-create-section .form-control { width: 100% !important; flex: none !important; }
.gto-link-section { flex-direction: column; gap: 10px; align-items: stretch; }
/* ---- Cards de paciente: ocultar remark no mobile ---- */
.image-doctor-remark { display: none; }
/* ---- Extra info panel compacto ---- */
.extra-info-panel { padding: 10px 14px; font-size: 0.85rem; margin-bottom: 12px; }
/* ---- Botões maiores para toque ---- */ /* ---- Botões maiores para toque ---- */
.btn { min-height: 40px; } .btn { min-height: 40px; }
/* ---- Form actions empilham no mobile ---- */
.form-actions-inline { flex-direction: column; gap: 8px; }
.form-actions-inline .btn { width: 100%; }
/* ---- Toast no mobile ---- */
.toast-container { bottom: 16px; right: 16px; left: 16px; }
.toast { max-width: 100%; font-size: 0.9rem; padding: 12px 16px; }
} }
/* ================================================================ /* ================================================================
@@ -795,10 +934,23 @@ body {
.images-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; } .images-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.image-preview { height: 130px; } .image-preview { height: 130px; }
.image-info { padding: 10px; } .image-info { padding: 10px; }
.image-doctor-remark { display: none; } /* economiza espaço no card compacto */ .image-patient-name { font-size: 0.85rem; }
.image-meta { font-size: 0.75rem; }
.stat-card-row > * { flex: 1 1 100% !important; } .stat-card-row > * { flex: 1 1 100% !important; }
.header h1 { font-size: 1.15rem; } .header h1 { font-size: 1.15rem; }
.modal-header h2 { font-size: 1.05rem; } .modal-header h2 { font-size: 0.9rem; max-width: 45vw; }
.modal-actions-group .btn { width: 32px; height: 32px; font-size: 0.9rem; }
/* TransformModal — preview ainda maior */
.transform-panel img { max-width: 95%; max-height: 95%; }
.sidebar-btn { width: 44px; height: 44px; }
/* Paginação: só anterior/próximo */
.pagination-controls .btn:not(:first-child):not(:last-child) { display: none; }
/* Collapsible mais compacto */
.collapsible-header { padding: 10px 12px; font-size: 0.78rem; }
.collapsible-body { padding: 0 12px 12px; }
} }
/* ================================================================ /* ================================================================
@@ -811,4 +963,6 @@ body {
.mobile-topbar-brand span:not(.mobile-topbar-logo) { font-size: 0.95rem; } .mobile-topbar-brand span:not(.mobile-topbar-logo) { font-size: 0.95rem; }
.responsive-table td { font-size: 0.85rem; } .responsive-table td { font-size: 0.85rem; }
.btn { font-size: 0.88rem; padding: 9px 14px; } .btn { font-size: 0.88rem; padding: 9px 14px; }
.sidebar-btn { width: 40px; height: 40px; }
.sidebar-btn .label { display: none; }
} }
@@ -124,7 +124,7 @@ export default function AdminClinicsPage() {
</header> </header>
<div className="content-scroll"> <div className="content-scroll">
{/* Stats */} {/* Stats */}
<div style={{ display: 'flex', gap: 16, marginBottom: 24, flexWrap: 'wrap' }}> <div className="stat-card-row" style={{ display: 'flex', gap: 16, marginBottom: 24, flexWrap: 'wrap' }}>
{[ {[
{ label: 'Total de Dispositivos', value: total, color: '#4f46e5' }, { label: 'Total de Dispositivos', value: total, color: '#4f46e5' },
{ label: 'Dispositivos Ativos', value: active, color: '#10b981' }, { label: 'Dispositivos Ativos', value: active, color: '#10b981' },
@@ -12,6 +12,8 @@ import UserManagementModal from '../components/UserManagementModal';
import PluginsModal from '../components/PluginsModal'; import PluginsModal from '../components/PluginsModal';
import SyncModal from '../components/SyncModal'; import SyncModal from '../components/SyncModal';
import Sidebar from '../components/Sidebar'; import Sidebar from '../components/Sidebar';
import CollapsibleSection from '../components/CollapsibleSection';
import DropdownMenu from '../components/DropdownMenu';
function formatDate(d) { function formatDate(d) {
if (!d) return '—'; if (!d) return '—';
@@ -273,6 +275,17 @@ export default function DashboardPage() {
</div> </div>
{view === 'patients' && ( {view === 'patients' && (
<div className="header-actions"> <div className="header-actions">
{/* Busca sempre visível */}
<input
type="search"
className="search-input"
placeholder="🔍 Pesquisar paciente..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ height: 42, minWidth: 200 }}
/>
{/* Desktop: filtros inline */}
<span className="hide-mobile">
<select <select
className="client-filter" className="client-filter"
value={selectedClient} value={selectedClient}
@@ -293,17 +306,38 @@ export default function DashboardPage() {
> >
👁 {showDisabled ? 'Ver Ativas' : 'Ver Ocultas'} 👁 {showDisabled ? 'Ver Ativas' : 'Ver Ocultas'}
</button> </button>
<input
type="search"
className="search-input"
placeholder="🔍 Pesquisar paciente..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ height: 42, minWidth: 200 }}
/>
<button className="btn btn-secondary" onClick={() => loadPatients()} style={{ height: 42, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '10px 16px' }}> <button className="btn btn-secondary" onClick={() => loadPatients()} style={{ height: 42, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '10px 16px' }}>
🔄 🔄
</button> </button>
</span>
{/* Mobile: ações em dropdown */}
<span className="show-mobile">
<DropdownMenu
label="Ações"
buttonClassName="btn btn-primary"
items={[
{ label: showDisabled ? 'Ver Ativas' : 'Ver Ocultas', icon: '👁️', onClick: () => setShowDisabled(!showDisabled) },
{ label: 'Atualizar', icon: '🔄', onClick: () => loadPatients() },
]}
>
{/* Filtro de clientes dentro do dropdown */}
<div className="dropdown-custom">
<select
className="client-filter"
value={selectedClient}
onChange={(e) => setSelectedClient(e.target.value)}
style={{ height: 38, fontSize: '0.85rem', width: '100%' }}
>
<option value="">📋 Todos os Clientes</option>
{clientsList.map((c) => (
<option key={c.name} value={c.name}>
{c.name === 'Upload Web' ? 'Upload Web 💻' : `${c.name}${c.status === 'identified' ? ' ✅' : c.status === 'historic' ? ' 📚' : ''}`}
</option>
))}
</select>
</div>
</DropdownMenu>
</span>
</div> </div>
)} )}
{view === 'patient-images' && ( {view === 'patient-images' && (
@@ -128,7 +128,7 @@ export default function SettingsPage() {
{!loadingCron && cronJobs.length > 0 && ( {!loadingCron && cronJobs.length > 0 && (
<div style={{ overflowX: 'auto' }}> <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> <thead>
<tr> <tr>
{['Nome', 'Descrição', 'Intervalo', 'Status', 'Última execução', 'Próxima execução', 'Resultado', ''].map(col => ( {['Nome', 'Descrição', 'Intervalo', 'Status', 'Última execução', 'Próxima execução', 'Resultado', ''].map(col => (
@@ -145,28 +145,28 @@ export default function SettingsPage() {
<tbody> <tbody>
{cronJobs.map(job => ( {cronJobs.map(job => (
<tr key={job.key} style={{ borderBottom: '1px solid var(--border)' }}> <tr key={job.key} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '14px 16px', fontWeight: 600, whiteSpace: 'nowrap' }}>{job.name}</td> <td data-label="Nome" style={{ padding: '14px 16px', fontWeight: 600, whiteSpace: 'nowrap' }}>{job.name}</td>
<td style={{ padding: '14px 16px', color: 'var(--text-secondary)', maxWidth: 240, fontSize: '0.82rem' }}>{job.description}</td> <td data-label="Descrição" style={{ padding: '14px 16px', color: 'var(--text-secondary)', maxWidth: 240, fontSize: '0.82rem' }}>{job.description}</td>
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}> <td data-label="Intervalo" style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}>
A cada {job.intervalMinutes} min A cada {job.intervalMinutes} min
</td> </td>
<td style={{ padding: '14px 16px' }}> <td data-label="Status" style={{ padding: '14px 16px' }}>
<StatusBadge running={job.running} lastResult={job.lastResult} /> <StatusBadge running={job.running} lastResult={job.lastResult} />
</td> </td>
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}> <td data-label="Última execução" style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
{fmtDate(job.lastRun)} {fmtDate(job.lastRun)}
</td> </td>
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}> <td data-label="Próxima" style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
{fmtDate(job.nextRun)} {fmtDate(job.nextRun)}
</td> </td>
<td style={{ padding: '14px 16px', fontSize: '0.82rem', color: 'var(--text-secondary)' }}> <td data-label="Resultado" style={{ padding: '14px 16px', fontSize: '0.82rem', color: 'var(--text-secondary)' }}>
{job.lastResult ? ( {job.lastResult ? (
job.lastResult.status === 'error' job.lastResult.status === 'error'
? <span style={{ color: '#ef4444' }}>{job.lastResult.error}</span> ? <span style={{ color: '#ef4444' }}>{job.lastResult.error}</span>
: `${job.lastResult.total} imagem(ns) processada(s)` : `${job.lastResult.total} imagem(ns) processada(s)`
) : '—'} ) : '—'}
</td> </td>
<td style={{ padding: '14px 16px' }}> <td data-label="Ações" style={{ padding: '14px 16px' }}>
<button <button
className="btn btn-small btn-secondary" className="btn btn-small btn-secondary"
style={{ whiteSpace: 'nowrap', padding: '6px 12px', fontSize: '0.8rem' }} style={{ whiteSpace: 'nowrap', padding: '6px 12px', fontSize: '0.8rem' }}
+1 -1
View File
@@ -1 +1 @@
2.1.45 2.1.46