bmc_hub/app/modules/internet_connections/templates/detail.html
Christian 3311b8e590 Add comprehensive tests for internet connections module, invoice parsing, and subscription provisioning
- Implement tests for the internet connections module, covering routes, IP range creation, and connection validation.
- Add tests for the Invoice2DataService to validate extraction from GlobalConnect invoices.
- Create tests for subscription network provisioning, ensuring proper handling of network items and IP allocations.
- Include validation checks for subtotal mismatches and ensure error handling for missing IP selections.
2026-07-09 23:44:30 +02:00

2105 lines
95 KiB
HTML
Raw 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);
}
.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);
}
.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%;
}
.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;
}
.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-read-grid {
grid-template-columns: 1fr;
}
.detail-read-row,
.detail-read-row.full {
grid-template-columns: 1fr;
gap: 0.2rem;
}
}
</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>
<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="small mt-2 text-muted" id="detailSaveFeedback" role="status"></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>
</div>
</div>
</div>
<div class="row g-4 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">
<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>
<input type="text" class="form-control" id="fieldProvider">
</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-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">
<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">
<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"><input type="date" class="form-control" id="pricingEffectiveDateInput"></div>
<div class="col-md-3"><input type="number" class="form-control" id="pricingPurchaseInput" placeholder="Indkøbspris"></div>
<div class="col-md-3"><input type="number" class="form-control" id="pricingSalesInput" placeholder="Salgspris"></div>
<div class="col-md-3"><input type="text" class="form-control" id="pricingNotesInput" placeholder="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">
<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>
</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;
}
}
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] = 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'),
]);
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() : [];
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('');
}
function getSharedHeadOptions() {
return (connectionOptions || []).filter((item) => item?.is_shared_head);
}
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}">${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) {
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !connection?.is_shared_head);
if (!connection?.is_shared_head) return;
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('bmcnetWizardStartDate').value = new Date().toISOString().slice(0, 10);
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?.is_shared_head) 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;
}
input.value = option.dataset.price || '';
}
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),
start_date: document.getElementById('bmcnetWizardStartDate').value,
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 loadConnection() {
await loadLookups();
const [
connectionResponse,
rangesResponse,
addressesResponse,
summaryResponse,
pricingResponse,
pricingHistoryResponse,
contractsResponse,
historyResponse,
] = 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`),
]);
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 rangesResponse.json();
const addresses = await addressesResponse.json();
const summary = await summaryResponse.json();
const pricing = await pricingResponse.json();
const pricingHistory = await pricingHistoryResponse.json();
const contracts = await contractsResponse.json();
const history = await historyResponse.json();
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`), [])
: [];
renderCore(connection, pricing, summary);
renderRanges(currentRanges);
renderAddresses(currentAddresses);
renderPricing(pricing, pricingHistory);
renderHistory(history);
renderRelationGrid(connection);
renderBmcnetChildren(connection, currentBmcnetChildren);
renderContractsOverview(contracts);
}
function renderCore(connection, pricing, summary) {
document.getElementById('detailName').textContent = connection.name || 'Unavngiven forbindelse';
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);
const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || child.speed_mbps || 0), 0);
const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || child.speed_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.in_use || 0) + (summary.reserved || 0));
document.getElementById('metricBandwidth').textContent = `${usedDown} / ${totalDown || 0} Mbps`;
document.getElementById('metricBandwidthSub').textContent = totalUp
? `Upload ${usedUp} / ${totalUp} Mbps · ${downPct}% ned / ${upPct}% op`
: `${downPct}%`;
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>';
document.getElementById('coreReadView').innerHTML = `
<div class="detail-read-strip">
<div class="detail-read-chip"><span class="label">Leverandør</span><span class="value">${connection.provider || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Kredsløb</span><span class="value">${connection.circuit_number || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Binding</span><span class="value">${connection.allocation_model_label || '-'} · ${connection.value_type_label || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Type</span><span class="value">${connection.connection_type || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Teknologi</span><span class="value">${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">${connection.name || '-'}</div></div>
<div class="detail-read-row full"><span class="label">Adresse</span><div class="value">${connection.address || '-'}</div></div>
<div class="detail-read-row"><span class="label">Kunde</span><div class="value">${connection.customer_name || '-'}</div></div>
<div class="detail-read-row"><span class="label">Parent</span><div class="value">${connection.parent_name || '-'}</div></div>
<div class="detail-read-row"><span class="label">Abonnement</span><div class="value">${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">Fallback</span><div class="value">${connection.speed_mbps || 0} 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 full"><span class="label">Noter</span><div class="value">${connection.notes || '<span class="detail-read-muted">-</span>'}</div></div>
</div>
`;
setStatusBadge(connection.status);
document.getElementById('fieldName').value = connection.name || '';
document.getElementById('fieldProvider').value = connection.provider || '';
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('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 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="text-muted">Ingen IP-ranges registreret endnu.</div>';
rangeSelect.innerHTML = '<option value="">Ingen ranges</option>';
return;
}
const visibleMarkup = visibleRanges.map((range) => `
<div class="detail-grid-card" id="rangeCard-${range.id}">
<span class="label">${range.name || 'Range'}</span>
<div class="value">${range.cidr || '-'}</div>
<div class="small text-muted mt-2">Brugbare: ${range.usable_hosts || 0} · Brugt: ${range.used_addresses || 0} · Ledige: ${range.available_addresses || 0}</div>
${range.customer_name ? `<div class="small text-muted mt-1">Kunde: ${range.customer_name}</div>` : ''}
${range.provider_reference ? `<div class="small text-muted mt-1">Ref: ${range.provider_reference}</div>` : ''}
${range.contract_number ? `<div class="small text-muted mt-1">Kontrakt: ${range.contract_number}</div>` : ''}
${range.service_address ? `<div class="small text-muted mt-1">${range.service_address}</div>` : ''}
${(Number(range.monthly_cost || 0) || Number(range.sales_price || 0)) ? `<div class="small text-muted mt-1">Kost ${formatDKK(range.monthly_cost || 0)} · Salg ${formatDKK(range.sales_price || 0)}</div>` : ''}
${range.description ? `<div class="small text-muted mt-1">${range.description}</div>` : ''}
<div class="mt-3 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 mt-2" id="rangeFeedback-${range.id}"></div>
<div class="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="detail-grid-card" style="grid-column: 1 / -1; border-color: #f3c2c2; background: #fff8f8;">
<span class="label text-danger">Afvigelser</span>
<div class="small text-muted mb-2">Disse ranges matcher ikke forbindelsens adresse eller kunde og bør kontrolleres.</div>
${mismatchedRanges.map((range) => `
<div class="small mb-2">
<strong>${range.cidr}</strong> · ${range.customer_name || 'Ingen kunde'}<br>
<span class="text-danger">${range.alignment_warning || 'Matcher ikke forbindelsen'}</span>
</div>
`).join('')}
</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" class="text-muted py-4">Ingen IP-adresser matcher filtrene.</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(),
provider: document.getElementById('fieldProvider').value.trim() || 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,
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');
}
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) {
alert('Range og IP-adresse er påkrævet');
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) {
alert('Kunne ikke oprette IP-adresse');
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) {
alert('Kunne ikke opdatere IP-adressen');
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('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 %}