'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 ---------- // Innebygde kategorier (samme som serverens DEFAULT_CATEGORIES). Brukes som // startverdi før /api/state svarer; serveren sender den effektive lista // (innebygde + egendefinerte) som overstyrer denne. const DEFAULT_CATEGORIES = [ { id: 'L1', name: 'L1 Fysisk', color: '#ff9f43', desc: 'Kabling, fiber, patch, SFP, rack' }, { id: 'L2', name: 'L2 Switching', color: '#4fc3f7', desc: 'VLAN, STP, MAC, trunking' }, { id: 'L3', name: 'L3 Ruting', color: '#7fb8ff', desc: 'OSPF, ISIS, statisk, VRF' }, { id: 'L4', name: 'L4 Lastbalansering', color: '#bb86fc', desc: 'HAProxy, NGINX, F5, BIG-IP' }, { id: 'BGP', name: 'BGP', color: '#4ade80', desc: 'iBGP / eBGP, policies, RR' }, { id: 'PEER', name: 'Peering / IX', color: '#26de81', desc: 'IXP, private peering, PeeringDB' }, { id: 'SEC', name: 'Firewall / Security', color: '#ff5370', desc: 'ACL, firewall, IPS, audit' }, { id: 'NMS', name: 'Overvåking / NMS', color: '#ffd93d', desc: 'LibreNMS, Grafana, alerts' }, { id: 'AUTO', name: 'Automatisering', color: '#00d2d3', desc: 'Ansible, scripts, CI/CD' }, { id: 'DOC', name: 'Dokumentasjon', color: '#a0a0a0', desc: 'Diagrammer, IPAM, runbooks' }, { id: 'INC', name: 'Incident / On-call', color: '#ff6b6b', desc: 'Feilsøking, postmortem' }, { id: 'PROJ', name: 'Prosjekt / Plan', color: '#8172B3', desc: 'Design, anskaffelse, oppgraderinger' }, ]; const NEW_CAT = '__new__'; // sentinel-verdi i kategori-nedtrekk for "ny kategori" 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' }, category: { icon: '🏷', verb: 'la til kategori' }, }; // Kategori-oppslag mot gjeldende (dynamiske) liste i state. function cats() { return state.categories.length ? state.categories : DEFAULT_CATEGORIES; } function catById(id) { return cats().find(c => c.id === id); } function catColor(id) { return (catById(id) || {}).color || '#4fc3f7'; } function catName(id) { return (catById(id) || {}).name || id; } // ---------- State ---------- const state = { user: 'anon', canSetName: false, auth: { mode: 'proxy', via: null, hasPassword: false }, tasks: [], activity: [], knownUsers: [], online: [], categories: DEFAULT_CATEGORIES, planIndex: {}, // taskId -> [{user, date, name}] (delte planer + egne) editingMap: {}, // taskId -> [brukere med modal åpen] filters: { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' }, sort: { field: 'priority', dir: 'asc' }, view: 'board', // 'board' | 'table' | 'plan' editingId: null, // id i åpen modal (null = ny oppgave) editingBaseV: null, // versjonen modalen redigerer fra editingStatus: null, // statusen oppgaven hadde da modalen ble åpnet // Dagsplan i visning: date + hvem sin plan (viewUser), navn, items, delt. plan: { date: '', viewUser: '', name: '', items: [], shared: true }, fieldMode: false, // feltmodus (større knapper for mobil) wsOk: false, }; const VIEWS = ['board', 'table', 'plan']; 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() { let p = {}; try { p = JSON.parse(localStorage.getItem('netops.prefs') || '{}') || {}; } catch { /* ignorer korrupt localStorage */ } if (p.filters) Object.assign(state.filters, p.filters); if (p.sort) state.sort = p.sort; if (VIEWS.includes(p.view)) state.view = p.view; if (typeof p.fieldMode === 'boolean') state.fieldMode = p.fieldMode; // Slå på feltmodus automatisk på små skjermer hvis brukeren ikke har valgt selv, // og land på dagsplanen hvis det er første besøk (ingen lagret visning). if (localStorage.getItem('netops.fieldModeSet') !== '1' && window.innerWidth < 640) { state.fieldMode = true; if (!VIEWS.includes(p.view)) state.view = 'plan'; } } function savePrefs() { localStorage.setItem('netops.prefs', JSON.stringify({ filters: state.filters, sort: state.sort, view: state.view, fieldMode: state.fieldMode, })); } // ---------- 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) { // Sesjon utløpt / ikke innlogget i app-modus → til login-siden. if (res.status === 401 && data && data.login) { location.href = '/login'; } 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 'plan': { // Oppdater badge-indeksen for (bruker, dato), og den viste planen hvis vi // ser på akkurat denne. Delte planer mottas av alle; private kun av eier. applyPlanToIndex(msg.user, msg.date, msg.items, msg.name); if (msg.user === state.plan.viewUser && msg.date === state.plan.date) { state.plan.name = msg.name || ''; state.plan.items = msg.items || []; state.plan.shared = msg.shared !== false; } renderAll(); break; } case 'categories': if (Array.isArray(msg.categories)) { state.categories = msg.categories; populateCategorySelects(); renderAll(); } 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); state.plan.items = state.plan.items.filter(id => id !== msg.id); delete state.planIndex[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; if (data.auth) state.auth = data.auth; state.tasks = data.tasks || []; state.activity = data.activity || []; state.knownUsers = data.known_users || []; state.online = data.online || []; if (Array.isArray(data.categories) && data.categories.length) state.categories = data.categories; state.planIndex = data.planIndex || {}; if (data.plan) { state.plan = { date: data.plan.date, viewUser: data.plan.user || data.user, name: data.plan.name || '', items: data.plan.items || [], shared: data.plan.shared !== false, }; } if (!state.plan.date) state.plan = { date: todayISO(), viewUser: data.user, name: '', items: [], shared: true }; populateCategorySelects(); renderAll(); } catch (e) { toast('Kunne ikke laste: ' + e.message, { kind: 'err' }); } } function ownPlan() { return state.plan.viewUser === state.user; } // ---------- 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 = cats().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) => `${label} ${escapeHtml(val)}`; chips.push(chip('tittel', p.title || '(mangler)')); if (p.category) chips.push(chip('kategori', catName(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; const chipClickable = state.canSetName || state.auth.mode === 'app'; $('user-chip').className = 'user-chip' + (chipClickable ? ' clickable' : ''); $('user-chip').title = state.auth.mode === 'app' ? 'Konto: passord / logg ut' : 'Pålogget bruker'; $('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 => `${escapeHtml(u)}`).join('') + (others.length > 6 ? `+${others.length - 6}` : '') : ''; } 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]) => catName(k)), datasets: [{ data: catEntries.map(([, v]) => v), backgroundColor: catEntries.map(([k]) => catColor(k) + 'CC'), borderColor: catEntries.map(([k]) => catColor(k)), 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' : ''; $('view-plan').className = state.view === 'plan' ? 'active-view' : ''; // Statusfilteret gir bare mening i tabellvisningen (tavle/plan grupperer selv). $('fg-status').style.display = state.view === 'table' ? '' : 'none'; $('board').style.display = state.view === 'board' ? '' : 'none'; $('table-panel').style.display = state.view === 'table' ? '' : 'none'; $('planner').style.display = state.view === 'plan' ? '' : 'none'; if (state.view === 'board') renderBoard(); else if (state.view === 'table') renderTable(); else renderPlan(); flashIds.clear(); } // Felles småbiter for kort og rader function ownerBadge(t) { const cls = !t.owner ? 'unassigned' : (t.owner === state.user ? 'me' : ''); return `${t.owner ? escapeHtml(t.owner) : 'utildelt'}`; } function catBadge(t) { const cat = catById(t.category); const col = catColor(t.category); return `${escapeHtml(t.category)}`; } // Plan-badge(r) for en oppgave: hvilke(n) dag(er)/person(er) den er planlagt. function planBadges(t) { const entries = state.planIndex[t.id]; if (!entries || !entries.length) return ''; // Sorter på dato; vis nærmeste, resten som "+N". const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date)); const fmt = e => { const me = e.user === state.user; const d = e.date.slice(8, 10) + '.' + e.date.slice(5, 7); const who = me ? 'meg' : e.user; const title = e.name ? `${e.name} — ${who} ${e.date}` : `Planlagt ${e.date} av ${who}`; return `📅 ${d} ${escapeHtml(who)}`; }; let html = fmt(sorted[0]); if (sorted.length > 1) html += ` +${sorted.length - 1}`; return html; } function editingTag(t) { const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user); return editors.length ? `✎ ${escapeHtml(editors[0])}` : ''; } 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 = `
+ ${items.length - 15} eldre ferdige (se tabell-visning)
`; 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 `

${STATUS_LABELS[status]}

${items.length}${est > 0 && status !== 'done' ? ` · ${fmtHours(est)}` : ''}
${items.map(renderCard).join('')} ${moreNote}
`; }).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 => `#${escapeHtml(tag)}`).join(' '); return `
${editingTag(t)}
${t.priority} ${catBadge(t)} ${t.location ? `${escapeHtml(t.location)}` : ''}
${escapeHtml(t.title)}
${ownerBadge(t)} ${planBadges(t)} ${t.deadline ? `${dl.text}` : ''} ${t.estHours ? `~${t.estHours}t` : ''} ${t.comments && t.comments.length ? `💬 ${t.comments.length}` : ''} ${tags}
`; } 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; // Drag til Ferdig/Blokkert → tilby kommentar (ingen optimistisk flytting, // siden brukeren kan avbryte i dialogen). if (status === 'done' || status === 'blocked') { changeStatusWithComment(id, 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 => `#${escapeHtml(tag)}`).join(' '); const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user); return ` ${t.priority}
${escapeHtml(t.title)} ${t.comments && t.comments.length ? `💬${t.comments.length}` : ''} ${editors.length ? `✎ ${escapeHtml(editors.join(', '))} redigerer` : ''}
${t.desc ? `
${escapeHtml(t.desc.slice(0, 160))}${t.desc.length > 160 ? '…' : ''}
` : ''} ${tags || planBadges(t) ? `
${planBadges(t)} ${tags}
` : ''} ${catBadge(t)} ${STATUS_LABELS[t.status] || t.status} ${ownerBadge(t)} ${escapeHtml(t.location || '—')} ${t.estHours || '—'} ${dl.text}
${t.status !== 'done' ? `` : ``} ${!t.owner ? `` : ''}
`; }).join(''); } // ---------- Dagsplan ---------- // Oppgavene i planen, i lagret rekkefølge, med slettede filtrert bort. function planTasks() { return state.plan.items.map(findTask).filter(Boolean); } // Oppdater badge-indeksen for én (bruker, dato): fjern gamle bidrag, legg til nye. function applyPlanToIndex(user, date, items, name) { for (const id of Object.keys(state.planIndex)) { const kept = state.planIndex[id].filter(e => !(e.user === user && e.date === date)); if (kept.length) state.planIndex[id] = kept; else delete state.planIndex[id]; } for (const id of items || []) { (state.planIndex[id] ||= []).push({ user, date, name: name || '' }); } } function renderPlan() { $('empty').style.display = 'none'; const own = ownPlan(); // Header-felter const dateInput = $('plan-date'); if (document.activeElement !== dateInput) dateInput.value = state.plan.date; const nameInput = $('plan-name'); if (document.activeElement !== nameInput) nameInput.value = state.plan.name || ''; nameInput.readOnly = !own; nameInput.placeholder = own ? 'Navngi planen (valgfritt) …' : '(uten navn)'; $('plan-shared').checked = state.plan.shared !== false; $('plan-shared').disabled = !own; $('plan-shared').closest('.plan-share').style.display = own ? '' : 'none'; $('plan-move').style.display = own ? '' : 'none'; populatePlanUserSelect(); // Lesemodus-banner når man ser en annens plan const ro = $('plan-readonly'); if (!own) { ro.textContent = `Du ser ${state.plan.viewUser} sin dagsplan (skrivebeskyttet).`; ro.classList.add('show'); } else { ro.classList.remove('show'); } // Venstre kolonne (legg til) gir bare mening for egen plan $('plan-available-col').style.display = own ? '' : 'none'; $('planner').style.gridTemplateColumns = own ? '' : '1fr'; const planned = planTasks(); const planIds = new Set(planned.map(t => t.id)); if (own) { // Venstre: åpne oppgaver som ikke alt er i planen (respekterer kat/prio/eier/søk). const avail = sortTasks( applyFilters(state.tasks, { ignoreStatus: true }) .filter(t => t.status !== 'done' && !planIds.has(t.id)) ); $('plan-avail-count').textContent = avail.length ? `(${avail.length})` : ''; $('plan-available-body').innerHTML = avail.length ? avail.map(renderAvailRow).join('') : '
Ingen åpne oppgaver igjen — alt er planlagt eller filtrert bort.
'; } // Høyre: kapasitet + ordnet liste. const remaining = planned.filter(t => t.status !== 'done'); const hours = remaining.reduce((s, t) => s + (t.estHours || 0), 0); const doneCount = planned.length - remaining.length; const cap = $('plan-capacity'); cap.innerHTML = planned.length ? `${planned.length} oppg. · ${fmtHours(hours)} planlagt${doneCount ? ` · ${doneCount} ferdig` : ''}` : ''; cap.className = 'col-meta' + (hours > 8 ? ' over-capacity' : ''); const emptyMsg = own ? 'Tomt. Legg til oppgaver fra venstre med +, eller dra dem hit. Dra for å sortere.' : 'Denne dagsplanen er tom.'; $('plan-today-body').innerHTML = planned.length ? planned.map((t, i) => renderPlanItem(t, i, own)).join('') : `
${emptyMsg}
`; if (own) bindPlanDnD(); } function populatePlanUserSelect() { const sel = $('plan-view-user'); const users = new Set([state.user, ...state.knownUsers, ...state.online]); const sorted = [...users].filter(Boolean).sort(); sel.innerHTML = sorted.map(u => ``).join(''); sel.value = state.plan.viewUser; } function renderAvailRow(t) { const dl = formatDeadline(t.deadline); return `
${escapeHtml(t.title)}
${t.priority} ${catBadge(t)} ${t.estHours ? `~${t.estHours}t` : ''} ${t.deadline ? `${dl.text}` : ''} ${t.owner ? ownerBadge(t) : ''}
`; } function renderPlanItem(t, i, own = true) { const dl = formatDeadline(t.deadline); const doneCls = t.status === 'done' ? ' done-item' : ''; return `
${own ? '' : ''} ${i + 1}
${escapeHtml(t.title)}
${t.location ? `
📍 ${escapeHtml(t.location)}
` : ''}
${t.priority} ${catBadge(t)} ${STATUS_LABELS[t.status] || t.status} ${t.estHours ? `~${t.estHours}t` : ''} ${t.deadline ? `${dl.text}` : ''}
${own ? `
${t.status !== 'done' ? `` : ``}
` : ''}
`; } function getDragAfterElement(container, y) { const els = [...container.querySelectorAll('.plan-item:not(.dragging)')]; let closest = { offset: -Infinity, el: null }; for (const child of els) { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) closest = { offset, el: child }; } return closest.el; } // Per-element drag-bindinger. Containeren beholder sine lyttere (satt én gang // i bindUI), så her bindes bare de nygenererte radene/kortene. function bindPlanDnD() { const container = $('plan-today-body'); container.querySelectorAll('.plan-item').forEach(item => { item.addEventListener('dragstart', e => { item.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', item.dataset.id); }); item.addEventListener('dragend', () => { item.classList.remove('dragging'); const ids = [...container.querySelectorAll('.plan-item')].map(el => el.dataset.id); if (ids.join() !== state.plan.items.join()) { state.plan.items = ids; savePlan(); } }); }); $('plan-available-body').querySelectorAll('.avail-row').forEach(row => { row.addEventListener('dragstart', e => { row.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', row.dataset.id); }); row.addEventListener('dragend', () => row.classList.remove('dragging')); }); } // Container-nivå drag-lyttere (settes én gang). function bindPlanContainer() { const container = $('plan-today-body'); container.addEventListener('dragover', e => { e.preventDefault(); const dragging = container.querySelector('.plan-item.dragging'); if (!dragging) return; // drar inn et tilgjengelig-kort: håndteres i drop const after = getDragAfterElement(container, e.clientY); if (after == null) container.appendChild(dragging); else container.insertBefore(dragging, after); }); container.addEventListener('drop', e => { e.preventDefault(); const id = e.dataTransfer.getData('text/plain'); if (id && !state.plan.items.includes(id) && findTask(id)) addToPlan(id); }); } async function savePlan() { if (!ownPlan()) return; // kan ikke endre andres plan renderPlan(); // optimistisk try { const { name, items, shared } = await api('PUT', '/api/plan', { date: state.plan.date, items: state.plan.items, name: state.plan.name, shared: state.plan.shared, }); state.plan.name = name; state.plan.items = items; state.plan.shared = shared; renderPlan(); } catch (e) { toast('Kunne ikke lagre dagsplan: ' + e.message, { kind: 'err' }); refreshPlan(); } } window.addToPlan = function (id) { if (!ownPlan()) return; if (!state.plan.items.includes(id) && findTask(id)) { state.plan.items.push(id); savePlan(); toast('Lagt i dagsplan'); } }; window.removeFromPlan = function (id) { if (!ownPlan()) return; state.plan.items = state.plan.items.filter(x => x !== id); savePlan(); }; // Lagre planens navn (kalles på blur/enter) async function savePlanName() { if (!ownPlan()) return; const name = $('plan-name').value.trim(); if (name === (state.plan.name || '')) return; state.plan.name = name; savePlan(); } // Slå deling av/på for egen plan på valgt dato async function togglePlanShared() { if (!ownPlan()) return; const shared = $('plan-shared').checked; state.plan.shared = shared; try { await api('PUT', '/api/plan/share', { date: state.plan.date, shared }); toast(shared ? 'Planen er nå synlig for alle' : 'Planen er nå privat'); } catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); refreshPlan(); } } // Flytt hele planen til en annen dato async function movePlanToDate() { if (!ownPlan()) return; const to = $('plan-move-date').value; if (!to) { toast('Velg en måldato først', { kind: 'warn' }); return; } if (to === state.plan.date) { toast('Planen er allerede på denne datoen', { kind: 'warn' }); return; } const from = state.plan.date; try { const data = await api('PUT', '/api/plan/move', { from, to }); state.plan = { date: data.date, viewUser: state.user, name: data.name, items: data.items, shared: data.shared !== false }; $('plan-move-date').value = ''; renderPlan(); toast(`Dagsplanen er flyttet til ${to}`); } catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); } } async function refreshPlan(date = state.plan.date, user = state.plan.viewUser) { try { const data = await api('GET', `/api/plan?date=${encodeURIComponent(date)}&user=${encodeURIComponent(user || state.user)}`); state.plan = { date: data.date, viewUser: data.user || user || state.user, name: data.name || '', items: data.items || [], shared: data.shared !== false, }; if (state.view === 'plan') renderPlan(); } catch (e) { if (e.status === 403) { toast(`${user} sin plan denne dagen er privat`, { kind: 'warn' }); state.plan.viewUser = state.user; refreshPlan(date, state.user); } } } function renderActivity() { const body = $('drawer-body'); if (!state.activity.length) { body.innerHTML = '
Ingen aktivitet ennå.
'; 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) ? `${escapeHtml(a.title)}` : `${escapeHtml(a.title || '')}`; const detail = (a.detail || []).join(' · '); return `
${meta.icon} ${escapeHtml(a.user)} ${meta.verb} ${titleHtml}
${detail ? `
${escapeHtml(detail)}
` : ''}
${relTime(a.at)}
`; }).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 = '' + sorted.map(u => ``).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 = '' + sorted.map(u => ``).join(''); } // ---------- Statuskommentar-dialog ---------- // Lover en streng (kommentar, evt. tom = hopp over) eller null (avbrutt). let statusResolve = null; function askStatusComment(task, status) { return new Promise(resolve => { statusResolve = resolve; const label = status === 'done' ? 'Avslutningskommentar (valgfritt)' : status === 'blocked' ? 'Hva blokkerer? (valgfritt)' : 'Kommentar (valgfritt)'; $('status-modal-title').textContent = `${STATUS_LABELS[status]}: ${task.title.slice(0, 44)}${task.title.length > 44 ? '…' : ''}`; $('status-modal-label').textContent = label; $('status-comment').value = ''; $('status-modal').classList.add('open'); setTimeout(() => $('status-comment').focus(), 50); }); } function resolveStatus(val) { if (!statusResolve) return; $('status-modal').classList.remove('open'); const r = statusResolve; statusResolve = null; r(val); } // Statusbytte som tilbyr en kommentar ved fullføring/blokkering. async function changeStatusWithComment(id, status) { const t = findTask(id); if (!t || t.status === status) return; let comment = ''; if (status === 'done' || status === 'blocked') { const res = await askStatusComment(t, status); if (res === null) return; // avbrutt — ingen endring comment = res; } await quickStatus(id, status, comment); } // ---------- Hurtighandlinger (rad/kort) ---------- async function quickStatus(id, status, comment = '') { const t = findTask(id); if (!t) return; try { const body = { ...t, status, v: t.v }; if (comment) body.comment = comment; const { task } = await api('PUT', `/api/tasks/${id}`, body); upsertTask(task); renderAll(); if (comment) toast('Lagret med kommentar'); } catch (e) { handleSaveError(e, id); } } window.markDone = id => changeStatusWithComment(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; state.editingStatus = t.status; $('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}`; updateStatusNoteField(); renderComments(t); updateModalWarning(); $('modal').classList.add('open'); sendWS({ type: 'editing', id, active: true }); }; // Viser et kommentarfelt i modalen når status endres til Ferdig/Blokkert. function updateStatusNoteField() { const field = $('f-statusnote-field'); if (!state.editingId) { field.style.display = 'none'; $('f-statusnote').value = ''; return; } const val = $('f-stat').value; if (val !== state.editingStatus && (val === 'done' || val === 'blocked')) { $('f-statusnote-label').textContent = val === 'done' ? 'Avslutningskommentar (valgfritt)' : 'Hva blokkerer? (valgfritt)'; field.style.display = ''; } else { field.style.display = 'none'; $('f-statusnote').value = ''; } } function openNew(prefill = {}) { state.editingId = null; state.editingBaseV = null; state.editingStatus = null; $('f-statusnote-field').style.display = 'none'; $('f-statusnote').value = ''; $('modal-title').textContent = 'Ny oppgave'; $('f-title').value = prefill.title || ''; $('f-desc').value = ''; $('f-cat').value = prefill.category || cats()[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; // Statuskommentar (kun synlig når status endres til ferdig/blokkert) const note = $('f-statusnote-field').style.display !== 'none' ? $('f-statusnote').value.trim() : ''; if (note) data.comment = note; ({ 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 = '
Ingen kommentarer ennå.
'; return; } list.innerHTML = t.comments.map(c => { const isStatus = c.kind === 'status' && c.status; const kindTag = isStatus ? `${STATUS_LABELS[c.status] || c.status}` : ''; return `
${escapeHtml(c.by)} ${kindTag} ${relTime(c.at)}
${escapeHtml(c.text)}
${c.by === state.user ? `` : ''}
`; }).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' }); } }; // ---------- Feltmodus ---------- function applyFieldMode() { document.body.classList.toggle('field-mode', state.fieldMode); $('fieldBtn').className = state.fieldMode ? 'active-view' : ''; } function toggleFieldMode() { state.fieldMode = !state.fieldMode; localStorage.setItem('netops.fieldModeSet', '1'); // brukeren har valgt selv // I felt vil man ha dagsplanen først; hopp dit når feltmodus slås på. if (state.fieldMode) state.view = 'plan'; savePrefs(); applyFieldMode(); renderView(); toast(state.fieldMode ? 'Feltmodus på — viser dagsplan' : 'Feltmodus av'); } // ---------- 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' }); } } // ---------- Konto (app-innlogging) ---------- function openAccount() { const viaProxy = state.auth.via === 'proxy'; const has = state.auth.hasPassword; const status = $('account-status'); if (viaProxy && !has) { status.textContent = `Du er innlogget via proxy (${state.user}). Sett et app-passord her, så kan du logge inn uten proxy og skru av Basic Auth.`; } else if (viaProxy && has) { status.textContent = `Innlogget via proxy som ${state.user}. Du har allerede et app-passord — du kan endre det her.`; } else { status.textContent = `Innlogget som ${state.user} (app-sesjon).`; } status.className = 'modal-warning show'; // Nåværende passord kreves kun når du endrer via en app-sesjon $('account-current-field').style.display = (has && state.auth.via === 'session') ? '' : 'none'; $('account-pw-label').textContent = has ? 'Nytt passord' : 'Velg passord'; $('account-current').value = ''; $('account-newpw').value = ''; $('account-newpw2').value = ''; $('account-modal').classList.add('open'); setTimeout(() => $('account-newpw').focus(), 50); } function closeAccount() { $('account-modal').classList.remove('open'); } async function saveAccountPassword() { const pw = $('account-newpw').value; const pw2 = $('account-newpw2').value; if (pw.length < 6) { toast('Passord må være minst 6 tegn', { kind: 'warn' }); return; } if (pw !== pw2) { toast('Passordene er ikke like', { kind: 'warn' }); return; } const body = { password: pw }; if ($('account-current-field').style.display !== 'none') body.current = $('account-current').value; try { await api('POST', '/api/auth/password', body); state.auth.hasPassword = true; state.auth.via = 'session'; closeAccount(); renderHeader(); toast('Passord lagret — du er nå innlogget med app-sesjon'); } catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); } } async function logout() { try { await api('POST', '/api/auth/logout'); } catch { /* uansett til login */ } location.href = '/login'; } // ---------- 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(); }); $('view-plan').addEventListener('click', () => { state.view = 'plan'; savePrefs(); renderView(); }); $('plan-date').addEventListener('change', e => refreshPlan(e.target.value || todayISO(), state.plan.viewUser)); $('plan-view-user').addEventListener('change', e => refreshPlan(state.plan.date, e.target.value)); $('plan-name').addEventListener('change', savePlanName); $('plan-name').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); } }); $('plan-shared').addEventListener('change', togglePlanShared); $('plan-move-btn').addEventListener('click', movePlanToDate); $('fieldBtn').addEventListener('click', toggleFieldMode); bindPlanContainer(); // 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 = ''; } }); $('f-cat').addEventListener('change', e => { if (e.target.value === NEW_CAT) addCategoryFlow(); }); $('comment-send').addEventListener('click', sendComment); $('comment-input').addEventListener('keydown', e => { if (e.key === 'Enter') sendComment(); }); $('f-stat').addEventListener('change', updateStatusNoteField); // Statuskommentar-dialog $('status-save').addEventListener('click', () => resolveStatus($('status-comment').value.trim())); $('status-skip').addEventListener('click', () => resolveStatus('')); $('status-modal-close').addEventListener('click', () => resolveStatus(null)); $('status-modal').addEventListener('click', e => { if (e.target.id === 'status-modal') resolveStatus(null); }); $('status-comment').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); resolveStatus($('status-comment').value.trim()); } }); // 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.auth.mode === 'app') openAccount(); else if (state.canSetName) setName(); }); // Konto-modal $('account-close').addEventListener('click', closeAccount); $('account-cancel').addEventListener('click', closeAccount); $('account-save').addEventListener('click', saveAccountPassword); $('account-logout').addEventListener('click', logout); $('account-modal').addEventListener('click', e => { if (e.target.id === 'account-modal') closeAccount(); }); $('account-newpw2').addEventListener('keydown', e => { if (e.key === 'Enter') saveAccountPassword(); }); // 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') { if ($('status-modal').classList.contains('open')) { resolveStatus(null); return; } if ($('account-modal').classList.contains('open')) { closeAccount(); return; } 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 = VIEWS[(VIEWS.indexOf(state.view) + 1) % VIEWS.length]; savePrefs(); renderView(); } if (e.key === 'p') { state.view = 'plan'; savePrefs(); renderView(); } if (e.key === 'f') toggleFieldMode(); 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'); const opts = cats().map(c => ``).join(''); // Filter: behold valgt verdi filterSel.innerHTML = '' + opts; filterSel.value = [...filterSel.options].some(o => o.value === state.filters.category) ? state.filters.category : 'all'; // Modal: behold valgt verdi, og tilby "ny kategori" const prev = formSel.value; formSel.innerHTML = opts + ``; if ([...formSel.options].some(o => o.value === prev)) formSel.value = prev; } // Oppretter en ny kategori via prompt og velger den i modalen. async function addCategoryFlow() { const editing = state.editingId && findTask(state.editingId); const fallback = editing ? editing.category : cats()[0].id; const name = prompt('Navn på ny kategori (f.eks. «Trådløst», «Telefoni»):', ''); if (!name || !name.trim()) { $('f-cat').value = fallback; return; } try { const { category } = await api('POST', '/api/categories', { name: name.trim() }); // state.categories oppdateres også via WS-broadcast, men sett lokalt nå for respons if (!state.categories.some(c => c.id === category.id)) state.categories = [...state.categories, category]; populateCategorySelects(); $('f-cat').value = category.id; toast(`Kategori «${category.name}» (${category.id}) opprettet`); } catch (e) { $('f-cat').value = fallback; toast('Feil: ' + e.message, { kind: 'err' }); } } // ---------- Init ---------- async function init() { loadPrefs(); applyFieldMode(); populateCategorySelects(); bindUI(); await refreshState(); connectWS(); // Oppdater relative tidsstempler i aktivitetsfeeden jevnlig setInterval(() => { if ($('drawer').classList.contains('open')) renderActivity(); }, 60000); } init();