feat: cron automático de thumbnails + lista de pacientes em tabela + CRUD
continuous-integration/webhook Deploy concluído (VPS4)
continuous-integration/webhook Deploy concluído (VPS4)
Backend: - server.js: cron thumb-backfill a cada 10min com rastreio de estado (lastRun, nextRun, running, lastResult). GET /api/cron/status e POST /api/cron/run/:key para consulta e disparo manual via painel. - routes/images.js: PUT /api/images/patients/:name — edita patient_name, doctor e remark em todas as imagens do paciente de uma vez. Frontend: - PatientsPage.jsx (/patients): tabela com colunas Nome (+ miniatura), Dentista, Imagens, Última atualização, Observações, Ações. Suporta busca, paginação infinita, editar e excluir por linha. - SettingsPage.jsx (/settings): seção de Cron Jobs com status, resultado e botão "Executar agora"; seção de informações do servidor. - EditPatientModal.jsx: edita nome, dentista e observações do paciente. - Sidebar.jsx: add "Pacientes" (todos usuários) e "Configurações" (admin). - App.jsx: rotas /patients (protegida) e /settings (admin). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import ClientsPage from './pages/ClientsPage';
|
||||
import AdminClinicsPage from './pages/AdminClinicsPage';
|
||||
import ResetPage from './pages/ResetPage';
|
||||
import DownloadPage from './pages/DownloadPage';
|
||||
import PatientsPage from './pages/PatientsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -55,6 +57,18 @@ export default function App() {
|
||||
</AdminRoute>
|
||||
} />
|
||||
|
||||
<Route path="/patients" element={
|
||||
<ProtectedRoute>
|
||||
<PatientsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/settings" element={
|
||||
<AdminRoute>
|
||||
<SettingsPage />
|
||||
</AdminRoute>
|
||||
} />
|
||||
|
||||
{/* Catch-all: redireciona para home */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Modal from './Modal';
|
||||
import api from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
export default function EditPatientModal({ isOpen, onClose, patient, onSaved }) {
|
||||
const { showToast } = useToast();
|
||||
const [form, setForm] = useState({ newName: '', doctor: '', remark: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (patient) {
|
||||
setForm({
|
||||
newName: patient.patient_name || '',
|
||||
doctor: patient.doctor || '',
|
||||
remark: patient.remark || '',
|
||||
});
|
||||
}
|
||||
}, [patient]);
|
||||
|
||||
const set = (key) => (e) => setForm((prev) => ({ ...prev, [key]: e.target.value }));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.newName.trim()) return showToast('Nome é obrigatório.', 'error');
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/images/patients/${encodeURIComponent(patient.patient_name)}`, form);
|
||||
showToast('Paciente atualizado com sucesso!', 'success');
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
showToast(err.response?.data?.error || 'Erro ao salvar.', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Editar Paciente">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Nome completo *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.newName}
|
||||
onChange={set('newName')}
|
||||
placeholder="Nome do paciente"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Dentista</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.doctor}
|
||||
onChange={set('doctor')}
|
||||
placeholder="Nome do dentista responsável"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Observações</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.remark}
|
||||
onChange={set('remark')}
|
||||
placeholder="Informações adicionais..."
|
||||
/>
|
||||
</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...' : 'Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,13 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o
|
||||
<i className="fa-solid fa-images" /> Galeria
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/patients"
|
||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<i className="fa-solid fa-users" /> Pacientes
|
||||
</NavLink>
|
||||
|
||||
<button className="nav-item" onClick={onNewPatient}>
|
||||
<i className="fa-solid fa-user-plus" /> Novo Paciente
|
||||
</button>
|
||||
@@ -90,6 +97,13 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o
|
||||
<button className="nav-item" onClick={onPlugins}>
|
||||
<i className="fa-solid fa-plug" /> Plugins / Wasabi
|
||||
</button>
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<i className="fa-solid fa-sliders" /> Configurações
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
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 CreatePatientModal from '../components/CreatePatientModal';
|
||||
import EditPatientModal from '../components/EditPatientModal';
|
||||
import SettingsModal from '../components/SettingsModal';
|
||||
import UserManagementModal from '../components/UserManagementModal';
|
||||
import PluginsModal from '../components/PluginsModal';
|
||||
import SyncModal from '../components/SyncModal';
|
||||
|
||||
function formatDate(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',
|
||||
});
|
||||
}
|
||||
|
||||
export default function PatientsPage() {
|
||||
const { showToast } = useToast();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
const [patients, setPatients] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [version, setVersion] = useState('');
|
||||
const isFetchingRef = useRef(false);
|
||||
|
||||
// Modals
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState(null);
|
||||
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');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/version.txt').then(r => r.text()).then(v => setVersion(v.trim())).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadPatients = useCallback(async (reset = true) => {
|
||||
if (isFetchingRef.current) return;
|
||||
if (!reset && !hasMore) return;
|
||||
isFetchingRef.current = true;
|
||||
const currentPage = reset ? 1 : page;
|
||||
if (reset) { setPage(1); setHasMore(true); setLoading(true); }
|
||||
|
||||
try {
|
||||
const { data } = await api.get(
|
||||
`/images/patients?search=${encodeURIComponent(search)}&page=${currentPage}&limit=50`
|
||||
);
|
||||
if (data.length < 50) setHasMore(false);
|
||||
if (reset) {
|
||||
setPatients(data);
|
||||
setPage(2);
|
||||
} else {
|
||||
setPatients(prev => [...prev, ...data]);
|
||||
setPage(p => p + 1);
|
||||
}
|
||||
} catch {
|
||||
showToast('Erro ao carregar pacientes', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isFetchingRef.current = false;
|
||||
}
|
||||
}, [search, page, hasMore, showToast]);
|
||||
|
||||
useEffect(() => { loadPatients(true); }, [search]);
|
||||
|
||||
const handleScroll = (e) => {
|
||||
const el = e.target;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 200) loadPatients(false);
|
||||
};
|
||||
|
||||
const deletePatient = async (patientName) => {
|
||||
if (!confirm(`⚠️ Excluir permanentemente todas as imagens de "${patientName}"?\n\nEsta ação não pode ser desfeita.`)) return;
|
||||
try {
|
||||
await api.delete(`/images/by-patient?name=${encodeURIComponent(patientName)}`);
|
||||
showToast('Paciente excluído com sucesso!', 'success');
|
||||
loadPatients(true);
|
||||
} catch (e) {
|
||||
showToast(e.response?.data?.error || 'Falha ao excluir.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-container">
|
||||
<Sidebar
|
||||
version={version}
|
||||
onNewPatient={() => setShowCreate(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 className="header">
|
||||
<div className="header-content">
|
||||
<div className="header-left">
|
||||
<h1>Pacientes</h1>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<input
|
||||
type="search"
|
||||
className="search-input"
|
||||
placeholder="🔍 Pesquisar paciente..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{ height: 42, minWidth: 220 }}
|
||||
/>
|
||||
<button className="btn btn-primary" style={{ height: 42 }} onClick={() => setShowCreate(true)}>
|
||||
+ Novo Paciente
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ height: 42, padding: '10px 14px' }} onClick={() => loadPatients(true)}>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="content-scroll" onScroll={handleScroll}>
|
||||
{loading && (
|
||||
<div className="loading"><div className="spinner" /><p>Carregando...</p></div>
|
||||
)}
|
||||
|
||||
{!loading && patients.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">👥</div>
|
||||
<h2>Nenhum paciente encontrado</h2>
|
||||
<p>As imagens enviadas pelos clientes aparecerão aqui agrupadas por paciente.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && patients.length > 0 && (
|
||||
<div style={{ padding: '0 24px 24px' }}>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: '0.875rem',
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-hover, rgba(0,0,0,0.04))' }}>
|
||||
{['Paciente', 'Dentista', 'Imagens', 'Última atualização', 'Observações', 'Ações'].map(col => (
|
||||
<th key={col} style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{patients.map((p, i) => (
|
||||
<tr key={`${p.patient_name}-${i}`} style={{
|
||||
borderBottom: '1px solid var(--border)',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-hover, rgba(0,0,0,0.025))'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', fontWeight: 600, maxWidth: 200 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{p.thumb_url
|
||||
? <img src={p.thumb_url} alt="" style={{ width: 36, height: 36, borderRadius: 6, objectFit: 'cover', flexShrink: 0, background: '#e0e7ff' }} loading="lazy" />
|
||||
: <div style={{ width: 36, height: 36, borderRadius: 6, background: 'var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, flexShrink: 0 }}>🦷</div>
|
||||
}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={p.patient_name}>
|
||||
{p.patient_name || 'Sem nome'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', color: 'var(--text-secondary)', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.doctor || '—'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(59,130,246,0.1)', color: '#3b82f6',
|
||||
borderRadius: 20, padding: '2px 10px', fontWeight: 700, fontSize: '0.8rem',
|
||||
}}>
|
||||
{p.image_count || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
|
||||
{formatDate(p.last_date)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', color: 'var(--text-secondary)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.remark || '—'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={() => setEditTarget(p)}
|
||||
title="Editar paciente"
|
||||
style={{ padding: '5px 10px' }}
|
||||
>✏️ Editar</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="btn btn-small"
|
||||
onClick={() => deletePatient(p.patient_name)}
|
||||
title="Excluir paciente"
|
||||
style={{ padding: '5px 10px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.3)' }}
|
||||
>🗑️</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{!hasMore && patients.length > 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>
|
||||
{patients.length} paciente{patients.length !== 1 ? 's' : ''} no total
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CreatePatientModal isOpen={showCreate} onClose={() => setShowCreate(false)} onCreated={() => loadPatients(true)} />
|
||||
<EditPatientModal isOpen={!!editTarget} patient={editTarget} onClose={() => setEditTarget(null)} onSaved={() => loadPatients(true)} />
|
||||
<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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
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';
|
||||
|
||||
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', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function StatusBadge({ running, lastResult }) {
|
||||
if (running) return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'rgba(234,179,8,0.12)', color: '#ca8a04', borderRadius: 20, padding: '3px 12px', fontWeight: 600, fontSize: '0.78rem' }}>⏳ Executando</span>;
|
||||
if (!lastResult) return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'rgba(107,114,128,0.12)', color: '#6b7280', borderRadius: 20, padding: '3px 12px', fontWeight: 600, fontSize: '0.78rem' }}>— Aguardando</span>;
|
||||
if (lastResult.status === 'error') return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'rgba(239,68,68,0.12)', color: '#ef4444', borderRadius: 20, padding: '3px 12px', fontWeight: 600, fontSize: '0.78rem' }}>❌ Erro</span>;
|
||||
return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'rgba(34,197,94,0.12)', color: '#16a34a', borderRadius: 20, padding: '3px 12px', fontWeight: 600, fontSize: '0.78rem' }}>✅ OK</span>;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { showToast } = useToast();
|
||||
const [cronJobs, setCronJobs] = useState([]);
|
||||
const [loadingCron, setLoadingCron] = useState(true);
|
||||
const [runningKey, setRunningKey] = useState(null);
|
||||
const [version, setVersion] = useState('');
|
||||
|
||||
// sidebar modals
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showAccountSettings, setShowAccountSettings] = useState(false);
|
||||
const [showUsers, setShowUsers] = useState(false);
|
||||
const [showPlugins, setShowPlugins] = useState(false);
|
||||
const [showSync, setShowSync] = useState(false);
|
||||
const [syncTab, setSyncTab] = useState('devices');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/version.txt').then(r => r.text()).then(v => setVersion(v.trim())).catch(() => {});
|
||||
loadCronStatus();
|
||||
// Atualiza o status a cada 15s para refletir execuções em andamento
|
||||
const interval = setInterval(loadCronStatus, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadCronStatus = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/cron/status');
|
||||
setCronJobs(data);
|
||||
} catch {
|
||||
// silencia — pode não ter permissão
|
||||
} finally {
|
||||
setLoadingCron(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runCron = async (key) => {
|
||||
setRunningKey(key);
|
||||
try {
|
||||
await api.post(`/cron/run/${key}`);
|
||||
showToast('Cron iniciado em background.', 'info');
|
||||
setTimeout(loadCronStatus, 2000);
|
||||
} catch (e) {
|
||||
showToast(e.response?.data?.error || 'Falha ao iniciar.', 'error');
|
||||
} finally {
|
||||
setRunningKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-container">
|
||||
<Sidebar
|
||||
version={version}
|
||||
onNewPatient={() => setShowCreate(true)}
|
||||
onUpload={() => { setSyncTab('upload'); setShowSync(true); }}
|
||||
onSettings={() => setShowAccountSettings(true)}
|
||||
onUsers={() => setShowUsers(true)}
|
||||
onSync={(tab) => { setSyncTab(tab || 'devices'); setShowSync(true); }}
|
||||
onPlugins={() => setShowPlugins(true)}
|
||||
/>
|
||||
|
||||
<main className="main-content">
|
||||
<header className="header">
|
||||
<div className="header-content">
|
||||
<div className="header-left">
|
||||
<h1>Configurações</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="content-scroll">
|
||||
<div style={{ padding: '24px', maxWidth: 900, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
|
||||
{/* ── Cron Jobs ─────────────────────────────────────── */}
|
||||
<section style={{ background: 'var(--surface)', borderRadius: 12, border: '1px solid var(--border)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '18px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1rem', fontWeight: 700, margin: 0 }}>⏰ Cron Jobs</h2>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--text-secondary)' }}>
|
||||
Tarefas agendadas que rodam automaticamente em background.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.8rem', padding: '7px 14px' }}
|
||||
onClick={loadCronStatus}
|
||||
>
|
||||
🔄 Atualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingCron && (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<div className="spinner" style={{ margin: '0 auto 8px' }} />
|
||||
Carregando...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingCron && cronJobs.length === 0 && (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
Nenhum cron job encontrado.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingCron && cronJobs.length > 0 && (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{['Nome', 'Descrição', 'Intervalo', 'Status', 'Última execução', 'Próxima execução', 'Resultado', ''].map(col => (
|
||||
<th key={col} style={{
|
||||
padding: '10px 16px', textAlign: 'left', fontWeight: 600,
|
||||
color: 'var(--text-secondary)', fontSize: '0.72rem',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap',
|
||||
background: 'var(--surface-hover, rgba(0,0,0,0.025))',
|
||||
}}>{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cronJobs.map(job => (
|
||||
<tr key={job.key} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '14px 16px', fontWeight: 600, whiteSpace: 'nowrap' }}>{job.name}</td>
|
||||
<td style={{ padding: '14px 16px', color: 'var(--text-secondary)', maxWidth: 240, fontSize: '0.82rem' }}>{job.description}</td>
|
||||
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}>
|
||||
A cada {job.intervalMinutes} min
|
||||
</td>
|
||||
<td style={{ padding: '14px 16px' }}>
|
||||
<StatusBadge running={job.running} lastResult={job.lastResult} />
|
||||
</td>
|
||||
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
|
||||
{fmtDate(job.lastRun)}
|
||||
</td>
|
||||
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
|
||||
{fmtDate(job.nextRun)}
|
||||
</td>
|
||||
<td style={{ padding: '14px 16px', fontSize: '0.82rem', color: 'var(--text-secondary)' }}>
|
||||
{job.lastResult ? (
|
||||
job.lastResult.status === 'error'
|
||||
? <span style={{ color: '#ef4444' }}>{job.lastResult.error}</span>
|
||||
: `${job.lastResult.total} imagem(ns) processada(s)`
|
||||
) : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '14px 16px' }}>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
style={{ whiteSpace: 'nowrap', padding: '6px 12px', fontSize: '0.8rem' }}
|
||||
disabled={job.running || runningKey === job.key}
|
||||
onClick={() => runCron(job.key)}
|
||||
>
|
||||
{job.running || runningKey === job.key ? '⏳ Rodando...' : '▶ Executar agora'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Servidor ──────────────────────────────────────── */}
|
||||
<section style={{ background: 'var(--surface)', borderRadius: 12, border: '1px solid var(--border)', padding: '18px 20px' }}>
|
||||
<h2 style={{ fontSize: '1rem', fontWeight: 700, margin: '0 0 12px' }}>🖥️ Informações do Servidor</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
|
||||
{[
|
||||
{ label: 'Versão', value: version ? `v${version}` : '—' },
|
||||
{ label: 'Ambiente', value: 'Produção' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} style={{ background: 'var(--surface-hover, rgba(0,0,0,0.03))', borderRadius: 8, padding: '12px 14px' }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.95rem' }}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CreatePatientModal isOpen={showCreate} onClose={() => setShowCreate(false)} />
|
||||
<SettingsModal isOpen={showAccountSettings} onClose={() => setShowAccountSettings(false)} />
|
||||
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
|
||||
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
|
||||
<SyncModal isOpen={showSync} onClose={() => setShowSync(false)} defaultTab={syncTab} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -207,6 +207,49 @@ router.get('/by-patient', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// PUT /api/images/patients/:name - Editar dados de um paciente
|
||||
// Atualiza patient_name, doctor e remark em todas as imagens do paciente.
|
||||
// ================================================================
|
||||
router.put('/patients/:name', async (req, res) => {
|
||||
try {
|
||||
const oldName = decodeURIComponent(req.params.name);
|
||||
const { newName, doctor, remark } = req.body;
|
||||
|
||||
if (!oldName) return res.status(400).json({ error: 'Nome do paciente obrigatório.' });
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
|
||||
if (newName !== undefined && newName.trim() !== '') {
|
||||
updates.push(`patient_name = $${i++}`);
|
||||
params.push(newName.trim());
|
||||
}
|
||||
if (doctor !== undefined) {
|
||||
updates.push(`doctor = $${i++}`);
|
||||
params.push(doctor);
|
||||
}
|
||||
if (remark !== undefined) {
|
||||
updates.push(`remark = $${i++}`);
|
||||
params.push(remark);
|
||||
}
|
||||
|
||||
if (updates.length === 0) return res.status(400).json({ error: 'Nenhum campo para atualizar.' });
|
||||
|
||||
params.push(oldName);
|
||||
const result = await db.run(
|
||||
`UPDATE images SET ${updates.join(', ')} WHERE patient_name = $${i}`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ success: true, updated: result.changes });
|
||||
} catch (error) {
|
||||
console.error('Erro ao editar paciente:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// DELETE /api/images/by-patient - Excluir todas imagens de um paciente
|
||||
// ================================================================
|
||||
|
||||
+68
-6
@@ -1286,9 +1286,29 @@ async function start() {
|
||||
// Inicializar cliente DragonflyDB/Redis
|
||||
redis.initRedis();
|
||||
|
||||
// Iniciar servidor
|
||||
// Backfill de thumbnails ausentes: roda em background após o boot
|
||||
setTimeout(async () => {
|
||||
// ================================================================
|
||||
// CRON — Geração automática de thumbnails ausentes
|
||||
// ================================================================
|
||||
const THUMB_CRON_INTERVAL_MS = 10 * 60 * 1000; // 10 minutos
|
||||
|
||||
const cronState = {
|
||||
thumbBackfill: {
|
||||
name: 'Geração de Thumbnails',
|
||||
description: 'Gera e envia ao Wasabi as miniaturas de RX que ainda não possuem thumbnail.',
|
||||
intervalMs: THUMB_CRON_INTERVAL_MS,
|
||||
running: false,
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
lastResult: null,
|
||||
}
|
||||
};
|
||||
|
||||
async function runThumbCron() {
|
||||
if (cronState.thumbBackfill.running) return;
|
||||
cronState.thumbBackfill.running = true;
|
||||
cronState.thumbBackfill.lastRun = new Date().toISOString();
|
||||
cronState.thumbBackfill.nextRun = new Date(Date.now() + THUMB_CRON_INTERVAL_MS).toISOString();
|
||||
console.log('[cron:thumbs] Iniciando varredura de thumbnails ausentes...');
|
||||
try {
|
||||
const { runThumbBackfill } = require('./routes/images');
|
||||
const missing = await db.all(
|
||||
@@ -1296,11 +1316,53 @@ async function start() {
|
||||
WHERE thumb_filename IS NULL AND enabled = true ORDER BY created_at DESC`
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
console.log(`[boot] Iniciando backfill de thumbnails para ${missing.length} imagem(ns) sem thumb...`);
|
||||
runThumbBackfill(missing);
|
||||
console.log(`[cron:thumbs] ${missing.length} imagem(ns) sem thumbnail — processando...`);
|
||||
await runThumbBackfill(missing);
|
||||
cronState.thumbBackfill.lastResult = { total: missing.length, status: 'ok' };
|
||||
} else {
|
||||
console.log('[cron:thumbs] Nenhuma imagem pendente.');
|
||||
cronState.thumbBackfill.lastResult = { total: 0, status: 'ok' };
|
||||
}
|
||||
} catch (e) { /* silencia erro de backfill para não afetar o boot */ }
|
||||
} catch (e) {
|
||||
console.error('[cron:thumbs] Erro:', e.message);
|
||||
cronState.thumbBackfill.lastResult = { total: 0, status: 'error', error: e.message };
|
||||
} finally {
|
||||
cronState.thumbBackfill.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Rodar na inicialização após 5s e depois a cada intervalo
|
||||
setTimeout(() => {
|
||||
cronState.thumbBackfill.nextRun = new Date(Date.now() + THUMB_CRON_INTERVAL_MS).toISOString();
|
||||
runThumbCron();
|
||||
}, 5000);
|
||||
setInterval(runThumbCron, THUMB_CRON_INTERVAL_MS);
|
||||
|
||||
// GET /api/cron/status — estado dos cron jobs (admin)
|
||||
app.get('/api/cron/status', authenticateToken, (req, res) => {
|
||||
res.json(
|
||||
Object.entries(cronState).map(([key, job]) => ({
|
||||
key,
|
||||
name: job.name,
|
||||
description: job.description,
|
||||
intervalMinutes: Math.round(job.intervalMs / 60000),
|
||||
running: job.running,
|
||||
lastRun: job.lastRun,
|
||||
nextRun: job.nextRun,
|
||||
lastResult: job.lastResult,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
// POST /api/cron/run/:key — dispara um cron job manualmente (admin)
|
||||
app.post('/api/cron/run/:key', authenticateToken, async (req, res) => {
|
||||
if (req.params.key === 'thumbBackfill') {
|
||||
res.json({ success: true, message: 'Cron iniciado em background.' });
|
||||
runThumbCron();
|
||||
} else {
|
||||
res.status(404).json({ error: 'Cron job não encontrado.' });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
|
||||
Reference in New Issue
Block a user