Files
rx.scoreodonto.com/dental-server/dental-client/src/pages/GtosPage.jsx
T
VPS 4 Deploy Agent f027842c76
continuous-integration/webhook Deploy concluído (VPS4)
feat(gtos): página de gerenciamento de GTOs com filtros e paginação
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>
2026-05-31 16:06:42 +02:00

547 lines
26 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} />
</>
);
}