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-09-11 13:51:10 +02:00
.quick-customer-results {
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 0 0 12px 12px;
background: var(--bg-card);
}
.quick-customer-result {
display: block;
width: 100%;
padding: 0.7rem 0.85rem;
border: 0;
border-bottom: 1px solid var(--border-color);
background: transparent;
color: var(--text-primary);
text-align: left;
}
.quick-customer-result:hover { background: rgba(15, 76, 117, 0.09); }
.quick-type-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:.75rem; }
.quick-type-choice { border:2px solid var(--border-color); border-radius:14px; padding:1rem; background:var(--bg-card); color:var(--text-primary); text-align:left; }
.quick-type-choice:hover { border-color:#0f4c75; background:rgba(15,76,117,.06); }
.quick-type-choice.active { border-color:#0f4c75; background:#0f4c75; color:#fff; box-shadow:0 8px 22px rgba(15,76,117,.22); }
.quick-type-choice.active .text-muted { color:rgba(255,255,255,.8)!important; }
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 >
2026-09-11 13:51:10 +02:00
< button class = "btn btn-primary" id = "newConnectionBtn" type = "button" data-bs-toggle = "modal" data-bs-target = "#quickSetupModal" >
< i class = "bi bi-plus-lg me-1" > < / i > Opret eller tilknyt
2026-07-09 23:44:30 +02:00
< / 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 >
2026-09-11 13:51:10 +02:00
< / div >
< div class = "modal fade" id = "quickSetupModal" tabindex = "-1" aria-hidden = "true" >
< div class = "modal-dialog modal-lg modal-dialog-scrollable" >
< div class = "modal-content border-0 shadow-lg" >
< div class = "modal-header" >
< div > < div class = "small text-uppercase text-muted fw-semibold" > Hurtig oprettelse< / div > < h5 class = "modal-title mb-0" > Forbindelse og abonnement< / h5 > < / div >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
< div class = "quick-type-grid mb-3" >
< button class = "quick-type-choice" type = "button" data-quick-type = "physical" onclick = "selectQuickSetupType('physical')" > < div class = "fw-bold" > < i class = "bi bi-router me-2" > < / i > Fysisk forbindelse< / div > < div class = "small text-muted mt-1" > En direkte forbindelse fra en internetleverandør< / div > < / button >
< button class = "quick-type-choice" type = "button" data-quick-type = "shared" onclick = "selectQuickSetupType('shared')" > < div class = "fw-bold" > < i class = "bi bi-share me-2" > < / i > Delefiber / hovedforbindelse< / div > < div class = "small text-muted mt-1" > BMC Networks ejer fiberen, og den kan deles til flere kunder< / div > < / button >
< button class = "quick-type-choice" type = "button" data-quick-type = "bmcnet" onclick = "selectQuickSetupType('bmcnet')" > < div class = "fw-bold" > < i class = "bi bi-diagram-3 me-2" > < / i > BMCnet (delefiber)< / div > < div class = "small text-muted mt-1" > En kundeforbindelse under en delt BMC-hovedforbindelse< / div > < / button >
< / div >
< div class = "alert alert-info mb-0" id = "quickTypeHint" > Vælg først hvilken type forbindelse du vil oprette.< / div >
< div class = "row g-3 d-none mt-1" id = "quickSetupFields" >
< div class = "col-12" > < div class = "fw-semibold" > 1. Kunde< / div > < / div >
< div class = "col-12" id = "quickCustomerWrap" >
< input class = "form-control" id = "quickCustomerLookup" placeholder = "Søg kundenavn eller CVR…" autocomplete = "off" >
< input type = "hidden" id = "quickCustomerId" >
< div class = "quick-customer-results d-none" id = "quickCustomerResults" > < / div >
< / div >
< div class = "col-12 d-none" id = "quickOwnerWrap" > < div class = "alert alert-success mb-0" > < i class = "bi bi-building-check me-2" > < / i > < strong > Ejer: BMC Networks< / strong > · forbindelsen markeres som delt hovedfiber.< / div > < / div >
< div class = "col-12 quick-subscription-field" > < hr class = "my-1" > < div class = "fw-semibold" > 2. Abonnement< / div > < / div >
< div class = "col-md-8 quick-subscription-field" > < label class = "form-label" > Internetprodukt< / label > < select class = "form-select" id = "quickProduct" > < option value = "" > Vælg internetprodukt…< / option > < / select > < / div >
< div class = "col-md-4 quick-subscription-field" > < label class = "form-label" > Pris pr. periode< / label > < input class = "form-control" id = "quickPrice" type = "number" min = "0" step = "0.01" > < / div >
< div class = "col-md-4 quick-subscription-field" > < label class = "form-label" > Startdato< / label > < input class = "form-control" id = "quickStartDate" type = "date" > < / div >
< div class = "col-md-4 quick-subscription-field" > < label class = "form-label" > Interval< / label > < select class = "form-select" id = "quickInterval" > < option value = "monthly" > Månedlig< / option > < option value = "quarterly" > Kvartalsvis< / option > < option value = "yearly" > Årlig< / option > < / select > < / div >
< div class = "col-md-4 quick-subscription-field" > < label class = "form-label" > Fakturadag< / label > < input class = "form-control" id = "quickBillingDay" type = "number" min = "1" max = "31" value = "1" > < / div >
< div class = "col-12" > < hr class = "my-1" > < div class = "fw-semibold" id = "quickConnectionStepTitle" > 3. Forbindelse< / div > < / div >
< div class = "col-12 d-none" id = "quickSharedHeadWrap" > < label class = "form-label" > Delt hovedforbindelse< / label > < select class = "form-select" id = "quickSharedHead" > < option value = "" > Vælg hovedforbindelse…< / option > < / select > < div class = "small mt-1" id = "quickSharedHeadSuggestion" > Skriv installationsadressen, så foreslår systemet en hovedforbindelse.< / div > < / div >
< div class = "col-12" id = "quickConnectionModeWrap" > < select class = "form-select" id = "quickConnectionMode" onchange = "toggleQuickConnectionMode()" > < option value = "new" > Opret ny forbindelse< / option > < option value = "existing" > Knyt en eksisterende forbindelse< / option > < / select > < / div >
< div class = "col-12 d-none" id = "quickExistingWrap" > < select class = "form-select" id = "quickExistingConnection" > < option value = "" > Vælg forbindelse…< / option > < / select > < / div >
< div class = "col-md-6 quick-new-field" > < label class = "form-label" > Navn< / label > < input class = "form-control" id = "quickConnectionName" placeholder = "Fx Internet - Kundenavn" > < / div >
< div class = "col-md-6 quick-new-field quick-physical-field" > < label class = "form-label" > Leverandør< / label > < select class = "form-select" id = "quickVendor" > < option value = "" > Vælg leverandør…< / option > < / select > < / div >
< div class = "col-md-6 quick-new-field quick-physical-field" > < label class = "form-label" > Kredsløbsnummer< / label > < input class = "form-control" id = "quickCircuit" > < / div >
< div class = "col-md-6 quick-new-field" > < label class = "form-label" > Installationsadresse< / label > < input class = "form-control" id = "quickAddress" > < / div >
< div class = "col-12" id = "quickAdvancedConnectionFields" > < details class = "border rounded p-3" > < summary class = "fw-semibold" > Avanceret — valgfrit< / summary > < div class = "row g-2 mt-2" > < div class = "col-md-4" > < label class = "form-label" > Teknologi< / label > < input class = "form-control" id = "quickTechnology" placeholder = "Fiber" > < / div > < div class = "col-md-4" > < label class = "form-label" > Download Mbps< / label > < input class = "form-control" id = "quickDownload" type = "number" > < / div > < div class = "col-md-4" > < label class = "form-label" > Upload Mbps< / label > < input class = "form-control" id = "quickUpload" type = "number" > < / div > < div class = "col-md-4" > < label class = "form-label" > Kost pr. måned< / label > < input class = "form-control" id = "quickCost" type = "number" min = "0" step = "0.01" > < / div > < div class = "col-md-8" > < label class = "form-label" > Noter< / label > < input class = "form-control" id = "quickNotes" > < / div > < / div > < / details > < / div >
< div class = "col-12 d-none" id = "quickBmcnetNotesWrap" > < label class = "form-label" > Noter < span class = "text-muted" > (valgfrit)< / span > < / label > < input class = "form-control" id = "quickBmcnetNotes" > < / div >
< div class = "col-12" > < div class = "alert alert-light border mb-0" id = "quickSetupSummary" > Vælg kunde og produkt. Systemet opretter en abonnementssag, et abonnement som kladde og forbinder det hele.< / div > < / div >
< / div >
< / div >
< div class = "modal-footer" > < span class = "small text-muted me-auto" id = "quickSetupFeedback" > < / span > < button class = "btn btn-outline-secondary" type = "button" data-bs-dismiss = "modal" > Luk< / button > < button class = "btn btn-primary d-none" id = "quickSetupSubmit" type = "button" onclick = "submitQuickSetup()" > < i class = "bi bi-check2-circle me-1" > < / i > Opret forbindelse og abonnement< / button > < / div >
< / div >
< / div >
2026-07-28 14:18:24 +02:00
< / 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-09-11 13:51:10 +02:00
let quickCustomerOptions = [];
let quickProductOptions = [];
let quickCustomerTimer = null;
let quickSetupType = null;
let quickSharedHeadOptions = [];
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);
2026-09-11 13:51:10 +02:00
const isOwnedSharedFiber = Boolean(item.is_shared_head & & (item.value_type === 'delefiber' || item.is_manual_shared));
2026-08-30 14:34:43 +02:00
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-09-11 13:51:10 +02:00
< div > ${escapeHtml(isOwnedSharedFiber ? 'BMC Networks' : (item.customer_name || '-'))}< / div >
< div class = "internet-mini" > ${isOwnedSharedFiber ? 'Ejer · delt hovedfiber' : `Kunde-ID: ${item.customer_id || '-'}`}< / div >
${!isOwnedSharedFiber & & 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 > ` : ''}
${!isOwnedSharedFiber & & !uniqueSuggestion & & suggestionCount > 1 ? `< div class = "internet-mini text-warning mt-1" > ${suggestionCount} adresseforslag · vælg på forbindelsen< / div > ` : ''}
${isOwnedSharedFiber ? '' : item.sla_subscription_id
2026-08-30 14:34:43 +02:00
? `< 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('');
2026-09-11 13:51:10 +02:00
const quickVendor = document.getElementById('quickVendor');
if (quickVendor) quickVendor.innerHTML = '< option value = "" > Vælg leverandør…< / option > ' + vendors
.map((vendor) => `< option value = "${vendor.id}" > ${escapeHtml(vendor.name)}< / option > `).join('');
2026-08-30 14:34:43 +02:00
} catch (error) {
select.innerHTML = '< option value = "" > Kunne ikke hente leverandører< / option > ';
}
}
2026-09-11 13:51:10 +02:00
function quickCustomerLabel(item) {
return `${item.id} · ${item.name || '-'}`;
}
function resolveQuickCustomerId() {
const raw = document.getElementById('quickCustomerLookup').value.trim();
const normalized = raw.toLowerCase();
const match = quickCustomerOptions.find((item) =>
quickCustomerLabel(item).toLowerCase() === normalized
|| String(item.name || '').trim().toLowerCase() === normalized
|| String(item.id) === raw
);
const id = match ? Number(match.id) : null;
document.getElementById('quickCustomerId').value = id || '';
if (match & & !document.getElementById('quickConnectionName').value.trim()) {
document.getElementById('quickConnectionName').value = `Internet - ${match.name}`;
}
if (match) {
const customerAddress = [match.address, match.postal_code, match.city].filter(Boolean).join(', ');
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
suggestQuickSharedHead();
}
updateQuickSetupSummary();
return id;
}
async function searchQuickCustomers(query) {
const q = String(query || '').trim();
const results = document.getElementById('quickCustomerResults');
if (q.length < 2 ) {
quickCustomerOptions = [];
results.innerHTML = '';
results.classList.add('d-none');
return;
}
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(q)}`);
if (document.getElementById('quickCustomerLookup').value.trim() !== q) return;
if (response.status === 401) {
quickCustomerOptions = [];
results.innerHTML = '< div class = "p-3 text-danger" > < strong > Din session er udløbet.< / strong > < br > < a href = "/login" > Log ind igen< / a > og åbn derefter guiden på ny.< / div > ';
results.classList.remove('d-none');
return;
}
if (!response.ok) {
quickCustomerOptions = [];
results.innerHTML = `< div class = "p-3 text-danger" > Kunderne kunne ikke hentes (fejl ${response.status}).< / div > `;
results.classList.remove('d-none');
return;
}
quickCustomerOptions = response.ok ? await response.json() : [];
results.innerHTML = quickCustomerOptions.length
? quickCustomerOptions.map((item) => `
< button class = "quick-customer-result" type = "button" data-quick-customer-id = "${item.id}" >
< strong > ${escapeHtml(item.name || '-')}< / strong >
< span class = "small text-muted ms-2" > ${item.cvr_nummer ? `CVR ${escapeHtml(item.cvr_nummer)}` : `ID ${item.id}`}< / span >
${item.address ? `< div class = "small text-muted" > ${escapeHtml([item.address, item.postal_code, item.city].filter(Boolean).join(', '))}< / div > ` : ''}
< / button > `).join('')
: '< div class = "small text-muted p-3" > Ingen kunder fundet< / div > ';
results.classList.remove('d-none');
}
function selectQuickCustomer(customerId) {
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(customerId));
if (!customer) return;
document.getElementById('quickCustomerLookup').value = customer.name || quickCustomerLabel(customer);
document.getElementById('quickCustomerId').value = String(customer.id);
document.getElementById('quickCustomerResults').classList.add('d-none');
if (!document.getElementById('quickConnectionName').value.trim()) {
document.getElementById('quickConnectionName').value = `Internet - ${customer.name}`;
}
const customerAddress = [customer.address, customer.postal_code, customer.city].filter(Boolean).join(', ');
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
suggestQuickSharedHead();
updateQuickSetupSummary();
}
async function loadQuickProducts() {
const response = await fetch('/api/v1/products?limit=1000&is_active=true');
const products = response.ok ? await response.json() : [];
quickProductOptions = products.filter((item) => {
const text = `${item.name || ''} ${item.short_description || ''}`.toLowerCase();
const network = item.attributes_json?.network || {};
return network.kind === 'internet_access' || /internet|fiber|bredb/.test(text);
});
document.getElementById('quickProduct').innerHTML = '< option value = "" > Vælg internetprodukt…< / option > '
+ quickProductOptions.map((item) => `< option value = "${item.id}" data-price = "${Number(item.sales_price || 0)}" > ${escapeHtml(item.name || '-')} · ${formatDKK(item.sales_price || 0)}< / option > `).join('');
}
function selectQuickSetupType(type) {
quickSetupType = type;
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.toggle('active', button.dataset.quickType === type));
document.getElementById('quickTypeHint').classList.add('d-none');
document.getElementById('quickSetupFields').classList.remove('d-none');
document.getElementById('quickSetupSubmit').classList.remove('d-none');
const isBmcnet = type === 'bmcnet';
const isShared = type === 'shared';
document.getElementById('quickSharedHeadWrap').classList.toggle('d-none', !isBmcnet);
document.getElementById('quickConnectionModeWrap').classList.toggle('d-none', isBmcnet || isShared);
document.getElementById('quickCustomerWrap').classList.toggle('d-none', isShared);
document.getElementById('quickOwnerWrap').classList.toggle('d-none', !isShared);
document.querySelectorAll('.quick-subscription-field').forEach((node) => node.classList.toggle('d-none', isShared));
document.getElementById('quickConnectionStepTitle').textContent = isShared ? '2. Forbindelse' : '3. Forbindelse';
document.getElementById('quickAdvancedConnectionFields').classList.toggle('d-none', isBmcnet);
document.getElementById('quickBmcnetNotesWrap').classList.toggle('d-none', !isBmcnet);
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', isBmcnet));
if (isBmcnet || isShared) document.getElementById('quickConnectionMode').value = 'new';
document.getElementById('quickSetupSubmit').innerHTML = isBmcnet
? '< i class = "bi bi-check2-circle me-1" > < / i > Opret BMCnet og abonnement'
: isShared
? '< i class = "bi bi-check2-circle me-1" > < / i > Opret delt hovedfiber'
: '< i class = "bi bi-check2-circle me-1" > < / i > Opret forbindelse og abonnement';
toggleQuickConnectionMode();
}
function toggleQuickConnectionMode() {
const existing = document.getElementById('quickConnectionMode').value === 'existing';
document.getElementById('quickExistingWrap').classList.toggle('d-none', !existing);
document.querySelectorAll('.quick-new-field').forEach((node) => node.classList.toggle('d-none', existing));
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', existing || quickSetupType === 'bmcnet'));
updateQuickSetupSummary();
}
async function populateQuickConnections() {
let connections = [];
try {
const response = await fetch('/api/v1/internet-connections');
connections = response.ok ? await response.json() : [];
} catch (error) {
connections = allConnections;
}
document.getElementById('quickExistingConnection').innerHTML = '< option value = "" > Vælg forbindelse…< / option > '
+ connections.filter((item) => !item.subscription_id).map((item) => `< option value = "${item.id}" > ${escapeHtml(item.name || `#${item.id}`)} · ${escapeHtml(item.customer_name || 'Ingen kunde')}< / option > `).join('');
quickSharedHeadOptions = connections.filter((item) => item.is_shared_head);
document.getElementById('quickSharedHead').innerHTML = '< option value = "" > Vælg hovedforbindelse…< / option > '
+ quickSharedHeadOptions.map((item) => `< option value = "${item.id}" > ${escapeHtml(item.name || `#${item.id}`)}${item.address ? ` · ${escapeHtml(item.address)}` : ''}< / option > `).join('');
}
function normalizeQuickAddress(value) {
return String(value || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function quickEditDistance(left, right) {
const previous = Array.from({length: right.length + 1}, (_, index) => index);
for (let i = 1; i < = left.length; i += 1) {
let diagonal = previous[0];
previous[0] = i;
for (let j = 1; j < = right.length; j += 1) {
const above = previous[j];
previous[j] = Math.min(
previous[j] + 1,
previous[j - 1] + 1,
diagonal + (left[i - 1] === right[j - 1] ? 0 : 1),
);
diagonal = above;
}
}
return previous[right.length];
}
function quickTokenSimilarity(left, right) {
if (left === right) return 1;
return 1 - (quickEditDistance(left, right) / Math.max(left.length, right.length, 1));
}
function quickAddressMatchScore(input, candidate) {
const wanted = normalizeQuickAddress(input);
const existing = normalizeQuickAddress(candidate);
if (!wanted || !existing) return 0;
if (wanted === existing) return 100;
const wantedParts = wanted.split(' ').filter((part) => part.length > 1 & & !/^\d+$/.test(part));
const existingParts = existing.split(' ').filter((part) => part.length > 1 & & !/^\d+$/.test(part));
const tokenScore = wantedParts.length
? wantedParts.reduce((sum, part) => sum + Math.max(0, ...existingParts.map((candidatePart) => quickTokenSimilarity(part, candidatePart))), 0) / wantedParts.length
: 0;
const wantedNumbers = wanted.match(/\b\d+[a-z]?\b/g) || [];
const existingNumbers = new Set(existing.match(/\b\d+[a-z]?\b/g) || []);
const wantedPostal = wantedNumbers.find((part) => /^\d{4}$/.test(part));
const wantedHouse = wantedNumbers.find((part) => part !== wantedPostal);
const samePostal = Boolean(wantedPostal & & existingNumbers.has(wantedPostal));
const sameHouseNumber = Boolean(wantedHouse & & existingNumbers.has(wantedHouse));
return Math.round((tokenScore * 65) + (sameHouseNumber ? 25 : 0) + (samePostal ? 10 : 0));
}
function suggestQuickSharedHead() {
if (quickSetupType !== 'bmcnet') return;
const address = document.getElementById('quickAddress').value.trim();
const select = document.getElementById('quickSharedHead');
const hint = document.getElementById('quickSharedHeadSuggestion');
if (address.length < 4 ) {
hint.className = 'small text-muted mt-1';
hint.textContent = 'Skriv installationsadressen, så foreslår systemet en hovedforbindelse.';
return;
}
const matches = quickSharedHeadOptions
.map((item) => ({item, score: quickAddressMatchScore(address, item.address)}))
.filter((match) => match.score >= 55)
.sort((a, b) => b.score - a.score);
if (!matches.length) {
hint.className = 'small text-warning mt-1';
hint.textContent = 'Ingen delt hovedforbindelse matcher adressen. Vælg manuelt.';
return;
}
const best = matches[0];
select.value = String(best.item.id);
hint.className = 'small text-success fw-semibold mt-1';
hint.innerHTML = `< i class = "bi bi-check-circle me-1" > < / i > Foreslået ud fra adressen: ${escapeHtml(best.item.name || `#${best.item.id}`)}${best.item.address ? ` · ${escapeHtml(best.item.address)}` : ''}`;
updateQuickSetupSummary();
}
function updateQuickSetupSummary() {
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(document.getElementById('quickCustomerId').value || 0));
const product = quickProductOptions.find((item) => Number(item.id) === Number(document.getElementById('quickProduct').value || 0));
const existing = document.getElementById('quickConnectionMode').value === 'existing';
const connectionText = existing
? document.getElementById('quickExistingConnection').selectedOptions[0]?.textContent || 'vælg forbindelse'
: document.getElementById('quickConnectionName').value.trim() || 'ny forbindelse';
const owner = quickSetupType === 'shared' ? 'BMC Networks (delefiber)' : (customer?.name || 'vælg kunde');
document.getElementById('quickSetupSummary').innerHTML = quickSetupType === 'shared'
? `< strong > Kontrol:< / strong > ${escapeHtml(owner)} · ${escapeHtml(connectionText)}. Der oprettes kun en delt hovedforbindelse — intet produkt eller abonnement.`
: `< strong > Kontrol:< / strong > ${escapeHtml(owner)} · ${escapeHtml(product?.name || 'vælg produkt')} · ${escapeHtml(connectionText)}. Abonnementet oprettes som kladde.`;
}
async function submitQuickSetup() {
const feedback = document.getElementById('quickSetupFeedback');
const button = document.getElementById('quickSetupSubmit');
const customerId = resolveQuickCustomerId();
const productId = Number(document.getElementById('quickProduct').value || 0) || null;
const existing = document.getElementById('quickConnectionMode').value === 'existing';
const payload = {
customer_id: customerId,
product_id: productId,
start_date: document.getElementById('quickStartDate').value,
connection_id: existing ? (Number(document.getElementById('quickExistingConnection').value || 0) || null) : null,
connection_name: existing ? null : document.getElementById('quickConnectionName').value.trim(),
vendor_id: existing ? null : (Number(document.getElementById('quickVendor').value || 0) || null),
circuit_number: document.getElementById('quickCircuit').value.trim() || null,
address: document.getElementById('quickAddress').value.trim() || null,
technology: document.getElementById('quickTechnology').value.trim() || null,
download_mbps: Number(document.getElementById('quickDownload').value || 0) || null,
upload_mbps: Number(document.getElementById('quickUpload').value || 0) || null,
monthly_cost: Number(document.getElementById('quickCost').value || 0),
unit_price: document.getElementById('quickPrice').value === '' ? null : Number(document.getElementById('quickPrice').value),
billing_interval: document.getElementById('quickInterval').value,
billing_day: Number(document.getElementById('quickBillingDay').value || 1),
notes: (quickSetupType === 'bmcnet' ? document.getElementById('quickBmcnetNotes').value : document.getElementById('quickNotes').value).trim() || null,
shared_fiber: quickSetupType === 'shared',
};
if (!quickSetupType) {
feedback.textContent = 'Vælg først fysisk forbindelse eller BMCnet.';
return;
}
const missingSubscription = quickSetupType !== 'shared' & & (!payload.customer_id || !payload.product_id || !payload.start_date);
if (missingSubscription || (existing ? !payload.connection_id : !payload.connection_name)) {
feedback.textContent = quickSetupType === 'shared'
? 'Skriv et navn til hovedforbindelsen.'
: 'Vælg kunde, produkt, startdato og forbindelse.';
return;
}
button.disabled = true;
feedback.textContent = 'Opretter og forbinder…';
try {
const sharedHeadId = Number(document.getElementById('quickSharedHead').value || 0) || null;
if (quickSetupType === 'bmcnet' & & !sharedHeadId) throw new Error('Vælg en delt hovedforbindelse.');
const endpoint = quickSetupType === 'bmcnet'
? `/api/v1/internet-connections/${sharedHeadId}/bmcnet-connections`
: '/api/v1/internet-connections/quick-setup';
const requestPayload = quickSetupType === 'bmcnet' ? {
customer_id: payload.customer_id,
case_title: payload.connection_name,
service_label: payload.connection_name,
address: payload.address,
billing_interval: payload.billing_interval,
billing_day: payload.billing_day,
start_date: payload.start_date,
internet_product_id: payload.product_id,
internet_unit_price: payload.unit_price,
notes: payload.notes,
} : payload;
const response = await fetch(endpoint, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(requestPayload)});
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
const connectionLink = `< a href = "/economy/internet-connections/${result.connection.id}" > Åbn forbindelsen< / a > `;
const subscriptionLink = result.sag?.id ? ` · < a href = "/sag/${result.sag.id}/v3?tab=subscription" > Åbn abonnementet< / a > ` : '';
feedback.innerHTML = `Oprettet. ${connectionLink}${subscriptionLink}`;
await loadInternetPage();
} catch (error) {
feedback.textContent = `Kunne ikke oprette: ${error.message}`;
} finally {
button.disabled = false;
}
}
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) || '';
});
2026-09-11 13:51:10 +02:00
document.getElementById('quickCustomerLookup').addEventListener('input', (event) => {
document.getElementById('quickCustomerId').value = '';
clearTimeout(quickCustomerTimer);
quickCustomerTimer = setTimeout(() => searchQuickCustomers(event.target.value), 220);
});
document.getElementById('quickCustomerLookup').addEventListener('change', resolveQuickCustomerId);
document.getElementById('quickCustomerResults').addEventListener('click', (event) => {
const button = event.target.closest('[data-quick-customer-id]');
if (button) selectQuickCustomer(Number(button.dataset.quickCustomerId));
});
document.getElementById('quickProduct').addEventListener('change', (event) => {
const option = event.target.selectedOptions[0];
document.getElementById('quickPrice').value = option?.value ? option.dataset.price || '' : '';
updateQuickSetupSummary();
});
document.getElementById('quickAddress').addEventListener('input', suggestQuickSharedHead);
['quickExistingConnection','quickConnectionName','quickStartDate','quickInterval','quickBillingDay'].forEach((id) => document.getElementById(id)?.addEventListener('change', updateQuickSetupSummary));
document.getElementById('quickSetupModal').addEventListener('show.bs.modal', async () => {
quickSetupType = null;
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.remove('active'));
document.getElementById('quickTypeHint').classList.remove('d-none');
document.getElementById('quickSetupFields').classList.add('d-none');
document.getElementById('quickSetupSubmit').classList.add('d-none');
if (!document.getElementById('quickStartDate').value) document.getElementById('quickStartDate').value = new Date().toISOString().slice(0, 10);
await loadQuickProducts();
await populateQuickConnections();
toggleQuickConnectionMode();
});
2026-07-09 23:44:30 +02:00
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 %}