netops/netops-todo-node/public/app.js
Jon Vanvik 6b9dbf8254 Legg til Node/npm-versjon med sanntidssynk + Debian 13-oppsettsskript
Ny app i netops-todo-node/: Express + ws, WebSocket-push i stedet for
polling, kanban med drag & drop, kommentarer, aktivitetslogg, optimistisk
låsing, quick-add-parsing og presence. 12 integrasjonstester (node:test).
setup-debian13.sh setter opp alt på en fersk LXC; README dekker Nginx
Proxy Manager-oppsett. PHP-versjonen er uendret (kun handoff-peker).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:22 +02:00

1081 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
/**
* NetOps To-Do — frontend.
* Vanlig JS uten rammeverk (bevisst, lett å patche). All data via REST-API;
* sanntidsoppdateringer, presence og "redigerer nå" via WebSocket.
*/
// ---------- Konstanter ----------
const CATEGORIES = [
{ id: 'L1', name: 'L1 Fysisk', desc: 'Kabling, fiber, patch, SFP, rack' },
{ id: 'L2', name: 'L2 Switching', desc: 'VLAN, STP, MAC, trunking' },
{ id: 'L3', name: 'L3 Ruting', desc: 'OSPF, ISIS, statisk, VRF' },
{ id: 'L4', name: 'L4 Lastbalansering', desc: 'HAProxy, NGINX, F5, BIG-IP' },
{ id: 'BGP', name: 'BGP', desc: 'iBGP / eBGP, policies, RR' },
{ id: 'PEER', name: 'Peering / IX', desc: 'IXP, private peering, PeeringDB' },
{ id: 'SEC', name: 'Firewall / Security',desc: 'ACL, firewall, IPS, audit' },
{ id: 'NMS', name: 'Overvåking / NMS', desc: 'LibreNMS, Grafana, alerts' },
{ id: 'AUTO', name: 'Automatisering', desc: 'Ansible, scripts, CI/CD' },
{ id: 'DOC', name: 'Dokumentasjon', desc: 'Diagrammer, IPAM, runbooks' },
{ id: 'INC', name: 'Incident / On-call', desc: 'Feilsøking, postmortem' },
{ id: 'PROJ', name: 'Prosjekt / Plan', desc: 'Design, anskaffelse, oppgraderinger' },
];
const CAT_COLORS = {
L1: '#ff9f43', L2: '#4fc3f7', L3: '#7fb8ff', L4: '#bb86fc',
BGP: '#4ade80', PEER: '#26de81', SEC: '#ff5370', NMS: '#ffd93d',
AUTO: '#00d2d3', DOC: '#a0a0a0', INC: '#ff6b6b', PROJ: '#8172B3'
};
const STATUS_LABELS = { todo: 'Å gjøre', progress: 'Pågår', blocked: 'Blokkert', done: 'Ferdig' };
const PRIO_ORDER = { P1: 1, P2: 2, P3: 3, P4: 4 };
const BOARD_COLS = ['todo', 'progress', 'blocked', 'done'];
const ACT_META = {
create: { icon: '', verb: 'opprettet' },
update: { icon: '✎', verb: 'endret' },
done: { icon: '✓', verb: 'fullførte' },
reopen: { icon: '↺', verb: 'gjenåpnet' },
delete: { icon: '✕', verb: 'slettet' },
comment: { icon: '💬', verb: 'kommenterte' },
import: { icon: '⬆', verb: 'importerte' },
seed: { icon: '◇', verb: 'lastet eksempeldata' },
};
// ---------- State ----------
const state = {
user: 'anon',
canSetName: false,
tasks: [],
activity: [],
knownUsers: [],
online: [],
editingMap: {}, // taskId -> [brukere med modal åpen]
filters: { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' },
sort: { field: 'priority', dir: 'asc' },
view: 'board',
editingId: null, // id i åpen modal (null = ny oppgave)
editingBaseV: null, // versjonen modalen redigerer fra
wsOk: false,
};
let charts = {};
let ws = null;
let wsRetry = 0;
let fallbackPoll = null;
const flashIds = new Set(); // ids som skal blinke ved neste render (endret av andre)
const $ = id => document.getElementById(id);
// ---------- Persistens av UI-valg ----------
function loadPrefs() {
try {
const p = JSON.parse(localStorage.getItem('netops.prefs') || '{}');
if (p.filters) Object.assign(state.filters, p.filters);
if (p.sort) state.sort = p.sort;
if (p.view === 'table' || p.view === 'board') state.view = p.view;
} catch { /* ignorer korrupt localStorage */ }
}
function savePrefs() {
localStorage.setItem('netops.prefs', JSON.stringify({
filters: state.filters, sort: state.sort, view: state.view,
}));
}
// ---------- API ----------
async function api(method, path, body = null) {
const opts = { method, headers: {} };
if (body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
const ct = res.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
throw new Error(`Uventet svar fra server (HTTP ${res.status})`);
}
const data = await res.json();
if (!res.ok) {
const err = new Error(data.error || `HTTP ${res.status}`);
err.status = res.status;
err.data = data;
throw err;
}
return data;
}
// ---------- Toast ----------
function toast(msg, opts = {}) {
const stack = $('toast-stack');
const el = document.createElement('div');
el.className = 'toast' + (opts.kind ? ' ' + opts.kind : '');
const span = document.createElement('span');
span.textContent = msg;
el.appendChild(span);
if (opts.action) {
const btn = document.createElement('button');
btn.textContent = opts.action.label;
btn.addEventListener('click', () => { el.remove(); opts.action.fn(); });
el.appendChild(btn);
}
stack.appendChild(el);
setTimeout(() => el.remove(), opts.ttl || 3500);
}
// ---------- Utils ----------
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[m]);
}
function todayISO(offsetDays = 0) {
const d = new Date(); d.setDate(d.getDate() + offsetDays);
return d.toISOString().slice(0, 10);
}
function formatDeadline(dateStr) {
if (!dateStr) return { text: '—', cls: '' };
const d = new Date(dateStr);
const today = new Date(); today.setHours(0, 0, 0, 0);
const diff = Math.round((d - today) / 86400000);
if (diff < 0) return { text: `${Math.abs(diff)}d forfalt`, cls: 'overdue' };
if (diff === 0) return { text: 'I dag', cls: 'soon' };
if (diff === 1) return { text: 'I morgen', cls: 'soon' };
if (diff <= 3) return { text: `${diff}d igjen`, cls: 'soon' };
return { text: dateStr, cls: '' };
}
function relTime(iso) {
const diff = (Date.now() - Date.parse(iso)) / 1000;
if (diff < 60) return 'nå nettopp';
if (diff < 3600) return `${Math.floor(diff / 60)} min siden`;
if (diff < 86400) return `${Math.floor(diff / 3600)} t siden`;
const d = new Date(iso);
return d.toLocaleString('nb-NO', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
function findTask(id) { return state.tasks.find(t => t.id === id); }
function upsertTask(task) {
const idx = state.tasks.findIndex(t => t.id === task.id);
if (idx >= 0) state.tasks[idx] = task; else state.tasks.push(task);
}
function pushActivity(entry) {
if (!entry) return;
state.activity.unshift(entry);
if (state.activity.length > 400) state.activity.length = 400;
}
// ---------- WebSocket ----------
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => {
wsRetry = 0;
state.wsOk = true;
setConnStatus(true);
if (fallbackPoll) { clearInterval(fallbackPoll); fallbackPoll = null; }
};
ws.onmessage = ev => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
handleWSMessage(msg);
};
ws.onclose = () => {
state.wsOk = false;
setConnStatus(false);
// Fallback: poll mens WS er nede, og prøv å koble til igjen med backoff.
if (!fallbackPoll) fallbackPoll = setInterval(refreshState, 30000);
const delay = Math.min(15000, 1000 * Math.pow(2, wsRetry++));
setTimeout(connectWS, delay);
};
ws.onerror = () => ws.close();
}
function sendWS(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
function setConnStatus(ok) {
const dot = $('conn-dot');
const txt = $('conn-text');
if (ok) {
dot.className = 'live';
txt.textContent = 'live · sanntidssynk aktiv';
} else {
dot.className = 'off';
txt.textContent = 'frakoblet · prøver igjen …';
}
}
function handleWSMessage(msg) {
switch (msg.type) {
case 'hello':
state.user = msg.user;
state.online = msg.online || [];
state.editingMap = msg.editing || {};
renderHeader();
break;
case 'sync':
state.tasks = msg.tasks || [];
state.activity = msg.activity || [];
if (msg.by && msg.by !== state.user) toast(`${msg.by} erstattet oppgavelisten (import)`);
renderAll();
break;
case 'presence':
state.online = msg.online || [];
renderHeader();
break;
case 'editing':
state.editingMap = msg.editing || {};
renderView();
updateModalWarning();
break;
case 'task': {
const mine = msg.by === state.user;
pushActivity(msg.activity);
if (msg.op === 'delete') {
state.tasks = state.tasks.filter(t => t.id !== msg.id);
if (!mine && state.editingId === msg.id) {
closeModal();
toast(`Oppgaven du redigerte ble slettet av ${msg.by}`, { kind: 'warn', ttl: 6000 });
}
} else if (msg.task) {
upsertTask(msg.task);
if (!mine) {
flashIds.add(msg.task.id);
// Noen andre lagret oppgaven jeg har åpen i modal → varsle, ikke overskriv skjemaet.
if (state.editingId === msg.task.id) updateModalWarning(msg.by);
if (state.editingId === msg.task.id) renderComments(msg.task);
}
}
renderAll();
break;
}
}
}
// ---------- Hent state via REST (init + fallback) ----------
async function refreshState() {
try {
const data = await api('GET', '/api/state');
state.user = data.user;
state.canSetName = !!data.canSetName;
state.tasks = data.tasks || [];
state.activity = data.activity || [];
state.knownUsers = data.known_users || [];
state.online = data.online || [];
renderAll();
} catch (e) {
toast('Kunne ikke laste: ' + e.message, { kind: 'err' });
}
}
// ---------- Quick add ----------
function parseQuickAdd(text) {
const out = { title: '', tags: [] };
const words = [];
for (const tok of text.trim().split(/\s+/)) {
if (!tok) continue;
let m;
if (/^p[1-4]$/i.test(tok)) { out.priority = tok.toUpperCase(); continue; }
if ((m = tok.match(/^#(.+)$/))) {
const cat = CATEGORIES.find(c => c.id.toLowerCase() === m[1].toLowerCase());
if (cat && !out.category) out.category = cat.id;
else out.tags.push(m[1]);
continue;
}
if ((m = tok.match(/^@(.+)$/))) {
out.owner = m[1].toLowerCase() === 'me' ? state.user : m[1];
continue;
}
if ((m = tok.match(/^!(.+)$/))) {
const v = m[1].toLowerCase();
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) out.deadline = v;
else if ((m = v.match(/^\+(\d{1,3})d?$/))) out.deadline = todayISO(Number(m[1]));
else if (v === 'idag' || v === 'today') out.deadline = todayISO(0);
else if (v === 'imorgen' || v === 'tomorrow') out.deadline = todayISO(1);
continue;
}
if ((m = tok.match(/^~(\d+(?:[.,]\d+)?)t?h?$/i))) { out.estHours = parseFloat(m[1].replace(',', '.')); continue; }
if ((m = tok.match(/^loc:(.+)$/i))) { out.location = m[1]; continue; }
words.push(tok);
}
out.title = words.join(' ');
return out;
}
function renderQuickAddPreview() {
const text = $('quickadd-input').value;
const el = $('quickadd-preview');
if (!text.trim()) { el.innerHTML = ''; return; }
const p = parseQuickAdd(text);
const chips = [];
const chip = (label, val) => `<span class="qa-chip">${label} <b>${escapeHtml(val)}</b></span>`;
chips.push(chip('tittel', p.title || '(mangler)'));
if (p.category) chips.push(chip('kategori', (CATEGORIES.find(c => c.id === p.category) || {}).name || p.category));
if (p.priority) chips.push(chip('prio', p.priority));
if (p.owner) chips.push(chip('eier', p.owner));
if (p.deadline) chips.push(chip('frist', p.deadline));
if (p.estHours != null) chips.push(chip('est', p.estHours + 't'));
if (p.location) chips.push(chip('lokasjon', p.location));
if (p.tags.length) chips.push(chip('tags', p.tags.join(', ')));
el.innerHTML = chips.join('');
}
async function submitQuickAdd() {
const input = $('quickadd-input');
const p = parseQuickAdd(input.value);
if (!p.title) { toast('Skriv en tittel først', { kind: 'warn' }); return; }
try {
const { task } = await api('POST', '/api/tasks', p);
upsertTask(task);
input.value = '';
renderQuickAddPreview();
renderAll();
toast('Opprettet: ' + p.title.slice(0, 40));
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
// ---------- Filtrering / sortering ----------
function applyFilters(tasks, { ignoreStatus = false } = {}) {
const f = state.filters;
return tasks.filter(t => {
if (!ignoreStatus) {
if (f.status === 'open') { if (t.status === 'done') return false; }
else if (f.status !== 'all' && t.status !== f.status) return false;
}
if (f.category !== 'all' && t.category !== f.category) return false;
if (f.priority !== 'all' && t.priority !== f.priority) return false;
if (f.owner === '__me__') { if ((t.owner || '') !== state.user) return false; }
else if (f.owner === '__unassigned__') { if (t.owner) return false; }
else if (f.owner !== 'all') { if ((t.owner || '') !== f.owner) return false; }
if (f.search) {
const q = f.search.toLowerCase();
const hay = [t.title, t.desc, t.location, t.owner, (t.tags || []).join(' ')].join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
function sortTasks(tasks) {
const { field, dir } = state.sort;
const sign = dir === 'asc' ? 1 : -1;
return [...tasks].sort((a, b) => {
let av = a[field], bv = b[field];
if (field === 'priority') { av = PRIO_ORDER[av]; bv = PRIO_ORDER[bv]; }
if (field === 'deadline') { av = av || '9999'; bv = bv || '9999'; }
if (field === 'estHours') { av = av || 0; bv = bv || 0; }
if (av == null) av = '';
if (bv == null) bv = '';
if (av < bv) return -1 * sign;
if (av > bv) return 1 * sign;
return 0;
});
}
// ---------- Render ----------
function renderAll() {
renderHeader();
renderKPIs();
renderCharts();
renderView();
renderActivity();
updateOwnerSelects();
}
function renderHeader() {
$('user-chip').textContent = state.user;
$('user-chip').className = 'user-chip' + (state.canSetName ? ' clickable' : '');
$('anon-banner').className = 'anon-banner' + (state.user === 'anon' && state.canSetName ? ' show' : '');
const others = state.online.filter(u => u !== state.user);
$('online-row').innerHTML = others.length
? others.slice(0, 6).map(u => `<span class="online-chip">${escapeHtml(u)}</span>`).join('')
+ (others.length > 6 ? `<span class="online-chip">+${others.length - 6}</span>` : '')
: '';
}
function renderKPIs() {
const open = state.tasks.filter(t => t.status !== 'done');
const mine = open.filter(t => (t.owner || '') === state.user);
const p1 = open.filter(t => t.priority === 'P1').length;
const hours = open.reduce((s, t) => s + (t.estHours || 0), 0);
const today = todayISO();
const overdue = open.filter(t => t.deadline && t.deadline < today).length;
const weekAgo = todayISO(-7);
const done7 = state.tasks.filter(t => t.status === 'done' && t.completed && t.completed >= weekAgo).length;
$('kpi-open').textContent = open.length;
$('kpi-open-sub').textContent = `av ${state.tasks.length} totalt`;
$('kpi-mine').textContent = mine.length;
const mineHours = mine.reduce((s, t) => s + (t.estHours || 0), 0);
$('kpi-mine-sub').textContent = mineHours > 0 ? `${fmtHours(mineHours)} arbeid` : 'tildelt meg';
$('kpi-p1').textContent = p1;
$('kpi-hours').textContent = fmtHours(hours);
$('kpi-hours-sub').textContent = hours > 8 ? `${(hours / 8).toFixed(1)} arbeidsdager` : 'arbeidstimer';
$('kpi-overdue').textContent = overdue;
$('kpi-done').textContent = done7;
}
function fmtHours(h) { return h.toFixed(1).replace(/\.0$/, '') + 't'; }
function renderCharts() {
if (typeof Chart === 'undefined') { $('chart-row').style.display = 'none'; return; }
const open = state.tasks.filter(t => t.status !== 'done');
const byCat = {};
open.forEach(t => { byCat[t.category] = (byCat[t.category] || 0) + 1; });
const catEntries = Object.entries(byCat).sort((a, b) => b[1] - a[1]);
$('cat-total').textContent = `${open.length} åpne`;
const byPrio = { P1: 0, P2: 0, P3: 0, P4: 0 };
open.forEach(t => { byPrio[t.priority]++; });
const byOwner = {};
open.forEach(t => {
const o = t.owner || '(utildelt)';
byOwner[o] = (byOwner[o] || 0) + 1;
});
const ownerEntries = Object.entries(byOwner).sort((a, b) => b[1] - a[1]).slice(0, 10);
Object.values(charts).forEach(c => c && c.destroy());
const commonFont = { family: "-apple-system, 'Segoe UI', sans-serif", size: 11 };
const barOpts = {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => `${ctx.parsed.x} oppgave${ctx.parsed.x === 1 ? '' : 'r'}` } }
},
scales: {
x: { beginAtZero: true, ticks: { color: '#8b98a9', font: commonFont, stepSize: 1 }, grid: { color: '#2a3340' } },
y: { ticks: { color: '#e6edf3', font: commonFont }, grid: { display: false } }
}
};
charts.cat = new Chart($('catChart'), {
type: 'bar',
data: {
labels: catEntries.map(([k]) => (CATEGORIES.find(c => c.id === k) || { name: k }).name),
datasets: [{
data: catEntries.map(([, v]) => v),
backgroundColor: catEntries.map(([k]) => (CAT_COLORS[k] || '#666') + 'CC'),
borderColor: catEntries.map(([k]) => CAT_COLORS[k] || '#666'),
borderWidth: 1, borderRadius: 4,
}]
},
options: barOpts,
});
charts.prio = new Chart($('prioChart'), {
type: 'doughnut',
data: {
labels: ['P1 Kritisk', 'P2 Høy', 'P3 Middels', 'P4 Lav'],
datasets: [{
data: [byPrio.P1, byPrio.P2, byPrio.P3, byPrio.P4],
backgroundColor: ['#ff5370CC', '#ff9f43CC', '#ffd93dCC', '#7fb8ffCC'],
borderColor: '#1a212b', borderWidth: 2,
}]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '60%',
plugins: {
legend: { position: 'right', labels: { color: '#e6edf3', font: commonFont, usePointStyle: true, padding: 12 } },
tooltip: { callbacks: { label: ctx => {
const tot = ctx.dataset.data.reduce((a, b) => a + b, 0);
const pct = tot ? ((ctx.parsed / tot) * 100).toFixed(0) : 0;
return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
} } }
}
}
});
charts.owner = new Chart($('ownerChart'), {
type: 'bar',
data: {
labels: ownerEntries.map(([k]) => k),
datasets: [{
data: ownerEntries.map(([, v]) => v),
backgroundColor: ownerEntries.map(([k]) => k === state.user ? '#4ade80CC' : '#bb86fcCC'),
borderColor: ownerEntries.map(([k]) => k === state.user ? '#4ade80' : '#bb86fc'),
borderWidth: 1, borderRadius: 4,
}]
},
options: barOpts,
});
}
function renderView() {
$('view-board').className = state.view === 'board' ? 'active-view' : '';
$('view-table').className = state.view === 'table' ? 'active-view' : '';
$('fg-status').style.display = state.view === 'board' ? 'none' : '';
$('board').style.display = state.view === 'board' ? '' : 'none';
$('table-panel').style.display = state.view === 'table' ? '' : 'none';
if (state.view === 'board') renderBoard(); else renderTable();
flashIds.clear();
}
// Felles småbiter for kort og rader
function ownerBadge(t) {
const cls = !t.owner ? 'unassigned' : (t.owner === state.user ? 'me' : '');
return `<span class="owner-badge ${cls}">${t.owner ? escapeHtml(t.owner) : 'utildelt'}</span>`;
}
function catBadge(t) {
const cat = CATEGORIES.find(c => c.id === t.category);
const col = CAT_COLORS[t.category] || '#4fc3f7';
return `<span class="cat-badge" style="color:${col};border-color:${col}40;background:${col}15" title="${cat ? escapeHtml(cat.desc) : ''}">${escapeHtml(t.category)}</span>`;
}
function editingTag(t) {
const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user);
return editors.length ? `<span class="card-editing" title="Redigeres nå av ${escapeHtml(editors.join(', '))}">✎ ${escapeHtml(editors[0])}</span>` : '';
}
function renderBoard() {
const filtered = applyFilters(state.tasks, { ignoreStatus: true });
const board = $('board');
const showEmpty = state.tasks.length === 0;
$('empty').style.display = showEmpty ? 'block' : 'none';
$('empty-seed').style.display = showEmpty ? 'block' : 'none';
board.innerHTML = BOARD_COLS.map(status => {
let items = filtered.filter(t => t.status === status);
let moreNote = '';
if (status === 'done') {
items.sort((a, b) => (b.completed || '').localeCompare(a.completed || ''));
if (items.length > 15) {
moreNote = `<div class="board-more">+ ${items.length - 15} eldre ferdige (se tabell-visning)</div>`;
items = items.slice(0, 15);
}
} else {
items.sort((a, b) =>
(PRIO_ORDER[a.priority] - PRIO_ORDER[b.priority])
|| (a.deadline || '9999').localeCompare(b.deadline || '9999'));
}
const est = items.reduce((s, t) => s + (t.estHours || 0), 0);
return `
<div class="board-col" data-status="${status}">
<div class="board-col-head">
<h3>${STATUS_LABELS[status]}</h3>
<span class="col-meta">${items.length}${est > 0 && status !== 'done' ? ` · ${fmtHours(est)}` : ''}</span>
</div>
<div class="board-col-body" data-status="${status}">
${items.map(renderCard).join('')}
${moreNote}
</div>
</div>`;
}).join('');
bindBoardDnD();
}
function renderCard(t) {
const dl = formatDeadline(t.deadline);
const flash = flashIds.has(t.id) ? ' flash' : '';
const doneCls = t.status === 'done' ? ' done-card' : '';
const tags = (t.tags || []).slice(0, 4).map(tag => `<span class="tag-chip">#${escapeHtml(tag)}</span>`).join(' ');
return `
<div class="card${flash}${doneCls}" draggable="true" data-id="${t.id}">
${editingTag(t)}
<div class="card-top">
<span class="badge prio-${t.priority}">${t.priority}</span>
${catBadge(t)}
${t.location ? `<span class="tag-chip" style="margin-left:auto">${escapeHtml(t.location)}</span>` : ''}
</div>
<div class="card-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
<div class="card-meta">
${ownerBadge(t)}
${t.deadline ? `<span class="time-cell ${dl.cls}">${dl.text}</span>` : ''}
${t.estHours ? `<span>~${t.estHours}t</span>` : ''}
${t.comments && t.comments.length ? `<span class="comment-count">💬 ${t.comments.length}</span>` : ''}
${tags}
</div>
</div>`;
}
function bindBoardDnD() {
document.querySelectorAll('.card[draggable]').forEach(card => {
card.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', card.dataset.id);
e.dataTransfer.effectAllowed = 'move';
card.classList.add('dragging');
});
card.addEventListener('dragend', () => card.classList.remove('dragging'));
});
document.querySelectorAll('.board-col').forEach(col => {
col.addEventListener('dragover', e => { e.preventDefault(); col.classList.add('drag-over'); });
col.addEventListener('dragleave', () => col.classList.remove('drag-over'));
col.addEventListener('drop', e => {
e.preventDefault();
col.classList.remove('drag-over');
const id = e.dataTransfer.getData('text/plain');
moveTask(id, col.dataset.status);
});
});
}
async function moveTask(id, status) {
const t = findTask(id);
if (!t || t.status === status) return;
const prev = t.status;
t.status = status; // optimistisk — broadcast/svar korrigerer
renderAll();
try {
const { task } = await api('PUT', `/api/tasks/${id}`, { ...t, status, v: t.v });
upsertTask(task);
renderAll();
} catch (e) {
t.status = prev;
renderAll();
handleSaveError(e, id);
}
}
function renderTable() {
const filtered = sortTasks(applyFilters(state.tasks));
const tbody = $('tbl-body');
$('tbl-count').textContent = filtered.length;
const showEmpty = filtered.length === 0;
$('empty').style.display = showEmpty ? 'block' : 'none';
$('empty-seed').style.display = state.tasks.length === 0 ? 'block' : 'none';
if (showEmpty) { tbody.innerHTML = ''; return; }
tbody.innerHTML = filtered.map(t => {
const dl = formatDeadline(t.deadline);
const flash = flashIds.has(t.id) ? ' flash' : '';
const tags = (t.tags || []).map(tag => `<span class="tag-chip">#${escapeHtml(tag)}</span>`).join(' ');
const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user);
return `
<tr class="status-${t.status}${flash}" data-id="${t.id}">
<td><span class="badge prio-${t.priority}">${t.priority}</span></td>
<td>
<div class="task-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}
${t.comments && t.comments.length ? `<span class="comment-count">💬${t.comments.length}</span>` : ''}
${editors.length ? `<span class="editing-tag">✎ ${escapeHtml(editors.join(', '))} redigerer</span>` : ''}
</div>
${t.desc ? `<div class="task-desc">${escapeHtml(t.desc.slice(0, 160))}${t.desc.length > 160 ? '…' : ''}</div>` : ''}
${tags ? `<div style="margin-top:4px">${tags}</div>` : ''}
</td>
<td>${catBadge(t)}</td>
<td><span class="status-badge status-${t.status}">${STATUS_LABELS[t.status] || t.status}</span></td>
<td>${ownerBadge(t)}</td>
<td style="font-family:var(--mono);font-size:12px;color:var(--text-dim)">${escapeHtml(t.location || '—')}</td>
<td style="text-align:right;font-family:var(--mono)">${t.estHours || '—'}</td>
<td class="time-cell ${dl.cls}">${dl.text}</td>
<td>
<div class="row-actions">
${t.status !== 'done'
? `<button class="icon-btn do" onclick="markDone('${t.id}')" title="Marker ferdig">✓</button>`
: `<button class="icon-btn" onclick="reopenTask('${t.id}')" title="Gjenåpne">↺</button>`}
<button class="icon-btn" onclick="openEdit('${t.id}')" title="Rediger">✎</button>
${!t.owner ? `<button class="icon-btn do" onclick="claimTask('${t.id}')" title="Ta eierskap">👤</button>` : ''}
<button class="icon-btn del" onclick="deleteTask('${t.id}')" title="Slett">🗑</button>
</div>
</td>
</tr>`;
}).join('');
}
function renderActivity() {
const body = $('drawer-body');
if (!state.activity.length) {
body.innerHTML = '<div class="act-entry" style="color:var(--text-faint)">Ingen aktivitet ennå.</div>';
return;
}
body.innerHTML = state.activity.slice(0, 150).map(a => {
const meta = ACT_META[a.action] || { icon: '·', verb: a.action };
const titleHtml = a.taskId && findTask(a.taskId)
? `<span class="act-task" onclick="openEdit('${a.taskId}')">${escapeHtml(a.title)}</span>`
: `<span style="color:var(--text-dim)">${escapeHtml(a.title || '')}</span>`;
const detail = (a.detail || []).join(' · ');
return `
<div class="act-entry">
<div class="act-line">
<span class="act-icon">${meta.icon}</span>
<span class="act-text"><span class="act-user">${escapeHtml(a.user)}</span> ${meta.verb} ${titleHtml}</span>
</div>
${detail ? `<div class="act-detail">${escapeHtml(detail)}</div>` : ''}
<div class="act-time">${relTime(a.at)}</div>
</div>`;
}).join('');
}
function updateOwnerSelects() {
const users = new Set(state.knownUsers);
state.tasks.forEach(t => { if (t.owner) users.add(t.owner); });
state.online.forEach(u => users.add(u));
const sorted = [...users].sort();
const sel = $('f-owner');
const current = state.filters.owner;
sel.innerHTML = '<option value="all">Alle</option><option value="__me__">Meg</option><option value="__unassigned__">Utildelt</option>'
+ sorted.map(u => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join('');
const ok = [...sel.options].some(o => o.value === current);
sel.value = ok ? current : 'all';
if (!ok) state.filters.owner = 'all';
const msel = $('f-owner-select');
msel.innerHTML = '<option value="">— velg —</option>'
+ sorted.map(u => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join('');
}
// ---------- Hurtighandlinger (rad/kort) ----------
async function quickStatus(id, status) {
const t = findTask(id);
if (!t) return;
try {
const { task } = await api('PUT', `/api/tasks/${id}`, { ...t, status, v: t.v });
upsertTask(task);
renderAll();
} catch (e) { handleSaveError(e, id); }
}
window.markDone = id => quickStatus(id, 'done');
window.reopenTask = id => quickStatus(id, 'todo');
window.claimTask = async function (id) {
const t = findTask(id);
if (!t) return;
try {
const { task } = await api('PUT', `/api/tasks/${id}`, { ...t, owner: state.user, v: t.v });
upsertTask(task);
renderAll();
toast(`Du eier nå "${task.title.slice(0, 30)}${task.title.length > 30 ? '…' : ''}"`);
} catch (e) { handleSaveError(e, id); }
};
window.deleteTask = async function (id) {
const t = findTask(id);
if (!t) return;
const copy = { ...t };
try {
await api('DELETE', `/api/tasks/${id}`);
state.tasks = state.tasks.filter(x => x.id !== id);
renderAll();
toast(`Slettet "${copy.title.slice(0, 30)}"`, {
ttl: 7000,
action: { label: 'Angre', fn: async () => {
try {
const { task } = await api('POST', '/api/tasks', copy);
upsertTask(task);
renderAll();
toast('Gjenopprettet');
} catch (e) { toast('Kunne ikke gjenopprette: ' + e.message, { kind: 'err' }); }
} },
});
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
// 409-håndtering: ta inn serverens versjon og be brukeren prøve igjen.
function handleSaveError(e, id) {
if (e.status === 409 && e.data && e.data.task) {
upsertTask(e.data.task);
renderAll();
const by = e.data.task.updatedBy || 'noen andre';
if (state.editingId === id) {
state.editingBaseV = e.data.task.v;
updateModalWarning(by, true);
} else {
toast(`Endret av ${by} i mellomtiden — visningen er oppdatert, prøv igjen`, { kind: 'warn', ttl: 6000 });
}
} else {
toast('Feil: ' + e.message, { kind: 'err' });
}
}
// ---------- Modal ----------
window.openEdit = function (id) {
const t = findTask(id);
if (!t) return;
state.editingId = id;
state.editingBaseV = t.v;
$('modal-title').textContent = 'Rediger oppgave';
$('f-title').value = t.title;
$('f-desc').value = t.desc || '';
$('f-cat').value = t.category;
$('f-prio').value = t.priority;
$('f-stat').value = t.status;
$('f-est').value = t.estHours || '';
$('f-owner-input').value = t.owner || '';
$('f-owner-select').value = '';
$('f-loc').value = t.location || '';
$('f-dl').value = t.deadline || '';
$('f-tags').value = (t.tags || []).join(', ');
$('modal-delete').style.display = 'inline-flex';
$('comments-section').style.display = '';
$('modal-audit').textContent = `opprettet ${relTime(t.created)} av ${t.createdBy} · sist endret ${relTime(t.updated)} av ${t.updatedBy}`;
renderComments(t);
updateModalWarning();
$('modal').classList.add('open');
sendWS({ type: 'editing', id, active: true });
};
function openNew(prefill = {}) {
state.editingId = null;
state.editingBaseV = null;
$('modal-title').textContent = 'Ny oppgave';
$('f-title').value = prefill.title || '';
$('f-desc').value = '';
$('f-cat').value = prefill.category || CATEGORIES[0].id;
$('f-prio').value = prefill.priority || 'P2';
$('f-stat').value = 'todo';
$('f-est').value = '';
$('f-owner-input').value = state.user !== 'anon' ? state.user : '';
$('f-owner-select').value = '';
$('f-loc').value = '';
$('f-dl').value = '';
$('f-tags').value = '';
$('modal-delete').style.display = 'none';
$('comments-section').style.display = 'none';
$('modal-audit').textContent = '';
updateModalWarning();
$('modal').classList.add('open');
setTimeout(() => $('f-title').focus(), 50);
}
function closeModal() {
if (state.editingId) sendWS({ type: 'editing', id: state.editingId, active: false });
$('modal').classList.remove('open');
state.editingId = null;
state.editingBaseV = null;
}
// Varsellinje i modal: andre redigerer / noen lagret nettopp / 409.
function updateModalWarning(savedBy = null, conflict = false) {
const el = $('modal-warning');
if (!$('modal').classList.contains('open')) { el.className = 'modal-warning'; return; }
const msgs = [];
if (state.editingId) {
const others = (state.editingMap[state.editingId] || []).filter(u => u !== state.user);
if (others.length) msgs.push(`${others.join(', ')} redigerer også denne oppgaven nå.`);
if (conflict) msgs.push(`${savedBy} lagret en nyere versjon. Sjekk feltene — lagrer du igjen, overskriver du.`);
else if (savedBy) msgs.push(`${savedBy} lagret nettopp endringer på denne oppgaven.`);
}
el.textContent = msgs.join(' ');
el.className = 'modal-warning' + (msgs.length ? ' show' : '');
}
async function saveForm() {
const title = $('f-title').value.trim();
if (!title) { toast('Tittel er påkrevd', { kind: 'warn' }); return; }
const data = {
title,
desc: $('f-desc').value.trim(),
category: $('f-cat').value,
priority: $('f-prio').value,
status: $('f-stat').value,
estHours: parseFloat($('f-est').value) || 0,
owner: $('f-owner-input').value.trim(),
location: $('f-loc').value.trim(),
deadline: $('f-dl').value,
tags: $('f-tags').value.split(',').map(s => s.trim()).filter(Boolean),
};
try {
let task;
if (state.editingId) {
data.v = state.editingBaseV;
({ task } = await api('PUT', `/api/tasks/${state.editingId}`, data));
toast('Lagret');
} else {
({ task } = await api('POST', '/api/tasks', data));
toast('Opprettet');
}
upsertTask(task);
closeModal();
renderAll();
} catch (e) { handleSaveError(e, state.editingId); }
}
async function deleteFromModal() {
if (!state.editingId) return;
const id = state.editingId;
closeModal();
window.deleteTask(id);
}
// ---------- Kommentarer ----------
function renderComments(t) {
const list = $('comments-list');
$('comment-count').textContent = t.comments && t.comments.length ? `(${t.comments.length})` : '';
if (!t.comments || !t.comments.length) {
list.innerHTML = '<div class="no-comments">Ingen kommentarer ennå.</div>';
return;
}
list.innerHTML = t.comments.map(c => `
<div class="comment">
<div class="comment-head">
<span class="comment-by ${c.by === state.user ? 'me' : ''}">${escapeHtml(c.by)}</span>
<span class="comment-at">${relTime(c.at)}</span>
</div>
<div class="comment-text">${escapeHtml(c.text)}</div>
${c.by === state.user ? `<button class="icon-btn del comment-del" onclick="deleteComment('${t.id}','${c.id}')" title="Slett kommentar">×</button>` : ''}
</div>`).join('');
}
async function sendComment() {
if (!state.editingId) return;
const input = $('comment-input');
const text = input.value.trim();
if (!text) return;
try {
const { task } = await api('POST', `/api/tasks/${state.editingId}/comments`, { text });
upsertTask(task);
input.value = '';
renderComments(task);
renderAll();
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
window.deleteComment = async function (taskId, commentId) {
try {
const { task } = await api('DELETE', `/api/tasks/${taskId}/comments/${commentId}`);
upsertTask(task);
renderComments(task);
renderAll();
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
// ---------- Navnevalg (cookie-auth) ----------
async function setName() {
const name = prompt('Brukernavn (a-z, 0-9, . _ @ -):', state.user === 'anon' ? '' : state.user);
if (!name) return;
try {
await api('POST', '/api/login', { name: name.trim() });
location.reload();
} catch (e) { toast(e.message, { kind: 'err' }); }
}
// ---------- Import / eksport / seed ----------
function exportJSON() {
const blob = new Blob([JSON.stringify(state.tasks, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `netops-todo-${todayISO()}.json`;
a.click();
URL.revokeObjectURL(a.href);
}
async function importJSON(file) {
try {
const data = JSON.parse(await file.text());
if (!Array.isArray(data)) throw new Error('Forventet array');
if (!confirm(`Importere ${data.length} oppgaver? Dette erstatter alt på serveren for alle brukere.`)) return;
await api('POST', '/api/replace', data);
toast('Importert');
} catch (e) { toast('Kunne ikke importere: ' + e.message, { kind: 'err' }); }
}
async function seedSamples() {
try {
const { count } = await api('POST', '/api/seed');
toast(`${count} eksempel-oppgaver lastet`);
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
// ---------- Bindings ----------
function bindUI() {
// Visning
$('view-board').addEventListener('click', () => { state.view = 'board'; savePrefs(); renderView(); });
$('view-table').addEventListener('click', () => { state.view = 'table'; savePrefs(); renderView(); });
// Filtre
const f = state.filters;
$('f-status').value = f.status; $('f-category').value = f.category;
$('f-priority').value = f.priority; $('f-search').value = f.search;
$('f-status').addEventListener('change', e => { f.status = e.target.value; savePrefs(); renderView(); });
$('f-category').addEventListener('change', e => { f.category = e.target.value; savePrefs(); renderView(); });
$('f-priority').addEventListener('change', e => { f.priority = e.target.value; savePrefs(); renderView(); });
$('f-owner').addEventListener('change', e => { f.owner = e.target.value; savePrefs(); renderView(); });
$('f-search').addEventListener('input', e => { f.search = e.target.value; savePrefs(); renderView(); });
$('clearFilters').addEventListener('click', () => {
state.filters = { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' };
$('f-status').value = 'open'; $('f-category').value = 'all';
$('f-priority').value = 'all'; $('f-owner').value = 'all'; $('f-search').value = '';
savePrefs(); renderView();
});
// Quick add
$('quickadd-input').addEventListener('input', renderQuickAddPreview);
$('quickadd-input').addEventListener('keydown', e => { if (e.key === 'Enter') submitQuickAdd(); });
$('quickadd-btn').addEventListener('click', submitQuickAdd);
// Modal
$('newTaskBtn').addEventListener('click', () => openNew());
$('modal-close').addEventListener('click', closeModal);
$('modal-cancel').addEventListener('click', closeModal);
$('modal-save').addEventListener('click', saveForm);
$('modal-delete').addEventListener('click', deleteFromModal);
$('modal').addEventListener('click', e => { if (e.target.id === 'modal') closeModal(); });
$('f-owner-select').addEventListener('change', e => {
if (e.target.value) { $('f-owner-input').value = e.target.value; e.target.value = ''; }
});
$('comment-send').addEventListener('click', sendComment);
$('comment-input').addEventListener('keydown', e => { if (e.key === 'Enter') sendComment(); });
// Aktivitets-skuff
$('activityBtn').addEventListener('click', () => $('drawer').classList.toggle('open'));
$('drawer-close').addEventListener('click', () => $('drawer').classList.remove('open'));
// Import / eksport / seed / navn
$('exportBtn').addEventListener('click', exportJSON);
$('importBtn').addEventListener('click', () => $('importFile').click());
$('importFile').addEventListener('change', e => {
if (e.target.files[0]) importJSON(e.target.files[0]);
e.target.value = '';
});
$('seedBtn').addEventListener('click', seedSamples);
$('setNameBtn').addEventListener('click', setName);
$('user-chip').addEventListener('click', () => { if (state.canSetName) setName(); });
// Sortering (tabell)
document.querySelectorAll('thead th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const field = th.dataset.sort;
if (state.sort.field === field) {
state.sort.dir = state.sort.dir === 'asc' ? 'desc' : 'asc';
} else {
state.sort.field = field;
state.sort.dir = field === 'priority' || field === 'deadline' ? 'asc' : 'desc';
}
savePrefs(); renderView();
});
});
// Tastatursnarveier
document.addEventListener('keydown', e => {
const tag = e.target.tagName;
const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
if (e.key === 'Escape') { closeModal(); $('drawer').classList.remove('open'); return; }
if (typing) return;
if (e.key === 'n') { e.preventDefault(); openNew(); }
if (e.key === '/') { e.preventDefault(); $('f-search').focus(); }
if (e.key === 'v') {
state.view = state.view === 'board' ? 'table' : 'board';
savePrefs(); renderView();
}
if (e.key === 'a') $('drawer').classList.toggle('open');
});
// Hent ferskt når fanen blir synlig igjen (mobil/sove-PC)
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !state.wsOk) refreshState();
});
}
function populateCategorySelects() {
const filterSel = $('f-category');
const formSel = $('f-cat');
CATEGORIES.forEach(c => {
filterSel.insertAdjacentHTML('beforeend', `<option value="${c.id}">${c.name}</option>`);
formSel.insertAdjacentHTML('beforeend', `<option value="${c.id}">${c.name}</option>`);
});
}
// ---------- Init ----------
async function init() {
loadPrefs();
populateCategorySelects();
bindUI();
await refreshState();
connectWS();
// Oppdater relative tidsstempler i aktivitetsfeeden jevnlig
setInterval(() => { if ($('drawer').classList.contains('open')) renderActivity(); }, 60000);
}
init();