Initial commit — multi-tenant filtering, port constraints, chart bbox
This commit is contained in:
@@ -1122,6 +1122,16 @@ html.night .ol-zoom button {
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 2px; }
|
||||
|
||||
/* ── App container — fills remaining viewport after body flex layout ──────── */
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
#app.hidden { display: none; }
|
||||
|
||||
/* ── Login Screen ────────────────────────────────────────────────────────── */
|
||||
#login-screen {
|
||||
position: fixed; inset: 0;
|
||||
|
||||
+172
-3
@@ -84,8 +84,9 @@
|
||||
<div class="nav-item" id="nav-users">
|
||||
<span data-i18n="nav.users">USERS</span>
|
||||
<div class="nav-dropdown" id="dd-users">
|
||||
<div class="dd-item" data-action="users-list" data-i18n="dd.manage">Manage Users</div>
|
||||
<div class="dd-item" data-action="users-create" data-i18n="dd.create">Create User</div>
|
||||
<div class="dd-item" data-action="users-list" data-i18n="dd.manage">Manage Users</div>
|
||||
<div class="dd-item" data-action="users-create" data-i18n="dd.create">Create User</div>
|
||||
<div class="dd-item dd-sep" data-action="org-management">Organizations & Ports</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -371,11 +372,19 @@
|
||||
<div class="form-field" style="margin-top:10px">
|
||||
<label class="form-label">Role *</label>
|
||||
<select class="form-input-select" id="uf-role">
|
||||
<option value="USER">USER — Read only</option>
|
||||
<option value="USER">USER — Read only (client)</option>
|
||||
<option value="CLIENT_ADMIN">CLIENT ADMIN — Can record (client)</option>
|
||||
<option value="ADMIN">ADMIN — Can edit aids</option>
|
||||
<option value="SUPERADMIN">SUPERADMIN — Full access</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field" style="margin-top:10px" id="uf-company-row">
|
||||
<label class="form-label">Company (client port) *</label>
|
||||
<select class="form-input-select" id="uf-company">
|
||||
<option value="">— Select company —</option>
|
||||
</select>
|
||||
<div style="font-size:0.7rem;color:var(--text-muted);margin-top:4px" id="uf-company-hint"></div>
|
||||
</div>
|
||||
<div id="uf-error" class="modal-error hidden" style="margin-top:10px"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -712,6 +721,164 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DVR — AIS TRACK HISTORY ─────────────────────────────────────────── -->
|
||||
<div id="modal-track-history" class="modal modal-wide hidden" style="max-width:820px">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">AIS TRACK HISTORY — DVR Replay</span>
|
||||
<button class="modal-close" id="btn-close-track-history">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;align-items:flex-end;flex-wrap:wrap">
|
||||
<div class="form-field" style="flex:1;min-width:160px">
|
||||
<label class="form-label">MMSI</label>
|
||||
<input class="form-input" id="dvr-mmsi" placeholder="e.g. 123456789">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Date From</label>
|
||||
<input class="form-input" id="dvr-from" type="date">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Date To</label>
|
||||
<input class="form-input" id="dvr-to" type="date">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Source</label>
|
||||
<select class="form-input-select" id="dvr-source">
|
||||
<option value="vessel">Vessel (AIS)</option>
|
||||
<option value="aton">AtoN (Type 21)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-modal-primary" id="btn-dvr-load" style="padding:5px 14px;margin-bottom:0">LOAD TRACK</button>
|
||||
</div>
|
||||
|
||||
<!-- Track stats bar -->
|
||||
<div id="dvr-stats" style="font-size:0.72rem;color:var(--text-muted);margin-bottom:8px;display:none">
|
||||
<span id="dvr-stats-text"></span>
|
||||
<button class="chart-row-btn" id="btn-dvr-show-map" style="margin-left:10px">SHOW ON MAP</button>
|
||||
<button class="chart-row-btn" id="btn-dvr-csv" style="margin-left:4px">EXPORT CSV</button>
|
||||
</div>
|
||||
|
||||
<!-- DVR playback controls (visible once track loaded) -->
|
||||
<div id="dvr-controls" style="display:none;background:#0d1b2a;border:1px solid #1e3a5f;border-radius:4px;padding:10px;margin-bottom:10px">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<button class="chart-row-btn" id="btn-dvr-play" title="Play">▶</button>
|
||||
<button class="chart-row-btn" id="btn-dvr-pause" title="Pause">▮▮</button>
|
||||
<button class="chart-row-btn" id="btn-dvr-stop" title="Stop / Reset">■</button>
|
||||
<label class="form-label" style="margin:0 4px 0 8px">Speed</label>
|
||||
<select class="form-input-select" id="dvr-speed" style="width:80px">
|
||||
<option value="1">1×</option>
|
||||
<option value="5">5×</option>
|
||||
<option value="20" selected>20×</option>
|
||||
<option value="60">60×</option>
|
||||
<option value="300">300×</option>
|
||||
</select>
|
||||
<div style="flex:1;min-width:160px">
|
||||
<input type="range" id="dvr-slider" min="0" value="0" style="width:100%;accent-color:var(--accent)">
|
||||
</div>
|
||||
<span id="dvr-timestamp" class="mono" style="font-size:0.7rem;color:var(--accent);min-width:140px">--:--:-- UTC</span>
|
||||
</div>
|
||||
<div id="dvr-position" style="font-size:0.7rem;color:var(--text-muted);margin-top:6px">
|
||||
<span id="dvr-lat">LAT --</span> <span id="dvr-lon">LON --</span>
|
||||
· <span id="dvr-sog">SOG --</span> kn
|
||||
· <span id="dvr-cog">COG --</span>°
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Track table -->
|
||||
<div id="dvr-body" style="max-height:280px;overflow-y:auto">
|
||||
<div style="color:var(--text-muted);font-size:0.75rem;padding:20px 0;text-align:center">
|
||||
Enter an MMSI and date range, then press LOAD TRACK.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-modal-secondary" id="btn-close-track-history2">CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ORGANIZATION MANAGEMENT (Ports, Companies, Buoy Ownership) ────────── -->
|
||||
<div id="modal-org" class="modal modal-wide hidden" style="max-width:900px">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">ORGANIZATION — Ports, Companies & Buoy Ownership</span>
|
||||
<button class="modal-close" id="btn-close-org">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="stab-bar" style="margin-bottom:14px">
|
||||
<button class="stab active" data-otab="otab-ports">PORTS</button>
|
||||
<button class="stab" data-otab="otab-companies">COMPANIES</button>
|
||||
<button class="stab" data-otab="otab-ownership">BUOY OWNERSHIP</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB: PORTS -->
|
||||
<div id="otab-ports" class="otab-panel">
|
||||
<div style="font-size:0.72rem;color:var(--text-muted);margin-bottom:10px">
|
||||
Ports are used to set the default map view for each company's users on login.
|
||||
</div>
|
||||
<table class="chart-table" id="org-ports-table">
|
||||
<thead><tr><th>Name</th><th>Center</th><th>Zoom</th><th>Chart</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody id="org-ports-body"></tbody>
|
||||
</table>
|
||||
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
|
||||
<input class="form-input" id="op-name" placeholder="Port name" style="width:150px">
|
||||
<input class="form-input" id="op-lat" placeholder="Lat" type="number" step="0.0001" style="width:100px">
|
||||
<input class="form-input" id="op-lon" placeholder="Lon" type="number" step="0.0001" style="width:100px">
|
||||
<input class="form-input" id="op-zoom" placeholder="Zoom (12)" type="number" step="0.5" style="width:80px">
|
||||
<input class="form-input" id="op-chart" placeholder="Chart folder (opt.)" style="width:160px">
|
||||
<button class="btn-modal-primary" id="btn-port-add" style="padding:5px 12px">ADD PORT</button>
|
||||
</div>
|
||||
<div id="org-ports-status" class="save-status" style="margin-top:8px"></div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: COMPANIES -->
|
||||
<div id="otab-companies" class="otab-panel hidden">
|
||||
<table class="chart-table" id="org-companies-table">
|
||||
<thead><tr><th>Company</th><th>Home Port</th><th>Email</th><th>Phone</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody id="org-companies-body"></tbody>
|
||||
</table>
|
||||
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
|
||||
<input class="form-input" id="oc-name" placeholder="Company name" style="width:200px">
|
||||
<select class="form-input-select" id="oc-port" style="width:160px">
|
||||
<option value="">— Select port —</option>
|
||||
</select>
|
||||
<input class="form-input" id="oc-email" placeholder="Contact email" style="width:180px">
|
||||
<input class="form-input" id="oc-phone" placeholder="Phone" style="width:130px">
|
||||
<button class="btn-modal-primary" id="btn-company-add" style="padding:5px 12px">ADD COMPANY</button>
|
||||
</div>
|
||||
<div id="org-companies-status" class="save-status" style="margin-top:8px"></div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: BUOY OWNERSHIP -->
|
||||
<div id="otab-ownership" class="otab-panel hidden">
|
||||
<div style="display:flex;gap:10px;margin-bottom:10px;align-items:flex-end">
|
||||
<div class="form-field" style="flex:1">
|
||||
<label class="form-label">Filter by Company</label>
|
||||
<select class="form-input-select" id="ow-filter-company">
|
||||
<option value="">— All companies —</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="chart-row-btn" id="btn-ow-load">LOAD</button>
|
||||
</div>
|
||||
<table class="chart-table" id="org-ownership-table">
|
||||
<thead><tr><th>Company</th><th>Aid Name</th><th>MMSI</th><th>Notes</th><th></th></tr></thead>
|
||||
<tbody id="org-ownership-body"></tbody>
|
||||
</table>
|
||||
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
|
||||
<select class="form-input-select" id="ow-company-sel" style="width:200px">
|
||||
<option value="">— Company —</option>
|
||||
</select>
|
||||
<input class="form-input" id="ow-aid-id" placeholder="Aid ID (UUID)" style="width:230px">
|
||||
<input class="form-input" id="ow-mmsi" placeholder="or MMSI" style="width:130px">
|
||||
<input class="form-input" id="ow-notes" placeholder="Notes (opt.)" style="width:150px">
|
||||
<button class="btn-modal-primary" id="btn-ow-assign" style="padding:5px 12px">ASSIGN</button>
|
||||
</div>
|
||||
<div id="org-ownership-status" class="save-status" style="margin-top:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-modal-secondary" id="btn-close-org2">CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /modal-overlay -->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/ol@v9.2.4/dist/ol.js"></script>
|
||||
@@ -720,5 +887,7 @@
|
||||
<script src="js/map.js"></script>
|
||||
<script src="js/websocket.js"></script>
|
||||
<script src="js/menu.js"></script>
|
||||
<script src="js/dvr.js"></script>
|
||||
<script src="js/org.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+94
-21
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const API = 'http://localhost:5503';
|
||||
const API = ''; // relative — works from any IP (localhost, Tailscale, LAN)
|
||||
const SESSION_KEY = 'ams_session';
|
||||
|
||||
// ── Estado de sesión ────────────────────────────────────────────────────────
|
||||
@@ -51,7 +51,18 @@ const Auth = {
|
||||
`;
|
||||
el.title = 'Click to logout';
|
||||
el.onclick = () => {
|
||||
if (confirm(`Logout as ${this.session.nombre}?`)) Auth.clear();
|
||||
if (confirm(`Logout as ${this.session.nombre}?`)) {
|
||||
Auth.clear();
|
||||
window._clearPortConstraints?.();
|
||||
_portCache = null;
|
||||
// Return to login screen
|
||||
document.getElementById('app').classList.add('hidden');
|
||||
document.getElementById('login-screen').classList.remove('hidden');
|
||||
document.getElementById('ls-user').value = '';
|
||||
document.getElementById('ls-pass').value = '';
|
||||
document.getElementById('ls-error').classList.add('hidden');
|
||||
setTimeout(() => document.getElementById('ls-user').focus(), 100);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -97,15 +108,11 @@ const Modal = {
|
||||
if (!res.ok) throw new Error('invalid');
|
||||
const data = await res.json();
|
||||
|
||||
if (!['ADMIN','SUPERADMIN'].includes(data.role)) {
|
||||
errEl.textContent = 'Your account does not have edit permissions.';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
Auth.save({ token: data.access_token, username: data.username,
|
||||
nombre: data.nombre, role: data.role });
|
||||
_hideOverlay();
|
||||
afterLogin();
|
||||
_fetchAndApplyPort(data.access_token, data.role); // update constraints if user changed
|
||||
|
||||
if (this._pendingAid) {
|
||||
this.openEdit(this._pendingAid);
|
||||
@@ -360,6 +367,61 @@ function _hideOverlay() {
|
||||
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');
|
||||
@@ -367,9 +429,7 @@ async function initStartupLogin() {
|
||||
const btn = document.getElementById('ls-submit');
|
||||
const errEl = document.getElementById('ls-error');
|
||||
|
||||
// Validate the cached token against /auth/me before auto-entering. The
|
||||
// backend may have restarted / token may have expired — in that case we
|
||||
// force the user back to the login screen instead of showing a broken UI.
|
||||
// Validate cached token. If valid, configure map THEN show app.
|
||||
Auth.load();
|
||||
if (Auth.isLoggedIn()) {
|
||||
try {
|
||||
@@ -377,18 +437,22 @@ async function initStartupLogin() {
|
||||
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');
|
||||
app.classList.remove('hidden');
|
||||
afterLogin();
|
||||
return;
|
||||
}
|
||||
// 401 / network error → drop the stale session and show login
|
||||
Auth.clear();
|
||||
} catch {
|
||||
Auth.clear();
|
||||
}
|
||||
} catch { Auth.clear(); }
|
||||
}
|
||||
// Make sure login screen is visible if we got here
|
||||
screen.classList.remove('hidden');
|
||||
app.classList.add('hidden');
|
||||
|
||||
@@ -412,8 +476,18 @@ async function initStartupLogin() {
|
||||
const data = await res.json();
|
||||
Auth.save({ token: data.access_token, username: data.username,
|
||||
nombre: data.nombre, role: data.role });
|
||||
screen.classList.add('hidden');
|
||||
app.classList.remove('hidden');
|
||||
|
||||
// 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.';
|
||||
@@ -425,7 +499,7 @@ async function initStartupLogin() {
|
||||
}
|
||||
|
||||
btn.addEventListener('click', doLogin);
|
||||
document.getElementById('ls-pass').addEventListener('keydown', (e) => {
|
||||
document.getElementById('ls-pass').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
}
|
||||
@@ -433,7 +507,6 @@ async function initStartupLogin() {
|
||||
function afterLogin() {
|
||||
if (window.PortSearch) window.PortSearch.init();
|
||||
Auth._renderBadge();
|
||||
// Sincronizar barra de estado inferior con usuario conectado
|
||||
const sbUser = document.getElementById('sb-user');
|
||||
if (sbUser && Auth.session) {
|
||||
sbUser.textContent = `${Auth.session.nombre} · ${Auth.session.role}`;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
// DVR — AIS Track History and Playback
|
||||
|
||||
let _dvrTrack = []; // loaded track points
|
||||
let _dvrTimer = null; // setInterval handle for playback
|
||||
let _dvrIndex = 0; // current playback position
|
||||
let _dvrPlaying = false;
|
||||
|
||||
// ── Open / init modal ─────────────────────────────────────────────────────────
|
||||
window.openTrackHistoryModal = function() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const from = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
|
||||
document.getElementById('dvr-from').value = from;
|
||||
document.getElementById('dvr-to').value = today;
|
||||
document.getElementById('dvr-mmsi').value = '';
|
||||
document.getElementById('dvr-body').innerHTML =
|
||||
'<div style="color:var(--text-muted);font-size:0.75rem;padding:20px 0;text-align:center">Enter an MMSI and date range, then press LOAD TRACK.</div>';
|
||||
document.getElementById('dvr-stats').style.display = 'none';
|
||||
document.getElementById('dvr-controls').style.display = 'none';
|
||||
_dvrStop();
|
||||
_dvrTrack = [];
|
||||
_showModal('modal-track-history');
|
||||
};
|
||||
|
||||
// ── Load track ────────────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-dvr-load')?.addEventListener('click', _dvrLoad);
|
||||
|
||||
async function _dvrLoad() {
|
||||
const mmsi = document.getElementById('dvr-mmsi').value.trim();
|
||||
const from = document.getElementById('dvr-from').value;
|
||||
const to = document.getElementById('dvr-to').value;
|
||||
const source = document.getElementById('dvr-source').value;
|
||||
|
||||
if (!mmsi) { alert('Enter an MMSI first.'); return; }
|
||||
|
||||
const body = document.getElementById('dvr-body');
|
||||
body.innerHTML = '<div style="color:var(--text-muted);padding:8px">Loading…</div>';
|
||||
document.getElementById('dvr-stats').style.display = 'none';
|
||||
document.getElementById('dvr-controls').style.display = 'none';
|
||||
_dvrStop();
|
||||
|
||||
try {
|
||||
const endpoint = source === 'aton'
|
||||
? `${API}/tracks/atons/${encodeURIComponent(mmsi)}`
|
||||
: `${API}/tracks/vessels/${encodeURIComponent(mmsi)}`;
|
||||
const url = `${endpoint}?from=${from}T00:00:00&to=${to}T23:59:59&limit=50000`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${window.Auth?.token()}` }
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
_dvrTrack = await res.json();
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="modal-error" style="display:block">Error: ${e.message}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_dvrTrack.length) {
|
||||
body.innerHTML = '<div style="color:var(--text-muted);font-size:0.75rem;padding:20px 0;text-align:center">No track data found for this MMSI and date range.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Stats bar
|
||||
const statsEl = document.getElementById('dvr-stats');
|
||||
document.getElementById('dvr-stats-text').textContent =
|
||||
`${_dvrTrack.length} points · from ${_dvrTrack[0].ts.slice(0,19)} to ${_dvrTrack.at(-1).ts.slice(0,19)} UTC`;
|
||||
statsEl.style.display = '';
|
||||
|
||||
// Playback controls
|
||||
const ctrl = document.getElementById('dvr-controls');
|
||||
ctrl.style.display = '';
|
||||
const slider = document.getElementById('dvr-slider');
|
||||
slider.max = _dvrTrack.length - 1;
|
||||
slider.value = 0;
|
||||
_dvrIndex = 0;
|
||||
_dvrUpdateDisplay(0);
|
||||
|
||||
// Table preview (first/last 50)
|
||||
const preview = _dvrTrack.length > 100
|
||||
? [..._dvrTrack.slice(0, 50), null, ..._dvrTrack.slice(-50)]
|
||||
: _dvrTrack;
|
||||
|
||||
body.innerHTML = `
|
||||
<table class="users-table">
|
||||
<thead><tr>
|
||||
<th>#</th><th>Timestamp (UTC)</th><th>LAT</th><th>LON</th>
|
||||
<th>SOG (kn)</th><th>COG (°)</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${preview.map((p, i) => p ? `
|
||||
<tr style="font-size:0.7rem">
|
||||
<td class="mono" style="color:var(--text-muted)">${(i < 50 ? i + 1 : _dvrTrack.length - 99 + i)}</td>
|
||||
<td class="mono">${p.ts?.slice(0, 19)}</td>
|
||||
<td class="mono">${p.lat?.toFixed(5) ?? '--'}</td>
|
||||
<td class="mono">${p.lon?.toFixed(5) ?? '--'}</td>
|
||||
<td>${p.sog?.toFixed(1) ?? '--'}</td>
|
||||
<td>${p.cog?.toFixed(0) ?? '--'}</td>
|
||||
</tr>` : '<tr><td colspan="6" style="text-align:center;color:var(--text-muted);font-size:0.7rem">…</td></tr>'
|
||||
).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// ── Playback controls ─────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-dvr-play')?.addEventListener('click', _dvrPlay);
|
||||
document.getElementById('btn-dvr-pause')?.addEventListener('click', _dvrPause);
|
||||
document.getElementById('btn-dvr-stop')?.addEventListener('click', _dvrStop);
|
||||
|
||||
document.getElementById('dvr-slider')?.addEventListener('input', (e) => {
|
||||
_dvrIndex = parseInt(e.target.value);
|
||||
_dvrUpdateDisplay(_dvrIndex);
|
||||
});
|
||||
|
||||
function _dvrPlay() {
|
||||
if (!_dvrTrack.length) return;
|
||||
if (_dvrIndex >= _dvrTrack.length - 1) _dvrIndex = 0;
|
||||
_dvrPlaying = true;
|
||||
const speed = parseInt(document.getElementById('dvr-speed').value) || 20;
|
||||
// Each step advances by `speed` seconds of track data; timer fires at 100ms
|
||||
if (_dvrTimer) clearInterval(_dvrTimer);
|
||||
_dvrTimer = setInterval(() => {
|
||||
if (!_dvrPlaying || _dvrIndex >= _dvrTrack.length - 1) {
|
||||
_dvrPause();
|
||||
return;
|
||||
}
|
||||
// Find next point that is at least `speed` seconds ahead
|
||||
const curTs = new Date(_dvrTrack[_dvrIndex].ts).getTime();
|
||||
let next = _dvrIndex + 1;
|
||||
while (next < _dvrTrack.length - 1) {
|
||||
const diff = (new Date(_dvrTrack[next].ts).getTime() - curTs) / 1000;
|
||||
if (diff >= speed) break;
|
||||
next++;
|
||||
}
|
||||
_dvrIndex = next;
|
||||
document.getElementById('dvr-slider').value = _dvrIndex;
|
||||
_dvrUpdateDisplay(_dvrIndex);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function _dvrPause() {
|
||||
_dvrPlaying = false;
|
||||
if (_dvrTimer) { clearInterval(_dvrTimer); _dvrTimer = null; }
|
||||
}
|
||||
|
||||
function _dvrStop() {
|
||||
_dvrPause();
|
||||
_dvrIndex = 0;
|
||||
const slider = document.getElementById('dvr-slider');
|
||||
if (slider) slider.value = 0;
|
||||
if (_dvrTrack.length) _dvrUpdateDisplay(0);
|
||||
// Remove DVR marker from map if present
|
||||
if (window._dvrMapClear) window._dvrMapClear();
|
||||
}
|
||||
|
||||
function _dvrUpdateDisplay(idx) {
|
||||
if (!_dvrTrack.length || idx >= _dvrTrack.length) return;
|
||||
const p = _dvrTrack[idx];
|
||||
document.getElementById('dvr-timestamp').textContent =
|
||||
p.ts ? p.ts.slice(0, 19) + ' UTC' : '--';
|
||||
document.getElementById('dvr-lat').textContent = p.lat ? `LAT ${p.lat.toFixed(5)}` : 'LAT --';
|
||||
document.getElementById('dvr-lon').textContent = p.lon ? `LON ${p.lon.toFixed(5)}` : 'LON --';
|
||||
document.getElementById('dvr-sog').textContent = p.sog != null ? `SOG ${p.sog.toFixed(1)}` : 'SOG --';
|
||||
document.getElementById('dvr-cog').textContent = p.cog != null ? `COG ${p.cog.toFixed(0)}` : 'COG --';
|
||||
|
||||
// Move DVR marker on map (map.js must expose window._dvrMapMove)
|
||||
if (window._dvrMapMove && p.lat != null && p.lon != null) {
|
||||
window._dvrMapMove(p.lon, p.lat, p.cog ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Show on map ───────────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-dvr-show-map')?.addEventListener('click', () => {
|
||||
if (!_dvrTrack.length) return;
|
||||
if (window._dvrMapShowTrack) {
|
||||
window._dvrMapShowTrack(_dvrTrack);
|
||||
_hideModal('modal-track-history');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Export CSV ────────────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-dvr-csv')?.addEventListener('click', () => {
|
||||
if (!_dvrTrack.length) return;
|
||||
const mmsi = document.getElementById('dvr-mmsi').value.trim();
|
||||
const rows = ['timestamp,lat,lon,sog,cog,heading',
|
||||
..._dvrTrack.map(p =>
|
||||
`${p.ts},${p.lat},${p.lon},${p.sog ?? ''},${p.cog ?? ''},${p.heading ?? ''}`)
|
||||
];
|
||||
const blob = new Blob([rows.join('\n')], { type: 'text/csv' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `track_${mmsi}_${document.getElementById('dvr-from').value}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
|
||||
// ── Close buttons ─────────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-close-track-history')?.addEventListener('click', () => _hideModal('modal-track-history'));
|
||||
document.getElementById('btn-close-track-history2')?.addEventListener('click', () => _hideModal('modal-track-history'));
|
||||
+305
-20
@@ -1,8 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
const MAP_CENTER_LAT = 25.7743;
|
||||
const MAP_CENTER_LON = -80.1937;
|
||||
const MAP_ZOOM = 11;
|
||||
let MAP_CENTER_LAT = 11.0041; // Barranquilla — default for admin/superadmin
|
||||
let MAP_CENTER_LON = -74.8070;
|
||||
let MAP_ZOOM = 11;
|
||||
|
||||
// IALA region: 'A' = Europe/Africa/Asia (red=port), 'B' = Americas/Japan (green=port)
|
||||
// Can be changed from settings; default B for Colombian Caribbean
|
||||
@@ -43,11 +43,14 @@ const encLayer = new ol.layer.Vector({
|
||||
source: encSource,
|
||||
zIndex: 19, // S-57 aids on top of everything except live AIS aids
|
||||
style: encStyle,
|
||||
// 1200 m/px ≈ zoom 7 — below that, don't render ENC symbols at all.
|
||||
// Per-cell band ranges inside _cellShouldRender refine visibility further.
|
||||
// Band 0 (custom cells like BARRANQUILLA) stay visible down to this limit.
|
||||
maxResolution: 1200,
|
||||
declutter: true,
|
||||
// 12000 m/px ≈ zoom 6 — layer visible from here; encStyle scales symbols
|
||||
// down smoothly so they shrink instead of blinking out as user zooms away.
|
||||
// Per-cell band ranges inside _cellShouldRender still refine visibility.
|
||||
maxResolution: 12000,
|
||||
// declutter: false — aids are physical objects that don't overlap in practice.
|
||||
// With declutter:true, ol.style.Icon gets hidden by collision but ol.style.Circle
|
||||
// (the position dot) does NOT participate in declutter, leaving ghost dots.
|
||||
declutter: false,
|
||||
});
|
||||
|
||||
// ── Bathymetry — split into 2 layers so SOUNDG (numbers) renders ON TOP of
|
||||
@@ -131,6 +134,23 @@ function _cachedIcon(key, drawFn) {
|
||||
return _iconCache[key];
|
||||
}
|
||||
|
||||
// ── Soft-blur wrapper for aid canvases ────────────────────────────────────
|
||||
// Applies a gentle gaussian blur to soften jagged canvas edges.
|
||||
// Uses a WeakMap so each source canvas is blurred exactly ONCE — the result
|
||||
// is reused on every subsequent style call (same cost as the cached original).
|
||||
const _blurCache = new WeakMap();
|
||||
function _softCanvas(src, blurPx = 0.7) {
|
||||
if (_blurCache.has(src)) return _blurCache.get(src);
|
||||
const dst = document.createElement('canvas');
|
||||
dst.width = src.width;
|
||||
dst.height = src.height;
|
||||
const ctx = dst.getContext('2d');
|
||||
ctx.filter = `blur(${blurPx}px)`;
|
||||
ctx.drawImage(src, 0, 0);
|
||||
_blurCache.set(src, dst);
|
||||
return dst;
|
||||
}
|
||||
|
||||
// ── Ship canvas ───────────────────────────────────────────────────────────
|
||||
function _drawShip(color, sz = 32) {
|
||||
const c = document.createElement('canvas');
|
||||
@@ -798,6 +818,60 @@ function _encSppCanvas(sz = 40) {
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Special Beacon (BCNSPP) — poste + cuerpo amarillo + tope X ───────────
|
||||
// Misma estructura que BCNLAT pero: cuerpo amarillo, topmark = cruz X amarilla.
|
||||
function _encBcnSppCanvas(sz = 36) {
|
||||
const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2;
|
||||
const col = '#f9a825';
|
||||
|
||||
const groundY = sz * 0.92;
|
||||
const poleTopY = sz * 0.04;
|
||||
const poleW = sz * 0.055;
|
||||
const bodyW = sz * 0.28;
|
||||
const bodyTopY = sz * 0.48;
|
||||
const bodyBotY = groundY;
|
||||
const tmW = sz * 0.36;
|
||||
const tmBotY = sz * 0.27;
|
||||
const tmTopY = sz * 0.05;
|
||||
const tmCY = (tmTopY + tmBotY) / 2;
|
||||
const tmR = (tmBotY - tmTopY) * 0.38; // radio del X
|
||||
|
||||
// Sombra base
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, groundY, sz * 0.12, sz * 0.020, 0, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.22)'; ctx.fill();
|
||||
|
||||
// Poste gris
|
||||
const poleGrd = ctx.createLinearGradient(cx - poleW, 0, cx + poleW, 0);
|
||||
poleGrd.addColorStop(0, '#999'); poleGrd.addColorStop(0.4, '#ddd'); poleGrd.addColorStop(1, '#777');
|
||||
ctx.fillStyle = poleGrd;
|
||||
ctx.fillRect(cx - poleW / 2, poleTopY, poleW, groundY - poleTopY);
|
||||
|
||||
// Cuerpo amarillo (gradiente 3D)
|
||||
const bodyGrd = ctx.createLinearGradient(cx - bodyW / 2, 0, cx + bodyW / 2, 0);
|
||||
bodyGrd.addColorStop(0, _lighten3D(col, 0.35));
|
||||
bodyGrd.addColorStop(0.45, col);
|
||||
bodyGrd.addColorStop(1, _darken3D(col, 0.28));
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.65)'; ctx.lineWidth = 0.9;
|
||||
ctx.fillStyle = bodyGrd;
|
||||
ctx.fillRect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY);
|
||||
ctx.strokeRect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY);
|
||||
// Highlight lateral
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||||
ctx.fillRect(cx - bodyW / 2, bodyTopY, bodyW * 0.20, bodyBotY - bodyTopY);
|
||||
|
||||
// Topmark X — contorno oscuro primero, luego amarillo encima
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineWidth = sz * 0.10; ctx.strokeStyle = 'rgba(0,0,0,0.45)';
|
||||
ctx.beginPath(); ctx.moveTo(cx - tmR, tmCY - tmR); ctx.lineTo(cx + tmR, tmCY + tmR); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(cx + tmR, tmCY - tmR); ctx.lineTo(cx - tmR, tmCY + tmR); ctx.stroke();
|
||||
ctx.lineWidth = sz * 0.075; ctx.strokeStyle = col;
|
||||
ctx.beginPath(); ctx.moveTo(cx - tmR, tmCY - tmR); ctx.lineTo(cx + tmR, tmCY + tmR); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(cx + tmR, tmCY - tmR); ctx.lineTo(cx - tmR, tmCY + tmR); ctx.stroke();
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
IALA buoy canvases — exact port of AR ECDIS SVG defs (ui/js/map.js).
|
||||
Each function returns a canvas sized scale × SVG viewBox, with the
|
||||
@@ -1394,6 +1468,10 @@ function _recomputeHiddenCells() {
|
||||
|
||||
for (const [cellId, meta] of Object.entries(cells)) {
|
||||
if (!meta.bbox) continue;
|
||||
// Custom / operator-installed cells (band 0) are NEVER hidden by any NOAA
|
||||
// overview cell. They represent the primary operational chart for the port
|
||||
// and must remain visible at all zoom levels.
|
||||
if (meta.band === 0) continue;
|
||||
for (const [otherId, other] of Object.entries(cells)) {
|
||||
if (otherId === cellId || !other.bbox) continue;
|
||||
if (other.band <= meta.band) continue;
|
||||
@@ -1621,11 +1699,20 @@ function encStyle(feature, resolution) {
|
||||
});
|
||||
break;
|
||||
case 'SPECIAL': {
|
||||
// BOYSPEC/BOYSPP — route through _encBuoyCanvas with sphere or special shape
|
||||
const shp = boyshp || 3;
|
||||
canvas = _cachedIcon(`boy_spp_${colours.join('-')}_${shp}_${hasLight}`,
|
||||
() => _encBuoyCanvas({ colours: colours.length ? colours : [6], boyshp: shp,
|
||||
catlam: null, region, hasLight }));
|
||||
// BOYSPP (boya flotante) → _encSppCanvas | BCNSPP (baliza fija) → _encBcnSppCanvas
|
||||
if (isBcn) {
|
||||
canvas = _cachedIcon(`bcn_spp_${hasLight}`, () => {
|
||||
const cv = _encBcnSppCanvas(36);
|
||||
if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, '#f9a825');
|
||||
return cv;
|
||||
});
|
||||
} else {
|
||||
canvas = _cachedIcon(`boy_spp_${hasLight}`, () => {
|
||||
const cv = _encSppCanvas(48);
|
||||
if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, '#f9a825');
|
||||
return cv;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'LEADING_LINE':
|
||||
@@ -1668,22 +1755,37 @@ function encStyle(feature, resolution) {
|
||||
// Compute zoom from resolution (deterministic, no map object access needed).
|
||||
// OL WebMercator: resolution ≈ 156543 / 2^zoom → zoom = log2(156543/res)
|
||||
const _zr = resolution ? Math.log2(156543.03392 / Math.max(resolution, 0.001)) : 14;
|
||||
// Scale: 0.29 at zoom 7, 0.65 at zoom 14+ — ~30 % larger than before.
|
||||
const iconScale = Math.max(0.29, Math.min(0.65, 0.29 + (_zr - 7) * 0.052));
|
||||
// Scale: 0.14 at zoom 6, grows to 0.52 at zoom 14+.
|
||||
// Smooth curve — symbols shrink as user zooms out instead of vanishing.
|
||||
const iconScale = Math.max(0.14, Math.min(0.52, 0.14 + (_zr - 6) * 0.0475));
|
||||
|
||||
return new ol.style.Style({
|
||||
// Position dot — small circle rendered at the exact geographic coordinate.
|
||||
// Anchor is [0.5, 1.0] so the dot sits precisely at the buoy's lat/lon.
|
||||
const dotStyle = new ol.style.Style({
|
||||
image: new ol.style.Circle({
|
||||
radius: 2,
|
||||
fill: new ol.style.Fill({ color: 'rgba(10,26,46,0.9)' }),
|
||||
stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.9)', width: 0.8 }),
|
||||
}),
|
||||
});
|
||||
|
||||
const iconStyle = new ol.style.Style({
|
||||
image: new ol.style.Icon({
|
||||
img: canvas, imgSize: [canvas.width, canvas.height], scale: iconScale,
|
||||
img: canvas, imgSize: [canvas.width, canvas.height],
|
||||
scale: iconScale,
|
||||
anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
|
||||
}),
|
||||
text: label ? new ol.style.Text({
|
||||
text: label,
|
||||
offsetY: 10,
|
||||
font: '500 9px "Inter", "Segoe UI", sans-serif',
|
||||
fill: new ol.style.Fill({ color: '#e8f4fd' }),
|
||||
offsetY: 8,
|
||||
font: '600 9px "Inter", "Segoe UI", sans-serif',
|
||||
fill: new ol.style.Fill({ color: '#0a1a2e' }),
|
||||
stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.92)', width: 2.5 }),
|
||||
overflow: true,
|
||||
}) : undefined,
|
||||
});
|
||||
|
||||
return [iconStyle, dotStyle];
|
||||
}
|
||||
|
||||
// ── Bathymetry rendering — IHO S-52 / NOAA convention ────────────────────
|
||||
@@ -2146,6 +2248,7 @@ window.loadChartLand();
|
||||
window.loadChartHazards();
|
||||
window.loadChartZones();
|
||||
|
||||
|
||||
const map = new ol.Map({
|
||||
target: 'map',
|
||||
layers: [osmLayer, oceanRefLayer, depthLayer, landLayer, zoneLayer, seaMapLayer, encLayer, hazardLayer, soundLayer, vesselsLayer, aidsLayer],
|
||||
@@ -3674,3 +3777,185 @@ setInterval(() => {
|
||||
|
||||
// Exponer aidsSource globalmente para el formulario
|
||||
window.aidsSource = aidsSource;
|
||||
|
||||
// ── Port constraints for USER role ────────────────────────────────────────
|
||||
// Called after login when the user has a company → port.
|
||||
// ── Port constraint state ─────────────────────────────────────────────────────
|
||||
let _portMinZoom = null; // minimum zoom for client users
|
||||
let _portExtent = null; // [minX,minY,maxX,maxY] in EPSG:3857
|
||||
let _constraintLock = false; // prevents re-entrant moveend feedback loop
|
||||
|
||||
// Single moveend listener — hard backstop for zoom + pan constraints.
|
||||
// Lock prevents the corrective setCenter/setZoom from re-triggering the
|
||||
// listener in a loop that would cause sluggishness.
|
||||
map.on('moveend', function() {
|
||||
if (_constraintLock) return;
|
||||
if (_portMinZoom === null && !_portExtent) return;
|
||||
|
||||
const view = map.getView();
|
||||
let newZoom = view.getZoom();
|
||||
let newCenter = view.getCenter().slice();
|
||||
let needsFix = false;
|
||||
|
||||
// Zoom floor
|
||||
if (_portMinZoom !== null && newZoom < _portMinZoom) {
|
||||
newZoom = _portMinZoom;
|
||||
needsFix = true;
|
||||
}
|
||||
|
||||
// Pan bounds — clamp center to chart extent
|
||||
if (_portExtent) {
|
||||
const cx = Math.max(_portExtent[0], Math.min(_portExtent[2], newCenter[0]));
|
||||
const cy = Math.max(_portExtent[1], Math.min(_portExtent[3], newCenter[1]));
|
||||
if (cx !== newCenter[0] || cy !== newCenter[1]) {
|
||||
newCenter = [cx, cy];
|
||||
needsFix = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsFix) {
|
||||
_constraintLock = true;
|
||||
view.setCenter(newCenter);
|
||||
view.setZoom(newZoom);
|
||||
setTimeout(function() { _constraintLock = false; }, 150);
|
||||
}
|
||||
});
|
||||
|
||||
window._setPortConstraints = function(port) {
|
||||
if (!port || port.center_lat == null) return;
|
||||
|
||||
_portMinZoom = port.default_zoom || 12;
|
||||
|
||||
// Extent: real ENC chart bbox preferred, fallback ±0.25° pad.
|
||||
// chart_bbox = [west, south, east, north] WGS-84.
|
||||
if (port.chart_bbox && port.chart_bbox.length === 4) {
|
||||
const [west, south, east, north] = port.chart_bbox;
|
||||
const sw = ol.proj.fromLonLat([west, south]);
|
||||
const ne = ol.proj.fromLonLat([east, north]);
|
||||
_portExtent = [sw[0], sw[1], ne[0], ne[1]];
|
||||
} else {
|
||||
const pad = 0.25;
|
||||
const sw = ol.proj.fromLonLat([port.center_lon - pad, port.center_lat - pad]);
|
||||
const ne = ol.proj.fromLonLat([port.center_lon + pad, port.center_lat + pad]);
|
||||
_portExtent = [sw[0], sw[1], ne[0], ne[1]];
|
||||
}
|
||||
|
||||
map.getView().setMinZoom(_portMinZoom);
|
||||
};
|
||||
|
||||
window._clearPortConstraints = function() {
|
||||
_portMinZoom = null;
|
||||
_portExtent = null;
|
||||
map.getView().setMinZoom(0);
|
||||
};
|
||||
|
||||
// Returns true once the map view is usable.
|
||||
window._mapReady = function() {
|
||||
try { return !!map.getView(); } catch (_) { return false; }
|
||||
};
|
||||
|
||||
// Call after the map container becomes visible (app shown after login).
|
||||
// OL initializes with display:none → size=0×0. updateSize() fixes both
|
||||
// the rendering size and re-anchors the center to the correct position.
|
||||
window._mapUpdateSize = function() {
|
||||
map.updateSize();
|
||||
};
|
||||
|
||||
// Jump to position immediately — no animation (used for client role login).
|
||||
window._mapJump = function(lon, lat, zoom) {
|
||||
const view = map.getView();
|
||||
view.setCenter(ol.proj.fromLonLat([lon, lat]));
|
||||
view.setZoom(zoom);
|
||||
};
|
||||
|
||||
// ── DVR Playback hooks ─────────────────────────────────────────────────────
|
||||
// dvr.js calls these to move a marker and draw the track on the map.
|
||||
(function() {
|
||||
const dvrSource = new ol.source.Vector();
|
||||
const dvrLayer = new ol.layer.Vector({
|
||||
source: dvrSource,
|
||||
zIndex: 50, // above everything
|
||||
visible: false,
|
||||
});
|
||||
map.addLayer(dvrLayer);
|
||||
|
||||
// Feature IDs
|
||||
const MARKER_ID = '__dvr_marker__';
|
||||
const TRACK_ID = '__dvr_track__';
|
||||
|
||||
// ── Move playback marker ─────────────────────────────────────────────────
|
||||
window._dvrMapMove = function(lon, lat, cog) {
|
||||
dvrLayer.setVisible(true);
|
||||
let feat = dvrSource.getFeatureById(MARKER_ID);
|
||||
const coord = ol.proj.fromLonLat([lon, lat]);
|
||||
if (!feat) {
|
||||
feat = new ol.Feature({ geometry: new ol.geom.Point(coord) });
|
||||
feat.setId(MARKER_ID);
|
||||
feat.setStyle(new ol.style.Style({
|
||||
image: new ol.style.RegularShape({
|
||||
fill: new ol.style.Fill({ color: '#00e5ff' }),
|
||||
stroke: new ol.style.Stroke({ color: '#003344', width: 1.5 }),
|
||||
points: 3,
|
||||
radius: 10,
|
||||
rotation: ((cog || 0) * Math.PI) / 180,
|
||||
rotateWithView: true,
|
||||
}),
|
||||
zIndex: 100,
|
||||
}));
|
||||
dvrSource.addFeature(feat);
|
||||
} else {
|
||||
feat.getGeometry().setCoordinates(coord);
|
||||
// Update triangle rotation
|
||||
const s = feat.getStyle();
|
||||
if (s && s.getImage()) {
|
||||
s.getImage().setRotation(((cog || 0) * Math.PI) / 180);
|
||||
}
|
||||
}
|
||||
// Pan map to keep marker in view if it's near the edge
|
||||
const view = map.getView();
|
||||
const extent = view.calculateExtent(map.getSize());
|
||||
if (!ol.extent.containsCoordinate(extent, coord)) {
|
||||
view.animate({ center: coord, duration: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Draw full track polyline ─────────────────────────────────────────────
|
||||
window._dvrMapShowTrack = function(track) {
|
||||
dvrLayer.setVisible(true);
|
||||
|
||||
// Remove existing track line
|
||||
const existing = dvrSource.getFeatureById(TRACK_ID);
|
||||
if (existing) dvrSource.removeFeature(existing);
|
||||
|
||||
if (!track || !track.length) return;
|
||||
|
||||
const coords = track
|
||||
.filter(p => p.lat != null && p.lon != null)
|
||||
.map(p => ol.proj.fromLonLat([p.lon, p.lat]));
|
||||
|
||||
if (coords.length < 2) return;
|
||||
|
||||
const line = new ol.Feature({
|
||||
geometry: new ol.geom.LineString(coords),
|
||||
});
|
||||
line.setId(TRACK_ID);
|
||||
line.setStyle(new ol.style.Style({
|
||||
stroke: new ol.style.Stroke({
|
||||
color: '#00e5ff',
|
||||
width: 2,
|
||||
lineDash: [6, 4],
|
||||
}),
|
||||
}));
|
||||
dvrSource.addFeature(line);
|
||||
|
||||
// Fit view to track extent with padding
|
||||
const ext = dvrSource.getExtent();
|
||||
map.getView().fit(ext, { padding: [60, 60, 60, 60], duration: 800 });
|
||||
};
|
||||
|
||||
// ── Clear DVR layer (called on stop) ────────────────────────────────────
|
||||
window._dvrMapClear = function() {
|
||||
dvrSource.clear();
|
||||
dvrLayer.setVisible(false);
|
||||
};
|
||||
})();
|
||||
|
||||
+111
-19
@@ -60,7 +60,15 @@ document.querySelectorAll('.dd-item').forEach(item => {
|
||||
openRecordingsModal();
|
||||
break;
|
||||
case 'ais-history':
|
||||
openRecordingsModal();
|
||||
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.');
|
||||
@@ -86,12 +94,24 @@ async function loadUsersList() {
|
||||
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>STATUS</th><th></th>
|
||||
<th>ROLE</th><th>COMPANY</th><th>STATUS</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -101,9 +121,10 @@ async function loadUsersList() {
|
||||
<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.activo})">EDIT</button>
|
||||
<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('')}
|
||||
@@ -116,11 +137,65 @@ async function loadUsersList() {
|
||||
}
|
||||
|
||||
function roleClass(role) {
|
||||
return { SUPERADMIN:'role-superadmin', ADMIN:'role-admin', USER:'role-user' }[role] || 'role-user';
|
||||
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 ───────────────────────────────────────────────────────
|
||||
function openUserForm() {
|
||||
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;
|
||||
@@ -128,23 +203,33 @@ function openUserForm() {
|
||||
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, activo) {
|
||||
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').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-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');
|
||||
};
|
||||
|
||||
@@ -161,15 +246,22 @@ window.deleteUser = async function(username) {
|
||||
};
|
||||
|
||||
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 errEl = document.getElementById('uf-error');
|
||||
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');
|
||||
@@ -184,7 +276,7 @@ document.getElementById('btn-uf-save').addEventListener('click', async () => {
|
||||
try {
|
||||
let res;
|
||||
if (isEdit) {
|
||||
const body = { nombre, email: email || null, role };
|
||||
const body = { nombre, email: email || null, role, company_id };
|
||||
if (password) body.password = password;
|
||||
res = await fetch(`${API}/auth/users/${username}`, {
|
||||
method: 'PUT',
|
||||
@@ -195,7 +287,7 @@ document.getElementById('btn-uf-save').addEventListener('click', async () => {
|
||||
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 }),
|
||||
body: JSON.stringify({ username, nombre, email: email || null, password, role, company_id }),
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
'use strict';
|
||||
// Organization Management — Ports, Companies, BuoyOwnership
|
||||
|
||||
let _orgPorts = [];
|
||||
let _orgCompanies = [];
|
||||
|
||||
// ── Open modal ────────────────────────────────────────────────────────────────
|
||||
window.openOrgModal = async function(tab = 'otab-ports') {
|
||||
_showModal('modal-org');
|
||||
_activateOrgTab(tab);
|
||||
await _loadOrgPorts();
|
||||
await _loadOrgCompanies();
|
||||
};
|
||||
|
||||
function _activateOrgTab(id) {
|
||||
document.querySelectorAll('.otab-panel').forEach(p => p.classList.add('hidden'));
|
||||
document.querySelectorAll('.stab[data-otab]').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById(id)?.classList.remove('hidden');
|
||||
document.querySelector(`.stab[data-otab="${id}"]`)?.classList.add('active');
|
||||
}
|
||||
|
||||
document.querySelectorAll('.stab[data-otab]').forEach(btn => {
|
||||
btn.addEventListener('click', () => _activateOrgTab(btn.dataset.otab));
|
||||
});
|
||||
|
||||
// ── Auth header helper ────────────────────────────────────────────────────────
|
||||
function _ah() {
|
||||
return { 'Content-Type': 'application/json', Authorization: `Bearer ${window.Auth?.token()}` };
|
||||
}
|
||||
|
||||
// ── PORTS ─────────────────────────────────────────────────────────────────────
|
||||
async function _loadOrgPorts() {
|
||||
try {
|
||||
const r = await fetch(`${API}/org/ports`, { headers: _ah() });
|
||||
_orgPorts = await r.json();
|
||||
_renderPorts();
|
||||
// populate company port selector
|
||||
const sel = document.getElementById('oc-port');
|
||||
sel.innerHTML = '<option value="">— Select port —</option>' +
|
||||
_orgPorts.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
|
||||
} catch (e) {
|
||||
document.getElementById('org-ports-body').innerHTML =
|
||||
`<tr><td colspan="6" style="color:var(--red)">Error: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderPorts() {
|
||||
const tbody = document.getElementById('org-ports-body');
|
||||
tbody.innerHTML = _orgPorts.map(p => `
|
||||
<tr>
|
||||
<td>${p.name}</td>
|
||||
<td class="mono" style="font-size:0.7rem">${p.center_lat?.toFixed(4) ?? '--'}, ${p.center_lon?.toFixed(4) ?? '--'}</td>
|
||||
<td>${p.default_zoom}</td>
|
||||
<td style="font-size:0.7rem;color:var(--text-muted)">${p.chart_name || '--'}</td>
|
||||
<td><span style="color:${p.activo ? 'var(--green)' : 'var(--red)'}">${p.activo ? 'ACTIVE' : 'OFF'}</span></td>
|
||||
<td>
|
||||
<button class="chart-row-btn" onclick="_editPort('${p.id}')">EDIT</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('btn-port-add')?.addEventListener('click', async () => {
|
||||
const name = document.getElementById('op-name').value.trim();
|
||||
const lat = parseFloat(document.getElementById('op-lat').value);
|
||||
const lon = parseFloat(document.getElementById('op-lon').value);
|
||||
const zoom = parseFloat(document.getElementById('op-zoom').value) || 12;
|
||||
const chart = document.getElementById('op-chart').value.trim();
|
||||
const st = document.getElementById('org-ports-status');
|
||||
if (!name) { st.textContent = 'Port name is required.'; return; }
|
||||
try {
|
||||
const r = await fetch(`${API}/org/ports`, {
|
||||
method: 'POST', headers: _ah(),
|
||||
body: JSON.stringify({ name, center_lat: isNaN(lat)?null:lat, center_lon: isNaN(lon)?null:lon,
|
||||
default_zoom: zoom, chart_name: chart || null })
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail);
|
||||
st.textContent = `Port "${name}" added.`;
|
||||
document.getElementById('op-name').value = '';
|
||||
await _loadOrgPorts();
|
||||
} catch (e) { st.textContent = `Error: ${e.message}`; }
|
||||
});
|
||||
|
||||
window._editPort = async function(portId) {
|
||||
const p = _orgPorts.find(x => x.id === portId);
|
||||
if (!p) return;
|
||||
const name = prompt('Port name:', p.name);
|
||||
if (!name) return;
|
||||
const chart = prompt('Chart folder (leave blank to keep):', p.chart_name || '');
|
||||
try {
|
||||
await fetch(`${API}/org/ports/${portId}`, {
|
||||
method: 'PUT', headers: _ah(),
|
||||
body: JSON.stringify({ name, chart_name: chart || null })
|
||||
});
|
||||
await _loadOrgPorts();
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
};
|
||||
|
||||
// ── COMPANIES ─────────────────────────────────────────────────────────────────
|
||||
async function _loadOrgCompanies() {
|
||||
try {
|
||||
const r = await fetch(`${API}/org/companies`, { headers: _ah() });
|
||||
_orgCompanies = await r.json();
|
||||
_renderCompanies();
|
||||
// populate ownership selectors
|
||||
const sels = [document.getElementById('ow-filter-company'),
|
||||
document.getElementById('ow-company-sel')];
|
||||
sels.forEach(sel => {
|
||||
if (!sel) return;
|
||||
const val = sel.value;
|
||||
sel.innerHTML = '<option value="">— All companies —</option>' +
|
||||
_orgCompanies.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
sel.value = val;
|
||||
});
|
||||
} catch (e) {
|
||||
document.getElementById('org-companies-body').innerHTML =
|
||||
`<tr><td colspan="6" style="color:var(--red)">Error: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderCompanies() {
|
||||
const portMap = Object.fromEntries(_orgPorts.map(p => [p.id, p.name]));
|
||||
const tbody = document.getElementById('org-companies-body');
|
||||
tbody.innerHTML = _orgCompanies.map(c => `
|
||||
<tr>
|
||||
<td>${c.name}</td>
|
||||
<td>${portMap[c.port_id] || '--'}</td>
|
||||
<td class="mono" style="font-size:0.7rem">${c.contact_email || '--'}</td>
|
||||
<td style="font-size:0.7rem">${c.contact_phone || '--'}</td>
|
||||
<td><span style="color:${c.activa ? 'var(--green)' : 'var(--red)'}">${c.activa ? 'ACTIVE' : 'OFF'}</span></td>
|
||||
<td><button class="chart-row-btn" onclick="_editCompany('${c.id}')">EDIT</button></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('btn-company-add')?.addEventListener('click', async () => {
|
||||
const name = document.getElementById('oc-name').value.trim();
|
||||
const portId = document.getElementById('oc-port').value;
|
||||
const email = document.getElementById('oc-email').value.trim();
|
||||
const phone = document.getElementById('oc-phone').value.trim();
|
||||
const st = document.getElementById('org-companies-status');
|
||||
if (!name) { st.textContent = 'Company name is required.'; return; }
|
||||
try {
|
||||
const r = await fetch(`${API}/org/companies`, {
|
||||
method: 'POST', headers: _ah(),
|
||||
body: JSON.stringify({ name, port_id: portId || null,
|
||||
contact_email: email || null, contact_phone: phone || null })
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail);
|
||||
st.textContent = `Company "${name}" added.`;
|
||||
document.getElementById('oc-name').value = '';
|
||||
await _loadOrgCompanies();
|
||||
} catch (e) { st.textContent = `Error: ${e.message}`; }
|
||||
});
|
||||
|
||||
window._editCompany = async function(companyId) {
|
||||
const c = _orgCompanies.find(x => x.id === companyId);
|
||||
if (!c) return;
|
||||
const name = prompt('Company name:', c.name);
|
||||
if (!name) return;
|
||||
const portId = prompt('Port ID (leave blank to keep):', c.port_id || '');
|
||||
try {
|
||||
await fetch(`${API}/org/companies/${companyId}`, {
|
||||
method: 'PUT', headers: _ah(),
|
||||
body: JSON.stringify({ name, port_id: portId || null })
|
||||
});
|
||||
await _loadOrgCompanies();
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
};
|
||||
|
||||
// ── BUOY OWNERSHIP ────────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-ow-load')?.addEventListener('click', _loadOwnership);
|
||||
|
||||
async function _loadOwnership() {
|
||||
const companyId = document.getElementById('ow-filter-company').value;
|
||||
if (!companyId) {
|
||||
document.getElementById('org-ownership-body').innerHTML =
|
||||
'<tr><td colspan="5" style="color:var(--text-muted)">Select a company first.</td></tr>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`${API}/org/companies/${companyId}/buoys`, { headers: _ah() });
|
||||
const rows = await r.json();
|
||||
const cName = _orgCompanies.find(c => c.id === companyId)?.name || companyId;
|
||||
document.getElementById('org-ownership-body').innerHTML = rows.length ? rows.map(row => `
|
||||
<tr>
|
||||
<td>${cName}</td>
|
||||
<td>${row.aid_nombre || '--'}</td>
|
||||
<td class="mono">${row.mmsi || '--'}</td>
|
||||
<td style="font-size:0.7rem;color:var(--text-muted)">${row.notas || '--'}</td>
|
||||
<td>
|
||||
<button class="chart-row-btn danger" onclick="_removeOwnership('${companyId}','${row.id}')">REMOVE</button>
|
||||
</td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="5" style="color:var(--text-muted)">No buoys assigned to this company.</td></tr>';
|
||||
} catch (e) {
|
||||
document.getElementById('org-ownership-body').innerHTML =
|
||||
`<tr><td colspan="5" style="color:var(--red)">Error: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
window._removeOwnership = async function(companyId, ownershipId) {
|
||||
if (!confirm('Remove this buoy assignment?')) return;
|
||||
try {
|
||||
const r = await fetch(`${API}/org/companies/${companyId}/buoys/${ownershipId}`, {
|
||||
method: 'DELETE', headers: _ah()
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail);
|
||||
// Refresh cache on server
|
||||
await fetch(`${API}/org/refresh`, { method: 'POST', headers: _ah() });
|
||||
await _loadOwnership();
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
};
|
||||
|
||||
document.getElementById('btn-ow-assign')?.addEventListener('click', async () => {
|
||||
const companyId = document.getElementById('ow-company-sel').value;
|
||||
const aidId = document.getElementById('ow-aid-id').value.trim();
|
||||
const mmsi = document.getElementById('ow-mmsi').value.trim();
|
||||
const notas = document.getElementById('ow-notes').value.trim();
|
||||
const st = document.getElementById('org-ownership-status');
|
||||
if (!companyId) { st.textContent = 'Select a company.'; return; }
|
||||
if (!aidId && !mmsi) { st.textContent = 'Provide an Aid ID or MMSI.'; return; }
|
||||
try {
|
||||
const r = await fetch(`${API}/org/companies/${companyId}/buoys`, {
|
||||
method: 'POST', headers: _ah(),
|
||||
body: JSON.stringify({ aid_id: aidId || null, mmsi: mmsi || null, notas: notas || null })
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail);
|
||||
// Refresh cache on server
|
||||
await fetch(`${API}/org/refresh`, { method: 'POST', headers: _ah() });
|
||||
st.textContent = 'Buoy assigned.';
|
||||
document.getElementById('ow-aid-id').value = '';
|
||||
document.getElementById('ow-mmsi').value = '';
|
||||
// Auto-load the company we just assigned
|
||||
document.getElementById('ow-filter-company').value = companyId;
|
||||
await _loadOwnership();
|
||||
} catch (e) { st.textContent = `Error: ${e.message}`; }
|
||||
});
|
||||
|
||||
// ── Close buttons ─────────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-close-org')?.addEventListener('click', () => _hideModal('modal-org'));
|
||||
document.getElementById('btn-close-org2')?.addEventListener('click', () => _hideModal('modal-org'));
|
||||
Reference in New Issue
Block a user