1981 lines
73 KiB
JavaScript
1981 lines
73 KiB
JavaScript
// ================================================================
|
|
// DENTAL IMAGE MANAGER - Main JavaScript
|
|
// Two-Level Navigation: Patients → Images → Transform
|
|
// ================================================================
|
|
|
|
const API_URL = '/api';
|
|
|
|
// ================================================================
|
|
// ERROR LOGGER — Logs to console AND shows on screen
|
|
// Ative DEBUG=true só para diagnóstico; em produção fica silencioso
|
|
// (info/warn/group viram no-op) para não poluir o console nem custar reflows.
|
|
// ================================================================
|
|
const DEBUG = false;
|
|
const _noop = () => {};
|
|
const _log = {
|
|
info: DEBUG ? (tag, ...args) => console.log(`%c[${tag}]`, 'color:#667eea;font-weight:bold', ...args) : _noop,
|
|
warn: DEBUG ? (tag, ...args) => console.warn(`%c[${tag}]`, 'color:#ffa500;font-weight:bold', ...args) : _noop,
|
|
error: (tag, ...args) => console.error(`%c[${tag}]`, 'color:#ff4444;font-weight:bold', ...args),
|
|
group: DEBUG ? (tag) => console.group(`%c[${tag}]`, 'color:#667eea;font-weight:bold') : _noop,
|
|
end: DEBUG ? () => console.groupEnd() : _noop
|
|
};
|
|
|
|
function showErrorBanner(msg, detail = '') {
|
|
let banner = document.getElementById('_errBanner');
|
|
if (!banner) {
|
|
banner = document.createElement('div');
|
|
banner.id = '_errBanner';
|
|
banner.style.cssText = [
|
|
'position:fixed','bottom:70px','left:50%','transform:translateX(-50%)',
|
|
'background:#c0392b','color:#fff','padding:12px 20px','border-radius:8px',
|
|
'z-index:9999','font-size:13px','max-width:90vw','text-align:center',
|
|
'box-shadow:0 4px 20px rgba(0,0,0,0.3)','cursor:pointer'
|
|
].join(';');
|
|
banner.onclick = () => banner.remove();
|
|
document.body.appendChild(banner);
|
|
}
|
|
banner.innerHTML = `<b>⚠️ ${msg}</b>${detail ? `<br><small>${detail}</small>` : ''}`;
|
|
setTimeout(() => banner && banner.remove(), 8000);
|
|
}
|
|
|
|
function getAuthToken() {
|
|
return localStorage.getItem('auth_token');
|
|
}
|
|
|
|
let authVerificationInProgress = false;
|
|
|
|
async function verifyAuth() {
|
|
if (authVerificationInProgress) return false;
|
|
const token = getAuthToken();
|
|
if (!token) { window.location.href = '/login'; return false; }
|
|
authVerificationInProgress = true;
|
|
try {
|
|
const response = await fetch(`${API_URL}/auth/verify`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
if (!response.ok) { localStorage.removeItem('auth_token'); window.location.href = '/login'; return false; }
|
|
const data = await response.json();
|
|
authVerificationInProgress = false;
|
|
if (data.valid) {
|
|
const isAdmin = !!data.user.is_admin;
|
|
localStorage.setItem('is_admin', isAdmin ? 'true' : 'false');
|
|
localStorage.setItem('username', data.user.username || '');
|
|
localStorage.setItem('email', data.user.email || '');
|
|
|
|
// Controle de visibilidade de itens de menu
|
|
updateAdminMenuVisibility(isAdmin);
|
|
|
|
// Se usuário comum tentar acessar clients ou admin-clinics no front, joga de volta pra home
|
|
if (!isAdmin && (window.location.pathname === '/clients' || window.location.pathname === '/admin-clinics')) {
|
|
window.location.href = '/';
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
localStorage.removeItem('auth_token'); window.location.href = '/login'; return false;
|
|
} catch {
|
|
localStorage.removeItem('auth_token');
|
|
authVerificationInProgress = false;
|
|
window.location.href = '/login'; return false;
|
|
}
|
|
}
|
|
|
|
function updateAdminMenuVisibility(isAdmin) {
|
|
if (isAdmin) {
|
|
document.body.classList.add('is-admin');
|
|
} else {
|
|
document.body.classList.remove('is-admin');
|
|
}
|
|
}
|
|
|
|
async function fetchWithAuth(url, options = {}) {
|
|
const token = getAuthToken();
|
|
_log.info('FETCH', `${options.method || 'GET'} ${url}`);
|
|
const t0 = Date.now();
|
|
try {
|
|
const res = await fetch(url, {
|
|
...options,
|
|
headers: { ...options.headers, 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const ms = Date.now() - t0;
|
|
if (!res.ok) {
|
|
_log.warn('FETCH', `${options.method || 'GET'} ${url} → ${res.status} (${ms}ms)`);
|
|
} else {
|
|
_log.info('FETCH', `${options.method || 'GET'} ${url} → ${res.status} OK (${ms}ms)`);
|
|
}
|
|
return res;
|
|
} catch (err) {
|
|
const ms = Date.now() - t0;
|
|
_log.error('FETCH', `${options.method || 'GET'} ${url} → FALHOU após ${ms}ms`, err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// STATE
|
|
// ================================================================
|
|
|
|
let socket = null;
|
|
let view = 'patients'; // 'patients' | 'patient-images'
|
|
let patients = [];
|
|
let patientImages = [];
|
|
let selectedPatient = null; // { patient_name, first_name, last_name, ... }
|
|
let selectedImageId = null;
|
|
let clientsList = [];
|
|
let selectedClient = '';
|
|
let showDisabled = false;
|
|
let fineTuneAngle = 0;
|
|
|
|
// ================================================================
|
|
// DOM REFS
|
|
// ================================================================
|
|
|
|
const searchInput = document.getElementById('searchInput');
|
|
const clientFilter = document.getElementById('clientFilter');
|
|
const toggleDisabledBtn = document.getElementById('toggleDisabledBtn');
|
|
const toggleText = document.getElementById('toggleText');
|
|
const imagesGrid = document.getElementById('imagesGrid');
|
|
const loadingState = document.getElementById('loadingState');
|
|
const emptyState = document.getElementById('emptyState');
|
|
const transformModal = document.getElementById('transformModal');
|
|
const backBtn = document.getElementById('backBtn');
|
|
const patientHeader = document.getElementById('patientHeader');
|
|
const patientTitle = document.getElementById('patientTitle');
|
|
const patientMeta = document.getElementById('patientMeta');
|
|
|
|
// ================================================================
|
|
// INIT
|
|
// ================================================================
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
_log.info('INIT', 'DOMContentLoaded — iniciando aplicação');
|
|
try {
|
|
const ok = await verifyAuth();
|
|
if (!ok) { _log.warn('AUTH', 'Verificação de auth falhou, redirecionando...'); return; }
|
|
_log.info('AUTH', 'Autenticação OK');
|
|
|
|
const token = getAuthToken();
|
|
if (token) {
|
|
_log.info('SOCKET', 'Conectando Socket.IO...');
|
|
socket = io({ auth: { token } });
|
|
const debouncedReload = debounce(() => {
|
|
if (view === 'patients') loadPatients();
|
|
else loadPatientImages(selectedPatient.patient_name);
|
|
}, 1200);
|
|
socket.on('new-image', () => {
|
|
_log.info('SOCKET', 'Nova imagem recebida via socket');
|
|
showToast('Nova imagem recebida!', 'info');
|
|
debouncedReload();
|
|
});
|
|
socket.on('clients-list-updated', () => loadClientsList());
|
|
socket.on('connect', () => _log.info('SOCKET', '✅ Socket.IO conectado, ID:', socket.id));
|
|
socket.on('disconnect', (r) => _log.warn('SOCKET', '❌ Socket.IO desconectado. Motivo:', r));
|
|
socket.on('connect_error', (e) => _log.error('SOCKET', 'Erro de conexão:', e.message));
|
|
}
|
|
|
|
await loadClientsList();
|
|
loadPatients();
|
|
|
|
// Registra busca, filtro de cliente, toggle "Ocultas", botão voltar e
|
|
// fechamento do modal de transformação. (Antes nunca era chamado, então
|
|
// a busca e os filtros do cabeçalho não funcionavam.)
|
|
setupEventListeners();
|
|
|
|
// Add global modal close listener
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('modal-close') || e.target.classList.contains('close-modal')) {
|
|
const modal = e.target.closest('.modal');
|
|
if (modal) modal.classList.remove('active');
|
|
} else if (e.target.classList.contains('modal')) {
|
|
e.target.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
// Add hash routing listener
|
|
window.addEventListener('hashchange', handleHashRoute);
|
|
// Call once to process initial hash
|
|
setTimeout(handleHashRoute, 200);
|
|
|
|
// Add scroll listener for infinite scroll
|
|
const contentScroll = document.querySelector('.content-scroll');
|
|
if (contentScroll) {
|
|
contentScroll.addEventListener('scroll', () => {
|
|
if (view === 'patients' && !isFetchingPatients && hasMorePatients) {
|
|
// Trigger load more when 300px from the bottom
|
|
if (contentScroll.scrollTop + contentScroll.clientHeight >= contentScroll.scrollHeight - 300) {
|
|
loadPatients(true, true);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
// Atualizações automáticas removidas para economizar banda (usando apenas eventos socket)
|
|
// setInterval(loadClientsList, 5000);
|
|
|
|
// Wasabi status update removed (was: setInterval(checkWasabiStatus, 15000))
|
|
|
|
initDragAndDrop();
|
|
|
|
checkWasabiStatus();
|
|
|
|
// Process Querystring actions (from navigation links in other pages)
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const action = urlParams.get('action');
|
|
if (action) {
|
|
setTimeout(() => {
|
|
if (action === 'new-patient') window.openCreatePatientModal();
|
|
else if (action === 'settings') window.openSettingsModal();
|
|
else if (action === 'plugins') window.openPluginsModal();
|
|
else if (action === 'sync-devices') window.openSyncModal('devices');
|
|
else if (action === 'sync-upload') window.openSyncModal('upload');
|
|
else if (action === 'sync') window.openSyncModal('devices');
|
|
else if (action === 'users') window.openUserManagementModal();
|
|
|
|
// Remove querystring to prevent reopening on reload
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|
}, 300);
|
|
}
|
|
} catch (error) {
|
|
_log.error('INIT', 'Erro fatal na inicialização:', error.message, error);
|
|
showErrorBanner('Erro na inicialização', error.message);
|
|
localStorage.removeItem('auth_token');
|
|
window.location.replace('/login');
|
|
}
|
|
});
|
|
|
|
// ================================================================
|
|
// EVENT LISTENERS
|
|
// ================================================================
|
|
|
|
function setupEventListeners() {
|
|
searchInput.addEventListener('input', debounce(() => {
|
|
if (view === 'patients') loadPatients();
|
|
else loadPatientImages(selectedPatient.patient_name);
|
|
}, 400));
|
|
|
|
clientFilter.addEventListener('change', e => {
|
|
selectedClient = e.target.value;
|
|
if (view === 'patients') loadPatients();
|
|
});
|
|
|
|
toggleDisabledBtn.addEventListener('click', () => {
|
|
showDisabled = !showDisabled;
|
|
toggleText.textContent = showDisabled ? 'Ver Ativas' : 'Ver Ocultas';
|
|
if (view === 'patients') loadPatients();
|
|
else loadPatientImages(selectedPatient.patient_name);
|
|
});
|
|
|
|
backBtn.addEventListener('click', () => { window.location.hash = '#/'; });
|
|
|
|
// Transform modal close on backdrop
|
|
transformModal.addEventListener('click', e => {
|
|
if (e.target === transformModal) transformModal.classList.remove('active');
|
|
});
|
|
document.getElementById('closeTransformModal').addEventListener('click', () => {
|
|
transformModal.classList.remove('active');
|
|
});
|
|
}
|
|
|
|
// ================================================================
|
|
// CLIENTS LIST
|
|
// ================================================================
|
|
|
|
async function loadClientsList() {
|
|
try {
|
|
let identified = [];
|
|
const isAdmin = localStorage.getItem('is_admin') === 'true';
|
|
if (isAdmin) {
|
|
const r = await fetchWithAuth('/api/socket/status');
|
|
if (r.ok) {
|
|
const data = await r.json();
|
|
identified = data.identified || [];
|
|
}
|
|
}
|
|
clientsList = identified.map(c => ({
|
|
...c, name: c.name || 'Unknown', displayName: c.name || 'Unknown',
|
|
dbName: c.name || 'Unknown', status: 'identified'
|
|
}));
|
|
|
|
const imagesRes = await fetchWithAuth('/api/images/unique-clients');
|
|
if (imagesRes.ok) {
|
|
const uniqueClients = await imagesRes.json();
|
|
uniqueClients.forEach(cn => {
|
|
if (!clientsList.find(c => c.dbName === cn || c.name === cn)) {
|
|
clientsList.push({ name: cn, displayName: cn, dbName: cn, type: 'historic', socketId: null, status: 'historic' });
|
|
}
|
|
});
|
|
}
|
|
updateClientsDropdown();
|
|
} catch (e) { console.error('Erro ao carregar clientes:', e); }
|
|
}
|
|
|
|
function updateClientsDropdown() {
|
|
const current = clientFilter.value;
|
|
clientFilter.innerHTML = '<option value="">📋 Todos os Clientes</option>';
|
|
const sorted = [...clientsList].sort((a, b) => {
|
|
const o = { identified: 1, connected: 2, historic: 3 };
|
|
return (o[a.status] || 99) - (o[b.status] || 99);
|
|
});
|
|
sorted.forEach(c => {
|
|
const opt = document.createElement('option');
|
|
opt.value = c.name;
|
|
let lbl = c.name;
|
|
if (c.name === 'Upload Web') {
|
|
lbl = 'Upload Web 💻 (Imagens do Painel)';
|
|
} else {
|
|
if (c.status === 'identified') lbl += ' ✅';
|
|
else if (c.status === 'historic') lbl += ' 📚';
|
|
}
|
|
opt.textContent = lbl;
|
|
clientFilter.appendChild(opt);
|
|
});
|
|
clientFilter.value = current || selectedClient;
|
|
}
|
|
|
|
// ================================================================
|
|
// VIEW: PATIENTS LIST
|
|
// ================================================================
|
|
|
|
let currentPatientsPage = 1;
|
|
let isFetchingPatients = false;
|
|
let hasMorePatients = true;
|
|
|
|
function showPatientsView(pushHash = true) {
|
|
view = 'patients';
|
|
selectedPatient = null;
|
|
backBtn.style.display = 'none';
|
|
patientHeader.style.display = 'none';
|
|
|
|
if (pushHash && window.location.hash && window.location.hash !== '#/') {
|
|
window.history.pushState(null, '', '#/');
|
|
}
|
|
|
|
loadPatients();
|
|
}
|
|
|
|
async function loadPatients(silent = false, loadMore = false) {
|
|
if (isFetchingPatients) return;
|
|
if (loadMore && !hasMorePatients) return;
|
|
|
|
_log.group('LOAD_PATIENTS');
|
|
_log.info('LOAD_PATIENTS', `Iniciando carga. silent=${silent}, showDisabled=${showDisabled}, client="${selectedClient}", loadMore=${loadMore}`);
|
|
|
|
isFetchingPatients = true;
|
|
|
|
if (!loadMore) {
|
|
currentPatientsPage = 1;
|
|
hasMorePatients = true;
|
|
if (!silent) {
|
|
loadingState.style.display = 'block';
|
|
imagesGrid.style.display = 'none';
|
|
emptyState.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
try {
|
|
const search = searchInput.value.trim();
|
|
const clientParam = selectedClient ? `&client=${encodeURIComponent(selectedClient)}` : '';
|
|
const url = `/api/images/patients?disabled=${showDisabled}&search=${encodeURIComponent(search)}${clientParam}&page=${currentPatientsPage}&limit=50`;
|
|
|
|
const res = await fetchWithAuth(url);
|
|
|
|
if (!res.ok) {
|
|
let errBody = '';
|
|
try { errBody = await res.text(); } catch (_) {}
|
|
_log.error('LOAD_PATIENTS', `HTTP ${res.status} — body:`, errBody);
|
|
if (res.status === 401 || res.status === 403) {
|
|
_log.warn('AUTH', 'Token inválido/expirado, redirecionando para login');
|
|
localStorage.removeItem('auth_token');
|
|
window.location.href = '/login';
|
|
return;
|
|
}
|
|
throw new Error(`HTTP ${res.status}: ${errBody || 'Erro ao carregar pacientes'}`);
|
|
}
|
|
|
|
const newPatients = await res.json();
|
|
|
|
if (newPatients.length < 50) {
|
|
hasMorePatients = false;
|
|
}
|
|
|
|
if (loadMore) {
|
|
patients = patients.concat(newPatients);
|
|
} else {
|
|
patients = newPatients;
|
|
}
|
|
|
|
_log.info('LOAD_PATIENTS', `✅ ${newPatients.length} paciente(s) recebido(s), total: ${patients.length}`);
|
|
if (patients.length > 0) {
|
|
_log.info('LOAD_PATIENTS', 'Primeiro paciente:', {
|
|
name: patients[0].patient_name,
|
|
thumb: patients[0].thumb_filename,
|
|
count: patients[0].image_count
|
|
});
|
|
}
|
|
|
|
loadingState.style.display = 'none';
|
|
|
|
if (patients.length === 0) {
|
|
_log.warn('LOAD_PATIENTS', 'Nenhum paciente encontrado — exibindo empty state');
|
|
emptyState.style.display = 'block';
|
|
imagesGrid.style.display = 'none';
|
|
} else {
|
|
emptyState.style.display = 'none';
|
|
imagesGrid.style.display = 'grid';
|
|
if (loadMore) {
|
|
appendPatientCards(patients.length - newPatients.length);
|
|
} else {
|
|
renderPatientCards();
|
|
}
|
|
}
|
|
|
|
if (loadMore) {
|
|
currentPatientsPage++;
|
|
} else if (hasMorePatients) {
|
|
currentPatientsPage = 2;
|
|
}
|
|
} catch (e) {
|
|
_log.error('LOAD_PATIENTS', '❌ Exceção:', e.message, e);
|
|
if (!loadMore) {
|
|
loadingState.style.display = 'none';
|
|
emptyState.style.display = 'block';
|
|
}
|
|
showErrorBanner('Erro ao carregar pacientes', e.message);
|
|
showToast('Erro ao carregar pacientes', 'error');
|
|
} finally {
|
|
isFetchingPatients = false;
|
|
_log.end();
|
|
}
|
|
}
|
|
|
|
// Template de um cartão de paciente (reutilizado no render completo e no append)
|
|
function patientCardHTML(p, index) {
|
|
const fullName = p.patient_name || 'Sem nome';
|
|
const thumb = p.thumb_filename ? `/uploads/${p.thumb_filename}` : '';
|
|
const count = p.image_count || 0;
|
|
const date = formatDate(p.last_date);
|
|
const doctor = p.doctor || '—';
|
|
const remark = p.remark || '—';
|
|
|
|
// <img loading="lazy"> permite ao browser adiar miniaturas fora da viewport;
|
|
// sem thumb, mantém o gradiente de fallback.
|
|
const preview = thumb
|
|
? `<img class="preview-img" src="${thumb}" alt="" loading="lazy" decoding="async">`
|
|
: '';
|
|
const previewStyle = thumb ? '' : ' style="background: linear-gradient(135deg,#667eea22,#764ba222);"';
|
|
|
|
return `
|
|
<div class="image-card patient-card" onclick="openPatientByIndex(${index})">
|
|
<div class="image-preview"${previewStyle}>
|
|
${preview}
|
|
<button type="button" class="delete-patient-btn" onclick="event.stopPropagation(); deletePatientImages('${escapeHtml(fullName)}')" title="Excluir todas as imagens de ${escapeHtml(fullName)}">🗑️</button>
|
|
<div class="patient-count-badge">${count} imagem${count !== 1 ? 'ns' : ''}</div>
|
|
</div>
|
|
<div class="image-info">
|
|
<div class="image-patient-name" title="${escapeHtml(fullName)}">${escapeHtml(fullName)}</div>
|
|
<div class="image-meta"><span>📅 ${date}</span></div>
|
|
<div class="image-doctor-remark">
|
|
<div title="${escapeHtml(doctor)}">🩺 <b>Dentista:</b> ${escapeHtml(doctor)}</div>
|
|
<div title="${escapeHtml(remark)}">📝 <b>Obs:</b> ${escapeHtml(remark)}</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Render completo (primeira página / refresh)
|
|
function renderPatientCards() {
|
|
imagesGrid.innerHTML = patients.map((p, index) => patientCardHTML(p, index)).join('');
|
|
}
|
|
|
|
// Scroll infinito: anexa apenas os cartões novos, sem repintar os já visíveis
|
|
function appendPatientCards(startIndex) {
|
|
const html = patients
|
|
.slice(startIndex)
|
|
.map((p, i) => patientCardHTML(p, startIndex + i))
|
|
.join('');
|
|
imagesGrid.insertAdjacentHTML('beforeend', html);
|
|
}
|
|
|
|
// Exclui todas as imagens de um determinado paciente (S3 e Local)
|
|
async function deletePatientImages(patientName) {
|
|
if (!patientName) return;
|
|
|
|
// TODO(security): Usando confirm() para manter coerência com o restante das confirmações do painel do projeto
|
|
const ok = confirm(`⚠️ ATENÇÃO: Tem certeza que deseja excluir permanentemente todas as imagens do paciente "${patientName}"?\n\nEsta ação apagará os arquivos locais, do Wasabi S3 e do banco de dados, e NÃO poderá ser desfeita.`);
|
|
if (!ok) return;
|
|
|
|
showToast('Excluindo imagens do paciente...', 'info');
|
|
|
|
try {
|
|
const res = await fetchWithAuth(`/api/images/by-patient?name=${encodeURIComponent(patientName)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || 'Erro ao excluir imagens do paciente.');
|
|
}
|
|
|
|
showToast('✅ Todas as imagens do paciente foram excluídas!', 'success');
|
|
|
|
// Atualizar listagens
|
|
await loadPatients();
|
|
await loadClientsList();
|
|
|
|
} catch (e) {
|
|
console.error('Erro na exclusão do paciente:', e);
|
|
showToast(`Falha ao excluir imagens: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// VIEW: PATIENT IMAGES
|
|
// ================================================================
|
|
|
|
function openPatient(patient, pushHash = true) {
|
|
selectedPatient = patient;
|
|
view = 'patient-images';
|
|
backBtn.style.display = 'inline-flex';
|
|
patientHeader.style.display = 'block';
|
|
|
|
const fullName = patient.patient_name || 'Sem nome';
|
|
patientTitle.textContent = fullName;
|
|
patientMeta.textContent = `${patient.image_count || 0} imagem(ns) · ${patient.doctor || '—'}`;
|
|
|
|
if (pushHash) {
|
|
const newHash = `#/patient/${encodeURIComponent(patient.patient_name)}`;
|
|
if (window.location.hash !== newHash) {
|
|
window.history.pushState(null, '', newHash);
|
|
}
|
|
}
|
|
|
|
loadPatientImages(patient.patient_name);
|
|
}
|
|
|
|
function handleHashRoute() {
|
|
const hash = decodeURIComponent(window.location.hash);
|
|
if (hash.startsWith('#/patient/')) {
|
|
const pName = hash.split('#/patient/')[1];
|
|
if (pName && (!selectedPatient || selectedPatient.patient_name !== pName)) {
|
|
openPatient({ patient_name: pName, image_count: '?', doctor: 'Carregando...' }, false);
|
|
}
|
|
} else if (hash === '#/' || hash === '') {
|
|
if (view !== 'patients') {
|
|
showPatientsView(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadPatientImages(patientName) {
|
|
_log.group('LOAD_IMAGES');
|
|
_log.info('LOAD_IMAGES', `Carregando imagens de: "${patientName}"`);
|
|
|
|
loadingState.style.display = 'block';
|
|
imagesGrid.style.display = 'none';
|
|
emptyState.style.display = 'none';
|
|
|
|
try {
|
|
const url = `/api/images/by-patient?name=${encodeURIComponent(patientName)}&disabled=${showDisabled}`;
|
|
const res = await fetchWithAuth(url);
|
|
|
|
if (!res.ok) {
|
|
let errBody = '';
|
|
try { errBody = await res.text(); } catch (_) {}
|
|
_log.error('LOAD_IMAGES', `HTTP ${res.status}:`, errBody);
|
|
throw new Error(`HTTP ${res.status}: ${errBody}`);
|
|
}
|
|
|
|
patientImages = await res.json();
|
|
_log.info('LOAD_IMAGES', `✅ ${patientImages.length} imagem(ns) recebida(s)`);
|
|
loadingState.style.display = 'none';
|
|
|
|
if (patientImages.length === 0) {
|
|
_log.warn('LOAD_IMAGES', 'Nenhuma imagem encontrada');
|
|
emptyState.style.display = 'block';
|
|
imagesGrid.style.display = 'none';
|
|
} else {
|
|
emptyState.style.display = 'none';
|
|
imagesGrid.style.display = 'grid';
|
|
renderPatientImages();
|
|
}
|
|
} catch (e) {
|
|
_log.error('LOAD_IMAGES', '❌ Exceção:', e.message, e);
|
|
loadingState.style.display = 'none';
|
|
emptyState.style.display = 'block';
|
|
showErrorBanner('Erro ao carregar imagens', e.message);
|
|
showToast('Erro ao carregar imagens', 'error');
|
|
} finally {
|
|
_log.end();
|
|
}
|
|
}
|
|
|
|
function renderPatientImages() {
|
|
imagesGrid.innerHTML = patientImages.map(image => {
|
|
const thumbUrl = image.thumb_filename ? `/uploads/${image.thumb_filename}` : `/uploads/${image.filename}`;
|
|
return `
|
|
<div class="image-card ${!image.enabled ? 'disabled' : ''}" data-id="${image.id}" onclick="handleImageClick(${image.id})">
|
|
<div class="image-preview"><img class="preview-img" src="${thumbUrl}" alt="" loading="lazy" decoding="async"></div>
|
|
<div class="image-info">
|
|
<div class="image-meta"><span>📅 ${formatDate(image.created_at)}</span></div>
|
|
<div class="image-guid">${escapeHtml(image.image_guid || image.filename)}</div>
|
|
<div class="image-actions">
|
|
<button class="btn btn-primary btn-small" style="flex:1" onclick="handleImageClick(${image.id}); event.stopPropagation();">
|
|
🔄 Orientar
|
|
</button>
|
|
<button class="btn btn-small" style="flex:1; background:${image.enabled ? 'rgba(255,107,107,0.12)' : 'rgba(81,207,102,0.12)'}; color:${image.enabled ? '#c0392b' : '#27ae60'}; border:1px solid ${image.enabled ? 'rgba(255,107,107,0.3)' : 'rgba(81,207,102,0.3)'};" onclick="toggleImageEnabled(${image.id}); event.stopPropagation();">
|
|
${image.enabled ? '🚫' : '✅'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
${!image.enabled ? '<div class="image-badge">Desabilitada</div>' : ''}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
// ================================================================
|
|
// IMAGE CLICK → TRANSFORM MODAL
|
|
// ================================================================
|
|
|
|
async function handleImageClick(imageId) {
|
|
selectedImageId = imageId;
|
|
await showTransformModal(imageId);
|
|
}
|
|
|
|
async function showTransformModal(imageId) {
|
|
try {
|
|
transformModal.classList.add('active');
|
|
|
|
// Default view is 'transform'
|
|
toggleModalView('transform');
|
|
|
|
// Find image to show details
|
|
const image = patientImages.find(img => img.id === imageId);
|
|
|
|
if (!image) {
|
|
throw new Error('Imagem não encontrada na lista carregada.');
|
|
}
|
|
|
|
const imageUrl = image.filename.startsWith('http') ? image.filename : `/uploads/${image.filename}`;
|
|
|
|
// Atribui a imagem às tags do DOM para exibição no painel
|
|
document.getElementById('originalImgPreview').src = imageUrl;
|
|
document.getElementById('transformedImgPreview').src = imageUrl;
|
|
|
|
// Check if the image has been modified (has original_image_id)
|
|
const btnResetImage = document.getElementById('btnResetImage');
|
|
if (image.original_image_id) {
|
|
btnResetImage.style.display = 'flex';
|
|
btnResetImage.setAttribute('data-modified', 'true');
|
|
} else {
|
|
btnResetImage.style.display = 'none';
|
|
btnResetImage.setAttribute('data-modified', 'false');
|
|
}
|
|
|
|
document.getElementById('transformModalTitle').innerText = `${escapeHtml(image.patient_name || 'Desconhecido')} - ${formatDate(image.created_at)}`;
|
|
|
|
document.getElementById('transformExtraInfo').innerHTML = `
|
|
<div style="margin-bottom: 5px;"><strong>👨⚕️ Dentista:</strong> ${escapeHtml(image.doctor || 'Não informado')}</div>
|
|
<div><strong>📝 Obs:</strong> ${escapeHtml(image.remark || 'Nenhuma observação')}</div>
|
|
`;
|
|
document.getElementById('transformExtraInfo').style.display = 'none'; // Keep hidden by default
|
|
|
|
loadGtosForPatient(image.patient_name);
|
|
|
|
// Reseta o ângulo sempre que abrir a imagem
|
|
fineTuneAngle = 0;
|
|
updateFineTuneDisplay();
|
|
|
|
// Exibe área de ação inferior (onde ficam os botões de salvar e observação)
|
|
document.getElementById('transformActionArea').style.display = 'block';
|
|
|
|
} catch (e) {
|
|
_log.error('TRANSFORM', '❌ Exceção:', e.message, e);
|
|
showToast('Erro ao carregar variações', 'error');
|
|
transformModal.classList.remove('active');
|
|
}
|
|
}
|
|
// As transformações base foram removidas. Usamos apenas o ajuste fino.
|
|
|
|
function applyFineTuneAngle(angleDelta) {
|
|
if (angleDelta === 0) {
|
|
fineTuneAngle = 0;
|
|
} else {
|
|
fineTuneAngle += angleDelta;
|
|
}
|
|
updateFineTuneDisplay();
|
|
|
|
// Apply to preview image
|
|
const previewImg = document.getElementById('transformedImgPreview');
|
|
|
|
// Agora temos apenas o fineTuneAngle, sem outras transformações base (flip)
|
|
previewImg.style.transform = `rotate(${fineTuneAngle}deg)`;
|
|
}
|
|
|
|
function clearTransformSelection() {
|
|
resetFineTune();
|
|
document.getElementById('newImageRemark').value = '';
|
|
}
|
|
|
|
async function saveSelectedTransform() {
|
|
if (fineTuneAngle === 0) {
|
|
showToast('Nenhuma edição aplicada.', 'error');
|
|
return;
|
|
}
|
|
const remark = document.getElementById('newImageRemark').value.trim();
|
|
|
|
const btn = document.getElementById('btnSaveTransform');
|
|
const originalText = btn.innerText;
|
|
btn.innerText = 'Salvando...';
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
const payload = {
|
|
rotation: fineTuneAngle,
|
|
flipH: false,
|
|
flipV: false,
|
|
remark: remark
|
|
};
|
|
|
|
const res = await fetchWithAuth(`/api/images/${selectedImageId}/transform`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!res.ok) throw new Error('Erro ao salvar');
|
|
|
|
transformModal.classList.remove('active');
|
|
showToast('Orientação salva!', 'success');
|
|
if (selectedPatient) loadPatientImages(selectedPatient.patient_name);
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast('Erro ao salvar orientação', 'error');
|
|
}
|
|
}
|
|
|
|
function adjustFineTune(offset) {
|
|
fineTuneAngle += offset;
|
|
updateFineTuneDisplay();
|
|
|
|
// Update the image style
|
|
const previewImg = document.getElementById('transformedImgPreview');
|
|
previewImg.style.transform = `rotate(${fineTuneAngle}deg)`;
|
|
}
|
|
|
|
function resetFineTune() {
|
|
fineTuneAngle = 0;
|
|
updateFineTuneDisplay();
|
|
document.getElementById('transformedImgPreview').style.transform = `rotate(0deg)`;
|
|
}
|
|
|
|
function updateFineTuneDisplay() {
|
|
const displayEl = document.getElementById('fineTuneValDisplay');
|
|
if (displayEl) {
|
|
displayEl.textContent = `${fineTuneAngle > 0 ? '+' : ''}${fineTuneAngle}°`;
|
|
}
|
|
|
|
const resetEl = document.getElementById('btnResetFineTune');
|
|
if (resetEl) {
|
|
resetEl.style.display = fineTuneAngle !== 0 ? 'inline-block' : 'none';
|
|
}
|
|
}
|
|
|
|
async function toggleImageEnabled(imageId) {
|
|
try {
|
|
const res = await fetchWithAuth(`/api/images/${imageId}/toggle-enabled`, { method: 'PUT' });
|
|
if (!res.ok) throw new Error();
|
|
const result = await res.json();
|
|
showToast(result.enabled ? 'Imagem habilitada!' : 'Imagem desabilitada!', 'success');
|
|
if (selectedPatient) loadPatientImages(selectedPatient.patient_name);
|
|
} catch {
|
|
showToast('Erro ao alterar status', 'error');
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// UTILITY FUNCTIONS
|
|
// ================================================================
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const d = document.createElement('div');
|
|
d.textContent = text;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function formatDate(dateString) {
|
|
if (!dateString) return '—';
|
|
const date = new Date(dateString);
|
|
return date.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function (...args) {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => func(...args), wait);
|
|
};
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.className = `toast show ${type}`;
|
|
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
}
|
|
|
|
function openPatientByIndex(index) {
|
|
if (patients[index]) {
|
|
openPatient(patients[index]);
|
|
}
|
|
}
|
|
|
|
window.openPatient = openPatient;
|
|
window.openPatientByIndex = openPatientByIndex;
|
|
window.handleImageClick = handleImageClick;
|
|
window.toggleImageEnabled = toggleImageEnabled;
|
|
|
|
// ================================================================
|
|
// PATIENT CREATION (DENTAL SENSOR)
|
|
// ================================================================
|
|
|
|
function openCreatePatientModal() {
|
|
document.getElementById('create-patient-modal').classList.add('active');
|
|
}
|
|
|
|
function closeCreatePatientModal() {
|
|
document.getElementById('create-patient-modal').classList.remove('active');
|
|
document.getElementById('create-patient-form').reset();
|
|
}
|
|
|
|
async function submitCreatePatient(event) {
|
|
event.preventDefault();
|
|
|
|
const submitBtn = event.target.querySelector('button[type="submit"]');
|
|
const originalText = submitBtn.textContent;
|
|
submitBtn.textContent = 'Salvando...';
|
|
submitBtn.disabled = true;
|
|
|
|
const payload = {
|
|
firstName: document.getElementById('patient-first-name').value.trim(),
|
|
lastName: document.getElementById('patient-last-name').value.trim(),
|
|
doctor: document.getElementById('patient-doctor').value.trim(),
|
|
birthday: document.getElementById('patient-birthday').value || null,
|
|
gender: document.getElementById('patient-gender').value,
|
|
phone: document.getElementById('patient-phone').value.trim(),
|
|
remark: document.getElementById('patient-remark').value.trim()
|
|
};
|
|
|
|
try {
|
|
const response = await fetchWithAuth(`${API_URL}/patients`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast('Paciente criado com sucesso no Dental Sensor!', 'success');
|
|
closeCreatePatientModal();
|
|
} else {
|
|
const err = await response.json();
|
|
alert('Erro ao criar paciente: ' + (err.error || 'Desconhecido'));
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert('Erro ao conectar com o servidor para criar paciente.');
|
|
} finally {
|
|
submitBtn.textContent = originalText;
|
|
submitBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// GTO LOGIC
|
|
// ================================================================
|
|
|
|
function toggleModalView(view) {
|
|
const transformViewWrapper = document.getElementById('transformViewWrapper');
|
|
const gtoView = document.getElementById('gtoView');
|
|
const btnShowTransform = document.getElementById('btnShowTransform');
|
|
const btnShowGto = document.getElementById('btnShowGto');
|
|
const btnResetImage = document.getElementById('btnResetImage');
|
|
|
|
if (view === 'transform') {
|
|
transformViewWrapper.style.display = 'flex';
|
|
gtoView.style.display = 'none';
|
|
btnShowTransform.className = 'btn btn-primary';
|
|
btnShowGto.className = 'btn btn-secondary';
|
|
|
|
// Restore reset button visibility if image is modified
|
|
if (btnResetImage.getAttribute('data-modified') === 'true') {
|
|
btnResetImage.style.display = 'flex';
|
|
}
|
|
} else {
|
|
transformViewWrapper.style.display = 'none';
|
|
gtoView.style.display = 'block';
|
|
btnShowTransform.className = 'btn btn-secondary';
|
|
btnShowGto.className = 'btn btn-primary';
|
|
|
|
// Hide reset button in GTO view
|
|
btnResetImage.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function toggleModalInfo() {
|
|
const infoDiv = document.getElementById('transformExtraInfo');
|
|
const btnShowInfo = document.getElementById('btnShowInfo');
|
|
if (infoDiv.style.display === 'none') {
|
|
infoDiv.style.display = 'block';
|
|
btnShowInfo.className = 'btn btn-primary';
|
|
} else {
|
|
infoDiv.style.display = 'none';
|
|
btnShowInfo.className = 'btn btn-secondary';
|
|
}
|
|
}
|
|
|
|
async function loadGtosForPatient(patientName) {
|
|
const container = document.getElementById('gtoListContainer');
|
|
container.innerHTML = '<div style="padding:20px; text-align:center; color:#888;">Carregando GTOs...</div>';
|
|
|
|
try {
|
|
const res = await fetchWithAuth(`/api/gtos?patient=${encodeURIComponent(patientName)}`);
|
|
if (!res.ok) throw new Error('Erro ao carregar GTOs');
|
|
const gtos = await res.json();
|
|
|
|
if (gtos.length === 0) {
|
|
container.innerHTML = '<div style="padding:20px; text-align:center; color:#888;">Nenhuma GTO cadastrada para este paciente.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '<ul style="list-style:none; padding:0; margin:0;">';
|
|
gtos.forEach((gto, idx) => {
|
|
const bg = idx % 2 === 0 ? '#fff' : '#f9f9f9';
|
|
html += `
|
|
<li style="display:flex; justify-content:space-between; align-items:center; padding:15px; background:${bg}; border-bottom:1px solid #eee;">
|
|
<div>
|
|
<div style="font-weight:600; font-size:1.05rem;">${escapeHtml(gto.gto_number)}</div>
|
|
<div style="color:#666; font-size:0.9rem;">${escapeHtml(gto.description || 'Sem descrição')}</div>
|
|
<div style="color:#999; font-size:0.8rem; margin-top:4px;">📅 ${formatDate(gto.created_at)}</div>
|
|
</div>
|
|
<div>
|
|
<button class="btn btn-primary btn-small" onclick="linkImageToGto(${gto.id}, this)">✅ Vincular</button>
|
|
</div>
|
|
</li>
|
|
`;
|
|
});
|
|
html += '</ul>';
|
|
container.innerHTML = html;
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
container.innerHTML = '<div style="padding:20px; text-align:center; color:red;">Erro ao carregar GTOs</div>';
|
|
}
|
|
}
|
|
|
|
async function inlineCreateGto() {
|
|
const numberInput = document.getElementById('inlineGtoNumber');
|
|
const descInput = document.getElementById('inlineGtoDesc');
|
|
|
|
const number = numberInput.value.trim();
|
|
if (!number) {
|
|
showToast('O Número da GTO é obrigatório.', 'error');
|
|
return;
|
|
}
|
|
|
|
const image = patientImages.find(img => img.id === selectedImageId);
|
|
if (!image || !image.patient_name) {
|
|
showToast('Erro: Paciente não identificado.', 'error');
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
gto_number: number,
|
|
description: descInput.value.trim(),
|
|
patient_name: image.patient_name
|
|
};
|
|
|
|
try {
|
|
const res = await fetchWithAuth('/api/gtos', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Erro ao criar GTO');
|
|
|
|
showToast('GTO criada com sucesso!', 'success');
|
|
|
|
// Limpar inputs
|
|
numberInput.value = '';
|
|
descInput.value = '';
|
|
|
|
// Recarregar lista
|
|
await loadGtosForPatient(image.patient_name);
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast('Erro ao criar GTO.', 'error');
|
|
}
|
|
}
|
|
|
|
async function linkImageToGto(gtoId, btnElement) {
|
|
if (!gtoId) return;
|
|
|
|
const originalText = btnElement.innerText;
|
|
btnElement.innerText = 'Vinculando...';
|
|
btnElement.disabled = true;
|
|
|
|
try {
|
|
const res = await fetchWithAuth(`/api/gtos/${gtoId}/images`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ image_id: selectedImageId })
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || 'Erro ao vincular');
|
|
}
|
|
|
|
showToast('Imagem vinculada à GTO!', 'success');
|
|
btnElement.innerText = 'Vinculada!';
|
|
btnElement.classList.replace('btn-primary', 'btn-success');
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast(e.message, 'error');
|
|
btnElement.innerText = originalText;
|
|
btnElement.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function resetImageToOriginal() {
|
|
if (!confirm('Tem certeza que deseja restaurar esta imagem para o estado original em que foi capturada pelo sensor? Rotações, espelhamentos e observações serão descartados.')) {
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('btnResetImage');
|
|
const originalContent = btn.innerHTML;
|
|
btn.innerHTML = '<div class="spinner" style="width: 16px; height: 16px; border-width: 2px; border-top-color: white;"></div>';
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
const payload = {
|
|
rotation: 0,
|
|
flipH: false,
|
|
flipV: false,
|
|
remark: ''
|
|
};
|
|
|
|
const res = await fetchWithAuth(`/api/images/${selectedImageId}/transform`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Erro ao resetar imagem');
|
|
|
|
showToast('Imagem restaurada para o original!', 'success');
|
|
|
|
transformModal.classList.remove('active');
|
|
clearTransformSelection();
|
|
|
|
const patientName = patientImages.find(img => img.id === selectedImageId)?.patient_name;
|
|
await loadPatients();
|
|
if (patientName) {
|
|
await loadPatientImages(patientName);
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast('Erro ao restaurar a imagem.', 'error');
|
|
} finally {
|
|
btn.innerHTML = originalContent;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Global scope exposures
|
|
window.handleImageClick = handleImageClick;
|
|
window.toggleImageEnabled = toggleImageEnabled;
|
|
window.openCreatePatientModal = openCreatePatientModal;
|
|
window.closeCreatePatientModal = closeCreatePatientModal;
|
|
window.submitCreatePatient = submitCreatePatient;
|
|
window.toggleModalView = toggleModalView;
|
|
window.toggleModalInfo = toggleModalInfo;
|
|
window.inlineCreateGto = inlineCreateGto;
|
|
window.saveSelectedTransform = saveSelectedTransform;
|
|
window.clearTransformSelection = clearTransformSelection;
|
|
window.resetImageToOriginal = resetImageToOriginal;
|
|
|
|
// ================================================================
|
|
// CONFIGURAÇÕES (Trocar Usuário/Senha)
|
|
// ================================================================
|
|
|
|
function openSettingsModal() {
|
|
const modal = document.getElementById('settingsModal');
|
|
if (modal) modal.classList.add('active');
|
|
|
|
// Limpar form e carregar valores atuais
|
|
document.getElementById('currentPassword').value = '';
|
|
document.getElementById('newUsername').value = localStorage.getItem('username') || '';
|
|
document.getElementById('newEmail').value = localStorage.getItem('email') || '';
|
|
document.getElementById('newPassword').value = '';
|
|
}
|
|
|
|
function closeSettingsModal() {
|
|
const modal = document.getElementById('settingsModal');
|
|
if (modal) modal.classList.remove('active');
|
|
}
|
|
|
|
async function updateCredentials(event) {
|
|
event.preventDefault();
|
|
|
|
const currentPassword = document.getElementById('currentPassword').value;
|
|
const newUsername = document.getElementById('newUsername').value.trim();
|
|
const newEmail = document.getElementById('newEmail').value.trim();
|
|
const newPassword = document.getElementById('newPassword').value;
|
|
|
|
if (!currentPassword) {
|
|
showToast('A senha atual é obrigatória.', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!newUsername && !newEmail && !newPassword) {
|
|
showToast('Preencha um novo usuário, e-mail ou nova senha.', 'error');
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('btnSaveSettings');
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = '<span class="spinner" style="width:14px;height:14px;display:inline-block;"></span> Salvando...';
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
const token = getAuthToken();
|
|
const res = await fetch(`${API_URL}/auth/update-credentials`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ currentPassword, newUsername, newEmail, newPassword })
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (!res.ok) {
|
|
throw new Error(data.error || 'Erro ao atualizar credenciais.');
|
|
}
|
|
|
|
// Sucesso
|
|
showToast('Credenciais atualizadas com sucesso!', 'success');
|
|
|
|
// Atualizar token no localStorage se tiver um novo
|
|
if (data.token) {
|
|
localStorage.setItem('auth_token', data.token);
|
|
// Salvar novos dados locais decodificando token ou usando retorno
|
|
if (data.user) {
|
|
localStorage.setItem('username', data.user.username || '');
|
|
localStorage.setItem('email', data.user.email || '');
|
|
}
|
|
}
|
|
|
|
closeSettingsModal();
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Global scope
|
|
window.openSettingsModal = openSettingsModal;
|
|
window.closeSettingsModal = closeSettingsModal;
|
|
window.updateCredentials = updateCredentials;
|
|
|
|
// ================================================================
|
|
// GERENCIAMENTO DE USUÁRIOS (ADMIN ONLY)
|
|
// ================================================================
|
|
|
|
function openUserManagementModal() {
|
|
const modal = document.getElementById('userManagementModal');
|
|
if (modal) modal.classList.add('active');
|
|
|
|
// Limpar formulário de criação
|
|
document.getElementById('new_username').value = '';
|
|
document.getElementById('new_email').value = '';
|
|
document.getElementById('new_password').value = '';
|
|
const isCheck = document.getElementById('new_is_admin');
|
|
if (isCheck) isCheck.checked = false;
|
|
|
|
loadUsersList();
|
|
}
|
|
|
|
function closeUserManagementModal() {
|
|
const modal = document.getElementById('userManagementModal');
|
|
if (modal) modal.classList.remove('active');
|
|
}
|
|
|
|
async function loadUsersList() {
|
|
const listBody = document.getElementById('usersListBody');
|
|
if (!listBody) return;
|
|
|
|
listBody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:15px;color:#a0aec0;"><span class="spinner" style="width:14px;height:14px;display:inline-block;"></span> Carregando...</td></tr>';
|
|
|
|
try {
|
|
const token = getAuthToken();
|
|
const res = await fetch(`${API_URL}/auth/users`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (!res.ok) throw new Error(data.error || 'Erro ao carregar usuários.');
|
|
|
|
if (data.success && data.users) {
|
|
listBody.innerHTML = '';
|
|
|
|
// Decodifica o token atual para não permitir excluir a si mesmo
|
|
let currentUserId = null;
|
|
try {
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
currentUserId = payload.id;
|
|
} catch (e) {}
|
|
|
|
data.users.forEach(u => {
|
|
const role = u.is_admin ? '👑 Administrador' : '🩺 Dentista / Comum';
|
|
const tr = document.createElement('tr');
|
|
tr.style.borderBottom = '1px solid var(--border-color)';
|
|
|
|
const isSelf = currentUserId && parseInt(u.id) === parseInt(currentUserId);
|
|
const deleteBtnHtml = isSelf
|
|
? '<span style="color:#a0aec0;font-size:0.85rem;font-style:italic;">Você</span>'
|
|
: `<button class="btn btn-danger" onclick="deleteUser(${u.id})" style="padding:4px 8px;font-size:0.8rem;border-radius:4px;cursor:pointer;background:#fee2e2;color:#dc2626;border:1px solid #fecaca;">Excluir</button>`;
|
|
|
|
tr.innerHTML = `
|
|
<td style="padding: 12px 16px; font-weight:600;">${u.username}</td>
|
|
<td style="padding: 12px 16px; color:var(--text-secondary);">${u.email}</td>
|
|
<td style="padding: 12px 16px; color:var(--text-secondary);">${role}</td>
|
|
<td style="padding: 12px 16px; text-align:center;">${deleteBtnHtml}</td>
|
|
`;
|
|
listBody.appendChild(tr);
|
|
});
|
|
} else {
|
|
listBody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:red;padding:15px;">Falha ao carregar dados.</td></tr>';
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
listBody.innerHTML = `<tr><td colspan="4" style="text-align:center;color:red;padding:15px;">${e.message || 'Erro ao carregar.'}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
async function createUser(event) {
|
|
event.preventDefault();
|
|
|
|
const username = document.getElementById('new_username').value.trim();
|
|
const email = document.getElementById('new_email').value.trim();
|
|
const password = document.getElementById('new_password').value;
|
|
const isCheck = document.getElementById('new_is_admin');
|
|
const is_admin = isCheck ? isCheck.checked : false;
|
|
|
|
if (!username || !email || !password) {
|
|
showToast('Todos os campos obrigatórios.', 'error');
|
|
return;
|
|
}
|
|
|
|
const submitBtn = event.target.querySelector('button[type="submit"]');
|
|
const originalText = submitBtn.innerHTML;
|
|
submitBtn.innerHTML = '<span class="spinner" style="width:14px;height:14px;display:inline-block;"></span> Cadastrando...';
|
|
submitBtn.disabled = true;
|
|
|
|
try {
|
|
const token = getAuthToken();
|
|
const res = await fetch(`${API_URL}/auth/create-user`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ username, email, password, is_admin })
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (!res.ok) throw new Error(data.error || 'Erro ao cadastrar usuário.');
|
|
|
|
showToast('Usuário cadastrado com sucesso!', 'success');
|
|
|
|
// Limpar formulário
|
|
document.getElementById('new_username').value = '';
|
|
document.getElementById('new_email').value = '';
|
|
document.getElementById('new_password').value = '';
|
|
if (isCheck) isCheck.checked = false;
|
|
|
|
// Atualizar lista
|
|
loadUsersList();
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
submitBtn.innerHTML = originalText;
|
|
submitBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function deleteUser(userId) {
|
|
if (!confirm('Tem certeza que deseja excluir este usuário permanentemente?')) return;
|
|
|
|
try {
|
|
const token = getAuthToken();
|
|
const res = await fetch(`${API_URL}/auth/users/${userId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (!res.ok) throw new Error(data.error || 'Erro ao excluir usuário.');
|
|
|
|
showToast('Usuário excluído com sucesso!', 'success');
|
|
loadUsersList();
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
window.openUserManagementModal = openUserManagementModal;
|
|
window.closeUserManagementModal = closeUserManagementModal;
|
|
window.createUser = createUser;
|
|
window.deleteUser = deleteUser;
|
|
|
|
// ================================================================
|
|
// CONFIGURAÇÕES DE PLUGINS (Wasabi Storage)
|
|
// ================================================================
|
|
|
|
function openPluginsModal() {
|
|
const modal = document.getElementById('pluginsModal');
|
|
if (modal) modal.classList.add('active');
|
|
|
|
// Limpar campos
|
|
document.getElementById('wasabiAccessKey').value = '';
|
|
document.getElementById('wasabiSecretKey').value = '';
|
|
document.getElementById('wasabiBucket').value = '';
|
|
document.getElementById('wasabiRegion').value = '';
|
|
document.getElementById('wasabiEndpoint').value = '';
|
|
|
|
const alertBox = document.getElementById('pluginsStatusAlert');
|
|
alertBox.style.display = 'none';
|
|
|
|
// Carregar configurações atuais
|
|
loadPluginsConfig();
|
|
}
|
|
|
|
function closePluginsModal() {
|
|
const modal = document.getElementById('pluginsModal');
|
|
if (modal) modal.classList.remove('active');
|
|
}
|
|
|
|
async function loadPluginsConfig() {
|
|
try {
|
|
const res = await fetchWithAuth('/api/system/storage-config');
|
|
if (!res.ok) throw new Error('Falha ao carregar configurações');
|
|
|
|
const config = await res.json();
|
|
|
|
document.getElementById('wasabiAccessKey').value = config.wasabiAccessKey || '';
|
|
document.getElementById('wasabiSecretKey').value = config.wasabiSecretKey || '';
|
|
document.getElementById('wasabiBucket').value = config.wasabiBucket || '';
|
|
document.getElementById('wasabiRegion').value = config.wasabiRegion || '';
|
|
document.getElementById('wasabiEndpoint').value = config.wasabiEndpoint || '';
|
|
|
|
const alertBox = document.getElementById('pluginsStatusAlert');
|
|
if (config.enabled) {
|
|
alertBox.className = 'alert-success';
|
|
alertBox.style.background = '#eafaf1';
|
|
alertBox.style.color = '#27ae60';
|
|
alertBox.style.border = '1px solid #2ecc7133';
|
|
alertBox.innerHTML = '🟢 <b>Status:</b> Wasabi Storage está ATIVO e conectado.';
|
|
} else {
|
|
alertBox.className = 'alert-warning';
|
|
alertBox.style.background = '#fffbeb';
|
|
alertBox.style.color = '#d97706';
|
|
alertBox.style.border = '1px solid #f59e0b33';
|
|
alertBox.innerHTML = '🟡 <b>Status:</b> Wasabi Storage está DESATIVADO (utilizando armazenamento local).';
|
|
}
|
|
alertBox.style.display = 'block';
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast('Erro ao carregar configurações do Wasabi.', 'error');
|
|
}
|
|
}
|
|
|
|
async function checkWasabiStatus() {
|
|
try {
|
|
const res = await fetchWithAuth('/api/system/wasabi-status');
|
|
if (res.ok) {
|
|
const status = await res.json();
|
|
const banner = document.getElementById('wasabi-alert-banner');
|
|
const errorMsg = document.getElementById('wasabi-alert-error');
|
|
|
|
if (status.enabled && status.error) {
|
|
// Existe um erro crítico, exibe o banner
|
|
if (errorMsg) errorMsg.textContent = status.error;
|
|
if (banner) banner.style.display = 'flex';
|
|
} else {
|
|
// Tudo certo, esconde o banner
|
|
if (banner) banner.style.display = 'none';
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Falha ao verificar status do Wasabi:', e);
|
|
}
|
|
}
|
|
|
|
async function updatePluginsConfig(event) {
|
|
event.preventDefault();
|
|
|
|
const wasabiAccessKey = document.getElementById('wasabiAccessKey').value.trim();
|
|
const wasabiSecretKey = document.getElementById('wasabiSecretKey').value;
|
|
const wasabiBucket = document.getElementById('wasabiBucket').value.trim();
|
|
const wasabiRegion = document.getElementById('wasabiRegion').value.trim();
|
|
const wasabiEndpoint = document.getElementById('wasabiEndpoint').value.trim();
|
|
|
|
const btn = document.getElementById('btnSavePlugins');
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = '<span class="spinner" style="width:14px;height:14px;display:inline-block;"></span> Salvando...';
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
const res = await fetchWithAuth('/api/system/storage-config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
wasabiAccessKey,
|
|
wasabiSecretKey,
|
|
wasabiBucket,
|
|
wasabiRegion,
|
|
wasabiEndpoint
|
|
})
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || 'Erro ao salvar configurações.');
|
|
}
|
|
|
|
showToast('Configurações do Wasabi salvas com sucesso!', 'success');
|
|
|
|
// Recarregar o status
|
|
await loadPluginsConfig();
|
|
checkWasabiStatus(); // Atualizar o banner na mesma hora
|
|
|
|
// Fechar após 1 segundo
|
|
setTimeout(closePluginsModal, 1200);
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
// Se for um erro 400 de validação da key, usamos um Alert para chamar a atenção
|
|
if (e.message.includes('Chave') || e.message.includes('bucket') || e.message.includes('Erro 403') || e.message.includes('conexão')) {
|
|
alert("⚠️ " + e.message);
|
|
} else {
|
|
showToast(e.message, 'error');
|
|
}
|
|
} finally {
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function triggerSync() {
|
|
showToast('🔄 Enviando sinal de sincronização para dispositivos...', 'info');
|
|
try {
|
|
const res = await fetchWithAuth('/api/system/trigger-sync', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || 'Erro ao acionar sincronização.');
|
|
}
|
|
|
|
const data = await res.json();
|
|
if (data.devicesTriggered > 0) {
|
|
showToast(`✅ Sinal enviado para ${data.devicesTriggered} dispositivo(s) conectado(s)!`, 'success');
|
|
} else {
|
|
showToast('⚠️ Nenhum dispositivo Windows conectado no momento.', 'warning');
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Global scope
|
|
window.openPluginsModal = openPluginsModal;
|
|
window.closePluginsModal = closePluginsModal;
|
|
window.updatePluginsConfig = updatePluginsConfig;
|
|
window.triggerSync = triggerSync;
|
|
|
|
// ================================================================
|
|
// ÁREA DE SINCRONIZAÇÃO E UPLOAD MANUAL
|
|
// ================================================================
|
|
|
|
let selectedUploadFiles = [];
|
|
|
|
function openSyncModal(defaultTab = 'devices') {
|
|
const modal = document.getElementById('syncModal');
|
|
if (!modal) return;
|
|
|
|
// Usuário não-admin sempre vai para aba upload
|
|
const isAdmin = document.body.classList.contains('is-admin');
|
|
const targetTab = (!isAdmin || defaultTab === 'upload') ? 'upload' : 'devices';
|
|
|
|
modal.classList.add('active');
|
|
|
|
// Resetar abas
|
|
switchSyncTab(targetTab);
|
|
|
|
// Limpar formulário de upload
|
|
resetManualUploadForm();
|
|
|
|
// Carregar dados
|
|
if (targetTab === 'devices') {
|
|
loadSyncDevices();
|
|
}
|
|
refreshUploadPatients();
|
|
|
|
// Escuta de clique fora para fechar modal
|
|
modal.onclick = (e) => {
|
|
if (e.target === modal) closeSyncModal();
|
|
};
|
|
}
|
|
|
|
function closeSyncModal() {
|
|
const modal = document.getElementById('syncModal');
|
|
if (!modal) return;
|
|
|
|
modal.classList.remove('active');
|
|
}
|
|
|
|
function switchSyncTab(tabName) {
|
|
const btnDevices = document.getElementById('tabBtnDevices');
|
|
const btnUpload = document.getElementById('tabBtnUpload');
|
|
const tabDevices = document.getElementById('tab-devices');
|
|
const tabUpload = document.getElementById('tab-upload');
|
|
|
|
if (!btnDevices || !btnUpload || !tabDevices || !tabUpload) return;
|
|
|
|
if (tabName === 'devices') {
|
|
btnDevices.classList.add('active');
|
|
btnUpload.classList.remove('active');
|
|
tabDevices.classList.add('active');
|
|
tabUpload.classList.remove('active');
|
|
loadSyncDevices();
|
|
} else {
|
|
btnDevices.classList.remove('active');
|
|
btnUpload.classList.add('active');
|
|
tabDevices.classList.remove('active');
|
|
tabUpload.classList.add('active');
|
|
}
|
|
}
|
|
|
|
async function loadSyncDevices() {
|
|
const loader = document.getElementById('syncDevicesLoading');
|
|
const empty = document.getElementById('syncDevicesEmpty');
|
|
const list = document.getElementById('syncDeviceList');
|
|
|
|
if (!loader || !empty || !list) return;
|
|
|
|
loader.style.display = 'block';
|
|
empty.style.display = 'none';
|
|
list.style.display = 'none';
|
|
|
|
try {
|
|
const res = await fetchWithAuth('/api/socket/status');
|
|
if (!res.ok) throw new Error('Falha ao obter status dos dispositivos.');
|
|
const data = await res.json();
|
|
|
|
list.replaceChildren();
|
|
|
|
const windowsClients = (data.identified || []).filter(c => c.type === 'client' || c.type === 'windows');
|
|
|
|
if (windowsClients.length === 0) {
|
|
loader.style.display = 'none';
|
|
empty.style.display = 'block';
|
|
list.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
windowsClients.forEach(client => {
|
|
const card = document.createElement('div');
|
|
card.className = 'sync-device-card';
|
|
|
|
const left = document.createElement('div');
|
|
left.className = 'device-card-left';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'device-card-title';
|
|
title.textContent = `💻 ${client.name || 'Dispositivo Sem Nome'}`;
|
|
|
|
const subtitle = document.createElement('div');
|
|
subtitle.className = 'device-card-subtitle';
|
|
subtitle.textContent = `SO: ${client.system || 'Windows'} | Versão: ${client.version || 'N/A'} | ID: ${client.socketId}`;
|
|
|
|
left.appendChild(title);
|
|
left.appendChild(subtitle);
|
|
|
|
const badge = document.createElement('span');
|
|
badge.className = 'device-card-badge badge-connected';
|
|
badge.textContent = 'ONLINE';
|
|
|
|
card.appendChild(left);
|
|
card.appendChild(badge);
|
|
|
|
list.appendChild(card);
|
|
});
|
|
|
|
loader.style.display = 'none';
|
|
empty.style.display = 'none';
|
|
list.style.display = 'flex';
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
loader.style.display = 'none';
|
|
empty.style.display = 'block';
|
|
const p = empty.querySelector('p');
|
|
if (p) p.textContent = 'Erro ao carregar dispositivos: ' + e.message;
|
|
}
|
|
}
|
|
|
|
async function triggerSyncFromModal() {
|
|
const btn = document.getElementById('btnTriggerSyncModal');
|
|
if (!btn) return;
|
|
|
|
const originalText = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '🔄 Enviando sinal...';
|
|
|
|
try {
|
|
const res = await fetchWithAuth('/api/system/trigger-sync', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || 'Erro ao acionar sincronização.');
|
|
}
|
|
|
|
const data = await res.json();
|
|
if (data.devicesTriggered > 0) {
|
|
showToast(`✅ Sinal enviado para ${data.devicesTriggered} dispositivo(s) conectado(s)!`, 'success');
|
|
} else {
|
|
showToast('⚠️ Nenhum dispositivo Windows conectado para sincronizar.', 'warning');
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalText;
|
|
}
|
|
}
|
|
|
|
async function refreshUploadPatients() {
|
|
const select = document.getElementById('uploadPatientSelect');
|
|
if (!select) return;
|
|
|
|
// Manter as primeiras duas opções
|
|
while (select.options.length > 2) {
|
|
select.remove(2);
|
|
}
|
|
|
|
try {
|
|
const res = await fetchWithAuth('/api/images/patients?disabled=false&search=');
|
|
if (!res.ok) throw new Error('Erro ao listar pacientes.');
|
|
|
|
const patients = await res.json();
|
|
patients.forEach(p => {
|
|
if (!p || !p.patient_name) return;
|
|
const opt = document.createElement('option');
|
|
opt.value = p.patient_name;
|
|
opt.textContent = p.patient_name;
|
|
select.appendChild(opt);
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast('Erro ao carregar lista de pacientes.', 'error');
|
|
}
|
|
}
|
|
|
|
function toggleNewPatientFields() {
|
|
const select = document.getElementById('uploadPatientSelect');
|
|
const fields = document.getElementById('newPatientFields');
|
|
|
|
if (!select || !fields) return;
|
|
|
|
if (select.value === 'NEW_PATIENT') {
|
|
fields.style.display = 'block';
|
|
document.getElementById('uploadNewPatientFirstName').required = true;
|
|
document.getElementById('uploadNewPatientLastName').required = true;
|
|
} else {
|
|
fields.style.display = 'none';
|
|
document.getElementById('uploadNewPatientFirstName').required = false;
|
|
document.getElementById('uploadNewPatientLastName').required = false;
|
|
}
|
|
}
|
|
|
|
function resetManualUploadForm() {
|
|
selectedUploadFiles = [];
|
|
|
|
const form = document.getElementById('manualUploadForm');
|
|
if (form) form.reset();
|
|
|
|
const previews = document.getElementById('uploadPreviewsContainer');
|
|
if (previews) previews.replaceChildren();
|
|
|
|
const progressContainer = document.getElementById('uploadProgressContainer');
|
|
if (progressContainer) progressContainer.style.display = 'none';
|
|
|
|
const fields = document.getElementById('newPatientFields');
|
|
if (fields) fields.style.display = 'none';
|
|
|
|
const btnSubmit = document.getElementById('btnSubmitManualUpload');
|
|
if (btnSubmit) btnSubmit.disabled = true;
|
|
}
|
|
|
|
// Inicializar Drag and Drop
|
|
function initDragAndDrop() {
|
|
const zone = document.getElementById('uploadDragDropZone');
|
|
const fileInput = document.getElementById('uploadFileInput');
|
|
|
|
if (!zone || !fileInput) return;
|
|
|
|
// Evento de clique na zona abre o seletor de arquivos
|
|
zone.addEventListener('click', () => fileInput.click());
|
|
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
zone.addEventListener(eventName, e => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}, false);
|
|
});
|
|
|
|
['dragenter', 'dragover'].forEach(eventName => {
|
|
zone.addEventListener(eventName, () => {
|
|
zone.classList.add('drag-active');
|
|
}, false);
|
|
});
|
|
|
|
['dragleave', 'drop'].forEach(eventName => {
|
|
zone.addEventListener(eventName, () => {
|
|
zone.classList.remove('drag-active');
|
|
}, false);
|
|
});
|
|
|
|
zone.addEventListener('drop', e => {
|
|
const dt = e.dataTransfer;
|
|
if (dt) handleUploadFiles(dt.files);
|
|
});
|
|
|
|
fileInput.addEventListener('change', e => {
|
|
if (e.target && e.target.files) handleUploadFiles(e.target.files);
|
|
});
|
|
}
|
|
|
|
function handleUploadFiles(files) {
|
|
if (!files || files.length === 0) return;
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
|
|
if (!file.type.match('image.*')) {
|
|
showToast(`O arquivo "${file.name}" não é uma imagem válida!`, 'warning');
|
|
continue;
|
|
}
|
|
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
showToast(`A imagem "${file.name}" excede o limite de tamanho de 10MB!`, 'warning');
|
|
continue;
|
|
}
|
|
|
|
if (selectedUploadFiles.some(f => f.name === file.name && f.size === file.size)) {
|
|
continue;
|
|
}
|
|
|
|
selectedUploadFiles.push(file);
|
|
}
|
|
|
|
renderUploadPreviews();
|
|
}
|
|
|
|
function renderUploadPreviews() {
|
|
const container = document.getElementById('uploadPreviewsContainer');
|
|
const btnSubmit = document.getElementById('btnSubmitManualUpload');
|
|
if (!container) return;
|
|
|
|
container.replaceChildren();
|
|
|
|
selectedUploadFiles.forEach((file, index) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'preview-item';
|
|
|
|
const img = document.createElement('img');
|
|
img.alt = file.name;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
if (e.target && e.target.result) img.src = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
const btnRemove = document.createElement('button');
|
|
btnRemove.type = 'button';
|
|
btnRemove.className = 'preview-remove';
|
|
btnRemove.innerHTML = '×';
|
|
btnRemove.title = 'Remover Imagem';
|
|
btnRemove.onclick = (e) => {
|
|
e.stopPropagation();
|
|
removeUploadPreview(index);
|
|
};
|
|
|
|
item.appendChild(img);
|
|
item.appendChild(btnRemove);
|
|
container.appendChild(item);
|
|
});
|
|
|
|
if (btnSubmit) {
|
|
btnSubmit.disabled = selectedUploadFiles.length === 0;
|
|
}
|
|
}
|
|
|
|
function removeUploadPreview(index) {
|
|
selectedUploadFiles.splice(index, 1);
|
|
renderUploadPreviews();
|
|
}
|
|
|
|
function readFileAsBase64(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result);
|
|
reader.onerror = error => reject(error);
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async function handleManualUploadSubmit(event) {
|
|
event.preventDefault();
|
|
|
|
if (selectedUploadFiles.length === 0) {
|
|
showToast('Selecione pelo menos uma imagem para enviar.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const select = document.getElementById('uploadPatientSelect');
|
|
const remarkInput = document.getElementById('uploadRemark');
|
|
const btnSubmit = document.getElementById('btnSubmitManualUpload');
|
|
|
|
if (!select || !btnSubmit) return;
|
|
|
|
let patientName = '';
|
|
let doctor = '';
|
|
|
|
if (select.value === 'NEW_PATIENT') {
|
|
const firstName = document.getElementById('uploadNewPatientFirstName').value.trim();
|
|
const lastName = document.getElementById('uploadNewPatientLastName').value.trim();
|
|
doctor = document.getElementById('uploadNewPatientDoctor').value.trim();
|
|
|
|
if (!firstName || !lastName) {
|
|
showToast('Nome e sobrenome são obrigatórios para novo paciente.', 'warning');
|
|
return;
|
|
}
|
|
|
|
patientName = `${firstName} ${lastName}`;
|
|
} else {
|
|
patientName = select.value;
|
|
}
|
|
|
|
const remark = remarkInput ? remarkInput.value.trim() : '';
|
|
|
|
const progressContainer = document.getElementById('uploadProgressContainer');
|
|
const progressText = document.getElementById('uploadProgressText');
|
|
const progressBarFill = document.getElementById('uploadProgressBarFill');
|
|
const progressPercent = document.getElementById('uploadProgressPercent');
|
|
|
|
if (progressContainer) progressContainer.style.display = 'block';
|
|
btnSubmit.disabled = true;
|
|
|
|
const totalFiles = selectedUploadFiles.length;
|
|
let successCount = 0;
|
|
|
|
for (let i = 0; i < totalFiles; i++) {
|
|
const file = selectedUploadFiles[i];
|
|
|
|
if (progressText) progressText.textContent = `Enviando imagem ${i + 1} de ${totalFiles}...`;
|
|
if (progressBarFill) {
|
|
const pct = Math.round((i / totalFiles) * 100);
|
|
progressBarFill.style.width = `${pct}%`;
|
|
if (progressPercent) progressPercent.textContent = `${pct}%`;
|
|
}
|
|
|
|
try {
|
|
const base64 = await readFileAsBase64(file);
|
|
|
|
const response = await fetchWithAuth('/api/images/upload', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
imageBase64: base64,
|
|
filename: file.name,
|
|
patientName: patientName,
|
|
doctor: doctor || null,
|
|
remark: remark || null
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json();
|
|
throw new Error(err.error || `Erro HTTP ${response.status}`);
|
|
}
|
|
|
|
successCount++;
|
|
} catch (e) {
|
|
console.error(`Erro ao enviar arquivo ${file.name}:`, e);
|
|
showToast(`Falha ao enviar a imagem ${file.name}: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
if (progressBarFill) {
|
|
progressBarFill.style.width = '100%';
|
|
if (progressPercent) progressPercent.textContent = '100%';
|
|
}
|
|
if (progressText) progressText.textContent = `Finalizado! ${successCount} de ${totalFiles} imagens enviadas.`;
|
|
|
|
setTimeout(async () => {
|
|
closeSyncModal();
|
|
showToast(`✅ ${successCount} imagem(ns) enviada(s) com sucesso!`, 'success');
|
|
|
|
await loadPatients();
|
|
if (window.loadClientsList) {
|
|
await window.loadClientsList();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
// Vinculando ao window
|
|
window.openSyncModal = openSyncModal;
|
|
window.closeSyncModal = closeSyncModal;
|
|
window.switchSyncTab = switchSyncTab;
|
|
window.triggerSyncFromModal = triggerSyncFromModal;
|
|
window.refreshUploadPatients = refreshUploadPatients;
|
|
window.toggleNewPatientFields = toggleNewPatientFields;
|
|
window.handleManualUploadSubmit = handleManualUploadSubmit;
|
|
window.initDragAndDrop = initDragAndDrop;
|
|
window.deletePatientImages = deletePatientImages;
|
|
window.adjustFineTune = adjustFineTune;
|
|
window.resetFineTune = resetFineTune;
|
|
// A aplicação é inicializada no evento DOMContentLoaded acima
|
|
|
|
function logout() { localStorage.removeItem('auth_token'); window.location.href = '/login'; }
|