From f027842c76ad490bf0ba30b9f3a3842ded5d40f6 Mon Sep 17 00:00:00 2001 From: VPS 4 Deploy Agent Date: Sun, 31 May 2026 16:06:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(gtos):=20p=C3=A1gina=20de=20gerenciamento?= =?UTF-8?q?=20de=20GTOs=20com=20filtros=20e=20pagina=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- dental-server/dental-client/src/App.jsx | 7 + .../dental-client/src/components/Sidebar.jsx | 7 + .../dental-client/src/pages/GtosPage.jsx | 546 ++++++++++++++++++ dental-server/routes/gtos.js | 89 ++- 4 files changed, 625 insertions(+), 24 deletions(-) create mode 100644 dental-server/dental-client/src/pages/GtosPage.jsx diff --git a/dental-server/dental-client/src/App.jsx b/dental-server/dental-client/src/App.jsx index 48b6c06..c3db12d 100644 --- a/dental-server/dental-client/src/App.jsx +++ b/dental-server/dental-client/src/App.jsx @@ -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() { } /> + + + + } /> + {/* Catch-all: redireciona para home */} } /> diff --git a/dental-server/dental-client/src/components/Sidebar.jsx b/dental-server/dental-client/src/components/Sidebar.jsx index 5a9b834..e36cb84 100644 --- a/dental-server/dental-client/src/components/Sidebar.jsx +++ b/dental-server/dental-client/src/components/Sidebar.jsx @@ -45,6 +45,13 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o Pacientes + `nav-item${isActive ? ' active' : ''}`} + > + GTOs + + diff --git a/dental-server/dental-client/src/pages/GtosPage.jsx b/dental-server/dental-client/src/pages/GtosPage.jsx new file mode 100644 index 0000000..4606898 --- /dev/null +++ b/dental-server/dental-client/src/pages/GtosPage.jsx @@ -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 ( + + ✈️ Enviada + + ); + if (imageCount > 0) return ( + + 🔗 Vinculada + + ); + return ( + + 📋 Pendente + + ); +} + +/* ── 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 ( + + {loading ? ( +
+ ) : detail ? ( +
+ + {/* Info */} +
+ {[ + { 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 }) => ( +
+
{label}
+
{value}
+
+ ))} +
+ + {detail.description && ( +
+
Descrição
+
{detail.description}
+
+ )} + + {/* Status + ação */} +
+
+ + {detail.sent && Enviada em {fmtDate(detail.sent_at)}} +
+ +
+ + {/* Imagens vinculadas */} +
+
+ Imagens vinculadas ({detail.images?.length || 0}) +
+ {(!detail.images || detail.images.length === 0) ? ( +
+ Nenhuma imagem vinculada a esta GTO. +
+ ) : ( +
+ {detail.images.map(img => ( +
+ +
+ {img.patient_name || img.filename} +
+ +
+ ))} +
+ )} +
+ +
+ ) : null} + + ); +} + +/* ── 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 ( + +
+
+ + +
+
+ + +
+
+ +