2026-03-30 07:50:15 +02:00
{% extends "shared/frontend/base.html" %}
{% block title %}AnyDesk Sessions - BMC Hub{% endblock %}
{% block extra_css %}
< style >
/* ─── Layout ─────────────────────────────────────────────────── */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.page-header h1 { font-size: 1.8rem; font-weight: 700; margin: 0; }
/* ─── Stat cards ─────────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,.1);
border-radius: 12px;
padding: 1.1rem 1.4rem;
display: flex;
flex-direction: column;
gap: .25rem;
}
.stat-card .label { font-size: .8rem; color: var(--text-secondary); font-weight:500; }
.stat-card .value { font-size: 1.7rem; font-weight: 700; color: var(--accent); line-height:1; }
.stat-card.warn .value { color: #e6a817; }
.stat-card.ok .value { color: #28a745; }
/* ─── Controls ───────────────────────────────────────────────── */
.controls-bar {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,.1);
border-radius: 12px;
padding: 1rem 1.2rem;
margin-bottom: 1rem;
display: flex;
flex-wrap: wrap;
gap: .75rem;
align-items: center;
}
.controls-bar select,
.controls-bar input {
background: var(--bg-body);
color: var(--text-primary);
border: 1px solid rgba(0,0,0,.2);
border-radius: 6px;
padding: .4rem .7rem;
font-size: .9rem;
}
/* ─── Session table ──────────────────────────────────────────── */
.sessions-table-wrap {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,.1);
border-radius: 12px;
overflow: hidden;
}
.sessions-table {
width: 100%;
border-collapse: collapse;
font-size: .875rem;
}
.sessions-table th {
background: rgba(0,0,0,.04);
padding: .65rem .9rem;
text-align: left;
font-weight: 600;
font-size: .8rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .04em;
white-space: nowrap;
}
[data-theme="dark"] .sessions-table th { background: rgba(255,255,255,.05); }
.sessions-table td {
padding: .6rem .9rem;
border-top: 1px solid rgba(0,0,0,.06);
vertical-align: middle;
}
[data-theme="dark"] .sessions-table td { border-color: rgba(255,255,255,.06); }
.sessions-table tr:hover td { background: rgba(15,76,117,.04); }
/* badges */
.badge-link { display:inline-flex; align-items:center; gap:.3rem; font-size:.78rem; padding:.2rem .55rem; border-radius:5px; font-weight:500; white-space:nowrap; }
2026-10-08 08:58:02 +02:00
.badge-hw { background:rgba(15,76,117,.12); color:var(--theme-primary-text, #0f4c75); }
2026-03-30 07:50:15 +02:00
.badge-cust{ background:rgba(40,167,69,.12); color:#1a6e30; }
.badge-cont{ background:rgba(255,140,0,.13); color:#b36200; }
.badge-sag { background:rgba(111,66,193,.13); color:#4b1d8c; }
[data-theme="dark"] .badge-hw { background:rgba(86,156,214,.15); color:#7bb8e8; }
[data-theme="dark"] .badge-cust{ background:rgba(78,201,176,.15); color:#4ec9b0; }
[data-theme="dark"] .badge-cont{ background:rgba(255,193,7,.15); color:#ffd54f; }
[data-theme="dark"] .badge-sag { background:rgba(180,120,255,.15);color:#c197ff; }
.badge-link i { font-size:.75rem; }
.badge-unreg { background:rgba(220,53,69,.1); color:#dc3545; font-size:.75rem; padding:.2rem .5rem; border-radius:4px; }
.badge-ok { background:rgba(40,167,69,.12); color:#1a6e30; font-size:.75rem; padding:.2rem .5rem; border-radius:4px; }
[data-theme="dark"] .badge-unreg { background:rgba(255,100,100,.15); color:#ff8080; }
[data-theme="dark"] .badge-ok { background:rgba(78,201,176,.15); color:#4ec9b0; }
.dur-pill {
display:inline-block;
background: rgba(15,76,117,.08);
color: var(--accent);
padding:.15rem .5rem;
border-radius:5px;
font-weight:600;
font-size:.82rem;
}
.row-unregistered td { background: rgba(220,53,69,.03); }
/* action buttons */
.btn-link-action {
border: none;
background: none;
cursor: pointer;
color: var(--text-secondary);
padding: .2rem .4rem;
border-radius: 4px;
font-size: .85rem;
transition: all .15s;
}
.btn-link-action:hover { background: rgba(15,76,117,.1); color: var(--accent); }
/* ─── Sessions table: rows are clickable ────────────────────── */
.sessions-table tbody tr { cursor: pointer; }
.sessions-table tbody tr:hover td { background: rgba(15,76,117,.06); }
[data-theme="dark"] .sessions-table tbody tr:hover td { background: rgba(86,156,214,.08); }
/* ─── Right-side session drawer ─────────────────────────────── */
.drawer-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.35);
z-index: 1039;
}
.drawer-backdrop.open { display: block; }
.session-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(500px, 100vw);
background: var(--bg-card);
z-index: 1040;
transform: translateX(105%);
transition: transform .28s cubic-bezier(.4, 0, .2, 1);
display: flex;
flex-direction: column;
box-shadow: -12px 0 48px rgba(0,0,0,.2);
overflow: hidden;
}
.session-drawer.open { transform: translateX(0); }
.reg-header {
padding: 1.2rem 1.4rem .9rem;
border-bottom: 1px solid rgba(0,0,0,.08);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-shrink: 0;
}
[data-theme="dark"] .reg-header { border-color: rgba(255,255,255,.08); }
.reg-header h5 { font-weight:700; margin:0; font-size:1.1rem; }
.reg-close { background:none; border:none; cursor:pointer; color:var(--text-secondary); font-size:1.4rem; line-height:1; padding:0; }
.reg-close:hover { color: var(--text-primary); }
/* session context bar */
.reg-context {
background: rgba(15,76,117,.06);
border-bottom: 1px solid rgba(0,0,0,.07);
padding: .8rem 1.4rem;
display: flex;
flex-wrap: wrap;
gap: .6rem 1.2rem;
align-items: center;
flex-shrink: 0;
}
[data-theme="dark"] .reg-context { background: rgba(86,156,214,.08); }
.ctx-item { display:flex; flex-direction:column; gap:.1rem; }
.ctx-label { font-size:.72rem; color:var(--text-secondary); font-weight:500; text-transform:uppercase; letter-spacing:.04em; }
.ctx-value { font-size:.9rem; font-weight:600; }
.ctx-badge { display:inline-flex; align-items:center; gap:.3rem; padding:.2rem .55rem; border-radius:5px; font-size:.78rem; font-weight:500; }
.ctx-tech { background:rgba(40,167,69,.12); color:#1a6e30; }
[data-theme="dark"] .ctx-tech { background:rgba(78,201,176,.15); color:#4ec9b0; }
.ctx-unknown { background:rgba(220,53,69,.1); color:#dc3545; }
[data-theme="dark"] .ctx-unknown { background:rgba(255,100,100,.15); color:#ff8080; }
/* form body */
.reg-body {
padding: 1.1rem 1.4rem;
display: flex;
flex-direction: column;
gap: .9rem;
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.reg-label {
font-size:.8rem;
font-weight:600;
color:var(--text-secondary);
text-transform:uppercase;
letter-spacing:.04em;
margin-bottom:.3rem;
display:block;
}
.reg-body select,
.reg-body input,
.reg-body textarea {
width:100%;
background: var(--bg-body);
color: var(--text-primary);
border: 1px solid rgba(0,0,0,.18);
border-radius: 7px;
padding: .45rem .75rem;
font-size: .88rem;
}
[data-theme="dark"] .reg-body select,
[data-theme="dark"] .reg-body input,
[data-theme="dark"] .reg-body textarea { border-color: rgba(255,255,255,.15); }
.reg-body textarea { resize:vertical; min-height:60px; }
/* quick pick typeahead */
.quick-pick {
margin-top: .3rem;
border: 1px solid rgba(0,0,0,.12);
border-radius: 7px;
background: var(--bg-card);
max-height: 180px;
overflow-y: auto;
}
[data-theme="dark"] .quick-pick { border-color: rgba(255,255,255,.12); }
.quick-item {
padding: .45rem .65rem;
border-top: 1px solid rgba(0,0,0,.06);
cursor: pointer;
font-size: .84rem;
}
[data-theme="dark"] .quick-item { border-top-color: rgba(255,255,255,.06); }
.quick-item:first-child { border-top: none; }
.quick-item:hover { background: rgba(15,76,117,.08); }
.quick-item .sub { color: var(--text-secondary); font-size: .76rem; }
/* Sag search */
.sag-search-wrap { position:relative; }
.sag-results {
position:absolute;
top:100%;
left:0;
right:0;
background: var(--bg-card);
border: 1px solid rgba(0,0,0,.15);
border-radius: 0 0 8px 8px;
max-height: 200px;
overflow-y: auto;
z-index: 10;
box-shadow: 0 8px 24px rgba(0,0,0,.15);
display:none;
}
.sag-results.open { display:block; }
.sag-result-item {
padding:.55rem .9rem;
cursor:pointer;
border-top: 1px solid rgba(0,0,0,.06);
font-size:.88rem;
display:flex;
justify-content:space-between;
align-items:center;
gap:.5rem;
}
.sag-result-item:hover { background:rgba(15,76,117,.07); }
.sag-result-item:first-child { border-top:none; }
.sag-status-badge { font-size:.72rem; padding:.15rem .5rem; border-radius:4px; white-space:nowrap; }
.sag-st-open { background:rgba(40,167,69,.12); color:#1a6e30; }
.sag-st-other { background:rgba(0,0,0,.08); color:var(--text-secondary); }
/* suggestion card inside body */
.hw-suggestion {
border: 1.5px solid rgba(15,76,117,.3);
border-radius: 10px;
padding: .75rem .9rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: .8rem;
background: rgba(15,76,117,.04);
cursor: pointer;
transition: background .15s;
}
.hw-suggestion:hover { background: rgba(15,76,117,.1); }
.hw-suggestion .sug-label { font-size:.73rem; color:var(--text-secondary); margin-bottom:.12rem; }
.hw-suggestion .sug-value { font-weight:600; font-size:.9rem; }
.hw-suggestion.confirmed { border-color:#28a745; background:rgba(40,167,69,.06); cursor:default; }
/* reg footer */
.reg-footer {
padding: .9rem 1.4rem;
border-top: 1px solid rgba(0,0,0,.08);
display: flex;
justify-content: flex-end;
gap: .6rem;
flex-shrink: 0;
}
[data-theme="dark"] .reg-footer { border-color: rgba(255,255,255,.08); }
/* ─── Loading state ──────────────────────────────────────────── */
.spinner { width:20px; height:20px; border:3px solid rgba(15,76,117,.2); border-top-color:var(--accent); border-radius:50%; animation:spin .7s linear infinite; display:inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ─── Toast ──────────────────────────────────────────────────── */
.toast-container { position:fixed; bottom:1.5rem; right:1.5rem; z-index:9999; display:flex; flex-direction:column; gap:.5rem; }
.toast-msg { background:var(--accent); color:white; padding:.7rem 1.2rem; border-radius:8px; font-size:.9rem; font-weight:500; animation:fadeIn .2s; }
.toast-msg.error { background:#dc3545; }
.toast-msg.success { background:#28a745; }
@keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:none} }
< / style >
{% endblock %}
{% block content %}
< div class = "container-fluid px-3 px-md-4 py-3" >
<!-- Header -->
< div class = "page-header" >
< h1 > < i class = "bi bi-display me-2" style = "color:var(--accent)" > < / i > AnyDesk Sessions< / h1 >
< div class = "d-flex gap-2 flex-wrap" >
< button class = "btn btn-sm btn-outline-secondary" onclick = "fetchFromApi()" id = "btn-fetch" >
< i class = "bi bi-cloud-download me-1" > < / i > Hent fra AnyDesk
< / button >
< button class = "btn btn-sm btn-primary" onclick = "autoLink()" id = "btn-autolink" >
< i class = "bi bi-link-45deg me-1" > < / i > Auto-link hardware
< / button >
< / div >
< / div >
<!-- Stats -->
< div class = "stat-grid" id = "stat-area" >
< div class = "stat-card" > < div class = "label" > Sessions i alt< / div > < div class = "value" id = "s-total" > – < / div > < / div >
< div class = "stat-card warn" > < div class = "label" > Uden sag/kontakt< / div > < div class = "value" id = "s-unregistered" > – < / div > < / div >
< div class = "stat-card ok" > < div class = "label" > Total tid (min)< / div > < div class = "value" id = "s-duration" > – < / div > < / div >
< div class = "stat-card" > < div class = "label" > Unikke enheder< / div > < div class = "value" id = "s-devices" > – < / div > < / div >
< / div >
<!-- Controls -->
< div class = "controls-bar" >
< label class = "me-1" style = "font-size:.85rem;font-weight:500" > Periode:< / label >
< select id = "filter-days" onchange = "loadSessions()" >
< option value = "30" > Seneste 30 dage< / option >
< option value = "60" > Seneste 60 dage< / option >
< option value = "90" selected > Seneste 90 dage< / option >
< option value = "365" > Seneste år< / option >
< / select >
< label class = "ms-2 me-1" style = "font-size:.85rem;font-weight:500" > Vis:< / label >
< select id = "filter-unreg" onchange = "loadSessions()" >
< option value = "0" > Alle sessions< / option >
< option value = "1" > Kun uregistrerede< / option >
< / select >
< input type = "text" id = "filter-search" placeholder = "Søg remote ID, hardware, navn…"
style="min-width:200px;" oninput="filterTable()" />
< span class = "ms-auto" id = "loading-indicator" style = "display:none" >
< span class = "spinner" > < / span >
< / span >
< span class = "text-secondary" style = "font-size:.82rem" id = "row-count" > < / span >
< / div >
<!-- Table -->
< div class = "sessions-table-wrap" >
< table class = "sessions-table" id = "sessions-table" >
< thead >
< tr >
< th > Tidspunkt< / th >
< th > Varighed< / th >
< th > Remote ID< / th >
< th > Teknikker< / th >
< th > Hardware< / th >
< th > Kunde< / th >
< th > Kontakt< / th >
< th > Sag< / th >
< th > Status< / th >
< th > < / th >
< / tr >
< / thead >
< tbody id = "sessions-tbody" >
< tr > < td colspan = "9" class = "text-center py-4 text-secondary" > Indlæser…< / td > < / tr >
< / tbody >
< / table >
< / div >
< / div >
<!-- Drawer backdrop -->
< div class = "drawer-backdrop" id = "drawer-backdrop" onclick = "closeRegModal(null)" > < / div >
<!-- Right - side session drawer -->
< div class = "session-drawer" id = "session-drawer" >
<!-- Header -->
< div class = "reg-header" >
< div >
< h5 style = "font-weight:700;margin:0;font-size:1rem" >
< i class = "bi bi-display me-2" style = "color:var(--accent)" > < / i > Session
< span id = "reg-session-id-label" style = "color:var(--text-secondary);font-weight:500" > < / span >
< / h5 >
< / div >
< button class = "reg-close" onclick = "closeRegModal(null)" title = "Luk (Esc)" > × < / button >
< / div >
<!-- Context bar -->
< div class = "reg-context" >
< div class = "ctx-item" >
< span class = "ctx-label" > Tidspunkt< / span >
< span class = "ctx-value" id = "ctx-dt" > – < / span >
< / div >
< div class = "ctx-item" >
< span class = "ctx-label" > Varighed< / span >
< span class = "ctx-value" id = "ctx-dur" > – < / span >
< / div >
< div class = "ctx-item" >
< span class = "ctx-label" > Maskine-ID< / span >
< span class = "ctx-value font-monospace" style = "font-size:.82rem" id = "ctx-machine" > – < / span >
< / div >
< div class = "ctx-item" >
< span class = "ctx-label" > Teknikker< / span >
< span id = "ctx-tech" > < / span >
< / div >
< / div >
<!-- Scrollable body -->
< div class = "reg-body" >
< input type = "hidden" id = "reg-session-id" / >
< input type = "hidden" id = "reg-hw-id" / >
< input type = "hidden" id = "reg-sag-id" / >
<!-- Hardware suggestion -->
< div id = "reg-hw-suggestion" style = "display:none" onclick = "confirmHwSuggestion()" >
< div class = "hw-suggestion" id = "reg-hw-suggestion-inner" >
< div >
< div class = "sug-label" > < i class = "bi bi-magic me-1" > < / i > Foreslået hardware< / div >
< div class = "sug-value" id = "reg-sug-hw-name" > < / div >
< div style = "font-size:.76rem;color:var(--text-secondary)" id = "reg-sug-hw-cust" > < / div >
< / div >
< button class = "btn btn-sm btn-primary" id = "reg-sug-btn" > Bekræft< / button >
< / div >
< / div >
<!-- Hardware (manual) -->
< div id = "reg-hw-manual-wrap" >
< label class = "reg-label" > < i class = "bi bi-pc-display me-1" > < / i > Hardware< / label >
< input type = "text" id = "reg-hw-search" placeholder = "Søg hardware (brand/model/AnyDesk ID)" oninput = "filterHwOptions()" onfocus = "filterHwOptions()" >
< div class = "quick-pick" id = "reg-hw-quick" style = "display:none" > < / div >
< select id = "reg-hw-select" onchange = "onHwChange()" >
< option value = "" > – vælg hardware – < / option >
< / select >
< / div >
<!-- Kunde -->
< div >
< label class = "reg-label" > < i class = "bi bi-building me-1" > < / i > Kunde< / label >
< input type = "text" id = "reg-customer-search" placeholder = "Søg firma" oninput = "filterCustomerOptions()" onfocus = "filterCustomerOptions()" >
< div class = "quick-pick" id = "reg-customer-quick" style = "display:none" > < / div >
< select id = "reg-customer" onchange = "onCustomerChange()" >
< option value = "" > – vælg kunde – < / option >
< / select >
2026-10-20 07:18:57 +02:00
< div class = "small text-muted mt-1" > AnyDesk-ID gemmes på kontaktpersonens hardware. Findes maskinen ikke, oprettes en PC under firmaet. Koblingen bruges også på tidligere uregistrerede sessioner. Sag og noter gælder kun denne session.< / div >
2026-03-30 07:50:15 +02:00
< / div >
<!-- Kontakt -->
< div >
< label class = "reg-label" > < i class = "bi bi-person me-1" > < / i > Kontakt< / label >
< input type = "text" id = "reg-contact-search" placeholder = "Søg kontakt" oninput = "filterContactOptions()" onfocus = "filterContactOptions()" >
< div class = "quick-pick" id = "reg-contact-quick" style = "display:none" > < / div >
< select id = "reg-contact" >
< option value = "" > – ingen / vælg – < / option >
< / select >
< / div >
<!-- Sag live search -->
< div >
< label class = "reg-label" > < i class = "bi bi-card-text me-1" > < / i > Sag< / label >
< div class = "sag-search-wrap" >
< input type = "text" id = "reg-sag-search" placeholder = "Søg sag efter titel eller #ID…"
autocomplete="off" oninput="onSagSearch()" onfocus="onSagSearch()">
< div class = "sag-results" id = "sag-results" > < / div >
< / div >
< div id = "reg-sag-selected" style = "display:none;margin-top:.4rem" >
< span class = "badge-link badge-sag" style = "font-size:.82rem" >
< i class = "bi bi-card-text" > < / i >
< span id = "reg-sag-selected-label" > < / span >
< button type = "button" onclick = "clearSagSelection()"
style="background:none;border:none;cursor:pointer;opacity:.7;padding:0;color:inherit">× < / button >
< / span >
< / div >
< / div >
<!-- Notat -->
< div >
< label class = "reg-label" > < i class = "bi bi-chat-left-text me-1" > < / i > Notat< / label >
< textarea id = "reg-notes" placeholder = "Hvad blev lavet? (valgfri)" > < / textarea >
< / div >
< / div >
<!-- Footer -->
< div class = "reg-footer" >
< button class = "btn btn-secondary btn-sm" onclick = "closeRegModal(null)" > Luk< / button >
< button class = "btn btn-primary btn-sm" onclick = "saveRegModal()" >
< i class = "bi bi-check-lg me-1" > < / i > Gem registrering
< / button >
< / div >
< / div >
<!-- Toast container -->
< div class = "toast-container" id = "toast-container" > < / div >
{% endblock %}
{% block extra_js %}
< script >
/* ─── State ──────────────────────────────────────────────────── */
let allSessions = [];
let hardwareList = [];
let customerList = [];
let currentContactList = [];
/* ─── Init ───────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', async () => {
await Promise.all([loadSessions(), loadHardware(), loadCustomers()]);
});
/* ─── API helpers ────────────────────────────────────────────── */
async function api(url, opts = {}) {
const res = await fetch(url, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
/* ─── Load sessions ──────────────────────────────────────────── */
async function loadSessions() {
const days = document.getElementById('filter-days').value;
const unreg = document.getElementById('filter-unreg').value;
document.getElementById('loading-indicator').style.display = '';
try {
const data = await api(
`/api/v1/anydesk/sessions-overview?days=${days}& unregistered_only=${unreg === '1'}& limit=500`
);
allSessions = data.sessions || [];
renderStats(allSessions);
filterTable();
} catch (e) {
toast('Kunne ikke hente sessions: ' + e.message, 'error');
} finally {
document.getElementById('loading-indicator').style.display = 'none';
}
}
/* ─── Stats ──────────────────────────────────────────────────── */
function renderStats(sessions) {
const unreg = sessions.filter(s => !s.sag & & !s.contact & & !s.hardware).length;
const totalMin = sessions.reduce((a, s) => a + (s.duration_minutes || 0), 0);
const devices = new Set(sessions.map(s => s.customer_machine_id || s.remote_id).filter(Boolean)).size;
document.getElementById('s-total').textContent = sessions.length;
document.getElementById('s-unregistered').textContent = unreg;
document.getElementById('s-duration').textContent = totalMin;
document.getElementById('s-devices').textContent = devices;
}
/* ─── Filter + render ────────────────────────────────────────── */
function filterTable() {
const q = document.getElementById('filter-search').value.toLowerCase().trim();
const rows = q
? allSessions.filter(s =>
(s.customer_machine_id || s.remote_id || '').includes(q) ||
(s.customer_alias || s.remote_alias || '').toLowerCase().includes(q) ||
(s.hardware?.model || '').toLowerCase().includes(q) ||
(s.hardware?.brand || '').toLowerCase().includes(q) ||
(s.customer?.name || '').toLowerCase().includes(q) ||
(s.contact?.name || '').toLowerCase().includes(q) ||
(s.sag?.titel || '').toLowerCase().includes(q)
)
: allSessions;
document.getElementById('row-count').textContent = `${rows.length} sessions`;
renderTable(rows);
}
/* ─── Render table ───────────────────────────────────────────── */
function renderTable(sessions) {
const tbody = document.getElementById('sessions-tbody');
if (!sessions.length) {
tbody.innerHTML = '< tr > < td colspan = "10" class = "text-center py-5 text-secondary" > Ingen sessions fundet< / td > < / tr > ';
return;
}
tbody.innerHTML = sessions.map(s => {
const isUnreg = !s.sag & & !s.contact & & !s.hardware;
const rowCls = isUnreg ? 'row-unregistered' : '';
const dt = s.started_at ? new Date(s.started_at) : null;
const dtStr = dt
? dt.toLocaleDateString('da-DK', {day:'2-digit',month:'short',year:'numeric'}) +
' ' + dt.toLocaleTimeString('da-DK', {hour:'2-digit',minute:'2-digit'})
: '– ';
const dur = s.duration_minutes != null
? `< span class = "dur-pill" > ${s.duration_minutes} min< / span > `
: `< span class = "text-secondary" > – < / span > `;
// customer_machine_id = to.cid (kundens maskine), remote_alias = fra.alias (teknikkerens alias)
const machineId = s.customer_machine_id || s.remote_id;
const machineAlias = s.customer_alias;
const remoteId = machineId
? `< code style = "font-size:.78rem" > ${machineId}< / code > ${machineAlias ? `< br > < small class = "text-secondary" > ${machineAlias}< / small > ` : ''}`
: `< span class = "text-secondary" > – < / span > `;
const techCell = s.technician_name
? `< span class = "badge-link badge-cust" > < i class = "bi bi-person-badge" > < / i > ${s.technician_name}< / span > `
: s.remote_alias
? `< span style = "font-size:.82rem" > ${s.remote_alias}< / span > `
: s.technician_id
? `< code style = "font-size:.78rem" > ${s.technician_id}< / code > `
: `< span class = "text-secondary" style = "font-size:.78rem" > – < / span > `;
const hwCell = s.hardware
? `< a href = "/hardware/${s.hardware.id}" class = "badge-link badge-hw" > < i class = "bi bi-pc-display" > < / i > ${s.hardware.brand||''} ${s.hardware.model||''}< / a > `
: `< span class = "text-secondary" style = "font-size:.78rem" > – < / span > `;
const custCell = (s.customer)
? `< a href = "/customers/${s.customer.id}" class = "badge-link badge-cust" > < i class = "bi bi-building" > < / i > ${s.customer.name}< / a > `
: `< span class = "text-secondary" style = "font-size:.78rem" > – < / span > `;
const contCell = s.contact
? `< a href = "/contacts/${s.contact.id}" class = "badge-link badge-cont" > < i class = "bi bi-person" > < / i > ${s.contact.name}< / a > `
: `< span class = "text-secondary" style = "font-size:.78rem" > – < / span > `;
const sagCell = s.sag
feat: Update sag links to include versioning in URLs across multiple templates and services
- Updated links in index_old.html, varekob_salg.html, log.html, opportunities.html, detail.html, and various frontend files to point to the new versioned sag URLs.
- Modified reminder_notification_service.py to reflect the new sag URL structure in notifications.
- Added FedEx shipment management functionality, including API client, service layer, and router for handling FedEx bookings, tracking, and cancellations.
- Created database migration for FedEx shipments, including tables for shipments, packages, and tracking events.
2026-04-30 23:06:00 +02:00
? `< a href = "/sag/${s.sag.id}/v3" class = "badge-link badge-sag" > < i class = "bi bi-card-text" > < / i > ${s.sag.titel||'Sag #'+s.sag.id}< / a > `
2026-03-30 07:50:15 +02:00
: `< span class = "text-secondary" style = "font-size:.78rem" > – < / span > `;
const statusBadge = isUnreg
? `< span class = "badge-unreg" > Uregistreret< / span > `
: `< span class = "badge-ok" > Registreret< / span > `;
return `< tr class = "${rowCls}" data-id = "${s.id}" onclick = "openRegModal(${s.id})" >
< td style = "white-space:nowrap" > ${dtStr}< / td >
< td > ${dur}< / td >
< td > ${remoteId}< / td >
< td > ${techCell}< / td >
< td > ${hwCell}< / td >
< td > ${custCell}< / td >
< td > ${contCell}< / td >
< td > ${sagCell}< / td >
< td > ${statusBadge}< / td >
< td style = "text-align:right;padding-right:1rem" >
< i class = "bi bi-chevron-right" style = "color:var(--text-secondary);font-size:.78rem" > < / i >
< / td >
< / tr > `;
}).join('');
}
/* ─── Fetch from AnyDesk API ─────────────────────────────────── */
async function fetchFromApi() {
const btn = document.getElementById('btn-fetch');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '< span class = "spinner" > < / span > Henter…';
try {
const data = await api('/api/v1/anydesk/fetch-from-api?days=90& limit=1000', { method: 'POST' });
toast(`✅ ${data.imported} nye, ${data.updated} opdateret`, 'success');
await loadSessions();
} catch (e) {
toast('Fejl: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
}
}
/* ─── Auto-link ──────────────────────────────────────────────── */
async function autoLink() {
const btn = document.getElementById('btn-autolink');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '< span class = "spinner" > < / span > Linker…';
try {
const data = await api('/api/v1/anydesk/auto-link', { method: 'POST' });
toast(`✅ ${data.linked} sessions linket til hardware`, 'success');
await loadSessions();
} catch (e) {
toast('Fejl: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
}
}
/* ─── Load hardware + customers for dropdowns ────────────────── */
async function loadHardware() {
try {
const data = await api('/api/v1/anydesk/hardware-assets');
hardwareList = data.assets || [];
} catch(e) { /* silent */ }
}
async function loadCustomers() {
try {
2026-10-20 07:18:57 +02:00
const data = await api('/api/v1/customers/?limit=500&is_active=true');
customerList = (data.customers || data || []).filter(customer => customer.is_active === true);
2026-03-30 07:50:15 +02:00
} catch(e) { /* silent */ }
}
function populateHwSelect(selectedId) {
const sel = document.getElementById('reg-hw-select');
sel.innerHTML = '< option value = "" > – vælg hardware – < / option > ';
hardwareList.forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.brand||''} ${a.model||''} (AD: ${a.anydesk_id||'– '}) – ${a.customer_name||'– '}`;
if (a.id == selectedId) opt.selected = true;
sel.appendChild(opt);
});
}
function filterHwOptions() {
const q = (document.getElementById('reg-hw-search').value || '').toLowerCase().trim();
const selectedId = document.getElementById('reg-hw-select').value;
const sel = document.getElementById('reg-hw-select');
sel.innerHTML = '< option value = "" > – vælg hardware – < / option > ';
const matches = hardwareList
.filter(a => {
if (!q) return true;
const hay = `${a.brand || ''} ${a.model || ''} ${a.anydesk_id || ''} ${a.customer_name || ''}`.toLowerCase();
return hay.includes(q);
});
matches.forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.brand||''} ${a.model||''} (AD: ${a.anydesk_id||'– '}) – ${a.customer_name||'– '}`;
if (String(a.id) === String(selectedId)) opt.selected = true;
sel.appendChild(opt);
});
renderHwQuick(matches.slice(0, 8));
}
function renderHwQuick(items) {
const box = document.getElementById('reg-hw-quick');
if (!items.length) {
box.style.display = 'none';
box.innerHTML = '';
return;
}
box.innerHTML = items.map(a => `
< div class = "quick-item" onclick = "pickHardware(${a.id})" >
< div > ${a.brand || ''} ${a.model || ''}< / div >
< div class = "sub" > AD: ${a.anydesk_id || '– '} · ${a.customer_name || '– '}< / div >
< / div >
`).join('');
box.style.display = '';
}
function pickHardware(hwId) {
const hw = hardwareList.find(a => String(a.id) === String(hwId));
if (!hw) return;
document.getElementById('reg-hw-select').value = hw.id;
document.getElementById('reg-hw-search').value = `${hw.brand || ''} ${hw.model || ''}`.trim();
document.getElementById('reg-hw-quick').style.display = 'none';
onHwChange();
}
function populateCustomerSelect(selectedId) {
const sel = document.getElementById('reg-customer');
sel.innerHTML = '< option value = "" > – vælg kunde – < / option > ';
customerList.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
if (c.id == selectedId) opt.selected = true;
sel.appendChild(opt);
});
}
function filterCustomerOptions() {
const q = (document.getElementById('reg-customer-search').value || '').toLowerCase().trim();
const selectedId = document.getElementById('reg-customer').value;
const sel = document.getElementById('reg-customer');
sel.innerHTML = '< option value = "" > – vælg kunde – < / option > ';
const matches = customerList
.filter(c => !q || (c.name || '').toLowerCase().includes(q))
;
matches.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
if (String(c.id) === String(selectedId)) opt.selected = true;
sel.appendChild(opt);
});
renderCustomerQuick(matches.slice(0, 8));
}
function renderCustomerQuick(items) {
const box = document.getElementById('reg-customer-quick');
if (!items.length) {
box.style.display = 'none';
box.innerHTML = '';
return;
}
box.innerHTML = items.map(c => `
< div class = "quick-item" onclick = "pickCustomer(${c.id})" >
< div > ${c.name || '– '}< / div >
< / div >
`).join('');
box.style.display = '';
}
function pickCustomer(customerId) {
const customer = customerList.find(c => String(c.id) === String(customerId));
if (!customer) return;
document.getElementById('reg-customer').value = customer.id;
document.getElementById('reg-customer-search').value = customer.name || '';
document.getElementById('reg-customer-quick').style.display = 'none';
onCustomerChange();
}
/* ─── Smart registration modal ───────────────────────────────── */
let _sagSearchTimer = null;
let _regSuggestedHwId = null;
function openRegModal(sessionId) {
const s = allSessions.find(x => x.id === sessionId);
if (!s) return;
// Reset state
_regSuggestedHwId = null;
document.getElementById('reg-session-id').value = sessionId;
document.getElementById('reg-hw-id').value = '';
document.getElementById('reg-sag-id').value = '';
document.getElementById('reg-sag-search').value = '';
document.getElementById('sag-results').innerHTML = '';
document.getElementById('sag-results').classList.remove('open');
document.getElementById('reg-sag-selected').style.display = 'none';
document.getElementById('reg-sag-search').style.display = '';
document.getElementById('reg-notes').value = s.notes || '';
document.getElementById('reg-session-id-label').textContent = '#' + sessionId;
document.getElementById('reg-hw-search').value = '';
document.getElementById('reg-customer-search').value = '';
document.getElementById('reg-contact-search').value = '';
// Context bar
const dt = s.started_at ? new Date(s.started_at) : null;
document.getElementById('ctx-dt').textContent = dt
? dt.toLocaleDateString('da-DK', {day:'2-digit', month:'short', year:'numeric'}) +
' ' + dt.toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})
: '– ';
document.getElementById('ctx-dur').textContent =
s.duration_minutes != null ? s.duration_minutes + ' min' : '– ';
document.getElementById('ctx-machine').textContent =
s.customer_machine_id || s.remote_id || '– ';
// Technician badge
const techEl = document.getElementById('ctx-tech');
if (s.technician_name) {
techEl.innerHTML = `< span class = "ctx-badge ctx-tech" > 👤 ${s.technician_name}< / span > `;
} else if (s.remote_alias) {
techEl.innerHTML = `< span class = "ctx-badge ctx-tech" > 👤 ${s.remote_alias}< / span > `;
} else if (s.technician_id) {
techEl.innerHTML = `< span class = "ctx-badge ctx-unknown" > ID ${s.technician_id}< / span > `;
} else {
techEl.innerHTML = `< span class = "ctx-badge ctx-unknown" > Ukendt< / span > `;
}
// Populate selects
populateHwSelect(s.hardware?.id);
populateCustomerSelect(s.customer?.id);
loadContactsForCustomer(s.customer?.id, s.contact?.id);
// Pre-fill sag if already linked
if (s.sag) {
document.getElementById('reg-sag-id').value = s.sag.id;
document.getElementById('reg-sag-selected-label').textContent =
'#' + s.sag.id + (s.sag.titel ? ' — ' + s.sag.titel : '');
document.getElementById('reg-sag-selected').style.display = '';
document.getElementById('reg-sag-search').style.display = 'none';
}
// Hardware suggestion
document.getElementById('reg-hw-suggestion').style.display = 'none';
document.getElementById('reg-hw-manual-wrap').style.display = '';
const machineId = s.customer_machine_id || s.remote_id;
if (machineId) {
const match = hardwareList.find(a => a.anydesk_id & & a.anydesk_id === machineId);
if (match & & !s.hardware) {
_regSuggestedHwId = match.id;
document.getElementById('reg-sug-hw-name').textContent =
(match.brand || '') + ' ' + (match.model || '');
document.getElementById('reg-sug-hw-cust').textContent =
match.customer_name ? '📍 ' + match.customer_name : '';
document.getElementById('reg-hw-suggestion').style.display = '';
// Hide manual picker when suggestion shown and approved
} else if (match & & s.hardware & & match.id === s.hardware.id) {
// Already linked to this hw — show as confirmed
_regSuggestedHwId = match.id;
document.getElementById('reg-sug-hw-name').textContent =
(match.brand || '') + ' ' + (match.model || '');
document.getElementById('reg-sug-hw-cust').textContent =
match.customer_name ? '📍 ' + match.customer_name : '';
document.getElementById('reg-hw-suggestion').style.display = '';
document.getElementById('reg-hw-suggestion-inner').classList.add('confirmed');
document.getElementById('reg-sug-btn').style.display = 'none';
document.getElementById('reg-hw-manual-wrap').style.display = 'none';
}
}
// If already linked hardware (no suggestion path)
if (s.hardware) {
document.getElementById('reg-hw-select').value = s.hardware.id;
document.getElementById('reg-hw-id').value = s.hardware.id;
}
document.getElementById('drawer-backdrop').classList.add('open');
document.getElementById('session-drawer').classList.add('open');
}
function confirmHwSuggestion() {
if (!_regSuggestedHwId) return;
const inner = document.getElementById('reg-hw-suggestion-inner');
if (inner.classList.contains('confirmed')) return;
inner.classList.add('confirmed');
document.getElementById('reg-sug-btn').style.display = 'none';
document.getElementById('reg-hw-manual-wrap').style.display = 'none';
document.getElementById('reg-hw-id').value = _regSuggestedHwId;
document.getElementById('reg-hw-select').value = _regSuggestedHwId;
// Auto-fill customer from hardware asset
const hw = hardwareList.find(a => a.id == _regSuggestedHwId);
if (hw & & hw.customer_id) {
document.getElementById('reg-customer').value = hw.customer_id;
loadContactsForCustomer(hw.customer_id, null);
}
}
function onHwChange() {
const hwId = document.getElementById('reg-hw-select').value;
document.getElementById('reg-hw-id').value = hwId;
if (!hwId) return;
const hw = hardwareList.find(a => a.id == hwId);
if (hw & & hw.customer_id) {
document.getElementById('reg-customer').value = hw.customer_id;
loadContactsForCustomer(hw.customer_id, null);
}
}
async function onCustomerChange() {
const customerId = document.getElementById('reg-customer').value;
await loadContactsForCustomer(customerId, null);
// Reset sag search filter
document.getElementById('reg-sag-search').value = '';
document.getElementById('sag-results').innerHTML = '';
document.getElementById('sag-results').classList.remove('open');
}
function filterContactOptions() {
const q = (document.getElementById('reg-contact-search').value || '').toLowerCase().trim();
const selectedId = document.getElementById('reg-contact').value;
const sel = document.getElementById('reg-contact');
sel.innerHTML = '< option value = "" > – ingen / vælg – < / option > ';
const matches = currentContactList
.filter(c => {
if (!q) return true;
const fullName = `${c.first_name || ''} ${c.last_name || ''}`.toLowerCase();
const email = (c.email || '').toLowerCase();
return fullName.includes(q) || email.includes(q);
});
matches.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = ((c.first_name||'') + ' ' + (c.last_name||'')).trim();
if (c.email) opt.textContent += ` (${c.email})`;
if (String(c.id) === String(selectedId)) opt.selected = true;
sel.appendChild(opt);
});
renderContactQuick(matches.slice(0, 8));
}
function renderContactQuick(items) {
const box = document.getElementById('reg-contact-quick');
if (!items.length) {
box.style.display = 'none';
box.innerHTML = '';
return;
}
box.innerHTML = items.map(c => {
const fullName = `${c.first_name || ''} ${c.last_name || ''}`.trim() || '(ingen navn)';
return `
< div class = "quick-item" onclick = "pickContact(${c.id})" >
< div > ${fullName}< / div >
< div class = "sub" > ${c.email || ''}< / div >
< / div > `;
}).join('');
box.style.display = '';
}
function pickContact(contactId) {
const contact = currentContactList.find(c => String(c.id) === String(contactId));
if (!contact) return;
document.getElementById('reg-contact').value = contact.id;
document.getElementById('reg-contact-search').value = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
document.getElementById('reg-contact-quick').style.display = 'none';
}
async function loadContactsForCustomer(customerId, selectedId) {
const sel = document.getElementById('reg-contact');
sel.innerHTML = '< option value = "" > – ingen / vælg – < / option > ';
try {
const url = customerId
2026-10-20 07:18:57 +02:00
? `/api/v1/contacts/?customer_id=${customerId}& limit=100& is_active=true`
: '/api/v1/contacts/?limit=100&is_active=true';
2026-03-30 07:50:15 +02:00
const data = await api(url);
currentContactList = data.contacts || data || [];
filterContactOptions();
if (selectedId) {
document.getElementById('reg-contact').value = selectedId;
}
} catch(e) { /* silent */ }
}
function onSagSearch() {
clearTimeout(_sagSearchTimer);
const q = document.getElementById('reg-sag-search').value.trim();
if (!q) {
document.getElementById('sag-results').innerHTML = '';
document.getElementById('sag-results').classList.remove('open');
return;
}
_sagSearchTimer = setTimeout(async () => {
const customerId = document.getElementById('reg-customer').value;
let url = `/api/v1/sag?q=${encodeURIComponent(q)}&limit=8`;
if (customerId) url += `&customer_id=${customerId}`;
try {
const data = await api(url);
const sager = data.sager || data || [];
const resultsEl = document.getElementById('sag-results');
if (!sager.length) {
resultsEl.innerHTML = '< div class = "sag-result-item text-secondary" > Ingen sager fundet< / div > ';
} else {
resultsEl.innerHTML = sager.map(sag => {
const statusClass = (sag.status||'').toLowerCase().includes('åben') ||
(sag.status||'').toLowerCase().includes('open')
? 'sag-st-open' : 'sag-st-other';
const titel = sag.titel || sag.title || '(ingen titel)';
return `< div class = "sag-result-item"
onclick="selectSag(${sag.id}, ${JSON.stringify(titel)}, ${JSON.stringify(sag.status||'')})">
< span > #${sag.id} — ${titel}< / span >
${sag.status ? `< span class = "sag-status-badge ${statusClass}" > ${sag.status}< / span > ` : ''}
< / div > `;
}).join('');
}
resultsEl.classList.add('open');
} catch(e) { /* silent */ }
}, 280);
}
function selectSag(id, titel, status) {
document.getElementById('reg-sag-id').value = id;
const label = '#' + id + (titel ? ' — ' + titel : '');
document.getElementById('reg-sag-selected-label').textContent = label;
document.getElementById('reg-sag-selected').style.display = '';
document.getElementById('reg-sag-search').style.display = 'none';
document.getElementById('sag-results').classList.remove('open');
document.getElementById('reg-sag-search').value = '';
}
function clearSagSelection() {
document.getElementById('reg-sag-id').value = '';
document.getElementById('reg-sag-selected').style.display = 'none';
document.getElementById('reg-sag-search').style.display = '';
document.getElementById('reg-sag-search').value = '';
document.getElementById('reg-sag-search').focus();
}
function closeRegModal(event) {
document.getElementById('drawer-backdrop').classList.remove('open');
document.getElementById('session-drawer').classList.remove('open');
document.getElementById('sag-results').classList.remove('open');
document.getElementById('reg-hw-quick').style.display = 'none';
document.getElementById('reg-customer-quick').style.display = 'none';
document.getElementById('reg-contact-quick').style.display = 'none';
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeRegModal(null);
});
document.addEventListener('click', e => {
if (!e.target.closest('#reg-hw-manual-wrap')) {
document.getElementById('reg-hw-quick').style.display = 'none';
}
if (!e.target.closest('#reg-customer') & & !e.target.closest('#reg-customer-search') & & !e.target.closest('#reg-customer-quick')) {
document.getElementById('reg-customer-quick').style.display = 'none';
}
if (!e.target.closest('#reg-contact') & & !e.target.closest('#reg-contact-search') & & !e.target.closest('#reg-contact-quick')) {
document.getElementById('reg-contact-quick').style.display = 'none';
}
});
async function saveRegModal() {
const id = document.getElementById('reg-session-id').value;
const hw = document.getElementById('reg-hw-id').value ||
document.getElementById('reg-hw-select').value;
const cust = document.getElementById('reg-customer').value;
const contact = document.getElementById('reg-contact').value;
const sag = document.getElementById('reg-sag-id').value;
const notes = document.getElementById('reg-notes').value;
const params = new URLSearchParams();
if (hw) params.append('hardware_asset_id', hw);
if (cust) params.append('customer_id', cust);
2026-10-20 07:18:57 +02:00
if (cust) params.append('remember_machine_link', 'true');
2026-03-30 07:50:15 +02:00
if (contact) params.append('contact_id', contact);
if (sag) params.append('sag_id', sag);
if (notes) params.append('notes', notes);
try {
2026-10-20 07:18:57 +02:00
const result = await api(`/api/v1/anydesk/sessions/${id}/link?${params}`, { method: 'PATCH' });
toast(result.remembered
? `✅ AnyDesk-ID gemt på hardware · ${result.linked_sessions || 0} andre sessioner opdateret`
: '✅ Session registreret', 'success');
if (result.remembered) await loadHardware();
2026-03-30 07:50:15 +02:00
document.getElementById('drawer-backdrop').classList.remove('open');
document.getElementById('session-drawer').classList.remove('open');
await loadSessions();
} catch (e) {
toast('Fejl: ' + e.message, 'error');
}
}
/* ─── Toast ──────────────────────────────────────────────────── */
function toast(msg, type = '') {
const el = document.createElement('div');
el.className = 'toast-msg ' + type;
el.textContent = msg;
document.getElementById('toast-container').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
< / script >
{% endblock %}