bmc_hub/app/anydesk/frontend/sessions.html
Christian bc504b9257 feat: Add subscription management functionality and AnyDesk API integration
- Implemented subscription creation, updating, and rendering in script_9.js.
- Added functions for handling subscription line items, product selection, and total calculations.
- Integrated AnyDesk API for session management in test_anydesk.py.
- Created REST client test requests for API endpoints in api.http.
- Developed a script to check ESET machine status and save details in tmp_check_eset_machine.py.
2026-03-30 07:50:15 +02:00

1166 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% 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; }
.badge-hw { background:rgba(15,76,117,.12); color:#0f4c75; }
.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)">&times;</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>
</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">&times;</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
? `<a href="/sag/${s.sag.id}" class="badge-link badge-sag"><i class="bi bi-card-text"></i>${s.sag.titel||'Sag #'+s.sag.id}</a>`
: `<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 {
const data = await api('/api/v1/customers/?limit=500');
customerList = data.customers || data || [];
} 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
? `/api/v1/contacts/?customer_id=${customerId}&limit=300`
: '/api/v1/contacts/?limit=300';
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);
if (contact) params.append('contact_id', contact);
if (sag) params.append('sag_id', sag);
if (notes) params.append('notes', notes);
try {
await api(`/api/v1/anydesk/sessions/${id}/link?${params}`, { method: 'PATCH' });
toast('✅ Session registreret', 'success');
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 %}