Restore RX modal UI and sidebar dropdown
This commit is contained in:
+670
-140
@@ -34,18 +34,11 @@ function showErrorBanner(msg, detail = '') {
|
||||
setTimeout(() => banner && banner.remove(), 8000);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
function getAuthToken() {
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
|
||||
let authVerificationInProgress = false;
|
||||
let currentUser = null;
|
||||
|
||||
async function verifyAuth() {
|
||||
if (authVerificationInProgress) return false;
|
||||
@@ -59,14 +52,7 @@ async function verifyAuth() {
|
||||
if (!response.ok) { localStorage.removeItem('auth_token'); window.location.href = '/login'; return false; }
|
||||
const data = await response.json();
|
||||
authVerificationInProgress = false;
|
||||
if (data.valid) {
|
||||
currentUser = data.user;
|
||||
if (currentUser && currentUser.is_admin) {
|
||||
const navAdmin = document.getElementById('navAdminClinics');
|
||||
if (navAdmin) navAdmin.style.display = 'flex';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (data.valid) return true;
|
||||
localStorage.removeItem('auth_token'); window.location.href = '/login'; return false;
|
||||
} catch {
|
||||
localStorage.removeItem('auth_token');
|
||||
@@ -112,6 +98,7 @@ let clientsList = [];
|
||||
let selectedClient = '';
|
||||
let showDisabled = false;
|
||||
let currentTransformations = [];
|
||||
let fineTuneAngle = 0;
|
||||
|
||||
// ================================================================
|
||||
// DOM REFS
|
||||
@@ -163,6 +150,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadClientsList();
|
||||
loadPatients();
|
||||
setupEventListeners();
|
||||
initDragAndDrop();
|
||||
setInterval(loadClientsList, 5000);
|
||||
} catch (error) {
|
||||
_log.error('INIT', 'Erro fatal na inicialização:', error.message, error);
|
||||
@@ -243,8 +231,12 @@ function updateClientsDropdown() {
|
||||
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);
|
||||
});
|
||||
@@ -355,6 +347,7 @@ function renderPatientCards() {
|
||||
return `
|
||||
<div class="image-card patient-card" onclick="openPatientByIndex(${index})">
|
||||
<div class="image-preview" style="${thumb ? `background-image: url('${thumb}'); background-size: cover; background-position: center;` : 'background: linear-gradient(135deg,#667eea22,#764ba222);'}">
|
||||
<button type="button" class="delete-patient-btn" onclick="event.stopPropagation(); deletePatientImages('${escapeHtml(fullName)}')" title="Excluir todas as imagens de ${escapeHtml(fullName)}">🗑️</button>
|
||||
<div class="patient-count-badge">${count} imagem${count !== 1 ? 'ns' : ''}</div>
|
||||
</div>
|
||||
<div class="image-info">
|
||||
@@ -388,6 +381,38 @@ function renderPatientCards() {
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
// ================================================================
|
||||
@@ -449,9 +474,11 @@ async function loadPatientImages(patientName) {
|
||||
}
|
||||
|
||||
function renderPatientImages() {
|
||||
imagesGrid.innerHTML = patientImages.map(image => `
|
||||
imagesGrid.innerHTML = patientImages.map(image => {
|
||||
const thumbUrl = image.thumb_filename ? `/uploads/${image.thumb_filename}` : `/uploads/${image.filename}`;
|
||||
return `
|
||||
<div class="image-card ${!image.enabled ? 'disabled' : ''}" data-id="${image.id}" onclick="handleImageClick(${image.id})">
|
||||
<div class="image-preview" style="background-image: url('/uploads/${image.filename}'); background-size: cover; background-position: center;"></div>
|
||||
<div class="image-preview" style="background-image: url('${thumbUrl}'); background-size: cover; background-position: center;"></div>
|
||||
<div class="image-info">
|
||||
<div class="image-meta"><span>📅 ${formatDate(image.created_at)}</span></div>
|
||||
<div class="image-guid">${escapeHtml(image.image_guid || image.filename)}</div>
|
||||
@@ -465,8 +492,8 @@ function renderPatientImages() {
|
||||
</div>
|
||||
</div>
|
||||
${!image.enabled ? '<div class="image-badge">Desabilitada</div>' : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
@@ -520,19 +547,28 @@ async function showTransformModal(imageId) {
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth(`/api/images/${imageId}/transformations`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) { localStorage.removeItem('auth_token'); window.location.href = '/login'; return; }
|
||||
throw new Error('Erro ao gerar transformações');
|
||||
const image = patientImages.find(img => img.id === imageId);
|
||||
if (!image) {
|
||||
throw new Error('Imagem não encontrada localmente');
|
||||
}
|
||||
|
||||
const transformations = await res.json();
|
||||
// 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) => `
|
||||
<div class="transform-option" data-index="${i}" onclick="selectTransform(${i})">
|
||||
<img src="${t.base64}" alt="${escapeHtml(t.name)}" loading="lazy">
|
||||
<img src="${imageUrl}" alt="${escapeHtml(t.name)}" style="${t.css}" loading="lazy">
|
||||
<div class="transform-name">${escapeHtml(t.name)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -540,7 +576,6 @@ async function showTransformModal(imageId) {
|
||||
// ---------------------------------------------------------------
|
||||
// Detectar orientação da imagem original → aplicar layout correto
|
||||
// ---------------------------------------------------------------
|
||||
if (transformations.length > 0 && transformations[0].base64) {
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
const orientation = probe.naturalWidth >= probe.naturalHeight ? 'landscape' : 'portrait';
|
||||
@@ -551,12 +586,11 @@ async function showTransformModal(imageId) {
|
||||
probe.onerror = () => {
|
||||
_log.warn('TRANSFORM', 'Não foi possível detectar orientação — usando fallback');
|
||||
};
|
||||
probe.src = transformations[0].base64;
|
||||
}
|
||||
probe.src = imageUrl;
|
||||
|
||||
} catch (e) {
|
||||
_log.error('TRANSFORM', '❌ Exceção:', e.message, e);
|
||||
showToast('Erro ao gerar variações', 'error');
|
||||
showToast('Erro ao carregar variações', 'error');
|
||||
transformModal.classList.remove('active');
|
||||
}
|
||||
}
|
||||
@@ -585,6 +619,10 @@ function selectTransform(index) {
|
||||
|
||||
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';
|
||||
|
||||
@@ -599,11 +637,26 @@ 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 = '';
|
||||
}
|
||||
@@ -626,7 +679,7 @@ async function saveSelectedTransform() {
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
rotation: transform.rotation,
|
||||
rotation: transform.rotation + fineTuneAngle,
|
||||
flipH: transform.flipH,
|
||||
flipV: transform.flipV,
|
||||
remark: remark
|
||||
@@ -635,7 +688,7 @@ async function saveSelectedTransform() {
|
||||
const res = await fetchWithAuth(`/api/images/${selectedImageId}/transform`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rotation: transform.rotation, flipH: transform.flipH, flipV: transform.flipV })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error('Erro ao salvar');
|
||||
|
||||
@@ -648,6 +701,49 @@ async function saveSelectedTransform() {
|
||||
}
|
||||
}
|
||||
|
||||
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' });
|
||||
@@ -1060,166 +1156,600 @@ window.closeSettingsModal = closeSettingsModal;
|
||||
window.updateCredentials = updateCredentials;
|
||||
|
||||
// ================================================================
|
||||
// GERENCIAMENTO DE CLÍNICAS (ADMIN)
|
||||
// CONFIGURAÇÕES DE PLUGINS (Wasabi Storage)
|
||||
// ================================================================
|
||||
|
||||
function showAdminClinics() {
|
||||
view = 'admin-clinics';
|
||||
document.querySelector('.content-scroll').scrollTop = 0;
|
||||
function openPluginsModal() {
|
||||
const modal = document.getElementById('pluginsModal');
|
||||
if (modal) modal.style.display = 'block';
|
||||
|
||||
// Esconder outras visões
|
||||
imagesGrid.style.display = 'none';
|
||||
emptyState.style.display = 'none';
|
||||
patientHeader.style.display = 'none';
|
||||
backBtn.style.display = 'none';
|
||||
// Limpar campos
|
||||
document.getElementById('wasabiAccessKey').value = '';
|
||||
document.getElementById('wasabiSecretKey').value = '';
|
||||
document.getElementById('wasabiBucket').value = '';
|
||||
document.getElementById('wasabiRegion').value = '';
|
||||
document.getElementById('wasabiEndpoint').value = '';
|
||||
|
||||
// Mostrar painel admin
|
||||
document.getElementById('adminClinicsState').style.display = 'block';
|
||||
const alertBox = document.getElementById('pluginsStatusAlert');
|
||||
alertBox.style.display = 'none';
|
||||
|
||||
// Atualizar menu
|
||||
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('navAdminClinics').classList.add('active');
|
||||
|
||||
loadAdminClinics();
|
||||
// Carregar configurações atuais
|
||||
loadPluginsConfig();
|
||||
}
|
||||
|
||||
// Override para voltar aos pacientes e esconder o admin state
|
||||
const originalShowPatientsView = showPatientsView;
|
||||
showPatientsView = function() {
|
||||
document.getElementById('adminClinicsState').style.display = 'none';
|
||||
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
||||
document.querySelector('a[href="/"]').classList.add('active');
|
||||
originalShowPatientsView();
|
||||
};
|
||||
window.showPatientsView = showPatientsView;
|
||||
|
||||
async function loadAdminClinics() {
|
||||
const tbody = document.getElementById('clinicsTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px;">Carregando...</td></tr>';
|
||||
function closePluginsModal() {
|
||||
const modal = document.getElementById('pluginsModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
async function loadPluginsConfig() {
|
||||
try {
|
||||
const res = await fetchWithAuth(`${API_URL}/auth/users`);
|
||||
if (!res.ok) throw new Error('Erro ao carregar clínicas');
|
||||
const data = await res.json();
|
||||
const res = await fetchWithAuth('/api/system/storage-config');
|
||||
if (!res.ok) throw new Error('Falha ao carregar configurações');
|
||||
|
||||
if (data.users.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px;">Nenhuma clínica cadastrada.</td></tr>';
|
||||
return;
|
||||
const config = await res.json();
|
||||
|
||||
document.getElementById('wasabiAccessKey').value = config.wasabiAccessKey || '';
|
||||
document.getElementById('wasabiSecretKey').value = config.wasabiSecretKey || '';
|
||||
document.getElementById('wasabiBucket').value = config.wasabiBucket || '';
|
||||
document.getElementById('wasabiRegion').value = config.wasabiRegion || '';
|
||||
document.getElementById('wasabiEndpoint').value = config.wasabiEndpoint || '';
|
||||
|
||||
const alertBox = document.getElementById('pluginsStatusAlert');
|
||||
if (config.enabled) {
|
||||
alertBox.className = 'alert-success';
|
||||
alertBox.style.background = '#eafaf1';
|
||||
alertBox.style.color = '#27ae60';
|
||||
alertBox.style.border = '1px solid #2ecc7133';
|
||||
alertBox.innerHTML = '🟢 <b>Status:</b> Wasabi Storage está ATIVO e conectado.';
|
||||
} else {
|
||||
alertBox.className = 'alert-warning';
|
||||
alertBox.style.background = '#fffbeb';
|
||||
alertBox.style.color = '#d97706';
|
||||
alertBox.style.border = '1px solid #f59e0b33';
|
||||
alertBox.innerHTML = '🟡 <b>Status:</b> Wasabi Storage está DESATIVADO (utilizando armazenamento local).';
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.users.map(u => `
|
||||
<tr style="border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 12px 15px;">#${u.id}</td>
|
||||
<td style="padding: 12px 15px;"><strong>${escapeHtml(u.username)}</strong>${u.is_admin ? ' <span style="background: #e3f2fd; color: #1976d2; padding: 2px 6px; border-radius: 4px; font-size: 11px;">Admin</span>' : ''}</td>
|
||||
<td style="padding: 12px 15px;">${escapeHtml(u.clinic_name || '—')}</td>
|
||||
<td style="padding: 12px 15px;">${escapeHtml(u.email)}</td>
|
||||
<td style="padding: 12px 15px;">${escapeHtml(u.pc_name || '—')}</td>
|
||||
<td style="padding: 12px 15px; text-align: center;">
|
||||
<button class="btn btn-primary btn-small" onclick="generateClinicToken(${u.id})" title="Gerar Token">🔑 Token</button>
|
||||
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-small" onclick="deleteClinic(${u.id}, '${escapeHtml(u.username)}')" title="Excluir">🗑️</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
alertBox.style.display = 'block';
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: red;">Erro ao carregar dados.</td></tr>';
|
||||
console.error(e);
|
||||
showToast('Erro ao carregar configurações do Wasabi.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateClinicModal() {
|
||||
document.getElementById('createClinicModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeCreateClinicModal() {
|
||||
document.getElementById('createClinicModal').style.display = 'none';
|
||||
document.getElementById('createClinicForm').reset();
|
||||
}
|
||||
|
||||
async function submitCreateClinic(event) {
|
||||
async function updatePluginsConfig(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const btn = event.target.querySelector('button[type="submit"]');
|
||||
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.innerHTML = '<span class="spinner" style="width:14px;height:14px;display:inline-block;"></span> Salvando...';
|
||||
btn.disabled = true;
|
||||
|
||||
const payload = {
|
||||
username: document.getElementById('clinicUsername').value.trim(),
|
||||
email: document.getElementById('clinicEmail').value.trim(),
|
||||
password: document.getElementById('clinicPassword').value,
|
||||
clinic_name: document.getElementById('clinicName').value.trim(),
|
||||
pc_name: document.getElementById('clinicPcName').value.trim()
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth(`${API_URL}/auth/create-user`, {
|
||||
const res = await fetchWithAuth('/api/system/storage-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify({
|
||||
wasabiAccessKey,
|
||||
wasabiSecretKey,
|
||||
wasabiBucket,
|
||||
wasabiRegion,
|
||||
wasabiEndpoint
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Erro ao criar');
|
||||
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);
|
||||
|
||||
showToast('Clínica cadastrada com sucesso!', 'success');
|
||||
closeCreateClinicModal();
|
||||
loadAdminClinics();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
console.error(e);
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClinic(id, username) {
|
||||
if (!confirm(`Tem certeza que deseja excluir permanentemente o acesso da clínica "${username}"?`)) return;
|
||||
|
||||
async function triggerSync() {
|
||||
showToast('🔄 Enviando sinal de sincronização para dispositivos...', 'info');
|
||||
try {
|
||||
const res = await fetchWithAuth(`${API_URL}/auth/users/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Erro ao excluir');
|
||||
showToast('Clínica excluída', 'success');
|
||||
loadAdminClinics();
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
async function generateClinicToken(id) {
|
||||
// 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_URL}/auth/users/${id}/token`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Erro ao gerar token');
|
||||
const res = await fetchWithAuth('/api/socket/status');
|
||||
if (!res.ok) throw new Error('Falha ao obter status dos dispositivos.');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('clinicTokenInput').value = data.token;
|
||||
document.getElementById('tokenCopySuccess').style.display = 'none';
|
||||
document.getElementById('showTokenModal').style.display = 'block';
|
||||
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) {
|
||||
showToast(e.message, 'error');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function closeShowTokenModal() {
|
||||
document.getElementById('showTokenModal').style.display = 'none';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function copyTokenToClipboard() {
|
||||
const input = document.getElementById('clinicTokenInput');
|
||||
input.select();
|
||||
input.setSelectionRange(0, 99999);
|
||||
navigator.clipboard.writeText(input.value).then(() => {
|
||||
document.getElementById('tokenCopySuccess').style.display = 'block';
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// Admin Exports
|
||||
window.showAdminClinics = showAdminClinics;
|
||||
window.openCreateClinicModal = openCreateClinicModal;
|
||||
window.closeCreateClinicModal = closeCreateClinicModal;
|
||||
window.submitCreateClinic = submitCreateClinic;
|
||||
window.deleteClinic = deleteClinic;
|
||||
window.generateClinicToken = generateClinicToken;
|
||||
window.closeShowTokenModal = closeShowTokenModal;
|
||||
window.copyTokenToClipboard = copyTokenToClipboard;
|
||||
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'; }
|
||||
|
||||
+174
-60
@@ -25,19 +25,30 @@
|
||||
<a href="/clients" class="nav-item">
|
||||
<span class="icon">🖥️</span> Dispositivos
|
||||
</a>
|
||||
<a href="#" class="nav-item admin-only" id="navAdminClinics" style="display: none;" onclick="showAdminClinics(); return false;">
|
||||
<span class="icon">🏢</span> Gerenciar Clínicas
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="openSettingsModal(); return false;">
|
||||
<span class="icon">⚙️</span> Configurações
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="openPluginsModal(); return false;">
|
||||
<span class="icon">🔌</span> Plugins / Wasabi
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="openSyncModal(); return false;">
|
||||
<span class="icon">🔄</span> Sincronização
|
||||
</a>
|
||||
<a href="/reset" class="nav-item" style="color: #dc3545;">
|
||||
<span class="icon">⚠️</span> Zerar Sistema
|
||||
</a>
|
||||
<a href="/admin-clinics.html" class="nav-item">
|
||||
<span class="icon">🏢</span> Gerenciar Clínicas
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="logout(); return false;">
|
||||
<span class="icon">🚪</span> Sair
|
||||
</a>
|
||||
<div class="nav-divider"></div>
|
||||
<div style="padding: 10px 20px;">
|
||||
<select id="clientFilter" class="client-filter" style="width: 100%; border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1); color: white; border-radius: 6px; padding: 8px;">
|
||||
<option value="" style="color: black;">📋 Todos os Clientes</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="toggleDisabledBtn" class="btn btn-secondary" style="margin: 10px 20px; width: calc(100% - 40px);">
|
||||
<span id="toggleText">Ocultas</span>
|
||||
</button>
|
||||
@@ -56,9 +67,6 @@
|
||||
<h1 id="mainTitle">Galeria de Imagens</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<select id="clientFilter" class="client-filter">
|
||||
<option value="">📋 Todos os Clientes</option>
|
||||
</select>
|
||||
<input
|
||||
type="search"
|
||||
id="searchInput"
|
||||
@@ -96,30 +104,6 @@
|
||||
<p>As imagens aparecerão aqui quando forem enviadas pelo cliente Windows</p>
|
||||
</div>
|
||||
|
||||
<!-- Admin Clínicas State -->
|
||||
<div id="adminClinicsState" style="display: none; padding: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>🏢 Gerenciamento de Clínicas</h2>
|
||||
<button class="btn btn-primary" onclick="openCreateClinicModal()">+ Nova Clínica</button>
|
||||
</div>
|
||||
<div class="table-responsive" style="background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); overflow: hidden;">
|
||||
<table class="data-table" id="clinicsTable" style="width: 100%; border-collapse: collapse; text-align: left;">
|
||||
<thead style="background: #f8f9fa; border-bottom: 1px solid #eee;">
|
||||
<tr>
|
||||
<th style="padding: 15px;">ID</th>
|
||||
<th style="padding: 15px;">Login (Username)</th>
|
||||
<th style="padding: 15px;">Clínica</th>
|
||||
<th style="padding: 15px;">Email</th>
|
||||
<th style="padding: 15px;">Nome PC</th>
|
||||
<th style="padding: 15px; text-align: center;">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="clinicsTableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Images / Patients Grid -->
|
||||
<div id="imagesGrid" class="images-grid"> </div>
|
||||
|
||||
@@ -152,7 +136,27 @@
|
||||
<!-- View 1: Transforms -->
|
||||
<div id="transformViewWrapper" style="display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%;">
|
||||
<div id="transformOptions" class="transform-grid" style="flex: 1; min-height: 0;"></div>
|
||||
<div id="transformActionArea" style="display: none; margin-top: 20px; background: #f8f9fa; padding: 15px; border-radius: 8px; flex-shrink: 0;">
|
||||
<div id="transformActionArea" style="display: none; margin-top: 20px; background: #f8f9fa; padding: 15px; border-radius: 8px; flex-shrink: 0; border: 1px solid #e3e8ee;">
|
||||
<!-- Ajuste Fino de Rotação -->
|
||||
<div style="display: flex; flex-direction: column; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #e3e8ee; padding-bottom: 15px;">
|
||||
<span style="font-weight: 700; font-size: 0.95rem; margin-bottom: 10px; color: var(--dark-color); display: flex; align-items: center; gap: 6px;">
|
||||
📐 Ajuste Fino de Ângulo (Sensor Inclinado)
|
||||
</span>
|
||||
<div style="display: flex; align-items: center; gap: 8px; justify-content: center; flex-wrap: wrap;">
|
||||
<button type="button" class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 3px;" onclick="adjustFineTune(-5)" title="Rotacionar -5°">🔄 -5°</button>
|
||||
<button type="button" class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 3px;" onclick="adjustFineTune(-1)" title="Rotacionar -1°">🔄 -1°</button>
|
||||
|
||||
<div style="min-width: 100px; text-align: center; padding: 6px 12px; background: white; border: 1px solid #cbd5e0; border-radius: 6px; font-family: monospace; font-weight: 700; font-size: 1.1rem; color: var(--primary-color);" id="fineTuneValDisplay">
|
||||
0°
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 3px;" onclick="adjustFineTune(1)" title="Rotacionar +1°">🔄 +1°</button>
|
||||
<button type="button" class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 3px;" onclick="adjustFineTune(5)" title="Rotacionar +5°">🔄 +5°</button>
|
||||
|
||||
<button type="button" class="btn btn-danger btn-small" style="padding: 6px 12px; font-size: 0.85rem; margin-left: 8px; display: none; background: #e53e3e; border-color: #e53e3e; color: white;" id="btnResetFineTune" onclick="resetFineTune()" title="Resetar ajuste fino">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style="font-weight: 600; display: block; margin-bottom: 8px;">Nova Observação / Detalhes (Opcional):</label>
|
||||
<textarea id="newImageRemark" class="search-input" rows="2" style="width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 6px;" placeholder="Ex: Raio-X Dente 45..."></textarea>
|
||||
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
||||
@@ -300,55 +304,165 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Nova Clínica -->
|
||||
<div class="modal" id="createClinicModal">
|
||||
<div class="modal-content" style="max-width: 500px; height: auto; border-radius: var(--radius);">
|
||||
<!-- Plugins / Wasabi Modal -->
|
||||
<div id="pluginsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Nova Clínica (Acesso Desktop)</h2>
|
||||
<div class="modal-close" onclick="closeCreateClinicModal()">×</div>
|
||||
<h2>🔌 Configuração do Wasabi Storage</h2>
|
||||
<span class="modal-close" onclick="closePluginsModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="createClinicForm" onsubmit="submitCreateClinic(event)">
|
||||
<div class="form-group">
|
||||
<label>Login (Username) *</label>
|
||||
<input type="text" id="clinicUsername" class="form-control" required placeholder="Ex: clinica_sorriso">
|
||||
<form id="pluginsForm" onsubmit="updatePluginsConfig(event)">
|
||||
<div style="background: #f8f9fa; padding: 12px 18px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 20px; font-size: 0.95rem; color: #555;">
|
||||
Configure as credenciais e bucket do **Wasabi S3** para habilitar o armazenamento na nuvem. Se desativado, o sistema usará o armazenamento local do servidor de forma automática.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Email *</label>
|
||||
<input type="email" id="clinicEmail" class="form-control" required placeholder="Ex: contato@clinicasorriso.com">
|
||||
<label for="wasabiAccessKey">Access Key *</label>
|
||||
<input type="text" id="wasabiAccessKey" placeholder="Ex: AIPL5M4G..." required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Senha de Acesso *</label>
|
||||
<input type="password" id="clinicPassword" class="form-control" required placeholder="Senha para o cliente desktop">
|
||||
<label for="wasabiSecretKey">Secret Key *</label>
|
||||
<input type="password" id="wasabiSecretKey" placeholder="Sua Secret Key" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Nome da Clínica (Opcional)</label>
|
||||
<input type="text" id="clinicName" class="form-control" placeholder="Ex: Clínica Odonto Sorriso">
|
||||
<label for="wasabiBucket">Bucket Ativo *</label>
|
||||
<input type="text" id="wasabiBucket" placeholder="Ex: meu-bucket-rx" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nome do PC (Opcional)</label>
|
||||
<input type="text" id="clinicPcName" class="form-control" placeholder="Ex: PC-Recepção">
|
||||
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="wasabiRegion">Região</label>
|
||||
<input type="text" id="wasabiRegion" placeholder="Ex: us-east-1 (padrão)">
|
||||
</div>
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="wasabiEndpoint">Endpoint</label>
|
||||
<input type="text" id="wasabiEndpoint" placeholder="Ex: s3.wasabisys.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pluginsStatusAlert" style="display: none; padding: 10px; border-radius: 6px; font-size: 0.9rem; margin-top: 15px;"></div>
|
||||
|
||||
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid #eee; padding-top: 15px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closePluginsModal()">Cancelar</button>
|
||||
<button type="submit" class="btn btn-primary" id="btnSavePlugins">Salvar Configurações</button>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 15px;">Cadastrar Clínica</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Mostrar Token -->
|
||||
<div class="modal" id="showTokenModal">
|
||||
<div class="modal-content" style="max-width: 600px; border-radius: var(--radius);">
|
||||
<!-- Sync / Upload Modal -->
|
||||
<div id="syncModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 650px; height: auto; border-radius: var(--radius);">
|
||||
<div class="modal-header">
|
||||
<h2>🔑 Token de Acesso Desktop</h2>
|
||||
<div class="modal-close" onclick="closeShowTokenModal()">×</div>
|
||||
<h2>🔄 Área de Sincronização</h2>
|
||||
<span class="modal-close" onclick="closeSyncModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom: 15px; color: #555;">Copie o token abaixo e cole no cliente desktop junto com o email e a senha para autenticar esta clínica.</p>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="clinicTokenInput" class="form-control" readonly style="flex: 1; font-family: monospace; font-size: 14px;">
|
||||
<button class="btn btn-primary" onclick="copyTokenToClipboard()">📋 Copiar</button>
|
||||
<div class="sync-tabs">
|
||||
<button type="button" id="tabBtnDevices" class="sync-tab-btn active" onclick="switchSyncTab('devices')">💻 Dispositivos Conectados</button>
|
||||
<button type="button" id="tabBtnUpload" class="sync-tab-btn" onclick="switchSyncTab('upload')">📤 Envio Manual</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Dispositivos -->
|
||||
<div id="tab-devices" class="sync-tab-content active">
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 20px; font-size: 0.95rem; color: #555; line-height: 1.5;">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div id="syncDevicesLoading" class="loading" style="padding: 20px 0;">
|
||||
<div class="spinner" style="width: 30px; height: 30px; margin-bottom: 10px;"></div>
|
||||
<p>Buscando dispositivos online...</p>
|
||||
</div>
|
||||
|
||||
<div id="syncDevicesEmpty" style="display: none; text-align: center; padding: 30px 20px; color: #666;">
|
||||
<span style="font-size: 2.5rem; display: block; margin-bottom: 10px;">📭</span>
|
||||
<p>Nenhum dispositivo Windows conectado no momento.</p>
|
||||
</div>
|
||||
|
||||
<div id="syncDeviceList" class="sync-device-list" style="display: none;">
|
||||
<!-- Preenchido via Javascript -->
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 25px; border-top: 1px solid #eee; padding-top: 15px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSyncModal()">Fechar</button>
|
||||
<button type="button" class="btn btn-primary" onclick="triggerSyncFromModal()" id="btnTriggerSyncModal">
|
||||
<span class="icon">🔄</span> Enviar Sinal de Sincronização
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Envio Manual -->
|
||||
<div id="tab-upload" class="sync-tab-content">
|
||||
<form id="manualUploadForm" onsubmit="handleManualUploadSubmit(event)">
|
||||
<div class="form-group">
|
||||
<label for="uploadPatientSelect">Paciente *</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<select id="uploadPatientSelect" onchange="toggleNewPatientFields()" required style="flex: 1;">
|
||||
<option value="">-- Selecione o Paciente --</option>
|
||||
<option value="NEW_PATIENT">➕ Novo Paciente...</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary" onclick="refreshUploadPatients()" title="Atualizar Lista" style="width: 44px; padding: 0; display: flex; align-items: center; justify-content: center;">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campos para Novo Paciente (ocultos por padrão) -->
|
||||
<div id="newPatientFields" style="display: none; background: #f8f9fa; padding: 15px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 18px; animation: slideDown 0.2s ease;">
|
||||
<h4 style="margin: 0 0 12px 0; color: var(--dark-color); font-size: 0.95rem;">Dados do Novo Paciente</h4>
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 12px;">
|
||||
<div style="flex: 1;">
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Nome *</label>
|
||||
<input type="text" id="uploadNewPatientFirstName" placeholder="Ex: Maria" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Sobrenome *</label>
|
||||
<input type="text" id="uploadNewPatientLastName" placeholder="Ex: Oliveira" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Dentista Responsável</label>
|
||||
<input type="text" id="uploadNewPatientDoctor" placeholder="Nome do dentista (opcional)" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="uploadRemark">Observações / Detalhes (Opcional)</label>
|
||||
<textarea id="uploadRemark" placeholder="Ex: Raio-X Panorâmico" rows="2" style="width: 100%; padding: 10px; border: 2px solid var(--border-color); border-radius: var(--radius-sm); font-family: inherit; font-size: 1rem; resize: vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Zona de Drag and Drop -->
|
||||
<div class="drag-drop-zone" id="uploadDragDropZone">
|
||||
<div class="drag-drop-icon">📸</div>
|
||||
<div class="drag-drop-text">Arraste as imagens aqui ou clique para selecionar</div>
|
||||
<div class="drag-drop-subtext">Suporta arquivos JPG, JPEG e PNG (máx. 10MB por imagem)</div>
|
||||
<input type="file" id="uploadFileInput" multiple accept="image/png, image/jpeg, image/jpg" style="display: none;">
|
||||
</div>
|
||||
|
||||
<!-- Previews das Imagens Selecionadas -->
|
||||
<div class="upload-previews" id="uploadPreviewsContainer"></div>
|
||||
|
||||
<!-- Progresso de Envio -->
|
||||
<div class="upload-progress-container" id="uploadProgressContainer" style="display: none;">
|
||||
<div class="progress-bar-label">
|
||||
<span id="uploadProgressText">Enviando imagens...</span>
|
||||
<span id="uploadProgressPercent">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar-track">
|
||||
<div class="progress-bar-fill" id="uploadProgressBarFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="margin-top: 25px; border-top: 1px solid #eee; padding-top: 15px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSyncModal()">Cancelar</button>
|
||||
<button type="submit" class="btn btn-primary" id="btnSubmitManualUpload" disabled>Enviar Imagens</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<p id="tokenCopySuccess" style="color: green; margin-top: 10px; display: none;">✅ Token copiado para a área de transferência!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1072,3 +1072,240 @@ body {
|
||||
#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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user