netops/netops-todo-server/index.php
2026-06-12 11:17:07 +02:00

1024 lines
43 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<?php
/**
* NetOps To-Do — entry point.
* Leverer HTML + JS. All datalagring skjer via api.php.
*/
$currentUser = $_SERVER['REMOTE_USER']
?? $_SERVER['PHP_AUTH_USER']
?? $_SERVER['HTTP_X_REMOTE_USER']
?? 'anon';
$currentUser = preg_replace('/[^A-Za-z0-9._@\-]/', '', $currentUser);
if ($currentUser === '') $currentUser = 'anon';
?><!DOCTYPE html>
<html lang="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NetOps To-Do · Network Engineer</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1"></script>
<style>
:root {
--bg: #0f1419;
--bg-soft: #151b23;
--card: #1a212b;
--card-hover: #202836;
--border: #2a3340;
--border-strong: #3a4555;
--text: #e6edf3;
--text-dim: #8b98a9;
--text-faint: #5a6878;
--accent: #4fc3f7;
--accent-soft: rgba(79, 195, 247, 0.12);
--p1: #ff5370;
--p2: #ff9f43;
--p3: #ffd93d;
--p4: #7fb8ff;
--ok: #4ade80;
--warn: #ff9f43;
--err: #ff5370;
--radius: 8px;
--gap: 14px;
--mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px; line-height: 1.5; min-height: 100vh;
}
.container { max-width: 1500px; margin: 0 auto; padding: 20px; }
header {
display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 12px; margin-bottom: 20px;
padding-bottom: 16px; border-bottom: 1px solid var(--border);
}
header h1 { font-size: 22px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
header h1::before {
content: ""; width: 10px; height: 10px; border-radius: 50%;
background: var(--ok); box-shadow: 0 0 12px var(--ok); animation: pulse 2s infinite;
}
@keyframes pulse { 0%,100% {opacity:1;} 50% {opacity:0.5;} }
header .sub { color: var(--text-dim); font-size: 13px; font-family: var(--mono); }
.user-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; background: var(--accent-soft); color: var(--accent);
border: 1px solid rgba(79,195,247,0.25); border-radius: 12px;
font-family: var(--mono); font-size: 12px;
}
.user-chip::before { content: "●"; }
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
button, .btn {
background: var(--card); color: var(--text); border: 1px solid var(--border);
padding: 8px 14px; border-radius: 6px; font-size: 13px; font-weight: 500;
cursor: pointer; transition: all 0.15s;
display: inline-flex; align-items: center; gap: 6px;
}
button:hover, .btn:hover { background: var(--card-hover); border-color: var(--border-strong); }
button.primary { background: var(--accent); color: #06151f; border-color: var(--accent); font-weight: 600; }
button.primary:hover { background: #7dd3fc; border-color: #7dd3fc; }
button.danger { background: transparent; color: var(--err); border-color: rgba(255,83,112,0.3); }
button.danger:hover { background: rgba(255,83,112,0.1); border-color: var(--err); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.kpi-row {
display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: var(--gap); margin-bottom: var(--gap);
}
.kpi { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px 18px; position: relative; overflow: hidden; }
.kpi::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); }
.kpi.kpi-p1::before { background: var(--p1); }
.kpi.kpi-warn::before { background: var(--warn); }
.kpi.kpi-ok::before { background: var(--ok); }
.kpi.kpi-me::before { background: #bb86fc; }
.kpi-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-dim); margin-bottom: 6px; font-weight: 600; }
.kpi-value { font-size: 26px; font-weight: 700; font-family: var(--mono); line-height: 1.1; }
.kpi-sub { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
.chart-row { display: grid; grid-template-columns: 1.2fr 1fr 1fr; gap: var(--gap); margin-bottom: var(--gap); }
.panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
.panel h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-dim);
margin-bottom: 14px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
.chart-wrap { position: relative; height: 220px; }
.filters {
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
margin-bottom: var(--gap); padding: 12px 16px;
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
}
.filter-group { display: flex; align-items: center; gap: 6px; }
.filter-group label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; }
select, input[type="text"], input[type="number"], input[type="date"], textarea {
background: var(--bg-soft); color: var(--text); border: 1px solid var(--border);
padding: 7px 10px; border-radius: 5px; font-size: 13px; font-family: inherit;
}
select:focus, input:focus, textarea:focus { outline: none; border-color: var(--accent); }
.search-input { flex: 1; min-width: 200px; }
.table-panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.table-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--border); }
.table-head h3 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.table-head .count { color: var(--text-dim); font-family: var(--mono); font-size: 12px; margin-left: 8px; }
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
text-align: left; padding: 10px 14px; background: var(--bg-soft); color: var(--text-dim);
font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px;
cursor: pointer; user-select: none; white-space: nowrap; border-bottom: 1px solid var(--border);
}
thead th:hover { color: var(--text); }
tbody td { padding: 11px 14px; border-bottom: 1px solid var(--border); vertical-align: top; }
tbody tr { transition: background 0.1s; }
tbody tr:hover { background: var(--card-hover); }
tbody tr.status-done td { color: var(--text-faint); }
tbody tr.status-done .task-title { text-decoration: line-through; }
.task-title { font-weight: 500; color: var(--text); cursor: pointer; }
.task-title:hover { color: var(--accent); }
.task-desc { color: var(--text-dim); font-size: 12px; margin-top: 2px; max-width: 400px; }
.badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 4px;
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; }
.prio-P1 { background: rgba(255,83,112,0.15); color: var(--p1); border: 1px solid rgba(255,83,112,0.3); }
.prio-P2 { background: rgba(255,159,67,0.15); color: var(--p2); border: 1px solid rgba(255,159,67,0.3); }
.prio-P3 { background: rgba(255,217,61,0.15); color: var(--p3); border: 1px solid rgba(255,217,61,0.3); }
.prio-P4 { background: rgba(127,184,255,0.15); color: var(--p4); border: 1px solid rgba(127,184,255,0.3); }
.status-badge { padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; }
.status-todo { background: rgba(127,184,255,0.12); color: #7fb8ff; }
.status-progress { background: rgba(255,159,67,0.15); color: var(--warn); }
.status-blocked { background: rgba(255,83,112,0.15); color: var(--err); }
.status-done { background: rgba(74,222,128,0.12); color: var(--ok); }
.cat-badge { font-family: var(--mono); font-size: 11px; padding: 2px 7px; border-radius: 4px;
background: var(--accent-soft); color: var(--accent); border: 1px solid rgba(79,195,247,0.25); }
.owner-badge { display:inline-flex; align-items:center; gap:5px; font-family: var(--mono); font-size: 12px;
padding: 2px 7px; background: rgba(187,134,252,0.1); color: #d4b0ff;
border: 1px solid rgba(187,134,252,0.25); border-radius: 4px; white-space: nowrap; }
.owner-badge.me { background: rgba(74,222,128,0.1); color: var(--ok); border-color: rgba(74,222,128,0.3); }
.owner-badge.unassigned { background: rgba(139,152,169,0.1); color: var(--text-dim); border-color: var(--border); }
.time-cell { font-family: var(--mono); font-size: 12px; color: var(--text-dim); white-space: nowrap; }
.time-cell.overdue { color: var(--err); font-weight: 600; }
.time-cell.soon { color: var(--warn); }
.row-actions { display: flex; gap: 4px; justify-content: flex-end; }
.icon-btn { background: none; border: none; color: var(--text-dim); padding: 4px 6px;
font-size: 13px; cursor: pointer; border-radius: 3px; }
.icon-btn:hover { background: var(--bg-soft); color: var(--text); }
.icon-btn.do:hover { color: var(--ok); }
.icon-btn.del:hover { color: var(--err); }
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(3px);
display: none; align-items: center; justify-content: center; z-index: 100; padding: 20px;
}
.modal-overlay.open { display: flex; }
.modal { background: var(--card); border: 1px solid var(--border-strong); border-radius: var(--radius);
width: 100%; max-width: 620px; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.modal-head { padding: 18px 22px; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center; }
.modal-head h2 { font-size: 16px; font-weight: 600; }
.modal-body { padding: 20px 22px; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.form-field { display: flex; flex-direction: column; gap: 4px; }
.form-field.full { grid-column: 1 / -1; }
.form-field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; }
.form-field textarea { resize: vertical; min-height: 72px; font-family: inherit; }
.form-field .owner-input-row { display:flex; gap:6px; }
.form-field .owner-input-row input { flex:1; }
.form-field .owner-input-row select { flex:1; }
.modal-foot { padding: 14px 22px; border-top: 1px solid var(--border);
display: flex; justify-content: space-between; gap: 8px; }
.empty-state { padding: 40px; text-align: center; color: var(--text-dim); }
.empty-state button { margin-top: 12px; }
.toast {
position: fixed; bottom: 20px; right: 20px; padding: 12px 18px;
background: var(--card); border: 1px solid var(--border-strong); border-radius: 6px;
color: var(--text); font-size: 13px; z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
transform: translateX(120%); transition: transform 0.25s;
}
.toast.show { transform: translateX(0); }
.toast.err { border-color: var(--err); color: var(--err); }
footer { margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--border);
text-align: center; color: var(--text-faint); font-size: 12px; font-family: var(--mono); }
@media (max-width: 1100px) { .chart-row { grid-template-columns: 1fr 1fr; } }
@media (max-width: 720px) {
.chart-row { grid-template-columns: 1fr; }
.modal-body { grid-template-columns: 1fr; }
.kpi-value { font-size: 22px; }
header h1 { font-size: 18px; }
}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>NetOps To-Do</h1>
<div class="sub" id="clock">network-engineer@nms:~$</div>
</div>
<div class="header-actions">
<span class="user-chip" title="Pålogget bruker (fra Apache/proxy)"><?php echo htmlspecialchars($currentUser, ENT_QUOTES, 'UTF-8'); ?></span>
<button id="exportBtn" title="Eksporter JSON">⬇ Eksporter</button>
<button id="importBtn" title="Importer JSON (erstatter alt)">⬆ Importer</button>
<input type="file" id="importFile" accept=".json" style="display:none">
<button class="primary" id="newTaskBtn">+ Ny oppgave</button>
</div>
</header>
<section class="kpi-row">
<div class="kpi">
<div class="kpi-label">Åpne oppgaver</div>
<div class="kpi-value" id="kpi-open">0</div>
<div class="kpi-sub" id="kpi-open-sub">av 0 totalt</div>
</div>
<div class="kpi kpi-me">
<div class="kpi-label">Mine åpne</div>
<div class="kpi-value" id="kpi-mine">0</div>
<div class="kpi-sub" id="kpi-mine-sub">tildelt meg</div>
</div>
<div class="kpi kpi-p1">
<div class="kpi-label">P1 Kritisk</div>
<div class="kpi-value" id="kpi-p1">0</div>
<div class="kpi-sub">umiddelbar handling</div>
</div>
<div class="kpi kpi-warn">
<div class="kpi-label">Est. tid igjen</div>
<div class="kpi-value" id="kpi-hours">0t</div>
<div class="kpi-sub" id="kpi-hours-sub">arbeidstimer</div>
</div>
<div class="kpi">
<div class="kpi-label">Forfalte</div>
<div class="kpi-value" id="kpi-overdue">0</div>
<div class="kpi-sub">over frist</div>
</div>
<div class="kpi kpi-ok">
<div class="kpi-label">Ferdig (7d)</div>
<div class="kpi-value" id="kpi-done">0</div>
<div class="kpi-sub">siste 7 dager</div>
</div>
</section>
<section class="chart-row">
<div class="panel">
<h3>Åpne oppgaver per kategori <span id="cat-total" style="color:var(--text-faint);font-family:var(--mono);text-transform:none;letter-spacing:0"></span></h3>
<div class="chart-wrap"><canvas id="catChart"></canvas></div>
</div>
<div class="panel">
<h3>Fordeling etter prioritet</h3>
<div class="chart-wrap"><canvas id="prioChart"></canvas></div>
</div>
<div class="panel">
<h3>Åpne oppgaver per eier</h3>
<div class="chart-wrap"><canvas id="ownerChart"></canvas></div>
</div>
</section>
<div class="filters">
<div class="filter-group">
<label>Status</label>
<select id="f-status">
<option value="open">Åpne</option>
<option value="all">Alle</option>
<option value="todo">Å gjøre</option>
<option value="progress">Pågår</option>
<option value="blocked">Blokkert</option>
<option value="done">Ferdig</option>
</select>
</div>
<div class="filter-group">
<label>Kategori</label>
<select id="f-category"><option value="all">Alle</option></select>
</div>
<div class="filter-group">
<label>Prioritet</label>
<select id="f-priority">
<option value="all">Alle</option>
<option value="P1">P1 Kritisk</option>
<option value="P2">P2 Høy</option>
<option value="P3">P3 Middels</option>
<option value="P4">P4 Lav</option>
</select>
</div>
<div class="filter-group">
<label>Eier</label>
<select id="f-owner">
<option value="all">Alle</option>
<option value="__me__">Meg</option>
<option value="__unassigned__">Utildelt</option>
</select>
</div>
<input class="search-input" type="text" id="f-search" placeholder="Søk tittel, beskrivelse, lokasjon, tag ...">
<button id="clearFilters">Nullstill</button>
<button id="refreshBtn" title="Hent på nytt fra server">↻</button>
</div>
<div class="table-panel">
<div class="table-head">
<h3>Oppgaveliste <span class="count" id="tbl-count">0</span></h3>
<div class="sub" style="color:var(--text-faint); font-family:var(--mono); font-size:11px">klikk kolonne for å sortere</div>
</div>
<div class="table-scroll">
<table id="tbl">
<thead>
<tr>
<th data-sort="priority" style="width:70px">Prio</th>
<th data-sort="title">Oppgave</th>
<th data-sort="category" style="width:110px">Kategori</th>
<th data-sort="status" style="width:100px">Status</th>
<th data-sort="owner" style="width:130px">Eier</th>
<th data-sort="location" style="width:130px">Lokasjon</th>
<th data-sort="estHours" style="width:70px;text-align:right">Est (t)</th>
<th data-sort="deadline" style="width:120px">Frist</th>
<th style="width:110px;text-align:right">Handling</th>
</tr>
</thead>
<tbody id="tbl-body"></tbody>
</table>
</div>
<div id="empty" class="empty-state" style="display:none">
Ingen oppgaver matcher filtrene.
<div id="empty-seed" style="display:none;margin-top:14px">
Tom database. <button id="seedBtn" class="primary">Last inn eksempel-data</button>
</div>
</div>
</div>
<footer>
Data lagres på serveren · Endringer fra andre brukere synes etter neste oppdatering (↻)
</footer>
</div>
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-head">
<h2 id="modal-title">Ny oppgave</h2>
<button class="icon-btn" id="modal-close" style="font-size:18px">×</button>
</div>
<div class="modal-body">
<div class="form-field full">
<label>Tittel *</label>
<input type="text" id="f-title" placeholder="F.eks. Konfigurer BGP-peer mot AS2119">
</div>
<div class="form-field full">
<label>Beskrivelse / notater</label>
<textarea id="f-desc" placeholder="Kommandoer, konfigurasjon, referanser ..."></textarea>
</div>
<div class="form-field">
<label>Kategori *</label>
<select id="f-cat"></select>
</div>
<div class="form-field">
<label>Prioritet</label>
<select id="f-prio">
<option value="P1">P1 Kritisk</option>
<option value="P2" selected>P2 Høy</option>
<option value="P3">P3 Middels</option>
<option value="P4">P4 Lav</option>
</select>
</div>
<div class="form-field">
<label>Status</label>
<select id="f-stat">
<option value="todo" selected>Å gjøre</option>
<option value="progress">Pågår</option>
<option value="blocked">Blokkert</option>
<option value="done">Ferdig</option>
</select>
</div>
<div class="form-field">
<label>Est. tid (timer)</label>
<input type="number" id="f-est" min="0" step="0.5" placeholder="2">
</div>
<div class="form-field full">
<label>Eier / tildelt</label>
<div class="owner-input-row">
<input type="text" id="f-owner-input" placeholder="brukernavn, eller blank for utildelt">
<select id="f-owner-select" title="Velg eksisterende bruker">
<option value="">— velg —</option>
</select>
</div>
</div>
<div class="form-field">
<label>Lokasjon / site</label>
<input type="text" id="f-loc" placeholder="DC-OSL-1, Bergen POP, remote ...">
</div>
<div class="form-field">
<label>Frist</label>
<input type="date" id="f-dl">
</div>
<div class="form-field full">
<label>Tags (komma-separert)</label>
<input type="text" id="f-tags" placeholder="vedlikeholdsvindu, kunde-X, audit">
</div>
</div>
<div class="modal-foot">
<button class="danger" id="modal-delete" style="display:none">Slett</button>
<div style="flex:1"></div>
<button id="modal-cancel">Avbryt</button>
<button class="primary" id="modal-save">Lagre</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ---------- Config ----------
const CURRENT_USER = <?php echo json_encode($currentUser); ?>;
const API = 'api.php';
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 };
// ---------- State ----------
let state = {
tasks: [],
knownUsers: [],
filters: { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' },
sort: { field: 'priority', dir: 'asc' },
editingId: null,
};
let charts = {};
// ---------- API helpers ----------
async function api(action, body = null) {
const opts = { method: body ? 'POST' : 'GET', headers: {} };
if (body) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(`${API}?action=${action}`, opts);
const ct = res.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
throw new Error(`Uventet svar fra server (HTTP ${res.status}). Er api.php tilgjengelig?`);
}
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
function toast(msg, isErr = false) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show' + (isErr ? ' err' : '');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2800);
}
// ---------- Utilities ----------
function daysFromNow(d) {
const date = new Date(); date.setDate(date.getDate() + d);
return date.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 escapeHtml(s) {
return String(s||'').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
}
// ---------- Filtering / sorting ----------
function applyFilters(tasks) {
const f = state.filters;
return tasks.filter(t => {
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||'') !== CURRENT_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 render() {
renderKPIs();
renderCharts();
renderTable();
updateOwnerFilterOptions();
}
function renderKPIs() {
const open = state.tasks.filter(t => t.status !== 'done');
const mine = open.filter(t => (t.owner||'') === CURRENT_USER);
const total = state.tasks.length;
const p1 = open.filter(t => t.priority === 'P1').length;
const hours = open.reduce((s,t) => s + (t.estHours || 0), 0);
const today = new Date().toISOString().slice(0,10);
const overdue = open.filter(t => t.deadline && t.deadline < today).length;
const weekAgo = daysFromNow(-7);
const done7 = state.tasks.filter(t => t.status === 'done' && t.completed && t.completed >= weekAgo).length;
document.getElementById('kpi-open').textContent = open.length;
document.getElementById('kpi-open-sub').textContent = `av ${total} totalt`;
document.getElementById('kpi-mine').textContent = mine.length;
const mineHours = mine.reduce((s,t) => s + (t.estHours||0), 0);
document.getElementById('kpi-mine-sub').textContent = mineHours > 0 ? `≈ ${mineHours.toFixed(1).replace(/\.0$/,'')}t arbeid` : 'tildelt meg';
document.getElementById('kpi-p1').textContent = p1;
document.getElementById('kpi-hours').textContent = hours.toFixed(1).replace(/\.0$/,'') + 't';
document.getElementById('kpi-hours-sub').textContent = hours > 8 ? `≈ ${(hours/8).toFixed(1)} arbeidsdager` : 'arbeidstimer';
document.getElementById('kpi-overdue').textContent = overdue;
document.getElementById('kpi-done').textContent = done7;
}
function renderCharts() {
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]);
document.getElementById('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 };
charts.cat = new Chart(document.getElementById('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: {
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.prio = new Chart(document.getElementById('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(document.getElementById('ownerChart'), {
type: 'bar',
data: {
labels: ownerEntries.map(([k]) => k),
datasets: [{
data: ownerEntries.map(([,v]) => v),
backgroundColor: ownerEntries.map(([k]) => k === CURRENT_USER ? '#4ade80CC' : '#bb86fcCC'),
borderColor: ownerEntries.map(([k]) => k === CURRENT_USER ? '#4ade80' : '#bb86fc'),
borderWidth: 1, borderRadius: 4,
}]
},
options: {
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 } }
}
}
});
}
function renderTable() {
const filtered = sortTasks(applyFilters(state.tasks));
const tbody = document.getElementById('tbl-body');
const empty = document.getElementById('empty');
const emptySeed = document.getElementById('empty-seed');
document.getElementById('tbl-count').textContent = filtered.length;
if (filtered.length === 0) {
tbody.innerHTML = '';
empty.style.display = 'block';
emptySeed.style.display = state.tasks.length === 0 ? 'block' : 'none';
return;
}
empty.style.display = 'none';
tbody.innerHTML = filtered.map(t => {
const cat = CATEGORIES.find(c => c.id === t.category);
const dl = formatDeadline(t.deadline);
const tags = (t.tags||[]).map(tag => `<span style="font-family:var(--mono);font-size:11px;color:var(--text-faint)">#${escapeHtml(tag)}</span>`).join(' ');
const ownerCls = !t.owner ? 'unassigned' : (t.owner === CURRENT_USER ? 'me' : '');
const ownerText = t.owner ? escapeHtml(t.owner) : 'utildelt';
return `
<tr class="status-${t.status}" 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)}</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><span class="cat-badge" style="color:${CAT_COLORS[t.category]||'#4fc3f7'};border-color:${(CAT_COLORS[t.category]||'#4fc3f7')}40;background:${(CAT_COLORS[t.category]||'#4fc3f7')}15" title="${cat?escapeHtml(cat.desc):''}">${cat?escapeHtml(cat.name):escapeHtml(t.category)}</span></td>
<td><span class="status-badge status-${t.status}">${STATUS_LABELS[t.status]||t.status}</span></td>
<td><span class="owner-badge ${ownerCls}" title="${t.createdBy?`Opprettet av ${escapeHtml(t.createdBy)}`:''}">${ownerText}</span></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="unmarkDone('${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="claim('${t.id}')" title="Ta eierskap">👤</button>` : ''}
<button class="icon-btn del" onclick="deleteTask('${t.id}')" title="Slett">🗑</button>
</div>
</td>
</tr>`;
}).join('');
}
function updateOwnerFilterOptions() {
const sel = document.getElementById('f-owner');
const current = sel.value;
// Samle alle eiere
const users = new Set(state.knownUsers);
state.tasks.forEach(t => { if (t.owner) users.add(t.owner); });
// Bevar "Alle", "Meg", "Utildelt", legg til øvrige
sel.innerHTML = '<option value="all">Alle</option><option value="__me__">Meg</option><option value="__unassigned__">Utildelt</option>';
[...users].sort().forEach(u => {
const opt = document.createElement('option');
opt.value = u; opt.textContent = u;
sel.appendChild(opt);
});
sel.value = [...sel.options].some(o => o.value === current) ? current : 'all';
// Samme for modal-dropdown
const msel = document.getElementById('f-owner-select');
msel.innerHTML = '<option value="">— velg —</option>';
[...users].sort().forEach(u => {
const opt = document.createElement('option');
opt.value = u; opt.textContent = u;
msel.appendChild(opt);
});
}
// ---------- Actions ----------
async function loadAll() {
try {
const data = await api('list');
state.tasks = data.tasks || [];
state.knownUsers = data.known_users || [];
render();
} catch (e) {
toast('Kunne ikke laste: ' + e.message, true);
console.error(e);
}
}
window.markDone = async function(id) {
const t = state.tasks.find(x => x.id === id);
if (!t) return;
try {
const { task } = await api('update', { ...t, status: 'done' });
Object.assign(t, task);
render();
toast('Markert ferdig');
} catch (e) { toast('Feil: ' + e.message, true); }
};
window.unmarkDone = async function(id) {
const t = state.tasks.find(x => x.id === id);
if (!t) return;
try {
const { task } = await api('update', { ...t, status: 'todo' });
Object.assign(t, task);
render();
} catch (e) { toast('Feil: ' + e.message, true); }
};
window.claim = async function(id) {
const t = state.tasks.find(x => x.id === id);
if (!t) return;
try {
const { task } = await api('update', { ...t, owner: CURRENT_USER });
Object.assign(t, task);
render();
toast(`Du eier nå "${task.title.slice(0,30)}${task.title.length>30?'…':''}"`);
} catch (e) { toast('Feil: ' + e.message, true); }
};
window.deleteTask = async function(id) {
const t = state.tasks.find(x => x.id === id);
if (!t) return;
if (!confirm(`Slette oppgaven "${t.title}"?`)) return;
try {
await api('delete', { id });
state.tasks = state.tasks.filter(x => x.id !== id);
render();
toast('Slettet');
} catch (e) { toast('Feil: ' + e.message, true); }
};
window.openEdit = function(id) {
const t = state.tasks.find(x => x.id === id);
if (!t) return;
state.editingId = id;
document.getElementById('modal-title').textContent = 'Rediger oppgave';
document.getElementById('f-title').value = t.title;
document.getElementById('f-desc').value = t.desc || '';
document.getElementById('f-cat').value = t.category;
document.getElementById('f-prio').value = t.priority;
document.getElementById('f-stat').value = t.status;
document.getElementById('f-est').value = t.estHours || '';
document.getElementById('f-owner-input').value = t.owner || '';
document.getElementById('f-owner-select').value = '';
document.getElementById('f-loc').value = t.location || '';
document.getElementById('f-dl').value = t.deadline || '';
document.getElementById('f-tags').value = (t.tags||[]).join(', ');
document.getElementById('modal-delete').style.display = 'inline-flex';
document.getElementById('modal').classList.add('open');
};
function openNew() {
state.editingId = null;
document.getElementById('modal-title').textContent = 'Ny oppgave';
document.getElementById('f-title').value = '';
document.getElementById('f-desc').value = '';
document.getElementById('f-cat').value = CATEGORIES[0].id;
document.getElementById('f-prio').value = 'P2';
document.getElementById('f-stat').value = 'todo';
document.getElementById('f-est').value = '';
document.getElementById('f-owner-input').value = CURRENT_USER;
document.getElementById('f-owner-select').value = '';
document.getElementById('f-loc').value = '';
document.getElementById('f-dl').value = '';
document.getElementById('f-tags').value = '';
document.getElementById('modal-delete').style.display = 'none';
document.getElementById('modal').classList.add('open');
setTimeout(() => document.getElementById('f-title').focus(), 50);
}
function closeModal() {
document.getElementById('modal').classList.remove('open');
state.editingId = null;
}
async function saveForm() {
const title = document.getElementById('f-title').value.trim();
if (!title) { alert('Tittel er påkrevd'); return; }
const data = {
title,
desc: document.getElementById('f-desc').value.trim(),
category: document.getElementById('f-cat').value,
priority: document.getElementById('f-prio').value,
status: document.getElementById('f-stat').value,
estHours: parseFloat(document.getElementById('f-est').value) || 0,
owner: document.getElementById('f-owner-input').value.trim(),
location: document.getElementById('f-loc').value.trim(),
deadline: document.getElementById('f-dl').value,
tags: document.getElementById('f-tags').value.split(',').map(s=>s.trim()).filter(Boolean),
};
try {
if (state.editingId) {
data.id = state.editingId;
const { task } = await api('update', data);
const idx = state.tasks.findIndex(x => x.id === state.editingId);
if (idx >= 0) state.tasks[idx] = task;
toast('Lagret');
} else {
const { task } = await api('create', data);
state.tasks.push(task);
toast('Opprettet');
}
closeModal();
render();
} catch (e) { toast('Feil: ' + e.message, true); }
}
async function deleteFromModal() {
if (!state.editingId) return;
const t = state.tasks.find(x => x.id === state.editingId);
if (!t) return;
if (!confirm(`Slette oppgaven "${t.title}"?`)) return;
try {
await api('delete', { id: state.editingId });
state.tasks = state.tasks.filter(x => x.id !== state.editingId);
closeModal();
render();
toast('Slettet');
} catch (e) { toast('Feil: ' + e.message, true); }
}
// ---------- Filters ----------
function bindFilters() {
document.getElementById('f-status').addEventListener('change', e => { state.filters.status = e.target.value; render(); });
document.getElementById('f-category').addEventListener('change', e => { state.filters.category = e.target.value; render(); });
document.getElementById('f-priority').addEventListener('change', e => { state.filters.priority = e.target.value; render(); });
document.getElementById('f-owner').addEventListener('change', e => { state.filters.owner = e.target.value; render(); });
document.getElementById('f-search').addEventListener('input', e => { state.filters.search = e.target.value; render(); });
document.getElementById('clearFilters').addEventListener('click', () => {
state.filters = { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' };
document.getElementById('f-status').value = 'open';
document.getElementById('f-category').value = 'all';
document.getElementById('f-priority').value = 'all';
document.getElementById('f-owner').value = 'all';
document.getElementById('f-search').value = '';
render();
});
document.getElementById('refreshBtn').addEventListener('click', loadAll);
}
function populateCategorySelects() {
const filterSel = document.getElementById('f-category');
const formSel = document.getElementById('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>`);
});
}
function bindSort() {
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';
}
render();
});
});
}
// ---------- Import / Export ----------
function exportJSON() {
const data = JSON.stringify(state.tasks, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `netops-todo-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(a.href);
}
async function importJSON(file) {
try {
const text = await file.text();
const data = JSON.parse(text);
if (!Array.isArray(data)) throw new Error('Forventet array');
if (!confirm(`Importere ${data.length} oppgaver? Dette erstatter alt på serveren.`)) return;
await api('replace', data);
toast('Importert');
await loadAll();
} catch (e) { toast('Kunne ikke importere: ' + e.message, true); }
}
// ---------- Seed ----------
async function seedSamples() {
if (!confirm('Last inn 16 eksempel-oppgaver?')) return;
try {
await api('seed', {});
toast('Eksempel-data lastet');
await loadAll();
} catch (e) { toast('Feil: ' + e.message, true); }
}
// ---------- Clock ----------
function updateClock() {
const now = new Date();
const s = now.toLocaleString('nb-NO', { weekday:'short', day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' });
document.getElementById('clock').textContent = `$ date → ${s}`;
}
// ---------- Init ----------
function init() {
populateCategorySelects();
bindFilters();
bindSort();
document.getElementById('newTaskBtn').addEventListener('click', openNew);
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('modal-cancel').addEventListener('click', closeModal);
document.getElementById('modal-save').addEventListener('click', saveForm);
document.getElementById('modal-delete').addEventListener('click', deleteFromModal);
document.getElementById('modal').addEventListener('click', e => { if (e.target.id === 'modal') closeModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
if (e.key === 'n' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA' && e.target.tagName !== 'SELECT') {
e.preventDefault(); openNew();
}
});
// Owner select i modal fyller inn fritekstfeltet
document.getElementById('f-owner-select').addEventListener('change', e => {
if (e.target.value) {
document.getElementById('f-owner-input').value = e.target.value;
e.target.value = '';
}
});
document.getElementById('exportBtn').addEventListener('click', exportJSON);
document.getElementById('importBtn').addEventListener('click', () => document.getElementById('importFile').click());
document.getElementById('importFile').addEventListener('change', e => {
if (e.target.files[0]) importJSON(e.target.files[0]);
e.target.value = '';
});
document.getElementById('seedBtn').addEventListener('click', seedSamples);
updateClock();
setInterval(updateClock, 30000);
// Auto-refresh hvert 60s for å hente inn andres endringer
setInterval(loadAll, 60000);
loadAll();
}
init();
</script>
</body>
</html>