2026-02-11 13:23:32 +01:00
|
|
|
{% extends "shared/frontend/base.html" %}
|
|
|
|
|
|
|
|
|
|
{% block title %}ESET Import - Hardware - BMC Hub{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block extra_css %}
|
|
|
|
|
<style>
|
|
|
|
|
.page-header {
|
|
|
|
|
margin-bottom: 2rem;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.section-card {
|
|
|
|
|
background: var(--bg-card);
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
|
|
|
padding: 1.5rem;
|
|
|
|
|
margin-bottom: 2rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.table thead th {
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.02em;
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.device-uuid {
|
|
|
|
|
max-width: 240px;
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.status-pill {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
padding: 0.35rem 0.6rem;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: var(--accent-light);
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.contact-results {
|
|
|
|
|
max-height: 220px;
|
|
|
|
|
overflow: auto;
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
background: var(--bg-body);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.contact-result {
|
|
|
|
|
padding: 0.6rem 0.8rem;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.contact-result:hover {
|
|
|
|
|
background: var(--accent-light);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.contact-muted {
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
}
|
2026-02-11 23:51:21 +01:00
|
|
|
|
|
|
|
|
.contact-result.active,
|
|
|
|
|
.contact-result:focus {
|
|
|
|
|
outline: none;
|
|
|
|
|
background: var(--accent-light);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.contact-chip {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
padding: 0.35rem 0.6rem;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: var(--accent-light);
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
margin-top: 0.5rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.devices-table {
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.devices-cards {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 07:03:18 +01:00
|
|
|
.show-cards .devices-table {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.show-cards .devices-cards {
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 23:51:21 +01:00
|
|
|
.device-card {
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
background: var(--bg-body);
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.device-card-title {
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
margin-bottom: 0.25rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.device-card-meta {
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.inline-results {
|
|
|
|
|
max-height: 180px;
|
|
|
|
|
overflow: auto;
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
background: var(--bg-body);
|
|
|
|
|
margin-top: 0.35rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.inline-result {
|
|
|
|
|
padding: 0.5rem 0.7rem;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.inline-result:hover,
|
|
|
|
|
.inline-result.active {
|
|
|
|
|
background: var(--accent-light);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: 991px) {
|
|
|
|
|
.devices-table {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.devices-cards {
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 13:23:32 +01:00
|
|
|
</style>
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
<div class="container-fluid" style="margin-top: 2rem; max-width: 1400px;">
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
<h1>⬇️ ESET Import</h1>
|
|
|
|
|
<div class="d-flex gap-2">
|
|
|
|
|
<a href="/hardware/eset" class="btn btn-outline-secondary">ESET Oversigt</a>
|
|
|
|
|
<a href="/hardware" class="btn btn-outline-secondary">Tilbage til hardware</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-12 07:03:18 +01:00
|
|
|
<div class="section-card" id="devicesSection">
|
2026-02-11 13:23:32 +01:00
|
|
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
|
|
|
|
<div class="status-pill" id="deviceStatus">Ingen data indlaest</div>
|
2026-02-12 07:03:18 +01:00
|
|
|
<div class="d-flex gap-2">
|
|
|
|
|
<button class="btn btn-outline-secondary" id="tabletToggle" onclick="toggleTabletView()">Tablet visning</button>
|
|
|
|
|
<button class="btn btn-primary" onclick="loadDevices()">Hent devices</button>
|
|
|
|
|
</div>
|
2026-02-11 13:23:32 +01:00
|
|
|
</div>
|
2026-02-11 23:51:21 +01:00
|
|
|
<div class="table-responsive devices-table">
|
2026-02-11 13:23:32 +01:00
|
|
|
<table class="table table-hover align-middle">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Navn</th>
|
|
|
|
|
<th>Serial</th>
|
|
|
|
|
<th>Gruppe</th>
|
|
|
|
|
<th>Device UUID</th>
|
|
|
|
|
<th>Handling</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="devicesTable">
|
|
|
|
|
<tr>
|
|
|
|
|
<td colspan="5" class="text-center text-muted">Klik "Hent devices" for at hente ESET-listen.</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
2026-02-11 23:51:21 +01:00
|
|
|
<div id="devicesCards" class="devices-cards"></div>
|
2026-02-11 13:23:32 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Import Modal -->
|
|
|
|
|
<div class="modal fade" id="esetImportModal" tabindex="-1" aria-hidden="true">
|
|
|
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
<h5 class="modal-title">Import fra ESET</h5>
|
|
|
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Device UUID</label>
|
|
|
|
|
<input type="text" class="form-control" id="importDeviceUuid" readonly>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Find kontakt</label>
|
|
|
|
|
<div class="input-group mb-2">
|
2026-02-11 23:51:21 +01:00
|
|
|
<input type="text" class="form-control" id="contactSearch" placeholder="Navn eller email" autocomplete="off">
|
2026-02-11 13:23:32 +01:00
|
|
|
<button class="btn btn-outline-secondary" type="button" onclick="searchContacts()">Sog</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="contactResults" class="contact-results"></div>
|
2026-02-11 23:51:21 +01:00
|
|
|
<div id="contactHint" class="contact-muted">Tip: Skriv 2+ tegn og tryk Enter for at vaelge den forste.</div>
|
2026-02-11 13:23:32 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Valgt kontakt</label>
|
|
|
|
|
<input type="text" class="form-control" id="selectedContact" placeholder="Ingen valgt" readonly>
|
2026-02-11 23:51:21 +01:00
|
|
|
<div id="selectedContactChip" class="contact-chip d-none"></div>
|
2026-02-11 13:23:32 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="importStatus" class="contact-muted"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
|
|
|
|
<button type="button" class="btn btn-primary" onclick="importDevice()">Importer</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block extra_js %}
|
|
|
|
|
<script>
|
|
|
|
|
const devicesTable = document.getElementById('devicesTable');
|
|
|
|
|
const deviceStatus = document.getElementById('deviceStatus');
|
2026-02-11 23:51:21 +01:00
|
|
|
const devicesCards = document.getElementById('devicesCards');
|
2026-02-12 07:03:18 +01:00
|
|
|
const devicesSection = document.getElementById('devicesSection');
|
|
|
|
|
const tabletToggle = document.getElementById('tabletToggle');
|
2026-02-11 13:23:32 +01:00
|
|
|
const importModal = new bootstrap.Modal(document.getElementById('esetImportModal'));
|
|
|
|
|
let selectedContactId = null;
|
2026-02-11 23:51:21 +01:00
|
|
|
let contactResults = [];
|
|
|
|
|
let contactSearchTimer = null;
|
|
|
|
|
const inlineSelections = {};
|
|
|
|
|
const inlineTimers = {};
|
2026-02-12 07:03:18 +01:00
|
|
|
let isTabletView = false;
|
|
|
|
|
|
|
|
|
|
function toggleTabletView() {
|
|
|
|
|
isTabletView = !isTabletView;
|
|
|
|
|
if (devicesSection) {
|
|
|
|
|
devicesSection.classList.toggle('show-cards', isTabletView);
|
|
|
|
|
}
|
|
|
|
|
if (tabletToggle) {
|
|
|
|
|
tabletToggle.textContent = isTabletView ? 'Tabel visning' : 'Tablet visning';
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 13:23:32 +01:00
|
|
|
|
|
|
|
|
function parseDevices(payload) {
|
|
|
|
|
if (Array.isArray(payload)) return payload;
|
|
|
|
|
if (!payload || typeof payload !== 'object') return [];
|
|
|
|
|
return payload.devices || payload.items || payload.results || payload.data || [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 23:51:21 +01:00
|
|
|
function getNextPageToken(payload) {
|
|
|
|
|
if (!payload || typeof payload !== 'object') return null;
|
|
|
|
|
return payload.nextPageToken || payload.next_page_token || payload.nextPage || null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 13:23:32 +01:00
|
|
|
function getField(device, keys) {
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
if (device[key]) return device[key];
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderDevices(devices) {
|
|
|
|
|
if (!devices.length) {
|
|
|
|
|
devicesTable.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen devices fundet.</td></tr>';
|
2026-02-11 23:51:21 +01:00
|
|
|
if (devicesCards) {
|
|
|
|
|
devicesCards.innerHTML = '<div class="text-center text-muted">Ingen devices fundet.</div>';
|
|
|
|
|
}
|
2026-02-11 13:23:32 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
devicesTable.innerHTML = devices.map(device => {
|
|
|
|
|
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
|
|
|
|
|
const name = getField(device, ['displayName', 'deviceName', 'name']);
|
|
|
|
|
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
|
|
|
|
|
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<tr>
|
|
|
|
|
<td>${name || '-'}</td>
|
|
|
|
|
<td>${serial || '-'}</td>
|
|
|
|
|
<td>${group || '-'}</td>
|
|
|
|
|
<td class="device-uuid">${uuid || '-'}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openImportModal('${uuid || ''}')">Importer</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
2026-02-11 23:51:21 +01:00
|
|
|
|
|
|
|
|
if (devicesCards) {
|
|
|
|
|
devicesCards.innerHTML = devices.map((device, index) => {
|
|
|
|
|
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
|
|
|
|
|
const name = getField(device, ['displayName', 'deviceName', 'name']);
|
|
|
|
|
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
|
|
|
|
|
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
|
|
|
|
|
const safeName = name || '-';
|
|
|
|
|
const safeSerial = serial || '-';
|
|
|
|
|
const safeGroup = group || '-';
|
|
|
|
|
const safeUuid = uuid || '';
|
|
|
|
|
return `
|
|
|
|
|
<div class="device-card" data-index="${index}" data-uuid="${safeUuid}">
|
|
|
|
|
<div class="device-card-title">${safeName}</div>
|
|
|
|
|
<div class="device-card-meta">Serial: ${safeSerial}</div>
|
|
|
|
|
<div class="device-card-meta">Gruppe: ${safeGroup}</div>
|
|
|
|
|
<div class="device-card-meta">UUID: ${safeUuid || '-'}</div>
|
|
|
|
|
<div class="mt-3">
|
|
|
|
|
<label class="form-label small">Vaelg kontakt (ejer)</label>
|
|
|
|
|
<input type="text" class="form-control form-control-sm inline-contact-search" data-index="${index}" placeholder="Sog kontakt..." autocomplete="off">
|
|
|
|
|
<div id="inlineResults-${index}" class="inline-results"></div>
|
|
|
|
|
<div id="inlineSelected-${index}" class="contact-muted">Ingen valgt</div>
|
|
|
|
|
<div id="inlineStatus-${index}" class="contact-muted"></div>
|
|
|
|
|
<div class="d-flex gap-2 mt-2">
|
|
|
|
|
<button class="btn btn-sm btn-primary" onclick="importDeviceInline(${index})">Importer</button>
|
|
|
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="openImportModal('${safeUuid || ''}')">Detaljer</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
|
|
|
|
bindInlineSearch();
|
|
|
|
|
}
|
2026-02-11 13:23:32 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-11 23:51:21 +01:00
|
|
|
function bindInlineSearch() {
|
|
|
|
|
document.querySelectorAll('.inline-contact-search').forEach(input => {
|
|
|
|
|
input.addEventListener('input', (event) => {
|
|
|
|
|
const index = event.target.dataset.index;
|
|
|
|
|
const query = event.target.value.trim();
|
|
|
|
|
if (inlineTimers[index]) {
|
|
|
|
|
clearTimeout(inlineTimers[index]);
|
|
|
|
|
}
|
|
|
|
|
if (query.length < 2) {
|
|
|
|
|
clearInlineResults(index);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
inlineTimers[index] = setTimeout(() => {
|
|
|
|
|
searchInlineContacts(query, index);
|
|
|
|
|
}, 250);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearInlineResults(index) {
|
|
|
|
|
const results = document.getElementById(`inlineResults-${index}`);
|
|
|
|
|
if (results) results.innerHTML = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function searchInlineContacts(query, index) {
|
|
|
|
|
const results = document.getElementById(`inlineResults-${index}`);
|
|
|
|
|
if (!results) return;
|
|
|
|
|
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=10`);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const err = await response.text();
|
|
|
|
|
throw new Error(err || 'Request failed');
|
|
|
|
|
}
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
const contacts = data.contacts || [];
|
|
|
|
|
if (!contacts.length) {
|
|
|
|
|
results.innerHTML = '<div class="p-2 text-muted">Ingen kontakter fundet.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
results.innerHTML = contacts.map((c, idx) => {
|
|
|
|
|
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
|
|
|
|
|
const company = (c.company_names || []).join(', ');
|
|
|
|
|
return `
|
|
|
|
|
<div class="inline-result" onclick="selectInlineContact(${index}, ${c.id}, '${name.replace(/'/g, "\\'")}', '${company.replace(/'/g, "\\'")}')">
|
|
|
|
|
<div>
|
|
|
|
|
<div>${name || 'Ukendt'}</div>
|
|
|
|
|
<div class="contact-muted">${c.email || ''}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="contact-muted">${company || '-'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
results.innerHTML = `<div class="p-2 text-danger">${err.message}</div>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectInlineContact(index, id, name, company) {
|
|
|
|
|
inlineSelections[index] = { id, label: company ? `${name} (${company})` : name };
|
|
|
|
|
const selectedEl = document.getElementById(`inlineSelected-${index}`);
|
|
|
|
|
if (selectedEl) selectedEl.textContent = inlineSelections[index].label || 'Ingen valgt';
|
|
|
|
|
clearInlineResults(index);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function importDeviceInline(index) {
|
|
|
|
|
const card = document.querySelector(`.device-card[data-index="${index}"]`);
|
|
|
|
|
const statusEl = document.getElementById(`inlineStatus-${index}`);
|
|
|
|
|
if (!card || !statusEl) return;
|
|
|
|
|
const uuid = card.dataset.uuid || '';
|
|
|
|
|
if (!uuid) {
|
|
|
|
|
statusEl.textContent = 'Manglende UUID';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
statusEl.textContent = 'Importer...';
|
2026-02-11 13:23:32 +01:00
|
|
|
try {
|
2026-02-11 23:51:21 +01:00
|
|
|
const payload = { device_uuid: uuid };
|
|
|
|
|
if (inlineSelections[index]?.id) {
|
|
|
|
|
payload.contact_id = inlineSelections[index].id;
|
|
|
|
|
}
|
|
|
|
|
const response = await fetch('/api/v1/hardware/eset/import', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
});
|
2026-02-11 13:23:32 +01:00
|
|
|
if (!response.ok) {
|
|
|
|
|
const err = await response.text();
|
|
|
|
|
throw new Error(err || 'Request failed');
|
|
|
|
|
}
|
|
|
|
|
const data = await response.json();
|
2026-02-11 23:51:21 +01:00
|
|
|
statusEl.textContent = `Importeret hardware #${data.id}`;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
statusEl.textContent = `Fejl: ${err.message}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadDevices() {
|
|
|
|
|
deviceStatus.textContent = 'Henter...';
|
|
|
|
|
const allDevices = [];
|
|
|
|
|
let pageToken = null;
|
|
|
|
|
let pageIndex = 0;
|
|
|
|
|
const pageSize = 200;
|
|
|
|
|
const maxPages = 20;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
while (pageIndex < maxPages) {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
params.set('page_size', String(pageSize));
|
|
|
|
|
if (pageToken) {
|
|
|
|
|
params.set('page_token', pageToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deviceStatus.textContent = `Henter side ${pageIndex + 1}...`;
|
|
|
|
|
const response = await fetch(`/api/v1/hardware/eset/devices?${params.toString()}`);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const err = await response.text();
|
|
|
|
|
throw new Error(err || 'Request failed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
const devices = parseDevices(data);
|
|
|
|
|
allDevices.push(...devices);
|
|
|
|
|
|
|
|
|
|
pageToken = getNextPageToken(data);
|
|
|
|
|
if (!pageToken || !devices.length) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
pageIndex += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pageIndex >= maxPages) {
|
|
|
|
|
deviceStatus.textContent = `${allDevices.length} devices hentet (afkortet)`;
|
|
|
|
|
} else {
|
|
|
|
|
deviceStatus.textContent = `${allDevices.length} devices hentet`;
|
|
|
|
|
}
|
|
|
|
|
renderDevices(allDevices);
|
2026-02-11 13:23:32 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
deviceStatus.textContent = 'Fejl ved hentning';
|
|
|
|
|
devicesTable.innerHTML = `<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openImportModal(uuid) {
|
|
|
|
|
document.getElementById('importDeviceUuid').value = uuid;
|
|
|
|
|
document.getElementById('contactSearch').value = '';
|
|
|
|
|
document.getElementById('contactResults').innerHTML = '';
|
|
|
|
|
document.getElementById('selectedContact').value = '';
|
2026-02-11 23:51:21 +01:00
|
|
|
document.getElementById('selectedContactChip').classList.add('d-none');
|
|
|
|
|
document.getElementById('selectedContactChip').textContent = '';
|
2026-02-11 13:23:32 +01:00
|
|
|
document.getElementById('importStatus').textContent = '';
|
|
|
|
|
selectedContactId = null;
|
2026-02-11 23:51:21 +01:00
|
|
|
contactResults = [];
|
2026-02-11 13:23:32 +01:00
|
|
|
importModal.show();
|
2026-02-11 23:51:21 +01:00
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const input = document.getElementById('contactSearch');
|
|
|
|
|
if (input) input.focus();
|
|
|
|
|
}, 150);
|
2026-02-11 13:23:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function searchContacts() {
|
|
|
|
|
const query = document.getElementById('contactSearch').value.trim();
|
|
|
|
|
const results = document.getElementById('contactResults');
|
|
|
|
|
if (!query) {
|
|
|
|
|
results.innerHTML = '<div class="p-2 text-muted">Indtast soegning.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=20`);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const err = await response.text();
|
|
|
|
|
throw new Error(err || 'Request failed');
|
|
|
|
|
}
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
const contacts = data.contacts || [];
|
2026-02-11 23:51:21 +01:00
|
|
|
contactResults = contacts;
|
2026-02-11 13:23:32 +01:00
|
|
|
if (!contacts.length) {
|
|
|
|
|
results.innerHTML = '<div class="p-2 text-muted">Ingen kontakter fundet.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
results.innerHTML = contacts.map(c => {
|
|
|
|
|
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
|
|
|
|
|
const company = (c.company_names || []).join(', ');
|
|
|
|
|
return `
|
2026-02-11 23:51:21 +01:00
|
|
|
<div class="contact-result" tabindex="0" onclick="selectContact(${c.id}, '${name.replace(/'/g, "\\'")}', '${company.replace(/'/g, "\\'")}')">
|
2026-02-11 13:23:32 +01:00
|
|
|
<div>
|
|
|
|
|
<div>${name || 'Ukendt'}</div>
|
|
|
|
|
<div class="contact-muted">${c.email || ''}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="contact-muted">${company || '-'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
2026-02-11 23:51:21 +01:00
|
|
|
highlightFirstContact();
|
2026-02-11 13:23:32 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
results.innerHTML = `<div class="p-2 text-danger">${err.message}</div>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectContact(id, name, company) {
|
|
|
|
|
selectedContactId = id;
|
|
|
|
|
const label = company ? `${name} (${company})` : name;
|
|
|
|
|
document.getElementById('selectedContact').value = label;
|
2026-02-11 23:51:21 +01:00
|
|
|
const chip = document.getElementById('selectedContactChip');
|
|
|
|
|
chip.textContent = `Ejer: ${label}`;
|
|
|
|
|
chip.classList.remove('d-none');
|
2026-02-11 13:23:32 +01:00
|
|
|
document.getElementById('contactResults').innerHTML = '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 23:51:21 +01:00
|
|
|
function highlightFirstContact() {
|
|
|
|
|
const first = document.querySelector('#contactResults .contact-result');
|
|
|
|
|
if (!first) return;
|
|
|
|
|
document.querySelectorAll('#contactResults .contact-result').forEach(el => el.classList.remove('active'));
|
|
|
|
|
first.classList.add('active');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectHighlightedContact() {
|
|
|
|
|
const active = document.querySelector('#contactResults .contact-result.active');
|
|
|
|
|
if (active) {
|
|
|
|
|
active.click();
|
|
|
|
|
} else if (contactResults.length > 0) {
|
|
|
|
|
const c = contactResults[0];
|
|
|
|
|
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
|
|
|
|
|
const company = (c.company_names || []).join(', ');
|
|
|
|
|
selectContact(c.id, name, company);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function moveHighlight(step) {
|
|
|
|
|
const items = Array.from(document.querySelectorAll('#contactResults .contact-result'));
|
|
|
|
|
if (!items.length) return;
|
|
|
|
|
let index = items.findIndex(el => el.classList.contains('active'));
|
|
|
|
|
if (index === -1) index = 0;
|
|
|
|
|
index = (index + step + items.length) % items.length;
|
|
|
|
|
items.forEach(el => el.classList.remove('active'));
|
|
|
|
|
items[index].classList.add('active');
|
|
|
|
|
items[index].scrollIntoView({ block: 'nearest' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('contactSearch').addEventListener('input', () => {
|
|
|
|
|
if (contactSearchTimer) {
|
|
|
|
|
clearTimeout(contactSearchTimer);
|
|
|
|
|
}
|
|
|
|
|
contactSearchTimer = setTimeout(() => {
|
|
|
|
|
const query = document.getElementById('contactSearch').value.trim();
|
|
|
|
|
if (query.length >= 2) {
|
|
|
|
|
searchContacts();
|
|
|
|
|
}
|
|
|
|
|
}, 250);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('contactSearch').addEventListener('keydown', (event) => {
|
|
|
|
|
if (event.key === 'Enter') {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
selectHighlightedContact();
|
|
|
|
|
} else if (event.key === 'ArrowDown') {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
moveHighlight(1);
|
|
|
|
|
} else if (event.key === 'ArrowUp') {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
moveHighlight(-1);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-11 13:23:32 +01:00
|
|
|
async function importDevice() {
|
|
|
|
|
const uuid = document.getElementById('importDeviceUuid').value.trim();
|
|
|
|
|
const statusEl = document.getElementById('importStatus');
|
|
|
|
|
statusEl.textContent = 'Importer...';
|
|
|
|
|
try {
|
|
|
|
|
const payload = { device_uuid: uuid };
|
|
|
|
|
if (selectedContactId) {
|
|
|
|
|
payload.contact_id = selectedContactId;
|
|
|
|
|
}
|
|
|
|
|
const response = await fetch('/api/v1/hardware/eset/import', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const err = await response.text();
|
|
|
|
|
throw new Error(err || 'Request failed');
|
|
|
|
|
}
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
statusEl.textContent = `Importeret hardware #${data.id}`;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
statusEl.textContent = `Fejl: ${err.message}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
{% endblock %}
|