feat(gtos): página de gerenciamento de GTOs com filtros e paginação
continuous-integration/webhook Deploy concluído (VPS4)
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:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user