- 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.
2105 lines
95 KiB
HTML
2105 lines
95 KiB
HTML
{% 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">Wizard’en bruger som standard den hovedforbindelse du står på nu.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="row g-3">
|
||
<div class="col-lg-6">
|
||
<div class="wizard-section h-100">
|
||
<div class="wizard-section-title">1. Kunde og service</div>
|
||
<div class="row g-2">
|
||
<div class="col-12">
|
||
<label class="form-label">Kunde</label>
|
||
<input type="text" class="form-control" id="bmcnetWizardCustomerLookup" list="customerLookupList" placeholder="Søg kunde">
|
||
<input type="hidden" id="bmcnetWizardCustomerId">
|
||
</div>
|
||
<div class="col-12">
|
||
<label class="form-label">Forbindelsesnavn</label>
|
||
<input type="text" class="form-control" id="bmcnetWizardName" placeholder="fx BMCnet - Kunde">
|
||
</div>
|
||
<div class="col-12">
|
||
<label class="form-label">Service</label>
|
||
<input type="text" class="form-control" id="bmcnetWizardService" placeholder="fx Internet / Firewall / WAN">
|
||
</div>
|
||
<div class="col-12">
|
||
<label class="form-label">Adresse</label>
|
||
<input type="text" class="form-control" id="bmcnetWizardAddress" placeholder="Serviceadresse">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-6">
|
||
<div class="wizard-section h-100">
|
||
<div class="wizard-section-title">2. Abonnement</div>
|
||
<div class="row g-2">
|
||
<div class="col-md-6">
|
||
<label class="form-label">Internetprodukt</label>
|
||
<select class="form-select" id="bmcnetWizardInternetProduct"></select>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">IP-produkt</label>
|
||
<select class="form-select" id="bmcnetWizardIpProduct"></select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label">Interval</label>
|
||
<select class="form-select" id="bmcnetWizardBillingInterval">
|
||
<option value="monthly">Måned</option>
|
||
<option value="quarterly">Kvartal</option>
|
||
<option value="yearly">År</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label">Faktura dag</label>
|
||
<input type="number" class="form-control" id="bmcnetWizardBillingDay" min="1" max="31" value="1">
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label">Startdato</label>
|
||
<input type="date" class="form-control" id="bmcnetWizardStartDate">
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Pris internet</label>
|
||
<input type="number" class="form-control" id="bmcnetWizardInternetPrice" placeholder="Standard fra produkt" step="0.01">
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Pris IP-produkt</label>
|
||
<input type="number" class="form-control" id="bmcnetWizardIpPrice" placeholder="Standard fra produkt" step="0.01">
|
||
</div>
|
||
<div class="col-12">
|
||
<label class="form-label">Noter</label>
|
||
<textarea class="form-control" id="bmcnetWizardNotes" rows="3" placeholder="Interne noter"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-12">
|
||
<div class="wizard-section">
|
||
<div class="wizard-section-title">3. IP-range</div>
|
||
<div class="row g-2 align-items-end" id="bmcnetWizardRangeWrap">
|
||
<div class="col-lg-8">
|
||
<label class="form-label">Ledigt range på hovedforbindelsen</label>
|
||
<select class="form-select" id="bmcnetWizardRangeSelect"></select>
|
||
</div>
|
||
<div class="col-lg-4">
|
||
<div class="small text-muted" id="bmcnetWizardRangeHint">Vælg et helt ledigt range, hvis kunden skal have egne IP-adresser.</div>
|
||
</div>
|
||
</div>
|
||
<div class="row g-2 align-items-end d-none" id="bmcnetWizardIpWrap">
|
||
<div class="col-lg-8">
|
||
<label class="form-label">Ledig IP på hovedadressens ranges</label>
|
||
<select class="form-select" id="bmcnetWizardIpSelect"></select>
|
||
</div>
|
||
<div class="col-lg-4">
|
||
<div class="form-check mt-4">
|
||
<input class="form-check-input" type="checkbox" id="bmcnetWizardGatewayCheckbox">
|
||
<label class="form-check-label" for="bmcnetWizardGatewayCheckbox">Marker som BMCnet kunde gateway</label>
|
||
</div>
|
||
<div class="small text-muted mt-2" id="bmcnetWizardIpHint">Vælg en ledig IP fra alle ranges på hovedadressen.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="small mt-3" id="bmcnetWizardFeedback"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
|
||
<button type="button" class="btn btn-primary" id="bmcnetWizardSubmitBtn" onclick="createBmcnetConnection()">
|
||
<i class="bi bi-check2-circle me-1"></i>Opret BMCnet-forbindelse
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const connectionId = {{ connection_id }};
|
||
let currentConnection = null;
|
||
let currentAddresses = [];
|
||
let currentRanges = [];
|
||
let currentBmcnetChildren = [];
|
||
let editingAddressId = null;
|
||
let coreEditMode = false;
|
||
let customerOptions = [];
|
||
let connectionOptions = [];
|
||
let subscriptionOptions = [];
|
||
let bmcnetWizardProducts = [];
|
||
let bmcnetWizardModal = null;
|
||
let bmcnetWizardRangesByHead = {};
|
||
let bmcnetWizardAddressesByHead = {};
|
||
|
||
function formatDKK(value) {
|
||
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
|
||
}
|
||
|
||
function formatDate(value) {
|
||
if (!value) return '-';
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return date.toLocaleDateString('da-DK');
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
if (!value) return '-';
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return date.toLocaleString('da-DK');
|
||
}
|
||
|
||
function formatSpeed(item) {
|
||
const download = Number(item?.download_mbps || 0);
|
||
const upload = Number(item?.upload_mbps || 0);
|
||
const fallback = Number(item?.speed_mbps || 0);
|
||
|
||
if (download > 0 || upload > 0) {
|
||
if (download > 0 && upload > 0) return `${download}/${upload} Mbps`;
|
||
return `${download || upload} Mbps`;
|
||
}
|
||
if (fallback > 0) return `${fallback} Mbps`;
|
||
return '-';
|
||
}
|
||
|
||
function formatIpRole(value) {
|
||
const key = String(value || '').trim().toLowerCase();
|
||
const labels = {
|
||
bmcnet_gateway: 'BMCnet gateway',
|
||
webserver: 'Webserver',
|
||
mailserver: 'Mailserver',
|
||
unifi: 'Unifi',
|
||
firewall: 'Firewall',
|
||
vpn: 'VPN',
|
||
service: 'Service',
|
||
subscription: 'Abonnement',
|
||
};
|
||
return labels[key] || (value || '-');
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value || '')
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
async function safeJson(response, fallback) {
|
||
if (!response || !response.ok) return fallback;
|
||
try {
|
||
return await response.json();
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
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 IP’er på den valgte hovedadresse lige nu.';
|
||
}
|
||
|
||
async function refreshBmcnetWizardAllocationMode() {
|
||
const ipProductId = Number(document.getElementById('bmcnetWizardIpProduct').value || 0) || null;
|
||
const isStaticIp = ipProductId ? isStaticWanIpProduct(ipProductId) : false;
|
||
document.getElementById('bmcnetWizardRangeWrap').classList.toggle('d-none', isStaticIp);
|
||
document.getElementById('bmcnetWizardIpWrap').classList.toggle('d-none', !isStaticIp);
|
||
if (isStaticIp) {
|
||
await refreshBmcnetWizardIpState(document.getElementById('bmcnetWizardSharedHeadSelect').value);
|
||
}
|
||
}
|
||
|
||
async function refreshBmcnetWizardHeadState(sharedHeadId) {
|
||
const headId = Number(sharedHeadId || 0);
|
||
const head = getSharedHeadOptions().find((item) => Number(item.id) === headId) || currentConnection;
|
||
const rangeSelect = document.getElementById('bmcnetWizardRangeSelect');
|
||
const hint = document.getElementById('bmcnetWizardRangeHint');
|
||
const headHint = document.getElementById('bmcnetWizardHeadHint');
|
||
const feedback = document.getElementById('bmcnetWizardFeedback');
|
||
|
||
if (headHint) {
|
||
headHint.textContent = head?.address
|
||
? `${head.name || 'Hovedforbindelse'} · ${head.address}`
|
||
: (head?.name || 'Vælg hovedforbindelse');
|
||
}
|
||
|
||
const ranges = headId === Number(connectionId)
|
||
? currentRanges
|
||
: await loadRangesForSharedHead(headId);
|
||
const availableRanges = filterAvailableBmcnetRanges(ranges);
|
||
rangeSelect.innerHTML = `<option value="">Ingen IP-range endnu</option>${availableRanges.map((range) => `
|
||
<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 %}
|