'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 = `
USERNAME FULL NAME EMAIL
ROLE COMPANY STATUS
${users.map(u => `
${u.username}
${u.nombre}
${u.email || '--'}
${u.role}
${u.company_id ? (companyMap[u.company_id] || u.company_id) : '--'}
${u.activo ? 'ACTIVE' : 'INACTIVE'}
EDIT
${u.username !== 'admin' ? `DELETE ` : ''}
`).join('')}
`;
} catch (e) {
body.innerHTML = `Error loading users: ${e.message}
`;
}
}
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 = '— Select company — ' +
_ufCompanies.map(c => `${c.name} `).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
? `REMOVE `
: `DOWNLOAD `}
`).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
A — Europe/Asia (Red=Port)
B — Americas/Japan (Green=Port)
REBUILD
REMOVE
`).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 `
${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 = `
VESSEL MMSI AID
START (UTC) END (UTC) MIN DIST
${recs.map(r => `
${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' : '--'}
`).join('')}
`;
} catch (e) {
body.innerHTML = `Error: ${e.message}
`;
}
});
[
['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 || ''}
EDIT
DEL
`).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
SAVE
CANCEL
`;
// 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}
EDIT
DEL
`;
}).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 = `
PORT AUTH
OWNER
EMAIL
WHATSAPP
SMS
SAVE
CANCEL
`;
};
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
? `⚡ SEND TO SELECTED — uncheck below to skip a recipient `
: '';
recDiv.innerHTML = `
${sendAllBtn}
PORT AUTHORITY${aidInfo.puerto_responsable ? ` — "${aidInfo.puerto_responsable}"` : ''}
${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.'}
`}
OWNER${aidInfo.empresa_responsable ? ` — "${aidInfo.empresa_responsable}"` : ''}
${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(`✉ EMAIL `);
if (c.whatsapp) channels.push(`🟢 WHATSAPP `);
if (c.phone) channels.push(`☎ SMS `);
if (has > 1) channels.push(`⚡ ALL `);
const roleLabel = c.role === 'PORT_AUTHORITY' ? 'PORT AUTHORITY' : 'OWNER';
const roleColor = c.role === 'PORT_AUTHORITY' ? 'var(--accent)' : 'var(--cyan)';
return `
${c.name} (${roleLabel})
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 });
}