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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Total de Dispositivos
+
0
+
+
+
+
+
+
+
+
+
+
+
+ Status
+ Clínica
+ Computador
+ Email de Acesso
+ Último IP
+ Token de Autenticação
+ Ações
+
+
+
+
+
+
+
+ Carregando dispositivos...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dispositivo Cadastrado!
+
Copie o token abaixo e cole-o no Aplicativo Desktop junto com o email e senha para liberar o envio de raios-x.
+
+
+ 8f8a3b20-1d8c-4b3f-9e7b...
+
+
+
+
+
+
+ Entendido
+
+
+
+
+ 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 = '📋 Todos os Clientes ';
+ 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}
+
+
+
`;
+ }).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)}
+
+
+ 🔄 Orientar
+
+
+ ${image.enabled ? '🚫' : '✅'}
+
+
+
+ ${!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) => `
+
+ `).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 = '';
+ gtos.forEach((gto, idx) => {
+ const bg = idx % 2 === 0 ? '#fff' : '#f9f9f9';
+ html += `
+
+
+
${escapeHtml(gto.gto_number)}
+
${escapeHtml(gto.description || 'Sem descrição')}
+
📅 ${formatDate(gto.created_at)}
+
+
+ ✅ Vincular
+
+
+ `;
+ });
+ 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
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📭
+
Nenhum resultado encontrado
+
As imagens aparecerão aqui quando forem enviadas pelo cliente Windows
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Operação realizada com sucesso!
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 💻 Dispositivos Conectados
+ 📤 Envio Manual
+
+
+
+
+
+ Os computadores clientes com Windows instalados e ativos enviam imagens em tempo real. Você pode solicitar que eles façam uma sincronização manual forçada com o servidor enviando um sinal.
+
+
+
+
+
Buscando dispositivos online...
+
+
+
+
📭
+
Nenhum dispositivo Windows conectado no momento.
+
+
+
+
+
+
+
+ Fechar
+
+ 🔄 Enviar Sinal de Sincronização
+
+
+
+
+
+
+
+
+
+
+
+
Dados do Novo Paciente
+
+
+ Dentista Responsável
+
+
+
+
+
+ Observações / Detalhes (Opcional)
+
+
+
+
+
+
📸
+
Arraste as imagens aqui ou clique para selecionar
+
Suporta arquivos JPG, JPEG e PNG (máx. 10MB por imagem)
+
+
+
+
+
+
+
+
+
+ Enviando imagens...
+ 0%
+
+
+
+
+
+ Cancelar
+ Enviar Imagens
+
+
+
+
+
+
+
+
+
+
+
+
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...
+
+
+
+
+
+
+
+
+
+
+
Instalando... Por favor aguarde.
+
+
+
+
✅
+
Instalação Concluída!
+
O sistema foi configurado com sucesso.
+
+ Ir para Login
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+ Confirme sua senha atual:
+
+
+
+
+ DELETAR TODOS OS DADOS
+
+ Cancelar e voltar
+
+
+
+ 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