2026-07-09 23:44:30 +02:00
{% extends "shared/frontend/base.html" %}
{% block title %}Internetforbindelser{% endblock %}
{% block extra_css %}
< style >
.internet-hero {
background:
radial-gradient(circle at top right, rgba(15, 76, 117, 0.18), transparent 34%),
linear-gradient(135deg, rgba(15, 76, 117, 0.1), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(15, 76, 117, 0.14);
border-radius: 24px;
padding: 1.5rem;
box-shadow: 0 16px 36px rgba(15, 76, 117, 0.08);
}
.internet-panel {
background: var(--bg-card);
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 20px;
box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06);
}
.internet-kpi {
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 16px;
padding: 1rem;
height: 100%;
}
.internet-kpi-label {
color: var(--text-secondary);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.internet-kpi-value {
color: var(--text-primary);
font-size: 1.55rem;
font-weight: 700;
}
.internet-toolbar {
display: grid;
grid-template-columns: 1.4fr 1fr 0.8fr 0.9fr auto auto;
gap: 0.75rem;
}
.internet-row {
cursor: pointer;
}
.internet-row td {
vertical-align: middle;
}
.internet-tabs {
display: inline-flex;
padding: 0.35rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.07);
gap: 0.35rem;
}
.internet-tab {
border: 0;
background: transparent;
border-radius: 999px;
padding: 0.6rem 1rem;
font-weight: 700;
color: var(--text-secondary);
}
.internet-tab.active {
background: #0f4c75;
color: white;
}
.internet-tag {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 999px;
padding: 0.18rem 0.5rem;
font-size: 0.72rem;
font-weight: 700;
background: rgba(15, 76, 117, 0.08);
color: #0f4c75;
margin-right: 0.35rem;
}
.internet-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.internet-status.active {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
}
.internet-status.inactive {
background: rgba(108, 117, 125, 0.14);
color: #495057;
}
.internet-status.pending,
.internet-status.planned {
background: rgba(255, 193, 7, 0.18);
color: #997404;
}
.internet-status.terminated,
.internet-status.cancelled {
background: rgba(220, 53, 69, 0.12);
color: #b02a37;
}
.internet-mini {
color: var(--text-secondary);
font-size: 0.83rem;
}
.internet-quick-form {
border: 1px dashed rgba(15, 76, 117, 0.2);
border-radius: 18px;
background: rgba(15, 76, 117, 0.03);
padding: 1rem;
}
2026-07-28 14:18:24 +02:00
.invoice-sync-status {
display:inline-flex;
align-items:center;
border-radius:999px;
padding:.3rem .65rem;
font-size:.72rem;
font-weight:800;
white-space:nowrap;
}
.invoice-sync-status.success { background:rgba(25,135,84,.14); color:#146c43; }
.invoice-sync-status.warning,
.invoice-sync-status.skipped,
.invoice-sync-status.not_logged { background:rgba(255,193,7,.2); color:#765a02; }
.invoice-sync-status.error { background:rgba(220,53,69,.14); color:#b02a37; }
.invoice-sync-status.legacy_success { background:rgba(13,110,253,.12); color:#084298; }
.invoice-sync-error { max-width:420px; white-space:normal; color:#b02a37; font-size:.8rem; }
2026-07-09 23:44:30 +02:00
@media (max-width: 991px) {
.internet-toolbar {
grid-template-columns: 1fr;
}
}
< / style >
{% endblock %}
{% block content %}
< div class = "container-fluid py-4" >
< div class = "internet-hero mb-4" >
< div class = "d-flex flex-column flex-xl-row justify-content-between align-items-xl-center gap-3" >
< div >
< div class = "small text-uppercase fw-semibold text-muted mb-2" > Økonomi / Drift / Support< / div >
< h2 class = "h3 mb-1" > Internetforbindelser< / h2 >
< div class = "text-muted" > Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.< / div >
< / div >
< div class = "d-flex gap-2 flex-wrap" >
2026-08-30 14:34:43 +02:00
< button class = "btn btn-outline-secondary" type = "button" onclick = "refreshActiveTab()" >
2026-07-09 23:44:30 +02:00
< i class = "bi bi-arrow-repeat me-1" > < / i > Opdater
< / button >
2026-08-30 14:34:43 +02:00
< button class = "btn btn-outline-primary" type = "button" data-bs-toggle = "modal" data-bs-target = "#ipNordicImportModal" >
< i class = "bi bi-file-earmark-spreadsheet me-1" > < / i > Importér IP Nordic
< / button >
< button class = "btn btn-primary" id = "newConnectionBtn" type = "button" data-bs-toggle = "collapse" data-bs-target = "#createConnectionBlock" >
2026-07-09 23:44:30 +02:00
< i class = "bi bi-plus-lg me-1" > < / i > Ny forbindelse
< / button >
< / div >
< / div >
< div class = "internet-tabs mt-3" >
< button class = "internet-tab active" type = "button" id = "tabAll" onclick = "setActiveTab('all')" > Alle forbindelser< / button >
< button class = "internet-tab" type = "button" id = "tabShared" onclick = "setActiveTab('shared')" > Delte hovedforbindelser< / button >
< button class = "internet-tab" type = "button" id = "tabBmcnet" onclick = "setActiveTab('bmcnet')" > BMCnet< / button >
2026-08-30 14:34:43 +02:00
< button class = "internet-tab" type = "button" id = "tabDedicated" onclick = "setActiveTab('dedicated')" > Dedikerede< / button >
< button class = "internet-tab" type = "button" id = "tabUnallocated" onclick = "setActiveTab('unallocated')" > Ikke allokeret< / button >
< button class = "internet-tab" type = "button" id = "tabInvoices" onclick = "setActiveTab('invoices')" > Behandlede internetfakturaer< / button >
2026-07-09 23:44:30 +02:00
< / div >
< / div >
2026-08-30 14:34:43 +02:00
< div class = "row g-3 mb-4" id = "connectionsMetrics" >
2026-07-09 23:44:30 +02:00
< div class = "col-6 col-xl-3" >
< div class = "internet-kpi" >
< div class = "internet-kpi-label" > Forbindelser< / div >
< div class = "internet-kpi-value" id = "metricTotal" > 0< / div >
< / div >
< / div >
< div class = "col-6 col-xl-3" >
< div class = "internet-kpi" >
2026-08-30 14:34:43 +02:00
< div class = "internet-kpi-label" > Aktive / afventer< / div >
2026-07-09 23:44:30 +02:00
< div class = "internet-kpi-value" id = "metricActive" > 0< / div >
< / div >
< / div >
< div class = "col-6 col-xl-3" >
< div class = "internet-kpi" >
< div class = "internet-kpi-label" id = "metricSharedLabel" > Delte hoveder< / div >
< div class = "internet-kpi-value" id = "metricShared" > 0< / div >
< / div >
< / div >
< div class = "col-6 col-xl-3" >
< div class = "internet-kpi" >
< div class = "internet-kpi-label" > Dækningsbidrag< / div >
< div class = "internet-kpi-value" id = "metricMargin" > 0 kr.< / div >
< / div >
< / div >
< / div >
< div class = "internet-panel p-4 mb-4 collapse" id = "createConnectionBlock" >
< div class = "internet-quick-form" >
< div class = "d-flex justify-content-between align-items-center mb-3" >
< div >
< div class = "fw-semibold" > Opret ny forbindelse< / div >
< div class = "internet-mini" > Bruges til fysiske og interne BMC-forbindelser.< / div >
< / div >
< div class = "small text-muted" id = "createConnectionFeedback" role = "status" > < / div >
< / div >
< div class = "row g-2" >
< div class = "col-lg-3" >
< input type = "text" class = "form-control" id = "connectionNameInput" placeholder = "Navn" / >
< / div >
< div class = "col-lg-2" >
2026-08-30 14:34:43 +02:00
< select class = "form-select" id = "connectionVendorInput" > < option value = "" > Vælg internetleverandør< / option > < / select >
2026-07-09 23:44:30 +02:00
< / div >
< div class = "col-lg-2" >
< input type = "number" class = "form-control" id = "connectionCustomerIdInput" placeholder = "Kunde-ID" / >
< / div >
< div class = "col-lg-2" >
< input type = "text" class = "form-control" id = "connectionCircuitInput" placeholder = "Kredsløb" / >
< / div >
< div class = "col-lg-3" >
< input type = "text" class = "form-control" id = "connectionAddressInput" placeholder = "Installationsadresse" / >
< / div >
< div class = "col-lg-2" >
< select class = "form-select" id = "connectionAllocationInput" >
< option value = "dedicated" > Dedikeret< / option >
< option value = "shared" > Delt< / option >
< / select >
< / div >
< div class = "col-lg-2" >
< select class = "form-select" id = "connectionValueTypeInput" onchange = "toggleCreateValueFields()" >
< option value = "other" > Anden værdi< / option >
< option value = "subscription" > Abonnement< / option >
< option value = "bmc_networks" > BMC Networks< / option >
< option value = "delefiber" > Delefiber< / option >
< / select >
< / div >
< div class = "col-lg-4" id = "createValueLabelWrap" >
< input type = "text" class = "form-control" id = "connectionValueLabelInput" placeholder = "Værdi / klassifikation" value = "Mangler klassifikation" / >
< / div >
< div class = "col-lg-4 d-none" id = "createSubscriptionWrap" >
< input type = "text" class = "form-control" id = "connectionSubscriptionInput" list = "subscriptionLookupList" placeholder = "Abonnement" >
< input type = "hidden" id = "connectionSubscriptionIdInput" / >
< / div >
< div class = "col-lg-2" >
< input type = "text" class = "form-control" id = "connectionTechnologyInput" placeholder = "Teknologi" / >
< / div >
< div class = "col-lg-2" >
< input type = "number" class = "form-control" id = "connectionDownloadInput" placeholder = "Download" / >
< / div >
< div class = "col-lg-2" >
< input type = "number" class = "form-control" id = "connectionUploadInput" placeholder = "Upload" / >
< / div >
< div class = "col-lg-2" >
< input type = "number" class = "form-control" id = "connectionPurchaseInput" placeholder = "Kost" / >
< / div >
< div class = "col-lg-2" >
< input type = "number" class = "form-control" id = "connectionSalesInput" placeholder = "Salg" / >
< / div >
< div class = "col-lg-2" >
< select class = "form-select" id = "connectionStatusInput" >
< option value = "active" > Aktiv< / option >
< option value = "planned" > Planlagt< / option >
< option value = "inactive" > Inaktiv< / option >
< / select >
< / div >
< div class = "col-12" >
< textarea class = "form-control" id = "connectionNotesInput" rows = "2" placeholder = "Noter, SLA, overvågning, intern struktur..." > < / textarea >
< / div >
< / div >
< div class = "mt-3" >
< button class = "btn btn-primary" type = "button" onclick = "submitConnectionForm()" > Gem forbindelse< / button >
< / div >
< / div >
< / div >
2026-08-30 14:34:43 +02:00
< div class = "internet-panel p-4 mb-4" id = "connectionsOverview" >
2026-07-09 23:44:30 +02:00
< div class = "internet-toolbar mb-3" >
< input type = "search" class = "form-control" id = "searchInput" placeholder = "Søg navn, kunde, leverandør, kredsløb eller adresse" / >
< input type = "text" class = "form-control" id = "providerFilter" placeholder = "Filtrer leverandør" / >
< select class = "form-select" id = "statusFilter" >
< option value = "" > Alle statusser< / option >
< option value = "active" > Aktive< / option >
2026-08-30 14:34:43 +02:00
< option value = "pending" > Afventer kontrol< / option >
2026-07-09 23:44:30 +02:00
< option value = "planned" > Planlagte< / option >
< option value = "inactive" > Inaktive< / option >
< option value = "terminated" > Opsagte< / option >
< / select >
< select class = "form-select" id = "valueTypeFilter" >
< option value = "" > Alle værdier< / option >
< option value = "subscription" > Abonnement< / option >
< option value = "bmc_networks" > BMC Networks< / option >
< option value = "delefiber" > Delefiber< / option >
< option value = "other" > Anden< / option >
< / select >
< button class = "btn btn-outline-secondary" type = "button" onclick = "loadInternetPage()" > Anvend< / button >
< button class = "btn btn-light border" type = "button" onclick = "resetFilters()" > Nulstil< / button >
< / div >
< div class = "row g-3 mb-3" >
< div class = "col-xl-6" >
< div class = "internet-mini" id = "pageSummaryText" > Indlæser forbindelser...< / div >
< / div >
< div class = "col-xl-6 text-xl-end" >
< div class = "internet-mini" > Klik på en række for at åbne forbindelsesdetaljer.< / div >
< / div >
< / div >
< div class = "table-responsive" >
< table class = "table align-middle mb-0" >
< thead class = "table-light" >
< tr >
< th > Forbindelse< / th >
< th > Kunde< / th >
< th > Leverandør / kredsløb< / th >
< th > Hastighed / IP< / th >
< th > Økonomi< / th >
< th > Status< / th >
< th > < / th >
< / tr >
< / thead >
< tbody id = "connectionsTableBody" >
< tr >
< td colspan = "7" class = "text-muted py-4" > Indlæser...< / td >
< / tr >
< / tbody >
< / table >
< / div >
< / div >
2026-07-28 14:18:24 +02:00
2026-08-30 14:34:43 +02:00
< div class = "internet-panel p-4 mb-4 d-none" id = "invoiceProcessingOverview" >
2026-07-28 14:18:24 +02:00
< div class = "d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3" >
< div >
< h3 class = "h5 mb-1" > Behandlede internetfakturaer< / h3 >
< div class = "internet-mini" > GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.< / div >
< / div >
< div class = "d-flex gap-2" >
2026-08-30 14:34:43 +02:00
< span class = "internet-mini align-self-center" id = "invoiceReconcileFeedback" role = "status" > < / span >
2026-07-28 14:18:24 +02:00
< select class = "form-select form-select-sm" id = "invoiceSyncStatusFilter" onchange = "loadInvoiceSyncRuns()" >
< option value = "" > Alle resultater< / option >
< option value = "success" > Gennemført< / option >
< option value = "warning" > Kræver kontrol< / option >
< option value = "error" > Fejl< / option >
< option value = "skipped" > Sprunget over< / option >
< option value = "not_logged" > Ældre uden detaljer< / option >
< / select >
2026-08-30 14:34:43 +02:00
< button class = "btn btn-sm btn-outline-primary" id = "invoiceReconcileBtn" type = "button" onclick = "reconcileInvoiceSyncRuns()" > < i class = "bi bi-arrow-clockwise me-1" > < / i > Genkontrollér< / button >
2026-07-28 14:18:24 +02:00
< button class = "btn btn-sm btn-outline-secondary" type = "button" onclick = "loadInvoiceSyncRuns()" > < i class = "bi bi-arrow-repeat me-1" > < / i > Opdater< / button >
< / div >
< / div >
< div class = "row g-3 mb-3" >
< div class = "col-6 col-xl-3" > < div class = "internet-kpi" > < div class = "internet-kpi-label" > Fakturaer< / div > < div class = "internet-kpi-value" id = "invoiceMetricTotal" > 0< / div > < / div > < / div >
< div class = "col-6 col-xl-3" > < div class = "internet-kpi" > < div class = "internet-kpi-label" > Gennemført< / div > < div class = "internet-kpi-value text-success" id = "invoiceMetricSuccess" > 0< / div > < / div > < / div >
< div class = "col-6 col-xl-3" > < div class = "internet-kpi" > < div class = "internet-kpi-label" > Kræver kontrol< / div > < div class = "internet-kpi-value text-warning" id = "invoiceMetricWarnings" > 0< / div > < / div > < / div >
< div class = "col-6 col-xl-3" > < div class = "internet-kpi" > < div class = "internet-kpi-label" > Fejl< / div > < div class = "internet-kpi-value text-danger" id = "invoiceMetricErrors" > 0< / div > < / div > < / div >
< / div >
< div class = "table-responsive" >
< table class = "table table-sm align-middle mb-0" >
< thead class = "table-light" > < tr > < th > Faktura< / th > < th > Behandlet< / th > < th > Resultat< / th > < th > Forbindelser< / th > < th > IP-ranges< / th > < th > Fejl / bemærkning< / th > < / tr > < / thead >
< tbody id = "invoiceSyncTableBody" > < tr > < td colspan = "6" class = "text-muted py-4" > Indlæser fakturabehandling…< / td > < / tr > < / tbody >
< / table >
< / div >
< / div >
< / div >
2026-08-30 14:34:43 +02:00
< div class = "modal fade" id = "ipNordicImportModal" tabindex = "-1" aria-hidden = "true" >
< div class = "modal-dialog modal-xl modal-dialog-scrollable" >
< div class = "modal-content" >
< div class = "modal-header" >
< div >
< h5 class = "modal-title mb-1" > Importér IP Nordic-forbindelser< / h5 >
< div class = "internet-mini" > Excel-filen kontrolleres før import. Kunder tilknyttes aldrig automatisk.< / div >
< / div >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
< label class = "form-label fw-semibold" for = "ipNordicFileInput" > IP Nordic Excel-fil (.xlsx)< / label >
< input class = "form-control" id = "ipNordicFileInput" type = "file" accept = ".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" >
< div class = "small mt-2" id = "ipNordicImportFeedback" role = "status" > < / div >
< div class = "table-responsive mt-3 d-none" id = "ipNordicPreviewWrap" >
< table class = "table table-sm align-middle" >
< thead > < tr > < th > Handling< / th > < th > Rapporteret firma< / th > < th > Adresse< / th > < th > Start< / th > < th class = "text-end" > Kost< / th > < th class = "text-end" > Salg< / th > < / tr > < / thead >
< tbody id = "ipNordicPreviewBody" > < / tbody >
< / table >
< / div >
< / div >
< div class = "modal-footer" >
< button class = "btn btn-outline-secondary" type = "button" data-bs-dismiss = "modal" > Luk< / button >
< button class = "btn btn-outline-primary" id = "ipNordicPreviewBtn" type = "button" onclick = "previewIpNordicImport()" > Kontrollér fil< / button >
< button class = "btn btn-primary d-none" id = "ipNordicCommitBtn" type = "button" onclick = "commitIpNordicImport()" > Importér nye forbindelser< / button >
< / div >
< / div >
< / div >
< / div >
2026-07-28 14:18:24 +02:00
< div class = "modal fade" id = "invoiceReviewModal" tabindex = "-1" aria-hidden = "true" >
< div class = "modal-dialog modal-xl modal-dialog-scrollable" >
< div class = "modal-content" >
< div class = "modal-header" >
< div > < h5 class = "modal-title mb-1" > Kontrollér internetfaktura < span id = "invoiceReviewNumber" > < / span > < / h5 > < div class = "internet-mini" id = "invoiceReviewSummary" > < / div > < / div >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
< div class = "alert alert-info small" > Vælg en eksisterende forbindelse, opret en separat afventende forbindelse, eller ignorér linjen med en begrundelse. Fakturaen markeres gennemført, når alle kontrollinjer er afklaret.< / div >
2026-08-30 14:34:43 +02:00
< div class = "form-check form-switch mb-3" >
< input class = "form-check-input" type = "checkbox" id = "showResolvedInvoiceLines" onchange = "toggleResolvedInvoiceLines()" >
< label class = "form-check-label" for = "showResolvedInvoiceLines" > Vis allerede løste linjer< / label >
< / div >
2026-07-28 14:18:24 +02:00
< div id = "invoiceReviewLines" > < / div >
< / div >
< / div >
< / div >
2026-07-09 23:44:30 +02:00
< / div >
< script >
let allConnections = [];
2026-07-28 14:18:24 +02:00
let invoiceSyncItems = [];
2026-07-09 23:44:30 +02:00
let activeTab = 'all';
let subscriptionOptions = [];
2026-08-30 14:34:43 +02:00
let ipNordicPreview = null;
let allocationSuggestionsByConnection = new Map();
function escapeHtml(value) {
return String(value ?? '').replace(/[& < >'"]/g, (char) => ({
'& ': '& ', '< ': '< ', '>': '> ', "'": '' ', '"': '" '
}[char]));
}
async function runIpNordicImport(commit) {
const input = document.getElementById('ipNordicFileInput');
const feedback = document.getElementById('ipNordicImportFeedback');
const previewButton = document.getElementById('ipNordicPreviewBtn');
const commitButton = document.getElementById('ipNordicCommitBtn');
const file = input.files?.[0];
if (!file) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = 'Vælg først en Excel-fil.';
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('commit', commit ? 'true' : 'false');
previewButton.disabled = true;
commitButton.disabled = true;
feedback.className = 'small mt-2 text-muted';
feedback.textContent = commit ? 'Importerer...' : 'Kontrollerer filen...';
try {
const response = await fetch('/api/v1/internet-connections/import/ip-nordic', { method: 'POST', body: formData });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || 'Importen kunne ikke gennemføres.');
ipNordicPreview = payload;
document.getElementById('ipNordicPreviewWrap').classList.remove('d-none');
document.getElementById('ipNordicPreviewBody').innerHTML = (payload.items || []).map((item) => `
< tr >
< td > ${item.action === 'skip' ? `< span class = "badge text-bg-secondary" > Findes #${item.existing_connection_id}< / span > ` : '< span class = "badge text-bg-success" > Ny< / span > '}< / td >
< td > ${escapeHtml(item.reported_company || '-')}< div class = "internet-mini" > Nr. ${escapeHtml(item.company_number || '-')} · ${Number(item.line_count || 0)} linjer< / div > < / td >
< td > ${escapeHtml(item.address || '-')}< / td >
< td > ${escapeHtml(item.start_date || '-')}< / td >
< td class = "text-end" > ${formatDKK(item.monthly_cost)}< / td >
< td class = "text-end" > ${formatDKK(item.sales_price)}< / td >
< / tr > `).join('');
feedback.className = 'small mt-2 text-success';
feedback.textContent = commit
? `${payload.created_count} forbindelser oprettet, ${payload.skipped_count} eksisterende sprunget over.`
: `${payload.create_count} nye og ${payload.existing_count} eksisterende forbindelser fundet.`;
commitButton.classList.toggle('d-none', commit || payload.create_count === 0);
if (commit) await loadInternetPage();
} catch (error) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = error.message || 'Importen kunne ikke gennemføres.';
} finally {
previewButton.disabled = false;
commitButton.disabled = false;
}
}
function previewIpNordicImport() { return runIpNordicImport(false); }
function commitIpNordicImport() { return runIpNordicImport(true); }
2026-07-09 23:44:30 +02:00
function formatDKK(value) {
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
}
function formatSpeed(item) {
const down = Number(item.download_mbps || 0);
const up = Number(item.upload_mbps || 0);
const speed = Number(item.speed_mbps || 0);
if (down || up) return `${down || 0}/${up || 0} Mbps`;
if (speed) return `${speed} Mbps`;
return '-';
}
function statusBadge(status) {
const value = String(status || 'inactive').toLowerCase();
const labelMap = {
active: 'Aktiv',
planned: 'Planlagt',
pending: 'Afventer',
inactive: 'Inaktiv',
terminated: 'Opsagt',
cancelled: 'Annulleret',
};
return `< span class = "internet-status ${value}" > ${labelMap[value] || value}< / span > `;
}
function allocationBadge(item) {
const label = item.allocation_model_label || (item.allocation_model === 'shared' ? 'Delt' : 'Dedikeret');
2026-08-30 14:34:43 +02:00
return `< span class = "internet-tag" > ${escapeHtml(label)}< / span > `;
2026-07-09 23:44:30 +02:00
}
function valueBadge(item) {
const label = item.value_type_label || 'Anden';
2026-08-30 14:34:43 +02:00
return `< span class = "internet-tag" > ${escapeHtml(label)}< / span > `;
2026-07-09 23:44:30 +02:00
}
function currentTabDescription() {
if (activeTab === 'shared') return ' i delte hovedforbindelser';
if (activeTab === 'bmcnet') return ' i BMCnet';
2026-08-30 14:34:43 +02:00
if (activeTab === 'dedicated') return ' i dedikerede forbindelser';
if (activeTab === 'unallocated') return ' uden allokeret kunde';
2026-07-09 23:44:30 +02:00
return '';
}
async function safeJson(response, fallback) {
if (!response.ok) return fallback;
try {
return await response.json();
} catch {
return fallback;
}
}
async function extractErrorMessage(response, fallback) {
try {
const payload = await response.clone().json();
if (typeof payload?.detail === 'string' & & payload.detail.trim()) return payload.detail.trim();
if (Array.isArray(payload?.detail)) {
const validation = payload.detail
.map((item) => item?.msg || item?.message || '')
.filter(Boolean)
.join(', ');
if (validation) return validation;
}
} catch (_) {}
try {
const text = (await response.text()).trim();
if (text) return text;
} catch (_) {}
return fallback;
}
2026-07-28 14:18:24 +02:00
function escapeInvoiceText(value) {
return String(value ?? '')
.replaceAll('& ', '& ')
.replaceAll('< ', '< ')
.replaceAll('>', '> ')
.replaceAll('"', '" ')
.replaceAll("'", '' ');
}
function invoiceSyncBadge(status) {
const labels = {
success: 'Gennemført',
legacy_success: 'Ældre · gennemført',
warning: 'Kræver kontrol',
skipped: 'Sprunget over',
error: 'Fejl',
not_logged: 'Ingen detaljer',
};
return `< span class = "invoice-sync-status ${escapeInvoiceText(status)}" > ${labels[status] || escapeInvoiceText(status || 'Ukendt')}< / span > `;
}
function formatInvoiceSyncDate(value) {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? escapeInvoiceText(value) : date.toLocaleString('da-DK');
}
function invoiceSyncMessage(item) {
if (item.error_message) return `< div class = "invoice-sync-error" > ${escapeInvoiceText(item.error_message)}< / div > `;
if (item.processing_status === 'warning') {
const skipped = Number(item.skipped_lines || 0);
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
const reasons = [...new Set(skippedItems.map(entry => entry.reason).filter(Boolean))];
return `< div class = "text-warning small mb-2" > ${Number(item.unresolved_lines ?? skipped)} af ${skipped} linje(r) mangler kontrol${reasons.length ? `: ${escapeInvoiceText(reasons.join('; '))}` : ''}< / div > ${item.run_id ? `< button class = "btn btn-sm btn-warning" type = "button" onclick = "openInvoiceReview(${Number(item.run_id)})" > < i class = "bi bi-check2-square me-1" > < / i > Kontrollér< / button > ` : ''}`;
}
if (item.processing_status === 'skipped') return '< div class = "text-warning small" > Fakturaen gav ingen internetdata til synkronisering.< / div > ';
if (item.processing_status === 'not_logged') return '< div class = "text-muted small" > Ældre faktura uden gemt behandlingsresultat.< / div > ';
if (item.processing_status === 'legacy_success') return '< div class = "text-muted small" > Fundet via eksisterende forbindelseshistorik.< / div > ';
return '< div class = "text-muted small" > Ingen fejl registreret.< / div > ';
}
async function loadInvoiceSyncRuns() {
const status = document.getElementById('invoiceSyncStatusFilter').value;
const params = new URLSearchParams({limit: '200'});
if (status) params.set('status', status);
const body = document.getElementById('invoiceSyncTableBody');
body.innerHTML = '< tr > < td colspan = "6" class = "text-muted py-4" > Indlæser fakturabehandling…< / td > < / tr > ';
try {
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs?${params.toString()}`);
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Kunne ikke hente fakturabehandling.'));
const payload = await response.json();
const items = Array.isArray(payload.items) ? payload.items : [];
invoiceSyncItems = items;
const summary = payload.summary || {};
document.getElementById('invoiceMetricTotal').textContent = String(summary.total || 0);
document.getElementById('invoiceMetricSuccess').textContent = String(summary.success || 0);
document.getElementById('invoiceMetricWarnings').textContent = String(summary.warnings || 0);
document.getElementById('invoiceMetricErrors').textContent = String(summary.errors || 0);
if (!items.length) {
body.innerHTML = '< tr > < td colspan = "6" class = "text-muted py-4" > Ingen fakturaer matcher filteret.< / td > < / tr > ';
return;
}
body.innerHTML = items.map(item => `
< tr >
< td > < div class = "fw-semibold" > ${escapeInvoiceText(item.invoice_number || '-')}< / div > < div class = "internet-mini" > ${escapeInvoiceText(item.vendor_name || 'GlobalConnect')} · ${escapeInvoiceText(item.invoice_date || '-')}< / div > < / td >
< td > ${formatInvoiceSyncDate(item.processed_at)}< / td >
< td > ${invoiceSyncBadge(item.processing_status)}< / td >
< td > < strong > ${Number(item.connections_synced || item.legacy_connection_count || 0)}< / strong > < div class = "internet-mini" > ${Number(item.connections_created || 0)} nye · ${Number(item.connections_updated || 0)} opdateret< / div > < / td >
< td > < strong > ${Number(item.ip_ranges_synced || 0)}< / strong > < / td >
< td > ${invoiceSyncMessage(item)}< / td >
< / tr >
`).join('');
} catch (error) {
body.innerHTML = `< tr > < td colspan = "6" class = "text-danger py-4" > ${escapeInvoiceText(error.message || 'Kunne ikke indlæse fakturabehandling.')}< / td > < / tr > `;
}
}
2026-08-30 14:34:43 +02:00
async function reconcileInvoiceSyncRuns() {
const button = document.getElementById('invoiceReconcileBtn');
const feedback = document.getElementById('invoiceReconcileFeedback');
button.disabled = true;
feedback.className = 'internet-mini align-self-center text-muted';
feedback.textContent = 'Genkontrollerer…';
try {
const response = await fetch('/api/v1/internet-connections/invoice-sync-runs/reconcile', { method: 'POST' });
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Genkontrollen fejlede.'));
const payload = await response.json();
feedback.className = 'internet-mini align-self-center text-success';
feedback.textContent = `${Number(payload.resolved_lines || 0)} linjer løst`;
await loadInvoiceSyncRuns();
} catch (error) {
feedback.className = 'internet-mini align-self-center text-danger';
feedback.textContent = error.message || 'Genkontrollen fejlede.';
} finally {
button.disabled = false;
}
}
2026-07-28 14:18:24 +02:00
function reviewConnectionOptions(selectedId = '') {
return '< option value = "" > Vælg forbindelse…< / option > ' + allConnections
.filter(item => String(item.provider || '').toLowerCase().includes('globalconnect'))
.map(item => `< option value = "${Number(item.id)}" $ { String ( item . id ) = = = String ( selectedId ) ? ' selected ' : ' ' } > ${escapeInvoiceText(item.circuit_number || item.name || `#${item.id}`)} · ${escapeInvoiceText(item.address || '-')}< / option > `)
.join('');
}
2026-08-30 14:34:43 +02:00
function toggleResolvedInvoiceLines() {
const show = document.getElementById('showResolvedInvoiceLines').checked;
document.querySelectorAll('#invoiceReviewLines .resolved-review-line').forEach((element) => {
element.classList.toggle('d-none', !show);
});
}
async function openInvoiceReview(runId) {
const summaryItem = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
if (!summaryItem) return;
const linesWrap = document.getElementById('invoiceReviewLines');
linesWrap.innerHTML = '< div class = "text-muted py-4" > Henter kontrollinjer…< / div > ';
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs/${runId}`);
if (!response.ok) {
linesWrap.innerHTML = `< div class = "text-danger py-4" > ${escapeInvoiceText(await extractErrorMessage(response, 'Kunne ikke hente kontrollinjer.'))}< / div > `;
return;
}
const detail = await response.json();
const item = { ...summaryItem, ...detail };
2026-07-28 14:18:24 +02:00
if (!item) return;
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
const decisions = Array.isArray(item.review_decisions) ? item.review_decisions : [];
const decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision]));
document.getElementById('invoiceReviewNumber').textContent = item.invoice_number || '-';
document.getElementById('invoiceReviewSummary').textContent = `${Number(item.resolved_lines || 0)} løst · ${Number(item.unresolved_lines || 0)} mangler`;
2026-08-30 14:34:43 +02:00
document.getElementById('showResolvedInvoiceLines').checked = false;
2026-07-28 14:18:24 +02:00
const groups = skippedItems.reduce((result, line) => {
const reason = line.reason || 'Anden kontrol';
(result[reason] ||= []).push(line);
return result;
}, {});
document.getElementById('invoiceReviewLines').innerHTML = Object.entries(groups).map(([reason, lines]) => `
< section class = "mb-4" >
< h6 class = "mb-2" > ${escapeInvoiceText(reason)} < span class = "badge bg-secondary" > ${lines.length}< / span > < / h6 >
< div class = "vstack gap-2" >
${lines.map(line => {
const decision = decisionsByLine.get(Number(line.line_number));
2026-08-30 14:34:43 +02:00
return `< div class = "border rounded p-3 ${decision ? 'bg-light opacity-75 resolved-review-line d-none' : ''}" >
2026-07-28 14:18:24 +02:00
< div class = "d-flex flex-column flex-lg-row justify-content-between gap-3" >
< div class = "flex-grow-1" >
< div class = "fw-semibold" > Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}< / div >
< div class = "internet-mini" > Reference: ${escapeInvoiceText(line.provider_reference || '-')} · Adresse: ${escapeInvoiceText(line.service_address || '-')}< / div >
${line.ip_address ? `< div class = "internet-mini" > IP-range: ${escapeInvoiceText(line.ip_address)}< / div > ` : ''}
${decision ? `< div class = "text-success small mt-2" > < i class = "bi bi-check-circle me-1" > < / i > Løst: ${escapeInvoiceText(decision.action)}${decision.note ? ` · ${escapeInvoiceText(decision.note)}` : ''}< / div > ` : ''}
< / div >
${decision ? '' : `< div style = "min-width:340px;" >
< select class = "form-select form-select-sm mb-2" id = "reviewConnection-${runId}-${Number(line.line_number)}" > ${reviewConnectionOptions()}< / select >
< input class = "form-control form-control-sm mb-2" id = "reviewNote-${runId}-${Number(line.line_number)}" placeholder = "Begrundelse / note" >
< div class = "d-flex flex-wrap gap-2" >
< button class = "btn btn-sm btn-primary" type = "button" onclick = "submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'link_existing')" > Brug valgt< / button >
< button class = "btn btn-sm btn-outline-primary" type = "button" onclick = "submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'create_separate')" > Opret separat< / button >
< button class = "btn btn-sm btn-outline-secondary" type = "button" onclick = "submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'ignore')" > Ignorer< / button >
< / div >
< / div > `}
< / div >
< / div > `;
}).join('')}
< / div >
< / section >
`).join('') || '< div class = "text-success" > Alle kontrollinjer er løst.< / div > ';
}
async function submitInvoiceReview(runId, lineNumber, action) {
const connectionId = document.getElementById(`reviewConnection-${runId}-${lineNumber}`)?.value || null;
const note = document.getElementById(`reviewNote-${runId}-${lineNumber}`)?.value.trim() || '';
if (action === 'link_existing' & & !connectionId) {
alert('Vælg først en eksisterende forbindelse.');
return;
}
if (action === 'ignore' & & !note) {
alert('Skriv en begrundelse for at ignorere linjen.');
return;
}
if (action === 'create_separate' & & !confirm('Opret en separat, afventende forbindelse for denne linje?')) return;
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs/${runId}/review`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
line_number: lineNumber,
action,
connection_id: connectionId ? Number(connectionId) : null,
note: note || null,
}),
});
if (!response.ok) {
alert(await extractErrorMessage(response, 'Kontrollen kunne ikke gemmes.'));
return;
}
await loadInvoiceSyncRuns();
openInvoiceReview(runId);
}
2026-07-09 23:44:30 +02:00
async function loadInternetPage() {
const search = document.getElementById('searchInput').value.trim();
const provider = document.getElementById('providerFilter').value.trim();
const status = document.getElementById('statusFilter').value;
const valueType = document.getElementById('valueTypeFilter').value;
const params = new URLSearchParams();
if (search) params.set('q', search);
if (provider) params.set('provider', provider);
if (status) params.set('status', status);
if (valueType) params.set('value_type', valueType);
if (activeTab === 'shared') params.set('shared_only', 'true');
if (activeTab === 'bmcnet') params.set('bmcnet_only', 'true');
2026-08-30 14:34:43 +02:00
if (activeTab === 'dedicated') {
params.set('allocation_model', 'dedicated');
params.set('allocated_only', 'true');
}
if (activeTab === 'unallocated') params.set('unallocated_only', 'true');
2026-07-09 23:44:30 +02:00
try {
const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`);
const connections = await safeJson(connectionsResponse, []);
allConnections = Array.isArray(connections) ? connections : [];
2026-08-30 14:34:43 +02:00
allocationSuggestionsByConnection = new Map();
if (activeTab === 'unallocated') {
const allocationResponse = await fetch('/api/v1/internet-connections/allocation-overview');
const allocationPayload = await safeJson(allocationResponse, { items: [] });
allocationSuggestionsByConnection = new Map(
(allocationPayload.items || []).map((entry) => [Number(entry.connection_id), entry])
);
}
2026-07-09 23:44:30 +02:00
const total = allConnections.length;
const active = allConnections.filter((item) => item.status === 'active').length;
2026-08-30 14:34:43 +02:00
const pending = allConnections.filter((item) => item.status === 'pending').length;
2026-07-09 23:44:30 +02:00
const sharedHeads = allConnections.filter((item) => item.is_shared_head).length;
const bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length;
const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0);
document.getElementById('metricTotal').textContent = String(total);
2026-08-30 14:34:43 +02:00
document.getElementById('metricActive').textContent = `${active} / ${pending}`;
const focusMetric = activeTab === 'bmcnet'
? ['BMCnet', bmcnetConnections]
: activeTab === 'dedicated'
? ['Dedikerede', total]
: activeTab === 'unallocated'
? ['Uden kunde', total]
: ['Delte hoveder', sharedHeads];
document.getElementById('metricSharedLabel').textContent = focusMetric[0];
document.getElementById('metricShared').textContent = String(focusMetric[1]);
2026-07-09 23:44:30 +02:00
document.getElementById('metricMargin').textContent = formatDKK(margin);
renderConnections(allConnections);
} catch (error) {
document.getElementById('connectionsTableBody').innerHTML = '< tr > < td colspan = "7" class = "text-danger py-4" > Kunne ikke indlæse internetforbindelser.< / td > < / tr > ';
document.getElementById('pageSummaryText').textContent = 'Indlæsning fejlede.';
}
}
function renderConnections(connections) {
const body = document.getElementById('connectionsTableBody');
document.getElementById('pageSummaryText').textContent = `${connections.length} forbindelser vist${currentTabDescription()}`;
if (!connections.length) {
body.innerHTML = '< tr > < td colspan = "7" class = "text-muted py-4" > Ingen forbindelser matcher filtrene.< / td > < / tr > ';
return;
}
2026-08-30 14:34:43 +02:00
body.innerHTML = connections.map((item) => {
const allocation = allocationSuggestionsByConnection.get(Number(item.id));
const uniqueSuggestion = allocation?.unique_suggestion;
const suggestionCount = Number(allocation?.suggestions?.length || 0);
return `
2026-07-09 23:44:30 +02:00
< tr class = "internet-row" onclick = "window.location.href='/economy/internet-connections/${item.id}'" >
< td >
2026-08-30 14:34:43 +02:00
< div class = "fw-semibold" > ${escapeHtml(item.name || '-')}< / div >
2026-07-09 23:44:30 +02:00
< div class = "mb-1" > ${allocationBadge(item)}${valueBadge(item)}< / div >
2026-08-30 14:34:43 +02:00
< div class = "internet-mini" > ${escapeHtml(item.address || '-')}< / div >
${item.parent_name ? `< div class = "internet-mini" > < i class = "bi bi-diagram-2 me-1" > < / i > Under ${escapeHtml(item.parent_name)}< / div > ` : ''}
2026-07-09 23:44:30 +02:00
${item.is_shared_head ? `< div class = "internet-mini" > < i class = "bi bi-diagram-3 me-1" > < / i > ${Number(item.bmcnet_child_count || 0)} BMCnet-kunder · ${formatDKK(item.bmcnet_child_sales_price || 0)} salg< / div > ` : ''}
< / td >
< td >
2026-08-30 14:34:43 +02:00
< div > ${escapeHtml(item.customer_name || '-')}< / div >
2026-07-09 23:44:30 +02:00
< div class = "internet-mini" > Kunde-ID: ${item.customer_id || '-'}< / div >
2026-08-30 14:34:43 +02:00
${uniqueSuggestion ? `< button class = "btn btn-sm btn-outline-success mt-1" type = "button" onclick = "event.stopPropagation(); assignSuggestedCustomer(${Number(item.id)}, ${Number(uniqueSuggestion.customer_id)})" > < i class = "bi bi-person-check me-1" > < / i > ${escapeHtml(uniqueSuggestion.customer_name || 'Tildel foreslået kunde')}< / button > ` : ''}
${!uniqueSuggestion & & suggestionCount > 1 ? `< div class = "internet-mini text-warning mt-1" > ${suggestionCount} adresseforslag · vælg på forbindelsen< / div > ` : ''}
${item.sla_subscription_id
? `< div class = "internet-mini ${Number(item.sla_price || 0) > 0 && item.sla_status === 'active' ? 'text-success' : 'text-warning'} fw-semibold" > < i class = "bi bi-shield-check me-1" > < / i > ${escapeHtml(item.sla_product_name || 'SLA')} · ${formatDKK(item.sla_price || 0)}${Number(item.sla_price || 0) > 0 ? '' : ' · Kontrollér pris'}${item.sla_status === 'active' ? '' : ' · Ikke aktiv'}< / div > `
: `< div class = "internet-mini text-danger fw-semibold" > < i class = "bi bi-shield-exclamation me-1" > < / i > Ingen SLA-aftale< / div > `}
2026-07-09 23:44:30 +02:00
< / td >
< td >
2026-08-30 14:34:43 +02:00
< div > ${escapeHtml(item.provider || '-')}< / div >
< div class = "internet-mini" > ${escapeHtml(item.circuit_number || 'Intet kredsløb')}< / div >
${item.subscription_number ? `< div class = "internet-mini" > Abonnement ${escapeHtml(item.subscription_number)} · ${escapeHtml(item.subscription_product_name || '-')}< / div > ` : (item.value_label ? `< div class = "internet-mini" > ${escapeHtml(item.value_label)}< / div > ` : '')}
2026-07-09 23:44:30 +02:00
< / td >
< td >
< div > ${formatSpeed(item)}< / div >
< div class = "internet-mini" > Ranges ${Number(item.ip_range_count || 0)} · IP i brug: ${Number(item.in_use_ip_addresses || 0)} / ${Number(item.total_ip_addresses || 0)}< / div >
${item.is_shared_head ? `< div class = "internet-mini" > BMCnet-IP i brug: ${Number(item.bmcnet_child_ip_count || 0)}< / div > ` : ''}
< / td >
< td >
< div > ${formatDKK(item.sales_price || 0)}< / div >
< div class = "internet-mini" > Kost ${formatDKK(item.monthly_cost || 0)} · DB ${formatDKK(item.margin_amount || 0)}< / div >
< / td >
< td > ${statusBadge(item.status)}< / td >
< td class = "text-end" >
< a href = "/economy/internet-connections/${item.id}" class = "btn btn-sm btn-outline-primary" onclick = "event.stopPropagation()" >
Åbn
< / a >
< / td >
< / tr >
2026-08-30 14:34:43 +02:00
`}).join('');
}
async function assignSuggestedCustomer(connectionId, customerId) {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customerId }),
});
if (!response.ok) {
document.getElementById('pageSummaryText').textContent = await extractErrorMessage(response, 'Kunden kunne ikke tildeles.');
return;
}
await loadInternetPage();
2026-07-09 23:44:30 +02:00
}
async function submitConnectionForm() {
const feedback = document.getElementById('createConnectionFeedback');
const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary');
const payload = {
name: document.getElementById('connectionNameInput').value.trim(),
2026-08-30 14:34:43 +02:00
vendor_id: Number(document.getElementById('connectionVendorInput').value || 0) || null,
2026-07-09 23:44:30 +02:00
customer_id: Number(document.getElementById('connectionCustomerIdInput').value || 0) || null,
circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null,
address: document.getElementById('connectionAddressInput').value.trim() || null,
technology: document.getElementById('connectionTechnologyInput').value.trim() || null,
download_mbps: Number(document.getElementById('connectionDownloadInput').value || 0) || null,
upload_mbps: Number(document.getElementById('connectionUploadInput').value || 0) || null,
monthly_cost: Number(document.getElementById('connectionPurchaseInput').value || 0),
sales_price: Number(document.getElementById('connectionSalesInput').value || 0),
status: document.getElementById('connectionStatusInput').value || 'active',
notes: document.getElementById('connectionNotesInput').value.trim() || null,
connection_type: 'fiber',
allocation_model: document.getElementById('connectionAllocationInput').value || 'dedicated',
value_type: document.getElementById('connectionValueTypeInput').value || 'other',
value_label: document.getElementById('connectionValueLabelInput').value.trim() || null,
subscription_id: Number(document.getElementById('connectionSubscriptionIdInput').value || 0) || null,
2026-08-30 14:34:43 +02:00
is_manual_shared: document.getElementById('connectionAllocationInput').value === 'shared'
& & document.getElementById('connectionValueTypeInput').value === 'delefiber',
2026-07-09 23:44:30 +02:00
};
if (!payload.name) {
feedback.textContent = 'Navn er påkrævet.';
return;
}
if (!payload.address) {
feedback.textContent = 'Adresse er påkrævet.';
return;
}
if (payload.value_type === 'subscription' & & !payload.subscription_id) {
feedback.textContent = 'Vælg et gyldigt abonnement.';
return;
}
feedback.textContent = 'Gemmer...';
if (saveButton) saveButton.disabled = true;
try {
const response = await fetch('/api/v1/internet-connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
feedback.textContent = await extractErrorMessage(response, 'Kunne ikke gemme forbindelsen.');
return;
}
feedback.textContent = 'Forbindelse oprettet.';
[
'connectionNameInput',
2026-08-30 14:34:43 +02:00
'connectionVendorInput',
2026-07-09 23:44:30 +02:00
'connectionCustomerIdInput',
'connectionCircuitInput',
'connectionAddressInput',
'connectionTechnologyInput',
'connectionDownloadInput',
'connectionUploadInput',
'connectionPurchaseInput',
'connectionSalesInput',
'connectionNotesInput',
].forEach((id) => {
const field = document.getElementById(id);
if (field) field.value = '';
});
document.getElementById('connectionStatusInput').value = 'active';
document.getElementById('connectionAllocationInput').value = 'dedicated';
document.getElementById('connectionValueTypeInput').value = 'other';
document.getElementById('connectionValueLabelInput').value = 'Mangler klassifikation';
document.getElementById('connectionSubscriptionInput').value = '';
document.getElementById('connectionSubscriptionIdInput').value = '';
toggleCreateValueFields();
await loadInternetPage();
} catch (error) {
feedback.textContent = error?.message || 'Netværksfejl under gem.';
} finally {
if (saveButton) saveButton.disabled = false;
}
}
function resetFilters() {
document.getElementById('searchInput').value = '';
document.getElementById('providerFilter').value = '';
document.getElementById('statusFilter').value = '';
document.getElementById('valueTypeFilter').value = '';
loadInternetPage();
}
function setActiveTab(tab) {
activeTab = tab;
document.getElementById('tabAll').classList.toggle('active', tab === 'all');
document.getElementById('tabShared').classList.toggle('active', tab === 'shared');
document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet');
2026-08-30 14:34:43 +02:00
document.getElementById('tabDedicated').classList.toggle('active', tab === 'dedicated');
document.getElementById('tabUnallocated').classList.toggle('active', tab === 'unallocated');
document.getElementById('tabInvoices').classList.toggle('active', tab === 'invoices');
const invoiceMode = tab === 'invoices';
document.getElementById('connectionsMetrics').classList.toggle('d-none', invoiceMode);
document.getElementById('connectionsOverview').classList.toggle('d-none', invoiceMode);
document.getElementById('createConnectionBlock').classList.add('collapse');
document.getElementById('createConnectionBlock').classList.remove('show');
document.getElementById('newConnectionBtn').classList.toggle('d-none', invoiceMode);
document.getElementById('invoiceProcessingOverview').classList.toggle('d-none', !invoiceMode);
if (invoiceMode) loadInvoiceSyncRuns();
else loadInternetPage();
}
function refreshActiveTab() {
if (activeTab === 'invoices') return loadInvoiceSyncRuns();
return loadInternetPage();
2026-07-09 23:44:30 +02:00
}
function toggleCreateValueFields() {
const valueType = document.getElementById('connectionValueTypeInput').value;
document.getElementById('createValueLabelWrap').classList.toggle('d-none', valueType !== 'other');
document.getElementById('createSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription');
}
function buildSubscriptionLabel(item) {
return `${item.id} · ${item.subscription_number || '-'} · ${item.product_name || '-'} · ${item.customer_name || '-'}`;
}
function resolveSubscriptionId(value) {
const raw = String(value || '').trim();
if (!raw) return null;
const idMatch = raw.match(/^(\d+)\b/);
if (idMatch) return Number(idMatch[1]);
const matched = subscriptionOptions.find((item) => buildSubscriptionLabel(item).toLowerCase() === raw.toLowerCase());
return matched ? Number(matched.id) : null;
}
async function loadSubscriptionOptions() {
try {
const response = await fetch('/api/v1/internet-connections/subscription-options');
subscriptionOptions = response.ok ? await response.json() : [];
} catch (error) {
subscriptionOptions = [];
}
document.getElementById('subscriptionLookupList').innerHTML = subscriptionOptions
.map((item) => `< option value = "${buildSubscriptionLabel(item)}" > < / option > `)
.join('');
}
2026-08-30 14:34:43 +02:00
async function loadInternetVendors() {
const select = document.getElementById('connectionVendorInput');
try {
const response = await fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100');
const vendors = response.ok ? await response.json() : [];
select.innerHTML = '< option value = "" > Vælg internetleverandør< / option > ' + vendors
.map((vendor) => `< option value = "${vendor.id}" > ${escapeHtml(vendor.name)}< / option > `).join('');
} catch (error) {
select.innerHTML = '< option value = "" > Kunne ikke hente leverandører< / option > ';
}
}
2026-07-09 23:44:30 +02:00
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadInternetPage();
});
document.getElementById('connectionSubscriptionInput').addEventListener('change', () => {
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
});
toggleCreateValueFields();
2026-08-30 14:34:43 +02:00
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
2026-07-28 14:18:24 +02:00
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
2026-07-09 23:44:30 +02:00
});
< / script >
< datalist id = "subscriptionLookupList" > < / datalist >
{% endblock %}