cfd94f905a
- Restrict CORS to localhost origins (was allow_origins=[*])
- Require valid JWT on WebSocket /ws (anonymous no longer gets admin view)
- Fix path traversal in delete_cell(): resolve() + parent check
- Validate cell_id format in /charts/download-noaa/{cell_id}
- Exclude charts/ and Cartas/ from git (keep US1GC09M world overview)
- Add NOAA ENC Portal external link in charts catalog tab
- Untrack __pycache__/, .db, .claude/ session files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1638 lines
73 KiB
JavaScript
1638 lines
73 KiB
JavaScript
'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 = '<div style="color:var(--text-muted);padding:8px">Loading...</div>';
|
||
try {
|
||
const res = await fetch(`${API}/auth/users`, {
|
||
headers: { Authorization: `Bearer ${window.Auth.token()}` }
|
||
});
|
||
if (res.status === 401) { body.innerHTML = '<div style="color:var(--red);padding:8px">Session expired — please log in again (F5).</div>'; 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 = `
|
||
<table class="users-table">
|
||
<thead>
|
||
<tr>
|
||
<th>USERNAME</th><th>FULL NAME</th><th>EMAIL</th>
|
||
<th>ROLE</th><th>COMPANY</th><th>STATUS</th><th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${users.map(u => `
|
||
<tr data-username="${u.username}">
|
||
<td class="mono">${u.username}</td>
|
||
<td>${u.nombre}</td>
|
||
<td class="mono" style="font-size:0.72rem">${u.email || '--'}</td>
|
||
<td><span class="session-role ${roleClass(u.role)}">${u.role}</span></td>
|
||
<td style="font-size:0.72rem;color:var(--text-muted)">${u.company_id ? (companyMap[u.company_id] || u.company_id) : '--'}</td>
|
||
<td><span style="color:${u.activo ? 'var(--green)' : 'var(--red)'}">${u.activo ? 'ACTIVE' : 'INACTIVE'}</span></td>
|
||
<td style="display:flex;gap:4px;justify-content:flex-end">
|
||
<button class="chart-row-btn" onclick="editUser('${u.username}','${u.nombre}','${u.email||''}','${u.role}','${u.company_id||''}',${u.activo})">EDIT</button>
|
||
${u.username !== 'admin' ? `<button class="chart-row-btn danger" onclick="deleteUser('${u.username}')">DELETE</button>` : ''}
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
} catch (e) {
|
||
body.innerHTML = `<div class="modal-error" style="display:block">Error loading users: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
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 = '<option value="">— Select company —</option>' +
|
||
_ufCompanies.map(c => `<option value="${c.id}">${c.name}</option>`).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 = '<tr><td colspan="4" style="color:var(--text-muted)">Loading…</td></tr>';
|
||
try {
|
||
const r = await fetch(`${API}/charts/noaa-catalog`);
|
||
const cells = await r.json();
|
||
tbody.innerHTML = cells.map(c => `
|
||
<tr id="crow-${c.id}">
|
||
<td style="font-family:monospace;color:var(--accent)">${c.id}</td>
|
||
<td>${c.description}<br><span style="font-size:0.68rem;color:var(--text-muted)">1:${c.scale.toLocaleString()}</span></td>
|
||
<td>${c.installed
|
||
? '<span style="color:#4caf50">INSTALLED</span>'
|
||
: '<span style="color:var(--text-muted)">—</span>'}</td>
|
||
<td>
|
||
${c.installed
|
||
? `<button class="chart-row-btn danger" onclick="deleteCell('${c.id}')">REMOVE</button>`
|
||
: `<button class="chart-row-btn" onclick="downloadNOAA('${c.id}')">DOWNLOAD</button>`}
|
||
</td>
|
||
</tr>`).join('');
|
||
} catch(e) {
|
||
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--red)">Error loading catalog</td></tr>';
|
||
}
|
||
}
|
||
|
||
async function loadInstalledCells() {
|
||
const tbody = document.getElementById('installed-body');
|
||
const empty = document.getElementById('installed-empty');
|
||
empty.style.display = 'none';
|
||
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--text-muted)">Loading…</td></tr>';
|
||
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 => `
|
||
<tr id="crow-${c.id}">
|
||
<td style="font-family:monospace;color:var(--accent)">${c.id}</td>
|
||
<td id="feat-${c.id}">${c.features} features</td>
|
||
<td>
|
||
<select class="form-input-select" style="padding:2px 6px;font-size:0.7rem"
|
||
onchange="setCellRegion('${c.id}', this.value)">
|
||
<option value="A" ${c.region === 'A' ? 'selected' : ''}>A — Europe/Asia (Red=Port)</option>
|
||
<option value="B" ${c.region === 'B' ? 'selected' : ''}>B — Americas/Japan (Green=Port)</option>
|
||
</select>
|
||
</td>
|
||
<td style="white-space:nowrap;display:flex;gap:4px">
|
||
<button class="chart-row-btn" id="rbtn-${c.id}" onclick="rebuildCell('${c.id}')">REBUILD</button>
|
||
<button class="chart-row-btn danger" onclick="deleteCell('${c.id}')">REMOVE</button>
|
||
</td>
|
||
</tr>`).join('');
|
||
} catch(e) {
|
||
tbody.innerHTML = `<tr><td colspan="4" style="color:var(--red)">Error: ${e.message}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
// ── 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 = `<span style="color:var(--cyan)">Rebuilding ${cellId}…</span>`;
|
||
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 = `<span style="color:#4caf50">✓ ${cellId} rebuilt</span>`;
|
||
// 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 = `<span style="color:var(--red)">✗ ${e.message}</span>`;
|
||
} 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 = `<span style="color:var(--cyan)">Rebuilding all charts… (may take a moment)</span>`;
|
||
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 = `<span style="color:#4caf50">✓ ${n} chart(s) rebuilt: ${data.rebuilt?.join(', ')}</span>`;
|
||
loadInstalledCells(); // refresh table
|
||
window.reloadAllChartLayers?.();
|
||
} catch(e) {
|
||
status.innerHTML = `<span style="color:var(--red)">✗ ${e.message}</span>`;
|
||
} 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 = `<span style="color:var(--cyan)">Downloading ${cellId} from NOAA…</span>`;
|
||
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 = `<span style="color:#4caf50">✓ ${cellId} installed — ${data.installed?.join(', ')}</span>`;
|
||
loadNOAACatalog();
|
||
window.reloadAllChartLayers?.();
|
||
} catch(e) {
|
||
status.innerHTML = `<span style="color:var(--red)">✗ ${e.message}</span>`;
|
||
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 = `<span style="color:var(--text-muted)">${cellId} removed</span>`;
|
||
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 = '<span style="color:var(--red)">Select a file first</span>'; return; }
|
||
status.innerHTML = `<span style="color:var(--cyan)">Uploading ${f.name}…</span>`;
|
||
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 = `<span style="color:#4caf50">✓ Installed: ${data.installed.join(', ')}</span>`;
|
||
loadNOAACatalog();
|
||
window.reloadAllChartLayers?.();
|
||
} catch(e) {
|
||
status.innerHTML = `<span style="color:var(--red)">✗ ${e.message}</span>`;
|
||
}
|
||
});
|
||
|
||
// ── 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 = '<div style="color:var(--text-muted);font-size:0.78rem">No serial ports detected.</div>';
|
||
} 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 =>
|
||
`<div class="eq-sentence">${s}</div>`).join('');
|
||
return `
|
||
<div class="eq-card">
|
||
<div class="eq-card-header">
|
||
<span class="eq-type" style="color:${color}">${icon} ${d.device_type}</span>
|
||
<span class="eq-port">${d.port}${d.baud ? ' @ ' + d.baud + ' baud' : ''}</span>
|
||
</div>
|
||
<div class="eq-desc">${d.description}</div>
|
||
<div class="eq-hwid">${d.port_desc || ''}</div>
|
||
${sentences ? `<div class="eq-sentences">${sentences}</div>` : ''}
|
||
</div>`;
|
||
}).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 =
|
||
'<div style="color:var(--text-muted);font-size:0.75rem;padding:20px 0;text-align:center">Press SEARCH to load recordings.</div>';
|
||
_showModal('modal-recordings');
|
||
}
|
||
|
||
document.getElementById('btn-rec-search')?.addEventListener('click', async () => {
|
||
const body = document.getElementById('modal-recordings-body');
|
||
body.innerHTML = '<div style="color:var(--text-muted);padding:8px">Loading...</div>';
|
||
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 = '<div style="color:var(--text-muted);font-size:0.75rem;padding:20px 0;text-align:center">No recordings found for the selected period.</div>';
|
||
return;
|
||
}
|
||
body.innerHTML = `
|
||
<table class="users-table">
|
||
<thead><tr>
|
||
<th>VESSEL</th><th>MMSI</th><th>AID</th>
|
||
<th>START (UTC)</th><th>END (UTC)</th><th>MIN DIST</th>
|
||
</tr></thead>
|
||
<tbody>${recs.map(r => `
|
||
<tr>
|
||
<td>${r.vessel_nombre || '--'}</td>
|
||
<td class="mono">${r.mmsi}</td>
|
||
<td>${r.aid_nombre || '--'}</td>
|
||
<td class="mono" style="font-size:0.68rem">${r.inicio_utc ? new Date(r.inicio_utc).toUTCString().slice(0,25) : '--'}</td>
|
||
<td class="mono" style="font-size:0.68rem">${r.fin_utc ? new Date(r.fin_utc).toUTCString().slice(0,25) : 'ACTIVE'}</td>
|
||
<td>${r.distancia_min_m != null ? r.distancia_min_m + ' m' : '--'}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>`;
|
||
} catch (e) {
|
||
body.innerHTML = `<div class="modal-error" style="display:block">Error: ${e.message}</div>`;
|
||
}
|
||
});
|
||
|
||
[
|
||
['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 => `
|
||
<tr data-id="${l.id}">
|
||
<td>${l.manufacturer}</td>
|
||
<td>${l.model}</td>
|
||
<td>${l.lamp_count}</td>
|
||
<td>${l.voltage_min}</td>
|
||
<td>${l.voltage_max}</td>
|
||
<td style="color:var(--yellow)">${l.warn_v} V</td>
|
||
<td style="color:var(--red)">${l.alarm_v} V</td>
|
||
<td style="font-size:0.7rem;color:var(--text-muted)">${l.notes || ''}</td>
|
||
<td style="display:flex;gap:4px">
|
||
<button class="chart-row-btn" onclick="editLamp('${l.id}')">EDIT</button>
|
||
<button class="chart-row-btn danger" onclick="deleteLamp('${l.id}')">DEL</button>
|
||
</td>
|
||
</tr>`).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 = `
|
||
<td><input class="form-input" id="lp-edit-mfr" value="${l.manufacturer}"></td>
|
||
<td><input class="form-input" id="lp-edit-model" value="${l.model}"></td>
|
||
<td><input class="form-input" id="lp-edit-count" type="number" value="${l.lamp_count}" style="width:60px"></td>
|
||
<td><input class="form-input" id="lp-edit-vmin" type="number" step="0.1" value="${l.voltage_min}" style="width:70px"></td>
|
||
<td><input class="form-input" id="lp-edit-vmax" type="number" step="0.1" value="${l.voltage_max}" style="width:70px"></td>
|
||
<td><input class="form-input" id="lp-edit-wpct" type="number" step="1" min="1" max="50" value="${l.warn_pct ?? 20}" style="width:55px" title="Warn %"></td>
|
||
<td><input class="form-input" id="lp-edit-apct" type="number" step="1" min="1" max="50" value="${l.alarm_pct ?? 10}" style="width:55px" title="Alarm %"></td>
|
||
<td style="font-size:0.7rem" id="lp-edit-preview">${l.warn_v} V / ${l.alarm_v} V</td>
|
||
<td><input class="form-input" id="lp-edit-notes" value="${l.notes || ''}"></td>
|
||
<td style="display:flex;gap:4px">
|
||
<button class="chart-row-btn" onclick="saveLamp('${id}')">SAVE</button>
|
||
<button class="chart-row-btn danger" onclick="renderLampsTable()">CANCEL</button>
|
||
</td>`;
|
||
// 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 = `<span style="color:var(--yellow)">${t.warn} V</span> / <span style="color:var(--red)">${t.alarm} V</span>`;
|
||
}));
|
||
};
|
||
|
||
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 = `<span style="color:var(--yellow)">${t.warn} V</span> / <span style="color:var(--red)">${t.alarm} V</span>`;
|
||
}
|
||
['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 `
|
||
<tr data-id="${c.id}">
|
||
<td><span style="color:${roleColor};font-size:0.7rem">${c.role === 'PORT_AUTHORITY' ? 'PORT' : 'OWNER'}</span></td>
|
||
<td>${c.name}</td>
|
||
<td>${match || '<span style="color:var(--red)">—</span>'}</td>
|
||
<td style="font-size:0.7rem">${c.email || '—'}</td>
|
||
<td style="font-size:0.7rem">${c.phone || '—'}</td>
|
||
<td style="font-size:0.7rem">${c.whatsapp || '—'}</td>
|
||
<td style="font-size:0.7rem">${c.preferred_channel}</td>
|
||
<td style="display:flex;gap:4px">
|
||
<button class="chart-row-btn" onclick="editContact('${c.id}')">EDIT</button>
|
||
<button class="chart-row-btn danger" onclick="deleteContact('${c.id}')">DEL</button>
|
||
</td>
|
||
</tr>`;
|
||
}).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 = `
|
||
<td>
|
||
<select class="form-input-select" id="ct-edit-role">
|
||
<option value="PORT_AUTHORITY" ${c.role === 'PORT_AUTHORITY' ? 'selected' : ''}>PORT AUTH</option>
|
||
<option value="OWNER" ${c.role === 'OWNER' ? 'selected' : ''}>OWNER</option>
|
||
</select>
|
||
</td>
|
||
<td><input class="form-input" id="ct-edit-name" value="${c.name}"></td>
|
||
<td><input class="form-input" id="ct-edit-match" value="${match || ''}"></td>
|
||
<td><input class="form-input" id="ct-edit-email" value="${c.email || ''}"></td>
|
||
<td><input class="form-input" id="ct-edit-phone" value="${c.phone || ''}"></td>
|
||
<td><input class="form-input" id="ct-edit-whatsapp" value="${c.whatsapp || ''}"></td>
|
||
<td>
|
||
<select class="form-input-select" id="ct-edit-pref">
|
||
<option value="EMAIL" ${c.preferred_channel === 'EMAIL' ? 'selected' : ''}>EMAIL</option>
|
||
<option value="WHATSAPP" ${c.preferred_channel === 'WHATSAPP' ? 'selected' : ''}>WHATSAPP</option>
|
||
<option value="SMS" ${c.preferred_channel === 'SMS' ? 'selected' : ''}>SMS</option>
|
||
</select>
|
||
</td>
|
||
<td style="display:flex;gap:4px">
|
||
<button class="chart-row-btn" onclick="saveContact('${id}')">SAVE</button>
|
||
<button class="chart-row-btn danger" onclick="renderContactsTable()">CANCEL</button>
|
||
</td>`;
|
||
};
|
||
|
||
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 = `
|
||
<strong>${alertObj.tipo || 'ALERT'}</strong>
|
||
${alertObj.subtipo ? ` — ${alertObj.subtipo}` : ''}<br>
|
||
Aid: ${alertObj.aid_nombre || alertObj.aid_id || '—'}
|
||
${alertObj.mmsi ? ` · MMSI ${alertObj.mmsi}` : ''}<br>
|
||
<span style="color:var(--text-muted)">${alertObj.timestamp || new Date().toISOString()}</span>
|
||
`;
|
||
|
||
const recDiv = document.getElementById('report-recipients');
|
||
recDiv.innerHTML = '<div style="color:var(--text-muted);font-size:0.78rem">Loading…</div>';
|
||
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
|
||
? `<button class="chart-row-btn ch-all" style="margin-bottom:10px" onclick="sendReportToAllRecipients()">⚡ SEND TO SELECTED — uncheck below to skip a recipient</button>`
|
||
: '';
|
||
|
||
recDiv.innerHTML = `
|
||
${sendAllBtn}
|
||
<div class="modal-section-label">PORT AUTHORITY${aidInfo.puerto_responsable ? ` — "${aidInfo.puerto_responsable}"` : ''}</div>
|
||
${portAuths.length
|
||
? portAuths.map(c => _renderRecipientRow(c)).join('')
|
||
: `<div style="color:var(--yellow);font-size:0.72rem;padding:6px 10px;background:#1a1500;border-radius:3px;margin-bottom:8px">
|
||
⚠ ${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.'}
|
||
</div>`}
|
||
|
||
<div class="modal-section-label" style="margin-top:8px">OWNER${aidInfo.empresa_responsable ? ` — "${aidInfo.empresa_responsable}"` : ''}</div>
|
||
${owners.length
|
||
? owners.map(c => _renderRecipientRow(c)).join('')
|
||
: `<div style="color:var(--yellow);font-size:0.72rem;padding:6px 10px;background:#1a1500;border-radius:3px">
|
||
⚠ ${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.'}
|
||
</div>`}
|
||
`;
|
||
|
||
_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(`<button class="chart-row-btn ch-email" onclick="sendReport('${c.id}','${safeName}','EMAIL','${c.email}')">✉ EMAIL</button>`);
|
||
if (c.whatsapp) channels.push(`<button class="chart-row-btn ch-whatsapp" onclick="sendReport('${c.id}','${safeName}','WHATSAPP','${c.whatsapp}')">🟢 WHATSAPP</button>`);
|
||
if (c.phone) channels.push(`<button class="chart-row-btn ch-sms" onclick="sendReport('${c.id}','${safeName}','SMS','${c.phone}')">☎ SMS</button>`);
|
||
if (has > 1) channels.push(`<button class="chart-row-btn ch-all" onclick="sendReportAll('${c.id}','${safeName}','${c.email||''}','${c.whatsapp||''}','${c.phone||''}')">⚡ ALL</button>`);
|
||
const roleLabel = c.role === 'PORT_AUTHORITY' ? 'PORT AUTHORITY' : 'OWNER';
|
||
const roleColor = c.role === 'PORT_AUTHORITY' ? 'var(--accent)' : 'var(--cyan)';
|
||
return `
|
||
<div class="report-recipient" data-contact-id="${c.id}" style="background:#0e1a2a;border:1px solid #1f3550;border-radius:3px;padding:8px 10px;margin-bottom:6px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||
<input type="checkbox" class="rcp-check" checked
|
||
data-contact-id="${c.id}"
|
||
data-contact-name="${safeName}"
|
||
data-email="${c.email || ''}"
|
||
data-whatsapp="${c.whatsapp || ''}"
|
||
data-phone="${c.phone || ''}"
|
||
data-preferred="${c.preferred_channel}"
|
||
style="width:14px;height:14px;cursor:pointer">
|
||
<span><strong>${c.name}</strong> <span style="color:${roleColor};font-size:0.7rem">(${roleLabel})</span></span>
|
||
</label>
|
||
<span style="color:var(--text-muted);font-size:0.7rem">preferred: ${_CHANNEL_LABEL[c.preferred_channel] || c.preferred_channel}</span>
|
||
</div>
|
||
<div style="font-size:0.72rem;color:var(--text-muted);margin-bottom:6px;padding-left:22px">
|
||
${c.email ? '✉ '+c.email+' ' : ''}${c.phone ? '☎ '+c.phone+' ' : ''}${c.whatsapp ? '🟢 '+c.whatsapp : ''}
|
||
</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;padding-left:22px">${channels.join('') || '<span style="color:var(--text-muted);font-size:0.7rem">No channels configured</span>'}</div>
|
||
</div>`;
|
||
}
|
||
|
||
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:<br>
|
||
${fallbacks.map(f =>
|
||
`<a href="${f.url}" target="_blank" rel="noopener" style="display:inline-block;margin:4px 6px 0 0;color:var(--accent);text-decoration:underline;font-size:0.72rem">
|
||
${f.channel} → ${f.contactName}
|
||
</a>`).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 += `<div>✓ Reported to <strong>${contactName}</strong> via ${tag} — ${ts} by ${usr}</div>`;
|
||
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 || '') + `<div>✓ Reported to <strong>${contactName}</strong> via ${channel} — ${ts} by ${usr}</div>`;
|
||
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 });
|
||
}
|