Files
rx.scoreodonto.com/dental-server/public/app.js
T

1878 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ================================================================
// DENTAL IMAGE MANAGER - Main JavaScript
// Two-Level Navigation: Patients → Images → Transform
// ================================================================
const API_URL = '/api';
// ================================================================
// ERROR LOGGER — Logs to console AND shows on screen
// ================================================================
const _log = {
info: (tag, ...args) => console.log(`%c[${tag}]`, 'color:#667eea;font-weight:bold', ...args),
warn: (tag, ...args) => console.warn(`%c[${tag}]`, 'color:#ffa500;font-weight:bold', ...args),
error: (tag, ...args) => console.error(`%c[${tag}]`, 'color:#ff4444;font-weight:bold', ...args),
group: (tag) => console.group(`%c[${tag}]`, 'color:#667eea;font-weight:bold'),
end: () => console.groupEnd()
};
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) 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;
}
}
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 currentTransformations = [];
let fineTuneAngle = 0;
// ================================================================
// DOM REFS
// ================================================================
const searchInput = document.getElementById('searchInput');
const clientFilter = document.getElementById('clientFilter'); // Pode ser null se removido do header
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();
setupEventListeners();
initDragAndDrop();
setInterval(loadClientsList, 5000);
} 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));
if (clientFilter) {
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', () => showPatientsView());
// Transform modal close on backdrop
transformModal.addEventListener('click', e => {
if (e.target === transformModal) {
transformModal.classList.remove('active');
if (typeof clearTransformSelection === 'function') clearTransformSelection();
}
});
document.getElementById('closeTransformModal').addEventListener('click', () => {
transformModal.classList.remove('active');
if (typeof clearTransformSelection === 'function') clearTransformSelection();
});
}
// ================================================================
// CLIENTS LIST
// ================================================================
async function loadClientsList() {
try {
const r = await fetch('/api/socket/status');
const data = await r.json();
clientsList = (data.identified || []).map(c => ({
...c, name: c.name || 'Unknown', displayName: c.name || 'Unknown',
dbName: c.name || 'Unknown', status: 'identified'
}));
const imagesRes = await fetchWithAuth('/api/images/patients?disabled=false&search=');
if (imagesRes.ok) {
const pts = await imagesRes.json();
const uniqueClients = [...new Set(pts.map(p => p.client_name).filter(Boolean))];
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() {
// Se por acaso o elemento clientFilter ainda existir no DOM em alguma outra tela, atualiza ele
if (clientFilter) {
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;
}
// Renderizar o submenu dinâmico na sidebar
const submenu = document.getElementById('clientsSubmenu');
if (!submenu) return;
const sorted = [...clientsList].sort((a, b) => {
const o = { identified: 1, connected: 2, historic: 3 };
return (o[a.status] || 99) - (o[b.status] || 99);
});
submenu.innerHTML = '';
// Adiciona o item "Todos os Clientes"
const allItem = document.createElement('div');
allItem.className = `nav-item${selectedClient === '' ? ' active' : ''}`;
allItem.innerHTML = `<span class="icon">📋</span> <span style="flex:1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="Todos os Clientes">Todos os Clientes</span>`;
allItem.onclick = () => selectClientFilter('');
submenu.appendChild(allItem);
sorted.forEach(c => {
const item = document.createElement('div');
item.className = `nav-item${selectedClient === c.name ? ' active' : ''}`;
let lbl = c.name;
if (c.name === 'Upload Web') {
lbl = 'Upload Web 💻';
} else {
if (c.status === 'identified') lbl += ' ✅';
else if (c.status === 'historic') lbl += ' 📚';
}
item.innerHTML = `<span class="icon">🔹</span> <span style="flex:1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(lbl)}">${escapeHtml(lbl)}</span>`;
item.onclick = () => selectClientFilter(c.name);
submenu.appendChild(item);
});
}
window.toggleClientsSubmenu = function() {
const submenu = document.getElementById('clientsSubmenu');
const chevron = document.getElementById('clientsChevron');
if (!submenu) return;
if (submenu.style.display === 'none') {
submenu.style.display = 'flex';
if (chevron) chevron.style.transform = 'rotate(180deg)';
} else {
submenu.style.display = 'none';
if (chevron) chevron.style.transform = 'rotate(0deg)';
}
}
function selectClientFilter(clientName) {
selectedClient = clientName;
if (view === 'patients') {
loadPatients();
} else {
showPatientsView();
}
updateClientsDropdown();
}
// ================================================================
// VIEW: PATIENTS LIST
// ================================================================
function showPatientsView() {
view = 'patients';
selectedPatient = null;
backBtn.style.display = 'none';
patientHeader.style.display = 'none';
// Restaurar título do header
const mainTitle = document.getElementById('mainTitle');
if (mainTitle) mainTitle.textContent = 'Galeria de Imagens';
loadPatients();
}
async function loadPatients(silent = false) {
_log.group('LOAD_PATIENTS');
_log.info('LOAD_PATIENTS', `Iniciando carga. silent=${silent}, showDisabled=${showDisabled}, client="${selectedClient}"`);
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}`;
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'}`);
}
patients = await res.json();
_log.info('LOAD_PATIENTS', `${patients.length} paciente(s) recebido(s)`);
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';
_log.info('LOAD_PATIENTS', `imagesGrid display=${imagesGrid.style.display}`);
renderPatientCards();
}
} catch (e) {
_log.error('LOAD_PATIENTS', '❌ Exceção:', e.message, e);
loadingState.style.display = 'none';
emptyState.style.display = 'block';
showErrorBanner('Erro ao carregar pacientes', e.message);
showToast('Erro ao carregar pacientes', 'error');
} finally {
_log.end();
}
}
function renderPatientCards() {
_log.group('RENDER_CARDS');
_log.info('RENDER_CARDS', `Renderizando ${patients.length} card(s)...`);
// Inspecionar o grid antes de renderizar
const gridStyle = window.getComputedStyle(imagesGrid);
_log.info('RENDER_CARDS', 'imagesGrid computed style:', {
display: gridStyle.display,
gridTemplateColumns: gridStyle.gridTemplateColumns,
width: gridStyle.width,
height: gridStyle.height,
overflow: gridStyle.overflow
});
imagesGrid.innerHTML = patients.map((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 || '—';
if (index === 0) {
_log.info('RENDER_CARDS', 'Card #0 dados:', { fullName, thumb, count, date, doctor });
}
return `
<div class="image-card patient-card" onclick="openPatientByIndex(${index})">
<div class="image-preview" style="${thumb ? `background-image: url('${thumb}'); background-size: cover; background-position: center;` : 'background: linear-gradient(135deg,#667eea22,#764ba222);'}">
<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>`;
}).join('');
// Inspecionar o primeiro card renderizado
requestAnimationFrame(() => {
const firstCard = imagesGrid.querySelector('.image-card');
if (firstCard) {
const cs = window.getComputedStyle(firstCard);
const preview = firstCard.querySelector('.image-preview');
const pcs = preview ? window.getComputedStyle(preview) : null;
_log.info('RENDER_CARDS', 'Primeiro card computado:', {
cardW: cs.width, cardH: cs.height,
previewH: pcs ? pcs.height : 'n/a',
previewBg: pcs ? pcs.backgroundImage.substring(0, 60) : 'n/a'
});
} else {
_log.error('RENDER_CARDS', '❌ Nenhum card encontrado no DOM após render!');
}
_log.info('RENDER_CARDS', `Total de cards no DOM: ${imagesGrid.querySelectorAll('.image-card').length}`);
_log.end();
});
}
// 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) {
selectedPatient = patient;
view = 'patient-images';
backBtn.style.display = 'inline-flex';
// Ocultar o patientHeader para manter o header na mesma altura de 70px
patientHeader.style.display = 'none';
// Mudar o título principal para o nome do paciente - dentista
const fullName = patient.patient_name || 'Sem nome';
const doctorName = patient.doctor ? ` - ${patient.doctor}` : '';
const mainTitle = document.getElementById('mainTitle');
if (mainTitle) {
mainTitle.textContent = `${fullName}${doctorName}`;
}
patientTitle.textContent = fullName;
patientMeta.textContent = `${patient.image_count || 0} imagem(ns) · ${patient.doctor || '—'}`;
loadPatientImages(patient.patient_name);
}
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" style="background-image: url('${thumbUrl}'); background-size: cover; background-position: center;"></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) {
transformModal.classList.add('active');
// Default view is 'transform'
toggleModalView('transform');
// Find image to show details
const image = patientImages.find(img => img.id === imageId);
// Check if the image has been modified (has original_image_id)
const btnResetImage = document.getElementById('btnResetImage');
if (image && image.original_image_id) {
btnResetImage.style.display = 'flex';
btnResetImage.setAttribute('data-modified', 'true');
} else {
btnResetImage.style.display = 'none';
btnResetImage.setAttribute('data-modified', 'false');
}
if (image) {
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);
} else {
document.getElementById('transformModalTitle').innerText = `Opções da Imagem`;
document.getElementById('transformExtraInfo').innerHTML = '';
document.getElementById('transformExtraInfo').style.display = 'none';
}
document.getElementById('transformOptions').innerHTML = `
<div style="grid-column:1/-1; text-align:center; padding:40px 0; color:#888;">
<div class="spinner" style="margin:0 auto 16px;"></div>
<p>Gerando variações...</p>
</div>`;
try {
const image = patientImages.find(img => img.id === imageId);
if (!image) {
throw new Error('Imagem não encontrada localmente');
}
if (typeof clearTransformSelection === 'function') clearTransformSelection();
// Se existir thumb_filename, usar ele; caso contrário, fallback para a imagem cheia
const imageUrl = image.thumb_filename ? `/uploads/${image.thumb_filename}` : `/uploads/${image.filename}`;
const transformations = [
{ name: 'Original', rotation: 0, flipH: false, flipV: false, css: 'transform: none;' },
{ name: 'Flip Horizontal', rotation: 0, flipH: true, flipV: false, css: 'transform: scaleX(-1);' },
{ name: 'Flip Vertical', rotation: 0, flipH: false, flipV: true, css: 'transform: scaleY(-1);' },
{ name: 'Rotação +90°', rotation: 90, flipH: false, flipV: false, css: 'transform: rotate(90deg) scale(0.65);' },
{ name: 'Rotação -90°', rotation: -90, flipH: false, flipV: false, css: 'transform: rotate(-90deg) scale(0.65);' }
];
currentTransformations = transformations;
const container = document.getElementById('transformOptions');
container.innerHTML = transformations.map((t, i) => `
<div class="transform-option" data-index="${i}" onclick="selectTransform(${i})">
<div class="transform-image-wrapper">
<img src="${imageUrl}" alt="${escapeHtml(t.name)}" style="${t.css}" loading="lazy">
</div>
<div class="transform-name">${escapeHtml(t.name)}</div>
</div>
`).join('');
// ---------------------------------------------------------------
// Detectar orientação da imagem original → aplicar layout correto
// ---------------------------------------------------------------
const probe = new Image();
probe.onload = () => {
const orientation = probe.naturalWidth >= probe.naturalHeight ? 'landscape' : 'portrait';
container.classList.remove('grid-landscape', 'grid-portrait');
container.classList.add(`grid-${orientation}`);
_log.info('TRANSFORM', `Orientação detectada: ${orientation} (${probe.naturalWidth}×${probe.naturalHeight})`);
};
probe.onerror = () => {
_log.warn('TRANSFORM', 'Não foi possível detectar orientação — usando fallback');
};
probe.src = imageUrl;
} catch (e) {
_log.error('TRANSFORM', '❌ Exceção:', e.message, e);
showToast('Erro ao carregar variações', 'error');
transformModal.classList.remove('active');
}
}
function selectTransform(index) {
const el = document.querySelector(`.transform-option[data-index="${index}"]`);
if (el.classList.contains('selected')) {
// If clicking the already selected one, clear selection
clearTransformSelection();
return;
}
const container = document.getElementById('transformOptions');
container.classList.add('selection-mode');
document.querySelectorAll('.transform-option').forEach(option => {
option.classList.remove('selected');
const optIndex = parseInt(option.getAttribute('data-index'));
if (optIndex === 0 || optIndex === index) {
option.style.display = 'block';
} else {
option.style.display = 'none';
}
});
el.classList.add('selected');
// Reset fine tune angle when selecting a new transform
fineTuneAngle = 0;
updateFineTuneDisplay();
// Mover a legenda do card selecionado para o placeholder de ajuste fino
const nameEl = el.querySelector('.transform-name');
const placeholder = document.getElementById('fineTuneLabelPlaceholder');
if (nameEl && placeholder) {
placeholder.appendChild(nameEl);
}
// Mover o contêiner de ajuste fino para dentro do card selecionado
const ftContainer = document.getElementById('fineTuneContainer');
if (ftContainer) {
el.appendChild(ftContainer);
ftContainer.style.display = 'flex';
}
// Show action area
document.getElementById('transformActionArea').style.display = 'block';
// Pre-fill remark if we have an image
const image = patientImages.find(img => img.id === selectedImageId);
if (image && !document.getElementById('newImageRemark').value) {
document.getElementById('newImageRemark').value = image.remark || '';
}
}
function clearTransformSelection() {
const container = document.getElementById('transformOptions');
container.classList.remove('selection-mode');
// Devolver a legenda ativa para o card selecionado
const ftPlaceholder = document.getElementById('fineTuneLabelPlaceholder');
if (ftPlaceholder) {
const activeLabel = ftPlaceholder.querySelector('.transform-name');
const selectedEl = document.querySelector('.transform-option.selected');
if (activeLabel && selectedEl) {
selectedEl.appendChild(activeLabel);
}
}
// Mover o contêiner de ajuste fino de volta para o Wrapper e esconder
const ftContainer = document.getElementById('fineTuneContainer');
if (ftContainer) {
const viewWrapper = document.getElementById('transformViewWrapper');
if (viewWrapper) {
viewWrapper.appendChild(ftContainer);
} else {
document.body.appendChild(ftContainer);
}
ftContainer.style.display = 'none';
}
// Restore original transform styles on all images
document.querySelectorAll('.transform-option').forEach(option => {
option.classList.remove('selected');
option.style.display = 'block';
const optIndex = parseInt(option.getAttribute('data-index'));
const transform = currentTransformations[optIndex];
const imgEl = option.querySelector('img');
if (imgEl && transform) {
imgEl.style.transform = transform.css;
}
const nameEl = option.querySelector('.transform-name');
if (nameEl && transform) {
nameEl.innerText = transform.name;
}
});
fineTuneAngle = 0;
updateFineTuneDisplay();
document.getElementById('transformActionArea').style.display = 'none';
document.getElementById('newImageRemark').value = '';
}
async function saveSelectedTransform() {
const selectedEl = document.querySelector('.transform-option.selected');
if (!selectedEl) {
showToast('Selecione uma orientação primeiro.', 'error');
return;
}
const index = parseInt(selectedEl.getAttribute('data-index'));
const transform = currentTransformations[index];
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: transform.rotation + fineTuneAngle,
flipH: transform.flipH,
flipV: transform.flipV,
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');
clearTransformSelection();
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) {
const selectedEl = document.querySelector('.transform-option.selected');
if (!selectedEl) return;
const index = parseInt(selectedEl.getAttribute('data-index'));
const transform = currentTransformations[index];
fineTuneAngle += offset;
updateFineTuneDisplay();
// Update the image style using the combined transform
const imgEl = selectedEl.querySelector('img');
if (imgEl) {
let totalRotation = transform.rotation + fineTuneAngle;
let transformStr = `rotate(${totalRotation}deg)`;
if (transform.flipH) transformStr += ` scaleX(-1)`;
if (transform.flipV) transformStr += ` scaleY(-1)`;
imgEl.style.transform = transformStr;
}
// Update display name inside the card
const nameEl = selectedEl.querySelector('.transform-name');
if (nameEl && transform) {
if (fineTuneAngle === 0) {
nameEl.innerText = transform.name;
} else {
nameEl.innerText = `${transform.name} (${fineTuneAngle > 0 ? '+' : ''}${fineTuneAngle}°)`;
}
}
}
function resetFineTune() {
adjustFineTune(-fineTuneAngle);
}
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.selectTransform = selectTransform;
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.selectTransform = selectTransform;
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.style.display = 'block';
// Limpar form
document.getElementById('currentPassword').value = '';
document.getElementById('newUsername').value = '';
document.getElementById('newPassword').value = '';
}
function closeSettingsModal() {
const modal = document.getElementById('settingsModal');
if (modal) modal.style.display = 'none';
}
async function updateCredentials(event) {
event.preventDefault();
const currentPassword = document.getElementById('currentPassword').value;
const newUsername = document.getElementById('newUsername').value.trim();
const newPassword = document.getElementById('newPassword').value;
if (!currentPassword) {
showToast('A senha atual é obrigatória.', 'error');
return;
}
if (!newUsername && !newPassword) {
showToast('Preencha um novo usuário 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, 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);
}
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;
// ================================================================
// CONFIGURAÇÕES DE PLUGINS (Wasabi Storage)
// ================================================================
function openPluginsModal() {
const modal = document.getElementById('pluginsModal');
if (modal) modal.style.display = 'block';
// 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.style.display = 'none';
}
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 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();
// Fechar após 1 segundo
setTimeout(closePluginsModal, 1200);
} catch (e) {
console.error(e);
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() {
const modal = document.getElementById('syncModal');
if (!modal) return;
modal.classList.add('active');
modal.style.display = 'flex';
// Resetar abas
switchSyncTab('devices');
// Limpar formulário de upload
resetManualUploadForm();
// Carregar dados
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');
modal.style.display = 'none';
}
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 = '&times;';
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