diff --git a/dental-server/public/public/admin-clinics.css b/dental-server/public/public/admin-clinics.css new file mode 100644 index 0000000..ea13657 --- /dev/null +++ b/dental-server/public/public/admin-clinics.css @@ -0,0 +1,616 @@ +:root { + --primary: #4361ee; + --primary-hover: #3a56d4; + --primary-light: rgba(67, 97, 238, 0.1); + --success: #2ec4b6; + --success-light: rgba(46, 196, 182, 0.15); + --danger: #ef476f; + --danger-light: rgba(239, 71, 111, 0.15); + --warning: #ff9f1c; + --dark: #0b132b; + --gray-900: #1c2541; + --gray-800: #2b3a67; + --gray-200: #e2e8f0; + --light: #f8f9fa; + --white: #ffffff; + + --glass-bg: rgba(255, 255, 255, 0.05); + --glass-border: rgba(255, 255, 255, 0.1); + --glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); + + --font-heading: 'Outfit', sans-serif; + --font-body: 'Inter', sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-body); + background: linear-gradient(135deg, var(--dark) 0%, var(--gray-900) 100%); + color: var(--light); + min-height: 100vh; + display: flex; + overflow-x: hidden; +} + +/* Glassmorphism Classes */ +.glass-card { + background: var(--glass-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--glass-border); + border-radius: 16px; + box-shadow: var(--glass-shadow); +} + +/* Animations */ +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(67, 97, 238, 0.4); } + 70% { box-shadow: 0 0 0 10px rgba(67, 97, 238, 0); } + 100% { box-shadow: 0 0 0 0 rgba(67, 97, 238, 0); } +} + +.pulse-animation { + animation: pulse 2s infinite; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Sidebar */ +.sidebar { + width: 260px; + background: rgba(11, 19, 43, 0.8); + border-right: 1px solid var(--glass-border); + display: flex; + flex-direction: column; + padding: 24px 0; + z-index: 10; +} + +.sidebar-header { + padding: 0 24px 32px; + display: flex; + align-items: center; + gap: 12px; +} + +.logo-icon { + width: 40px; + height: 40px; + background: linear-gradient(135deg, #4cc9f0, var(--primary)); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: white; +} + +.sidebar-header h2 { + font-family: var(--font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 16px; + flex: 1; +} + +.nav-item { + padding: 12px 16px; + border-radius: 10px; + color: #a0aec0; + text-decoration: none; + font-weight: 500; + display: flex; + align-items: center; + gap: 12px; + transition: all 0.3s ease; +} + +.nav-item i { + font-size: 18px; + width: 20px; + text-align: center; +} + +.nav-item:hover { + background: var(--glass-bg); + color: var(--white); + transform: translateX(4px); +} + +.nav-item.active { + background: var(--primary-light); + color: #4cc9f0; + border-left: 3px solid #4cc9f0; +} + +.sidebar-footer { + padding: 0 24px; +} + +.logout-btn { + width: 100%; + padding: 12px; + background: transparent; + border: 1px solid var(--glass-border); + color: #a0aec0; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-family: var(--font-body); + font-weight: 500; + transition: all 0.3s; +} + +.logout-btn:hover { + background: var(--danger-light); + color: var(--danger); + border-color: var(--danger); +} + +/* Main Content */ +.main-content { + flex: 1; + padding: 32px 40px; + overflow-y: auto; +} + +.top-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 40px; + animation: fadeIn 0.5s ease-out; +} + +.header-title h1 { + font-family: var(--font-heading); + font-size: 32px; + margin-bottom: 8px; +} + +.header-title p { + color: #a0aec0; + font-size: 15px; +} + +/* Buttons */ +.primary-btn { + background: var(--primary); + color: white; + border: none; + padding: 12px 24px; + border-radius: 10px; + font-family: var(--font-body); + font-weight: 600; + font-size: 15px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(67, 97, 238, 0.3); +} + +.primary-btn:hover { + background: var(--primary-hover); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(67, 97, 238, 0.4); +} + +.secondary-btn { + background: transparent; + color: var(--white); + border: 1px solid var(--glass-border); + padding: 12px 24px; + border-radius: 10px; + font-family: var(--font-body); + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.secondary-btn:hover { + background: var(--glass-bg); +} + +.icon-btn { + background: transparent; + border: none; + color: #a0aec0; + cursor: pointer; + padding: 6px; + border-radius: 6px; + transition: all 0.2s; +} + +.icon-btn:hover { + color: var(--white); + background: var(--glass-bg); +} + +.icon-btn.delete:hover { + color: var(--danger); + background: var(--danger-light); +} + +/* Stats */ +.dashboard-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + margin-bottom: 40px; + animation: fadeIn 0.6s ease-out; +} + +.stat-card { + padding: 24px; + display: flex; + align-items: center; + gap: 20px; + transition: transform 0.3s ease; +} + +.stat-card:hover { + transform: translateY(-5px); +} + +.stat-icon { + width: 60px; + height: 60px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; +} + +.bg-blue { background: rgba(67, 97, 238, 0.15); color: #4cc9f0; } +.bg-green { background: var(--success-light); color: var(--success); } +.bg-red { background: var(--danger-light); color: var(--danger); } + +.stat-info h3 { + font-size: 14px; + color: #a0aec0; + font-weight: 500; + margin-bottom: 4px; +} + +.stat-info p { + font-family: var(--font-heading); + font-size: 28px; + font-weight: 700; +} + +/* Table */ +.data-section { + padding: 24px; + animation: fadeIn 0.7s ease-out; +} + +.table-container { + overflow-x: auto; +} + +.modern-table { + width: 100%; + border-collapse: separate; + border-spacing: 0 8px; +} + +.modern-table th { + text-align: left; + padding: 0 16px 12px; + color: #a0aec0; + font-weight: 500; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.modern-table td { + padding: 16px; + background: rgba(255, 255, 255, 0.02); +} + +.modern-table tr td:first-child { + border-radius: 10px 0 0 10px; +} + +.modern-table tr td:last-child { + border-radius: 0 10px 10px 0; +} + +.modern-table tbody tr { + transition: all 0.2s; +} + +.modern-table tbody tr:hover td { + background: rgba(255, 255, 255, 0.05); +} + +/* Status Badge */ +.status-badge { + padding: 6px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.status-active { + background: var(--success-light); + color: var(--success); +} + +.status-inactive { + background: var(--danger-light); + color: var(--danger); +} + +.token-blur { + filter: blur(4px); + transition: filter 0.3s; + cursor: pointer; + font-family: monospace; + background: rgba(0,0,0,0.2); + padding: 4px 8px; + border-radius: 4px; +} + +.token-blur:hover { + filter: blur(0); +} + +/* Modals */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(5px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.modal-overlay.active { + opacity: 1; + visibility: visible; +} + +.modal-content { + width: 100%; + max-width: 500px; + padding: 32px; + transform: scale(0.9); + transition: all 0.3s ease; +} + +.modal-overlay.active .modal-content { + transform: scale(1); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.modal-header h2 { + font-family: var(--font-heading); +} + +/* Forms */ +.modern-form .form-group { + margin-bottom: 20px; +} + +.modern-form label { + display: block; + margin-bottom: 8px; + font-size: 14px; + color: #a0aec0; +} + +.input-with-icon { + position: relative; + display: flex; + align-items: center; +} + +.input-with-icon i { + position: absolute; + left: 16px; + color: #a0aec0; +} + +.input-with-icon input { + width: 100%; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--glass-border); + color: white; + padding: 14px 16px 14px 45px; + border-radius: 10px; + font-family: var(--font-body); + font-size: 15px; + transition: all 0.3s; +} + +.input-with-icon input:focus { + outline: none; + border-color: var(--primary); + background: rgba(0, 0, 0, 0.4); +} + +.form-group small { + display: block; + margin-top: 6px; + color: #718096; + font-size: 12px; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 16px; + margin-top: 32px; +} + +/* Success Modal */ +.success-modal { + text-align: center; +} + +.success-icon { + font-size: 64px; + color: var(--success); + margin-bottom: 16px; +} + +.success-modal h2 { + margin-bottom: 12px; +} + +.success-modal p { + color: #a0aec0; + margin-bottom: 24px; + font-size: 14px; + line-height: 1.5; +} + +.token-container { + background: rgba(0, 0, 0, 0.3); + padding: 16px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid var(--primary-light); +} + +.token-container code { + font-family: monospace; + font-size: 16px; + color: #4cc9f0; + word-break: break-all; +} + +/* Toast */ +.toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 16px 24px; + background: var(--glass-bg); + backdrop-filter: blur(12px); + border-left: 4px solid var(--primary); + color: white; + border-radius: 8px; + box-shadow: 0 10px 30px rgba(0,0,0,0.3); + transform: translateX(120%); + transition: transform 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); + z-index: 1000; +} + +.toast.show { + transform: translateX(0); +} + +.toast.error { + border-left-color: var(--danger); +} + +.toast.success { + border-left-color: var(--success); +} + +.loading-cell { + text-align: center; + padding: 40px !important; + color: #a0aec0; +} + +.spinner { + width: 30px; + height: 30px; + border: 3px solid rgba(255,255,255,0.1); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Custom Switch Toggle */ +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; left: 0; right: 0; bottom: 0; + background-color: rgba(255,255,255,0.1); + transition: .4s; + border-radius: 34px; +} + +.slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: #a0aec0; + transition: .4s; + border-radius: 50%; +} + +input:checked + .slider { + background-color: var(--success); +} + +input:checked + .slider:before { + transform: translateX(20px); + background-color: white; +} diff --git a/dental-server/public/public/admin-clinics.html b/dental-server/public/public/admin-clinics.html new file mode 100644 index 0000000..a0f0450 --- /dev/null +++ b/dental-server/public/public/admin-clinics.html @@ -0,0 +1,201 @@ + + + + + + Gerenciamento de Clínicas - RF Dental + + + + + + + + + + + + + + + + + + +
+ +
+
+

Dispositivos e Clínicas

+

Gerencie o acesso do Aplicativo Desktop aos computadores das clínicas.

+
+
+ +
+
+ +
+
+
+ +
+
+

Total de Dispositivos

+

0

+
+
+
+
+ +
+
+

Ativos

+

0

+
+
+
+
+ +
+
+

Bloqueados

+

0

+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + +
StatusClínicaComputadorEmail de AcessoÚltimo IPToken de AutenticaçãoAções
+
+

Carregando dispositivos...

+
+
+
+ +
+ + + + + + + +
Notificação
+ + + + diff --git a/dental-server/public/public/admin-clinics.js b/dental-server/public/public/admin-clinics.js new file mode 100644 index 0000000..396139d --- /dev/null +++ b/dental-server/public/public/admin-clinics.js @@ -0,0 +1,251 @@ +document.addEventListener('DOMContentLoaded', () => { + // Configurações + const API_URL = '/api/admin/clinics'; + + // Pegar Token JWT do Admin + const token = localStorage.getItem('token') || ''; + + // Se não tem token, redirecionar imediatamente para login + if (!token) { + window.location.href = '/login'; + return; + } + + const fetchOptions = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }; + + // Elementos UI + const clinicsTableBody = document.getElementById('clinicsTableBody'); + const addClinicBtn = document.getElementById('addClinicBtn'); + const clinicModal = document.getElementById('clinicModal'); + const tokenModal = document.getElementById('tokenModal'); + const closeModals = document.querySelectorAll('.close-modal'); + const closeTokenModal = document.querySelector('.close-token-modal'); + const clinicForm = document.getElementById('clinicForm'); + const generatedTokenElem = document.getElementById('generatedToken'); + const copyTokenBtn = document.getElementById('copyTokenBtn'); + + // Stats + const totalDevicesElem = document.getElementById('totalDevices'); + const activeDevicesElem = document.getElementById('activeDevices'); + const blockedDevicesElem = document.getElementById('blockedDevices'); + + // Inicializar + loadClinics(); + + // ========================================== + // Carregar Clínicas + // ========================================== + async function loadClinics() { + try { + const response = await fetch(API_URL, fetchOptions); + + // Se recebeu 401 ou 403, token expirou — vai para login + if (response.status === 401 || response.status === 403) { + localStorage.removeItem('token'); + window.location.href = '/login'; + return; + } + + // Verificar se a resposta é realmente JSON (evita erro com HTML) + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + throw new Error('Sessão expirada ou servidor indisponível. Faça login novamente.'); + } + + if (!response.ok) { + throw new Error(`Erro HTTP ${response.status}`); + } + + const data = await response.json(); + if (data.success) { + renderClinics(data.clinics); + updateStats(data.clinics); + } else { + showToast(data.message, 'error'); + } + } catch (error) { + console.error(error); + clinicsTableBody.innerHTML = `${error.message || 'Erro ao carregar dados.'}`; + } + } + + // ========================================== + // Renderizar Tabela + // ========================================== + function renderClinics(clinics) { + if (clinics.length === 0) { + clinicsTableBody.innerHTML = `Nenhum dispositivo cadastrado.`; + return; + } + + clinicsTableBody.innerHTML = ''; + clinics.forEach(c => { + const isAct = c.is_active; + const statusClass = isAct ? 'status-active' : 'status-inactive'; + const statusIcon = isAct ? 'fa-check' : 'fa-ban'; + const statusText = isAct ? 'Ativo' : 'Bloqueado'; + + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${statusText} + ${c.clinic_name} + ${c.pc_name || '-'} + ${c.email} + ${c.last_ip || 'Nunca conectou'} + ${c.machine_token.split('-')[0]}... + +
+ + +
+ + `; + clinicsTableBody.appendChild(tr); + }); + } + + function updateStats(clinics) { + const total = clinics.length; + const active = clinics.filter(c => c.is_active).length; + const blocked = total - active; + + totalDevicesElem.textContent = total; + activeDevicesElem.textContent = active; + blockedDevicesElem.textContent = blocked; + } + + // ========================================== + // Cadastrar Clínica + // ========================================== + clinicForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = document.getElementById('submitBtn'); + submitBtn.disabled = true; + submitBtn.innerHTML = ' Salvando...'; + + const formData = new FormData(clinicForm); + const bodyData = Object.fromEntries(formData.entries()); + + try { + const response = await fetch(API_URL, { + method: 'POST', + headers: fetchOptions.headers, + body: JSON.stringify(bodyData) + }); + + const data = await response.json(); + + if (data.success) { + clinicModal.classList.remove('active'); + clinicForm.reset(); + loadClinics(); + + // Show Token Modal + generatedTokenElem.textContent = data.machine_token; + tokenModal.classList.add('active'); + } else { + showToast(data.message, 'error'); + } + } catch (error) { + showToast('Erro de conexão ao salvar', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.innerHTML = ' Salvar e Gerar Token'; + } + }); + + // ========================================== + // Toggle Status + // ========================================== + window.toggleStatus = async (id, isActive) => { + try { + const response = await fetch(`${API_URL}/${id}/status`, { + method: 'PUT', + headers: fetchOptions.headers, + body: JSON.stringify({ is_active: isActive }) + }); + const data = await response.json(); + if(data.success) { + showToast(isActive ? 'Dispositivo Ativado' : 'Dispositivo Bloqueado', 'success'); + loadClinics(); + } else { + showToast(data.message, 'error'); + loadClinics(); // revert UI + } + } catch (error) { + showToast('Erro ao alterar status', 'error'); + loadClinics(); + } + }; + + // ========================================== + // Deletar + // ========================================== + window.deleteClinic = async (id) => { + if(!confirm('Tem certeza que deseja excluir este dispositivo permanentemente? O Desktop perderá acesso instantaneamente.')) return; + + try { + const response = await fetch(`${API_URL}/${id}`, { + method: 'DELETE', + headers: fetchOptions.headers + }); + const data = await response.json(); + if(data.success) { + showToast('Dispositivo removido', 'success'); + loadClinics(); + } else { + showToast(data.message, 'error'); + } + } catch (error) { + showToast('Erro ao excluir', 'error'); + } + }; + + // ========================================== + // UI Actions (Modals, Copy, Logout) + // ========================================== + addClinicBtn.addEventListener('click', () => clinicModal.classList.add('active')); + + closeModals.forEach(btn => btn.addEventListener('click', () => { + clinicModal.classList.remove('active'); + })); + + closeTokenModal.addEventListener('click', () => { + tokenModal.classList.remove('active'); + }); + + copyTokenBtn.addEventListener('click', () => { + navigator.clipboard.writeText(generatedTokenElem.textContent).then(() => { + showToast('Token copiado com sucesso!', 'success'); + copyTokenBtn.innerHTML = ''; + setTimeout(() => { + copyTokenBtn.innerHTML = ''; + }, 2000); + }); + }); + + document.getElementById('logoutBtn').addEventListener('click', () => { + localStorage.removeItem('token'); + window.location.href = '/login'; + }); + + function showToast(message, type = 'success') { + const toast = document.getElementById('toast'); + toast.textContent = message; + toast.className = `toast show ${type}`; + + setTimeout(() => { + toast.className = 'toast'; + }, 3000); + } +}); diff --git a/dental-server/public/public/app.js b/dental-server/public/public/app.js new file mode 100644 index 0000000..29f2876 --- /dev/null +++ b/dental-server/public/public/app.js @@ -0,0 +1,1755 @@ +// ================================================================ +// 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 = `⚠️ ${msg}${detail ? `
${detail}` : ''}`; + 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'); +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)); + + 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'); + }); + document.getElementById('closeTransformModal').addEventListener('click', () => { + transformModal.classList.remove('active'); + }); +} + +// ================================================================ +// 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() { + const current = clientFilter.value; + clientFilter.innerHTML = ''; + 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 +// ================================================================ + +function showPatientsView() { + view = 'patients'; + selectedPatient = null; + backBtn.style.display = 'none'; + patientHeader.style.display = 'none'; + 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 ` +
+
+ +
${count} imagem${count !== 1 ? 'ns' : ''}
+
+
+
${escapeHtml(fullName)}
+
📅 ${date}
+
+
🩺 Dentista: ${escapeHtml(doctor)}
+
📝 Obs: ${escapeHtml(remark)}
+
+
+
`; + }).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'; + patientHeader.style.display = 'block'; + + const fullName = patient.patient_name || 'Sem nome'; + 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 ` +
+
+
+
📅 ${formatDate(image.created_at)}
+
${escapeHtml(image.image_guid || image.filename)}
+
+ + +
+
+ ${!image.enabled ? '
Desabilitada
' : ''} +
`; + }).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 = ` +
👨‍⚕️ Dentista: ${escapeHtml(image.doctor || 'Não informado')}
+
📝 Obs: ${escapeHtml(image.remark || 'Nenhuma observação')}
+ `; + 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 = ` +
+
+

Gerando variações...

+
`; + + try { + const image = patientImages.find(img => img.id === imageId); + if (!image) { + throw new Error('Imagem não encontrada localmente'); + } + + // 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);' }, + { name: 'Rotação -90°', rotation: -90, flipH: false, flipV: false, css: 'transform: rotate(-90deg);' } + ]; + + currentTransformations = transformations; + + const container = document.getElementById('transformOptions'); + container.innerHTML = transformations.map((t, i) => ` +
+ ${escapeHtml(t.name)} +
${escapeHtml(t.name)}
+
+ `).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(); + + // 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'); + + // 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'); + 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) { + 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 = '
Carregando GTOs...
'; + + 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 = '
Nenhuma GTO cadastrada para este paciente.
'; + return; + } + + let html = ''; + container.innerHTML = html; + + } catch (e) { + console.error(e); + container.innerHTML = '
Erro ao carregar GTOs
'; + } +} + +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 = '
'; + 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 = ' 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 = '🟢 Status: 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 = '🟡 Status: 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 = ' 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 = '×'; + 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('token'); window.location.href = '/login.html'; } diff --git a/dental-server/public/public/clients.html b/dental-server/public/public/clients.html new file mode 100644 index 0000000..7934046 --- /dev/null +++ b/dental-server/public/public/clients.html @@ -0,0 +1,484 @@ + + + + + + Clientes Conectados - Dental Server + + + + +
+
+
+

🖥️ Clientes Conectados

+

Monitoramento em tempo real dos clientes Socket.IO

+
+
+ ← Voltar + + +
+
+ +
+ +
+ +
+
+
Carregando...
+
+
+
+
+ + + + + + diff --git a/dental-server/public/public/favicon.svg b/dental-server/public/public/favicon.svg new file mode 100644 index 0000000..62bde5a --- /dev/null +++ b/dental-server/public/public/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/dental-server/public/public/index.html b/dental-server/public/public/index.html new file mode 100644 index 0000000..d235c6c --- /dev/null +++ b/dental-server/public/public/index.html @@ -0,0 +1,480 @@ + + + + + + Gerenciador de Imagens Dentais + + + + + +
+ + + + +
+ +
+
+
+ +

Galeria de Imagens

+
+
+ + +
+
+ + + +
+ + +
+ + +
+
+

Carregando...

+
+ + + + + +
+ +
+
+
+ + + + + + + + + + + +
Operação realizada com sucesso!
+ + + + + + + + + + + + + + + diff --git a/dental-server/public/public/install.html b/dental-server/public/public/install.html new file mode 100644 index 0000000..85ae77d --- /dev/null +++ b/dental-server/public/public/install.html @@ -0,0 +1,441 @@ + + + + + + Instalação - Dental Image Server + + + +
+

🦷 Instalação do Sistema

+

Configure o banco de dados e crie o usuário administrador

+ +
+
+ Verificando status da instalação... + +
+
+ +
+ +
+
+
+ 1 + Configuração do Banco de Dados +
+
+ + +
+
+ + +
+
+ + +
O banco será criado automaticamente se não existir
+
+
+ + +
+
+ + +
+
+ +
+
+ 2 + Usuário Administrador +
+
+ + +
+
+ + +
+
+ + +
Mínimo de 8 caracteres
+
+
+ + +
+ +
+
+

Instalando... Por favor aguarde.

+
+ +
+
+

Instalação Concluída!

+

O sistema foi configurado com sucesso.

+ +
+
+ + + + + diff --git a/dental-server/public/public/reset.html b/dental-server/public/public/reset.html new file mode 100644 index 0000000..ba2e157 --- /dev/null +++ b/dental-server/public/public/reset.html @@ -0,0 +1,204 @@ + + + + + + Resetar Sistema - DentalSys + + + + + + + +
+
+

⚠️ Apagar Tudo

+

Preparação para Servidor de Produção

+
+ +
+ Atenção: Esta ação é irreversível. Ela irá apagar todos os pacientes, GTOs, histórico e todas as imagens (físicas e do banco). O seu usuário e senha de acesso serão mantidos. +
+ +
+
+ + +
+ + + +
+
+ +
Mensagem
+ + + + diff --git a/dental-server/public/public/style.css b/dental-server/public/public/style.css new file mode 100644 index 0000000..8d40961 --- /dev/null +++ b/dental-server/public/public/style.css @@ -0,0 +1,1311 @@ +/* ================================================================ + DENTAL IMAGE MANAGER - STYLES (Mobile-First) + ================================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +:root { + --primary-color: #667eea; + --primary-dark: #5568d3; + --secondary-color: #764ba2; + --success-color: #51cf66; + --danger-color: #ff6b6b; + --warning-color: #ffd43b; + --info-color: #339af0; + --dark-color: #1a202c; + --light-color: #f0f4ff; + --border-color: #e2e8f0; + --text-secondary: #718096; + --card-bg: #ffffff; + --shadow: 0 2px 8px rgba(102, 126, 234, 0.1); + --shadow-lg: 0 10px 30px rgba(102, 126, 234, 0.15); + --radius: 14px; + --radius-sm: 8px; + --header-h: 60px; + --bg-color: var(--light-color); + --text-color: var(--dark-color); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--light-color); + color: var(--dark-color); + line-height: 1.6; + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +/* ================================================================ + LAYOUT CONTAINER + ================================================================ */ + +.container { + width: 100%; + max-width: 1400px; + margin: 0 auto; + padding: 12px; +} + +@media (min-width: 768px) { + .container { + padding: 24px; + } +} + +/* ================================================================ + HEADER + ================================================================ */ + +.header { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + padding: 18px 16px; + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + margin-bottom: 16px; + position: sticky; + top: 8px; + z-index: 100; +} + +@media (min-width: 768px) { + .header { + padding: 28px 30px; + margin-bottom: 28px; + position: static; + } +} + +.header-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +@media (min-width: 768px) { + .header-content { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +.header-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.header h1 { + font-size: 1.3rem; + font-weight: 700; + letter-spacing: -0.3px; + white-space: nowrap; +} + +@media (min-width: 768px) { + .header h1 { + font-size: 1.8rem; + } +} + +/* Back button */ +.btn-back { + background: rgba(255,255,255,0.15); + color: white; + border: 1px solid rgba(255,255,255,0.3); + padding: 8px 14px; + font-size: 0.85rem; + border-radius: 50px; + white-space: nowrap; + flex-shrink: 0; +} + +.btn-back:hover { + background: rgba(255,255,255,0.28); + transform: none; + box-shadow: none; +} + +/* Patient sub-header */ +.patient-header { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid rgba(255,255,255,0.15); +} + +.patient-header-name { + font-size: 1.15rem; + font-weight: 700; + color: white; +} + +.patient-header-meta { + font-size: 0.82rem; + color: rgba(255,255,255,0.7); + margin-top: 2px; +} + +.header-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +@media (min-width: 768px) { + .header-actions { + display: flex; + flex-direction: row; + gap: 12px; + align-items: center; + } +} + +.client-filter { + grid-column: span 2; + padding: 10px 14px; + border: none; + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-family: inherit; + background: rgba(255,255,255,0.95); + color: var(--dark-color); + cursor: pointer; + width: 100%; + font-weight: 500; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +@media (min-width: 768px) { + .client-filter { + min-width: 220px; + width: auto; + grid-column: auto; + padding: 12px 18px; + font-size: 1rem; + } +} + +.client-filter:focus { + outline: 3px solid rgba(255,255,255,0.4); +} + +.search-input { + grid-column: span 2; + padding: 10px 14px; + border: none; + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-family: inherit; + background: rgba(255,255,255,0.95); + width: 100%; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +@media (min-width: 768px) { + .search-input { + flex: 1; + grid-column: auto; + min-width: 220px; + padding: 12px 18px; + font-size: 1rem; + } +} + +.search-input:focus { + outline: 3px solid rgba(255,255,255,0.4); +} + +/* ================================================================ + BUTTONS + ================================================================ */ + +.btn { + padding: 10px 16px; + border: none; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + touch-action: manipulation; +} + +@media (min-width: 768px) { + .btn { + padding: 12px 22px; + font-size: 0.95rem; + } +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +.btn:active { + transform: translateY(0); +} + +.btn-primary { + background: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background: var(--primary-dark); +} + +.btn-secondary { + background: rgba(255,255,255,0.2); + color: white; + backdrop-filter: blur(10px); + border: 1px solid rgba(255,255,255,0.3); +} + +.btn-secondary:hover { + background: rgba(255,255,255,0.3); +} + +.btn-success { + background: var(--success-color); + color: white; +} + +.btn-success:hover { + background: #43b558; +} + +.btn-danger { + background: var(--danger-color); + color: white; +} + +.btn-danger:hover { + background: #f03e3e; +} + +.btn-info { + background: var(--info-color); + color: white; + text-decoration: none; +} + +.btn-info:hover { + background: #228be6; +} + +.btn-block { + width: 100%; + margin-top: 10px; +} + +.btn-small { + padding: 7px 12px; + font-size: 0.8rem; +} + +/* ================================================================ + IMAGES GRID + ================================================================ */ + +.images-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 40px; +} + +@media (min-width: 480px) { + .images-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 14px; + } +} + +@media (min-width: 768px) { + .images-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; + } +} + +/* ================================================================ + IMAGE CARD + ================================================================ */ + +.image-card { + background: var(--card-bg); + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow); + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; + position: relative; + border: 1px solid var(--border-color); + display: flex; + flex-direction: column; +} + +.image-card:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-lg); +} + +.image-card:active { + transform: translateY(-1px); +} + +.image-card.disabled { + opacity: 0.65; + border: 2px dashed var(--warning-color); +} + +.image-preview { + width: 100%; + height: 160px; + min-height: 160px; + object-fit: cover; + background: linear-gradient(135deg, #e2e8f0, #cbd5e0); + flex-shrink: 0; +} + +@media (min-width: 480px) { + .image-preview { + height: 200px; + min-height: 200px; + } +} + +@media (min-width: 768px) { + .image-preview { + height: 240px; + min-height: 240px; + } +} + +.image-info { + padding: 12px; +} + +@media (min-width: 768px) { + .image-info { + padding: 15px; + } +} + +.image-patient-name { + font-weight: 700; + margin-bottom: 3px; + font-size: 0.95rem; + color: var(--dark-color); + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (min-width: 768px) { + .image-patient-name { + font-size: 1.05rem; + } +} + +.image-guid { + font-size: 0.72rem; + color: var(--text-secondary); + margin-bottom: 8px; + word-break: break-all; + line-height: 1.4; + /* Show only 2 lines */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.image-meta { + display: flex; + justify-content: space-between; + font-size: 0.78rem; + color: #888; + flex-wrap: wrap; + gap: 4px; +} + +.image-doctor-remark { + margin-top: 6px; + padding-top: 6px; + border-top: 1px dashed #edf2f7; + font-size: 0.75rem; + color: #4a5568; + line-height: 1.5; +} + +.image-doctor-remark div { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-actions { + display: flex; + gap: 6px; + margin-top: 10px; + flex-wrap: wrap; +} + +.image-actions .btn { + flex: 1; + min-width: 0; + padding: 7px 8px; + font-size: 0.75rem; +} + +@media (min-width: 480px) { + .image-actions .btn { + font-size: 0.8rem; + padding: 8px 10px; + } +} + +.image-badge { + position: absolute; + top: 8px; + right: 8px; + background: var(--danger-color); + color: white; + padding: 4px 8px; + border-radius: 20px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.3px; +} + +/* Patient count badge (in thumbnail overlay) */ +.patient-count-badge { + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(0,0,0,0.65); + color: white; + padding: 4px 10px; + border-radius: 20px; + font-size: 0.78rem; + font-weight: 600; + backdrop-filter: blur(4px); +} + +/* Patient card image-preview needs relative positioning */ +.patient-card .image-preview { + position: relative; +} + +/* Subtle scale on patient card click */ +.patient-card:active { + transform: scale(0.98); +} + +/* ================================================================ + LOADING & EMPTY STATES + ================================================================ */ + +.loading { + text-align: center; + padding: 60px 20px; +} + +.spinner { + border: 3px solid var(--border-color); + border-top: 3px solid var(--primary-color); + border-radius: 50%; + width: 44px; + height: 44px; + animation: spin 0.8s linear infinite; + margin: 0 auto 20px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.empty-state { + text-align: center; + padding: 60px 20px; +} + +.empty-icon { + font-size: 3.5rem; + margin-bottom: 16px; +} + +/* ================================================================ + MODAL + ================================================================ */ + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.65); + backdrop-filter: blur(4px); + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.modal.active { + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.modal-content { + background: white; + border-radius: 0; + width: 100vw; + height: 100vh; + max-height: 100vh; + max-width: 100vw; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + box-shadow: none; + animation: slideUp 0.3s ease; + display: flex; + flex-direction: column; +} + +.modal-large { + width: 90vw; + height: 98vh; + max-width: 90vw; + max-height: 98vh; + border-radius: 16px; + box-shadow: 0 24px 80px rgba(0,0,0,0.45); + animation: modalAppear 0.25s ease; +} + +@keyframes modalAppear { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-30px); } + to { opacity: 1; transform: translateY(0); } +} + +.modal-header { + padding: 18px 20px; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: white; + z-index: 1; +} + +.modal-header h2 { + font-size: 1.1rem; + color: var(--dark-color); + font-weight: 700; +} + +@media (min-width: 600px) { + .modal-header { + padding: 24px 28px; + } + .modal-header h2 { + font-size: 1.4rem; + } +} + +.modal-close { + font-size: 1.8rem; + cursor: pointer; + color: #888; + transition: color 0.2s; + line-height: 1; + padding: 4px; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.modal-close:hover { + color: var(--danger-color); + background: #fff0f0; +} + +.modal-body { + padding: 20px; + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +@media (min-width: 600px) { + .modal-body { + padding: 28px; + } +} + +/* ================================================================ + TRANSFORM GRID — Adaptativo por orientação da imagem + ================================================================ + + HORIZONTAL (original paisagem): + - Original → coluna 1, 2 linhas + - Flip H + Flip V → coluna 2, empilhados (um sobre o outro) + - +90° e -90° → colunas 3 e 4, lado a lado + + VERTICAL (original retrato): + - Original → coluna 1, 2 linhas + - Flip H + Flip V → colunas 2 e 3, lado a lado + - +90° e -90° → coluna 4, empilhados (um sobre o outro) + ================================================================ */ + +.transform-grid { + display: grid; + gap: 12px; + height: 100%; + align-items: stretch; +} + +/* ---- HORIZONTAL (paisagem) ---- */ +.transform-grid.grid-landscape { + grid-template-columns: 2fr 1.3fr 1fr 1fr; + grid-template-rows: 1fr 1fr; +} +/* Original: col 1, 2 linhas */ +.transform-grid.grid-landscape .transform-option:nth-child(1) { grid-column: 1; grid-row: 1 / span 2; } +/* Flip H: col 2, linha 1 */ +.transform-grid.grid-landscape .transform-option:nth-child(2) { grid-column: 2; grid-row: 1; } +/* Flip V: col 2, linha 2 (empilhado sob Flip H) */ +.transform-grid.grid-landscape .transform-option:nth-child(3) { grid-column: 2; grid-row: 2; } +/* +90°: col 3, 2 linhas (lado a lado com -90°) */ +.transform-grid.grid-landscape .transform-option:nth-child(4) { grid-column: 3; grid-row: 1 / span 2; } +/* -90°: col 4, 2 linhas (lado a lado com +90°) */ +.transform-grid.grid-landscape .transform-option:nth-child(5) { grid-column: 4; grid-row: 1 / span 2; } + +/* ---- VERTICAL (retrato) ---- */ +.transform-grid.grid-portrait { + grid-template-columns: 1fr 1fr 1fr 1.6fr; + grid-template-rows: 1fr 1fr; +} +/* Original: col 1, 2 linhas */ +.transform-grid.grid-portrait .transform-option:nth-child(1) { grid-column: 1; grid-row: 1 / span 2; } +/* Flip H: col 2, 2 linhas (lado a lado com Flip V) */ +.transform-grid.grid-portrait .transform-option:nth-child(2) { grid-column: 2; grid-row: 1 / span 2; } +/* Flip V: col 3, 2 linhas (lado a lado com Flip H) */ +.transform-grid.grid-portrait .transform-option:nth-child(3) { grid-column: 3; grid-row: 1 / span 2; } +/* +90°: col 4, linha 1 (empilhado sobre -90°) */ +.transform-grid.grid-portrait .transform-option:nth-child(4) { + grid-column: 4; + grid-row: 1; +} + +/* Modo de Seleção Ativa (Exibe apenas 2 imagens) */ +.selection-mode { + display: flex !important; + justify-content: center; + gap: 30px; + align-items: center; + flex-wrap: wrap; +} +.selection-mode .transform-option { + width: 45%; + max-width: 450px; + grid-column: auto !important; + grid-row: auto !important; +} +.selection-mode .transform-option img { + height: 35vh !important; + max-height: 400px; + object-fit: contain; +} +/* -90°: col 4, linha 2 (empilhado sob +90°) */ +.transform-grid.grid-portrait .transform-option:nth-child(5) { grid-column: 4; grid-row: 2; } + +/* ---- Fallback (sem classe de orientação ainda) ---- */ +.transform-grid:not(.grid-landscape):not(.grid-portrait) { + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr; +} +.transform-grid:not(.grid-landscape):not(.grid-portrait) .transform-option:nth-child(1) { grid-column: 1; grid-row: 1 / span 2; } +.transform-grid:not(.grid-landscape):not(.grid-portrait) .transform-option:nth-child(2) { grid-column: 2; grid-row: 1 / span 2; } +.transform-grid:not(.grid-landscape):not(.grid-portrait) .transform-option:nth-child(3) { grid-column: 3; grid-row: 1 / span 2; } +.transform-grid:not(.grid-landscape):not(.grid-portrait) .transform-option:nth-child(4) { grid-column: 4; grid-row: 1; } +.transform-grid:not(.grid-landscape):not(.grid-portrait) .transform-option:nth-child(5) { grid-column: 4; grid-row: 2; } + +.transform-option { + border: 2px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 10px; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 0; + overflow: hidden; +} + +.transform-option:hover { + border-color: var(--primary-color); + transform: scale(1.02); +} + +.transform-option.selected { + border-color: var(--success-color); + background: rgba(81, 207, 102, 0.08); +} + +.transform-option img { + max-width: 100%; + flex: 1; + min-height: 0; + object-fit: contain; + border-radius: 4px; + margin-bottom: 10px; +} + +.transform-name { + font-size: 0.9rem; + font-weight: 600; + color: var(--dark-color); + flex-shrink: 0; +} + + + +/* ================================================================ + FORMS + ================================================================ */ + +.form-group { + margin-bottom: 18px; +} + +.form-group label { + display: block; + margin-bottom: 7px; + font-weight: 600; + font-size: 0.9rem; + color: var(--dark-color); +} + +.form-group input, +.form-group select { + width: 100%; + padding: 12px 14px; + border: 2px solid var(--border-color); + border-radius: var(--radius-sm); + font-size: 1rem; + font-family: inherit; + transition: border-color 0.2s; + background: white; + -webkit-appearance: none; + appearance: none; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15); +} + +.form-group small { + display: block; + margin-top: 5px; + color: var(--text-secondary); + font-size: 0.8rem; +} + +.form-actions { + margin-top: 20px; +} + +/* ================================================================ + TOAST NOTIFICATION + ================================================================ */ + +.toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%) translateY(120px); + padding: 13px 22px; + border-radius: 50px; + color: white; + font-weight: 600; + font-size: 0.9rem; + box-shadow: 0 8px 24px rgba(0,0,0,0.2); + opacity: 0; + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + z-index: 2000; + white-space: nowrap; + max-width: calc(100vw - 32px); + text-align: center; +} + +@media (min-width: 600px) { + .toast { + bottom: 30px; + right: 30px; + left: auto; + transform: translateX(0) translateY(120px); + border-radius: 10px; + } +} + +.toast.show { + transform: translateX(-50%) translateY(0); + opacity: 1; +} + +@media (min-width: 600px) { + .toast.show { + transform: translateX(0) translateY(0); + } +} + +.toast.success { background: var(--success-color); } +.toast.error { background: var(--danger-color); } +.toast.info { background: var(--primary-color); } + +/* ================================================================ + SIDEBAR LAYOUT + ================================================================ */ + +.app-container { + display: flex; + height: 100vh; + width: 100vw; + overflow: hidden; + background-color: var(--bg-color); +} + +.sidebar { + width: 250px; + background: #fff; + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + flex-shrink: 0; + transition: all 0.3s ease; + z-index: 10; +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid rgba(255,255,255,0.15); + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.sidebar-header h2 { + margin: 0; + font-size: 1.2rem; + color: #fff; + display: flex; + align-items: center; + gap: 8px; +} + +.sidebar-nav { + flex: 1; + padding: 12px 0; + overflow-y: auto; +} + +.nav-item { + display: flex; + align-items: center; + padding: 12px 20px; + color: var(--text-color); + text-decoration: none; + font-weight: 500; + transition: all 0.2s; + gap: 10px; + border-left: 3px solid transparent; +} + +.nav-item .icon { + font-size: 1.1rem; + flex-shrink: 0; +} + +.nav-item:hover { + background-color: rgba(102, 126, 234, 0.08); + color: var(--primary-color); + border-left-color: var(--primary-color); +} + +.nav-item.active { + background-color: rgba(102, 126, 234, 0.12); + color: var(--primary-color); + border-left-color: var(--primary-color); + font-weight: 600; +} + +.nav-divider { + height: 1px; + background: var(--border-color); + margin: 10px 0; +} + +/* --------------------------------------------------------------- + MAIN CONTENT — header fixo + área interna rolável + --------------------------------------------------------------- */ +.main-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.main-content .header { + border-radius: 0; + margin: 0; + border-bottom: 1px solid rgba(255,255,255,0.2); + box-shadow: none; + flex-shrink: 0; +} + +/* Wrapper rolável que envolve loading/empty/grid */ +.content-scroll { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + background: var(--bg-color); +} + +/* Compatibilidade */ +.app-container .container { display: none; } + +/* Header padding */ +.header-content { padding: 14px 20px; } + +/* --------------------------------------------------------------- + IMAGES GRID — grid puro sem flex tricks + --------------------------------------------------------------- */ +.app-container #imagesGrid { + display: grid !important; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 20px; + padding: 24px; + align-content: start; +} + +@media (min-width: 1400px) { + .app-container #imagesGrid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + } +} + +@media (max-width: 900px) { + .app-container #imagesGrid { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 14px; + padding: 16px; + } +} + +/* Image preview — altura fixa garantida */ +.app-container .image-preview { + height: 200px !important; + min-height: 200px !important; + flex-shrink: 0 !important; + background-size: cover !important; + background-position: center !important; +} + +/* --------------------------------------------------------------- + RESPONSIVE SIDEBAR + --------------------------------------------------------------- */ +@media (max-width: 768px) { + .sidebar { width: 60px; } + .sidebar-header { padding: 14px 10px; } + .sidebar-header h2 span:not(.icon) { display: none; } + .nav-item span:not(.icon) { display: none; } + .nav-item { justify-content: center; padding: 14px 0; gap: 0; } + .nav-item .icon { margin-right: 0; font-size: 1.3rem; } + #toggleDisabledBtn { font-size: 0; padding: 10px; } + #toggleDisabledBtn::before { content: '👁'; font-size: 1.1rem; } +} + +/* ================================================================ + SYNC & UPLOAD MODAL STYLES + ================================================================ */ + +.sync-tabs { + display: flex; + border-bottom: 2px solid var(--border-color); + margin-bottom: 20px; + gap: 10px; +} + +.sync-tab-btn { + padding: 12px 20px; + background: none; + border: none; + border-bottom: 3px solid transparent; + font-size: 1rem; + font-weight: 600; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.25s ease; +} + +.sync-tab-btn:hover { + color: var(--primary-color); +} + +.sync-tab-btn.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); +} + +.sync-tab-content { + display: none; + animation: slideDown 0.25s ease; +} + +.sync-tab-content.active { + display: block; +} + +/* Área de Drag & Drop */ +.drag-drop-zone { + border: 3px dashed var(--border-color); + border-radius: var(--radius); + padding: 40px 20px; + text-align: center; + background: #fdfdfd; + cursor: pointer; + transition: all 0.2s ease; + margin-bottom: 20px; +} + +.drag-drop-zone:hover, +.drag-drop-zone.drag-active { + border-color: var(--primary-color); + background: rgba(102, 126, 234, 0.04); +} + +.drag-drop-icon { + font-size: 3rem; + margin-bottom: 12px; +} + +.drag-drop-text { + font-size: 1rem; + color: var(--text-color); + font-weight: 500; + margin-bottom: 6px; +} + +.drag-drop-subtext { + font-size: 0.82rem; + color: var(--text-secondary); +} + +/* Preview de arquivos selecionados */ +.upload-previews { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 10px; + margin-bottom: 20px; +} + +.preview-item { + position: relative; + aspect-ratio: 1; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border-color); +} + +.preview-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.preview-remove { + position: absolute; + top: 4px; + right: 4px; + background: rgba(220, 53, 69, 0.85); + color: white; + border: none; + border-radius: 50%; + width: 18px; + height: 18px; + font-size: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.preview-remove:hover { + background: #dc3545; +} + +/* Barra de Progresso */ +.upload-progress-container { + background: #f8f9fa; + border-radius: 8px; + padding: 15px; + margin-bottom: 20px; + border: 1px solid var(--border-color); +} + +.progress-bar-label { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 8px; + color: var(--dark-color); +} + +.progress-bar-track { + background: #e2e8f0; + border-radius: 10px; + height: 8px; + overflow: hidden; +} + +.progress-bar-fill { + background: linear-gradient(90deg, var(--primary-color) 0%, #764ba2 100%); + height: 100%; + width: 0%; + transition: width 0.2s ease; +} + +/* Lista de dispositivos online */ +.sync-device-list { + display: flex; + flex-direction: column; + gap: 12px; + margin: 20px 0; +} + +.sync-device-card { + background: #f8f9fa; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 15px; + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.2s ease; +} + +.sync-device-card:hover { + border-color: var(--primary-color); + background: #fff; + box-shadow: 0 4px 12px rgba(0,0,0,0.03); +} + +.device-card-left { + display: flex; + flex-direction: column; + gap: 4px; +} + +.device-card-title { + font-weight: 700; + font-size: 0.95rem; + color: var(--dark-color); +} + +.device-card-subtitle { + font-size: 0.78rem; + color: var(--text-secondary); +} + +.device-card-badge { + padding: 5px 12px; + border-radius: 20px; + font-size: 0.72rem; + font-weight: 700; +} + +.badge-connected { + background: rgba(81, 207, 102, 0.15); + color: var(--success-color); +} + +.badge-disconnected { + background: rgba(220, 53, 69, 0.1); + color: var(--danger-color); +} + +/* Botão de Excluir Paciente nos cards iniciais */ +.delete-patient-btn { + position: absolute; + top: 10px; + right: 10px; + background: rgba(220, 53, 69, 0.85); + color: white; + border: none; + border-radius: 50%; + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 5; + transition: all 0.2s ease; + font-size: 1.05rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +.delete-patient-btn:hover { + background: #dc3545; + transform: scale(1.1); +} diff --git a/deploy-prod.sh b/deploy-prod.sh new file mode 100755 index 0000000..2c8fe67 --- /dev/null +++ b/deploy-prod.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +PROD_VPS="10.99.0.1" +PROD_USER="deploy" +PROD_PATH="/home/deploy/stack/rx.scoreodonto.com" +LOG_TAG="[rx.scoreodonto.com]" + +echo "========================================================" +echo "$LOG_TAG INICIANDO DEPLOY PARA PRODUCAO (VPS 1)" +echo "========================================================" + +echo "$LOG_TAG Puxando atualizacoes do Gitea na VPS 1..." +ssh -o BatchMode=yes -o StrictHostKeyChecking=no ${PROD_USER}@${PROD_VPS} " + set -e + cd ${PROD_PATH} + git pull origin main + + export DOCKER_HOST=unix:///run/user/1000/docker.sock + + echo '${LOG_TAG} Recriando imagem com novos arquivos...' + docker compose build app + + echo '${LOG_TAG} Subindo novo container sem derrubar o atual...' + docker compose up -d --no-deps app + + echo '${LOG_TAG} Container atualizado com sucesso!' +" + +echo "========================================================" +echo "$LOG_TAG DEPLOY CONCLUIDO SEM DOWNTIME!" +echo "========================================================" diff --git a/query.sql b/query.sql new file mode 100644 index 0000000..fe39b41 --- /dev/null +++ b/query.sql @@ -0,0 +1 @@ +SELECT patient_name, MAX(doctor) AS doctor, MAX(remark) AS remark, MAX(client_name) AS client_name, COUNT(*) AS image_count, MAX(created_at) AS last_date, (SELECT filename FROM images i2 WHERE i2.patient_name = images.patient_name AND i2.enabled = 1 ORDER BY i2.created_at DESC LIMIT 1) AS thumb_filename FROM images WHERE enabled = 1 GROUP BY patient_name ORDER BY MAX(created_at) DESC LIMIT 200