1312 lines
42 KiB
HTML
1312 lines
42 KiB
HTML
<!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 */
|
||
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);
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* Buttons */
|
||
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, .btn.primary {
|
||
background: var(--accent);
|
||
color: #06151f;
|
||
border-color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
|
||
button.primary:hover, .btn.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);
|
||
}
|
||
|
||
/* KPI row */
|
||
.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-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 */
|
||
.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 */
|
||
.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;
|
||
}
|
||
|
||
/* Task list table */
|
||
.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;
|
||
}
|
||
|
||
/* Badges */
|
||
.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);
|
||
}
|
||
|
||
.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 */
|
||
.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; }
|
||
|
||
.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);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/* Responsive */
|
||
@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">
|
||
<button id="exportBtn" title="Eksporter JSON">⬇ Eksporter</button>
|
||
<button id="importBtn" title="Importer JSON">⬆ Importer</button>
|
||
<input type="file" id="importFile" accept=".json" style="display:none">
|
||
<button class="primary" id="newTaskBtn">+ Ny oppgave</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- KPIs -->
|
||
<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-p1">
|
||
<div class="kpi-label">P1 Kritisk</div>
|
||
<div class="kpi-value" id="kpi-p1">0</div>
|
||
<div class="kpi-sub" id="kpi-p1-sub">krever 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" id="kpi-done-sub">siste 7 dager</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Charts -->
|
||
<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>Ferdigstilt siste 14 dager</h3>
|
||
<div class="chart-wrap"><canvas id="trendChart"></canvas></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Filters -->
|
||
<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>
|
||
<input class="search-input" type="text" id="f-search" placeholder="Søk tittel, beskrivelse, lokasjon, tag ...">
|
||
<button id="clearFilters">Nullstill</button>
|
||
</div>
|
||
|
||
<!-- Table -->
|
||
<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="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 som matcher filtrene. Prøv å nullstille filtre eller legg til en ny oppgave.
|
||
</div>
|
||
</div>
|
||
|
||
<footer>
|
||
Lagres lokalt i nettleseren · Kan eksporteres til JSON for backup
|
||
</footer>
|
||
</div>
|
||
|
||
<!-- Task Modal -->
|
||
<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">
|
||
<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>
|
||
|
||
<script>
|
||
// ---------- Constants ----------
|
||
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 };
|
||
|
||
// ---------- Sample seed data ----------
|
||
const SAMPLE_TASKS = [
|
||
{ title: 'OSPF adjacency drops mellom RTR-OSL-01 og RTR-OSL-02',
|
||
desc: 'Intermittent flapping ca. hvert 15. min. Sjekk MTU, hello/dead timers og interface-feil. Logger på NMS.',
|
||
category: 'L3', priority: 'P1', status: 'progress', estHours: 4,
|
||
location: 'DC-OSL-1', deadline: daysFromNow(0), tags: ['feilsøking','prod'] },
|
||
{ title: 'Høy latency mot NIX peering',
|
||
desc: 'Pakketap 0.3%, latency 8→18ms mot AS-path via NIX. Kan være congestion – kontakt NIX NOC.',
|
||
category: 'PEER', priority: 'P1', status: 'todo', estHours: 2,
|
||
location: 'remote', deadline: daysFromNow(1), tags: ['peering','nix'] },
|
||
{ title: 'Konfigurer nye eBGP-peers mot Telenor AS2119',
|
||
desc: 'To nye sesjoner på redundant uplink. Husk MD5, max-prefix, og route-map inn/ut. Dokumentér AS-set.',
|
||
category: 'BGP', priority: 'P2', status: 'todo', estHours: 3,
|
||
location: 'DC-OSL-1', deadline: daysFromNow(3), tags: ['bgp','telenor'] },
|
||
{ title: 'Bytt defekt SFP+ i core-sw01 port 24',
|
||
desc: 'CRC-feil øker, link flapper. RMA er bestilt, reservedel på lager B-12.',
|
||
category: 'L1', priority: 'P2', status: 'todo', estHours: 1,
|
||
location: 'DC-OSL-1', deadline: daysFromNow(2), tags: ['hardware'] },
|
||
{ title: 'Sett opp HAProxy for nytt API-cluster',
|
||
desc: 'Backend: 4 noder, TLS termination, HTTP/2, health check /healthz. ACL for rate limit.',
|
||
category: 'L4', priority: 'P2', status: 'todo', estHours: 4,
|
||
location: 'remote', deadline: daysFromNow(5), tags: ['prosjekt'] },
|
||
{ title: 'Fiberkoble nytt rack B12',
|
||
desc: '24x LC-LC single-mode, 3m patch. Dokumentér i kabelregister. Labels iht. standard.',
|
||
category: 'L1', priority: 'P3', status: 'todo', estHours: 6,
|
||
location: 'DC-BGO-1', deadline: daysFromNow(7), tags: ['felt','bergen'] },
|
||
{ title: 'Oppgrader IOS-XE på dist-sw03 til 05',
|
||
desc: 'Vedlikeholdsvindu 02:00–04:00. Backup konfig, ISSU der det støttes. Rollback plan.',
|
||
category: 'L2', priority: 'P2', status: 'todo', estHours: 3,
|
||
location: 'DC-OSL-1', deadline: daysFromNow(4), tags: ['vedlikehold','oppgradering'] },
|
||
{ title: 'Legg til route reflector i iBGP-mesh',
|
||
desc: 'Reduser full-mesh. RR-klienter: alle PE-rutere. Test failover mellom RR1/RR2.',
|
||
category: 'BGP', priority: 'P3', status: 'todo', estHours: 5,
|
||
location: 'remote', deadline: daysFromNow(10), tags: ['design'] },
|
||
{ title: 'Review FW-regler før Q2 audit',
|
||
desc: 'Finn og rydd vekk ubrukte regler, dupliserte objekter. Eksportér rapport til sec-team.',
|
||
category: 'SEC', priority: 'P2', status: 'blocked', estHours: 8,
|
||
location: 'remote', deadline: daysFromNow(14), tags: ['audit','compliance'] },
|
||
{ title: 'Bytt batteri på UPS i Trondheim POP',
|
||
desc: 'Batteritest feiler. Kontakt kjøpetjeneste for reservedel. Krever fysisk oppmøte.',
|
||
category: 'L1', priority: 'P2', status: 'todo', estHours: 2,
|
||
location: 'POP-TRD', deadline: daysFromNow(6), tags: ['felt','trondheim'] },
|
||
{ title: 'Oppdater LibreNMS poller-nodes',
|
||
desc: 'v24.x → v25.x. Backup MySQL, test med staging-poller først.',
|
||
category: 'NMS', priority: 'P4', status: 'todo', estHours: 1,
|
||
location: 'remote', deadline: daysFromNow(12), tags: ['vedlikehold'] },
|
||
{ title: 'Dokumentér nye VLAN-er (120-135) i IPAM',
|
||
desc: 'Oppdater NetBox. Inkluder prefix, site, role og tenant.',
|
||
category: 'DOC', priority: 'P3', status: 'progress', estHours: 2,
|
||
location: 'remote', deadline: daysFromNow(3), tags: ['netbox'] },
|
||
{ title: 'Ansible-playbook for standard switch-config',
|
||
desc: 'Base config, NTP, SNMP, AAA, banner. Idempotent. Test i lab først.',
|
||
category: 'AUTO', priority: 'P3', status: 'todo', estHours: 6,
|
||
location: 'remote', deadline: daysFromNow(14), tags: ['automatisering'] },
|
||
{ title: 'Postmortem for uptime-hendelse 2026-04-19',
|
||
desc: '45 min utfall etter oppgradering. RCA + tiltaksliste. Del med ledergruppe.',
|
||
category: 'INC', priority: 'P2', status: 'progress', estHours: 3,
|
||
location: 'remote', deadline: daysFromNow(2), tags: ['postmortem'] },
|
||
{ title: 'Planlegg 100G oppgradering DC-BGO ↔ DC-OSL',
|
||
desc: 'Kostestimat, lead time optikk, migreringsvindu, rollback.',
|
||
category: 'PROJ', priority: 'P3', status: 'todo', estHours: 10,
|
||
location: 'remote', deadline: daysFromNow(30), tags: ['planlegging','capex'] },
|
||
{ title: 'Verifisér BFD timere på alle OSPF-lenker',
|
||
desc: 'Mål: 150ms detection. Sjekk at alle rutere støtter og har konfigurert.',
|
||
category: 'L3', priority: 'P4', status: 'todo', estHours: 2,
|
||
location: 'remote', deadline: daysFromNow(20), tags: ['optimalisering'] },
|
||
// A few completed tasks for the trend chart
|
||
{ title: 'Ferdig: Bytt strømkurs i rack A3', category: 'L1', priority: 'P3',
|
||
status: 'done', estHours: 2, location: 'DC-OSL-1',
|
||
completed: daysFromNow(-2), tags: ['felt'] },
|
||
{ title: 'Ferdig: Aktivér BFD på eBGP mot AS1299', category: 'BGP', priority: 'P3',
|
||
status: 'done', estHours: 1, location: 'remote',
|
||
completed: daysFromNow(-4), tags: ['optimalisering'] },
|
||
{ title: 'Ferdig: Oppdater runbook for core-sw reboot', category: 'DOC', priority: 'P4',
|
||
status: 'done', estHours: 2, location: 'remote',
|
||
completed: daysFromNow(-1), tags: ['runbook'] },
|
||
{ title: 'Ferdig: Løst MAC-flap alarm på acc-sw14', category: 'L2', priority: 'P2',
|
||
status: 'done', estHours: 2, location: 'DC-OSL-1',
|
||
completed: daysFromNow(-6), tags: ['feilsøking'] },
|
||
];
|
||
|
||
function daysFromNow(d) {
|
||
const date = new Date();
|
||
date.setDate(date.getDate() + d);
|
||
return date.toISOString().slice(0, 10);
|
||
}
|
||
|
||
// ---------- State / Storage ----------
|
||
const STORAGE_KEY = 'netops-todo-v1';
|
||
|
||
function uid() {
|
||
return 't_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
|
||
}
|
||
|
||
function loadTasks() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (raw) return JSON.parse(raw);
|
||
} catch (e) { console.warn('Load error', e); }
|
||
// First run: seed with sample tasks
|
||
const seeded = SAMPLE_TASKS.map(t => ({
|
||
id: uid(),
|
||
title: t.title,
|
||
desc: t.desc || '',
|
||
category: t.category,
|
||
priority: t.priority,
|
||
status: t.status,
|
||
estHours: t.estHours || 0,
|
||
location: t.location || '',
|
||
deadline: t.deadline || '',
|
||
tags: t.tags || [],
|
||
created: new Date().toISOString(),
|
||
completed: t.completed || null
|
||
}));
|
||
saveTasks(seeded);
|
||
return seeded;
|
||
}
|
||
|
||
function saveTasks(tasks) {
|
||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); }
|
||
catch (e) { console.warn('Save error', e); }
|
||
}
|
||
|
||
let state = {
|
||
tasks: loadTasks(),
|
||
filters: { status: 'open', category: 'all', priority: 'all', search: '' },
|
||
sort: { field: 'priority', dir: 'asc' },
|
||
editingId: null,
|
||
};
|
||
|
||
let charts = {};
|
||
|
||
// ---------- Rendering ----------
|
||
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);
|
||
let text, cls = '';
|
||
if (diff < 0) { text = `${Math.abs(diff)}d forfalt`; cls = 'overdue'; }
|
||
else if (diff === 0) { text = 'I dag'; cls = 'soon'; }
|
||
else if (diff === 1) { text = 'I morgen'; cls = 'soon'; }
|
||
else if (diff <= 3) { text = `${diff}d igjen`; cls = 'soon'; }
|
||
else { text = dateStr; }
|
||
return { text, cls };
|
||
}
|
||
|
||
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.search) {
|
||
const q = f.search.toLowerCase();
|
||
const hay = [t.title, t.desc, t.location, (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;
|
||
});
|
||
}
|
||
|
||
function render() {
|
||
renderKPIs();
|
||
renderCharts();
|
||
renderTable();
|
||
}
|
||
|
||
function renderKPIs() {
|
||
const open = state.tasks.filter(t => t.status !== 'done');
|
||
const total = state.tasks.length;
|
||
const p1 = open.filter(t => t.priority === 'P1').length;
|
||
const hours = open.reduce((sum, t) => sum + (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-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');
|
||
|
||
// By category
|
||
const byCat = {};
|
||
CATEGORIES.forEach(c => { byCat[c.id] = 0; });
|
||
open.forEach(t => { byCat[t.category] = (byCat[t.category] || 0) + 1; });
|
||
const catEntries = Object.entries(byCat).filter(([,v]) => v > 0)
|
||
.sort((a,b) => b[1] - a[1]);
|
||
document.getElementById('cat-total').textContent = `${open.length} åpne`;
|
||
|
||
// By priority
|
||
const byPrio = { P1:0, P2:0, P3:0, P4:0 };
|
||
open.forEach(t => { byPrio[t.priority]++; });
|
||
|
||
// Trend (last 14 days)
|
||
const days = [];
|
||
for (let i = 13; i >= 0; i--) days.push(daysFromNow(-i));
|
||
const completedByDay = days.map(d => state.tasks.filter(t => t.status === 'done' && t.completed === d).length);
|
||
|
||
// Destroy existing
|
||
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),
|
||
datasets: [{
|
||
data: catEntries.map(([,v]) => v),
|
||
backgroundColor: catEntries.map(([k]) => CAT_COLORS[k] + 'CC'),
|
||
borderColor: catEntries.map(([k]) => CAT_COLORS[k]),
|
||
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 total = ctx.dataset.data.reduce((a,b)=>a+b,0);
|
||
const pct = total ? ((ctx.parsed/total)*100).toFixed(0) : 0;
|
||
return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
charts.trend = new Chart(document.getElementById('trendChart'), {
|
||
type: 'bar',
|
||
data: {
|
||
labels: days.map(d => d.slice(5)),
|
||
datasets: [{
|
||
label: 'Ferdige',
|
||
data: completedByDay,
|
||
backgroundColor: '#4ade80CC',
|
||
borderColor: '#4ade80',
|
||
borderWidth: 1,
|
||
borderRadius: 3,
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: { callbacks: { label: ctx => `${ctx.parsed.y} ferdig` } }
|
||
},
|
||
scales: {
|
||
x: { ticks: { color: '#8b98a9', font: { ...commonFont, size: 10 }, maxRotation: 0, autoSkip: true, autoSkipPadding: 10 }, grid: { display: false } },
|
||
y: { beginAtZero: true, ticks: { color: '#8b98a9', font: commonFont, stepSize: 1 }, grid: { color: '#2a3340' } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderTable() {
|
||
const filtered = sortTasks(applyFilters(state.tasks));
|
||
const tbody = document.getElementById('tbl-body');
|
||
const empty = document.getElementById('empty');
|
||
document.getElementById('tbl-count').textContent = filtered.length;
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = '';
|
||
empty.style.display = 'block';
|
||
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)">#${tag}</span>`).join(' ');
|
||
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]};border-color:${CAT_COLORS[t.category]}40;background:${CAT_COLORS[t.category]}15" title="${cat?cat.desc:''}">${cat?cat.name:t.category}</span></td>
|
||
<td><span class="status-badge status-${t.status}">${STATUS_LABELS[t.status]}</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>
|
||
<button class="icon-btn del" onclick="deleteTask('${t.id}')" title="Slett">🗑</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s||'').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
||
}
|
||
|
||
// ---------- Actions ----------
|
||
window.markDone = function(id) {
|
||
const t = state.tasks.find(x => x.id === id);
|
||
if (!t) return;
|
||
t.status = 'done';
|
||
t.completed = new Date().toISOString().slice(0,10);
|
||
saveTasks(state.tasks);
|
||
render();
|
||
};
|
||
|
||
window.unmarkDone = function(id) {
|
||
const t = state.tasks.find(x => x.id === id);
|
||
if (!t) return;
|
||
t.status = 'todo';
|
||
t.completed = null;
|
||
saveTasks(state.tasks);
|
||
render();
|
||
};
|
||
|
||
window.deleteTask = function(id) {
|
||
const t = state.tasks.find(x => x.id === id);
|
||
if (!t) return;
|
||
if (!confirm(`Slette oppgaven "${t.title}"?`)) return;
|
||
state.tasks = state.tasks.filter(x => x.id !== id);
|
||
saveTasks(state.tasks);
|
||
render();
|
||
};
|
||
|
||
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-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-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;
|
||
}
|
||
|
||
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,
|
||
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),
|
||
};
|
||
|
||
if (state.editingId) {
|
||
const t = state.tasks.find(x => x.id === state.editingId);
|
||
const wasDone = t.status === 'done';
|
||
Object.assign(t, data);
|
||
if (data.status === 'done' && !wasDone) t.completed = new Date().toISOString().slice(0,10);
|
||
if (data.status !== 'done') t.completed = null;
|
||
} else {
|
||
state.tasks.push({
|
||
id: uid(), ...data,
|
||
created: new Date().toISOString(),
|
||
completed: data.status === 'done' ? new Date().toISOString().slice(0,10) : null
|
||
});
|
||
}
|
||
saveTasks(state.tasks);
|
||
closeModal();
|
||
render();
|
||
}
|
||
|
||
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;
|
||
state.tasks = state.tasks.filter(x => x.id !== state.editingId);
|
||
saveTasks(state.tasks);
|
||
closeModal();
|
||
render();
|
||
}
|
||
|
||
// ---------- 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-search').addEventListener('input', e => { state.filters.search = e.target.value; render(); });
|
||
document.getElementById('clearFilters').addEventListener('click', () => {
|
||
state.filters = { status: 'open', category: 'all', priority: 'all', search: '' };
|
||
document.getElementById('f-status').value = 'open';
|
||
document.getElementById('f-category').value = 'all';
|
||
document.getElementById('f-priority').value = 'all';
|
||
document.getElementById('f-search').value = '';
|
||
render();
|
||
});
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function importJSON(file) {
|
||
const reader = new FileReader();
|
||
reader.onload = e => {
|
||
try {
|
||
const data = JSON.parse(e.target.result);
|
||
if (!Array.isArray(data)) throw new Error('Forventet array');
|
||
if (!confirm(`Importere ${data.length} oppgaver? Dette erstatter eksisterende data.`)) return;
|
||
state.tasks = data;
|
||
saveTasks(state.tasks);
|
||
render();
|
||
} catch (err) { alert('Kunne ikke lese JSON: ' + err.message); }
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
// ---------- 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();
|
||
}
|
||
});
|
||
|
||
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 = '';
|
||
});
|
||
|
||
updateClock();
|
||
setInterval(updateClock, 30000);
|
||
render();
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|