Files
gandalf/static/cutsheet.js
T

569 lines
26 KiB
JavaScript
Raw Normal View History

'use strict';
/* global CS_CAN_EDIT, deviceUrl */
// Cutsheet page: renders /api/cutsheet and edits cables, items and panel-port labels.
const esc = s => lt.escHtml(s == null ? '' : String(s));
const VERDICT_CLASS = {
match: 'badge-ok', wrong_neighbor: 'badge-critical', no_link: 'badge-critical', missing_port: 'badge-critical',
conflict: 'badge-critical', unverified: 'badge-info', idle: 'badge-neutral', not_checked: 'badge-neutral',
planned: 'badge-purple', dark: 'badge-neutral', retired: 'badge-neutral',
};
const VERDICT_SHORT = {
match: 'Matches', wrong_neighbor: 'Wrong neighbor', no_link: 'No link', missing_port: 'Missing port',
conflict: 'Port conflict', unverified: 'Unverified', idle: 'Idle', not_checked: 'Not checked',
planned: 'Planned', dark: 'Dark', retired: 'Retired',
};
const KIND_TEXT = { patch_panel: 'Patch panel', wall_jack: 'Wall jack', device: 'Device', shelf: 'Shelf', other: 'Other' };
let _cs = null;
let _showAllSuggestions = false;
const SUGGESTIONS_SHOWN = 10;
function natural(a, b) {
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
function itemByName(name) {
return (_cs.items || []).find(i => i.name === name);
}
function isPassive(name) {
const item = itemByName(name);
return !!(item && _cs.options.passive_kinds.includes(item.kind));
}
function verdictBadge(c) {
return `<span class="lt-badge ${VERDICT_CLASS[c.verdict] || 'badge-neutral'}" title="${esc(c.detail)}">${esc(VERDICT_SHORT[c.verdict] || c.verdict)}</span>`;
}
function deviceLink(name) {
if (!name) return '<span class="val-neutral">unterminated</span>';
const item = itemByName(name);
const known = (_cs.catalog || []).some(d => d.name === name && (d.kind === 'switch' || d.kind === 'host'));
if (item && !known) return `<a class="device-link" href="#item-${esc(item.id)}">${esc(name)}</a>`;
return `<a class="device-link" href="${esc(deviceUrl(name))}">${esc(name)}</a>`;
}
function endText(c, end) {
const device = c[`${end}_device`];
if (!device) return deviceLink('');
const port = c[`${end}_port`];
const side = c[`${end}_side`];
let text = deviceLink(device);
if (port) text += ` <span class="cs-port">${/^\d+$/.test(port) ? 'port ' : ''}${esc(port)}</span>`;
if (side) text += ` <span class="cs-side-tag">${esc(side)}</span>`;
return text;
}
function pathText(c) {
// The run beyond this cable, through any panels or jacks
const nodes = (c.path || []).filter(h => !('cable_id' in h)).map(h => h.text);
return nodes.length > 2 ? nodes.map(esc).join(' → ') : '';
}
// ── Rendering ───────────────────────────────────────────────────────────
function renderSummary() {
const cables = _cs.cables.filter(c => c.status !== 'retired');
const problems = cables.filter(c => c.problem).length;
const matches = cables.filter(c => c.verdict === 'match').length;
const labels = _cs.labels.cables.length + _cs.labels.ports.length;
const chip = (n, text, cls, target) =>
`<a class="cs-chip ${cls}" href="#${target}"><b>${n}</b> ${esc(text)}</a>`;
document.getElementById('cs-summary').innerHTML = [
chip(cables.length, 'cables', '', 'cs-cables-section'),
chip(matches, 'match what Gandalf sees', 'cs-chip-ok', 'cs-cables-section'),
chip(problems, problems === 1 ? 'problem' : 'problems', problems ? 'cs-chip-bad' : '', 'cs-cables-section'),
chip(_cs.suggestions.length, 'undocumented links', _cs.suggestions.length ? 'cs-chip-warn' : '', 'cs-suggest-section'),
chip(labels, 'labels to make', labels ? 'cs-chip-warn' : '', 'cs-labels-section'),
].join('');
}
function cableMatches(c, words, status, problemsOnly) {
if (status && c.status !== status) return false;
if (problemsOnly && !c.problem) return false;
if (!words.length) return true;
const hay = [c.label, c.a_text, c.b_text, c.cable_type, c.color, c.notes, c.detail, c.status]
.join(' ').toLowerCase();
return words.every(w => hay.includes(w));
}
function renderCables() {
const words = document.getElementById('cs-filter').value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const status = document.getElementById('cs-status-filter').value;
const problemsOnly = document.getElementById('cs-problems-only').checked;
const rows = _cs.cables
.filter(c => cableMatches(c, words, status, problemsOnly))
.sort((a, b) => (b.problem - a.problem) || natural(a.label || '~', b.label || '~') || a.id - b.id);
document.getElementById('cs-cable-count').textContent = _cs.cables.length;
const wrap = document.getElementById('cs-cables');
if (!_cs.cables.length) {
wrap.innerHTML = emptyState('🔌', 'No cables yet',
CS_CAN_EDIT ? 'Add one with + Cable, or start from the undocumented links below.' : 'An admin can add cables.');
return;
}
if (!rows.length) {
wrap.innerHTML = emptyState('🔍', 'No cables match the filter', '');
return;
}
wrap.innerHTML = `
<div class="lt-table-wrap">
<table class="lt-table cs-table">
<caption class="lt-sr-only">Cables</caption>
<thead><tr><th>ID</th><th>From</th><th>To</th><th>Type</th><th>Status</th><th>Check</th>${CS_CAN_EDIT ? '<th class="no-print"></th>' : ''}</tr></thead>
<tbody>${rows.map(c => `
<tr id="cable-${esc(c.id)}" class="${c.problem ? 'cs-row-problem' : ''}${c.status === 'retired' ? ' row-resolved' : ''}">
<td class="cs-id">${c.label ? esc(c.label) : '<span class="val-neutral">–</span>'}${c.label && !c.label_applied && c.status !== 'retired' ? ' <span class="cs-tag" title="The label isn\'t on the cable yet">not stuck on</span>' : ''}</td>
<td data-label="From">${endText(c, 'a')}</td>
<td data-label="To">${endText(c, 'b')}${pathText(c) ? `<div class="cs-path">${pathText(c)}</div>` : ''}</td>
<td data-label="Type">${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '–'}</td>
<td data-label="Status">${esc(c.status)}</td>
<td data-label="Check">${verdictBadge(c)}<div class="cs-detail">${esc(c.detail)}</div></td>
${CS_CAN_EDIT ? `<td class="no-print"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-cable" data-id="${esc(c.id)}" aria-label="Edit cable ${esc(c.label || c.id)}">Edit</button></td>` : ''}
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
function renderSuggestions() {
const list = _cs.suggestions;
document.getElementById('cs-suggest-count').textContent = list.length;
const wrap = document.getElementById('cs-suggestions');
if (!list.length) {
wrap.innerHTML = emptyState('✅', 'Every live switch port is documented', '');
return;
}
wrap.innerHTML = `
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Undocumented links</caption>
<thead><tr><th>Switch port</th><th>UniFi name</th><th>Sees</th><th>Found by</th>${CS_CAN_EDIT ? '<th></th>' : ''}</tr></thead>
<tbody>${list.slice(0, _showAllSuggestions ? list.length : SUGGESTIONS_SHOWN).map((s, i) => `
<tr>
<td>${deviceLink(s.switch)} <span class="cs-port">port ${esc(s.port_idx)}</span></td>
<td>${esc(s.port_name)}</td>
<td>${s.neighbor ? `${deviceLink(s.neighbor)}${s.neighbor_port ? ` <span class="cs-port">${esc(s.neighbor_port)}</span>` : ''}` : '<span class="val-neutral">unknown</span>'}</td>
<td>${esc(s.via || 'link only')}</td>
${CS_CAN_EDIT ? `<td><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="add-suggestion" data-i="${i}">Add</button></td>` : ''}
</tr>`).join('')}
</tbody>
</table>
</div>
${list.length > SUGGESTIONS_SHOWN ? `<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm cs-more" data-action="toggle-suggestions">
${_showAllSuggestions ? 'Show fewer' : `Show all ${list.length}`}</button>` : ''}`;
}
function cableAt(name, port, side) {
return _cs.cables.find(c => c.status !== 'retired' &&
['a', 'b'].some(e => c[`${e}_device`] === name && c[`${e}_port`] === String(port) && c[`${e}_side`] === side));
}
function farEnd(c, name, port, side) {
const end = (c.a_device === name && c.a_port === String(port) && c.a_side === side) ? 'b' : 'a';
return endText(c, end);
}
function renderPanels() {
const panels = _cs.items.filter(i => i.kind === 'patch_panel').sort((a, b) => natural(a.name, b.name));
const wrap = document.getElementById('cs-panels');
if (!panels.length) {
wrap.innerHTML = emptyState('🗄️', 'No patch panels yet', CS_CAN_EDIT ? 'Add one with + Panel / item.' : '');
return;
}
const labels = {};
for (const l of _cs.port_labels) labels[`${l.item_id}|${l.port}`] = l;
wrap.innerHTML = panels.map(p => {
const rows = [];
for (let n = 1; n <= (p.port_count || 0); n++) {
const front = cableAt(p.name, n, 'front');
const rear = cableAt(p.name, n, 'rear');
const lab = labels[`${p.id}|${n}`];
const cell = (c, side) => c
? `<a class="cs-id" href="#cable-${esc(c.id)}">${esc(c.label || 'unlabeled')}</a> → ${farEnd(c, p.name, n, side)}`
: '<span class="val-neutral">–</span>';
rows.push(`
<tr>
<td class="cs-id">${n}</td>
<td>${lab ? esc(lab.label) + (lab.applied ? '' : ' <span class="cs-tag">not stuck on</span>') : '<span class="val-neutral">–</span>'}
${CS_CAN_EDIT ? `<button type="button" class="cs-link-btn no-print" data-action="edit-port-label" data-item="${esc(p.id)}" data-port="${n}" aria-label="Edit label for ${esc(p.name)} port ${n}">edit</button>` : ''}</td>
<td>${cell(front, 'front')}</td>
<td>${cell(rear, 'rear')}</td>
</tr>`);
}
return `
<div class="lt-frame cs-panel" id="item-${esc(p.id)}">
<span class="lt-frame-bl">&#x255A;</span><span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">${esc(p.name)}${p.rack_u ? ` · U${esc(p.rack_u)}` : ''}${p.location ? ` · ${esc(p.location)}` : ''}</div>
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Ports on ${esc(p.name)}</caption>
<thead><tr><th>Port</th><th>Label</th><th>Front (patch cord)</th><th>Rear (run)</th></tr></thead>
<tbody>${rows.join('')}</tbody>
</table>
</div>
</div>`;
}).join('');
}
function renderLabels() {
const { cables, ports, unlabeled_cable_ids: unlabeled } = _cs.labels;
document.getElementById('cs-label-count').textContent = cables.length + ports.length;
const wrap = document.getElementById('cs-labels');
if (!cables.length && !ports.length) {
wrap.innerHTML = emptyState('🏷️', 'Nothing to label', unlabeled.length
? `${unlabeled.length} cable(s) have no ID yet: give them one to add them here.` : '');
return;
}
const tick = (attrs, label) => CS_CAN_EDIT
? `<input type="checkbox" class="no-print" ${attrs} aria-label="${esc(label)}">` : '';
wrap.innerHTML = `
<div class="cs-label-grid">
${cables.map(c => `
<div class="cs-label-card">
${tick(`data-action="applied-cable" data-id="${esc(c.id)}"`, `${c.text} is stuck on`)}
<span class="cs-label-text">${esc(c.text)}</span>
<span class="cs-label-copies">×${esc(c.copies)}</span>
<span class="cs-label-where">${esc(c.where)}</span>
</div>`).join('')}
${ports.map(p => `
<div class="cs-label-card">
${tick(`data-action="applied-port" data-item="${esc(p.item)}" data-port="${esc(p.port)}"`, `${p.text} is stuck on`)}
<span class="cs-label-text">${esc(p.text)}</span>
<span class="cs-label-copies">×1</span>
<span class="cs-label-where">${esc(p.item)} port ${esc(p.port)}</span>
</div>`).join('')}
</div>
${unlabeled.length ? `<p class="cs-hint">${unlabeled.length} more cable(s) have no ID yet.</p>` : ''}`;
}
function renderRack() {
const placed = _cs.items.filter(i => i.rack_u).sort((a, b) => (b.rack_u + (b.rack_height || 1)) - (a.rack_u + (a.rack_height || 1)));
const wrap = document.getElementById('cs-rack');
if (!placed.length) {
wrap.innerHTML = emptyState('🗄️', 'No rack positions recorded', CS_CAN_EDIT ? 'Set a rack unit on a panel, shelf or device in the inventory.' : '');
return;
}
wrap.innerHTML = `
<div class="cs-rack">
${placed.map(i => {
const top = i.rack_u + (i.rack_height || 1) - 1;
return `
<div class="cs-rack-unit" style="--u:${Math.min(i.rack_height || 1, 8)}">
<span class="cs-rack-u">${i.rack_height > 1 ? `U${esc(top)}–${esc(i.rack_u)}` : `U${esc(i.rack_u)}`}</span>
<span class="cs-rack-name">${deviceLink(i.name)}</span>
<span class="cs-rack-kind">${esc(KIND_TEXT[i.kind] || i.kind)}</span>
</div>`;
}).join('')}
</div>`;
}
function renderItems() {
const items = [..._cs.items].sort((a, b) => natural(a.kind, b.kind) || natural(a.name, b.name));
document.getElementById('cs-item-count').textContent = items.length;
const wrap = document.getElementById('cs-items');
if (!items.length) {
wrap.innerHTML = emptyState('📦', 'No items yet', CS_CAN_EDIT ? 'Add your patch panel first: + Panel / item.' : '');
return;
}
wrap.innerHTML = `
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Inventory</caption>
<thead><tr><th>Name</th><th>Kind</th><th>Ports</th><th>Rack</th><th>Location</th><th>Notes</th>${CS_CAN_EDIT ? '<th class="no-print"></th>' : ''}</tr></thead>
<tbody>${items.map(i => `
<tr id="${i.kind === 'patch_panel' ? '' : `item-${esc(i.id)}`}">
<td>${deviceLink(i.name)}</td>
<td>${esc(KIND_TEXT[i.kind] || i.kind)}</td>
<td>${esc(i.port_count || '–')}</td>
<td>${i.rack_u ? `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}` : '–'}</td>
<td>${esc(i.location || '–')}</td>
<td>${esc(i.notes || '')}</td>
${CS_CAN_EDIT ? `<td class="no-print"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-item" data-id="${esc(i.id)}" aria-label="Edit ${esc(i.name)}">Edit</button></td>` : ''}
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
function renderHistory() {
const rows = _cs.history || [];
const wrap = document.getElementById('cs-history');
if (!rows.length) {
wrap.innerHTML = emptyState('📋', 'No changes yet', '');
return;
}
wrap.innerHTML = `
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Change history</caption>
<thead><tr><th>When</th><th>Who</th><th>Change</th></tr></thead>
<tbody>${rows.map(h => `
<tr><td class="ts-cell">${esc(fmtWhen(h.changed_at))}</td><td>${esc(h.changed_by)}</td><td>${esc(h.summary)}</td></tr>`).join('')}
</tbody>
</table>
</div>`;
}
function fmtWhen(iso) {
const d = new Date(iso);
return isNaN(d) ? iso : d.toLocaleString();
}
function emptyState(icon, title, body) {
return `<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">${icon}</div>
<div class="lt-empty-state-title">${esc(title)}</div>${body ? `<div class="lt-empty-state-body">${esc(body)}</div>` : ''}</div>`;
}
function renderDatalists() {
document.getElementById('cs-device-list').innerHTML =
(_cs.catalog || []).map(d => `<option value="${esc(d.name)}">${esc(d.kind)}</option>`).join('');
}
function render() {
renderSummary();
renderCables();
renderSuggestions();
renderPanels();
renderLabels();
renderRack();
renderItems();
renderHistory();
renderDatalists();
}
async function load() {
try {
_cs = await lt.api.get('/api/cutsheet');
} catch (err) {
document.getElementById('cs-summary').innerHTML =
`<div class="lt-badge badge-critical">Couldn't load the cutsheet: ${esc(err.message)}</div>`;
return;
}
render();
const hash = window.location.hash;
if (hash && /^#(cable|item)-\d+$/.test(hash)) {
const el = document.getElementById(hash.slice(1));
if (el) { el.classList.add('cs-flash'); el.scrollIntoView({ block: 'center' }); }
}
}
// ── Editing ─────────────────────────────────────────────────────────────
function fillPorts(end) {
const device = document.querySelector(`#cs-cable-form [name="${end}_device"]`).value;
const entry = (_cs.catalog || []).find(d => d.name === device);
document.getElementById(`cs-${end}-port-list`).innerHTML = entry
? entry.ports.map(p => `<option value="${esc(p.value)}">${esc(p.text)}</option>`).join('') : '';
const side = document.querySelector(`.cs-side[data-end="${end}"]`);
side.hidden = !isPassive(device);
}
function openCable(cable, prefill) {
const form = document.getElementById('cs-cable-form');
form.reset();
const types = ['', ..._cs.options.cable_types];
form.cable_type.innerHTML = types.map(t => `<option value="${esc(t)}">${esc(t || '–')}</option>`).join('');
const data = Object.assign({ status: 'installed', label: cable ? '' : _cs.next_label }, cable || {}, prefill || {});
for (const key of ['id', 'label', 'a_device', 'a_port', 'b_device', 'b_port', 'cable_type', 'length_m', 'color', 'status', 'notes']) {
if (form[key]) form[key].value = data[key] == null ? '' : data[key];
}
form.a_side.value = data.a_side || 'front';
form.b_side.value = data.b_side || 'front';
form.label_applied.checked = !!data.label_applied;
form.querySelector('.cs-delete').hidden = !cable;
document.getElementById('cs-next-label').textContent = _cs.next_label;
document.getElementById('cs-cable-title').textContent = cable ? `Edit ${cable.label || 'cable'}` : 'New cable';
fillPorts('a');
fillPorts('b');
lt.modal.open('cs-cable-modal');
}
function openItem(item) {
const form = document.getElementById('cs-item-form');
form.reset();
const data = Object.assign({ kind: 'patch_panel' }, item || {});
for (const key of ['id', 'name', 'kind', 'port_count', 'rack_u', 'rack_height', 'location', 'notes']) {
form[key].value = data[key] == null ? '' : data[key];
}
if (!item) form.port_count.value = 24;
form.querySelector('.cs-delete').hidden = !item;
document.getElementById('cs-item-title').textContent = item ? `Edit ${item.name}` : 'New panel or item';
lt.modal.open('cs-item-modal');
}
function openPortLabel(itemId, port) {
const form = document.getElementById('cs-port-form');
form.reset();
const item = _cs.items.find(i => String(i.id) === String(itemId));
const lab = _cs.port_labels.find(l => String(l.item_id) === String(itemId) && String(l.port) === String(port));
form.item_id.value = itemId;
form.port.value = port;
form.label.value = lab ? lab.label : '';
form.applied.checked = !!(lab && lab.applied);
document.getElementById('cs-port-title').textContent = `${item ? item.name : 'Panel'} port ${port}`;
lt.modal.open('cs-port-modal');
}
function formData(form, fields) {
const out = {};
for (const f of fields) out[f] = form[f].value;
return out;
}
async function save(fn, okText, modalId) {
try {
await fn();
if (modalId) lt.modal.close(modalId);
lt.toast.success(okText);
await load();
} catch (err) {
lt.toast.error(err.message || 'Save failed');
}
}
async function saveCable(e) {
e.preventDefault();
const form = e.target;
const body = formData(form, ['label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
'cable_type', 'length_m', 'color', 'status', 'notes']);
body.label_applied = form.label_applied.checked;
const id = form.id.value;
await save(() => id ? lt.api.put(`/api/cutsheet/cables/${encodeURIComponent(id)}`, body)
: lt.api.post('/api/cutsheet/cables', body), 'Cable saved', 'cs-cable-modal');
}
async function saveItem(e) {
e.preventDefault();
const form = e.target;
const body = formData(form, ['name', 'kind', 'port_count', 'rack_u', 'rack_height', 'location', 'notes']);
const id = form.id.value;
await save(() => id ? lt.api.put(`/api/cutsheet/items/${encodeURIComponent(id)}`, body)
: lt.api.post('/api/cutsheet/items', body), 'Saved', 'cs-item-modal');
}
async function savePortLabel(e) {
e.preventDefault();
const form = e.target;
const url = `/api/cutsheet/items/${encodeURIComponent(form.item_id.value)}/ports/${encodeURIComponent(form.port.value)}`;
await save(() => lt.api.put(url, { label: form.label.value, applied: form.applied.checked }), 'Label saved', 'cs-port-modal');
}
async function markCableApplied(id, applied) {
const c = _cs.cables.find(x => String(x.id) === String(id));
if (!c) return;
const body = {};
for (const k of ['label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side', 'cable_type', 'length_m', 'color', 'status', 'notes']) {
body[k] = c[k] == null ? '' : c[k];
}
body.label_applied = applied;
await save(() => lt.api.put(`/api/cutsheet/cables/${encodeURIComponent(id)}`, body), `${c.label} marked as labeled`);
}
async function markPortApplied(itemName, port, applied) {
const item = itemByName(itemName);
const lab = item && _cs.port_labels.find(l => l.item_id === item.id && String(l.port) === String(port));
if (!lab) return;
await save(() => lt.api.put(`/api/cutsheet/items/${encodeURIComponent(item.id)}/ports/${encodeURIComponent(port)}`,
{ label: lab.label, applied }), `${lab.label} marked as labeled`);
}
async function importCsv(e, dryRun) {
if (e) e.preventDefault();
const errorsEl = document.getElementById('cs-import-errors');
errorsEl.innerHTML = '';
const fileInput = document.getElementById('cs-import-file');
let text = document.getElementById('cs-import-text').value;
if (fileInput.files.length) text = await fileInput.files[0].text();
let resp, data;
try {
// fetch directly: lt.api keeps only the message, and the row errors matter here
resp = await fetch('/api/cutsheet/import', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, lt.csrf.headers()),
body: JSON.stringify({ csv: text, dry_run: dryRun }),
});
data = await resp.json();
} catch (err) {
errorsEl.innerHTML = `<div class="val-crit">Import failed: ${esc(err.message)}</div>`;
return;
}
if (!resp.ok) {
errorsEl.innerHTML = `<div class="val-crit">${esc(data.error || `HTTP ${resp.status}`)}</div>` +
(data.errors ? `<ul>${data.errors.map(x => `<li>${esc(x)}</li>`).join('')}</ul>` : '');
return;
}
if (dryRun) {
errorsEl.innerHTML = `<div class="val-good">${esc(data.rows)} row(s) look valid. Nothing imported yet.</div>`;
return;
}
lt.modal.close('cs-import-modal');
lt.toast.success(`Imported: ${data.added} added, ${data.updated} updated`);
await load();
}
// ── Wiring ──────────────────────────────────────────────────────────────
document.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'new-cable') openCable(null);
else if (action === 'new-item') openItem(null);
else if (action === 'edit-cable') openCable(_cs.cables.find(c => String(c.id) === btn.dataset.id));
else if (action === 'edit-item') openItem(_cs.items.find(i => String(i.id) === btn.dataset.id));
else if (action === 'edit-port-label') openPortLabel(btn.dataset.item, btn.dataset.port);
else if (action === 'print-labels') printLabels();
else if (action === 'use-next-label') document.getElementById('cs-label').value = _cs.next_label;
else if (action === 'toggle-suggestions') { _showAllSuggestions = !_showAllSuggestions; renderSuggestions(); }
else if (action === 'import-check') importCsv(null, true);
else if (action === 'add-suggestion') {
const s = _cs.suggestions[Number(btn.dataset.i)];
openCable(null, {
a_device: s.switch, a_port: String(s.port_idx), b_device: s.neighbor, b_port: s.neighbor_port,
cable_type: s.cable_type,
});
} else if (action === 'delete-cable') {
const form = document.getElementById('cs-cable-form');
if (!confirm('Delete this cable record? Retiring it keeps the history visible; deleting removes it.')) return;
save(() => lt.api.delete(`/api/cutsheet/cables/${encodeURIComponent(form.id.value)}`), 'Cable deleted', 'cs-cable-modal');
} else if (action === 'delete-item') {
const form = document.getElementById('cs-item-form');
if (!confirm(`Delete ${form.name.value}?`)) return;
save(() => lt.api.delete(`/api/cutsheet/items/${encodeURIComponent(form.id.value)}`), 'Deleted', 'cs-item-modal');
}
});
document.addEventListener('change', e => {
const t = e.target;
if (t.dataset.action === 'applied-cable') markCableApplied(t.dataset.id, t.checked);
else if (t.dataset.action === 'applied-port') markPortApplied(t.dataset.item, t.dataset.port, t.checked);
});
function printLabels() {
document.body.classList.add('cs-print-labels');
window.print();
document.body.classList.remove('cs-print-labels');
}
function openFromQuery() {
const q = new URLSearchParams(window.location.search);
if (!CS_CAN_EDIT || !q.get('a_device')) return;
openCable(null, { a_device: q.get('a_device'), a_port: q.get('a_port') || '' });
history.replaceState(null, '', window.location.pathname);
}
document.getElementById('cs-filter').addEventListener('input', () => _cs && renderCables());
document.getElementById('cs-status-filter').addEventListener('change', () => _cs && renderCables());
document.getElementById('cs-problems-only').addEventListener('change', () => _cs && renderCables());
if (CS_CAN_EDIT) {
document.getElementById('cs-cable-form').addEventListener('submit', saveCable);
document.getElementById('cs-item-form').addEventListener('submit', saveItem);
document.getElementById('cs-port-form').addEventListener('submit', savePortLabel);
document.getElementById('cs-import-form').addEventListener('submit', e => importCsv(e, false));
for (const end of ['a', 'b']) {
const input = document.querySelector(`#cs-cable-form [name="${end}_device"]`);
input.addEventListener('input', () => fillPorts(end));
input.addEventListener('change', () => fillPorts(end));
}
}
load().then(openFromQuery);