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>
1081 lines
41 KiB
JavaScript
1081 lines
41 KiB
JavaScript
'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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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();
|