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 DownloadPage from './pages/DownloadPage';
|
||||||
import PatientsPage from './pages/PatientsPage';
|
import PatientsPage from './pages/PatientsPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
import GtosPage from './pages/GtosPage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +70,12 @@ export default function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
<Route path="/gtos" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<GtosPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Catch-all: redireciona para home */}
|
{/* Catch-all: redireciona para home */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o
|
|||||||
<i className="fa-solid fa-users" /> Pacientes
|
<i className="fa-solid fa-users" /> Pacientes
|
||||||
</NavLink>
|
</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}>
|
<button className="nav-item" onClick={onNewPatient}>
|
||||||
<i className="fa-solid fa-user-plus" /> Novo Paciente
|
<i className="fa-solid fa-user-plus" /> Novo Paciente
|
||||||
</button>
|
</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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,34 +3,75 @@ const router = express.Router();
|
|||||||
const db = require('../database');
|
const db = require('../database');
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// GET /api/gtos?patient=nome — listar GTOs de um paciente
|
// GET /api/gtos — listar GTOs com filtros e paginação
|
||||||
// GET /api/gtos — listar todas as GTOs
|
// Query params:
|
||||||
|
// search — busca por patient_name ou gto_number (ILIKE)
|
||||||
|
// filter — 'all' | 'vinculada' | 'enviada'
|
||||||
|
// page — número da página (default 1)
|
||||||
|
// limit — itens por página (default 20)
|
||||||
// ================================================================
|
// ================================================================
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { patient } = req.query;
|
const search = (req.query.search || '').trim();
|
||||||
let rows;
|
const filter = req.query.filter || 'all';
|
||||||
if (patient) {
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||||
rows = await db.all(
|
const limit = Math.min(100, parseInt(req.query.limit) || 20);
|
||||||
`SELECT g.*, COUNT(gi.id) as image_count
|
const offset = (page - 1) * limit;
|
||||||
FROM gtos g
|
|
||||||
LEFT JOIN gto_images gi ON gi.gto_id = g.id
|
let params = [];
|
||||||
WHERE g.patient_name = $1
|
let where = [];
|
||||||
GROUP BY g.id
|
let i = 1;
|
||||||
ORDER BY g.created_at DESC`,
|
|
||||||
[patient]
|
if (search) {
|
||||||
);
|
where.push(`(g.patient_name ILIKE $${i} OR g.gto_number ILIKE $${i})`);
|
||||||
} else {
|
params.push(`%${search}%`);
|
||||||
rows = await db.all(
|
i++;
|
||||||
`SELECT g.*, COUNT(gi.id) as image_count
|
|
||||||
FROM gtos g
|
|
||||||
LEFT JOIN gto_images gi ON gi.gto_id = g.id
|
|
||||||
GROUP BY g.id
|
|
||||||
ORDER BY g.created_at DESC
|
|
||||||
LIMIT 200`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
res.json(rows);
|
|
||||||
|
if (filter === 'vinculada') {
|
||||||
|
where.push(`EXISTS (SELECT 1 FROM gto_images gi2 WHERE gi2.gto_id = g.id)`);
|
||||||
|
} else if (filter === 'enviada') {
|
||||||
|
where.push(`g.sent = TRUE`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
// Total para paginação
|
||||||
|
const countRow = await db.get(
|
||||||
|
`SELECT COUNT(*) as total FROM gtos g ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = parseInt(countRow.total);
|
||||||
|
|
||||||
|
const rows = await db.all(
|
||||||
|
`SELECT g.*,
|
||||||
|
COUNT(gi.id)::int AS image_count
|
||||||
|
FROM gtos g
|
||||||
|
LEFT JOIN gto_images gi ON gi.gto_id = g.id
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY g.id
|
||||||
|
ORDER BY g.created_at DESC
|
||||||
|
LIMIT ${limit} OFFSET ${offset}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adiciona thumb_url da primeira imagem vinculada para preview
|
||||||
|
for (const row of rows) {
|
||||||
|
if (parseInt(row.image_count) > 0) {
|
||||||
|
const firstImg = await db.get(
|
||||||
|
`SELECT i.id FROM gto_images gi
|
||||||
|
JOIN images i ON i.id = gi.image_id
|
||||||
|
WHERE gi.gto_id = $1
|
||||||
|
ORDER BY gi.created_at ASC LIMIT 1`,
|
||||||
|
[row.id]
|
||||||
|
);
|
||||||
|
row.thumb_url = firstImg ? `/api/images/${firstImg.id}/thumbnail` : null;
|
||||||
|
} else {
|
||||||
|
row.thumb_url = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ rows, total, page, limit, pages: Math.ceil(total / limit) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erro ao listar GTOs:', err);
|
console.error('Erro ao listar GTOs:', err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
|
|||||||
Reference in New Issue
Block a user