';
_showOverlay('modal-edit');
// Refresh caches so the dropdowns show up-to-date contacts and lamps
try {
await Promise.all([
window.loadContacts ? window.loadContacts() : Promise.resolve(),
window.loadLamps ? window.loadLamps() : Promise.resolve(),
]);
} catch {}
body.innerHTML = buildEditForm(aidData);
document.getElementById('btn-edit-submit').onclick = () => this.submitEdit(aidData);
},
closeEdit() { _hideOverlay(); },
async submitEdit(aidData) {
const btn = document.getElementById('btn-edit-submit');
const status = document.getElementById('edit-save-status');
const motivo = document.getElementById('ef-motivo')?.value?.trim();
if (!motivo) {
status.textContent = 'Reason for change is required.';
status.className = 'save-status err';
return;
}
btn.disabled = true;
btn.textContent = 'SAVING...';
status.textContent = '';
// MMSI: empty string → unassign (sends "" so backend nulls the column).
// Non-empty → must be digits-only; backend enforces uniqueness across aids.
const mmsiVal = v('ef-mmsi');
if (mmsiVal && !/^\d{6,9}$/.test(mmsiVal)) {
status.textContent = 'MMSI must be 6–9 digits, or blank to unassign.';
status.className = 'save-status err';
btn.disabled = false; btn.textContent = 'SAVE CHANGES';
return;
}
const payload = {
puerto_responsable: v('ef-puerto') || null,
empresa_responsable: v('ef-empresa') || null,
caracteristica_luz: v('ef-luz') || null,
alcance_nm: flt('ef-alcance'),
radio_borneo_m: flt('ef-borneo'),
displacement_warn_m: flt('ef-drift-warn'),
displacement_alarm_m: flt('ef-drift-alarm'),
signal_loss_min: flt('ef-signal-loss') ? parseInt(v('ef-signal-loss')) : null,
din3_function: v('ef-din3') || null,
din4_function: v('ef-din4') || null,
observaciones: v('ef-obs') || null,
lamp_id: v('ef-lamp') || null,
mmsi: mmsiVal, // "" unassigns; backend handles
tipo_ais: mmsiVal ? 'ATON_21' : 'SIN_AIS',
modificado_por: Auth.session.nombre,
motivo_cambio: motivo,
};
// Posición nominal solo si superadmin y se modificó
if (Auth.isSuperAdmin()) {
const lat = flt('ef-lat');
const lon = flt('ef-lon');
if (lat) payload.lat_nominal = lat;
if (lon) payload.lon_nominal = lon;
}
try {
const res = await fetch(`${API}/aids/${aidData.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${Auth.token()}`,
},
body: JSON.stringify(payload),
});
if (res.status === 401) {
Auth.clear();
_hideOverlay();
this.openLogin(aidData);
return;
}
if (!res.ok) throw new Error(await res.text());
const updated = await res.json();
// Actualizar feature en el mapa
const feature = window.aidsSource?.getFeatureById(aidData.id);
if (feature) feature.setProperties({ ...feature.getProperties(), ...updated });
// Refrescar panel info
if (window.refreshInfoPanel) window.refreshInfoPanel({ ...aidData, ...updated });
status.textContent = 'Saved successfully.';
status.className = 'save-status ok';
setTimeout(() => _hideOverlay(), 1200);
} catch (e) {
status.textContent = 'Error: ' + e.message;
status.className = 'save-status err';
} finally {
btn.disabled = false;
btn.textContent = 'SAVE CHANGES';
}
},
};
// ── Form builder ─────────────────────────────────────────────────────────────
function buildEditForm(p) {
// Port options from PORT_AUTHORITY contacts (deduplicated by port_name)
const contacts = window._contactsCache || [];
const portsFromContacts = [...new Set(contacts
.filter(c => c.role === 'PORT_AUTHORITY' && c.port_name)
.map(c => c.port_name))];
// Keep the current value as an option even if no contact uses it yet
if (p.puerto_responsable && !portsFromContacts.includes(p.puerto_responsable)) {
portsFromContacts.push(p.puerto_responsable);
}
const pOpts = portsFromContacts.map(x =>
``).join('');
// Company options from OWNER contacts (deduplicated by company_name)
const companiesFromContacts = [...new Set(contacts
.filter(c => c.role === 'OWNER' && c.company_name)
.map(c => c.company_name))];
if (p.empresa_responsable && !companiesFromContacts.includes(p.empresa_responsable)) {
companiesFromContacts.push(p.empresa_responsable);
}
const cOpts = companiesFromContacts.map(x =>
``).join('');
// Lamp options from the lamp catalog
const lamps = window._lampCache || [];
const lOpts = lamps.map(l =>
``).join('');
const nominalBlock = Auth.isSuperAdmin() ? `
NOMINAL POSITION — SUPERADMIN ONLY
` : '';
// AIS link — when set, AIS Type 21 with this MMSI updates the buoy's
// lat_actual (ghost marker) and triggers drift/battery alerts using this
// aid's per-buoy thresholds.
const aisBlock = `
AIS LINK
MMSI of the AtoN transponder on this buoy.
Once set, the system listens for AIS Type 21 with this MMSI and shows
the AIS-reported position as a transparent ghost over the nominal marker.
Leave blank if the buoy has no AIS.
`;
return `
GENERAL
TECHNICAL
ALERT THRESHOLDS
Leave blank to use global settings. Override per buoy based on anchor chain length and operating area.
${aisBlock}
${nominalBlock}
AUDIT
`;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function v(id) { return document.getElementById(id)?.value?.trim() || ''; }
function flt(id) { const n = parseFloat(v(id)); return isNaN(n) ? null : n; }
function _showOverlay(modalId) {
document.getElementById('modal-overlay').classList.remove('hidden');
['modal-login','modal-edit'].forEach(id => {
document.getElementById(id).classList.toggle('hidden', id !== modalId);
});
}
function _hideOverlay() {
document.getElementById('modal-overlay').classList.add('hidden');
document.getElementById('modal-login').classList.add('hidden');
document.getElementById('modal-edit').classList.add('hidden');
}
// ── Port navigation + constraints ─────────────────────────────────────────────
// Cached port result — re-applied after the app container becomes visible.
let _portCache = null; // { lon, lat, zoom, port }
// Called right after #app becomes visible.
// OL initialized with display:none → size=0×0 → map renders half-screen and
// ignores setCenter. Fix: updateSize() tells OL the real container size,
// then re-apply port position so the map lands at the correct place.
function _showMap() {
requestAnimationFrame(() => {
window._mapUpdateSize?.(); // fix half-screen rendering
requestAnimationFrame(() => {
_applyPortToMap(); // re-apply position after resize
});
});
}
function _applyPortToMap() {
if (!_portCache) return;
const { lon, lat, zoom, port } = _portCache;
// Step 1: clear any previous constraints so navigation is free
window._clearPortConstraints?.();
// Step 2: fly to port (same as port search)
window.flyToCoords?.(lon, lat, zoom);
// Step 3: after animation lands, lock zoom-out and extent
if (port) {
setTimeout(() => window._setPortConstraints?.(port), 1400); // 1200ms anim + margin
}
}
// Fetch /org/me/company, cache result, apply to map.
// Call this BEFORE showing the app, then re-apply after show (OL resize fix).
async function _fetchAndApplyPort(token, role) {
try {
const res = await fetch(`${API}/org/me/company`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const me = await res.json();
if (!me.port || me.port.center_lat == null || me.port.center_lon == null) return;
const isClient = (role === 'USER' || role === 'CLIENT_ADMIN');
_portCache = {
lon: me.port.center_lon,
lat: me.port.center_lat,
zoom: me.port.default_zoom || 12,
port: isClient ? me.port : null,
};
_applyPortToMap();
} catch (_) { /* non-fatal */ }
}
// ── Login de inicio ───────────────────────────────────────────────────────────
async function initStartupLogin() {
const screen = document.getElementById('login-screen');
const app = document.getElementById('app');
const btn = document.getElementById('ls-submit');
const errEl = document.getElementById('ls-error');
// Validate cached token. If valid, configure map THEN show app.
Auth.load();
if (Auth.isLoggedIn()) {
try {
const r = await fetch(`${API}/auth/me`, {
headers: { Authorization: `Bearer ${Auth.token()}` },
});
if (r.ok) {
try {
await _fetchAndApplyPort(Auth.token(), Auth.session.role);
app.classList.remove('hidden');
window._mapUpdateSize?.();
_applyPortToMap();
} catch (e) {
console.warn('[auth] map positioning error:', e);
app.classList.remove('hidden');
}
screen.classList.add('hidden');
afterLogin();
return;
}
Auth.clear();
} catch { Auth.clear(); }
}
screen.classList.remove('hidden');
app.classList.add('hidden');
async function doLogin() {
const username = document.getElementById('ls-user').value.trim();
const password = document.getElementById('ls-pass').value;
if (!username || !password) return;
btn.disabled = true;
btn.textContent = 'AUTHENTICATING...';
errEl.classList.add('hidden');
try {
const form = new URLSearchParams({ username, password });
const res = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form,
});
if (!res.ok) throw new Error('invalid');
const data = await res.json();
Auth.save({ token: data.access_token, username: data.username,
nombre: data.nombre, role: data.role });
// Map positioning is separate — errors here must NOT show credential message
try {
await _fetchAndApplyPort(data.access_token, data.role);
app.classList.remove('hidden'); // show app BEHIND login screen (z-index:9999)
window._mapUpdateSize?.(); // OL measures real container size
_applyPortToMap(); // position map at correct port
} catch (e) {
console.warn('[auth] map positioning error:', e);
app.classList.remove('hidden'); // show app anyway at default position
}
screen.classList.add('hidden'); // reveal map
afterLogin();
} catch {
errEl.textContent = 'Invalid username or password.';
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'LOGIN';
}
}
btn.addEventListener('click', doLogin);
document.getElementById('ls-pass').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
}
function afterLogin() {
if (window.PortSearch) window.PortSearch.init();
Auth._renderBadge();
const sbUser = document.getElementById('sb-user');
if (sbUser && Auth.session) {
sbUser.textContent = `${Auth.session.nombre} · ${Auth.session.role}`;
}
}
// ── Event listeners ──────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initStartupLogin();
document.getElementById('btn-login-submit')?.addEventListener('click', () => Modal.submitLogin());
document.getElementById('btn-login-cancel')?.addEventListener('click', () => Modal.closeLogin());
document.getElementById('btn-close-login')?.addEventListener('click', () => Modal.closeLogin());
document.getElementById('btn-close-edit')?.addEventListener('click', () => Modal.closeEdit());
document.getElementById('btn-edit-cancel')?.addEventListener('click', () => Modal.closeEdit());
document.getElementById('login-pass')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') Modal.submitLogin();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') _hideOverlay();
});
// (Click on the backdrop intentionally does NOT close modals — user must
// use the X or CLOSE button. Avoids losing unsaved Settings inputs.)
});
window.Auth = Auth;
window.Modal = Modal;