Dagsplan: flytt/navn/deling + plan-badge, kategorier, feltmodus

Dagsplan kan navngis og flyttes til annen dato (slår sammen uten datatap).
Delbar som hovedregel (shared-flagg default true) med toggle for privat;
andres delte planer kan ses skrivebeskyttet via "Plan for"-velger.
getPlanFor håndhever tilgang. Per-oppgave plan-badge (dag + person) via
planIndex filtrert per viewer.

Egendefinerte kategorier (categories.json) i tillegg til de 12 innebygde;
dynamisk validering, deles via WS, addes fra kategori-nedtrekket.

Feltmodus (body.field-mode): større berøringsmål for mobil, auto på <640px.

plans.json migreres array -> {name,items,shared} ved oppstart (bakoverkompat).
26 integrasjonstester (6 nye), alle grønne. README + CLAUDE.md oppdatert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-15 13:59:19 +02:00
parent be166034de
commit bdeb6254b9
8 changed files with 724 additions and 147 deletions

View file

@ -6,38 +6,43 @@
*/
// ---------- 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' },
// 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 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 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' },
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 = {
@ -47,6 +52,8 @@ const state = {
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' },
@ -54,7 +61,9 @@ const state = {
editingId: null, // id i åpen modal (null = ny oppgave)
editingBaseV: null, // versjonen modalen redigerer fra
editingStatus: null, // statusen oppgaven hadde da modalen ble åpnet
plan: { date: '', items: [] }, // dagsplan for valgt dato (ordnet liste av task-ids)
// 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'];
@ -73,11 +82,16 @@ function loadPrefs() {
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;
} catch { /* ignorer korrupt localStorage */ }
// Slå på feltmodus automatisk på små skjermer hvis brukeren ikke har valgt selv.
if (localStorage.getItem('netops.fieldModeSet') !== '1' && window.innerWidth < 640) {
state.fieldMode = true;
}
}
function savePrefs() {
localStorage.setItem('netops.prefs', JSON.stringify({
filters: state.filters, sort: state.sort, view: state.view,
filters: state.filters, sort: state.sort, view: state.view, fieldMode: state.fieldMode,
}));
}
@ -232,12 +246,24 @@ function handleWSMessage(msg) {
updateModalWarning();
break;
case 'plan':
// Dagsplanen synkes mellom samme brukers faner. Bruk bare hvis den
// gjelder datoen vi ser på nå.
if (msg.date === state.plan.date) {
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 || [];
if (state.view === 'plan') renderPlan();
state.plan.shared = msg.shared !== false;
}
renderAll();
break;
}
case 'categories':
if (Array.isArray(msg.categories)) {
state.categories = msg.categories;
populateCategorySelects();
renderAll();
}
break;
@ -247,6 +273,7 @@ function handleWSMessage(msg) {
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 });
@ -276,13 +303,23 @@ async function refreshState() {
state.activity = data.activity || [];
state.knownUsers = data.known_users || [];
state.online = data.online || [];
if (data.plan) state.plan = data.plan;
if (!state.plan.date) state.plan = { date: todayISO(), items: [] };
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) {
@ -293,7 +330,7 @@ function parseQuickAdd(text) {
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());
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;
@ -326,7 +363,7 @@ function renderQuickAddPreview() {
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.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));
@ -468,11 +505,11 @@ function renderCharts() {
charts.cat = new Chart($('catChart'), {
type: 'bar',
data: {
labels: catEntries.map(([k]) => (CATEGORIES.find(c => c.id === k) || { name: k }).name),
labels: catEntries.map(([k]) => catName(k)),
datasets: [{
data: catEntries.map(([, v]) => v),
backgroundColor: catEntries.map(([k]) => (CAT_COLORS[k] || '#666') + 'CC'),
borderColor: catEntries.map(([k]) => CAT_COLORS[k] || '#666'),
backgroundColor: catEntries.map(([k]) => catColor(k) + 'CC'),
borderColor: catEntries.map(([k]) => catColor(k)),
borderWidth: 1, borderRadius: 4,
}]
},
@ -538,9 +575,27 @@ function ownerBadge(t) {
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>`;
const cat = catById(t.category);
const col = catColor(t.category);
return `<span class="cat-badge" style="color:${col};border-color:${col}40;background:${col}15" title="${cat ? escapeHtml(cat.desc || cat.name) : ''}">${escapeHtml(t.category)}</span>`;
}
// 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 `<span class="plan-badge ${me ? 'me' : ''}" title="${escapeHtml(title)}">📅 ${d} ${escapeHtml(who)}</span>`;
};
let html = fmt(sorted[0]);
if (sorted.length > 1) html += ` <span class="plan-badge more" title="Planlagt i ${sorted.length} planer">+${sorted.length - 1}</span>`;
return html;
}
function editingTag(t) {
const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user);
@ -601,6 +656,7 @@ function renderCard(t) {
<div class="card-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
<div class="card-meta">
${ownerBadge(t)}
${planBadges(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>` : ''}
@ -677,7 +733,7 @@ function renderTable() {
${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>` : ''}
${tags || planBadges(t) ? `<div style="margin-top:4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center">${planBadges(t)} ${tags}</div>` : ''}
</td>
<td>${catBadge(t)}</td>
<td><span class="status-badge status-${t.status}">${STATUS_LABELS[t.status] || t.status}</span></td>
@ -705,24 +761,61 @@ 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));
// 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('')
: '<div class="plan-empty">Ingen åpne oppgaver igjen — alt er planlagt eller filtrert bort.</div>';
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('')
: '<div class="plan-empty">Ingen åpne oppgaver igjen — alt er planlagt eller filtrert bort.</div>';
}
// Høyre: kapasitet + ordnet liste.
const remaining = planned.filter(t => t.status !== 'done');
@ -734,11 +827,23 @@ function renderPlan() {
: '';
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(renderPlanItem).join('')
: '<div class="plan-empty">Tomt. Legg til oppgaver fra venstre med +, eller dra dem hit. Dra for å sortere.</div>';
? planned.map((t, i) => renderPlanItem(t, i, own)).join('')
: `<div class="plan-empty">${emptyMsg}</div>`;
bindPlanDnD();
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 =>
`<option value="${escapeHtml(u)}">${u === state.user ? 'Meg' : escapeHtml(u)}</option>`).join('');
sel.value = state.plan.viewUser;
}
function renderAvailRow(t) {
@ -759,12 +864,12 @@ function renderAvailRow(t) {
</div>`;
}
function renderPlanItem(t, i) {
function renderPlanItem(t, i, own = true) {
const dl = formatDeadline(t.deadline);
const doneCls = t.status === 'done' ? ' done-item' : '';
return `
<div class="plan-item${doneCls}" draggable="true" data-id="${t.id}">
<span class="plan-handle" title="Dra for å sortere"></span>
<div class="plan-item${doneCls}"${own ? ' draggable="true"' : ''} data-id="${t.id}">
${own ? '<span class="plan-handle" title="Dra for å sortere">⠿</span>' : ''}
<span class="plan-num">${i + 1}</span>
<div class="plan-item-main">
<div class="plan-item-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
@ -776,12 +881,12 @@ function renderPlanItem(t, i) {
${t.deadline ? `<span class="time-cell ${dl.cls}">${dl.text}</span>` : ''}
</div>
</div>
<div class="plan-item-actions">
${own ? `<div class="plan-item-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 del" onclick="removeFromPlan('${t.id}')" title="Fjern fra dagsplan">×</button>
</div>
</div>` : ''}
</div>`;
}
@ -845,10 +950,14 @@ function bindPlanContainer() {
}
async function savePlan() {
if (!ownPlan()) return; // kan ikke endre andres plan
renderPlan(); // optimistisk
try {
const { date, items } = await api('PUT', '/api/plan', { date: state.plan.date, items: state.plan.items });
if (date === state.plan.date) { state.plan.items = items; renderPlan(); }
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();
@ -856,6 +965,7 @@ async function savePlan() {
}
window.addToPlan = function (id) {
if (!ownPlan()) return;
if (!state.plan.items.includes(id) && findTask(id)) {
state.plan.items.push(id);
savePlan();
@ -864,16 +974,62 @@ window.addToPlan = function (id) {
};
window.removeFromPlan = function (id) {
if (!ownPlan()) return;
state.plan.items = state.plan.items.filter(x => x !== id);
savePlan();
};
async function refreshPlan(date = state.plan.date) {
// 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 {
const data = await api('GET', `/api/plan?date=${encodeURIComponent(date)}`);
state.plan = { date: data.date, items: data.items || [] };
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 { /* stille — neste handling prøver igjen */ }
} 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() {
@ -1074,7 +1230,7 @@ function openNew(prefill = {}) {
$('modal-title').textContent = 'Ny oppgave';
$('f-title').value = prefill.title || '';
$('f-desc').value = '';
$('f-cat').value = prefill.category || CATEGORIES[0].id;
$('f-cat').value = prefill.category || cats()[0].id;
$('f-prio').value = prefill.priority || 'P2';
$('f-stat').value = 'todo';
$('f-est').value = '';
@ -1203,6 +1359,19 @@ window.deleteComment = async function (taskId, commentId) {
} 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
savePrefs();
applyFieldMode();
toast(state.fieldMode ? 'Feltmodus på' : 'Feltmodus av');
}
// ---------- Navnevalg (cookie-auth) ----------
async function setName() {
const name = prompt('Brukernavn (a-z, 0-9, . _ @ -):', state.user === 'anon' ? '' : state.user);
@ -1246,10 +1415,13 @@ function bindUI() {
$('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 => {
const d = e.target.value || todayISO();
refreshPlan(d);
});
$('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
@ -1283,6 +1455,7 @@ function bindUI() {
$('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);
@ -1341,6 +1514,7 @@ function bindUI() {
savePrefs(); renderView();
}
if (e.key === 'p') { state.view = 'plan'; savePrefs(); renderView(); }
if (e.key === 'f') toggleFieldMode();
if (e.key === 'a') $('drawer').classList.toggle('open');
});
@ -1353,15 +1527,39 @@ function bindUI() {
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>`);
});
const opts = cats().map(c => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>`).join('');
// Filter: behold valgt verdi
filterSel.innerHTML = '<option value="all">Alle</option>' + 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 + `<option value="${NEW_CAT}"> Ny kategori …</option>`;
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();