bmc_hub/app/modules/internet_connections/templates/detail.html
Christian 9ca562745a feat(subscriptions): enhance subscription update logic to allow direct edits for drafts and add new endpoint for manual invoice processing
feat(ticket): update email integration to use new priority constants for ticket classification

feat(procurement): add procurement overview page with dynamic data loading and display

test(subscriptions): add tests for billing calendar to ensure correct invoice dates

feat(reminder): implement automated task lists with user-defined rules for reminders

feat(migrations): create tables for managing delefiber product prices and mobile recorder provisioning history

test(mobile_recorder): add tests for provisioning mobile recorders to ensure correct asset creation and updates
2026-09-07 19:05:58 +02:00

2603 lines
128 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

{% extends "shared/frontend/base.html" %}
{% block title %}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);
position: relative;
overflow: hidden;
}
.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; }
.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);
}
.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; }
.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%;
position:relative;
overflow:hidden;
}
.detail-metric::after { content:""; position:absolute; width:55px; height:55px; right:-22px; bottom:-24px; border-radius:50%; background:rgba(15,76,117,.07); }
.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;
}
#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; }
.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) {
.detail-metrics-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
.detail-read-grid {
grid-template-columns: 1fr;
}
.detail-read-row,
.detail-read-row.full {
grid-template-columns: 1fr;
gap: 0.2rem;
}
}
@media (max-width: 575.98px) { .detail-metrics-grid { grid-template-columns:1fr; } .detail-title-icon { display:none; } }
</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">
<div class="detail-title-wrap">
<div class="detail-title-icon"><i class="bi bi-router"></i></div>
<div>
<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>
<div class="detail-circuit-badge" id="detailCircuitBadge"><i class="bi bi-diagram-3"></i><span>Henter kredsløb...</span></div>
<div class="small mt-2 text-muted" id="detailSaveFeedback" role="status"></div>
</div>
</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>
<a class="btn btn-outline-primary" id="createCaseForConnectionBtn" href="/sag/new">
<i class="bi bi-plus-circle me-1"></i>Opret sag
</a>
</div>
</div>
<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>
<a href="#connection-cases"><i class="bi bi-kanban"></i>Sager</a>
<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>
</div>
<div class="detail-metrics-grid mb-4">
<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">
<div class="detail-panel p-4 mb-4" id="connection-core">
<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>
<select class="form-select" id="fieldVendorId"><option value="">Ingen valgt</option></select>
</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>
<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>
<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>
<div class="detail-panel p-4 mb-4" id="connection-ip">
<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>
<div class="detail-panel p-4 mb-4" id="connection-pricing">
<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">
<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>
</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>
<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>
<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>
<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>
<div class="detail-panel p-4" id="connection-history">
<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">Wizarden 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>
<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>
</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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
async function safeJson(response, fallback) {
if (!response || !response.ok) return fallback;
try {
return await response.json();
} catch {
return fallback;
}
}
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' });
}
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() {
const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse, vendorsResponse] = await Promise.all([
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'),
fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100'),
]);
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() : [];
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('');
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('');
}
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();
}
function getSharedHeadOptions() {
return (connectionOptions || []).filter((item) => !item?.parent_id);
}
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 IPer 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) => `
<option value="${range.id}" data-prefix="${String(range.cidr || '').split('/')[1] || ''}">${range.cidr} · ${range.available_addresses || 0} ledige · ${range.service_address || head?.address || '-'}</option>
`).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) {
const canCreateBmcnet = Boolean(connection && !connection.parent_id);
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet);
if (!canCreateBmcnet) return;
const today = new Date().toISOString().slice(0, 10);
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';
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;
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() {
if (!currentConnection || currentConnection.parent_id) return;
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;
}
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();
}
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),
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',
start_date: document.getElementById('bmcnetWizardStartDate').value,
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),
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;
}
}
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;
}
}
async function loadConnection() {
await loadLookups();
const [
connectionResponse,
rangesResponse,
addressesResponse,
summaryResponse,
pricingResponse,
pricingHistoryResponse,
contractsResponse,
historyResponse,
casesResponse,
crossFieldPortsResponse,
] = 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`),
fetch(`/api/v1/internet-connections/${connectionId}/cases`),
fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`),
]);
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();
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, []);
const cases = await safeJson(casesResponse, []);
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} });
const failedSections = [
[rangesResponse, 'IP-ranges'], [addressesResponse, 'IP-adresser'], [summaryResponse, 'IP-oversigt'],
[pricingResponse, 'priser'], [pricingHistoryResponse, 'prishistorik'], [contractsResponse, 'kontrakter'],
[historyResponse, 'historik'], [crossFieldPortsResponse, 'krydsfelt'],
[casesResponse, 'sager'],
].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(', ')}.`;
}
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`), [])
: [];
await renderAllocationBanner(connection);
renderCore(connection, pricing, summary);
renderRanges(currentRanges);
renderAddresses(currentAddresses);
renderPricing(pricing, pricingHistory);
renderHistory(history);
renderConnectionCases(cases);
renderRelationGrid(connection);
renderBmcnetChildren(connection, currentBmcnetChildren);
await loadDelefiberProductPrices();
renderContractsOverview(contracts);
renderCrossFieldPorts(crossFieldPorts);
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('');
}
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('');
}
function renderCore(connection, pricing, summary) {
document.getElementById('detailName').textContent = connection.name || 'Unavngiven forbindelse';
document.querySelector('#detailCircuitBadge span').textContent = connection.circuit_number || 'Intet kredsløbsnummer';
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);
// 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);
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);
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';
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>';
renderSla(connection);
document.getElementById('coreReadView').innerHTML = `
<div class="detail-read-strip">
<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>
<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">
<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>
<div class="detail-read-row"><span class="label">Kontrakt</span><div class="value">${contractText}</div></div>
<div class="detail-read-row"><span class="label">Nominel hastighed</span><div class="value">${connection.speed_mbps ? `${connection.speed_mbps} Mbps` : '-'}</div></div>
<div class="detail-read-row"><span class="label">Overvågning</span><div class="value">${monitoringText}</div></div>
<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>
</div>
`;
setStatusBadge(connection.status);
document.getElementById('fieldName').value = connection.name || '';
document.getElementById('fieldVendorId').value = connection.vendor_id || '';
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';
document.getElementById('fieldManualShared').checked = Boolean(connection.is_manual_shared);
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);
}
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;
}
}
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) {
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>';
rangeSelect.innerHTML = '<option value="">Ingen ranges</option>';
return;
}
const visibleMarkup = visibleRanges.map((range) => `
<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">
<button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleRangeEdit(${range.id}, true)">Rediger</button>
</div>
<div class="small text-muted ip-range-edit" id="rangeFeedback-${range.id}"></div>
<div class="ip-range-edit d-none" id="rangeEdit-${range.id}">
<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 ? `
<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>
</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) {
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>';
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(),
vendor_id: Number(document.getElementById('fieldVendorId').value || 0) || null,
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,
is_manual_shared: document.getElementById('fieldManualShared').checked,
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');
}
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();
}
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) {
showDetailMessage('Range og IP-adresse er påkrævet.', true);
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) {
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke oprette IP-adresse.'), true);
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) {
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke opdatere IP-adressen.'), true);
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();
});
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);
});
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 %}