2026-07-09 23:44:30 +02:00
{% extends "shared/frontend/base.html" %}
{% block title %}Forbindelsesdetaljer{% endblock %}
{% block extra_css %}
< style >
.detail-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);
2026-08-30 14:34:43 +02:00
position: relative;
overflow: hidden;
2026-07-09 23:44:30 +02:00
}
2026-08-30 14:34:43 +02:00
.detail-hero::after { content:""; position:absolute; width:190px; height:190px; right:-65px; bottom:-95px; border-radius:50%; border:28px solid rgba(15,76,117,.06); pointer-events:none; }
.detail-title-wrap { display:flex; align-items:flex-start; gap:1rem; position:relative; z-index:1; }
.detail-title-icon { width:52px; height:52px; flex:0 0 52px; display:grid; place-items:center; border-radius:16px; color:#fff; background:linear-gradient(135deg,#0f4c75,#3282b8); box-shadow:0 9px 20px rgba(15,76,117,.22); font-size:1.35rem; }
.detail-circuit-badge { display:inline-flex; align-items:center; gap:.38rem; padding:.3rem .62rem; margin-top:.55rem; border-radius:999px; background:rgba(15,76,117,.09); color:var(--accent); font-size:.78rem; font-weight:750; letter-spacing:.025em; }
.detail-section-nav { display:flex; flex-wrap:wrap; gap:.4rem; margin-top:1rem; position:relative; z-index:1; }
.detail-section-nav a { display:inline-flex; align-items:center; gap:.35rem; padding:.38rem .65rem; border-radius:9px; color:var(--text-primary); background:rgba(255,255,255,.58); border:1px solid rgba(15,76,117,.1); text-decoration:none; font-size:.78rem; font-weight:650; }
.detail-section-nav a:hover { color:var(--accent); background:#fff; transform:translateY(-1px); }
.detail-metrics-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.8rem; }
.detail-metrics-grid > [class*="col-"] { width:auto; padding:0; }
2026-07-09 23:44:30 +02:00
.detail-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);
}
2026-08-30 14:34:43 +02:00
.allocation-banner { border:1px solid #f0c36a; background:linear-gradient(135deg,#fff8e7,#fffdf7); border-radius:18px; box-shadow:0 10px 28px rgba(120,82,20,.08); }
.allocation-suggestion { border:1px solid rgba(120,82,20,.16); background:#fff; border-radius:12px; padding:.7rem .85rem; cursor:pointer; }
.allocation-suggestion:hover { border-color:#d39a2c; background:#fffaf0; }
2026-07-09 23:44:30 +02:00
.detail-metric {
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%;
2026-08-30 14:34:43 +02:00
position:relative;
overflow:hidden;
2026-07-09 23:44:30 +02:00
}
2026-08-30 14:34:43 +02:00
.detail-metric::after { content:""; position:absolute; width:55px; height:55px; right:-22px; bottom:-24px; border-radius:50%; background:rgba(15,76,117,.07); }
2026-07-09 23:44:30 +02:00
.detail-metric-label {
color: var(--text-secondary);
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-metric-value {
color: var(--text-primary);
font-size: 1.35rem;
font-weight: 700;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.8rem;
}
.detail-grid-card {
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 14px;
background: rgba(15, 76, 117, 0.03);
padding: 0.85rem;
}
2026-08-30 14:34:43 +02:00
#ipRangesList { grid-template-columns: 1fr; }
.ip-range-card {
border-left:4px solid #3282b8;
background:linear-gradient(135deg,rgba(50,130,184,.07),rgba(255,255,255,.45));
display:grid;
grid-template-columns:minmax(250px,1.35fr) repeat(3,minmax(115px,.65fr)) auto;
align-items:center;
gap:1rem;
padding:1rem 1.1rem;
}
.ip-range-cidr { display:inline-flex; align-items:center; gap:.42rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:1.03rem; }
.ip-range-main,.ip-range-meta { min-width:0; }
.ip-range-meta .label { margin-bottom:.15rem; }
.ip-range-meta .value { font-size:.92rem; overflow-wrap:anywhere; }
.ip-range-actions { justify-self:end; }
.ip-range-edit { grid-column:1/-1; border-top:1px solid rgba(15,76,117,.1); padding-top:.9rem; }
.ip-range-warning { grid-column:1/-1; display:flex; align-items:flex-start; gap:.75rem; padding:.85rem 1rem; border:1px solid #f4cccc; border-radius:12px; background:#fff8f8; }
@media (max-width: 1000px) {
.ip-range-card { grid-template-columns:repeat(2,minmax(0,1fr)); }
.ip-range-main,.ip-range-actions,.ip-range-edit { grid-column:1/-1; }
.ip-range-actions { justify-self:start; }
}
.ip-empty-state { text-align:center; padding:2.5rem 1rem; border:1px dashed rgba(15,76,117,.22); border-radius:16px; background:rgba(15,76,117,.025); }
.ip-empty-state i { display:block; color:var(--accent); opacity:.55; font-size:2rem; margin-bottom:.55rem; }
2026-07-09 23:44:30 +02:00
.detail-grid-card.editing {
border-color: rgba(15, 76, 117, 0.28);
background: rgba(15, 76, 117, 0.06);
}
.detail-grid-card .label {
display: block;
color: var(--text-secondary);
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.detail-grid-card .value {
color: var(--text-primary);
font-weight: 600;
}
.status-pill {
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;
}
.status-pill.active {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
}
.status-pill.inactive {
background: rgba(108, 117, 125, 0.14);
color: #495057;
}
.status-pill.pending,
.status-pill.planned {
background: rgba(255, 193, 7, 0.18);
color: #997404;
}
.status-pill.terminated,
.status-pill.cancelled {
background: rgba(220, 53, 69, 0.12);
color: #b02a37;
}
.timeline-item {
border-left: 3px solid rgba(15, 76, 117, 0.25);
padding-left: 1rem;
margin-left: 0.5rem;
margin-bottom: 1rem;
}
.sticky-sidebar {
position: sticky;
top: 1rem;
}
.lookup-hint {
font-size: 0.78rem;
color: var(--text-secondary);
margin-top: 0.35rem;
}
.range-edit-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.75rem;
}
.detail-read-layout {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.detail-read-strip {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
padding-bottom: 0.2rem;
}
.detail-read-chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-height: 42px;
padding: 0.55rem 0.8rem;
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 14px;
background: rgba(15, 76, 117, 0.03);
line-height: 1.2;
}
.detail-read-chip .label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
font-weight: 700;
white-space: nowrap;
}
.detail-read-chip .value {
font-size: 0.96rem;
color: var(--text-primary);
font-weight: 600;
}
.detail-read-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem 1.2rem;
}
.detail-read-row {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 0.7rem;
align-items: start;
padding: 0.15rem 0;
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
}
.detail-read-row.full {
grid-column: 1 / -1;
}
.detail-read-row:last-child,
.detail-read-grid > .detail-read-row:nth-last-child(2):nth-child(odd) {
border-bottom: 0;
}
.detail-read-row .label {
font-size: 0.73rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
font-weight: 700;
padding-top: 0.2rem;
}
.detail-read-row .value {
font-size: 0.98rem;
color: var(--text-primary);
font-weight: 600;
word-break: break-word;
}
.detail-read-muted {
color: var(--text-secondary);
font-weight: 500;
}
.wizard-section {
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 16px;
background: linear-gradient(180deg, rgba(15, 76, 117, 0.03), rgba(15, 76, 117, 0.01));
padding: 1rem;
}
.wizard-section-title {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
font-weight: 700;
margin-bottom: 0.75rem;
}
@media (max-width: 991.98px) {
2026-08-30 14:34:43 +02:00
.detail-metrics-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
2026-07-09 23:44:30 +02:00
.detail-read-grid {
grid-template-columns: 1fr;
}
.detail-read-row,
.detail-read-row.full {
grid-template-columns: 1fr;
gap: 0.2rem;
}
}
2026-08-30 14:34:43 +02:00
@media (max-width: 575.98px) { .detail-metrics-grid { grid-template-columns:1fr; } .detail-title-icon { display:none; } }
2026-07-09 23:44:30 +02:00
< / style >
{% endblock %}
{% block content %}
< div class = "container-fluid py-4" >
< div class = "detail-hero mb-4" >
< div class = "d-flex flex-column flex-xl-row justify-content-between align-items-xl-start gap-3" >
2026-08-30 14:34:43 +02:00
< div class = "detail-title-wrap" >
< div class = "detail-title-icon" > < i class = "bi bi-router" > < / i > < / div >
< div >
2026-07-09 23:44:30 +02:00
< div class = "small text-uppercase fw-semibold text-muted mb-2" > Internetforbindelse< / div >
< h2 class = "h3 mb-1" id = "detailName" > Indlæser...< / h2 >
< div class = "text-muted" id = "detailSubtitle" > Henter forbindelsesdata...< / div >
2026-08-30 14:34:43 +02:00
< div class = "detail-circuit-badge" id = "detailCircuitBadge" > < i class = "bi bi-diagram-3" > < / i > < span > Henter kredsløb...< / span > < / div >
2026-07-09 23:44:30 +02:00
< div class = "small mt-2 text-muted" id = "detailSaveFeedback" role = "status" > < / div >
2026-08-30 14:34:43 +02:00
< / div >
2026-07-09 23:44:30 +02:00
< / div >
< div class = "d-flex gap-2 flex-wrap" >
< a href = "/economy/internet-connections" class = "btn btn-outline-secondary" >
< i class = "bi bi-arrow-left me-1" > < / i > Tilbage
< / a >
< button class = "btn btn-primary d-none" id = "openBmcnetWizardBtn" type = "button" onclick = "openBmcnetWizard()" >
< i class = "bi bi-diagram-3 me-1" > < / i > Ny BMCnet
< / button >
< button class = "btn btn-outline-primary" id = "editCoreDetailsBtn" type = "button" onclick = "toggleCoreEdit(true)" >
< i class = "bi bi-pencil me-1" > < / i > Rediger
< / button >
< button class = "btn btn-outline-secondary d-none" id = "cancelCoreDetailsBtn" type = "button" onclick = "toggleCoreEdit(false)" >
< i class = "bi bi-x-lg me-1" > < / i > Annuller
< / button >
< button class = "btn btn-primary d-none" id = "saveCoreDetailsBtn" type = "button" onclick = "saveCoreDetails()" >
< i class = "bi bi-save me-1" > < / i > Gem ændringer
< / button >
2026-08-31 13:01:35 +02:00
< a class = "btn btn-outline-primary" id = "createCaseForConnectionBtn" href = "/sag/new" >
< i class = "bi bi-plus-circle me-1" > < / i > Opret sag
< / a >
2026-07-09 23:44:30 +02:00
< / div >
< / div >
2026-08-30 14:34:43 +02:00
< nav class = "detail-section-nav" aria-label = "Sektioner" >
< a href = "#connection-core" > < i class = "bi bi-info-circle" > < / i > Grunddata< / a >
< a href = "#connection-ip" > < i class = "bi bi-hdd-network" > < / i > IP-ranges og adresser< / a >
< a href = "#connection-pricing" > < i class = "bi bi-cash-coin" > < / i > Pris og kontrakt< / a >
2026-08-31 13:01:35 +02:00
< a href = "#connection-cases" > < i class = "bi bi-kanban" > < / i > Sager< / a >
2026-08-30 14:34:43 +02:00
< a href = "#connection-history" > < i class = "bi bi-clock-history" > < / i > Historik< / a >
< / nav >
< / div >
< div class = "allocation-banner p-4 mb-4 d-none" id = "allocationBanner" >
< div class = "d-flex flex-column flex-xl-row justify-content-between gap-3" >
< div >
< div class = "fw-bold text-warning-emphasis" > < i class = "bi bi-exclamation-circle-fill me-2" > < / i > Forbindelsen er ikke tildelt en kunde< / div >
< div class = "small text-muted mt-1" > Vælg virksomheden på installationsadressen. Intet bliver tildelt automatisk.< / div >
< div class = "d-flex flex-wrap gap-2 mt-3" id = "allocationSuggestions" > < / div >
< / div >
< div style = "min-width:min(100%,420px);" class = "vstack gap-2" >
< select class = "form-select" id = "allocationCustomerSelect" onchange = "loadAllocationSubscriptions()" > < option value = "" > Vælg kunde…< / option > < / select >
< select class = "form-select" id = "allocationSubscriptionSelect" disabled > < option value = "" > Kun tildel kunde – intet abonnement< / option > < / select >
< div class = "d-flex gap-2 flex-wrap" >
< button class = "btn btn-warning" id = "saveAllocationBtn" type = "button" onclick = "saveConnectionAllocation()" > Tildel kunde< / button >
< a class = "btn btn-outline-secondary d-none" id = "allocationCustomerLink" href = "#" > Åbn kunde< / a >
< / div >
< div class = "small" id = "allocationFeedback" role = "status" > < / div >
< / div >
< / div >
< / div >
< div class = "alert mb-4" id = "slaBanner" role = "status" >
< div class = "d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3" >
< div id = "slaBannerContent" > < / div >
< div class = "d-flex gap-2 flex-wrap align-items-center" >
< select class = "form-select" id = "slaSubscriptionSelect" style = "min-width:300px" > < / select >
< button class = "btn btn-primary" id = "saveSlaBtn" type = "button" onclick = "saveSlaAllocation()" > Gem SLA< / button >
< / div >
< / div >
< div class = "small mt-2" id = "slaFeedback" > < / div >
2026-07-09 23:44:30 +02:00
< / div >
2026-08-30 14:34:43 +02:00
< div class = "detail-metrics-grid mb-4" >
2026-07-09 23:44:30 +02:00
< div class = "col-6 col-xl-3" >
< div class = "detail-metric" >
< div class = "detail-metric-label" > Salgspris< / div >
< div class = "detail-metric-value" id = "metricSales" > 0 kr.< / div >
< / div >
< / div >
< div class = "col-6 col-xl-3" >
< div class = "detail-metric" >
< div class = "detail-metric-label" > Indkøbspris< / div >
< div class = "detail-metric-value" id = "metricPurchase" > 0 kr.< / div >
< / div >
< / div >
< div class = "col-6 col-xl-3" >
< div class = "detail-metric" >
< div class = "detail-metric-label" > Dækningsbidrag< / div >
< div class = "detail-metric-value" id = "metricMargin" > 0 kr.< / div >
< / div >
< / div >
< div class = "col-6 col-xl-3" >
< div class = "detail-metric" >
< div class = "detail-metric-label" > IP-adresser< / div >
< div class = "detail-metric-value" id = "metricIps" > 0< / div >
< / div >
< / div >
< div class = "col-12 col-xl-3" >
< div class = "detail-metric" >
< div class = "detail-metric-label" > Båndbredde i brug< / div >
< div class = "detail-metric-value" id = "metricBandwidth" > 0 / 0 Mbps< / div >
< div class = "small text-muted mt-1" id = "metricBandwidthSub" > 0%< / div >
< / div >
< / div >
< / div >
< div class = "row g-4" >
< div class = "col-xl-8" >
2026-08-30 14:34:43 +02:00
< div class = "detail-panel p-4 mb-4" id = "connection-core" >
2026-07-09 23:44:30 +02:00
< div class = "d-flex justify-content-between align-items-center mb-3" >
< h5 class = "mb-0" > Grunddata< / h5 >
< span id = "detailStatusBadge" class = "status-pill inactive" > -< / span >
< / div >
< div id = "coreReadView" class = "detail-read-layout mb-2" > < / div >
< div class = "row g-3 d-none" id = "coreEditForm" >
< div class = "col-md-6" >
< label class = "form-label" > Navn< / label >
< input type = "text" class = "form-control" id = "fieldName" >
< / div >
< div class = "col-md-3" >
< label class = "form-label" > Leverandør< / label >
2026-08-30 14:34:43 +02:00
< select class = "form-select" id = "fieldVendorId" > < option value = "" > Ingen valgt< / option > < / select >
2026-07-09 23:44:30 +02:00
< / div >
< div class = "col-md-3" >
< label class = "form-label" > Kredsløb< / label >
< input type = "text" class = "form-control" id = "fieldCircuit" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Adresse< / label >
< input type = "text" class = "form-control" id = "fieldAddress" >
< / div >
< div class = "col-md-3" >
< label class = "form-label" > Kunde< / label >
< input type = "text" class = "form-control" id = "fieldCustomerLookup" list = "customerLookupList" placeholder = "Søg kunde" >
< input type = "hidden" id = "fieldCustomerId" >
< div class = "lookup-hint" > Søg på kundenavn og behold ID i baggrunden.< / div >
< / div >
< div class = "col-md-3" >
< label class = "form-label" > Parent-forbindelse< / label >
< input type = "text" class = "form-control" id = "fieldParentLookup" list = "connectionLookupList" placeholder = "Søg forbindelse" >
< input type = "hidden" id = "fieldParentId" >
< div class = "lookup-hint" > Bruges hvis denne ligger under en hovedforbindelse.< / div >
< / div >
< div class = "col-md-3" >
< label class = "form-label" > Forbindelsestype< / label >
< input type = "text" class = "form-control" id = "fieldConnectionType" >
< / div >
< div class = "col-md-3" >
< label class = "form-label" > Teknologi< / label >
< input type = "text" class = "form-control" id = "fieldTechnology" >
< / div >
< div class = "col-md-2" >
< label class = "form-label" > Download< / label >
< input type = "number" class = "form-control" id = "fieldDownload" >
< / div >
< div class = "col-md-2" >
< label class = "form-label" > Upload< / label >
< input type = "number" class = "form-control" id = "fieldUpload" >
< / div >
< div class = "col-md-2" >
< label class = "form-label" > Fallback Mbps< / label >
< input type = "number" class = "form-control" id = "fieldSpeed" >
< / div >
< div class = "col-md-3" >
< label class = "form-label" > Status< / label >
< select class = "form-select" id = "fieldStatus" >
< option value = "active" > Aktiv< / option >
< option value = "planned" > Planlagt< / option >
< option value = "pending" > Afventer< / option >
< option value = "inactive" > Inaktiv< / option >
< option value = "terminated" > Opsagt< / option >
< / select >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Overvågning< / label >
< input type = "text" class = "form-control" id = "fieldMonitoringUrl" placeholder = "https://..." >
< / div >
2026-08-30 14:34:43 +02:00
< div class = "col-12" >
< div class = "form-check form-switch border rounded-3 p-3 ps-5 bg-light" >
< input class = "form-check-input" type = "checkbox" role = "switch" id = "fieldManualShared" onchange = "toggleManualDelefiber()" >
< label class = "form-check-label fw-semibold" for = "fieldManualShared" > BMC Delefiber< / label >
< div class = "small text-muted" > BMC ejer hovedforbindelsen og udstyret og kan dele den ud til flere BMCnet-kunder.< / div >
< / div >
< / div >
2026-07-09 23:44:30 +02:00
< div class = "col-md-3" >
< label class = "form-label" > Allokering< / label >
< select class = "form-select" id = "fieldAllocationModel" >
< option value = "dedicated" > Dedikeret< / option >
< option value = "shared" > Delt< / option >
< / select >
< / div >
< div class = "col-md-3" >
< label class = "form-label" > Værdi-type< / label >
< select class = "form-select" id = "fieldValueType" onchange = "toggleValueFields()" >
< option value = "subscription" > Abonnement< / option >
< option value = "bmc_networks" > BMC Networks< / option >
< option value = "delefiber" > Delefiber< / option >
< option value = "other" > Anden< / option >
< / select >
< / div >
< div class = "col-md-6" id = "fieldValueLabelWrap" >
< label class = "form-label" > Værdi / klassifikation< / label >
< input type = "text" class = "form-control" id = "fieldValueLabel" placeholder = "fx delt transit, intern backbone" >
< / div >
< div class = "col-md-6 d-none" id = "fieldSubscriptionWrap" >
< label class = "form-label" > Abonnement< / label >
< input type = "text" class = "form-control" id = "fieldSubscriptionLookup" list = "subscriptionLookupList" placeholder = "Søg abonnement" >
< input type = "hidden" id = "fieldSubscriptionId" >
< div class = "lookup-hint" > Forbindelsen må kun have ét aktivt abonnement direkte koblet på.< / div >
< / div >
< div class = "col-md-3" >
< label class = "form-label" > Kontrakt start< / label >
< input type = "date" class = "form-control" id = "fieldContractStart" >
< / div >
< div class = "col-md-3" >
< label class = "form-label" > Kontrakt slut< / label >
< input type = "date" class = "form-control" id = "fieldContractEnd" >
< / div >
< div class = "col-12" >
< label class = "form-label" > Noter< / label >
< textarea class = "form-control" id = "fieldNotes" rows = "3" > < / textarea >
< / div >
< / div >
< / div >
2026-08-30 14:34:43 +02:00
< div class = "detail-panel p-4 mb-4" id = "connection-ip" >
2026-07-09 23:44:30 +02:00
< div class = "d-flex justify-content-between align-items-center mb-3" >
< div >
< h5 class = "mb-0" > IP-ranges< / h5 >
< div class = "small text-muted" id = "rangeCreateFeedback" > < / div >
< / div >
< button class = "btn btn-sm btn-outline-primary" type = "button" data-bs-toggle = "collapse" data-bs-target = "#createRangeForm" > Tilføj range< / button >
< / div >
< div class = "collapse mb-3" id = "createRangeForm" >
< div class = "row g-2" >
< div class = "col-md-3" >
< label class = "form-label small text-muted mb-1" for = "rangeCidrInput" > CIDR< / label >
< input type = "text" class = "form-control" id = "rangeCidrInput" placeholder = "fx 87.116.1.200/29" >
< / div >
< div class = "col-md-3" >
< label class = "form-label small text-muted mb-1" for = "rangeNameInput" > Navn< / label >
< input type = "text" class = "form-control" id = "rangeNameInput" placeholder = "Valgfrit, ellers bruges CIDR" >
< / div >
< div class = "col-md-3" >
< label class = "form-label small text-muted mb-1" for = "rangeProviderReferenceInput" > Provider ref.< / label >
< input type = "text" class = "form-control" id = "rangeProviderReferenceInput" placeholder = "Provider ref." >
< / div >
< div class = "col-md-3" >
< label class = "form-label small text-muted mb-1" for = "rangeContractInput" > Kontrakt< / label >
< input type = "text" class = "form-control" id = "rangeContractInput" placeholder = "Kontrakt" >
< / div >
< div class = "col-md-4" >
< label class = "form-label small text-muted mb-1" for = "rangeServiceAddressInput" > Serviceadresse< / label >
< input type = "text" class = "form-control" id = "rangeServiceAddressInput" placeholder = "Serviceadresse" >
< / div >
< div class = "col-md-4" >
< label class = "form-label small text-muted mb-1" for = "rangeDescriptionInput" > Beskrivelse< / label >
< input type = "text" class = "form-control" id = "rangeDescriptionInput" placeholder = "Beskrivelse" >
< / div >
< div class = "col-md-4" >
< label class = "form-label small text-muted mb-1" for = "rangeCustomerLookupInput" > Kunde på range< / label >
< input type = "text" class = "form-control" id = "rangeCustomerLookupInput" list = "customerLookupList" placeholder = "Kunde på range" >
< input type = "hidden" id = "rangeCustomerIdInput" >
< / div >
< div class = "col-md-2" >
< label class = "form-label small text-muted mb-1" for = "rangeMonthlyCostInput" > Kost< / label >
< input type = "number" class = "form-control" id = "rangeMonthlyCostInput" placeholder = "Kost" >
< / div >
< div class = "col-md-2" >
< label class = "form-label small text-muted mb-1" for = "rangeSalesPriceInput" > Salg< / label >
< input type = "number" class = "form-control" id = "rangeSalesPriceInput" placeholder = "Salg" >
< / div >
< div class = "col-md-2" > < button class = "btn btn-primary w-100" id = "createRangeButton" type = "button" onclick = "createRange()" > Gem< / button > < / div >
< / div >
< / div >
< div id = "ipRangesList" class = "detail-grid" > < / div >
< / div >
< div class = "detail-panel p-4 mb-4" >
< div class = "d-flex justify-content-between align-items-center mb-3" >
< div >
< h5 class = "mb-0" > IP-adresser< / h5 >
< div class = "small text-muted" id = "ipAddressSummary" > Indlæser...< / div >
< / div >
< button class = "btn btn-sm btn-outline-primary" type = "button" data-bs-toggle = "collapse" data-bs-target = "#createAddressForm" > Tilføj IP< / button >
< / div >
< div class = "collapse mb-3" id = "createAddressForm" >
< div class = "row g-2" >
< div class = "col-md-3" > < select class = "form-select" id = "addressRangeSelect" > < / select > < / div >
< div class = "col-md-3" > < input type = "text" class = "form-control" id = "addressIpInput" placeholder = "IP-adresse" > < / div >
< div class = "col-md-2" >
< select class = "form-select" id = "addressStatusInput" >
< option value = "available" > Tilgængelig< / option >
< option value = "reserved" > Reserveret< / option >
< option value = "in_use" > I brug< / option >
< / select >
< / div >
< div class = "col-md-2" > < input type = "text" class = "form-control" id = "addressAssignedInput" placeholder = "Label" > < / div >
< div class = "col-md-3" >
< input type = "text" class = "form-control" id = "addressAssignedCustomerInput" list = "customerLookupList" placeholder = "Kunde" >
< input type = "hidden" id = "addressAssignedCustomerIdInput" >
< / div >
< div class = "col-md-3" >
< input type = "text" class = "form-control" id = "addressAssignedConnectionInput" list = "connectionLookupList" placeholder = "Forbindelse" >
< input type = "hidden" id = "addressAssignedConnectionIdInput" >
< / div >
< div class = "col-md-2" > < button class = "btn btn-primary w-100" type = "button" onclick = "createAddress()" > Gem< / button > < / div >
< / div >
< / div >
< div class = "row g-2 mb-3" >
< div class = "col-md-6" > < input type = "search" class = "form-control" id = "addressSearchInput" placeholder = "Søg IP, tildeling eller kommentar" > < / div >
< div class = "col-md-3" >
< select class = "form-select" id = "addressStatusFilter" >
< option value = "" > Alle statusser< / option >
< option value = "available" > Tilgængelig< / option >
< option value = "reserved" > Reserveret< / option >
< option value = "in_use" > I brug< / option >
< / select >
< / div >
< div class = "col-md-3" > < button class = "btn btn-light border w-100" type = "button" onclick = "resetAddressFilters()" > Nulstil< / button > < / div >
< / div >
< div class = "table-responsive" >
< table class = "table align-middle mb-0" >
< thead class = "table-light" >
< tr >
< th > IP< / th >
< th > Status< / th >
< th > Abonnement< / th >
< th > Firma< / th >
< th > < / th >
< / tr >
< / thead >
< tbody id = "ipAddressesTableBody" >
< tr > < td colspan = "5" class = "text-muted py-4" > Indlæser...< / td > < / tr >
< / tbody >
< / table >
< / div >
< / div >
2026-08-30 14:34:43 +02:00
< div class = "detail-panel p-4 mb-4" id = "connection-pricing" >
2026-07-09 23:44:30 +02:00
< div class = "d-flex justify-content-between align-items-center mb-3" >
< h5 class = "mb-0" > Pris og kontrakt< / h5 >
< div class = "small text-muted" id = "pricingFeedback" > < / div >
< / div >
< div class = "row g-2 mb-3" >
2026-08-30 14:34:43 +02:00
< div class = "col-md-3" > < label class = "form-label" for = "pricingEffectiveDateInput" > Gældende fra< / label > < input type = "date" class = "form-control" id = "pricingEffectiveDateInput" > < / div >
< div class = "col-md-3" > < label class = "form-label" for = "pricingPurchaseInput" > Indkøbspris< / label > < input type = "number" class = "form-control" id = "pricingPurchaseInput" placeholder = "0 kr." step = "0.01" > < / div >
< div class = "col-md-3" > < label class = "form-label" for = "pricingSalesInput" > Salgspris< / label > < input type = "number" class = "form-control" id = "pricingSalesInput" placeholder = "0 kr." step = "0.01" > < / div >
< div class = "col-md-3" > < label class = "form-label" for = "pricingNotesInput" > Note< / label > < input type = "text" class = "form-control" id = "pricingNotesInput" placeholder = "Valgfri note" > < / div >
2026-07-09 23:44:30 +02:00
< / div >
< div class = "mb-3" >
< button class = "btn btn-primary" type = "button" onclick = "createPricingEntry()" > Gem prislinje< / button >
< / div >
< div class = "detail-grid mb-3" id = "pricingSummaryGrid" > < / div >
< div id = "pricingHistoryList" > < / div >
< / div >
2026-09-07 19:05:58 +02:00
< div class = "detail-panel p-4 mb-4 d-none" id = "delefiberProductPricesPanel" >
< div class = "d-flex justify-content-between align-items-center mb-3" > < div > < h5 class = "mb-0" > BMCnet standardpriser< / h5 > < div class = "small text-muted" > Priser gælder kun for denne delefiber/adresse.< / div > < / div > < / div >
< div class = "row g-2 mb-2" > < div class = "col-md-6" > < select class = "form-select" id = "delefiberPriceProduct" > < / select > < / div > < div class = "col-md-3" > < input class = "form-control" id = "delefiberPriceAmount" type = "number" min = "0" step = "0.01" placeholder = "Kr./md." > < / div > < div class = "col-md-3" > < button class = "btn btn-primary w-100" type = "button" onclick = "saveDelefiberProductPrice()" > Gem pris< / button > < / div > < / div >
< div id = "delefiberProductPricesList" class = "small" > < / div >
< / div >
2026-07-28 14:18:24 +02:00
< div class = "detail-panel p-4 mb-4 d-none" id = "crossFieldPortsPanel" >
< div class = "d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3" >
< div >
< h5 class = "mb-0" > Porte i krydsfelt< / h5 >
< div class = "small text-muted" id = "crossFieldPortsSummary" > < / div >
< / div >
< / div >
< div class = "table-responsive" >
< table class = "table table-hover align-middle mb-0" >
< thead class = "table-light" >
< tr >
< th > Lokation / krydsfelt< / th >
< th > Port< / th >
< th > Vægstik< / th >
< th > Switch< / th >
< th > Netværk< / th >
< th > Status< / th >
< / tr >
< / thead >
< tbody id = "crossFieldPortsBody" > < / tbody >
< / table >
< / div >
< / div >
2026-08-31 13:01:35 +02:00
< div class = "detail-panel p-4 mb-4" id = "connection-cases" >
< div class = "d-flex justify-content-between align-items-center gap-2 mb-3" >
< div > < h5 class = "mb-0" > Sager på forbindelsen< / h5 > < div class = "small text-muted" > Kun direkte tilknyttede sager.< / div > < / div >
< a class = "btn btn-sm btn-primary" id = "createCaseForConnectionPanelBtn" href = "/sag/new" > < i class = "bi bi-plus-lg me-1" > < / i > Opret sag< / a >
< / div >
< div id = "connectionCasesList" > < / div >
< / div >
2026-08-30 14:34:43 +02:00
< div class = "detail-panel p-4" id = "connection-history" >
2026-07-09 23:44:30 +02:00
< h5 class = "mb-3" > Historik< / h5 >
< div id = "historyList" > < / div >
< / div >
< / div >
< div class = "col-xl-4" >
< div class = "sticky-sidebar" >
< div class = "detail-panel p-4 mb-4" >
< h5 class = "mb-3" > Relationer< / h5 >
< div class = "detail-grid" id = "relationGrid" > < / div >
< / div >
< div class = "detail-panel p-4 mb-4 d-none" id = "bmcnetChildrenPanel" >
< div class = "d-flex justify-content-between align-items-center mb-3" >
< h5 class = "mb-0" > BMCnet< / h5 >
< div class = "d-flex align-items-center gap-2" >
< span class = "small text-muted" id = "bmcnetChildrenCount" > 0 kunder< / span >
< button class = "btn btn-sm btn-outline-primary" type = "button" onclick = "openBmcnetWizard()" >
< i class = "bi bi-plus-lg me-1" > < / i > Ny BMCnet
< / button >
< / div >
< / div >
< div class = "detail-grid mb-3" id = "bmcnetChildrenSummaryGrid" > < / div >
< div id = "bmcnetChildrenList" > < / div >
< / div >
< div class = "detail-panel p-4" >
< h5 class = "mb-3" > Kontraktoverblik< / h5 >
< div id = "contractsOverviewList" > < / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< datalist id = "customerLookupList" > < / datalist >
< datalist id = "connectionLookupList" > < / datalist >
< datalist id = "subscriptionLookupList" > < / datalist >
< div class = "modal fade" id = "bmcnetWizardModal" tabindex = "-1" aria-hidden = "true" >
< div class = "modal-dialog modal-xl 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" > Ny BMCnet-forbindelse< / h5 >
< / div >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" aria-label = "Luk" > < / button >
< / div >
< div class = "modal-body" >
< div class = "alert alert-info mb-3" id = "bmcnetWizardIntro" >
Opret en kunde-forbindelse direkte under denne delte hovedforbindelse, og reserver evt. et helt ledigt IP-range med det samme.
< / div >
< div class = "wizard-section mb-3" >
< div class = "wizard-section-title" > 0. Hovedforbindelse< / div >
< div class = "row g-2 align-items-end" >
< div class = "col-lg-8" >
< label class = "form-label" > Brug denne hovedforbindelse< / label >
< select class = "form-select" id = "bmcnetWizardSharedHeadSelect" > < / select >
< / div >
< div class = "col-lg-4" >
< div class = "small text-muted" id = "bmcnetWizardHeadHint" > Wizard’ en bruger som standard den hovedforbindelse du står på nu.< / div >
< / div >
< / div >
< / div >
< div class = "row g-3" >
< div class = "col-lg-6" >
< div class = "wizard-section h-100" >
< div class = "wizard-section-title" > 1. Kunde og service< / div >
< div class = "row g-2" >
< div class = "col-12" >
< label class = "form-label" > Kunde< / label >
< input type = "text" class = "form-control" id = "bmcnetWizardCustomerLookup" list = "customerLookupList" placeholder = "Søg kunde" >
< input type = "hidden" id = "bmcnetWizardCustomerId" >
< / div >
< div class = "col-12" >
< label class = "form-label" > Forbindelsesnavn< / label >
< input type = "text" class = "form-control" id = "bmcnetWizardName" placeholder = "fx BMCnet - Kunde" >
< / div >
< div class = "col-12" >
< label class = "form-label" > Service< / label >
< input type = "text" class = "form-control" id = "bmcnetWizardService" placeholder = "fx Internet / Firewall / WAN" >
< / div >
< div class = "col-12" >
< label class = "form-label" > Adresse< / label >
< input type = "text" class = "form-control" id = "bmcnetWizardAddress" placeholder = "Serviceadresse" >
< / div >
< / div >
< / div >
< / div >
< div class = "col-lg-6" >
< div class = "wizard-section h-100" >
< div class = "wizard-section-title" > 2. Abonnement< / div >
< div class = "row g-2" >
< div class = "col-md-6" >
< label class = "form-label" > Internetprodukt< / label >
< select class = "form-select" id = "bmcnetWizardInternetProduct" > < / select >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > IP-produkt< / label >
< select class = "form-select" id = "bmcnetWizardIpProduct" > < / select >
< / div >
< div class = "col-md-4" >
< label class = "form-label" > Interval< / label >
< select class = "form-select" id = "bmcnetWizardBillingInterval" >
< option value = "monthly" > Måned< / option >
< option value = "quarterly" > Kvartal< / option >
< option value = "yearly" > År< / option >
< / select >
< / div >
< div class = "col-md-4" >
< label class = "form-label" > Faktura dag< / label >
< input type = "number" class = "form-control" id = "bmcnetWizardBillingDay" min = "1" max = "31" value = "1" >
< / div >
< div class = "col-md-4" >
< label class = "form-label" > Startdato< / label >
< input type = "date" class = "form-control" id = "bmcnetWizardStartDate" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Pris internet< / label >
< input type = "number" class = "form-control" id = "bmcnetWizardInternetPrice" placeholder = "Standard fra produkt" step = "0.01" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Pris IP-produkt< / label >
< input type = "number" class = "form-control" id = "bmcnetWizardIpPrice" placeholder = "Standard fra produkt" step = "0.01" >
< / div >
< div class = "col-12" >
< label class = "form-label" > Noter< / label >
< textarea class = "form-control" id = "bmcnetWizardNotes" rows = "3" placeholder = "Interne noter" > < / textarea >
< / div >
2026-09-07 19:05:58 +02:00
< div class = "col-12" > < details class = "border rounded-3 p-3" > < summary class = "fw-semibold" > Periode og aftalevilkår < span class = "text-muted fw-normal" > — valgfrit< / span > < / summary > < div class = "row g-2 mt-2" > < div class = "col-md-4" > < label class = "form-label" > Periode start< / label > < input type = "date" class = "form-control" id = "bmcnetWizardPeriodStart" > < / div > < div class = "col-md-4" > < label class = "form-label" > Slutdato< / label > < input type = "date" class = "form-control" id = "bmcnetWizardEndDate" > < / div > < div class = "col-md-4" > < label class = "form-label" > Opsigelsesvarsel< / label > < div class = "input-group" > < input type = "number" class = "form-control" id = "bmcnetWizardNoticeDays" min = "0" value = "30" > < span class = "input-group-text" > dage< / span > < / div > < / div > < div class = "col-md-4" > < label class = "form-label" > Faktureringsretning< / label > < select class = "form-select" id = "bmcnetWizardBillingDirection" > < option value = "forward" > Forud< / option > < option value = "backward" > Bagud< / option > < / select > < / div > < div class = "col-md-4" > < label class = "form-label" > Perioder pr. faktura< / label > < input type = "number" class = "form-control" id = "bmcnetWizardAdvanceMonths" min = "1" value = "1" > < / div > < div class = "col-md-4" > < label class = "form-label" > Fakturér før perioden< / label > < div class = "input-group" > < input type = "number" class = "form-control" id = "bmcnetWizardLeadMonths" min = "0" value = "0" > < span class = "input-group-text" > mdr.< / span > < / div > < / div > < div class = "col-md-4" > < label class = "form-label" > Første faktura< / label > < select class = "form-select" id = "bmcnetWizardFirstInvoicePolicy" > < option value = "start_date" > På startdato< / option > < option value = "next_cycle" > Ved næste cyklus< / option > < / select > < / div > < div class = "col-md-4" > < label class = "form-label" > Binding< / label > < div class = "input-group" > < input type = "number" class = "form-control" id = "bmcnetWizardBindingMonths" min = "0" value = "0" > < span class = "input-group-text" > mdr.< / span > < / div > < / div > < / div > < / details > < / div >
2026-07-09 23:44:30 +02:00
< / div >
< / div >
< / div >
< div class = "col-12" >
< div class = "wizard-section" >
< div class = "wizard-section-title" > 3. IP-range< / div >
< div class = "row g-2 align-items-end" id = "bmcnetWizardRangeWrap" >
< div class = "col-lg-8" >
< label class = "form-label" > Ledigt range på hovedforbindelsen< / label >
< select class = "form-select" id = "bmcnetWizardRangeSelect" > < / select >
< / div >
< div class = "col-lg-4" >
< div class = "small text-muted" id = "bmcnetWizardRangeHint" > Vælg et helt ledigt range, hvis kunden skal have egne IP-adresser.< / div >
< / div >
< / div >
< div class = "row g-2 align-items-end d-none" id = "bmcnetWizardIpWrap" >
< div class = "col-lg-8" >
< label class = "form-label" > Ledig IP på hovedadressens ranges< / label >
< select class = "form-select" id = "bmcnetWizardIpSelect" > < / select >
< / div >
< div class = "col-lg-4" >
< div class = "form-check mt-4" >
< input class = "form-check-input" type = "checkbox" id = "bmcnetWizardGatewayCheckbox" >
< label class = "form-check-label" for = "bmcnetWizardGatewayCheckbox" > Marker som BMCnet kunde gateway< / label >
< / div >
< div class = "small text-muted mt-2" id = "bmcnetWizardIpHint" > Vælg en ledig IP fra alle ranges på hovedadressen.< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< div class = "small mt-3" id = "bmcnetWizardFeedback" > < / div >
< / div >
< div class = "modal-footer" >
< button type = "button" class = "btn btn-outline-secondary" data-bs-dismiss = "modal" > Luk< / button >
< button type = "button" class = "btn btn-primary" id = "bmcnetWizardSubmitBtn" onclick = "createBmcnetConnection()" >
< i class = "bi bi-check2-circle me-1" > < / i > Opret BMCnet-forbindelse
< / button >
< / div >
< / div >
< / div >
< / div >
< script >
const connectionId = {{ connection_id }};
let currentConnection = null;
let currentAddresses = [];
let currentRanges = [];
let currentBmcnetChildren = [];
let editingAddressId = null;
let coreEditMode = false;
let customerOptions = [];
let connectionOptions = [];
let subscriptionOptions = [];
let bmcnetWizardProducts = [];
let bmcnetWizardModal = null;
let bmcnetWizardRangesByHead = {};
let bmcnetWizardAddressesByHead = {};
function formatDKK(value) {
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
}
function formatDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString('da-DK');
}
function formatDateTime(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('da-DK');
}
function formatSpeed(item) {
const download = Number(item?.download_mbps || 0);
const upload = Number(item?.upload_mbps || 0);
const fallback = Number(item?.speed_mbps || 0);
if (download > 0 || upload > 0) {
if (download > 0 & & upload > 0) return `${download}/${upload} Mbps`;
return `${download || upload} Mbps`;
}
if (fallback > 0) return `${fallback} Mbps`;
return '-';
}
function formatIpRole(value) {
const key = String(value || '').trim().toLowerCase();
const labels = {
bmcnet_gateway: 'BMCnet gateway',
webserver: 'Webserver',
mailserver: 'Mailserver',
unifi: 'Unifi',
firewall: 'Firewall',
vpn: 'VPN',
service: 'Service',
subscription: 'Abonnement',
};
return labels[key] || (value || '-');
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('& ', '& ')
.replaceAll('< ', '< ')
.replaceAll('>', '> ')
.replaceAll('"', '" ')
.replaceAll("'", '' ');
}
async function safeJson(response, fallback) {
if (!response || !response.ok) return fallback;
try {
return await response.json();
} catch {
return fallback;
}
}
2026-08-30 14:34:43 +02:00
function showDetailMessage(message, isError = false) {
const feedback = document.getElementById('detailSaveFeedback');
feedback.className = `small ${isError ? 'text-danger' : 'text-success'}`;
feedback.textContent = message;
feedback.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
2026-07-09 23:44:30 +02:00
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;
}
function formatHistoryValue(field, value) {
if (value === null || value === undefined || value === '') return 'Tom';
if (['monthly_cost', 'purchase_price', 'sales_price'].includes(field)) {
return formatDKK(value);
}
if (['speed_mbps', 'download_mbps', 'upload_mbps'].includes(field)) {
return `${value} Mbps`;
}
if (field === 'allocation_model') {
return value === 'shared' ? 'Delt' : 'Dedikeret';
}
if (field === 'value_type') {
const labels = {
subscription: 'Abonnement',
bmc_networks: 'BMC Networks',
delefiber: 'Delefiber',
other: 'Anden',
};
return labels[String(value)] || String(value);
}
if (field === 'status') {
const labels = {
active: 'Aktiv',
planned: 'Planlagt',
pending: 'Afventer',
inactive: 'Inaktiv',
terminated: 'Opsagt',
cancelled: 'Annulleret',
};
return labels[String(value)] || String(value);
}
return String(value);
}
function renderHistoryDetails(entry) {
const details = entry & & entry.details & & typeof entry.details === 'object' ? entry.details : {};
const changes = details.changes & & typeof details.changes === 'object' ? details.changes : null;
const labels = {
customer_id: 'Kunde',
address: 'Adresse',
monthly_cost: 'Kostpris',
technology: 'Teknologi',
connection_type: 'Forbindelsestype',
circuit_number: 'Reference',
speed_mbps: 'Fallback hastighed',
download_mbps: 'Download',
upload_mbps: 'Upload',
status: 'Status',
allocation_model: 'Allokering',
value_type: 'Værdi-type',
provider_reference: 'Provider-ref.',
contract_number: 'Kontrakt',
service_address: 'Serviceadresse',
};
const meta = [];
if (details.reference) meta.push(`Ref: ${details.reference}`);
if (details.cidr) meta.push(`Range: ${details.cidr}`);
if (details.provider_reference & & !details.reference) meta.push(`Provider-ref.: ${details.provider_reference}`);
if (details.contract_number) meta.push(`Kontrakt: ${details.contract_number}`);
const metaHtml = meta.length
? `< div class = "small text-muted mt-1" > ${meta.join(' · ')}< / div > `
: '';
if (!changes || !Object.keys(changes).length) {
return metaHtml;
}
const changesHtml = Object.entries(changes).map(([field, change]) => `
< div class = "small mt-1" >
< strong > ${labels[field] || field}< / strong > : ${formatHistoryValue(field, change.from)} → ${formatHistoryValue(field, change.to)}
< / div >
`).join('');
return `${metaHtml}< div class = "mt-2" > ${changesHtml}< / div > `;
}
function setStatusBadge(status) {
const el = document.getElementById('detailStatusBadge');
const value = String(status || 'inactive').toLowerCase();
const labels = {
active: 'Aktiv',
planned: 'Planlagt',
pending: 'Afventer',
inactive: 'Inaktiv',
terminated: 'Opsagt',
cancelled: 'Annulleret',
};
el.className = `status-pill ${value}`;
el.textContent = labels[value] || value;
}
function buildLookupLabel(item) {
return `${item.id} · ${item.name || '-'}`;
}
function populateDatalist(listId, items) {
document.getElementById(listId).innerHTML = items
.map((item) => `< option value = "${buildLookupLabel(item)}" > < / option > `)
.join('');
}
function buildSubscriptionLabel(item) {
return `${item.id} · ${item.subscription_number || '-'} · ${item.product_name || '-'} · ${item.customer_name || '-'}`;
}
function resolveLookupId(value, items) {
const raw = String(value || '').trim();
if (!raw) return null;
const idMatch = raw.match(/^(\d+)\b/);
if (idMatch) return Number(idMatch[1]);
const matched = items.find((item) => String(item.name || '').toLowerCase() === raw.toLowerCase());
return matched ? Number(matched.id) : null;
}
function bindLookupInput(textInputId, hiddenInputId, itemsRef) {
const textInput = document.getElementById(textInputId);
const hiddenInput = document.getElementById(hiddenInputId);
const sync = () => {
hiddenInput.value = resolveLookupId(textInput.value, itemsRef()) || '';
};
textInput.addEventListener('change', sync);
textInput.addEventListener('blur', sync);
}
function setLookupValue(textInputId, hiddenInputId, items, selectedId) {
const match = items.find((item) => Number(item.id) === Number(selectedId));
document.getElementById(textInputId).value = match ? buildLookupLabel(match) : '';
document.getElementById(hiddenInputId).value = selectedId || '';
}
function setSubscriptionValue(selectedId) {
const match = subscriptionOptions.find((item) => Number(item.id) === Number(selectedId));
document.getElementById('fieldSubscriptionLookup').value = match ? buildSubscriptionLabel(match) : '';
document.getElementById('fieldSubscriptionId').value = selectedId || '';
}
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 loadLookups() {
2026-08-30 14:34:43 +02:00
const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse, vendorsResponse] = await Promise.all([
2026-07-09 23:44:30 +02:00
fetch('/api/v1/customers?limit=1000& is_active=true'),
fetch('/api/v1/internet-connections'),
fetch('/api/v1/internet-connections/subscription-options?status=active'),
fetch('/api/v1/products'),
2026-08-30 14:34:43 +02:00
fetch('/api/v1/vendors?is_active=true& is_internet_provider=true& limit=100'),
2026-07-09 23:44:30 +02:00
]);
const customersPayload = customersResponse.ok ? await customersResponse.json() : { customers: [] };
customerOptions = Array.isArray(customersPayload.customers) ? customersPayload.customers : [];
connectionOptions = connectionsResponse.ok ? await connectionsResponse.json() : [];
subscriptionOptions = subscriptionsResponse.ok ? await subscriptionsResponse.json() : [];
bmcnetWizardProducts = productsResponse.ok ? await productsResponse.json() : [];
2026-08-30 14:34:43 +02:00
const internetVendors = vendorsResponse.ok ? await vendorsResponse.json() : [];
document.getElementById('fieldVendorId').innerHTML = '< option value = "" > Ingen valgt< / option > ' + internetVendors
.map((vendor) => `< option value = "${vendor.id}" > ${escapeHtml(vendor.name)}< / option > `).join('');
2026-07-09 23:44:30 +02:00
populateDatalist('customerLookupList', customerOptions);
populateDatalist(
'connectionLookupList',
connectionOptions.filter((item) => Number(item.id) !== Number(connectionId))
);
document.getElementById('subscriptionLookupList').innerHTML = subscriptionOptions
.map((item) => `< option value = "${buildSubscriptionLabel(item)}" > < / option > `)
.join('');
populateBmcnetWizardProducts();
}
function parseProductAttributes(raw) {
if (!raw) return {};
if (typeof raw === 'object') return raw;
try {
const parsed = JSON.parse(raw);
return parsed & & typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function populateBmcnetWizardProducts() {
const internetSelect = document.getElementById('bmcnetWizardInternetProduct');
const ipSelect = document.getElementById('bmcnetWizardIpProduct');
if (!internetSelect || !ipSelect) return;
const internetProducts = bmcnetWizardProducts.filter((product) => {
const kind = parseProductAttributes(product.attributes_json)?.network?.kind;
return kind === 'internet_access';
});
const ipProducts = bmcnetWizardProducts.filter((product) => {
const kind = parseProductAttributes(product.attributes_json)?.network?.kind;
return kind === 'ip_allocation';
});
internetSelect.innerHTML = '< option value = "" > Vælg internetprodukt< / option > ' + internetProducts.map((product) => (
`< option value = "${product.id}" data-price = "${Number(product.sales_price || 0)}" > ${escapeHtml(product.name || '-')}< / option > `
)).join('');
ipSelect.innerHTML = '< option value = "" > Intet IP-produkt< / option > ' + ipProducts.map((product) => (
`< option value = "${product.id}" data-price = "${Number(product.sales_price || 0)}" > ${escapeHtml(product.name || '-')}< / option > `
)).join('');
}
2026-09-07 19:05:58 +02:00
let delefiberProductPrices = [];
function isDelefiber(connection = currentConnection) {
return Boolean(connection & & !connection.parent_id & & (connection.is_shared_head || connection.allocation_model === 'shared'));
}
async function loadDelefiberProductPrices() {
const panel = document.getElementById('delefiberProductPricesPanel');
if (!panel || !isDelefiber()) { panel?.classList.add('d-none'); return; }
panel.classList.remove('d-none');
const select = document.getElementById('delefiberPriceProduct');
const priceProducts = bmcnetWizardProducts.filter((product) => ['internet_access', 'ip_allocation'].includes(parseProductAttributes(product.attributes_json)?.network?.kind));
select.innerHTML = '< option value = "" > Vælg produkt< / option > ' + priceProducts.map((product) => `< option value = "${product.id}" > ${escapeHtml(product.name || '-')} · global ${formatDKK(product.sales_price || 0)}< / option > `).join('');
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`);
if (!response.ok) throw new Error('Kunne ikke hente standardpriser');
delefiberProductPrices = await response.json();
const list = document.getElementById('delefiberProductPricesList');
list.innerHTML = delefiberProductPrices.length ? delefiberProductPrices.map((row) => `< div class = "d-flex align-items-center gap-2 border-top py-2" > < strong class = "flex-grow-1" > ${escapeHtml(row.product_name || '-')}< / strong > < span > ${formatDKK(row.monthly_price || 0)}/md.< / span > < button class = "btn btn-sm btn-outline-danger" onclick = "deleteDelefiberProductPrice(${row.product_id})" title = "Fjern" > < i class = "bi bi-x-lg" > < / i > < / button > < / div > `).join('') : '< div class = "text-muted py-2" > Ingen lokale standardpriser endnu.< / div > ';
} catch (error) { document.getElementById('delefiberProductPricesList').innerHTML = `< div class = "text-danger" > ${escapeHtml(error.message)}< / div > `; }
}
async function saveDelefiberProductPrice() {
const productId = Number(document.getElementById('delefiberPriceProduct').value || 0);
const amount = document.getElementById('delefiberPriceAmount').value;
if (!productId || amount === '') { document.getElementById('detailSaveFeedback').textContent = 'Vælg produkt og pris.'; return; }
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id:productId,monthly_price:Number(amount)})});
if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke gemme standardpris.'); return; }
document.getElementById('delefiberPriceAmount').value = '';
await loadDelefiberProductPrices();
}
async function deleteDelefiberProductPrice(productId) {
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices/${productId}`, {method:'DELETE'});
if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke fjerne standardpris.'); return; }
await loadDelefiberProductPrices();
}
2026-07-09 23:44:30 +02:00
function getSharedHeadOptions() {
2026-08-30 14:34:43 +02:00
return (connectionOptions || []).filter((item) => !item?.parent_id);
2026-07-09 23:44:30 +02:00
}
async function loadRangesForSharedHead(sharedHeadId) {
const headId = Number(sharedHeadId || 0);
if (!headId) return [];
if (bmcnetWizardRangesByHead[headId]) {
return bmcnetWizardRangesByHead[headId];
}
const response = await fetch(`/api/v1/internet-connections/${headId}/ip-ranges`);
const ranges = response.ok ? await response.json() : [];
bmcnetWizardRangesByHead[headId] = Array.isArray(ranges) ? ranges : [];
return bmcnetWizardRangesByHead[headId];
}
async function loadAddressesForSharedHead(sharedHeadId) {
const headId = Number(sharedHeadId || 0);
if (!headId) return [];
if (bmcnetWizardAddressesByHead[headId]) {
return bmcnetWizardAddressesByHead[headId];
}
const response = await fetch(`/api/v1/internet-connections/${headId}/ip-addresses`);
const addresses = response.ok ? await response.json() : [];
bmcnetWizardAddressesByHead[headId] = Array.isArray(addresses) ? addresses : [];
return bmcnetWizardAddressesByHead[headId];
}
function filterAvailableBmcnetRanges(ranges) {
return (ranges || []).filter((range) =>
range
& & range.belongs_to_connection !== false
& & range.is_fully_available
);
}
function findWizardProduct(productId) {
return bmcnetWizardProducts.find((product) => Number(product.id) === Number(productId)) || null;
}
function isStaticWanIpProduct(productId) {
const product = findWizardProduct(productId);
if (!product) return false;
const attrs = parseProductAttributes(product.attributes_json);
const prefix = Number(attrs?.network?.ip_prefix_length || 0);
const text = `${product.name || ''} ${product.short_description || ''}`.toLowerCase();
return prefix === 32 || text.includes('statisk wan');
}
async function refreshBmcnetWizardIpState(sharedHeadId) {
const headId = Number(sharedHeadId || 0);
const head = getSharedHeadOptions().find((item) => Number(item.id) === headId) || currentConnection;
const selectedAddress = (document.getElementById('bmcnetWizardAddress').value || '').trim().toLowerCase();
const addresses = await loadAddressesForSharedHead(headId);
const availableIps = (addresses || []).filter((item) => {
if (String(item.status || '').toLowerCase() !== 'available') return false;
if (Number(item.range_connection_id || 0) !== headId) return false;
const serviceAddress = String(item.range_service_address || head?.address || '').trim().toLowerCase();
if (selectedAddress & & serviceAddress & & serviceAddress !== selectedAddress) return false;
return true;
});
const ipSelect = document.getElementById('bmcnetWizardIpSelect');
ipSelect.innerHTML = `< option value = "" > Ingen ledige IP'er< / option > ${availableIps.map((item) => `
< option value = "${item.id}" data-head-id = "${headId}" data-range-id = "${item.range_id || ''}" data-ip = "${escapeHtml(item.ip_address || '')}" >
${item.ip_address} · ${item.range_cidr || '-'}${item.range_service_address ? ` · ${escapeHtml(item.range_service_address)}` : ''}
< / option >
`).join('')}`;
ipSelect.value = '';
document.getElementById('bmcnetWizardIpHint').textContent = availableIps.length
? `Der er ${availableIps.length} ledige IP'er på den valgte hovedadresse.`
: 'Ingen ledige IP’ er på den valgte hovedadresse lige nu.';
}
async function refreshBmcnetWizardAllocationMode() {
const ipProductId = Number(document.getElementById('bmcnetWizardIpProduct').value || 0) || null;
const isStaticIp = ipProductId ? isStaticWanIpProduct(ipProductId) : false;
document.getElementById('bmcnetWizardRangeWrap').classList.toggle('d-none', isStaticIp);
document.getElementById('bmcnetWizardIpWrap').classList.toggle('d-none', !isStaticIp);
if (isStaticIp) {
await refreshBmcnetWizardIpState(document.getElementById('bmcnetWizardSharedHeadSelect').value);
}
}
async function refreshBmcnetWizardHeadState(sharedHeadId) {
const headId = Number(sharedHeadId || 0);
const head = getSharedHeadOptions().find((item) => Number(item.id) === headId) || currentConnection;
const rangeSelect = document.getElementById('bmcnetWizardRangeSelect');
const hint = document.getElementById('bmcnetWizardRangeHint');
const headHint = document.getElementById('bmcnetWizardHeadHint');
const feedback = document.getElementById('bmcnetWizardFeedback');
if (headHint) {
headHint.textContent = head?.address
? `${head.name || 'Hovedforbindelse'} · ${head.address}`
: (head?.name || 'Vælg hovedforbindelse');
}
const ranges = headId === Number(connectionId)
? currentRanges
: await loadRangesForSharedHead(headId);
const availableRanges = filterAvailableBmcnetRanges(ranges);
rangeSelect.innerHTML = `< option value = "" > Ingen IP-range endnu< / option > ${availableRanges.map((range) => `
2026-09-07 19:05:58 +02:00
< option value = "${range.id}" data-prefix = "${String(range.cidr || '').split('/')[1] || ''}" > ${range.cidr} · ${range.available_addresses || 0} ledige · ${range.service_address || head?.address || '-'}< / option >
2026-07-09 23:44:30 +02:00
`).join('')}`;
hint.textContent = availableRanges.length
? `Der er ${availableRanges.length} helt ledige ranges på den valgte hovedforbindelse.`
: 'Ingen helt ledige ranges på den valgte hovedforbindelse lige nu. Vælg en anden hovedforbindelse eller opret uden IP-range.';
if (feedback) feedback.textContent = '';
await refreshBmcnetWizardAllocationMode();
}
function populateBmcnetWizard(connection) {
2026-08-30 14:34:43 +02:00
const canCreateBmcnet = Boolean(connection & & !connection.parent_id);
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet);
if (!canCreateBmcnet) return;
2026-09-07 19:05:58 +02:00
const today = new Date().toISOString().slice(0, 10);
2026-07-09 23:44:30 +02:00
document.getElementById('bmcnetWizardCustomerLookup').value = '';
document.getElementById('bmcnetWizardCustomerId').value = '';
document.getElementById('bmcnetWizardName').value = '';
document.getElementById('bmcnetWizardService').value = '';
document.getElementById('bmcnetWizardAddress').value = connection.address || '';
document.getElementById('bmcnetWizardBillingInterval').value = 'monthly';
document.getElementById('bmcnetWizardBillingDay').value = '1';
2026-09-07 19:05:58 +02:00
document.getElementById('bmcnetWizardPeriodStart').value = today;
document.getElementById('bmcnetWizardEndDate').value = '';
document.getElementById('bmcnetWizardNoticeDays').value = '30';
document.getElementById('bmcnetWizardBillingDirection').value = 'forward';
document.getElementById('bmcnetWizardAdvanceMonths').value = '1';
document.getElementById('bmcnetWizardLeadMonths').value = '0';
document.getElementById('bmcnetWizardFirstInvoicePolicy').value = 'start_date';
document.getElementById('bmcnetWizardBindingMonths').value = '0';
document.getElementById('bmcnetWizardStartDate').value = today;
2026-07-09 23:44:30 +02:00
document.getElementById('bmcnetWizardInternetProduct').value = '';
document.getElementById('bmcnetWizardIpProduct').value = '';
document.getElementById('bmcnetWizardInternetPrice').value = '';
document.getElementById('bmcnetWizardIpPrice').value = '';
document.getElementById('bmcnetWizardNotes').value = '';
document.getElementById('bmcnetWizardGatewayCheckbox').checked = false;
document.getElementById('bmcnetWizardFeedback').textContent = '';
const sharedHeadSelect = document.getElementById('bmcnetWizardSharedHeadSelect');
const sharedHeads = getSharedHeadOptions();
sharedHeadSelect.innerHTML = sharedHeads.map((head) => (
`< option value = "${head.id}" > ${escapeHtml(head.name || '-')}${head.address ? ` · ${escapeHtml(head.address)}` : ''}< / option > `
)).join('');
sharedHeadSelect.value = String(connection.id);
refreshBmcnetWizardHeadState(connection.id);
}
function openBmcnetWizard() {
2026-08-30 14:34:43 +02:00
if (!currentConnection || currentConnection.parent_id) return;
2026-07-09 23:44:30 +02:00
populateBmcnetWizard(currentConnection);
if (!bmcnetWizardModal) {
bmcnetWizardModal = new bootstrap.Modal(document.getElementById('bmcnetWizardModal'));
}
bmcnetWizardModal.show();
}
function syncBmcnetWizardPrice(selectId, inputId) {
const select = document.getElementById(selectId);
const input = document.getElementById(inputId);
if (!select || !input) return;
const option = select.options[select.selectedIndex];
if (!option || !option.value) {
input.value = '';
return;
}
2026-09-07 19:05:58 +02:00
const local = delefiberProductPrices.find((row) => Number(row.product_id) === Number(option.value));
input.value = local ? Number(local.monthly_price || 0) : (option.dataset.price || '');
}
function selectIpProductForPrefix(prefixLength) {
const select = document.getElementById('bmcnetWizardIpProduct');
if (!select || !prefixLength) return;
const product = bmcnetWizardProducts.find((item) => {
const network = parseProductAttributes(item.attributes_json)?.network || {};
return network.kind === 'ip_allocation' & & Number(network.ip_prefix_length) === Number(prefixLength);
});
if (!product) return;
select.value = String(product.id);
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
refreshBmcnetWizardAllocationMode();
2026-07-09 23:44:30 +02:00
}
async function createBmcnetConnection() {
const feedback = document.getElementById('bmcnetWizardFeedback');
const button = document.getElementById('bmcnetWizardSubmitBtn');
document.getElementById('bmcnetWizardCustomerId').value =
resolveLookupId(document.getElementById('bmcnetWizardCustomerLookup').value, customerOptions) || '';
const customerId = Number(document.getElementById('bmcnetWizardCustomerId').value || 0) || null;
const sharedHeadId = Number(document.getElementById('bmcnetWizardSharedHeadSelect').value || 0) || null;
const internetProductId = Number(document.getElementById('bmcnetWizardInternetProduct').value || 0) || null;
const ipProductId = Number(document.getElementById('bmcnetWizardIpProduct').value || 0) || null;
const useStaticWanIp = ipProductId ? isStaticWanIpProduct(ipProductId) : false;
const ipSelect = document.getElementById('bmcnetWizardIpSelect');
const selectedIpOption = ipSelect?.options?.[ipSelect.selectedIndex] || null;
const selectedIpId = useStaticWanIp ? (Number(ipSelect?.value || 0) || null) : null;
const selectedIpHeadId = useStaticWanIp ? (Number(selectedIpOption?.dataset?.headId || 0) || null) : null;
const payload = {
customer_id: customerId,
case_title: document.getElementById('bmcnetWizardName').value.trim() || null,
service_label: document.getElementById('bmcnetWizardService').value.trim() || null,
address: document.getElementById('bmcnetWizardAddress').value.trim() || null,
billing_interval: document.getElementById('bmcnetWizardBillingInterval').value || 'monthly',
billing_day: Number(document.getElementById('bmcnetWizardBillingDay').value || 1),
2026-09-07 19:05:58 +02:00
billing_direction: document.getElementById('bmcnetWizardBillingDirection').value || 'forward',
advance_months: Number(document.getElementById('bmcnetWizardAdvanceMonths').value || 1),
billing_lead_months: Number(document.getElementById('bmcnetWizardLeadMonths').value || 0),
first_invoice_policy: document.getElementById('bmcnetWizardFirstInvoicePolicy').value || 'start_date',
2026-07-09 23:44:30 +02:00
start_date: document.getElementById('bmcnetWizardStartDate').value,
2026-09-07 19:05:58 +02:00
period_start: document.getElementById('bmcnetWizardPeriodStart').value || null,
end_date: document.getElementById('bmcnetWizardEndDate').value || null,
notice_period_days: Number(document.getElementById('bmcnetWizardNoticeDays').value || 0),
binding_months: Number(document.getElementById('bmcnetWizardBindingMonths').value || 0),
2026-07-09 23:44:30 +02:00
internet_product_id: internetProductId,
internet_unit_price: document.getElementById('bmcnetWizardInternetPrice').value !== ''
? Number(document.getElementById('bmcnetWizardInternetPrice').value)
: null,
ip_product_id: ipProductId,
ip_unit_price: document.getElementById('bmcnetWizardIpPrice').value !== ''
? Number(document.getElementById('bmcnetWizardIpPrice').value)
: null,
notes: document.getElementById('bmcnetWizardNotes').value.trim() || null,
range_id: useStaticWanIp ? null : (Number(document.getElementById('bmcnetWizardRangeSelect').value || 0) || null),
ip_address_id: selectedIpId,
mark_gateway: useStaticWanIp ? Boolean(document.getElementById('bmcnetWizardGatewayCheckbox').checked) : false,
};
if (!sharedHeadId) {
feedback.textContent = 'Vælg en hovedforbindelse.';
return;
}
if (!payload.customer_id) {
feedback.textContent = 'Vælg en kunde.';
return;
}
if (!payload.internet_product_id) {
feedback.textContent = 'Vælg et internetprodukt.';
return;
}
if (!payload.start_date) {
feedback.textContent = 'Vælg en startdato.';
return;
}
if (payload.ip_product_id & & useStaticWanIp & & !payload.ip_address_id) {
feedback.textContent = 'Vælg en ledig IP-adresse til Statisk WAN IP.';
return;
}
if (payload.ip_product_id & & useStaticWanIp & & selectedIpHeadId !== sharedHeadId) {
feedback.textContent = 'Den valgte IP matcher ikke den valgte hovedforbindelse. Vælg IP igen.';
return;
}
if (payload.ip_product_id & & !useStaticWanIp & & !payload.range_id) {
feedback.textContent = 'Vælg et IP-range til IP-produktet.';
return;
}
feedback.textContent = 'Opretter sag, abonnement og BMCnet-forbindelse...';
if (button) button.disabled = true;
try {
const response = await fetch(`/api/v1/internet-connections/${sharedHeadId}/bmcnet-connections`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
feedback.textContent = await extractErrorMessage(response, 'Kunne ikke oprette BMCnet-forbindelsen.');
return;
}
const created = await response.json();
feedback.innerHTML = created?.sag?.id
? `Oprettet. < a href = "/sag/${created.sag.id}/v3" > Åbn sag #${created.sag.id}< / a > `
: 'Oprettet.';
await loadConnection();
if (bmcnetWizardModal) bmcnetWizardModal.hide();
} catch (error) {
feedback.textContent = error?.message || 'Netværksfejl under oprettelse.';
} finally {
if (button) button.disabled = false;
}
}
2026-08-30 14:34:43 +02:00
async function renderAllocationBanner(connection) {
const banner = document.getElementById('allocationBanner');
const customerSelect = document.getElementById('allocationCustomerSelect');
const suggestionsWrap = document.getElementById('allocationSuggestions');
const isBmcSharedFiber = Boolean(
connection.is_manual_shared
|| (connection.parent_id == null
& & connection.allocation_model === 'shared'
& & connection.value_type === 'delefiber')
);
const shouldSuggestCustomer = !connection.customer_id & & !isBmcSharedFiber;
banner.classList.toggle('d-none', !shouldSuggestCustomer);
if (!shouldSuggestCustomer) return;
const payload = await safeJson(
await fetch(`/api/v1/internet-connections/${connectionId}/allocation-suggestions`),
{ items: [] }
);
const suggestions = Array.isArray(payload.items) ? payload.items : [];
const suggestedIds = new Set(suggestions.map((item) => Number(item.customer_id)));
const suggestedOptions = suggestions.map((item) => (
`< option value = "${item.customer_id}" > ${escapeHtml(item.customer_name || '-')} · ${escapeHtml(item.address || '')}< / option > `
)).join('');
const otherOptions = customerOptions
.filter((item) => !suggestedIds.has(Number(item.id)))
.map((item) => `< option value = "${item.id}" > ${escapeHtml(item.name || '-')}< / option > `)
.join('');
customerSelect.innerHTML = '< option value = "" > Vælg kunde…< / option > '
+ (suggestedOptions ? `< optgroup label = "Forslag fra adressen" > ${suggestedOptions}< / optgroup > ` : '')
+ `< optgroup label = "Alle kunder" > ${otherOptions}< / optgroup > `;
suggestionsWrap.innerHTML = suggestions.length
? suggestions.map((item) => `
< button class = "allocation-suggestion text-start" type = "button" onclick = "selectAllocationCustomer(${item.customer_id})" >
< span class = "fw-semibold d-block" > ${escapeHtml(item.customer_name || '-')}< / span >
< span class = "small text-muted" > ${escapeHtml(item.address || '')}${item.location_name ? ` · ${escapeHtml(item.location_name)}` : ''}< / span >
< / button > `).join('')
: '< span class = "small text-muted" > Ingen sikre kundematch på adressen. Vælg manuelt i listen.< / span > ';
document.getElementById('allocationFeedback').textContent = suggestions.length > 1
? `${suggestions.length} virksomheder er registreret på adressen. Vælg den rigtige.`
: suggestions.length === 1 ? 'Én virksomhed matcher adressen.' : '';
}
async function selectAllocationCustomer(customerId) {
document.getElementById('allocationCustomerSelect').value = String(customerId);
await loadAllocationSubscriptions();
}
async function loadAllocationSubscriptions() {
const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null;
const select = document.getElementById('allocationSubscriptionSelect');
const customerLink = document.getElementById('allocationCustomerLink');
customerLink.classList.toggle('d-none', !customerId);
customerLink.href = customerId ? `/customers/${customerId}` : '#';
if (!customerId) {
select.disabled = true;
select.innerHTML = '< option value = "" > Kun tildel kunde – intet abonnement< / option > ';
return;
}
select.disabled = true;
select.innerHTML = '< option value = "" > Henter abonnementer…< / option > ';
const subscriptions = await safeJson(
await fetch(`/api/v1/internet-connections/subscription-options?customer_id=${customerId}& status=active`),
[]
);
select.innerHTML = '< option value = "" > Kun tildel kunde – intet abonnement< / option > '
+ subscriptions.map((item) => `< option value = "${item.id}" > ${escapeHtml(item.subscription_number || `#${item.id}`)} · ${escapeHtml(item.product_name || '-')}< / option > `).join('');
select.disabled = false;
document.getElementById('saveAllocationBtn').textContent = subscriptions.length
? 'Tildel kunde / abonnement'
: 'Tildel kunde';
}
async function saveConnectionAllocation() {
const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null;
const subscriptionId = Number(document.getElementById('allocationSubscriptionSelect').value || 0) || null;
const feedback = document.getElementById('allocationFeedback');
const button = document.getElementById('saveAllocationBtn');
if (!customerId) {
feedback.className = 'small text-danger';
feedback.textContent = 'Vælg en kunde først.';
return;
}
const payload = { customer_id: customerId };
if (subscriptionId) {
payload.subscription_id = subscriptionId;
payload.value_type = 'subscription';
payload.value_label = null;
}
button.disabled = true;
feedback.className = 'small text-muted';
feedback.textContent = 'Gemmer tildelingen…';
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Tildelingen kunne ikke gemmes.'));
await loadConnection();
} catch (error) {
feedback.className = 'small text-danger';
feedback.textContent = error.message || 'Tildelingen kunne ikke gemmes.';
} finally {
button.disabled = false;
}
}
2026-07-09 23:44:30 +02:00
async function loadConnection() {
await loadLookups();
const [
connectionResponse,
rangesResponse,
addressesResponse,
summaryResponse,
pricingResponse,
pricingHistoryResponse,
contractsResponse,
historyResponse,
2026-08-31 13:01:35 +02:00
casesResponse,
2026-07-28 14:18:24 +02:00
crossFieldPortsResponse,
2026-07-09 23:44:30 +02:00
] = await Promise.all([
fetch(`/api/v1/internet-connections/${connectionId}`),
fetch(`/api/v1/internet-connections/${connectionId}/ip-ranges`),
fetch(`/api/v1/internet-connections/${connectionId}/ip-addresses`),
fetch(`/api/v1/internet-connections/${connectionId}/ip-addresses/summary`),
fetch(`/api/v1/internet-connections/${connectionId}/pricing`),
fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`),
fetch('/api/v1/internet-connections/contracts'),
fetch(`/api/v1/internet-connections/${connectionId}/history`),
2026-08-31 13:01:35 +02:00
fetch(`/api/v1/internet-connections/${connectionId}/cases`),
2026-07-28 14:18:24 +02:00
fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`),
2026-07-09 23:44:30 +02:00
]);
if (!connectionResponse.ok) {
document.getElementById('detailName').textContent = 'Forbindelse ikke fundet';
document.getElementById('detailSubtitle').textContent = 'Den ønskede forbindelse findes ikke.';
return;
}
const connection = await connectionResponse.json();
2026-08-30 14:34:43 +02:00
const ranges = await safeJson(rangesResponse, []);
const addresses = await safeJson(addressesResponse, []);
const summary = await safeJson(summaryResponse, { available: 0, in_use: 0, reserved: 0 });
const pricing = await safeJson(pricingResponse, {});
const pricingHistory = await safeJson(pricingHistoryResponse, []);
const contracts = await safeJson(contractsResponse, []);
const history = await safeJson(historyResponse, []);
2026-08-31 13:01:35 +02:00
const cases = await safeJson(casesResponse, []);
2026-07-28 14:18:24 +02:00
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} });
2026-07-09 23:44:30 +02:00
2026-08-30 14:34:43 +02:00
const failedSections = [
[rangesResponse, 'IP-ranges'], [addressesResponse, 'IP-adresser'], [summaryResponse, 'IP-oversigt'],
[pricingResponse, 'priser'], [pricingHistoryResponse, 'prishistorik'], [contractsResponse, 'kontrakter'],
[historyResponse, 'historik'], [crossFieldPortsResponse, 'krydsfelt'],
2026-08-31 13:01:35 +02:00
[casesResponse, 'sager'],
2026-08-30 14:34:43 +02:00
].filter(([response]) => !response.ok).map(([, label]) => label);
if (failedSections.length) {
const feedback = document.getElementById('detailSaveFeedback');
feedback.className = 'small text-danger';
feedback.textContent = `Kunne ikke hente: ${failedSections.join(', ')}.`;
}
2026-07-09 23:44:30 +02:00
currentConnection = connection;
currentAddresses = Array.isArray(addresses) ? addresses : [];
currentRanges = Array.isArray(ranges) ? ranges : [];
currentBmcnetChildren = connection.is_shared_head
? await safeJson(await fetch(`/api/v1/internet-connections/${connectionId}/children?bmcnet_only=true`), [])
: [];
2026-08-30 14:34:43 +02:00
await renderAllocationBanner(connection);
2026-07-09 23:44:30 +02:00
renderCore(connection, pricing, summary);
renderRanges(currentRanges);
renderAddresses(currentAddresses);
renderPricing(pricing, pricingHistory);
renderHistory(history);
2026-08-31 13:01:35 +02:00
renderConnectionCases(cases);
2026-07-09 23:44:30 +02:00
renderRelationGrid(connection);
renderBmcnetChildren(connection, currentBmcnetChildren);
2026-09-07 19:05:58 +02:00
await loadDelefiberProductPrices();
2026-07-09 23:44:30 +02:00
renderContractsOverview(contracts);
2026-07-28 14:18:24 +02:00
renderCrossFieldPorts(crossFieldPorts);
2026-08-31 13:01:35 +02:00
const createCaseUrl = `/sag/new?internet_connection_id=${encodeURIComponent(connectionId)}`;
document.getElementById('createCaseForConnectionBtn').href = createCaseUrl;
document.getElementById('createCaseForConnectionPanelBtn').href = createCaseUrl;
}
function renderConnectionCases(cases) {
const list = document.getElementById('connectionCasesList');
if (!Array.isArray(cases) || !cases.length) {
list.innerHTML = '< div class = "text-muted" > Ingen sager er knyttet direkte til forbindelsen endnu.< / div > ';
return;
}
list.innerHTML = cases.map(item => `
< a class = "d-flex justify-content-between align-items-center gap-3 border rounded-3 p-3 mb-2 text-decoration-none text-reset" href = "/sag/${item.id}/v3" >
< div > < div class = "fw-semibold" > SAG-${item.id} · ${escapeHtml(item.titel || 'Uden titel')}< / div > < div class = "small text-muted" > ${escapeHtml(item.customer_name || 'Ingen kunde')} · ${escapeHtml(item.responsible_name || 'Ikke tildelt')} · ændret ${formatDateTime(item.updated_at)}< / div > < / div >
< span class = "badge text-bg-light border" > ${escapeHtml(item.status || '-')}< / span >
< / a > `).join('');
2026-07-28 14:18:24 +02:00
}
function renderCrossFieldPorts(payload) {
const panel = document.getElementById('crossFieldPortsPanel');
const body = document.getElementById('crossFieldPortsBody');
const summaryEl = document.getElementById('crossFieldPortsSummary');
const items = Array.isArray(payload?.items) ? payload.items : [];
const summary = payload?.summary || {};
panel.classList.toggle('d-none', items.length === 0);
if (!items.length) {
body.innerHTML = '';
return;
}
summaryEl.textContent = `${summary.total || items.length} porte · ${summary.lan || 0} LAN · ${summary.wan || 0} WAN · ${summary.faulty || 0} defekte`;
body.innerHTML = items.map((port) => {
const status = String(port.status || '').toLowerCase();
const statusLabel = port.is_faulty ? 'Defekt' : (status === 'reserved' ? 'Reserveret' : 'Aktiv');
const statusClass = port.is_faulty ? 'bg-danger' : (status === 'reserved' ? 'bg-warning text-dark' : 'bg-success');
const roleClass = port.network_role === 'wan' ? 'bg-primary' : 'bg-info text-dark';
const switchLabel = port.switch_name || port.switch_hardware_model || '-';
return `
< tr class = "${port.is_faulty ? 'table-danger' : ''}" >
< td >
< a href = "/app/locations/${port.location_id}?tab=cross-field" class = "fw-semibold text-decoration-none" >
${escapeHtml(port.location_name || '-')}
< / a >
< div class = "small text-muted" > ${escapeHtml(port.cross_field_name || '-')}< / div >
< / td >
< td > < strong > ${escapeHtml(port.port_number || port.patch_port || '-')}< / strong > < / td >
< td > ${escapeHtml(port.outlet_number || '-')}< / td >
< td > ${escapeHtml(switchLabel)}${port.switch_port ? ` · port ${escapeHtml(port.switch_port)}` : ''}< / td >
< td > < span class = "badge ${roleClass}" > ${port.network_role === 'wan' ? 'WAN' : 'LAN'}< / span > < / td >
< td > < span class = "badge ${statusClass}" > ${statusLabel}< / span > < / td >
< / tr >
`;
}).join('');
2026-07-09 23:44:30 +02:00
}
function renderCore(connection, pricing, summary) {
document.getElementById('detailName').textContent = connection.name || 'Unavngiven forbindelse';
2026-08-30 14:34:43 +02:00
document.querySelector('#detailCircuitBadge span').textContent = connection.circuit_number || 'Intet kredsløbsnummer';
2026-07-09 23:44:30 +02:00
const ownerText = connection.customer_name || 'Ingen kunde koblet på';
const businessText = connection.allocation_model === 'shared'
? `Ejer: ${ownerText} · kunder fordeles på ranges/IP'er`
: ownerText;
const ownSales = Number(pricing.sales_price || 0);
const ownPurchase = Number(pricing.monthly_cost || 0);
const bmcnetSales = connection.is_shared_head ? Number(connection.bmcnet_child_sales_price || 0) : 0;
const bmcnetPurchase = connection.is_shared_head ? Number(connection.bmcnet_child_monthly_cost || 0) : 0;
const effectiveSales = ownSales + bmcnetSales;
const effectivePurchase = ownPurchase + bmcnetPurchase;
const effectiveMargin = effectiveSales - effectivePurchase;
const childConnections = Array.isArray(currentBmcnetChildren) ? currentBmcnetChildren : [];
const totalDown = Number(connection.download_mbps || 0);
const totalUp = Number(connection.upload_mbps || 0);
2026-08-30 14:34:43 +02:00
// Only explicit child allocations count as consumed capacity. A nominal
// connection speed is not proof that bandwidth was allocated to a customer.
const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || 0), 0);
const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || 0), 0);
2026-07-09 23:44:30 +02:00
const downPct = totalDown > 0 ? Math.round((usedDown / totalDown) * 100) : 0;
const upPct = totalUp > 0 ? Math.round((usedUp / totalUp) * 100) : 0;
document.getElementById('detailSubtitle').textContent = `${connection.provider || 'Ingen leverandør'} · ${businessText}`;
document.getElementById('metricSales').textContent = formatDKK(effectiveSales);
document.getElementById('metricPurchase').textContent = formatDKK(effectivePurchase);
document.getElementById('metricMargin').textContent = formatDKK(effectiveMargin);
2026-08-30 14:34:43 +02:00
document.getElementById('metricIps').textContent = String((summary.available || 0) + (summary.in_use || 0) + (summary.reserved || 0));
document.getElementById('metricBandwidth').textContent = childConnections.length
? `${usedDown} Mbps allokeret`
: '0 Mbps allokeret';
document.getElementById('metricBandwidthSub').textContent = totalDown || totalUp
? `Kapacitet ${totalDown || '-'} / ${totalUp || '-'} Mbps · ${downPct}% ned / ${upPct}% op${childConnections.length ? '' : ' · Ingen kundebåndbredde registreret'}`
: 'Kapacitet ikke registreret';
2026-07-09 23:44:30 +02:00
document.getElementById('ipAddressSummary').innerHTML = `Tilgængelige: < strong > ${summary.available || 0}< / strong > · Reserverede: < strong > ${summary.reserved || 0}< / strong > · I brug: < strong > ${summary.in_use || 0}< / strong > `;
const contractText = connection.contract_start || connection.contract_end
? `${formatDate(connection.contract_start)} → ${formatDate(connection.contract_end)}`
: '-';
const subscriptionText = connection.subscription_number
? `${connection.subscription_number} · ${connection.subscription_product_name || '-'}`
: (connection.value_label || '-');
const monitoringText = connection.monitoring_url
? `< a href = "${escapeHtml(connection.monitoring_url)}" target = "_blank" rel = "noopener noreferrer" > ${escapeHtml(connection.monitoring_url)}< / a > `
: '< span class = "detail-read-muted" > -< / span > ';
2026-08-30 14:34:43 +02:00
renderSla(connection);
2026-07-09 23:44:30 +02:00
document.getElementById('coreReadView').innerHTML = `
< div class = "detail-read-strip" >
2026-08-30 14:34:43 +02:00
< div class = "detail-read-chip" > < span class = "label" > Leverandør< / span > < span class = "value" > ${escapeHtml(connection.provider || '-')}< / span > < / div >
< div class = "detail-read-chip" > < span class = "label" > Kredsløb< / span > < span class = "value" > ${escapeHtml(connection.circuit_number || '-')}< / span > < / div >
${connection.is_shared_head ? `< div class = "detail-read-chip" > < span class = "label" > Netværksmodel< / span > < span class = "value" > Delt hovedforbindelse${connection.value_type_label ? ` · ${escapeHtml(connection.value_type_label)}` : ''}< / span > < / div > ` : ''}
< div class = "detail-read-chip" > < span class = "label" > Type< / span > < span class = "value" > ${escapeHtml(connection.connection_type || '-')}< / span > < / div >
< div class = "detail-read-chip" > < span class = "label" > Teknologi< / span > < span class = "value" > ${escapeHtml(connection.technology || '-')}< / span > < / div >
2026-07-09 23:44:30 +02:00
< div class = "detail-read-chip" > < span class = "label" > Hastighed< / span > < span class = "value" > ${connection.download_mbps || 0}/${connection.upload_mbps || 0} Mbps< / span > < / div >
< / div >
< div class = "detail-read-grid" >
2026-08-30 14:34:43 +02:00
< div class = "detail-read-row full" > < span class = "label" > Navn< / span > < div class = "value" > ${escapeHtml(connection.name || '-')}< / div > < / div >
< div class = "detail-read-row full" > < span class = "label" > Adresse< / span > < div class = "value" > ${escapeHtml(connection.address || '-')}< / div > < / div >
< div class = "detail-read-row" > < span class = "label" > Kunde< / span > < div class = "value" > ${escapeHtml(connection.customer_name || '-')}< / div > < / div >
< div class = "detail-read-row" > < span class = "label" > Parent< / span > < div class = "value" > ${escapeHtml(connection.parent_name || '-')}< / div > < / div >
< div class = "detail-read-row" > < span class = "label" > ${connection.subscription_number ? 'Abonnement' : 'Klassifikation'}< / span > < div class = "value" > ${escapeHtml(subscriptionText)}< / div > < / div >
2026-07-09 23:44:30 +02:00
< div class = "detail-read-row" > < span class = "label" > Kontrakt< / span > < div class = "value" > ${contractText}< / div > < / div >
2026-08-30 14:34:43 +02:00
< div class = "detail-read-row" > < span class = "label" > Nominel hastighed< / span > < div class = "value" > ${connection.speed_mbps ? `${connection.speed_mbps} Mbps` : '-'}< / div > < / div >
2026-07-09 23:44:30 +02:00
< div class = "detail-read-row" > < span class = "label" > Overvågning< / span > < div class = "value" > ${monitoringText}< / div > < / div >
2026-08-30 14:34:43 +02:00
< div class = "detail-read-row" > < span class = "label" > SLA< / span > < div class = "value" > ${connection.sla_subscription_number ? `${escapeHtml(connection.sla_product_name || 'SLA')} · ${formatDKK(connection.sla_price || 0)}` : '< span class = "text-danger fw-semibold" > Ingen SLA-aftale< / span > '}< / div > < / div >
< div class = "detail-read-row full" > < span class = "label" > Noter< / span > < div class = "value" > ${connection.notes ? escapeHtml(connection.notes) : '< span class = "detail-read-muted" > -< / span > '}< / div > < / div >
2026-07-09 23:44:30 +02:00
< / div >
`;
setStatusBadge(connection.status);
document.getElementById('fieldName').value = connection.name || '';
2026-08-30 14:34:43 +02:00
document.getElementById('fieldVendorId').value = connection.vendor_id || '';
2026-07-09 23:44:30 +02:00
document.getElementById('fieldCircuit').value = connection.circuit_number || '';
document.getElementById('fieldAddress').value = connection.address || '';
setLookupValue('fieldCustomerLookup', 'fieldCustomerId', customerOptions, connection.customer_id);
setLookupValue('fieldParentLookup', 'fieldParentId', connectionOptions, connection.parent_id);
document.getElementById('fieldConnectionType').value = connection.connection_type || '';
document.getElementById('fieldTechnology').value = connection.technology || '';
document.getElementById('fieldDownload').value = connection.download_mbps || '';
document.getElementById('fieldUpload').value = connection.upload_mbps || '';
document.getElementById('fieldSpeed').value = connection.speed_mbps || '';
document.getElementById('fieldStatus').value = connection.status || 'active';
document.getElementById('fieldMonitoringUrl').value = connection.monitoring_url || '';
document.getElementById('fieldAllocationModel').value = connection.allocation_model || 'dedicated';
document.getElementById('fieldValueType').value = connection.value_type || 'other';
2026-08-30 14:34:43 +02:00
document.getElementById('fieldManualShared').checked = Boolean(connection.is_manual_shared);
2026-07-09 23:44:30 +02:00
document.getElementById('fieldValueLabel').value = connection.value_label || '';
setSubscriptionValue(connection.subscription_id);
document.getElementById('fieldContractStart').value = connection.contract_start || '';
document.getElementById('fieldContractEnd').value = connection.contract_end || '';
document.getElementById('fieldNotes').value = connection.notes || '';
toggleValueFields();
toggleCoreEdit(false, true);
}
2026-08-30 14:34:43 +02:00
function renderSla(connection) {
const banner = document.getElementById('slaBanner');
const content = document.getElementById('slaBannerContent');
const select = document.getElementById('slaSubscriptionSelect');
const customerId = Number(connection.customer_id || 0);
const isBmcSharedFiber = Boolean(
connection.is_manual_shared
|| (connection.parent_id == null & & connection.allocation_model === 'shared' & & connection.value_type === 'delefiber')
);
if (isBmcSharedFiber) {
banner.className = 'd-none';
return;
}
const options = subscriptionOptions.filter((item) =>
Number(item.customer_id) === customerId & & /\bsla\b/i.test(String(item.product_name || ''))
);
if (connection.sla_subscription_id & & !options.some((item) => Number(item.id) === Number(connection.sla_subscription_id))) {
options.unshift({
id: connection.sla_subscription_id,
product_name: connection.sla_product_name,
subscription_number: connection.sla_subscription_number,
price: connection.sla_price,
status: connection.sla_status,
});
}
select.innerHTML = '< option value = "" > Ingen SLA-aftale< / option > ' + options.map((item) =>
`< option value = "${item.id}" $ { Number ( item . id ) = = = Number ( connection . sla_subscription_id ) ? ' selected ' : ' ' } > ${escapeHtml(item.product_name || 'SLA')} · ${escapeHtml(item.subscription_number || `#${item.id}`)} · ${formatDKK(item.price || 0)}< / option > `
).join('');
select.disabled = !customerId;
const hasSla = Boolean(connection.sla_subscription_id);
const priceOk = Number(connection.sla_price || 0) > 0;
const statusOk = connection.sla_status === 'active';
banner.className = `alert mb-4 ${hasSla & & priceOk & & statusOk ? 'alert-success' : 'alert-warning'}`;
content.innerHTML = hasSla
? `< div class = "fw-bold" > < i class = "bi bi-shield-check me-2" > < / i > ${escapeHtml(connection.sla_product_name || 'SLA-aftale')}< / div > < div class = "small mt-1" > ${escapeHtml(connection.sla_subscription_number || '')} · Pris ${formatDKK(connection.sla_price || 0)} · ${priceOk ? 'Pris registreret' : '< strong > Prisen skal kontrolleres< / strong > '}${statusOk ? '' : ' · < strong > SLA-aftalen er ikke aktiv< / strong > '}< / div > `
: `< div class = "fw-bold" > < i class = "bi bi-shield-exclamation me-2" > < / i > Ingen SLA-aftale< / div > < div class = "small mt-1" > ${customerId ? (options.length ? 'Vælg kundens SLA-aftale.' : 'Kunden har ingen aktiv SLA-aftale, der kan allokeres.') : 'Tildel først forbindelsen til en kunde.'}< / div > `;
}
async function saveSlaAllocation() {
const value = Number(document.getElementById('slaSubscriptionSelect').value || 0) || null;
const feedback = document.getElementById('slaFeedback');
const button = document.getElementById('saveSlaBtn');
button.disabled = true;
feedback.textContent = 'Gemmer SLA-allokering…';
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sla_subscription_id: value }),
});
if (!response.ok) throw new Error(await extractErrorMessage(response, 'SLA-allokeringen kunne ikke gemmes.'));
feedback.textContent = 'SLA-allokeringen er gemt.';
await loadConnection();
} catch (error) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = error.message;
} finally {
button.disabled = false;
}
}
2026-07-09 23:44:30 +02:00
function toggleCoreEdit(force, silent = false) {
coreEditMode = Boolean(force);
document.getElementById('coreReadView').classList.toggle('d-none', coreEditMode);
document.getElementById('coreEditForm').classList.toggle('d-none', !coreEditMode);
document.getElementById('editCoreDetailsBtn').classList.toggle('d-none', coreEditMode);
document.getElementById('cancelCoreDetailsBtn').classList.toggle('d-none', !coreEditMode);
document.getElementById('saveCoreDetailsBtn').classList.toggle('d-none', !coreEditMode);
if (!silent) {
document.getElementById('detailSaveFeedback').textContent = coreEditMode ? 'Redigering aktiv.' : '';
}
}
function renderRanges(ranges) {
const list = document.getElementById('ipRangesList');
const rangeSelect = document.getElementById('addressRangeSelect');
const visibleRanges = ranges.filter((range) => range.belongs_to_connection !== false);
const mismatchedRanges = ranges.filter((range) => range.belongs_to_connection === false);
if (!visibleRanges.length & & !mismatchedRanges.length) {
2026-08-30 14:34:43 +02:00
list.innerHTML = '< div class = "ip-empty-state" style = "grid-column:1/-1" > < i class = "bi bi-hdd-network" > < / i > < strong > Ingen IP-ranges registreret< / strong > < div class = "small text-muted mt-1" > Tilføj et CIDR-range eller kontrollér kredsløbsreferencen.< / div > < / div > ';
2026-07-09 23:44:30 +02:00
rangeSelect.innerHTML = '< option value = "" > Ingen ranges< / option > ';
return;
}
const visibleMarkup = visibleRanges.map((range) => `
2026-08-30 14:34:43 +02:00
< div class = "detail-grid-card ip-range-card" id = "rangeCard-${range.id}" >
< div class = "ip-range-main" >
< span class = "label" > ${range.name || 'Range'}< / span >
< div class = "value ip-range-cidr" > < i class = "bi bi-globe2" > < / i > ${range.cidr || '-'}< / div >
< div class = "small text-muted mt-1" > ${range.service_address || 'Ingen serviceadresse'}< / div >
< / div >
< div class = "ip-range-meta" >
< span class = "label" > Adresser< / span >
< div class = "value" > ${range.used_addresses || 0} brugt · ${range.available_addresses || 0} ledige< / div >
< div class = "small text-muted" > ${range.usable_hosts || 0} brugbare i alt< / div >
< / div >
< div class = "ip-range-meta" >
< span class = "label" > Reference< / span >
< div class = "value" > ${range.provider_reference || '-'}< / div >
< div class = "small text-muted" > Kontrakt ${range.contract_number || '-'}< / div >
< / div >
< div class = "ip-range-meta" >
< span class = "label" > Økonomi< / span >
< div class = "value" > ${formatDKK(range.monthly_cost || 0)} kost< / div >
< div class = "small text-muted" > ${formatDKK(range.sales_price || 0)} salg${range.customer_name ? ` · ${range.customer_name}` : ''}< / div >
< / div >
< div class = "ip-range-actions d-flex gap-2" >
2026-07-09 23:44:30 +02:00
< button class = "btn btn-sm btn-outline-primary" type = "button" onclick = "toggleRangeEdit(${range.id}, true)" > Rediger< / button >
< / div >
2026-08-30 14:34:43 +02:00
< div class = "small text-muted ip-range-edit" id = "rangeFeedback-${range.id}" > < / div >
< div class = "ip-range-edit d-none" id = "rangeEdit-${range.id}" >
2026-07-09 23:44:30 +02:00
< div class = "range-edit-grid" >
< div >
< label class = "form-label small text-muted mb-1" > Navn< / label >
< input type = "text" class = "form-control form-control-sm" id = "rangeEditName-${range.id}" value = "${escapeHtml(range.name || '')}" >
< / div >
< div >
< label class = "form-label small text-muted mb-1" > CIDR< / label >
< input type = "text" class = "form-control form-control-sm" value = "${escapeHtml(range.cidr || '')}" disabled >
< / div >
< div >
< label class = "form-label small text-muted mb-1" > Provider ref.< / label >
< input type = "text" class = "form-control form-control-sm" id = "rangeEditProviderRef-${range.id}" value = "${escapeHtml(range.provider_reference || '')}" >
< / div >
< div >
< label class = "form-label small text-muted mb-1" > Kontrakt< / label >
< input type = "text" class = "form-control form-control-sm" id = "rangeEditContract-${range.id}" value = "${escapeHtml(range.contract_number || '')}" >
< / div >
< div >
< label class = "form-label small text-muted mb-1" > Serviceadresse< / label >
< input type = "text" class = "form-control form-control-sm" id = "rangeEditServiceAddress-${range.id}" value = "${escapeHtml(range.service_address || '')}" >
< / div >
< div >
< label class = "form-label small text-muted mb-1" > Kunde på range< / label >
< input type = "text" class = "form-control form-control-sm" id = "rangeEditCustomer-${range.id}" value = "${range.customer_name ? `${range.customer_id} · ${escapeHtml(range.customer_name)}` : ''}" list = "customerLookupList" placeholder = "Kunde" >
< input type = "hidden" id = "rangeEditCustomerId-${range.id}" value = "${range.customer_id || ''}" >
< / div >
< div >
< label class = "form-label small text-muted mb-1" > Kost< / label >
< input type = "number" class = "form-control form-control-sm" id = "rangeEditCost-${range.id}" value = "${Number(range.monthly_cost || 0)}" >
< / div >
< div >
< label class = "form-label small text-muted mb-1" > Salg< / label >
< input type = "number" class = "form-control form-control-sm" id = "rangeEditSales-${range.id}" value = "${Number(range.sales_price || 0)}" >
< / div >
< div style = "grid-column: 1 / -1;" >
< label class = "form-label small text-muted mb-1" > Beskrivelse< / label >
< input type = "text" class = "form-control form-control-sm" id = "rangeEditDescription-${range.id}" value = "${escapeHtml(range.description || '')}" >
< / div >
< / div >
< div class = "mt-3 d-flex gap-2" >
< button class = "btn btn-sm btn-primary" type = "button" onclick = "saveRange(${range.id})" > Gem range< / button >
< button class = "btn btn-sm btn-outline-secondary" type = "button" onclick = "toggleRangeEdit(${range.id}, false)" > Annuller< / button >
< / div >
< / div >
< / div >
`).join('');
const mismatchMarkup = mismatchedRanges.length ? `
2026-08-30 14:34:43 +02:00
< div class = "ip-range-warning" >
< i class = "bi bi-exclamation-triangle-fill text-danger mt-1" > < / i >
< div >
< div class = "fw-semibold text-danger" > ${mismatchedRanges.length} range${mismatchedRanges.length === 1 ? '' : 's'} kræver kontrol< / div >
${mismatchedRanges.map((range) => `< div class = "small mt-1" > < strong > ${range.cidr}< / strong > · ${range.alignment_warning || 'Matcher ikke forbindelsen'}< / div > `).join('')}
< / div >
2026-07-09 23:44:30 +02:00
< / div >
` : '';
list.innerHTML = visibleMarkup + mismatchMarkup;
rangeSelect.innerHTML = visibleRanges.length
? visibleRanges.map((range) => `< option value = "${range.id}" > ${range.name} (${range.cidr})< / option > `).join('')
: '< option value = "" > Ingen matchende ranges< / option > ';
}
function toggleRangeEdit(rangeId, open) {
const panel = document.getElementById(`rangeEdit-${rangeId}`);
const card = document.getElementById(`rangeCard-${rangeId}`);
if (panel) panel.classList.toggle('d-none', !open);
if (card) card.classList.toggle('editing', !!open);
}
async function saveRange(rangeId) {
const feedback = document.getElementById(`rangeFeedback-${rangeId}`);
const customerValue = document.getElementById(`rangeEditCustomer-${rangeId}`).value;
const customerId = resolveLookupId(customerValue, customerOptions);
document.getElementById(`rangeEditCustomerId-${rangeId}`).value = customerId || '';
const payload = {
name: document.getElementById(`rangeEditName-${rangeId}`).value.trim() || null,
provider_reference: document.getElementById(`rangeEditProviderRef-${rangeId}`).value.trim() || null,
contract_number: document.getElementById(`rangeEditContract-${rangeId}`).value.trim() || null,
service_address: document.getElementById(`rangeEditServiceAddress-${rangeId}`).value.trim() || null,
customer_id: Number(document.getElementById(`rangeEditCustomerId-${rangeId}`).value || 0) || null,
monthly_cost: Number(document.getElementById(`rangeEditCost-${rangeId}`).value || 0),
sales_price: Number(document.getElementById(`rangeEditSales-${rangeId}`).value || 0),
description: document.getElementById(`rangeEditDescription-${rangeId}`).value.trim() || null,
};
if (!payload.name) {
feedback.textContent = 'Navn er påkrævet.';
return;
}
feedback.textContent = 'Gemmer range...';
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}/ip-ranges/${rangeId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
feedback.textContent = await extractErrorMessage(response, 'Kunne ikke gemme IP-range.');
return;
}
feedback.textContent = 'IP-range gemt.';
toggleRangeEdit(rangeId, false);
await loadConnection();
} catch (error) {
feedback.textContent = error?.message || 'Netværksfejl under gem af IP-range.';
}
}
function renderAddresses(addresses) {
const search = document.getElementById('addressSearchInput').value.trim().toLowerCase();
const statusFilter = document.getElementById('addressStatusFilter').value;
const body = document.getElementById('ipAddressesTableBody');
const filtered = addresses.filter((address) => {
if (address.belongs_to_connection === false) return false;
const haystack = `${address.ip_address || ''} ${address.assigned_to || ''} ${address.assigned_type || ''} ${address.assigned_customer_name || ''} ${address.assigned_connection_name || ''} ${address.comment || ''}`.toLowerCase();
const searchOk = !search || haystack.includes(search);
const statusOk = !statusFilter || address.status === statusFilter;
return searchOk & & statusOk;
});
if (!filtered.length) {
2026-08-30 14:34:43 +02:00
body.innerHTML = '< tr > < td colspan = "5" > < div class = "ip-empty-state my-2" > < i class = "bi bi-search" > < / i > < strong > Ingen IP-adresser matcher< / strong > < div class = "small text-muted mt-1" > Prøv at rydde søgning eller statusfilter.< / div > < / div > < / td > < / tr > ';
2026-07-09 23:44:30 +02:00
return;
}
body.innerHTML = filtered.map((address) => {
const isEditing = editingAddressId === address.id;
const companyName = address.assigned_customer_name || address.range_customer_name || '-';
const roleLabel = address.assigned_type ? formatIpRole(address.assigned_type) : '';
const serviceName = address.assigned_to || '-';
const subscriptionLink = Number(address.assigned_subscription_sag_id || 0) > 0
? `/sag/${address.assigned_subscription_sag_id}/v3`
: '';
const subscriptionTitle = address.assigned_subscription_number || address.assigned_to || '';
return `
< tr >
< td > < strong > ${address.ip_address || '-'}< / strong > < / td >
< td > < span class = "internet-status ${address.status || 'available'}" > ${address.status_label || address.status || '-'}< / span > < / td >
< td >
${subscriptionLink
? `< a href = "${subscriptionLink}" class = "d-inline-flex align-items-center gap-2 text-decoration-none" title = "${subscriptionTitle}" >
< input type = "checkbox" checked onclick = "window.location.href='${subscriptionLink}'; return false;" >
< span > ${subscriptionTitle}< / span >
< / a > `
: '< input type = "checkbox" disabled > '}
< / td >
< td >
< div > ${companyName}< / div >
< div class = "small text-muted" >
${roleLabel ? `${roleLabel} · ` : ''}${serviceName}
< / div >
< / td >
< td class = "text-end" >
< button class = "btn btn-sm btn-outline-primary" type = "button" onclick = "toggleAddressEdit(${address.id})" title = "Rediger IP" >
< i class = "bi ${isEditing ? 'bi-x-lg' : 'bi-pencil'}" > < / i >
< / button >
< / td >
< / tr >
${isEditing ? `
< tr class = "bg-light" >
< td colspan = "5" >
< div class = "row g-2 py-2" >
< div class = "col-md-2" >
< select class = "form-select form-select-sm" id = "addressStatus-${address.id}" >
< option value = "available" $ { address . status = == ' available ' ? ' selected ' : ' ' } > Tilgængelig< / option >
< option value = "reserved" $ { address . status = == ' reserved ' ? ' selected ' : ' ' } > Reserveret< / option >
< option value = "in_use" $ { address . status = == ' in_use ' ? ' selected ' : ' ' } > I brug< / option >
< / select >
< / div >
< div class = "col-md-2" >
< select class = "form-select form-select-sm" id = "addressRole-${address.id}" >
< option value = "" $ { ! address . assigned_type ? ' selected ' : ' ' } > Ingen rolle< / option >
< option value = "bmcnet_gateway" $ { address . assigned_type = == ' bmcnet_gateway ' ? ' selected ' : ' ' } > BMCnet gateway< / option >
< option value = "webserver" $ { address . assigned_type = == ' webserver ' ? ' selected ' : ' ' } > Webserver< / option >
< option value = "mailserver" $ { address . assigned_type = == ' mailserver ' ? ' selected ' : ' ' } > Mailserver< / option >
< option value = "unifi" $ { address . assigned_type = == ' unifi ' ? ' selected ' : ' ' } > Unifi< / option >
< option value = "firewall" $ { address . assigned_type = == ' firewall ' ? ' selected ' : ' ' } > Firewall< / option >
< option value = "vpn" $ { address . assigned_type = == ' vpn ' ? ' selected ' : ' ' } > VPN< / option >
< option value = "service" $ { address . assigned_type = == ' service ' ? ' selected ' : ' ' } > Anden service< / option >
< / select >
< / div >
< div class = "col-md-3" > < input type = "text" class = "form-control form-control-sm" id = "addressService-${address.id}" value = "${address.assigned_to || ''}" placeholder = "Service/navn" > < / div >
< div class = "col-md-3" >
< input type = "text" class = "form-control form-control-sm" id = "addressAssignedCustomer-${address.id}" value = "${address.assigned_customer_name ? `${address.assigned_customer_id} · ${address.assigned_customer_name}` : ''}" list = "customerLookupList" placeholder = "Kunde" >
< input type = "hidden" id = "addressAssignedCustomerId-${address.id}" value = "${address.assigned_customer_id || ''}" >
< / div >
< div class = "col-md-3" > < input type = "text" class = "form-control form-control-sm" id = "addressComment-${address.id}" value = "${address.comment || ''}" placeholder = "Kommentar" > < / div >
< div class = "col-md-auto d-flex justify-content-end" >
< button class = "btn btn-sm btn-primary" type = "button" onclick = "updateAddress(${address.id})" > Gem ændringer< / button >
< / div >
< / div >
< / td >
< / tr > ` : ''}
`;
}).join('');
}
function toggleAddressEdit(addressId) {
editingAddressId = editingAddressId === addressId ? null : addressId;
renderAddresses(currentAddresses);
}
function renderPricing(pricing, pricingHistory) {
document.getElementById('pricingSummaryGrid').innerHTML = `
< div class = "detail-grid-card" >
< span class = "label" > Aktuel kost< / span >
< div class = "value" > ${formatDKK(pricing.monthly_cost || 0)}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Aktuelt salg< / span >
< div class = "value" > ${formatDKK(pricing.sales_price || 0)}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Kontraktstatus< / span >
< div class = "value" > ${pricing.contract_status || 'ukendt'}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Periode< / span >
< div class = "value" > ${formatDate(pricing.contract_start)} → ${formatDate(pricing.contract_end)}< / div >
< / div >
`;
const list = document.getElementById('pricingHistoryList');
if (!pricingHistory.length) {
list.innerHTML = '< div class = "text-muted" > Ingen prishistorik endnu.< / div > ';
return;
}
list.innerHTML = pricingHistory.map((entry) => `
< div class = "timeline-item" >
< div class = "fw-semibold" > ${formatDate(entry.effective_from)}< / div >
< div class = "small text-muted" > ${entry.notes || 'Ingen note'}< / div >
< div class = "small mt-1" > Kost ${formatDKK(entry.purchase_price || 0)} · Salg ${formatDKK(entry.sales_price || 0)}< / div >
< / div >
`).join('');
}
function renderHistory(history) {
const list = document.getElementById('historyList');
if (!history.length) {
list.innerHTML = '< div class = "text-muted" > Ingen historik registreret endnu.< / div > ';
return;
}
list.innerHTML = history.map((entry) => `
< div class = "timeline-item" >
< div class = "fw-semibold" > ${entry.summary || entry.event_type || '-'}< / div >
< div class = "small text-muted" > ${formatDateTime(entry.created_at)}< / div >
${renderHistoryDetails(entry)}
< / div >
`).join('');
}
function renderRelationGrid(connection) {
document.getElementById('relationGrid').innerHTML = `
< div class = "detail-grid-card" >
< span class = "label" > Kunde< / span >
< div class = "value" > ${connection.customer_name || '-'}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Forretningsbinding< / span >
< div class = "value" > ${connection.allocation_model_label || '-'} · ${connection.value_type_label || '-'}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Parent< / span >
< div class = "value" > ${connection.parent_name || '-'}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Kunde-ID< / span >
< div class = "value" > ${connection.customer_id || '-'}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Range / IP< / span >
< div class = "value" > ${connection.ip_range_count || 0} / ${connection.total_ip_addresses || 0}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Overvågning< / span >
< div class = "value" > ${connection.monitoring_url ? `< a href = "${connection.monitoring_url}" target = "_blank" > Åbn< / a > ` : '-'}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Abonnement< / span >
< div class = "value" > ${connection.subscription_number ? `${connection.subscription_number} · ${connection.subscription_product_name || '-'}` : (connection.value_label || '-')}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > BMCnet underforbindelser< / span >
< div class = "value" > ${connection.is_shared_head ? Number(connection.bmcnet_child_count || 0) : '-'}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Opdateret< / span >
< div class = "value" > ${formatDate(connection.updated_at)}< / div >
< / div >
`;
}
function renderContractsOverview(contracts) {
const list = document.getElementById('contractsOverviewList');
if (!Array.isArray(contracts) || !contracts.length) {
list.innerHTML = '< div class = "text-muted" > Ingen kontrakter fundet.< / div > ';
return;
}
list.innerHTML = contracts.slice(0, 8).map((contract) => `
< div class = "timeline-item" >
< div class = "fw-semibold" > ${contract.name || '-'}< / div >
< div class = "small text-muted" > ${contract.provider || '-'} · ${formatDate(contract.contract_end)}< / div >
< / div >
`).join('');
}
function renderBmcnetChildren(connection, children) {
const panel = document.getElementById('bmcnetChildrenPanel');
const countEl = document.getElementById('bmcnetChildrenCount');
const summaryGrid = document.getElementById('bmcnetChildrenSummaryGrid');
const list = document.getElementById('bmcnetChildrenList');
if (!connection.is_shared_head) {
panel.classList.add('d-none');
return;
}
panel.classList.remove('d-none');
const summary = connection.bmcnet_summary || {};
countEl.textContent = `${Number(summary.connection_count || 0)} kunder`;
summaryGrid.innerHTML = `
< div class = "detail-grid-card" >
< span class = "label" > BMCnet kunder< / span >
< div class = "value" > ${Number(summary.connection_count || 0)}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Aktive< / span >
< div class = "value" > ${Number(summary.active_connection_count || 0)}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > Salg< / span >
< div class = "value" > ${formatDKK(summary.sales_price || 0)}< / div >
< / div >
< div class = "detail-grid-card" >
< span class = "label" > IP i brug< / span >
< div class = "value" > ${Number(summary.ip_in_use_count || 0)}< / div >
< / div >
`;
if (!children.length) {
list.innerHTML = '< div class = "text-muted" > Ingen BMCnet-kunder på denne hovedforbindelse endnu.< / div > ';
return;
}
list.innerHTML = children.map((child) => `
< div class = "timeline-item" >
< div class = "d-flex justify-content-between gap-2" >
< div >
< div class = "fw-semibold" > ${child.customer_name || child.name || 'Ukendt kunde'}< / div >
< div class = "small text-muted" > ${child.name || '-'} · ${child.subscription_number || 'Intet abonnement'} · ${formatSpeed(child)}< / div >
< div class = "small text-muted" > ${child.address || '-'}< / div >
< / div >
< div class = "text-end" >
< div class = "fw-semibold" > ${formatDKK(child.sales_price || 0)}< / div >
< div class = "small text-muted" > IP i brug ${Number(child.in_use_ip_addresses || 0)} / ${Number(child.total_ip_addresses || 0)}< / div >
< a href = "/economy/internet-connections/${child.id}" class = "small" > Åbn< / a >
< / div >
< / div >
< / div >
`).join('');
}
async function saveCoreDetails() {
const feedback = document.getElementById('detailSaveFeedback');
const saveButton = document.getElementById('saveCoreDetailsBtn');
document.getElementById('fieldCustomerId').value = resolveLookupId(document.getElementById('fieldCustomerLookup').value, customerOptions) || '';
document.getElementById('fieldParentId').value = resolveLookupId(document.getElementById('fieldParentLookup').value, connectionOptions) || '';
document.getElementById('fieldSubscriptionId').value = resolveSubscriptionId(document.getElementById('fieldSubscriptionLookup').value) || '';
const payload = {
name: document.getElementById('fieldName').value.trim(),
2026-08-30 14:34:43 +02:00
vendor_id: Number(document.getElementById('fieldVendorId').value || 0) || null,
2026-07-09 23:44:30 +02:00
circuit_number: document.getElementById('fieldCircuit').value.trim() || null,
address: document.getElementById('fieldAddress').value.trim() || null,
customer_id: Number(document.getElementById('fieldCustomerId').value || 0) || null,
parent_id: Number(document.getElementById('fieldParentId').value || 0) || null,
connection_type: document.getElementById('fieldConnectionType').value.trim() || null,
technology: document.getElementById('fieldTechnology').value.trim() || null,
download_mbps: Number(document.getElementById('fieldDownload').value || 0) || null,
upload_mbps: Number(document.getElementById('fieldUpload').value || 0) || null,
speed_mbps: Number(document.getElementById('fieldSpeed').value || 0) || null,
status: document.getElementById('fieldStatus').value || 'active',
monitoring_url: document.getElementById('fieldMonitoringUrl').value.trim() || null,
allocation_model: document.getElementById('fieldAllocationModel').value || 'dedicated',
value_type: document.getElementById('fieldValueType').value || 'other',
value_label: document.getElementById('fieldValueLabel').value.trim() || null,
subscription_id: Number(document.getElementById('fieldSubscriptionId').value || 0) || null,
2026-08-30 14:34:43 +02:00
is_manual_shared: document.getElementById('fieldManualShared').checked,
2026-07-09 23:44:30 +02:00
contract_start: document.getElementById('fieldContractStart').value || null,
contract_end: document.getElementById('fieldContractEnd').value || null,
notes: document.getElementById('fieldNotes').value.trim() || null,
};
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/${connectionId}`, {
method: 'PUT',
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 gemt.';
coreEditMode = false;
await loadConnection();
} catch (error) {
feedback.textContent = error?.message || 'Netværksfejl under gem.';
} finally {
if (saveButton) saveButton.disabled = false;
}
}
function toggleValueFields() {
const valueType = document.getElementById('fieldValueType').value;
document.getElementById('fieldValueLabelWrap').classList.toggle('d-none', valueType !== 'other');
document.getElementById('fieldSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription');
}
2026-08-30 14:34:43 +02:00
function toggleManualDelefiber() {
const enabled = document.getElementById('fieldManualShared').checked;
if (enabled) {
document.getElementById('fieldAllocationModel').value = 'shared';
document.getElementById('fieldValueType').value = 'delefiber';
} else if (document.getElementById('fieldValueType').value === 'delefiber') {
document.getElementById('fieldAllocationModel').value = 'dedicated';
document.getElementById('fieldValueType').value = 'other';
document.getElementById('fieldValueLabel').value = 'Internetforbindelse';
}
toggleValueFields();
}
2026-07-09 23:44:30 +02:00
async function createRange() {
const feedback = document.getElementById('rangeCreateFeedback');
const button = document.getElementById('createRangeButton');
const cidrValue = document.getElementById('rangeCidrInput').value.trim();
const nameValue = document.getElementById('rangeNameInput').value.trim();
const payload = {
name: nameValue || cidrValue,
cidr: cidrValue,
description: document.getElementById('rangeDescriptionInput').value.trim() || null,
provider_reference: document.getElementById('rangeProviderReferenceInput').value.trim() || null,
contract_number: document.getElementById('rangeContractInput').value.trim() || null,
customer_id: Number(document.getElementById('rangeCustomerIdInput').value || 0) || null,
service_address: document.getElementById('rangeServiceAddressInput').value.trim() || null,
monthly_cost: Number(document.getElementById('rangeMonthlyCostInput').value || 0),
sales_price: Number(document.getElementById('rangeSalesPriceInput').value || 0),
};
if (!payload.cidr) {
feedback.textContent = 'CIDR er påkrævet.';
return;
}
feedback.textContent = 'Gemmer range...';
if (button) button.disabled = true;
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}/ip-ranges`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
feedback.textContent = await extractErrorMessage(response, 'Kunne ikke oprette IP-range.');
return;
}
document.getElementById('rangeNameInput').value = '';
document.getElementById('rangeCidrInput').value = '';
document.getElementById('rangeDescriptionInput').value = '';
document.getElementById('rangeProviderReferenceInput').value = '';
document.getElementById('rangeContractInput').value = '';
document.getElementById('rangeCustomerLookupInput').value = '';
document.getElementById('rangeCustomerIdInput').value = '';
document.getElementById('rangeServiceAddressInput').value = '';
document.getElementById('rangeMonthlyCostInput').value = '';
document.getElementById('rangeSalesPriceInput').value = '';
feedback.textContent = 'IP-range oprettet.';
await loadConnection();
} catch (error) {
feedback.textContent = error?.message || 'Netværksfejl under oprettelse af IP-range.';
} finally {
if (button) button.disabled = false;
}
}
window.createRange = createRange;
async function createAddress() {
const payload = {
range_id: Number(document.getElementById('addressRangeSelect').value || 0),
ip_address: document.getElementById('addressIpInput').value.trim(),
status: document.getElementById('addressStatusInput').value || 'available',
assigned_to: document.getElementById('addressAssignedInput').value.trim() || null,
assigned_customer_id: Number(document.getElementById('addressAssignedCustomerIdInput').value || 0) || null,
assigned_connection_id: Number(document.getElementById('addressAssignedConnectionIdInput').value || 0) || null,
};
if (!payload.range_id || !payload.ip_address) {
2026-08-30 14:34:43 +02:00
showDetailMessage('Range og IP-adresse er påkrævet.', true);
2026-07-09 23:44:30 +02:00
return;
}
const response = await fetch(`/api/v1/internet-connections/${connectionId}/ip-addresses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
2026-08-30 14:34:43 +02:00
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke oprette IP-adresse.'), true);
2026-07-09 23:44:30 +02:00
return;
}
document.getElementById('addressIpInput').value = '';
document.getElementById('addressAssignedInput').value = '';
document.getElementById('addressAssignedCustomerInput').value = '';
document.getElementById('addressAssignedCustomerIdInput').value = '';
document.getElementById('addressAssignedConnectionInput').value = '';
document.getElementById('addressAssignedConnectionIdInput').value = '';
await loadConnection();
}
async function updateAddress(addressId) {
document.getElementById(`addressAssignedCustomerId-${addressId}`).value =
resolveLookupId(document.getElementById(`addressAssignedCustomer-${addressId}`).value, customerOptions) || '';
const currentAddress = (currentAddresses || []).find((item) => Number(item.id) === Number(addressId));
const serviceValue = document.getElementById(`addressService-${addressId}`).value.trim() || null;
let roleValue = document.getElementById(`addressRole-${addressId}`).value || null;
let normalizedServiceValue = serviceValue;
if (roleValue === 'bmcnet_gateway' & & !normalizedServiceValue) {
normalizedServiceValue = 'BMCnet kunde gateway';
}
if (!roleValue & & normalizedServiceValue) {
roleValue = 'service';
}
const payload = {
status: document.getElementById(`addressStatus-${addressId}`).value,
assigned_to: normalizedServiceValue,
assigned_type: roleValue,
assigned_customer_id: Number(document.getElementById(`addressAssignedCustomerId-${addressId}`).value || 0) || null,
assigned_connection_id: Number(currentAddress?.assigned_connection_id || 0) || null,
comment: document.getElementById(`addressComment-${addressId}`).value.trim() || null,
};
const response = await fetch(`/api/v1/internet-connections/${connectionId}/ip-addresses/${addressId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
2026-08-30 14:34:43 +02:00
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke opdatere IP-adressen.'), true);
2026-07-09 23:44:30 +02:00
return;
}
editingAddressId = null;
await loadConnection();
}
async function createPricingEntry() {
const payload = {
effective_from: document.getElementById('pricingEffectiveDateInput').value,
purchase_price: Number(document.getElementById('pricingPurchaseInput').value || 0),
sales_price: Number(document.getElementById('pricingSalesInput').value || 0),
notes: document.getElementById('pricingNotesInput').value.trim() || null,
};
if (!payload.effective_from) {
document.getElementById('pricingFeedback').textContent = 'Fra dato er påkrævet.';
return;
}
document.getElementById('pricingFeedback').textContent = 'Gemmer...';
const response = await fetch(`/api/v1/internet-connections/${connectionId}/pricing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
document.getElementById('pricingFeedback').textContent = 'Kunne ikke gemme prislinjen.';
return;
}
document.getElementById('pricingEffectiveDateInput').value = '';
document.getElementById('pricingPurchaseInput').value = '';
document.getElementById('pricingSalesInput').value = '';
document.getElementById('pricingNotesInput').value = '';
document.getElementById('pricingFeedback').textContent = 'Prislinje gemt.';
await loadConnection();
}
function resetAddressFilters() {
document.getElementById('addressSearchInput').value = '';
document.getElementById('addressStatusFilter').value = '';
renderAddresses(currentAddresses);
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('addressSearchInput').addEventListener('input', () => renderAddresses(currentAddresses));
document.getElementById('addressStatusFilter').addEventListener('change', () => renderAddresses(currentAddresses));
bindLookupInput('fieldCustomerLookup', 'fieldCustomerId', () => customerOptions);
bindLookupInput('fieldParentLookup', 'fieldParentId', () => connectionOptions);
bindLookupInput('bmcnetWizardCustomerLookup', 'bmcnetWizardCustomerId', () => customerOptions);
bindLookupInput('rangeCustomerLookupInput', 'rangeCustomerIdInput', () => customerOptions);
bindLookupInput('addressAssignedCustomerInput', 'addressAssignedCustomerIdInput', () => customerOptions);
bindLookupInput('addressAssignedConnectionInput', 'addressAssignedConnectionIdInput', () => connectionOptions);
document.getElementById('bmcnetWizardSharedHeadSelect').addEventListener('change', async (event) => {
await refreshBmcnetWizardHeadState(event.target.value);
});
document.getElementById('bmcnetWizardInternetProduct').addEventListener('change', () => syncBmcnetWizardPrice('bmcnetWizardInternetProduct', 'bmcnetWizardInternetPrice'));
document.getElementById('bmcnetWizardIpProduct').addEventListener('change', async () => {
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
await refreshBmcnetWizardAllocationMode();
});
2026-09-07 19:05:58 +02:00
document.getElementById('bmcnetWizardRangeSelect').addEventListener('change', (event) => {
const option = event.target.options[event.target.selectedIndex];
selectIpProductForPrefix(option?.dataset?.prefix);
});
document.getElementById('bmcnetWizardIpSelect').addEventListener('change', () => {
// A single selected public address is sold as a Static WAN IP.
if (document.getElementById('bmcnetWizardIpSelect').value) selectIpProductForPrefix(32);
});
2026-07-09 23:44:30 +02:00
document.getElementById('bmcnetWizardAddress').addEventListener('change', async () => {
await refreshBmcnetWizardAllocationMode();
});
document.getElementById('fieldSubscriptionLookup').addEventListener('change', () => {
document.getElementById('fieldSubscriptionId').value = resolveSubscriptionId(document.getElementById('fieldSubscriptionLookup').value) || '';
});
const createRangeButton = document.getElementById('createRangeButton');
if (createRangeButton) {
createRangeButton.addEventListener('click', (event) => {
event.preventDefault();
createRange();
});
}
loadConnection();
});
< / script >
{% endblock %}