1024 lines
43 KiB
PHP
1024 lines
43 KiB
PHP
<?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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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>
|