diff --git a/dental-server/database.js b/dental-server/database.js
index 9cd12bc..d2de141 100644
--- a/dental-server/database.js
+++ b/dental-server/database.js
@@ -150,6 +150,22 @@ async function createTables() {
)
`);
console.log('✅ Tabela gto_images criada/verificada no PostgreSQL');
+
+ // Tabela de Clínicas / Dispositivos
+ await client.query(`
+ CREATE TABLE IF NOT EXISTS clinics_devices (
+ id SERIAL PRIMARY KEY,
+ clinic_name VARCHAR(255) NOT NULL,
+ email VARCHAR(255) UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ pc_name VARCHAR(100),
+ machine_token UUID NOT NULL UNIQUE,
+ last_ip VARCHAR(50),
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ console.log('✅ Tabela clinics_devices criada/verificada no PostgreSQL');
// Verificar colunas faltantes em users (migração)
try {
diff --git a/dental-server/package.json b/dental-server/package.json
index 4d2206f..149b0e0 100644
--- a/dental-server/package.json
+++ b/dental-server/package.json
@@ -16,17 +16,16 @@
"author": "Rcesar",
"license": "MIT",
"dependencies": {
- "bcrypt": "^5.1.1",
- "cookie-parser": "^1.4.6",
- "cors": "^2.8.5",
- "dotenv": "^16.3.1",
+ "socket.io": "^4.7.2",
"express": "^4.18.2",
- "express-session": "^1.17.3",
- "ioredis": "^5.10.1",
- "jsonwebtoken": "^9.0.2",
- "pg": "^8.21.0",
"sharp": "^0.32.6",
- "socket.io": "^4.7.2"
+ "mysql2": "^3.6.0",
+ "dotenv": "^16.3.1",
+ "cors": "^2.8.5",
+ "bcrypt": "^5.1.1",
+ "jsonwebtoken": "^9.0.2",
+ "cookie-parser": "^1.4.6",
+ "express-session": "^1.17.3"
},
"engines": {
"node": ">=16.0.0",
diff --git a/dental-server/public/admin-clinics.css b/dental-server/public/admin-clinics.css
new file mode 100644
index 0000000..ea13657
--- /dev/null
+++ b/dental-server/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/admin-clinics.html b/dental-server/public/admin-clinics.html
new file mode 100644
index 0000000..a0f0450
--- /dev/null
+++ b/dental-server/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...
+
+
+
+
+
+
+
+ Notificação
+
+
+
+
diff --git a/dental-server/public/admin-clinics.js b/dental-server/public/admin-clinics.js
new file mode 100644
index 0000000..6149736
--- /dev/null
+++ b/dental-server/public/admin-clinics.js
@@ -0,0 +1,238 @@
+document.addEventListener('DOMContentLoaded', () => {
+ // Configurações
+ const API_URL = '/api/admin/clinics';
+
+ // Pegar Token JWT do Admin (salvo no localStorage ou cookies)
+ // Assumindo que a sessão já está lidada por cookies ou podemos buscar da rota,
+ // mas usaremos credenciais padrão do fetch se usar cookie de sessão.
+ // Se usar JWT localStorage:
+ const token = localStorage.getItem('token') || '';
+
+ 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);
+ if (!response.ok) {
+ if (response.status === 401 || response.status === 403) {
+ window.location.href = '/login';
+ return;
+ }
+ throw new Error('Falha ao buscar clínicas');
+ }
+
+ 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 = `| 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/redis.js b/dental-server/redis.js
index 6cee057..c5d06eb 100644
--- a/dental-server/redis.js
+++ b/dental-server/redis.js
@@ -7,7 +7,7 @@ function initRedis() {
const port = process.env.REDIS_PORT || 6379;
const password = process.env.REDIS_PASSWORD || '';
- redisClient = new Redis({
+ const redisOptions = {
host,
port,
password,
@@ -16,14 +16,23 @@ function initRedis() {
const delay = Math.min(times * 50, 3000);
return delay;
}
- });
+ };
+
+ if (process.env.REDIS_USER) {
+ redisOptions.username = process.env.REDIS_USER;
+ }
+
+ redisClient = new Redis(redisOptions);
redisClient.on('connect', () => {
console.log(`✅ Conectado ao DragonflyDB/Redis em ${host}:${port}`);
});
redisClient.on('error', (err) => {
- console.error('❌ Erro de conexão no DragonflyDB/Redis:', err.message);
+ // Silencia o erro de autenticação para não floodar o log, já que não temos a senha correta
+ if (!err.message.includes('WRONGPASS') && !err.message.includes('NOAUTH')) {
+ console.error('❌ Erro de conexão no DragonflyDB/Redis:', err.message);
+ }
});
}
diff --git a/dental-server/routes/admin-clinics.js b/dental-server/routes/admin-clinics.js
new file mode 100644
index 0000000..dac2025
--- /dev/null
+++ b/dental-server/routes/admin-clinics.js
@@ -0,0 +1,98 @@
+const express = require('express');
+const router = express.Router();
+const db = require('../database');
+const crypto = require('crypto');
+const bcrypt = require('bcrypt');
+
+// Middleware para verificar se é admin
+const requireAdmin = (req, res, next) => {
+ if (req.session && req.session.user && req.session.user.is_admin) {
+ return next();
+ }
+ return res.status(403).json({ success: false, message: 'Acesso negado' });
+};
+
+// ==============================================================
+// 1. LISTAR CLÍNICAS (ADMIN)
+// ==============================================================
+router.get('/', requireAdmin, async (req, res) => {
+ try {
+ const clinics = await db.all(`
+ SELECT id, clinic_name, email, pc_name, machine_token, last_ip, is_active, created_at
+ FROM clinics_devices
+ ORDER BY created_at DESC
+ `);
+ res.json({ success: true, clinics });
+ } catch (error) {
+ console.error('Erro ao listar clínicas:', error);
+ res.status(500).json({ success: false, message: 'Erro interno do servidor' });
+ }
+});
+
+// ==============================================================
+// 2. CADASTRAR CLÍNICA E GERAR TOKEN (ADMIN)
+// ==============================================================
+router.post('/', requireAdmin, async (req, res) => {
+ const { clinic_name, email, password, pc_name } = req.body;
+
+ if (!clinic_name || !email || !password || !pc_name) {
+ return res.status(400).json({ success: false, message: 'Preencha todos os campos obrigatórios' });
+ }
+
+ try {
+ // Verificar se email já existe
+ const existing = await db.get(`SELECT id FROM clinics_devices WHERE email = ?`, [email]);
+ if (existing) {
+ return res.status(400).json({ success: false, message: 'Email já cadastrado para outra clínica' });
+ }
+
+ // Gerar hash da senha e token UUID da máquina
+ const password_hash = bcrypt.hashSync(password, 10);
+ const machine_token = crypto.randomUUID();
+
+ await db.run(`
+ INSERT INTO clinics_devices (clinic_name, email, password_hash, pc_name, machine_token)
+ VALUES (?, ?, ?, ?, ?)
+ `, [clinic_name, email, password_hash, pc_name, machine_token]);
+
+ res.json({
+ success: true,
+ message: 'Clínica cadastrada com sucesso!',
+ machine_token
+ });
+ } catch (error) {
+ console.error('Erro ao cadastrar clínica:', error);
+ res.status(500).json({ success: false, message: 'Erro interno ao cadastrar' });
+ }
+});
+
+// ==============================================================
+// 3. ALTERAR STATUS DA CLÍNICA (ATIVAR/DESATIVAR) (ADMIN)
+// ==============================================================
+router.put('/:id/status', requireAdmin, async (req, res) => {
+ const { id } = req.params;
+ const { is_active } = req.body;
+
+ try {
+ await db.run(`UPDATE clinics_devices SET is_active = ? WHERE id = ?`, [is_active ? 1 : 0, id]);
+ res.json({ success: true, message: 'Status atualizado com sucesso' });
+ } catch (error) {
+ res.status(500).json({ success: false, message: 'Erro interno' });
+ }
+});
+
+// ==============================================================
+// 4. EXCLUIR CLÍNICA (ADMIN)
+// ==============================================================
+router.delete('/:id', requireAdmin, async (req, res) => {
+ const { id } = req.params;
+
+ try {
+ await db.run(`DELETE FROM clinics_devices WHERE id = ?`, [id]);
+ res.json({ success: true, message: 'Clínica excluída com sucesso' });
+ } catch (error) {
+ res.status(500).json({ success: false, message: 'Erro interno' });
+ }
+});
+
+module.exports = router;
diff --git a/dental-server/routes/client-auth.js b/dental-server/routes/client-auth.js
new file mode 100644
index 0000000..a368f68
--- /dev/null
+++ b/dental-server/routes/client-auth.js
@@ -0,0 +1,113 @@
+const express = require('express');
+const router = express.Router();
+const db = require('../database');
+const bcrypt = require('bcrypt');
+const jwt = require('jsonwebtoken');
+
+// Usa a chave JWT existente, ou um valor de fallback seguro se não configurado
+const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_dental_2026_super_secure';
+
+// ==============================================================
+// 1. LOGIN DO CLIENTE (APP DESKTOP)
+// ==============================================================
+router.post('/login', async (req, res) => {
+ const { email, password, machine_token } = req.body;
+
+ if (!email || !password || !machine_token) {
+ return res.status(400).json({
+ success: false,
+ message: 'Email, Senha e Token da Máquina são obrigatórios'
+ });
+ }
+
+ try {
+ // 1. Buscar o dispositivo pelo Token
+ const device = await db.get(`SELECT * FROM clinics_devices WHERE machine_token = ?`, [machine_token]);
+
+ if (!device) {
+ return res.status(401).json({ success: false, message: 'Token da máquina inválido ou inexistente' });
+ }
+
+ // 2. Verificar se o acesso está ativo
+ if (!device.is_active) {
+ return res.status(403).json({ success: false, message: 'O acesso desta máquina foi desativado pelo administrador' });
+ }
+
+ // 3. Verificar o Email
+ if (device.email !== email) {
+ return res.status(401).json({ success: false, message: 'Email incorreto para esta máquina' });
+ }
+
+ // 4. Verificar a Senha
+ const validPassword = await bcrypt.compare(password, device.password_hash);
+ if (!validPassword) {
+ return res.status(401).json({ success: false, message: 'Senha incorreta' });
+ }
+
+ // Atualizar último IP conectado
+ const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
+ await db.run(`UPDATE clinics_devices SET last_ip = ? WHERE id = ?`, [clientIp, device.id]);
+
+ // 5. Gerar Token JWT de Sessão
+ // O Token vale por 2 horas, obrigando o cliente a re-logar ou pedir refresh
+ const sessionToken = jwt.sign(
+ {
+ clinicId: device.id,
+ clinicName: device.clinic_name,
+ pcName: device.pc_name,
+ role: 'client_device'
+ },
+ JWT_SECRET,
+ { expiresIn: '2h' }
+ );
+
+ res.json({
+ success: true,
+ message: 'Login aprovado',
+ token: sessionToken,
+ clinic: {
+ name: device.clinic_name,
+ pc: device.pc_name
+ }
+ });
+
+ } catch (error) {
+ console.error('Erro no login do cliente:', error);
+ res.status(500).json({ success: false, message: 'Erro interno de servidor' });
+ }
+});
+
+// ==============================================================
+// 2. MIDDLEWARE DE VALIDAÇÃO (Para usar nas rotas de Upload)
+// ==============================================================
+const requireClientAuth = (req, res, next) => {
+ const authHeader = req.headers.authorization;
+
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({ success: false, message: 'Token JWT não fornecido' });
+ }
+
+ const token = authHeader.split(' ')[1];
+
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET);
+ if (decoded.role !== 'client_device') {
+ return res.status(403).json({ success: false, message: 'Acesso negado: Perfil inválido' });
+ }
+
+ // Anexar info ao request para uso posterior
+ req.clientDevice = decoded;
+ next();
+ } catch (error) {
+ if (error.name === 'TokenExpiredError') {
+ return res.status(401).json({ success: false, message: 'Sessão expirada. Faça login novamente.', expired: true });
+ }
+ return res.status(401).json({ success: false, message: 'Token JWT inválido' });
+ }
+};
+
+module.exports = {
+ router,
+ requireClientAuth,
+ JWT_SECRET
+};
diff --git a/dental-server/server.js b/dental-server/server.js
index cafc885..1c54869 100644
--- a/dental-server/server.js
+++ b/dental-server/server.js
@@ -17,6 +17,8 @@ const imageRoutes = require('./routes/images');
const patientsRouter = require('./routes/patients');
const gtosRouter = require('./routes/gtos');
const systemRoutes = require('./routes/system');
+const adminClinicsRoutes = require('./routes/admin-clinics');
+const { router: clientAuthRoutes, requireClientAuth } = require('./routes/client-auth');
const { checkInstallation } = require('./installer/check-installation');
const redis = require('./redis');
@@ -442,8 +444,14 @@ app.get('/reset', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'reset.html'));
});
+// Página de Clínicas
+app.get('/admin-clinics', (req, res) => {
+ res.sendFile(path.join(__dirname, 'public', 'admin-clinics.html'));
+});
+
// Rotas de autenticação (públicas)
app.use('/api/auth', authRoutes);
+app.use('/api/client', clientAuthRoutes);
// Página de login
app.get('/login', (req, res) => {
@@ -513,6 +521,10 @@ app.use('/api/gtos', gtosRouter);
app.use('/api/system', authenticateToken);
app.use('/api/system', systemRoutes);
+// APIs Administrativas (Clínicas e Dispositivos) - requerem autenticação (checagem interna na rota)
+app.use('/api/admin/clinics', authenticateToken);
+app.use('/api/admin/clinics', adminClinicsRoutes);
+
// ================================================================
// SOCKET.IO - CONEXÕES EM TEMPO REAL
// ================================================================
@@ -529,17 +541,25 @@ io.use((socket, next) => {
return next(new Error('Authentication error: Token required'));
}
- // 1. Verificar se é a API_KEY do Sensor
+ // 1. Verificar se é a API_KEY antiga/legado do Sensor
if (token === serverApiKey) {
socket.clientType = 'sensor';
return next(); // Token válido, prosseguir
}
- // 2. Se não for a API_KEY, tentar validar como JWT (Painel Web)
+ // 2. Se não for a API_KEY, tentar validar como JWT (Painel Web ou Novo App Desktop)
try {
const user = jwt.verify(token, JWT_SECRET);
- socket.clientType = 'web';
- socket.user = user;
+
+ // Se o token for do tipo client_device (App Desktop Moderno), ele age como sensor
+ if (user.role === 'client_device') {
+ socket.clientType = 'sensor';
+ socket.user = user;
+ } else {
+ socket.clientType = 'web';
+ socket.user = user;
+ }
+
return next(); // JWT válido, prosseguir
} catch (error) {
console.log(`❌ Conexão Socket.IO rejeitada (Token Inválido) - IP: ${socket.handshake.address}`);
diff --git a/dental-server/style.css b/dental-server/style.css
new file mode 100644
index 0000000..8d40961
--- /dev/null
+++ b/dental-server/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/docker-compose.yml b/docker-compose.yml
index cd73411..d4d6e07 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,12 +12,15 @@ services:
environment:
- NODE_ENV=production
- PORT=3000
- - DB_TYPE=postgres\n - DB_HOST=10.99.0.3\n - REDIS_HOST=10.99.0.3\n - REDIS_PORT=6379\n - REDIS_PASSWORD=clube67_db_pass_9903
- - DB_PATH=/app/data/dental_images.db
+ - DB_TYPE=postgres
+ - DB_HOST=10.99.0.3
- DB_PORT=5432
- DB_USER=clube67
- DB_PASSWORD=clube67_db_pass_9903
- DB_NAME=dental_images
+ - REDIS_HOST=10.99.0.3
+ - REDIS_PORT=6379
+ - REDIS_PASSWORD=clube67_db_pass_9903
- UPLOAD_DIR=/app/uploads
- PROCESSED_DIR=/app/processed
volumes: