feat(gtos): página de gerenciamento de GTOs com filtros e paginação
continuous-integration/webhook Deploy concluído (VPS4)

Backend:
- routes/gtos.js: GET /api/gtos reescrito com filtros (all/vinculada/enviada),
  busca ILIKE por patient_name e gto_number, paginação (page/limit),
  retorna { rows, total, pages } + thumb_url da primeira imagem vinculada.

Frontend:
- GtosPage.jsx (/gtos): cards de stats clicáveis (Todas/Vinculadas/Enviadas)
  com contadores em tempo real; tabela com colunas: thumb, número, paciente,
  qtd imagens (badge colorido), descrição, data, status, ações; paginação
  numérica; busca com debounce 350ms.
- StatusBadge: Pendente / Vinculada / Enviada com cores distintas.
- GtoDetailModal: info completa, grid de imagens vinculadas com thumb +
  botão desvincular, toggle enviada/pendente com feedback visual.
- CreateGtoModal: formulário com número, paciente, descrição.
- Sidebar: item GTOs (fa-file-medical) entre Pacientes e Novo Paciente.
- App.jsx: rota /gtos (ProtectedRoute).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-31 16:06:42 +02:00
parent 98dcb729a5
commit f027842c76
4 changed files with 625 additions and 24 deletions
+7
View File
@@ -13,6 +13,7 @@ import ResetPage from './pages/ResetPage';
import DownloadPage from './pages/DownloadPage';
import PatientsPage from './pages/PatientsPage';
import SettingsPage from './pages/SettingsPage';
import GtosPage from './pages/GtosPage';
export default function App() {
return (
@@ -69,6 +70,12 @@ export default function App() {
</AdminRoute>
} />
<Route path="/gtos" element={
<ProtectedRoute>
<GtosPage />
</ProtectedRoute>
} />
{/* Catch-all: redireciona para home */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -45,6 +45,13 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o
<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>
@@ -0,0 +1,546 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import Sidebar from '../components/Sidebar';
import ThumbImg from '../components/ThumbImg';
import Modal from '../components/Modal';
import CreatePatientModal from '../components/CreatePatientModal';
import SettingsModal from '../components/SettingsModal';
import UserManagementModal from '../components/UserManagementModal';
import PluginsModal from '../components/PluginsModal';
import SyncModal from '../components/SyncModal';
/* ── helpers ─────────────────────────────────────────────────── */
function fmtDate(d) {
if (!d) return '—';
return new Date(d).toLocaleString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function StatusBadge({ sent, imageCount }) {
if (sent) return (
<span style={{ display:'inline-flex', alignItems:'center', gap:5, background:'rgba(16,185,129,0.1)', color:'#065f46', border:'1px solid rgba(16,185,129,0.3)', borderRadius:20, padding:'3px 10px', fontSize:'0.75rem', fontWeight:700 }}>
Enviada
</span>
);
if (imageCount > 0) return (
<span style={{ display:'inline-flex', alignItems:'center', gap:5, background:'rgba(79,70,229,0.1)', color:'#3730a3', border:'1px solid rgba(79,70,229,0.3)', borderRadius:20, padding:'3px 10px', fontSize:'0.75rem', fontWeight:700 }}>
🔗 Vinculada
</span>
);
return (
<span style={{ display:'inline-flex', alignItems:'center', gap:5, background:'rgba(148,163,184,0.12)', color:'#64748b', border:'1px solid rgba(148,163,184,0.3)', borderRadius:20, padding:'3px 10px', fontSize:'0.75rem', fontWeight:700 }}>
📋 Pendente
</span>
);
}
/* ── Modal de detalhes/edição de GTO ─────────────────────────── */
function GtoDetailModal({ isOpen, onClose, gto, onUpdated }) {
const { showToast } = useToast();
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(false);
const [marking, setMarking] = useState(false);
useEffect(() => {
if (isOpen && gto?.id) loadDetail();
}, [isOpen, gto?.id]);
async function loadDetail() {
setLoading(true);
try {
const { data } = await api.get(`/gtos/${gto.id}`);
setDetail(data);
} catch { showToast('Erro ao carregar detalhe da GTO', 'error'); }
finally { setLoading(false); }
}
async function toggleSent() {
setMarking(true);
try {
await api.post(`/gtos/${gto.id}/sent`, { sent: !detail.sent });
showToast(detail.sent ? 'GTO marcada como pendente.' : 'GTO marcada como enviada!', 'success');
onUpdated?.();
loadDetail();
} catch { showToast('Erro ao alterar status', 'error'); }
finally { setMarking(false); }
}
async function unlinkImage(imageId) {
try {
await api.delete(`/gtos/${gto.id}/images/${imageId}`);
showToast('Imagem desvinculada.', 'success');
onUpdated?.();
loadDetail();
} catch { showToast('Erro ao desvincular imagem', 'error'); }
}
if (!gto) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} title={`GTO #${gto.gto_number}`} large>
{loading ? (
<div style={{ textAlign:'center', padding:32 }}><div className="spinner" style={{ margin:'0 auto' }} /></div>
) : detail ? (
<div style={{ display:'flex', flexDirection:'column', gap:20 }}>
{/* Info */}
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:12 }}>
{[
{ label:'Número GTO', value: detail.gto_number },
{ label:'Paciente', value: detail.patient_name },
{ label:'Criado em', value: fmtDate(detail.created_at) },
{ label:'Enviado em', value: detail.sent_at ? fmtDate(detail.sent_at) : '—' },
].map(({ label, value }) => (
<div key={label} style={{ background:'var(--bg-color)', borderRadius:8, padding:'10px 14px' }}>
<div style={{ fontSize:'0.7rem', color:'var(--text-secondary)', textTransform:'uppercase', letterSpacing:'0.05em', marginBottom:3 }}>{label}</div>
<div style={{ fontWeight:600 }}>{value}</div>
</div>
))}
</div>
{detail.description && (
<div style={{ background:'var(--bg-color)', borderRadius:8, padding:'10px 14px' }}>
<div style={{ fontSize:'0.7rem', color:'var(--text-secondary)', textTransform:'uppercase', letterSpacing:'0.05em', marginBottom:4 }}>Descrição</div>
<div style={{ fontSize:'0.9rem' }}>{detail.description}</div>
</div>
)}
{/* Status + ação */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'12px 16px', background: detail.sent ? 'rgba(16,185,129,0.06)' : 'rgba(79,70,229,0.06)', borderRadius:10, border:`1px solid ${detail.sent ? 'rgba(16,185,129,0.2)' : 'rgba(79,70,229,0.2)'}` }}>
<div>
<StatusBadge sent={detail.sent} imageCount={detail.images?.length || 0} />
{detail.sent && <span style={{ marginLeft:8, fontSize:'0.8rem', color:'var(--text-secondary)' }}>Enviada em {fmtDate(detail.sent_at)}</span>}
</div>
<button
className={`btn ${detail.sent ? 'btn-secondary' : 'btn-primary'}`}
onClick={toggleSent}
disabled={marking}
style={{ padding:'7px 16px', fontSize:'0.85rem' }}
>
{marking ? '⏳ Aguarde...' : detail.sent ? '↩️ Desfazer envio' : '✈️ Marcar como enviada'}
</button>
</div>
{/* Imagens vinculadas */}
<div>
<div style={{ fontSize:'0.8rem', fontWeight:600, color:'var(--text-secondary)', textTransform:'uppercase', letterSpacing:'0.05em', marginBottom:10 }}>
Imagens vinculadas ({detail.images?.length || 0})
</div>
{(!detail.images || detail.images.length === 0) ? (
<div style={{ textAlign:'center', padding:'20px 0', color:'var(--text-secondary)', fontSize:'0.85rem' }}>
Nenhuma imagem vinculada a esta GTO.
</div>
) : (
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(110px, 1fr))', gap:10 }}>
{detail.images.map(img => (
<div key={img.id} style={{ position:'relative', borderRadius:8, overflow:'hidden', border:'1px solid var(--border-color)', background:'var(--bg-color)' }}>
<ThumbImg
src={`/api/images/${img.id}/thumbnail`}
className="preview-img"
style={{ width:'100%', height:90, objectFit:'cover', display:'block' }}
label=""
/>
<div style={{ padding:'4px 6px', fontSize:'0.65rem', color:'var(--text-secondary)', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
{img.patient_name || img.filename}
</div>
<button
title="Desvincular imagem"
onClick={() => unlinkImage(img.id)}
style={{ position:'absolute', top:4, right:4, background:'rgba(239,68,68,0.85)', color:'#fff', border:'none', borderRadius:4, width:22, height:22, cursor:'pointer', fontSize:11, display:'flex', alignItems:'center', justifyContent:'center' }}
></button>
</div>
))}
</div>
)}
</div>
</div>
) : null}
</Modal>
);
}
/* ── Modal de criação de GTO ─────────────────────────────────── */
function CreateGtoModal({ isOpen, onClose, onCreated }) {
const { showToast } = useToast();
const [form, setForm] = useState({ gto_number:'', patient_name:'', description:'' });
const [saving, setSaving] = useState(false);
const set = k => e => setForm(p => ({ ...p, [k]: e.target.value }));
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.gto_number.trim() || !form.patient_name.trim()) {
return showToast('Número e paciente são obrigatórios.', 'error');
}
setSaving(true);
try {
await api.post('/gtos', form);
showToast('GTO criada com sucesso!', 'success');
setForm({ gto_number:'', patient_name:'', description:'' });
onCreated?.();
onClose();
} catch (err) {
showToast(err.response?.data?.error || 'Erro ao criar GTO.', 'error');
} finally { setSaving(false); }
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Nova GTO">
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Número da GTO *</label>
<input type="text" required placeholder="Ex: GTO-2024-001" value={form.gto_number} onChange={set('gto_number')} />
</div>
<div className="form-group">
<label>Paciente *</label>
<input type="text" required placeholder="Nome completo do paciente" value={form.patient_name} onChange={set('patient_name')} />
</div>
<div className="form-group">
<label>Descrição</label>
<textarea rows={3} placeholder="Procedimento, observações..." value={form.description} onChange={set('description')} />
</div>
<div style={{ display:'flex', gap:10, marginTop:16 }}>
<button type="button" className="btn btn-secondary" style={{ flex:1 }} onClick={onClose}>Cancelar</button>
<button type="submit" className="btn btn-primary" style={{ flex:1 }} disabled={saving}>
{saving ? 'Salvando...' : 'Criar GTO'}
</button>
</div>
</form>
</Modal>
);
}
/* ── Página principal ────────────────────────────────────────── */
const FILTERS = [
{ key: 'all', label: 'Todas', icon: '📋' },
{ key: 'vinculada', label: 'Vinculadas', icon: '🔗' },
{ key: 'enviada', label: 'Enviadas', icon: '✈️' },
];
export default function GtosPage() {
const { showToast } = useToast();
const { isAdmin } = useAuth();
const [gtos, setGtos] = useState([]);
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({ all:0, vinculada:0, enviada:0 });
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('all');
const [page, setPage] = useState(1);
const LIMIT = 20;
// Modals
const [showCreate, setShowCreate] = useState(false);
const [detailGto, setDetailGto] = useState(null);
const [version, setVersion] = useState('');
const [showSettings, setShowSettings] = useState(false);
const [showUsers, setShowUsers] = useState(false);
const [showPlugins, setShowPlugins] = useState(false);
const [showSync, setShowSync] = useState(false);
const [syncTab, setSyncTab] = useState('devices');
const [showNewPatient, setShowNewPatient] = useState(false);
const searchTimerRef = useRef(null);
useEffect(() => {
fetch('/version.txt').then(r => r.text()).then(v => setVersion(v.trim())).catch(() => {});
loadStats();
}, []);
useEffect(() => { loadGtos(); }, [filter, page]);
useEffect(() => {
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
setPage(1);
loadGtos(1);
}, 350);
return () => clearTimeout(searchTimerRef.current);
}, [search]);
async function loadStats() {
try {
const [all, vinc, env] = await Promise.all([
api.get('/gtos?limit=1&page=1'),
api.get('/gtos?limit=1&page=1&filter=vinculada'),
api.get('/gtos?limit=1&page=1&filter=enviada'),
]);
setStats({ all: all.data.total, vinculada: vinc.data.total, enviada: env.data.total });
} catch {}
}
const loadGtos = useCallback(async (forcePage) => {
setLoading(true);
try {
const p = forcePage || page;
const params = new URLSearchParams({ search, filter, page: p, limit: LIMIT });
const { data } = await api.get(`/gtos?${params}`);
setGtos(data.rows || []);
setTotal(data.total || 0);
setPages(data.pages || 1);
} catch {
showToast('Erro ao carregar GTOs', 'error');
} finally { setLoading(false); }
}, [search, filter, page]);
const reload = () => { loadStats(); loadGtos(); };
async function deleteGto(gto) {
if (!confirm(`Excluir GTO #${gto.gto_number} do paciente "${gto.patient_name}"?\n\nAs imagens NÃO serão excluídas, apenas o vínculo.`)) return;
try {
await api.delete(`/gtos/${gto.id}`);
showToast('GTO excluída.', 'success');
reload();
} catch { showToast('Erro ao excluir GTO.', 'error'); }
}
async function toggleSent(gto, e) {
e.stopPropagation();
try {
await api.post(`/gtos/${gto.id}/sent`, { sent: !gto.sent });
showToast(gto.sent ? 'GTO revertida para pendente.' : 'GTO marcada como enviada!', 'success');
reload();
} catch { showToast('Erro ao alterar status.', 'error'); }
}
const handleFilterChange = (key) => { setFilter(key); setPage(1); };
return (
<>
<div className="app-container">
<Sidebar
version={version}
onNewPatient={() => setShowNewPatient(true)}
onUpload={() => { setSyncTab('upload'); setShowSync(true); }}
onSettings={() => setShowSettings(true)}
onUsers={() => setShowUsers(true)}
onSync={(tab) => { setSyncTab(tab || 'devices'); setShowSync(true); }}
onPlugins={() => setShowPlugins(true)}
/>
<main className="main-content">
{/* Header */}
<header className="header">
<div className="header-content">
<div className="header-left">
<h1>GTOs</h1>
</div>
<div className="header-actions">
<input
type="search"
className="search-input"
placeholder="🔍 Buscar por paciente ou número..."
value={search}
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>
</div>
</div>
</header>
<div className="content-scroll">
<div style={{ padding:'0 24px 24px', display:'flex', flexDirection:'column', gap:18 }}>
{/* Stats */}
<div 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',
background: filter === f.key ? 'var(--primary-color)' : 'white',
color: filter === f.key ? '#fff' : 'var(--text-primary)',
borderRadius:12, padding:'16px 20px',
boxShadow: filter === f.key ? '0 4px 14px rgba(79,70,229,0.35)' : 'var(--shadow-sm)',
border: filter === f.key ? 'none' : '1px solid var(--border-color)',
transition:'all 0.2s',
textAlign:'left',
}}
>
<div style={{ fontSize:'1.8rem', marginBottom:4 }}>{f.icon}</div>
<div style={{ fontSize:'1.5rem', fontWeight:700 }}>{stats[f.key] ?? '—'}</div>
<div style={{ fontSize:'0.8rem', opacity:0.75, marginTop:2 }}>{f.label}</div>
</button>
))}
</div>
{/* Tabela */}
<div style={{ background:'white', borderRadius:12, border:'1px solid var(--border-color)', boxShadow:'var(--shadow-sm)', overflow:'hidden' }}>
{loading ? (
<div style={{ padding:40, textAlign:'center' }}><div className="spinner" style={{ margin:'0 auto 10px' }} /><p style={{ color:'var(--text-secondary)' }}>Carregando...</p></div>
) : gtos.length === 0 ? (
<div style={{ padding:48, textAlign:'center', color:'var(--text-secondary)' }}>
<div style={{ fontSize:'3rem', marginBottom:12 }}>📋</div>
<h3 style={{ marginBottom:6 }}>Nenhuma GTO encontrada</h3>
<p style={{ fontSize:'0.85rem' }}>
{search ? `Nenhum resultado para "${search}"` : 'Crie a primeira GTO clicando em "+ Nova GTO"'}
</p>
</div>
) : (
<div style={{ overflowX:'auto' }}>
<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 => (
<th key={col} style={{
padding:'11px 14px', textAlign:'left', fontWeight:600,
color:'var(--text-secondary)', fontSize:'0.72rem',
textTransform:'uppercase', letterSpacing:'0.05em',
borderBottom:'1px solid var(--border-color)', whiteSpace:'nowrap',
}}>{col}</th>
))}
</tr>
</thead>
<tbody>
{gtos.map(gto => (
<tr
key={gto.id}
style={{ borderBottom:'1px solid var(--border-color)', cursor:'pointer', transition:'background 0.15s' }}
onClick={() => setDetailGto(gto)}
onMouseEnter={e => e.currentTarget.style.background = '#f8fafc'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{/* Thumb */}
<td style={{ padding:'10px 8px 10px 14px', width:52 }}>
<ThumbImg
src={gto.thumb_url}
style={{ width:40, height:40, borderRadius:7, objectFit:'cover', flexShrink:0 }}
label=""
/>
</td>
{/* Número */}
<td 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' }}>
{gto.patient_name}
</td>
{/* Imagens */}
<td 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)',
color: gto.image_count > 0 ? '#4f46e5' : '#94a3b8',
borderRadius:20, padding:'2px 10px', fontWeight:700, fontSize:'0.8rem', minWidth:28,
}}>
{gto.image_count || 0}
</span>
</td>
{/* Descrição */}
<td 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' }}>
{fmtDate(gto.created_at)}
</td>
{/* Status */}
<td style={{ padding:'10px 14px' }}>
<StatusBadge sent={gto.sent} imageCount={gto.image_count} />
</td>
{/* Ações */}
<td style={{ padding:'10px 14px' }} onClick={e => e.stopPropagation()}>
<div style={{ display:'flex', gap:6 }}>
<button
className="btn btn-small btn-secondary"
title={gto.sent ? 'Desfazer envio' : 'Marcar como enviada'}
style={{ padding:'5px 9px', fontSize:'0.8rem' }}
onClick={(e) => toggleSent(gto, e)}
>
{gto.sent ? '↩️' : '✈️'}
</button>
{isAdmin && (
<button
className="btn btn-small"
title="Excluir GTO"
style={{ padding:'5px 9px', fontSize:'0.8rem', background:'rgba(239,68,68,0.1)', color:'#ef4444', border:'1px solid rgba(239,68,68,0.3)' }}
onClick={(e) => { e.stopPropagation(); deleteGto(gto); }}
>🗑</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Paginação */}
{pages > 1 && (
<div 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 }}>
<button
className="btn btn-small btn-secondary"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
style={{ padding:'5px 12px' }}
> Anterior</button>
{/* Números de páginas (máx 7 botões) */}
{Array.from({ length: Math.min(pages, 7) }, (_, i) => {
const pn = pages <= 7 ? i + 1 : Math.max(1, Math.min(page - 3, pages - 6)) + i;
return (
<button
key={pn}
className="btn btn-small"
onClick={() => setPage(pn)}
style={{
padding:'5px 10px',
background: pn === page ? 'var(--primary-color)' : 'white',
color: pn === page ? '#fff' : 'var(--text-primary)',
border: pn === page ? 'none' : '1px solid var(--border-color)',
fontWeight: pn === page ? 700 : 400,
}}
>{pn}</button>
);
})}
<button
className="btn btn-small btn-secondary"
disabled={page >= pages}
onClick={() => setPage(p => p + 1)}
style={{ padding:'5px 12px' }}
>Próxima </button>
</div>
</div>
)}
</div>
</div>
</div>
</main>
</div>
{/* Modals */}
<CreateGtoModal isOpen={showCreate} onClose={() => setShowCreate(false)} onCreated={reload} />
<GtoDetailModal isOpen={!!detailGto} gto={detailGto} onClose={() => setDetailGto(null)} onUpdated={reload} />
<CreatePatientModal isOpen={showNewPatient} onClose={() => setShowNewPatient(false)} />
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
<SyncModal isOpen={showSync} onClose={() => setShowSync(false)} defaultTab={syncTab} />
</>
);
}