'use strict'; // ── Menú desplegable ────────────────────────────────────────────────────────── document.querySelectorAll('.nav-item').forEach(item => { item.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = item.classList.contains('open'); document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('open')); if (!isOpen) item.classList.add('open'); }); }); document.addEventListener('click', () => { document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('open')); }); // ── Acciones del menú ───────────────────────────────────────────────────────── document.querySelectorAll('.dd-item').forEach(item => { item.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('open')); const action = item.dataset.action; switch (action) { case 'users-list': case 'users-create': if (!window.Auth?.isSuperAdmin()) { alert('Superadmin access required for user management.'); return; } openUsersModal(action === 'users-create'); break; case 'charts-catalog': openChartsModal('tab-installed'); break; case 'charts-install': openChartsModal('tab-upload'); break; case 'charts-noaa': openChartsModal('tab-catalog'); break; case 'settings-datasource': openSettingsModal('ais'); break; case 'settings-station': openSettingsModal('station'); break; case 'settings-alerts': openSettingsModal('alerts'); break; case 'settings-equipment': openSettingsModal('equipment'); break; case 'lamps-catalog': openLampsModal(); break; case 'contacts-catalog': openContactsModal(); break; case 'recordings-list': openRecordingsModal(); break; case 'ais-history': if (window.openTrackHistoryModal) openTrackHistoryModal(); else openRecordingsModal(); break; case 'org-management': if (window.Auth?.isSuperAdmin() || window.Auth?.isAdmin()) { if (window.openOrgModal) openOrgModal(); } else { alert('Admin access required.'); } break; case 'export-data': alert('CSV export coming soon.'); break; } }); }); // ── MODAL USUARIOS ──────────────────────────────────────────────────────────── async function openUsersModal(openCreate = false) { _showModal('modal-users'); await loadUsersList(); if (openCreate) openUserForm(); } async function loadUsersList() { const body = document.getElementById('modal-users-body'); body.innerHTML = '
Loading...
'; try { const res = await fetch(`${API}/auth/users`, { headers: { Authorization: `Bearer ${window.Auth.token()}` } }); if (res.status === 401) { body.innerHTML = '
Session expired — please log in again (F5).
'; return; } const data = await res.json(); const users = Array.isArray(data) ? data : []; // Also fetch companies to resolve company_id → name let companyMap = {}; try { const cr = await fetch(`${API}/org/companies`, { headers: { Authorization: `Bearer ${window.Auth.token()}` } }); if (cr.ok) { const cs = await cr.json(); cs.forEach(c => { companyMap[c.id] = c.name; }); window._orgPortsCache = window._orgPortsCache || []; // used by company hint } } catch {} body.innerHTML = ` ${users.map(u => ` `).join('')}
USERNAMEFULL NAMEEMAIL ROLECOMPANYSTATUS
${u.username} ${u.nombre} ${u.email || '--'} ${u.role} ${u.company_id ? (companyMap[u.company_id] || u.company_id) : '--'} ${u.activo ? 'ACTIVE' : 'INACTIVE'} ${u.username !== 'admin' ? `` : ''}
`; } catch (e) { body.innerHTML = ``; } } function roleClass(role) { return { SUPERADMIN: 'role-superadmin', ADMIN: 'role-admin', CLIENT_ADMIN: 'role-admin', // same cyan badge as admin but label differs USER: 'role-user', }[role] || 'role-user'; } // ── MODAL CREAR USUARIO ─────────────────────────────────────────────────────── let _ufCompanies = []; // cached list from /org/companies async function _loadCompaniesForForm() { try { const r = await fetch(`${API}/org/companies`, { headers: { Authorization: `Bearer ${window.Auth?.token()}` } }); _ufCompanies = await r.json(); } catch { _ufCompanies = []; } const sel = document.getElementById('uf-company'); sel.innerHTML = '' + _ufCompanies.map(c => ``).join(''); } function _ufToggleCompanyRow() { const role = document.getElementById('uf-role').value; const row = document.getElementById('uf-company-row'); // Company field required for client roles (USER and CLIENT_ADMIN) row.style.display = (role === 'USER' || role === 'CLIENT_ADMIN') ? '' : 'none'; } document.getElementById('uf-role').addEventListener('change', _ufToggleCompanyRow); // Auto-suggest username & full-name when a company is selected document.getElementById('uf-company').addEventListener('change', () => { const cid = document.getElementById('uf-company').value; const company = _ufCompanies.find(c => c.id === cid); const hint = document.getElementById('uf-company-hint'); if (!company) { hint.textContent = ''; return; } // Port name for the hint const portName = company.port_id ? (window._orgPortsCache?.find?.(p => p.id === company.port_id)?.name || company.port_id) : ''; hint.textContent = portName ? `Home port: ${portName}` : ''; // Auto-fill username: slug from company name (lowercase, no spaces) const slug = company.name.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, ''); const usernameEl = document.getElementById('uf-username'); if (!usernameEl.readOnly && !usernameEl.value) { usernameEl.value = slug; } // Auto-fill full name if empty const nombreEl = document.getElementById('uf-nombre'); if (!nombreEl.value) { nombreEl.value = company.name + (portName ? ` — ${portName}` : ''); } }); async function openUserForm() { document.getElementById('user-form-title').textContent = 'CREATE USER'; document.getElementById('uf-username').value = ''; document.getElementById('uf-username').readOnly = false; document.getElementById('uf-nombre').value = ''; document.getElementById('uf-email').value = ''; document.getElementById('uf-password').value = ''; document.getElementById('uf-role').value = 'USER'; document.getElementById('uf-company').value = ''; document.getElementById('uf-company-hint').textContent = ''; document.getElementById('uf-error').classList.add('hidden'); document.getElementById('btn-uf-save').dataset.editMode = ''; _ufToggleCompanyRow(); await _loadCompaniesForForm(); _showModal('modal-user-form'); } document.getElementById('btn-user-new').addEventListener('click', openUserForm); window.editUser = function(username, nombre, email, role, company_id, activo) { document.getElementById('user-form-title').textContent = `EDIT — ${username}`; document.getElementById('uf-username').value = username; document.getElementById('uf-username').readOnly = true; document.getElementById('uf-nombre').value = nombre; document.getElementById('uf-email').value = email; document.getElementById('uf-password').value = ''; document.getElementById('uf-role').value = role; document.getElementById('uf-company').value = company_id || ''; document.getElementById('uf-company-hint').textContent = ''; document.getElementById('uf-error').classList.add('hidden'); document.getElementById('btn-uf-save').dataset.editMode = '1'; _ufToggleCompanyRow(); _loadCompaniesForForm().then(() => { document.getElementById('uf-company').value = company_id || ''; }); _showModal('modal-user-form'); }; window.deleteUser = async function(username) { if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; try { const res = await fetch(`${API}/auth/users/${username}`, { method: 'DELETE', headers: { Authorization: `Bearer ${window.Auth.token()}` }, }); if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } await loadUsersList(); } catch (e) { alert('Error: ' + e.message); } }; document.getElementById('btn-uf-save').addEventListener('click', async () => { const username = document.getElementById('uf-username').value.trim(); const nombre = document.getElementById('uf-nombre').value.trim(); const email = document.getElementById('uf-email').value.trim(); const password = document.getElementById('uf-password').value; const role = document.getElementById('uf-role').value; const company_id = document.getElementById('uf-company').value || null; const errEl = document.getElementById('uf-error'); const isEdit = document.getElementById('btn-uf-save').dataset.editMode === '1'; // Company required for USER role if (role === 'USER' && !company_id) { errEl.textContent = 'Select a company for USER accounts.'; errEl.classList.remove('hidden'); return; } if (!isEdit && (!username || !nombre || !password)) { errEl.textContent = 'Username, full name and password are required.'; errEl.classList.remove('hidden'); return; } if (isEdit && !nombre) { errEl.textContent = 'Full name is required.'; errEl.classList.remove('hidden'); return; } try { let res; if (isEdit) { const body = { nombre, email: email || null, role, company_id }; if (password) body.password = password; res = await fetch(`${API}/auth/users/${username}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.Auth.token()}` }, body: JSON.stringify(body), }); } else { res = await fetch(`${API}/auth/users`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.Auth.token()}` }, body: JSON.stringify({ username, nombre, email: email || null, password, role, company_id }), }); } if (!res.ok) { const err = await res.json(); throw new Error(err.detail || 'Error saving user'); } document.getElementById('uf-username').readOnly = false; document.getElementById('btn-uf-save').dataset.editMode = ''; _hideModal('modal-user-form'); _showModal('modal-users'); await loadUsersList(); } catch (e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); } }); // ── MODAL CARTAS ────────────────────────────────────────────────────────────── function openChartsModal(tab = 'tab-catalog') { document.getElementById('chart-install-status').innerHTML = ''; document.getElementById('chart-save-status').textContent = ''; document.getElementById('chart-file').value = ''; _showModal('modal-charts'); _activateChartTab(tab); if (tab === 'tab-catalog' || tab === 'tab-installed') loadNOAACatalog(); if (tab === 'tab-installed') loadInstalledCells(); } // Chart tabs document.querySelectorAll('.stab[data-ctab]').forEach(btn => { btn.addEventListener('click', () => { const t = btn.dataset.ctab; _activateChartTab(t); if (t === 'tab-installed') loadInstalledCells(); if (t === 'tab-catalog') loadNOAACatalog(); }); }); function _activateChartTab(id) { document.querySelectorAll('.ctab-panel').forEach(p => p.classList.add('hidden')); document.querySelectorAll('.stab[data-ctab]').forEach(b => b.classList.remove('active')); document.getElementById(id)?.classList.remove('hidden'); document.querySelector(`.stab[data-ctab="${id}"]`)?.classList.add('active'); } async function loadNOAACatalog() { const tbody = document.getElementById('noaa-catalog-body'); tbody.innerHTML = 'Loading…'; try { const r = await fetch(`${API}/charts/noaa-catalog`); const cells = await r.json(); tbody.innerHTML = cells.map(c => ` ${c.id} ${c.description}
1:${c.scale.toLocaleString()} ${c.installed ? 'INSTALLED' : ''} ${c.installed ? `` : ``} `).join(''); } catch(e) { tbody.innerHTML = 'Error loading catalog'; } } async function loadInstalledCells() { const tbody = document.getElementById('installed-body'); const empty = document.getElementById('installed-empty'); empty.style.display = 'none'; tbody.innerHTML = 'Loading…'; try { const r = await fetch(`${API}/charts/cells`); if (!r.ok) throw new Error(`Server error ${r.status}`); const cells = await r.json(); if (!Array.isArray(cells) || !cells.length) { tbody.innerHTML = ''; empty.style.display = ''; return; } empty.style.display = 'none'; tbody.innerHTML = cells.map(c => ` ${c.id} ${c.features} features `).join(''); } catch(e) { tbody.innerHTML = `Error: ${e.message}`; } } // ── Rebuild a single chart cell (re-parses .000 → regenerates GeoJSON cache) window.rebuildCell = async function(cellId) { const btn = document.getElementById(`rbtn-${cellId}`); const featEl = document.getElementById(`feat-${cellId}`); const status = document.getElementById('chart-install-status'); if (btn) { btn.textContent = 'REBUILDING…'; btn.disabled = true; } status.innerHTML = `Rebuilding ${cellId}…`; try { const r = await fetch(`${API}/charts/cells/${cellId}/rebuild`, { method: 'POST' }); if (!r.ok) throw new Error((await r.json()).detail || `Error ${r.status}`); status.innerHTML = `✓ ${cellId} rebuilt`; // Refresh feature count const cells = await (await fetch(`${API}/charts/cells`)).json(); const updated = cells.find(c => c.id === cellId); if (updated && featEl) featEl.textContent = `${updated.features} features`; // Reload map layers window.reloadAllChartLayers?.(); } catch(e) { status.innerHTML = `✗ ${e.message}`; } finally { if (btn) { btn.textContent = 'REBUILD'; btn.disabled = false; } } }; // ── Rebuild ALL chart cells window.rebuildAllCells = async function() { const btn = document.getElementById('btn-rebuild-all'); const status = document.getElementById('chart-install-status'); if (btn) { btn.textContent = 'REBUILDING ALL…'; btn.disabled = true; } status.innerHTML = `Rebuilding all charts… (may take a moment)`; try { const r = await fetch(`${API}/charts/rebuild-cache`, { method: 'POST' }); const data = await r.json(); if (!r.ok) throw new Error(data.detail || `Error ${r.status}`); const n = data.rebuilt?.length || 0; status.innerHTML = `✓ ${n} chart(s) rebuilt: ${data.rebuilt?.join(', ')}`; loadInstalledCells(); // refresh table window.reloadAllChartLayers?.(); } catch(e) { status.innerHTML = `✗ ${e.message}`; } finally { if (btn) { btn.textContent = 'REBUILD ALL'; btn.disabled = false; } } }; window.setCellRegion = async function(cellId, region) { try { const r = await fetch(`${API}/charts/cells/${cellId}/region?region=${region}`, { method: 'PATCH' }); if (!r.ok) throw new Error((await r.json()).detail || 'Failed'); window.reloadAllChartLayers?.(); // re-render with new region colours } catch(e) { alert('Could not set region: ' + e.message); } }; window.downloadNOAA = async function(cellId) { const btn = document.querySelector(`#crow-${cellId} .chart-row-btn`); if (btn) { btn.textContent = 'DOWNLOADING…'; btn.disabled = true; } const status = document.getElementById('chart-install-status'); status.innerHTML = `Downloading ${cellId} from NOAA…`; try { const r = await fetch(`${API}/charts/download-noaa/${cellId}`, { method: 'POST' }); const data = await r.json(); if (!r.ok) throw new Error(data.detail || 'Download failed'); status.innerHTML = `✓ ${cellId} installed — ${data.installed?.join(', ')}`; loadNOAACatalog(); window.reloadAllChartLayers?.(); } catch(e) { status.innerHTML = `✗ ${e.message}`; if (btn) { btn.textContent = 'DOWNLOAD'; btn.disabled = false; } } }; window.deleteCell = async function(cellId) { if (!confirm(`Remove chart cell ${cellId}?`)) return; const status = document.getElementById('chart-install-status'); await fetch(`${API}/charts/cells/${cellId}`, { method: 'DELETE' }); status.innerHTML = `${cellId} removed`; loadNOAACatalog(); loadInstalledCells(); window.reloadAllChartLayers?.(); }; document.getElementById('btn-chart-upload')?.addEventListener('click', async () => { const f = document.getElementById('chart-file').files[0]; const status = document.getElementById('chart-upload-status'); if (!f) { status.innerHTML = 'Select a file first'; return; } status.innerHTML = `Uploading ${f.name}…`; const fd = new FormData(); fd.append('file', f); try { const r = await fetch(`${API}/charts/upload`, { method: 'POST', body: fd }); const data = await r.json(); if (!r.ok) throw new Error(data.detail); status.innerHTML = `✓ Installed: ${data.installed.join(', ')}`; loadNOAACatalog(); window.reloadAllChartLayers?.(); } catch(e) { status.innerHTML = `✗ ${e.message}`; } }); // ── Cerrar modales ──────────────────────────────────────────────────────────── [ ['btn-close-users', 'modal-users'], ['btn-close-users2', 'modal-users'], ['btn-close-charts', 'modal-charts'], ['btn-close-charts2', 'modal-charts'], ['btn-close-user-form','modal-user-form'], ['btn-uf-cancel', 'modal-user-form'], ].forEach(([btnId, modalId]) => { document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId)); }); // ── Rebuild ENC symbol cache (footer button — delegates to rebuildAllCells) ─── // onclick="rebuildAllCells()" is already set in HTML; this listener is a no-op // kept only so existing code that wires the button doesn't break. document.getElementById('btn-rebuild-cache')?.addEventListener('click', () => {}); // ── Helpers modales ─────────────────────────────────────────────────────────── function _showModal(id) { document.getElementById('modal-overlay').classList.remove('hidden'); document.getElementById(id).classList.remove('hidden'); } function _hideModal(id) { document.getElementById(id).classList.add('hidden'); const anyOpen = [...document.querySelectorAll('.modal')].some(m => !m.classList.contains('hidden')); if (!anyOpen) document.getElementById('modal-overlay').classList.add('hidden'); } window._showModal = _showModal; window._hideModal = _hideModal; // ── MODAL SETTINGS ──────────────────────────────────────────────────────── const SETTINGS_KEY = 'ams_settings'; function loadSettings() { try { return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {}; } catch { return {}; } } function saveSettings(data) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(data)); } async function openSettingsModal(tab = 'ais') { document.getElementById('settings-status').textContent = 'Loading…'; let s = {}; try { const r = await fetch(`${API}/settings`, { headers: { Authorization: `Bearer ${window.Auth?.token()}` }, }); if (r.ok) { s = await r.json(); saveSettings(s); // keep localStorage in sync as cache } else { s = loadSettings(); // fallback to localStorage if not authenticated yet } } catch { s = loadSettings(); } document.getElementById('set-ais-source').value = s.ais_source || s.aisSource || 'SIMULATOR'; document.getElementById('set-serial-port').value = s.ais_serial_port || s.serialPort || ''; document.getElementById('set-baud').value = s.ais_baud || s.baud || '38400'; document.getElementById('set-net-addr').value = s.ais_net_addr || s.netAddr || ''; document.getElementById('set-station-name').value = s.station_name || s.stationName || ''; document.getElementById('set-iala-region').value = s.iala_region || s.ialaRegion || window.IALA_REGION || 'B'; document.getElementById('set-gps-port').value = s.gps_port || s.gpsPort || ''; document.getElementById('set-ant-lat').value = s.antenna_lat ?? s.antLat ?? ''; document.getElementById('set-ant-lon').value = s.antenna_lon ?? s.antLon ?? ''; document.getElementById('set-ant-height').value = s.antenna_height_m ?? s.antHeight ?? ''; document.getElementById('set-station-notes').value = s.station_notes || s.stationNotes || ''; document.getElementById('set-disp-warn').value = s.displacement_warn_m ?? s.dispWarn ?? 10; document.getElementById('set-disp-alarm').value = s.displacement_alarm_m ?? s.dispAlarm ?? 15; document.getElementById('set-prox-warn').value = s.proximity_alert_meters ?? s.proxWarn ?? 300; document.getElementById('set-rec-trigger').value = s.auto_record_trigger_m ?? s.recTrigger ?? 200; document.getElementById('set-batt-warn').value = s.battery_warn_v ?? s.battWarn ?? 11.5; document.getElementById('set-batt-alarm').value = s.battery_alarm_v ?? s.battAlarm ?? 10.8; document.getElementById('set-smtp-host').value = s.smtp_host || ''; document.getElementById('set-smtp-port').value = s.smtp_port || 587; document.getElementById('set-smtp-user').value = s.smtp_user || ''; document.getElementById('set-smtp-password').value = s.smtp_password || ''; document.getElementById('set-smtp-from').value = s.smtp_from || ''; document.getElementById('set-smtp-from-name').value = s.smtp_from_name || ''; document.getElementById('set-smtp-tls').checked = s.smtp_use_tls !== false; document.getElementById('settings-status').textContent = ''; _activateTab(tab); _showModal('modal-settings'); } function _activateTab(tab) { document.querySelectorAll('.stab').forEach(b => { b.classList.toggle('active', b.dataset.tab === tab); }); document.querySelectorAll('.stab-panel').forEach(p => { p.classList.toggle('hidden', p.id !== `stab-${tab}`); }); } document.querySelectorAll('.stab[data-tab]').forEach(btn => { btn.addEventListener('click', () => _activateTab(btn.dataset.tab)); }); // ── EQUIPMENT SCANNER ───────────────────────────────────────────────────────── const DEVICE_ICONS = { GPS: '🛰', AIS: '📡', ATON: '🔆', GYRO: '🧭', RADAR: '📻', SONAR: '🌊', WIND: '💨', AUTOPILOT: '🎛', 'IN USE': '🔒', 'NO DATA': '—', UNKNOWN: '❓', }; const DEVICE_COLOR = { GPS:'var(--green)', AIS:'var(--cyan)', ATON:'var(--yellow)', GYRO:'var(--cyan)', RADAR:'#f97316', SONAR:'#06b6d4', WIND:'#a78bfa', AUTOPILOT:'#f472b6', 'IN USE':'var(--text-muted)', 'NO DATA':'var(--text-muted)', UNKNOWN:'var(--yellow)', }; document.getElementById('btn-scan-ports')?.addEventListener('click', async () => { const btn = document.getElementById('btn-scan-ports'); const status = document.getElementById('scan-status'); const list = document.getElementById('equipment-list'); btn.disabled = true; btn.textContent = 'SCANNING…'; status.textContent = 'Reading serial ports — this may take 15–30 s…'; list.innerHTML = ''; try { const r = await fetch(`${API}/equipment/scan`); const devs = await r.json(); if (!devs.length) { list.innerHTML = '
No serial ports detected.
'; } else { list.innerHTML = devs.map(d => { const icon = DEVICE_ICONS[d.device_type] || '?'; const color = DEVICE_COLOR[d.device_type] || 'var(--text-primary)'; const sentences = (d.sentences || []).map(s => `
${s}
`).join(''); return `
${icon} ${d.device_type} ${d.port}${d.baud ? ' @ ' + d.baud + ' baud' : ''}
${d.description}
${d.port_desc || ''}
${sentences ? `
${sentences}
` : ''}
`; }).join(''); } status.textContent = `${devs.length} port(s) scanned.`; } catch(e) { status.textContent = 'Scan failed: ' + e.message; } finally { btn.disabled = false; btn.textContent = 'SCAN PORTS'; } }); document.getElementById('btn-settings-save')?.addEventListener('click', async () => { const s = { aisSource: document.getElementById('set-ais-source').value, serialPort: document.getElementById('set-serial-port').value.trim(), baud: document.getElementById('set-baud').value, netAddr: document.getElementById('set-net-addr').value.trim(), ialaRegion: document.getElementById('set-iala-region').value, gpsPort: document.getElementById('set-gps-port').value.trim(), stationName: document.getElementById('set-station-name').value.trim(), antLat: parseFloat(document.getElementById('set-ant-lat').value) || null, antLon: parseFloat(document.getElementById('set-ant-lon').value) || null, antHeight: parseFloat(document.getElementById('set-ant-height').value) || null, stationNotes: document.getElementById('set-station-notes').value.trim(), dispWarn: parseFloat(document.getElementById('set-disp-warn').value), dispAlarm: parseFloat(document.getElementById('set-disp-alarm').value), proxWarn: parseFloat(document.getElementById('set-prox-warn').value), recTrigger: parseFloat(document.getElementById('set-rec-trigger').value), battWarn: parseFloat(document.getElementById('set-batt-warn').value), battAlarm: parseFloat(document.getElementById('set-batt-alarm').value), }; saveSettings(s); updateStatusBar(); // Apply IALA region live for non-S57 aids (S-57 cells have their own per-cell region) if (window.IALA_REGION !== s.ialaRegion) { window.IALA_REGION = s.ialaRegion; localStorage.setItem('ams_iala', s.ialaRegion); if (window._iconCache) Object.keys(window._iconCache).forEach(k => { if (k.startsWith('boya_lat')) delete window._iconCache[k]; }); if (window.aidsSource) window.aidsSource.changed(); } // Push to backend so alert thresholds, GPS, AIS source, etc. apply live const st = document.getElementById('settings-status'); st.textContent = 'Saving…'; st.className = 'save-status'; try { const res = await fetch(`${API}/settings`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${window.Auth?.token()}` }, body: JSON.stringify({ ais_source: s.aisSource, ais_serial_port: s.serialPort, ais_baud: parseInt(s.baud) || 38400, ais_net_addr: s.netAddr, gps_port: s.gpsPort, gps_baud: 9600, station_name: s.stationName, iala_region: s.ialaRegion, antenna_lat: s.antLat, antenna_lon: s.antLon, antenna_height_m: s.antHeight, station_notes: s.stationNotes, displacement_warn_m: s.dispWarn, displacement_alarm_m: s.dispAlarm, proximity_alert_meters: s.proxWarn, auto_record_trigger_m: s.recTrigger, battery_warn_v: s.battWarn, battery_alarm_v: s.battAlarm, smtp_host: document.getElementById('set-smtp-host').value.trim(), smtp_port: parseInt(document.getElementById('set-smtp-port').value) || 587, smtp_user: document.getElementById('set-smtp-user').value.trim(), smtp_password: document.getElementById('set-smtp-password').value, smtp_from: document.getElementById('set-smtp-from').value.trim(), smtp_from_name: document.getElementById('set-smtp-from-name').value.trim(), smtp_use_tls: document.getElementById('set-smtp-tls').checked, }), }); const data = await res.json(); if (!res.ok || !data.ok) throw new Error(data.detail || 'Failed'); const extra = data.applied?.length ? ` (${data.applied.join(', ')})` : ''; st.textContent = 'Settings saved.' + extra; st.className = 'save-status ok'; } catch (e) { st.textContent = 'Saved locally — backend error: ' + e.message; st.className = 'save-status error'; } setTimeout(() => { st.textContent = ''; }, 4000); }); [ ['btn-close-settings', 'modal-settings'], ['btn-close-settings2', 'modal-settings'], ].forEach(([btnId, modalId]) => { document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId)); }); // ── MODAL RECORDINGS ────────────────────────────────────────────────────── function openRecordingsModal() { const today = new Date().toISOString().slice(0, 10); document.getElementById('rec-filter-to').value = today; const from = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); document.getElementById('rec-filter-from').value = from; document.getElementById('modal-recordings-body').innerHTML = '
Press SEARCH to load recordings.
'; _showModal('modal-recordings'); } document.getElementById('btn-rec-search')?.addEventListener('click', async () => { const body = document.getElementById('modal-recordings-body'); body.innerHTML = '
Loading...
'; try { const from = document.getElementById('rec-filter-from').value; const to = document.getElementById('rec-filter-to').value; const mmsi = document.getElementById('rec-filter-mmsi').value.trim(); let url = `${API}/recordings?from=${from}&to=${to}`; if (mmsi) url += `&mmsi=${encodeURIComponent(mmsi)}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${window.Auth?.token()}` } }); if (!res.ok) throw new Error(await res.text()); const recs = await res.json(); if (!recs.length) { body.innerHTML = '
No recordings found for the selected period.
'; return; } body.innerHTML = ` ${recs.map(r => ` `).join('')}
VESSELMMSIAID START (UTC)END (UTC)MIN DIST
${r.vessel_nombre || '--'} ${r.mmsi} ${r.aid_nombre || '--'} ${r.inicio_utc ? new Date(r.inicio_utc).toUTCString().slice(0,25) : '--'} ${r.fin_utc ? new Date(r.fin_utc).toUTCString().slice(0,25) : 'ACTIVE'} ${r.distancia_min_m != null ? r.distancia_min_m + ' m' : '--'}
`; } catch (e) { body.innerHTML = ``; } }); [ ['btn-close-recordings', 'modal-recordings'], ['btn-close-recordings2', 'modal-recordings'], ].forEach(([btnId, modalId]) => { document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId)); }); // ── MODAL LAMP CATALOG ──────────────────────────────────────────────────── window._lampCache = []; // exposed so the right panel can populate its dropdown function _lampThresholds(vmin, vmax, warn_pct, alarm_pct) { const rng = vmax - vmin; const wp = ((warn_pct ?? 20) / 100); const ap = ((alarm_pct ?? 10) / 100); return { warn: +(vmin + rng * wp).toFixed(3), alarm: +(vmin + rng * ap).toFixed(3) }; } async function loadLamps() { try { const r = await fetch(`${API}/lamps/`); window._lampCache = await r.json(); } catch { window._lampCache = []; } return window._lampCache; } window.loadLamps = loadLamps; async function openLampsModal() { await loadLamps(); renderLampsTable(); document.getElementById('lamps-status').textContent = ''; _showModal('modal-lamps'); } function renderLampsTable() { const tbody = document.getElementById('lamps-body'); tbody.innerHTML = window._lampCache.map(l => ` ${l.manufacturer} ${l.model} ${l.lamp_count} ${l.voltage_min} ${l.voltage_max} ${l.warn_v} V ${l.alarm_v} V ${l.notes || ''} `).join(''); } window.editLamp = function(id) { const l = window._lampCache.find(x => x.id === id); if (!l) return; const tr = document.querySelector(`#lamps-body tr[data-id="${id}"]`); if (!tr) return; tr.innerHTML = ` ${l.warn_v} V / ${l.alarm_v} V `; // Live preview while editing vmin/vmax ['lp-edit-vmin','lp-edit-vmax','lp-edit-wpct','lp-edit-apct'].forEach(i => document.getElementById(i).addEventListener('input', () => { const vmin = parseFloat(document.getElementById('lp-edit-vmin').value); const vmax = parseFloat(document.getElementById('lp-edit-vmax').value); const wpct = parseFloat(document.getElementById('lp-edit-wpct').value); const apct = parseFloat(document.getElementById('lp-edit-apct').value); const cell = document.getElementById('lp-edit-preview'); if (isNaN(vmin) || isNaN(vmax) || vmax <= vmin) { cell.textContent = '—'; return; } const t = _lampThresholds(vmin, vmax, wpct, apct); cell.innerHTML = `${t.warn} V / ${t.alarm} V`; })); }; window.saveLamp = async function(id) { const status = document.getElementById('lamps-status'); const payload = { manufacturer: document.getElementById('lp-edit-mfr').value.trim(), model: document.getElementById('lp-edit-model').value.trim(), lamp_count: parseInt(document.getElementById('lp-edit-count').value) || 1, voltage_min: parseFloat(document.getElementById('lp-edit-vmin').value), voltage_max: parseFloat(document.getElementById('lp-edit-vmax').value), warn_pct: parseFloat(document.getElementById('lp-edit-wpct').value) || 20.0, alarm_pct: parseFloat(document.getElementById('lp-edit-apct').value) || 10.0, notes: document.getElementById('lp-edit-notes').value.trim() || null, }; if (!payload.manufacturer || !payload.model || isNaN(payload.voltage_min) || isNaN(payload.voltage_max)) { status.textContent = 'Manufacturer, model, V min and V max are required.'; status.className = 'save-status error'; return; } try { const r = await fetch(`${API}/lamps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const d = await r.json(); if (!r.ok) throw new Error(d.detail || 'Failed'); await loadLamps(); renderLampsTable(); status.textContent = `Updated ${d.manufacturer} ${d.model}`; status.className = 'save-status ok'; } catch (e) { status.textContent = 'Error: ' + e.message; status.className = 'save-status error'; } }; // Live preview of warn/alarm as the user types vmin/vmax in the new-row inputs function _updateLampPreview() { const vmin = parseFloat(document.getElementById('lp-new-vmin').value); const vmax = parseFloat(document.getElementById('lp-new-vmax').value); const cell = document.getElementById('lp-new-preview'); if (isNaN(vmin) || isNaN(vmax) || vmax <= vmin) { cell.textContent = '—'; return; } const t = _lampThresholds(vmin, vmax); cell.innerHTML = `${t.warn} V / ${t.alarm} V`; } ['lp-new-vmin', 'lp-new-vmax'].forEach(id => { document.getElementById(id)?.addEventListener('input', _updateLampPreview); }); document.getElementById('btn-lamp-add')?.addEventListener('click', async () => { const status = document.getElementById('lamps-status'); const payload = { manufacturer: document.getElementById('lp-new-mfr').value.trim(), model: document.getElementById('lp-new-model').value.trim(), lamp_count: parseInt(document.getElementById('lp-new-count').value) || 1, voltage_min: parseFloat(document.getElementById('lp-new-vmin').value), voltage_max: parseFloat(document.getElementById('lp-new-vmax').value), notes: document.getElementById('lp-new-notes').value.trim() || null, }; if (!payload.manufacturer || !payload.model || isNaN(payload.voltage_min) || isNaN(payload.voltage_max)) { status.textContent = 'Manufacturer, model, V min and V max are required.'; status.className = 'save-status error'; return; } try { const r = await fetch(`${API}/lamps/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const d = await r.json(); if (!r.ok) throw new Error(d.detail || 'Failed'); // Reset row + refresh ['lp-new-mfr','lp-new-model','lp-new-vmin','lp-new-vmax','lp-new-notes'].forEach(i => document.getElementById(i).value = ''); document.getElementById('lp-new-count').value = '1'; document.getElementById('lp-new-preview').textContent = '—'; await loadLamps(); renderLampsTable(); status.textContent = `Added ${d.manufacturer} ${d.model}`; status.className = 'save-status ok'; } catch (e) { status.textContent = 'Error: ' + e.message; status.className = 'save-status error'; } }); window.deleteLamp = async function(id) { if (!confirm('Delete this lamp from the catalog?')) return; const status = document.getElementById('lamps-status'); try { let r = await fetch(`${API}/lamps/${id}`, { method: 'DELETE' }); if (r.status === 409) { // Lamp is in use — ask user if they want to unassign and delete anyway const d = await r.json(); const ok = confirm(`${d.detail}.\n\nUnassign from those aids and delete anyway?`); if (!ok) return; r = await fetch(`${API}/lamps/${id}?force=true`, { method: 'DELETE' }); } const d = await r.json(); if (!r.ok) throw new Error(d.detail || 'Failed'); await loadLamps(); renderLampsTable(); status.textContent = d.unassigned_from ? `Deleted (unassigned from ${d.unassigned_from} aid(s)).` : 'Deleted.'; status.className = 'save-status ok'; } catch (e) { status.textContent = 'Error: ' + e.message; status.className = 'save-status error'; } }; [ ['btn-close-lamps', 'modal-lamps'], ['btn-close-lamps2', 'modal-lamps'], ].forEach(([btnId, modalId]) => { document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId)); }); // Pre-load lamps once at startup so the right panel dropdown is ready immediately. loadLamps(); // ── MODAL CONTACTS CATALOG ──────────────────────────────────────────────── window._contactsCache = []; async function loadContacts() { try { const r = await fetch(`${API}/contacts/`, { headers: { Authorization: `Bearer ${window.Auth?.token()}` }, }); window._contactsCache = await r.json(); } catch { window._contactsCache = []; } return window._contactsCache; } window.loadContacts = loadContacts; window.openContactsModal = async function openContactsModal() { await loadContacts(); renderContactsTable(); document.getElementById('contacts-status').textContent = ''; _showModal('modal-contacts'); } function renderContactsTable() { const tbody = document.getElementById('contacts-body'); tbody.innerHTML = window._contactsCache.map(c => { const match = c.role === 'PORT_AUTHORITY' ? c.port_name : c.company_name; const roleColor = c.role === 'PORT_AUTHORITY' ? 'var(--accent)' : 'var(--cyan)'; return ` ${c.role === 'PORT_AUTHORITY' ? 'PORT' : 'OWNER'} ${c.name} ${match || ''} ${c.email || '—'} ${c.phone || '—'} ${c.whatsapp || '—'} ${c.preferred_channel} `; }).join(''); } window.editContact = function(id) { const c = window._contactsCache.find(x => x.id === id); if (!c) return; const tr = document.querySelector(`#contacts-body tr[data-id="${id}"]`); if (!tr) return; const match = c.role === 'PORT_AUTHORITY' ? c.port_name : c.company_name; tr.innerHTML = ` `; }; window.saveContact = async function(id) { const status = document.getElementById('contacts-status'); const role = document.getElementById('ct-edit-role').value; const name = document.getElementById('ct-edit-name').value.trim(); const match = document.getElementById('ct-edit-match').value.trim(); const email = document.getElementById('ct-edit-email').value.trim(); const phone = document.getElementById('ct-edit-phone').value.trim(); const whatsapp = document.getElementById('ct-edit-whatsapp').value.trim(); const pref = document.getElementById('ct-edit-pref').value; if (!name || !match) { status.textContent = 'Name and Port/Company are required.'; status.className = 'save-status error'; return; } const payload = { role, name, email: email || null, phone: phone || null, whatsapp: whatsapp || null, preferred_channel: pref, port_name: role === 'PORT_AUTHORITY' ? match : null, company_name: role === 'OWNER' ? match : null, }; try { const r = await fetch(`${API}/contacts/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${window.Auth?.token()}`, }, body: JSON.stringify(payload), }); const d = await r.json(); if (!r.ok) throw new Error(d.detail || 'Failed'); await loadContacts(); renderContactsTable(); status.textContent = `Updated ${d.name}`; status.className = 'save-status ok'; } catch (e) { status.textContent = 'Error: ' + e.message; status.className = 'save-status error'; } }; document.getElementById('btn-contact-add')?.addEventListener('click', async () => { const status = document.getElementById('contacts-status'); const role = document.getElementById('ct-new-role').value; const name = document.getElementById('ct-new-name').value.trim(); const match = document.getElementById('ct-new-match').value.trim(); const email = document.getElementById('ct-new-email').value.trim(); const phone = document.getElementById('ct-new-phone').value.trim(); const whatsapp = document.getElementById('ct-new-whatsapp').value.trim(); const pref = document.getElementById('ct-new-pref').value; if (!name || !match) { status.textContent = 'Name and Port/Company are required.'; status.className = 'save-status error'; return; } const payload = { role, name, email: email || null, phone: phone || null, whatsapp: whatsapp || null, preferred_channel: pref, port_name: role === 'PORT_AUTHORITY' ? match : null, company_name: role === 'OWNER' ? match : null, }; try { const r = await fetch(`${API}/contacts/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${window.Auth?.token()}`, }, body: JSON.stringify(payload), }); const d = await r.json(); if (!r.ok) throw new Error(d.detail || 'Failed'); ['ct-new-name','ct-new-match','ct-new-email','ct-new-phone','ct-new-whatsapp'] .forEach(i => document.getElementById(i).value = ''); await loadContacts(); renderContactsTable(); status.textContent = `Added ${d.name}`; status.className = 'save-status ok'; } catch (e) { status.textContent = 'Error: ' + e.message; status.className = 'save-status error'; } }); window.deleteContact = async function(id) { if (!confirm('Delete this contact?')) return; const status = document.getElementById('contacts-status'); try { const r = await fetch(`${API}/contacts/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${window.Auth?.token()}` }, }); if (!r.ok) throw new Error((await r.json()).detail); await loadContacts(); renderContactsTable(); status.textContent = 'Deleted.'; status.className = 'save-status ok'; } catch (e) { status.textContent = 'Error: ' + e.message; status.className = 'save-status error'; } }; [ ['btn-close-contacts', 'modal-contacts'], ['btn-close-contacts2', 'modal-contacts'], ].forEach(([btnId, modalId]) => { document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId)); }); // ── MODAL REPORT ALERT ──────────────────────────────────────────────────── // Opened from each event card's REPORT button. Resolves recipients via the // aid metadata (port authority + owner), lets the operator edit the message, // then opens the chosen channel (mailto / wa.me / sms) and logs the report. const _CHANNEL_LABEL = { EMAIL: 'EMAIL', SMS: 'SMS', WHATSAPP: 'WHATSAPP' }; window.openReportModal = async function(itemEl) { let alertObj; try { alertObj = JSON.parse(itemEl.dataset.alertJson); } catch { window.alert('Invalid alert data'); return; } document.getElementById('modal-report').dataset.alertId = itemEl.dataset.alertId; document.getElementById('modal-report').dataset.alertJson = itemEl.dataset.alertJson; const summary = document.getElementById('report-alert-summary'); summary.innerHTML = ` ${alertObj.tipo || 'ALERT'} ${alertObj.subtipo ? ` — ${alertObj.subtipo}` : ''}
Aid: ${alertObj.aid_nombre || alertObj.aid_id || '—'} ${alertObj.mmsi ? ` · MMSI ${alertObj.mmsi}` : ''}
${alertObj.timestamp || new Date().toISOString()} `; const recDiv = document.getElementById('report-recipients'); recDiv.innerHTML = '
Loading…
'; document.getElementById('report-status').textContent = ''; document.getElementById('report-message').value = 'Loading…'; // Fetch full aid info + recipients in parallel const params = new URLSearchParams(); if (alertObj.aid_id) params.set('aid_id', alertObj.aid_id); if (alertObj.mmsi) params.set('mmsi', alertObj.mmsi); let aidFull = null; try { if (alertObj.aid_id) { const r = await fetch(`${API}/aids/${alertObj.aid_id}`, { headers: { Authorization: `Bearer ${window.Auth?.token()}` }, }); if (r.ok) aidFull = await r.json(); } } catch {} let recipientsResp; try { const r = await fetch(`${API}/alerts/resolve-recipients?${params.toString()}`); recipientsResp = await r.json(); } catch (e) { recipientsResp = { recipients: [] }; } // Live ATON telemetry (voltage, last update, etc.) const aton = window.atonData?.[alertObj.mmsi || aidFull?.mmsi] || {}; // Build a detailed default message from every source we have document.getElementById('report-message').value = _buildReportMessage(alertObj, aidFull, aton, recipientsResp.aid); // Split recipients by role so each section is explicit, and so the user // sees clearly when a role is unmatched (no contact for that port/owner). const portAuths = (recipientsResp.recipients || []).filter(c => c.role === 'PORT_AUTHORITY'); const owners = (recipientsResp.recipients || []).filter(c => c.role === 'OWNER'); const aidInfo = recipientsResp.aid || {}; const sendAllBtn = recipientsResp.recipients?.length ? `` : ''; recDiv.innerHTML = ` ${sendAllBtn} ${portAuths.length ? portAuths.map(c => _renderRecipientRow(c)).join('') : `
⚠ ${aidInfo.puerto_responsable ? `No PORT_AUTHORITY contact configured for port "${aidInfo.puerto_responsable}". Add one in SETTINGS → Contacts.` : 'This aid has no puerto_responsable assigned. Edit the aid to set it.'}
`} ${owners.length ? owners.map(c => _renderRecipientRow(c)).join('') : `
⚠ ${aidInfo.empresa_responsable ? `No OWNER contact configured for company "${aidInfo.empresa_responsable}". Add one in SETTINGS → Contacts.` : 'This aid has no empresa_responsable assigned. Edit the aid to set it.'}
`} `; _showModal('modal-report'); }; function _buildReportMessage(alertObj, aid, aton, aidLite) { const lines = []; lines.push(`╔══ ALERTA ${alertObj.tipo || ''}${alertObj.subtipo ? ' — ' + alertObj.subtipo : ''} ══╗`); lines.push(`Hora: ${alertObj.timestamp || new Date().toISOString()}`); lines.push(''); // Aid identification const a = aid || {}; lines.push('AYUDA A LA NAVEGACIÓN'); lines.push(` Nombre: ${a.nombre || alertObj.aid_nombre || '—'}`); if (a.mmsi || alertObj.mmsi) lines.push(` MMSI: ${a.mmsi || alertObj.mmsi}`); if (a.tipo) lines.push(` Tipo: ${a.tipo} (${a.categoria || '—'})`); if (a.tipo_ais) lines.push(` AIS: ${a.tipo_ais}`); // Positions lines.push(''); lines.push('POSICIÓN'); if (a.lat_nominal != null && a.lon_nominal != null) lines.push(` Oficial S-57: ${a.lat_nominal.toFixed(5)}, ${a.lon_nominal.toFixed(5)}`); if (a.lat_actual != null && a.lon_actual != null) lines.push(` Actual AIS: ${a.lat_actual.toFixed(5)}, ${a.lon_actual.toFixed(5)}`); if (a.desplazamiento_m != null) lines.push(` Desplazamiento: ${a.desplazamiento_m.toFixed(1)} m`); else if (alertObj.desplazamiento_m != null) lines.push(` Desplazamiento: ${alertObj.desplazamiento_m} m`); if (a.radio_borneo_m) lines.push(` Radio borneo: ${a.radio_borneo_m} m`); // Light characteristics if (a.caracteristica_luz || a.alcance_nm) { lines.push(''); lines.push('CARACTERÍSTICAS LUMINOSAS'); if (a.caracteristica_luz) lines.push(` Luz: ${a.caracteristica_luz}`); if (a.alcance_nm) lines.push(` Rango: ${a.alcance_nm} NM`); } // Lamp + battery if (a.lamp || aton.voltage_v != null || alertObj.voltage_v != null) { lines.push(''); lines.push('LÁMPARA Y BATERÍA'); if (a.lamp) { lines.push(` Modelo: ${a.lamp.manufacturer} ${a.lamp.model} (×${a.lamp.lamp_count})`); lines.push(` Vmin/Vmax nominal: ${a.lamp.voltage_min} V / ${a.lamp.voltage_max} V`); lines.push(` Umbral WARN: ${a.lamp.warn_v} V · ALARM: ${a.lamp.alarm_v} V`); } const v = aton.voltage_v ?? alertObj.voltage_v; if (v != null) { lines.push(` Voltaje actual: ${v} V` + (aton.last_update ? ` (${aton.last_update.slice(11,19)} UTC)` : '')); } if (aton.battery_low) lines.push(` ⚠ Battery_low flag activo`); if (aton.light_ok === false) lines.push(` ⚠ LIGHT FAULT reportado por AIS`); } // Responsibility lines.push(''); lines.push('RESPONSABLES'); lines.push(` Puerto: ${a.puerto_responsable || '(no asignado)'}`); lines.push(` Empresa: ${a.empresa_responsable || '(no asignada)'}`); if (a.observaciones) { lines.push(''); lines.push('OBSERVACIONES'); lines.push(` ${a.observaciones}`); } lines.push(''); lines.push('Por favor verifique en sitio y confirme.'); lines.push(`Reportado por: ${window.Auth?.session?.nombre || 'operator'}`); return lines.join('\n'); } function _renderRecipientRow(c) { const safeName = c.name.replace(/'/g, "\\'"); const channels = []; const has = (c.email ? 1 : 0) + (c.whatsapp ? 1 : 0) + (c.phone ? 1 : 0); if (c.email) channels.push(``); if (c.whatsapp) channels.push(``); if (c.phone) channels.push(``); if (has > 1) channels.push(``); const roleLabel = c.role === 'PORT_AUTHORITY' ? 'PORT AUTHORITY' : 'OWNER'; const roleColor = c.role === 'PORT_AUTHORITY' ? 'var(--accent)' : 'var(--cyan)'; return `
preferred: ${_CHANNEL_LABEL[c.preferred_channel] || c.preferred_channel}
${c.email ? '✉ '+c.email+' ' : ''}${c.phone ? '☎ '+c.phone+' ' : ''}${c.whatsapp ? '🟢 '+c.whatsapp : ''}
${channels.join('') || 'No channels configured'}
`; } window.sendReportToAllRecipients = function() { // Iterate the CURRENTLY-CHECKED recipient checkboxes synchronously so all // channel-opens happen inside the user-gesture tick (popup blocker happy). const modal = document.getElementById('modal-report'); const alertObj = JSON.parse(modal.dataset.alertJson); const message = document.getElementById('report-message').value; const subject = `[AidsMonitoring] ${alertObj.tipo || 'Alert'} — ${alertObj.aid_nombre || alertObj.aid_id || ''}`; const fires = []; document.querySelectorAll('.rcp-check:checked').forEach(cb => { const ch = cb.dataset.preferred; const email = cb.dataset.email; const whatsapp = cb.dataset.whatsapp; const phone = cb.dataset.phone; let recipient = null, channel = ch; if (ch === 'EMAIL') recipient = email; else if (ch === 'WHATSAPP') recipient = whatsapp; else if (ch === 'SMS') recipient = phone; if (!recipient) { // fallback to whatever is set if (email) { recipient = email; channel = 'EMAIL'; } else if (whatsapp) { recipient = whatsapp; channel = 'WHATSAPP'; } else if (phone) { recipient = phone; channel = 'SMS'; } } if (recipient) fires.push({ contactId: cb.dataset.contactId, contactName: cb.dataset.contactName, channel, recipient, }); }); if (!fires.length) { document.getElementById('report-status').textContent = 'No recipients selected.'; document.getElementById('report-status').className = 'save-status error'; return; } // Open every channel first (single tick), then log them async. // Also collect the URLs so we can offer manual click-through fallbacks // if Chrome's popup blocker swallowed one. const fallbacks = []; for (const f of fires) { const url = _openChannel(f.channel, f.recipient, subject, message); if (url) fallbacks.push({ ...f, url }); } for (const f of fires) _logAndMarkReport(f.contactId, f.contactName, f.channel, f.recipient, alertObj, message); // Render fallback links so the operator can manually click any blocked one const status = document.getElementById('report-status'); status.className = 'save-status ok'; status.innerHTML = `Tried to open ${fires.length} channel(s). If some didn't open (Chrome popup blocker), click here:
${fallbacks.map(f => ` ${f.channel} → ${f.contactName} `).join('')}`; }; window.sendReportAll = async function(contactId, contactName, email, whatsapp, phone) { // Open ALL channels first within the same user-gesture tick — browsers // allow multiple popups when triggered from a single click. Logging // happens after, async, so it never blocks the channel opening. const modal = document.getElementById('modal-report'); const alertObj = JSON.parse(modal.dataset.alertJson); const message = document.getElementById('report-message').value; const subject = `[AidsMonitoring] ${alertObj.tipo || 'Alert'} — ${alertObj.aid_nombre || alertObj.aid_id || ''}`; if (email) _openChannel('EMAIL', email, subject, message); if (whatsapp) _openChannel('WHATSAPP', whatsapp, subject, message); if (phone) _openChannel('SMS', phone, subject, message); // Audit log + UI badge for each — does not depend on opening order if (email) _logAndMarkReport(contactId, contactName, 'EMAIL', email, alertObj, message); if (whatsapp) _logAndMarkReport(contactId, contactName, 'WHATSAPP', whatsapp, alertObj, message); if (phone) _logAndMarkReport(contactId, contactName, 'SMS', phone, alertObj, message); }; async function _logAndMarkReport(contactId, contactName, channel, recipient, alertObj, message) { let smtpSent = false, smtpDetail = ''; try { const r = await fetch(`${API}/alerts/report`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${window.Auth?.token()}`, }, body: JSON.stringify({ alert_id: alertObj.id, alert_tipo: alertObj.tipo, alert_subtipo: alertObj.subtipo, aid_id: alertObj.aid_id, aid_nombre: alertObj.aid_nombre, mmsi: alertObj.mmsi, contact_id: contactId, contact_name: contactName, channel, recipient, message, }), }); if (r.ok) { const d = await r.json(); smtpSent = !!d.smtp_sent; smtpDetail = d.smtp_detail || ''; } } catch {} // EMAIL fallback: if backend SMTP didn't send, open the operator's mail client if (channel === 'EMAIL' && !smtpSent) { const subject = `[AidsMonitoring] ${alertObj.tipo || 'Alert'} — ${alertObj.aid_nombre || alertObj.aid_id || ''}`; _openChannel('EMAIL', recipient, subject, message); } const modal = document.getElementById('modal-report'); const itemEl = document.querySelector(`.event-item[data-alert-id="${modal.dataset.alertId}"]`); if (itemEl) { const badge = itemEl.querySelector('.ev-reported'); badge.classList.remove('hidden'); const ts = new Date().toUTCString().slice(17,25); const usr = window.Auth?.session?.nombre || 'operator'; const tag = (channel === 'EMAIL' && smtpSent) ? 'EMAIL ✉ SMTP' : channel; badge.innerHTML += `
✓ Reported to ${contactName} via ${tag} — ${ts} by ${usr}
`; itemEl.classList.add('ev-reported-state'); } } // Fire a channel using a synthetic anchor click. Browsers treat // anchor.click() inside a user-gesture handler as user-initiated, so the // popup blocker is more lenient than with window.open() — that's the only // way to reliably fire multiple channels from one click. Returns the URL // fired (so the caller can offer a fallback link if Chrome still blocks it). function _openChannel(channel, recipient, subject, message) { let url = null; if (channel === 'EMAIL') { url = `mailto:${recipient}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(message)}`; } else if (channel === 'WHATSAPP') { const phone = recipient.replace(/[^0-9]/g, ''); url = `https://web.whatsapp.com/send?phone=${phone}&text=${encodeURIComponent(message)}`; } else if (channel === 'SMS') { url = `sms:${recipient}?body=${encodeURIComponent(message)}`; } if (!url) return null; const a = document.createElement('a'); a.href = url; a.target = '_blank'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); return url; } window.sendReport = async function(contactId, contactName, channel, recipient) { const modal = document.getElementById('modal-report'); const alertObj = JSON.parse(modal.dataset.alertJson); const message = document.getElementById('report-message').value; const subject = `[AidsMonitoring] ${alertObj.tipo || 'Alert'} — ${alertObj.aid_nombre || alertObj.aid_id || ''}`; const status = document.getElementById('report-status'); // For EMAIL we POST first; if backend SMTP is configured, the email is // sent server-side and we skip mailto. WhatsApp/SMS still use anchors. if (channel !== 'EMAIL') { _openChannel(channel, recipient, subject, message); } // Log the report (audit trail) try { await fetch(`${API}/alerts/report`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${window.Auth?.token()}`, }, body: JSON.stringify({ alert_id: alertObj.id, alert_tipo: alertObj.tipo, alert_subtipo: alertObj.subtipo, aid_id: alertObj.aid_id, aid_nombre: alertObj.aid_nombre, mmsi: alertObj.mmsi, contact_id: contactId, contact_name: contactName, channel: channel, recipient: recipient, message: message, }), }); } catch (e) { console.warn('Could not log report:', e); } // Mark the originating event card as REPORTED const itemEl = document.querySelector(`.event-item[data-alert-id="${modal.dataset.alertId}"]`); if (itemEl) { const badge = itemEl.querySelector('.ev-reported'); badge.classList.remove('hidden'); const ts = new Date().toUTCString().slice(17,25); const usr = window.Auth?.session?.nombre || 'operator'; const prev = badge.innerHTML; badge.innerHTML = (prev || '') + `
✓ Reported to ${contactName} via ${channel} — ${ts} by ${usr}
`; itemEl.classList.add('ev-reported-state'); // Hide the REPORT button after first send (still visible if more recipients) } status.textContent = `Sent to ${contactName} via ${channel}.`; status.className = 'save-status ok'; }; [ ['btn-close-report', 'modal-report'], ['btn-close-report2', 'modal-report'], ].forEach(([btnId, modalId]) => { document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId)); }); // ── STATUS BAR ──────────────────────────────────────────────────────────── function setDot(dotId, labelId, text, color) { const dot = document.getElementById(dotId); const lbl = document.getElementById(labelId); if (dot) { dot.className = `sb-dot ${color}`; } if (lbl) lbl.textContent = text; } function updateStatusBar() { const user = window.Auth?.session; const sbUser = document.getElementById('sb-user'); if (sbUser) sbUser.textContent = user ? `${user.nombre} · ${user.role}` : ''; } // Poll /ais/stats every 5 s to show real signal state in status bar async function _pollAisStats() { try { const r = await fetch('/ais/stats'); const d = await r.json(); const src = (d.ais_source || 'SIMULATOR').toUpperCase(); if (src === 'SIMULATOR') { setDot('sb-dot-ais', 'sb-ais', 'AIS: SIMULATOR', 'yellow'); return; } const msgs = d.sentences_received ?? 0; const secs = d.seconds_since_last; const hasSignal = d.signal === true; const running = d.catcher_running; let label, color; if (!running) { label = `AIS: ${src} — no catcher`; color = 'grey'; } else if (!hasSignal) { const ago = secs != null ? ` (${secs}s)` : ' (sin señal)'; label = `AIS: ${src}${ago}`; color = 'red'; } else { label = `AIS: ${src} ✓ ${msgs} msg`; color = 'green'; } setDot('sb-dot-ais', 'sb-ais', label, color); } catch (e) { setDot('sb-dot-ais', 'sb-ais', 'AIS: ?', 'grey'); } } document.addEventListener('DOMContentLoaded', () => { updateStatusBar(); _pollAisStats(); setInterval(_pollAisStats, 5000); }); // Sync WS dot to ws-status indicator const _origWsStatus = document.getElementById('ws-status'); if (_origWsStatus) { new MutationObserver(() => { const online = _origWsStatus.classList.contains('online'); setDot('sb-dot-ws', 'sb-ws', `WS: ${online ? 'ONLINE' : 'OFFLINE'}`, online ? 'green' : 'grey'); }).observe(_origWsStatus, { childList: true, attributes: true, classList: true }); }