bmc_hub/app/modules/internet_connections/templates/index.html

1478 lines
77 KiB
HTML
Raw Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Internetforbindelser{% endblock %}
{% block extra_css %}
<style>
.internet-hero {
background:
radial-gradient(circle at top right, rgba(15, 76, 117, 0.18), transparent 34%),
linear-gradient(135deg, rgba(15, 76, 117, 0.1), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(15, 76, 117, 0.14);
border-radius: 24px;
padding: 1.5rem;
box-shadow: 0 16px 36px rgba(15, 76, 117, 0.08);
}
.internet-panel {
background: var(--bg-card);
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 20px;
box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06);
}
.internet-kpi {
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 16px;
padding: 1rem;
height: 100%;
}
.internet-kpi-label {
color: var(--text-secondary);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.internet-kpi-value {
color: var(--text-primary);
font-size: 1.55rem;
font-weight: 700;
}
.internet-toolbar {
display: grid;
grid-template-columns: 1.4fr 1fr 0.8fr 0.9fr auto auto;
gap: 0.75rem;
}
.internet-row {
cursor: pointer;
}
.internet-row td {
vertical-align: middle;
}
.internet-tabs {
display: inline-flex;
padding: 0.35rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.07);
gap: 0.35rem;
}
.internet-tab {
border: 0;
background: transparent;
border-radius: 999px;
padding: 0.6rem 1rem;
font-weight: 700;
color: var(--text-secondary);
}
.internet-tab.active {
background: #0f4c75;
color: white;
}
.internet-tag {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 999px;
padding: 0.18rem 0.5rem;
font-size: 0.72rem;
font-weight: 700;
background: rgba(15, 76, 117, 0.08);
color: #0f4c75;
margin-right: 0.35rem;
}
.internet-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.internet-status.active {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
}
.internet-status.inactive {
background: rgba(108, 117, 125, 0.14);
color: #495057;
}
.internet-status.pending,
.internet-status.planned {
background: rgba(255, 193, 7, 0.18);
color: #997404;
}
.internet-status.terminated,
.internet-status.cancelled {
background: rgba(220, 53, 69, 0.12);
color: #b02a37;
}
.internet-mini {
color: var(--text-secondary);
font-size: 0.83rem;
}
.internet-quick-form {
border: 1px dashed rgba(15, 76, 117, 0.2);
border-radius: 18px;
background: rgba(15, 76, 117, 0.03);
padding: 1rem;
}
.quick-customer-results {
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 0 0 12px 12px;
background: var(--bg-card);
}
.quick-customer-result {
display: block;
width: 100%;
padding: 0.7rem 0.85rem;
border: 0;
border-bottom: 1px solid var(--border-color);
background: transparent;
color: var(--text-primary);
text-align: left;
}
.quick-customer-result:hover { background: rgba(15, 76, 117, 0.09); }
.quick-type-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:.75rem; }
.quick-type-choice { border:2px solid var(--border-color); border-radius:14px; padding:1rem; background:var(--bg-card); color:var(--text-primary); text-align:left; }
.quick-type-choice:hover { border-color:#0f4c75; background:rgba(15,76,117,.06); }
.quick-type-choice.active { border-color:#0f4c75; background:#0f4c75; color:#fff; box-shadow:0 8px 22px rgba(15,76,117,.22); }
.quick-type-choice.active .text-muted { color:rgba(255,255,255,.8)!important; }
.invoice-sync-status {
display:inline-flex;
align-items:center;
border-radius:999px;
padding:.3rem .65rem;
font-size:.72rem;
font-weight:800;
white-space:nowrap;
}
.invoice-sync-status.success { background:rgba(25,135,84,.14); color:#146c43; }
.invoice-sync-status.warning,
.invoice-sync-status.skipped,
.invoice-sync-status.not_logged { background:rgba(255,193,7,.2); color:#765a02; }
.invoice-sync-status.error { background:rgba(220,53,69,.14); color:#b02a37; }
.invoice-sync-status.legacy_success { background:rgba(13,110,253,.12); color:#084298; }
.invoice-sync-error { max-width:420px; white-space:normal; color:#b02a37; font-size:.8rem; }
@media (max-width: 991px) {
.internet-toolbar {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="internet-hero mb-4">
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-center gap-3">
<div>
<div class="small text-uppercase fw-semibold text-muted mb-2">Økonomi / Drift / Support</div>
<h2 class="h3 mb-1">Internetforbindelser</h2>
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-outline-secondary" type="button" onclick="refreshActiveTab()">
<i class="bi bi-arrow-repeat me-1"></i>Opdater
</button>
<button class="btn btn-outline-primary" type="button" data-bs-toggle="modal" data-bs-target="#ipNordicImportModal">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Importér IP Nordic
</button>
<button class="btn btn-primary" id="newConnectionBtn" type="button" data-bs-toggle="modal" data-bs-target="#quickSetupModal">
<i class="bi bi-plus-lg me-1"></i>Opret eller tilknyt
</button>
</div>
</div>
<div class="internet-tabs mt-3">
<button class="internet-tab active" type="button" id="tabAll" onclick="setActiveTab('all')">Alle forbindelser</button>
<button class="internet-tab" type="button" id="tabShared" onclick="setActiveTab('shared')">Delte hovedforbindelser</button>
<button class="internet-tab" type="button" id="tabBmcnet" onclick="setActiveTab('bmcnet')">BMCnet</button>
<button class="internet-tab" type="button" id="tabDedicated" onclick="setActiveTab('dedicated')">Dedikerede</button>
<button class="internet-tab" type="button" id="tabUnallocated" onclick="setActiveTab('unallocated')">Ikke allokeret</button>
<button class="internet-tab" type="button" id="tabInvoices" onclick="setActiveTab('invoices')">Behandlede internetfakturaer</button>
</div>
</div>
<div class="row g-3 mb-4" id="connectionsMetrics">
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label">Forbindelser</div>
<div class="internet-kpi-value" id="metricTotal">0</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label">Aktive / afventer</div>
<div class="internet-kpi-value" id="metricActive">0</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label" id="metricSharedLabel">Delte hoveder</div>
<div class="internet-kpi-value" id="metricShared">0</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label">Dækningsbidrag</div>
<div class="internet-kpi-value" id="metricMargin">0 kr.</div>
</div>
</div>
</div>
<div class="internet-panel p-4 mb-4 collapse" id="createConnectionBlock">
<div class="internet-quick-form">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<div class="fw-semibold">Opret ny forbindelse</div>
<div class="internet-mini">Bruges til fysiske og interne BMC-forbindelser.</div>
</div>
<div class="small text-muted" id="createConnectionFeedback" role="status"></div>
</div>
<div class="row g-2">
<div class="col-lg-3">
<input type="text" class="form-control" id="connectionNameInput" placeholder="Navn" />
</div>
<div class="col-lg-2">
<select class="form-select" id="connectionVendorInput"><option value="">Vælg internetleverandør</option></select>
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionCustomerIdInput" placeholder="Kunde-ID" />
</div>
<div class="col-lg-2">
<input type="text" class="form-control" id="connectionCircuitInput" placeholder="Kredsløb" />
</div>
<div class="col-lg-3">
<input type="text" class="form-control" id="connectionAddressInput" placeholder="Installationsadresse" />
</div>
<div class="col-lg-2">
<select class="form-select" id="connectionAllocationInput">
<option value="dedicated">Dedikeret</option>
<option value="shared">Delt</option>
</select>
</div>
<div class="col-lg-2">
<select class="form-select" id="connectionValueTypeInput" onchange="toggleCreateValueFields()">
<option value="other">Anden værdi</option>
<option value="subscription">Abonnement</option>
<option value="bmc_networks">BMC Networks</option>
<option value="delefiber">Delefiber</option>
</select>
</div>
<div class="col-lg-4" id="createValueLabelWrap">
<input type="text" class="form-control" id="connectionValueLabelInput" placeholder="Værdi / klassifikation" value="Mangler klassifikation" />
</div>
<div class="col-lg-4 d-none" id="createSubscriptionWrap">
<input type="text" class="form-control" id="connectionSubscriptionInput" list="subscriptionLookupList" placeholder="Abonnement">
<input type="hidden" id="connectionSubscriptionIdInput" />
</div>
<div class="col-lg-2">
<input type="text" class="form-control" id="connectionTechnologyInput" placeholder="Teknologi" />
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionDownloadInput" placeholder="Download" />
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionUploadInput" placeholder="Upload" />
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionPurchaseInput" placeholder="Kost" />
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionSalesInput" placeholder="Salg" />
</div>
<div class="col-lg-2">
<select class="form-select" id="connectionStatusInput">
<option value="active">Aktiv</option>
<option value="planned">Planlagt</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
<div class="col-12">
<textarea class="form-control" id="connectionNotesInput" rows="2" placeholder="Noter, SLA, overvågning, intern struktur..."></textarea>
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary" type="button" onclick="submitConnectionForm()">Gem forbindelse</button>
</div>
</div>
</div>
<div class="internet-panel p-4 mb-4" id="connectionsOverview">
<div class="internet-toolbar mb-3">
<input type="search" class="form-control" id="searchInput" placeholder="Søg navn, kunde, leverandør, kredsløb eller adresse" />
<input type="text" class="form-control" id="providerFilter" placeholder="Filtrer leverandør" />
<select class="form-select" id="statusFilter">
<option value="">Alle statusser</option>
<option value="active">Aktive</option>
<option value="pending">Afventer kontrol</option>
<option value="planned">Planlagte</option>
<option value="inactive">Inaktive</option>
<option value="terminated">Opsagte</option>
</select>
<select class="form-select" id="valueTypeFilter">
<option value="">Alle værdier</option>
<option value="subscription">Abonnement</option>
<option value="bmc_networks">BMC Networks</option>
<option value="delefiber">Delefiber</option>
<option value="other">Anden</option>
</select>
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">Anvend</button>
<button class="btn btn-light border" type="button" onclick="resetFilters()">Nulstil</button>
</div>
<div class="row g-3 mb-3">
<div class="col-xl-6">
<div class="internet-mini" id="pageSummaryText">Indlæser forbindelser...</div>
</div>
<div class="col-xl-6 text-xl-end">
<div class="internet-mini">Klik på en række for at åbne forbindelsesdetaljer.</div>
</div>
</div>
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead class="table-light">
<tr>
<th>Forbindelse</th>
<th>Kunde</th>
<th>Leverandør / kredsløb</th>
<th>Hastighed / IP</th>
<th>Økonomi</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="connectionsTableBody">
<tr>
<td colspan="7" class="text-muted py-4">Indlæser...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="internet-panel p-4 mb-4 d-none" id="invoiceProcessingOverview">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
<div>
<h3 class="h5 mb-1">Behandlede internetfakturaer</h3>
<div class="internet-mini">GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.</div>
</div>
<div class="d-flex gap-2">
<span class="internet-mini align-self-center" id="invoiceReconcileFeedback" role="status"></span>
<select class="form-select form-select-sm" id="invoiceSyncStatusFilter" onchange="loadInvoiceSyncRuns()">
<option value="">Alle resultater</option>
<option value="success">Gennemført</option>
<option value="warning">Kræver kontrol</option>
<option value="error">Fejl</option>
<option value="skipped">Sprunget over</option>
<option value="not_logged">Ældre uden detaljer</option>
</select>
<button class="btn btn-sm btn-outline-primary" id="invoiceReconcileBtn" type="button" onclick="reconcileInvoiceSyncRuns()"><i class="bi bi-arrow-clockwise me-1"></i>Genkontrollér</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadInvoiceSyncRuns()"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Fakturaer</div><div class="internet-kpi-value" id="invoiceMetricTotal">0</div></div></div>
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Gennemført</div><div class="internet-kpi-value text-success" id="invoiceMetricSuccess">0</div></div></div>
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Kræver kontrol</div><div class="internet-kpi-value text-warning" id="invoiceMetricWarnings">0</div></div></div>
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Fejl</div><div class="internet-kpi-value text-danger" id="invoiceMetricErrors">0</div></div></div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light"><tr><th>Faktura</th><th>Behandlet</th><th>Resultat</th><th>Forbindelser</th><th>IP-ranges</th><th>Fejl / bemærkning</th></tr></thead>
<tbody id="invoiceSyncTableBody"><tr><td colspan="6" class="text-muted py-4">Indlæser fakturabehandling…</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="modal fade" id="quickSetupModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header">
<div><div class="small text-uppercase text-muted fw-semibold">Hurtig oprettelse</div><h5 class="modal-title mb-0">Forbindelse og abonnement</h5></div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="quick-type-grid mb-3">
<button class="quick-type-choice" type="button" data-quick-type="physical" onclick="selectQuickSetupType('physical')"><div class="fw-bold"><i class="bi bi-router me-2"></i>Fysisk forbindelse</div><div class="small text-muted mt-1">En direkte forbindelse fra en internetleverandør</div></button>
<button class="quick-type-choice" type="button" data-quick-type="shared" onclick="selectQuickSetupType('shared')"><div class="fw-bold"><i class="bi bi-share me-2"></i>Delefiber / hovedforbindelse</div><div class="small text-muted mt-1">BMC Networks ejer fiberen, og den kan deles til flere kunder</div></button>
<button class="quick-type-choice" type="button" data-quick-type="bmcnet" onclick="selectQuickSetupType('bmcnet')"><div class="fw-bold"><i class="bi bi-diagram-3 me-2"></i>BMCnet (delefiber)</div><div class="small text-muted mt-1">En kundeforbindelse under en delt BMC-hovedforbindelse</div></button>
</div>
<div class="alert alert-info mb-0" id="quickTypeHint">Vælg først hvilken type forbindelse du vil oprette.</div>
<div class="row g-3 d-none mt-1" id="quickSetupFields">
<div class="col-12"><div class="fw-semibold">1. Kunde</div></div>
<div class="col-12" id="quickCustomerWrap">
<input class="form-control" id="quickCustomerLookup" placeholder="Søg kundenavn eller CVR…" autocomplete="off">
<input type="hidden" id="quickCustomerId">
<div class="quick-customer-results d-none" id="quickCustomerResults"></div>
</div>
<div class="col-12 d-none" id="quickOwnerWrap"><div class="alert alert-success mb-0"><i class="bi bi-building-check me-2"></i><strong>Ejer: BMC Networks</strong> · forbindelsen markeres som delt hovedfiber.</div></div>
<div class="col-12 quick-subscription-field"><hr class="my-1"><div class="fw-semibold">2. Abonnement</div></div>
<div class="col-md-8 quick-subscription-field"><label class="form-label">Internetprodukt</label><select class="form-select" id="quickProduct"><option value="">Vælg internetprodukt…</option></select></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Pris pr. periode</label><input class="form-control" id="quickPrice" type="number" min="0" step="0.01"></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Startdato</label><input class="form-control" id="quickStartDate" type="date"></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Interval</label><select class="form-select" id="quickInterval"><option value="monthly">Månedlig</option><option value="quarterly">Kvartalsvis</option><option value="yearly">Årlig</option></select></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Fakturadag</label><input class="form-control" id="quickBillingDay" type="number" min="1" max="31" value="1"></div>
<div class="col-12"><hr class="my-1"><div class="fw-semibold" id="quickConnectionStepTitle">3. Forbindelse</div></div>
<div class="col-12 d-none" id="quickSharedHeadWrap"><label class="form-label">Delt hovedforbindelse</label><select class="form-select" id="quickSharedHead"><option value="">Vælg hovedforbindelse…</option></select><div class="small mt-1" id="quickSharedHeadSuggestion">Skriv installationsadressen, så foreslår systemet en hovedforbindelse.</div></div>
<div class="col-12" id="quickConnectionModeWrap"><select class="form-select" id="quickConnectionMode" onchange="toggleQuickConnectionMode()"><option value="new">Opret ny forbindelse</option><option value="existing">Knyt en eksisterende forbindelse</option></select></div>
<div class="col-12 d-none" id="quickExistingWrap"><select class="form-select" id="quickExistingConnection"><option value="">Vælg forbindelse…</option></select></div>
<div class="col-md-6 quick-new-field"><label class="form-label">Navn</label><input class="form-control" id="quickConnectionName" placeholder="Fx Internet - Kundenavn"></div>
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Leverandør</label><select class="form-select" id="quickVendor"><option value="">Vælg leverandør…</option></select></div>
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Kredsløbsnummer</label><input class="form-control" id="quickCircuit"></div>
<div class="col-md-6 quick-new-field"><label class="form-label">Installationsadresse</label><input class="form-control" id="quickAddress"></div>
<div class="col-12" id="quickAdvancedConnectionFields"><details class="border rounded p-3"><summary class="fw-semibold">Avanceret — valgfrit</summary><div class="row g-2 mt-2"><div class="col-md-4"><label class="form-label">Teknologi</label><input class="form-control" id="quickTechnology" placeholder="Fiber"></div><div class="col-md-4"><label class="form-label">Download Mbps</label><input class="form-control" id="quickDownload" type="number"></div><div class="col-md-4"><label class="form-label">Upload Mbps</label><input class="form-control" id="quickUpload" type="number"></div><div class="col-md-4"><label class="form-label">Kost pr. måned</label><input class="form-control" id="quickCost" type="number" min="0" step="0.01"></div><div class="col-md-8"><label class="form-label">Noter</label><input class="form-control" id="quickNotes"></div></div></details></div>
<div class="col-12 d-none" id="quickBmcnetNotesWrap"><label class="form-label">Noter <span class="text-muted">(valgfrit)</span></label><input class="form-control" id="quickBmcnetNotes"></div>
<div class="col-12"><div class="alert alert-light border mb-0" id="quickSetupSummary">Vælg kunde og produkt. Systemet opretter en abonnementssag, et abonnement som kladde og forbinder det hele.</div></div>
</div>
</div>
<div class="modal-footer"><span class="small text-muted me-auto" id="quickSetupFeedback"></span><button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button><button class="btn btn-primary d-none" id="quickSetupSubmit" type="button" onclick="submitQuickSetup()"><i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement</button></div>
</div>
</div>
</div>
<div class="modal fade" id="ipNordicImportModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title mb-1">Importér IP Nordic-forbindelser</h5>
<div class="internet-mini">Excel-filen kontrolleres før import. Kunder tilknyttes aldrig automatisk.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label fw-semibold" for="ipNordicFileInput">IP Nordic Excel-fil (.xlsx)</label>
<input class="form-control" id="ipNordicFileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
<div class="small mt-2" id="ipNordicImportFeedback" role="status"></div>
<div class="table-responsive mt-3 d-none" id="ipNordicPreviewWrap">
<table class="table table-sm align-middle">
<thead><tr><th>Handling</th><th>Rapporteret firma</th><th>Adresse</th><th>Start</th><th class="text-end">Kost</th><th class="text-end">Salg</th></tr></thead>
<tbody id="ipNordicPreviewBody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button>
<button class="btn btn-outline-primary" id="ipNordicPreviewBtn" type="button" onclick="previewIpNordicImport()">Kontrollér fil</button>
<button class="btn btn-primary d-none" id="ipNordicCommitBtn" type="button" onclick="commitIpNordicImport()">Importér nye forbindelser</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="invoiceReviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div><h5 class="modal-title mb-1">Kontrollér internetfaktura <span id="invoiceReviewNumber"></span></h5><div class="internet-mini" id="invoiceReviewSummary"></div></div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info small">Vælg en eksisterende forbindelse, opret en separat afventende forbindelse, eller ignorér linjen med en begrundelse. Fakturaen markeres gennemført, når alle kontrollinjer er afklaret.</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="showResolvedInvoiceLines" onchange="toggleResolvedInvoiceLines()">
<label class="form-check-label" for="showResolvedInvoiceLines">Vis allerede løste linjer</label>
</div>
<div id="invoiceReviewLines"></div>
</div>
</div>
</div>
</div>
<script>
let allConnections = [];
let invoiceSyncItems = [];
let activeTab = 'all';
let subscriptionOptions = [];
let quickCustomerOptions = [];
let quickProductOptions = [];
let quickCustomerTimer = null;
let quickSetupType = null;
let quickSharedHeadOptions = [];
let ipNordicPreview = null;
let allocationSuggestionsByConnection = new Map();
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
}[char]));
}
async function runIpNordicImport(commit) {
const input = document.getElementById('ipNordicFileInput');
const feedback = document.getElementById('ipNordicImportFeedback');
const previewButton = document.getElementById('ipNordicPreviewBtn');
const commitButton = document.getElementById('ipNordicCommitBtn');
const file = input.files?.[0];
if (!file) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = 'Vælg først en Excel-fil.';
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('commit', commit ? 'true' : 'false');
previewButton.disabled = true;
commitButton.disabled = true;
feedback.className = 'small mt-2 text-muted';
feedback.textContent = commit ? 'Importerer...' : 'Kontrollerer filen...';
try {
const response = await fetch('/api/v1/internet-connections/import/ip-nordic', { method: 'POST', body: formData });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || 'Importen kunne ikke gennemføres.');
ipNordicPreview = payload;
document.getElementById('ipNordicPreviewWrap').classList.remove('d-none');
document.getElementById('ipNordicPreviewBody').innerHTML = (payload.items || []).map((item) => `
<tr>
<td>${item.action === 'skip' ? `<span class="badge text-bg-secondary">Findes #${item.existing_connection_id}</span>` : '<span class="badge text-bg-success">Ny</span>'}</td>
<td>${escapeHtml(item.reported_company || '-')}<div class="internet-mini">Nr. ${escapeHtml(item.company_number || '-')} · ${Number(item.line_count || 0)} linjer</div></td>
<td>${escapeHtml(item.address || '-')}</td>
<td>${escapeHtml(item.start_date || '-')}</td>
<td class="text-end">${formatDKK(item.monthly_cost)}</td>
<td class="text-end">${formatDKK(item.sales_price)}</td>
</tr>`).join('');
feedback.className = 'small mt-2 text-success';
feedback.textContent = commit
? `${payload.created_count} forbindelser oprettet, ${payload.skipped_count} eksisterende sprunget over.`
: `${payload.create_count} nye og ${payload.existing_count} eksisterende forbindelser fundet.`;
commitButton.classList.toggle('d-none', commit || payload.create_count === 0);
if (commit) await loadInternetPage();
} catch (error) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = error.message || 'Importen kunne ikke gennemføres.';
} finally {
previewButton.disabled = false;
commitButton.disabled = false;
}
}
function previewIpNordicImport() { return runIpNordicImport(false); }
function commitIpNordicImport() { return runIpNordicImport(true); }
function formatDKK(value) {
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
}
function formatSpeed(item) {
const down = Number(item.download_mbps || 0);
const up = Number(item.upload_mbps || 0);
const speed = Number(item.speed_mbps || 0);
if (down || up) return `${down || 0}/${up || 0} Mbps`;
if (speed) return `${speed} Mbps`;
return '-';
}
function statusBadge(status) {
const value = String(status || 'inactive').toLowerCase();
const labelMap = {
active: 'Aktiv',
planned: 'Planlagt',
pending: 'Afventer',
inactive: 'Inaktiv',
terminated: 'Opsagt',
cancelled: 'Annulleret',
};
return `<span class="internet-status ${value}">${labelMap[value] || value}</span>`;
}
function allocationBadge(item) {
const label = item.allocation_model_label || (item.allocation_model === 'shared' ? 'Delt' : 'Dedikeret');
return `<span class="internet-tag">${escapeHtml(label)}</span>`;
}
function valueBadge(item) {
const label = item.value_type_label || 'Anden';
return `<span class="internet-tag">${escapeHtml(label)}</span>`;
}
function currentTabDescription() {
if (activeTab === 'shared') return ' i delte hovedforbindelser';
if (activeTab === 'bmcnet') return ' i BMCnet';
if (activeTab === 'dedicated') return ' i dedikerede forbindelser';
if (activeTab === 'unallocated') return ' uden allokeret kunde';
return '';
}
async function safeJson(response, fallback) {
if (!response.ok) return fallback;
try {
return await response.json();
} catch {
return fallback;
}
}
async function extractErrorMessage(response, fallback) {
try {
const payload = await response.clone().json();
if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail.trim();
if (Array.isArray(payload?.detail)) {
const validation = payload.detail
.map((item) => item?.msg || item?.message || '')
.filter(Boolean)
.join(', ');
if (validation) return validation;
}
} catch (_) {}
try {
const text = (await response.text()).trim();
if (text) return text;
} catch (_) {}
return fallback;
}
function escapeInvoiceText(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function invoiceSyncBadge(status) {
const labels = {
success: 'Gennemført',
legacy_success: 'Ældre · gennemført',
warning: 'Kræver kontrol',
skipped: 'Sprunget over',
error: 'Fejl',
not_logged: 'Ingen detaljer',
};
return `<span class="invoice-sync-status ${escapeInvoiceText(status)}">${labels[status] || escapeInvoiceText(status || 'Ukendt')}</span>`;
}
function formatInvoiceSyncDate(value) {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? escapeInvoiceText(value) : date.toLocaleString('da-DK');
}
function invoiceSyncMessage(item) {
if (item.error_message) return `<div class="invoice-sync-error">${escapeInvoiceText(item.error_message)}</div>`;
if (item.processing_status === 'warning') {
const skipped = Number(item.skipped_lines || 0);
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
const reasons = [...new Set(skippedItems.map(entry => entry.reason).filter(Boolean))];
return `<div class="text-warning small mb-2">${Number(item.unresolved_lines ?? skipped)} af ${skipped} linje(r) mangler kontrol${reasons.length ? `: ${escapeInvoiceText(reasons.join('; '))}` : ''}</div>${item.run_id ? `<button class="btn btn-sm btn-warning" type="button" onclick="openInvoiceReview(${Number(item.run_id)})"><i class="bi bi-check2-square me-1"></i>Kontrollér</button>` : ''}`;
}
if (item.processing_status === 'skipped') return '<div class="text-warning small">Fakturaen gav ingen internetdata til synkronisering.</div>';
if (item.processing_status === 'not_logged') return '<div class="text-muted small">Ældre faktura uden gemt behandlingsresultat.</div>';
if (item.processing_status === 'legacy_success') return '<div class="text-muted small">Fundet via eksisterende forbindelseshistorik.</div>';
return '<div class="text-muted small">Ingen fejl registreret.</div>';
}
async function loadInvoiceSyncRuns() {
const status = document.getElementById('invoiceSyncStatusFilter').value;
const params = new URLSearchParams({limit: '200'});
if (status) params.set('status', status);
const body = document.getElementById('invoiceSyncTableBody');
body.innerHTML = '<tr><td colspan="6" class="text-muted py-4">Indlæser fakturabehandling…</td></tr>';
try {
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs?${params.toString()}`);
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Kunne ikke hente fakturabehandling.'));
const payload = await response.json();
const items = Array.isArray(payload.items) ? payload.items : [];
invoiceSyncItems = items;
const summary = payload.summary || {};
document.getElementById('invoiceMetricTotal').textContent = String(summary.total || 0);
document.getElementById('invoiceMetricSuccess').textContent = String(summary.success || 0);
document.getElementById('invoiceMetricWarnings').textContent = String(summary.warnings || 0);
document.getElementById('invoiceMetricErrors').textContent = String(summary.errors || 0);
if (!items.length) {
body.innerHTML = '<tr><td colspan="6" class="text-muted py-4">Ingen fakturaer matcher filteret.</td></tr>';
return;
}
body.innerHTML = items.map(item => `
<tr>
<td><div class="fw-semibold">${escapeInvoiceText(item.invoice_number || '-')}</div><div class="internet-mini">${escapeInvoiceText(item.vendor_name || 'GlobalConnect')} · ${escapeInvoiceText(item.invoice_date || '-')}</div></td>
<td>${formatInvoiceSyncDate(item.processed_at)}</td>
<td>${invoiceSyncBadge(item.processing_status)}</td>
<td><strong>${Number(item.connections_synced || item.legacy_connection_count || 0)}</strong><div class="internet-mini">${Number(item.connections_created || 0)} nye · ${Number(item.connections_updated || 0)} opdateret</div></td>
<td><strong>${Number(item.ip_ranges_synced || 0)}</strong></td>
<td>${invoiceSyncMessage(item)}</td>
</tr>
`).join('');
} catch (error) {
body.innerHTML = `<tr><td colspan="6" class="text-danger py-4">${escapeInvoiceText(error.message || 'Kunne ikke indlæse fakturabehandling.')}</td></tr>`;
}
}
async function reconcileInvoiceSyncRuns() {
const button = document.getElementById('invoiceReconcileBtn');
const feedback = document.getElementById('invoiceReconcileFeedback');
button.disabled = true;
feedback.className = 'internet-mini align-self-center text-muted';
feedback.textContent = 'Genkontrollerer…';
try {
const response = await fetch('/api/v1/internet-connections/invoice-sync-runs/reconcile', { method: 'POST' });
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Genkontrollen fejlede.'));
const payload = await response.json();
feedback.className = 'internet-mini align-self-center text-success';
feedback.textContent = `${Number(payload.resolved_lines || 0)} linjer løst`;
await loadInvoiceSyncRuns();
} catch (error) {
feedback.className = 'internet-mini align-self-center text-danger';
feedback.textContent = error.message || 'Genkontrollen fejlede.';
} finally {
button.disabled = false;
}
}
function reviewConnectionOptions(selectedId = '') {
return '<option value="">Vælg forbindelse…</option>' + allConnections
.filter(item => String(item.provider || '').toLowerCase().includes('globalconnect'))
.map(item => `<option value="${Number(item.id)}" ${String(item.id) === String(selectedId) ? 'selected' : ''}>${escapeInvoiceText(item.circuit_number || item.name || `#${item.id}`)} · ${escapeInvoiceText(item.address || '-')}</option>`)
.join('');
}
function toggleResolvedInvoiceLines() {
const show = document.getElementById('showResolvedInvoiceLines').checked;
document.querySelectorAll('#invoiceReviewLines .resolved-review-line').forEach((element) => {
element.classList.toggle('d-none', !show);
});
}
async function openInvoiceReview(runId) {
const summaryItem = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
if (!summaryItem) return;
const linesWrap = document.getElementById('invoiceReviewLines');
linesWrap.innerHTML = '<div class="text-muted py-4">Henter kontrollinjer…</div>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs/${runId}`);
if (!response.ok) {
linesWrap.innerHTML = `<div class="text-danger py-4">${escapeInvoiceText(await extractErrorMessage(response, 'Kunne ikke hente kontrollinjer.'))}</div>`;
return;
}
const detail = await response.json();
const item = { ...summaryItem, ...detail };
if (!item) return;
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
const decisions = Array.isArray(item.review_decisions) ? item.review_decisions : [];
const decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision]));
document.getElementById('invoiceReviewNumber').textContent = item.invoice_number || '-';
document.getElementById('invoiceReviewSummary').textContent = `${Number(item.resolved_lines || 0)} løst · ${Number(item.unresolved_lines || 0)} mangler`;
document.getElementById('showResolvedInvoiceLines').checked = false;
const groups = skippedItems.reduce((result, line) => {
const reason = line.reason || 'Anden kontrol';
(result[reason] ||= []).push(line);
return result;
}, {});
document.getElementById('invoiceReviewLines').innerHTML = Object.entries(groups).map(([reason, lines]) => `
<section class="mb-4">
<h6 class="mb-2">${escapeInvoiceText(reason)} <span class="badge bg-secondary">${lines.length}</span></h6>
<div class="vstack gap-2">
${lines.map(line => {
const decision = decisionsByLine.get(Number(line.line_number));
return `<div class="border rounded p-3 ${decision ? 'bg-light opacity-75 resolved-review-line d-none' : ''}">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div class="flex-grow-1">
<div class="fw-semibold">Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}</div>
<div class="internet-mini">Reference: ${escapeInvoiceText(line.provider_reference || '-')} · Adresse: ${escapeInvoiceText(line.service_address || '-')}</div>
${line.ip_address ? `<div class="internet-mini">IP-range: ${escapeInvoiceText(line.ip_address)}</div>` : ''}
${decision ? `<div class="text-success small mt-2"><i class="bi bi-check-circle me-1"></i>Løst: ${escapeInvoiceText(decision.action)}${decision.note ? ` · ${escapeInvoiceText(decision.note)}` : ''}</div>` : ''}
</div>
${decision ? '' : `<div style="min-width:340px;">
<select class="form-select form-select-sm mb-2" id="reviewConnection-${runId}-${Number(line.line_number)}">${reviewConnectionOptions()}</select>
<input class="form-control form-control-sm mb-2" id="reviewNote-${runId}-${Number(line.line_number)}" placeholder="Begrundelse / note">
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-primary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'link_existing')">Brug valgt</button>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'create_separate')">Opret separat</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'ignore')">Ignorer</button>
</div>
</div>`}
</div>
</div>`;
}).join('')}
</div>
</section>
`).join('') || '<div class="text-success">Alle kontrollinjer er løst.</div>';
}
async function submitInvoiceReview(runId, lineNumber, action) {
const connectionId = document.getElementById(`reviewConnection-${runId}-${lineNumber}`)?.value || null;
const note = document.getElementById(`reviewNote-${runId}-${lineNumber}`)?.value.trim() || '';
if (action === 'link_existing' && !connectionId) {
alert('Vælg først en eksisterende forbindelse.');
return;
}
if (action === 'ignore' && !note) {
alert('Skriv en begrundelse for at ignorere linjen.');
return;
}
if (action === 'create_separate' && !confirm('Opret en separat, afventende forbindelse for denne linje?')) return;
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs/${runId}/review`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
line_number: lineNumber,
action,
connection_id: connectionId ? Number(connectionId) : null,
note: note || null,
}),
});
if (!response.ok) {
alert(await extractErrorMessage(response, 'Kontrollen kunne ikke gemmes.'));
return;
}
await loadInvoiceSyncRuns();
openInvoiceReview(runId);
}
async function loadInternetPage() {
const search = document.getElementById('searchInput').value.trim();
const provider = document.getElementById('providerFilter').value.trim();
const status = document.getElementById('statusFilter').value;
const valueType = document.getElementById('valueTypeFilter').value;
const params = new URLSearchParams();
if (search) params.set('q', search);
if (provider) params.set('provider', provider);
if (status) params.set('status', status);
if (valueType) params.set('value_type', valueType);
if (activeTab === 'shared') params.set('shared_only', 'true');
if (activeTab === 'bmcnet') params.set('bmcnet_only', 'true');
if (activeTab === 'dedicated') {
params.set('allocation_model', 'dedicated');
params.set('allocated_only', 'true');
}
if (activeTab === 'unallocated') params.set('unallocated_only', 'true');
try {
const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`);
const connections = await safeJson(connectionsResponse, []);
allConnections = Array.isArray(connections) ? connections : [];
allocationSuggestionsByConnection = new Map();
if (activeTab === 'unallocated') {
const allocationResponse = await fetch('/api/v1/internet-connections/allocation-overview');
const allocationPayload = await safeJson(allocationResponse, { items: [] });
allocationSuggestionsByConnection = new Map(
(allocationPayload.items || []).map((entry) => [Number(entry.connection_id), entry])
);
}
const total = allConnections.length;
const active = allConnections.filter((item) => item.status === 'active').length;
const pending = allConnections.filter((item) => item.status === 'pending').length;
const sharedHeads = allConnections.filter((item) => item.is_shared_head).length;
const bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length;
const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0);
document.getElementById('metricTotal').textContent = String(total);
document.getElementById('metricActive').textContent = `${active} / ${pending}`;
const focusMetric = activeTab === 'bmcnet'
? ['BMCnet', bmcnetConnections]
: activeTab === 'dedicated'
? ['Dedikerede', total]
: activeTab === 'unallocated'
? ['Uden kunde', total]
: ['Delte hoveder', sharedHeads];
document.getElementById('metricSharedLabel').textContent = focusMetric[0];
document.getElementById('metricShared').textContent = String(focusMetric[1]);
document.getElementById('metricMargin').textContent = formatDKK(margin);
renderConnections(allConnections);
} catch (error) {
document.getElementById('connectionsTableBody').innerHTML = '<tr><td colspan="7" class="text-danger py-4">Kunne ikke indlæse internetforbindelser.</td></tr>';
document.getElementById('pageSummaryText').textContent = 'Indlæsning fejlede.';
}
}
function renderConnections(connections) {
const body = document.getElementById('connectionsTableBody');
document.getElementById('pageSummaryText').textContent = `${connections.length} forbindelser vist${currentTabDescription()}`;
if (!connections.length) {
body.innerHTML = '<tr><td colspan="7" class="text-muted py-4">Ingen forbindelser matcher filtrene.</td></tr>';
return;
}
body.innerHTML = connections.map((item) => {
const allocation = allocationSuggestionsByConnection.get(Number(item.id));
const uniqueSuggestion = allocation?.unique_suggestion;
const suggestionCount = Number(allocation?.suggestions?.length || 0);
const isOwnedSharedFiber = Boolean(item.is_shared_head && (item.value_type === 'delefiber' || item.is_manual_shared));
return `
<tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'">
<td>
<div class="fw-semibold">${escapeHtml(item.name || '-')}</div>
<div class="mb-1">${allocationBadge(item)}${valueBadge(item)}</div>
<div class="internet-mini">${escapeHtml(item.address || '-')}</div>
${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${escapeHtml(item.parent_name)}</div>` : ''}
${item.is_shared_head ? `<div class="internet-mini"><i class="bi bi-diagram-3 me-1"></i>${Number(item.bmcnet_child_count || 0)} BMCnet-kunder · ${formatDKK(item.bmcnet_child_sales_price || 0)} salg</div>` : ''}
</td>
<td>
<div>${escapeHtml(isOwnedSharedFiber ? 'BMC Networks' : (item.customer_name || '-'))}</div>
<div class="internet-mini">${isOwnedSharedFiber ? 'Ejer · delt hovedfiber' : `Kunde-ID: ${item.customer_id || '-'}`}</div>
${!isOwnedSharedFiber && uniqueSuggestion ? `<button class="btn btn-sm btn-outline-success mt-1" type="button" onclick="event.stopPropagation(); assignSuggestedCustomer(${Number(item.id)}, ${Number(uniqueSuggestion.customer_id)})"><i class="bi bi-person-check me-1"></i>${escapeHtml(uniqueSuggestion.customer_name || 'Tildel foreslået kunde')}</button>` : ''}
${!isOwnedSharedFiber && !uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
${isOwnedSharedFiber ? '' : item.sla_subscription_id
? `<div class="internet-mini ${Number(item.sla_price || 0) > 0 && item.sla_status === 'active' ? 'text-success' : 'text-warning'} fw-semibold"><i class="bi bi-shield-check me-1"></i>${escapeHtml(item.sla_product_name || 'SLA')} · ${formatDKK(item.sla_price || 0)}${Number(item.sla_price || 0) > 0 ? '' : ' · Kontrollér pris'}${item.sla_status === 'active' ? '' : ' · Ikke aktiv'}</div>`
: `<div class="internet-mini text-danger fw-semibold"><i class="bi bi-shield-exclamation me-1"></i>Ingen SLA-aftale</div>`}
</td>
<td>
<div>${escapeHtml(item.provider || '-')}</div>
<div class="internet-mini">${escapeHtml(item.circuit_number || 'Intet kredsløb')}</div>
${item.subscription_number ? `<div class="internet-mini">Abonnement ${escapeHtml(item.subscription_number)} · ${escapeHtml(item.subscription_product_name || '-')}</div>` : (item.value_label ? `<div class="internet-mini">${escapeHtml(item.value_label)}</div>` : '')}
</td>
<td>
<div>${formatSpeed(item)}</div>
<div class="internet-mini">Ranges ${Number(item.ip_range_count || 0)} · IP i brug: ${Number(item.in_use_ip_addresses || 0)} / ${Number(item.total_ip_addresses || 0)}</div>
${item.is_shared_head ? `<div class="internet-mini">BMCnet-IP i brug: ${Number(item.bmcnet_child_ip_count || 0)}</div>` : ''}
</td>
<td>
<div>${formatDKK(item.sales_price || 0)}</div>
<div class="internet-mini">Kost ${formatDKK(item.monthly_cost || 0)} · DB ${formatDKK(item.margin_amount || 0)}</div>
</td>
<td>${statusBadge(item.status)}</td>
<td class="text-end">
<a href="/economy/internet-connections/${item.id}" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
Åbn
</a>
</td>
</tr>
`}).join('');
}
async function assignSuggestedCustomer(connectionId, customerId) {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customerId }),
});
if (!response.ok) {
document.getElementById('pageSummaryText').textContent = await extractErrorMessage(response, 'Kunden kunne ikke tildeles.');
return;
}
await loadInternetPage();
}
async function submitConnectionForm() {
const feedback = document.getElementById('createConnectionFeedback');
const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary');
const payload = {
name: document.getElementById('connectionNameInput').value.trim(),
vendor_id: Number(document.getElementById('connectionVendorInput').value || 0) || null,
customer_id: Number(document.getElementById('connectionCustomerIdInput').value || 0) || null,
circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null,
address: document.getElementById('connectionAddressInput').value.trim() || null,
technology: document.getElementById('connectionTechnologyInput').value.trim() || null,
download_mbps: Number(document.getElementById('connectionDownloadInput').value || 0) || null,
upload_mbps: Number(document.getElementById('connectionUploadInput').value || 0) || null,
monthly_cost: Number(document.getElementById('connectionPurchaseInput').value || 0),
sales_price: Number(document.getElementById('connectionSalesInput').value || 0),
status: document.getElementById('connectionStatusInput').value || 'active',
notes: document.getElementById('connectionNotesInput').value.trim() || null,
connection_type: 'fiber',
allocation_model: document.getElementById('connectionAllocationInput').value || 'dedicated',
value_type: document.getElementById('connectionValueTypeInput').value || 'other',
value_label: document.getElementById('connectionValueLabelInput').value.trim() || null,
subscription_id: Number(document.getElementById('connectionSubscriptionIdInput').value || 0) || null,
is_manual_shared: document.getElementById('connectionAllocationInput').value === 'shared'
&& document.getElementById('connectionValueTypeInput').value === 'delefiber',
};
if (!payload.name) {
feedback.textContent = 'Navn er påkrævet.';
return;
}
if (!payload.address) {
feedback.textContent = 'Adresse er påkrævet.';
return;
}
if (payload.value_type === 'subscription' && !payload.subscription_id) {
feedback.textContent = 'Vælg et gyldigt abonnement.';
return;
}
feedback.textContent = 'Gemmer...';
if (saveButton) saveButton.disabled = true;
try {
const response = await fetch('/api/v1/internet-connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
feedback.textContent = await extractErrorMessage(response, 'Kunne ikke gemme forbindelsen.');
return;
}
feedback.textContent = 'Forbindelse oprettet.';
[
'connectionNameInput',
'connectionVendorInput',
'connectionCustomerIdInput',
'connectionCircuitInput',
'connectionAddressInput',
'connectionTechnologyInput',
'connectionDownloadInput',
'connectionUploadInput',
'connectionPurchaseInput',
'connectionSalesInput',
'connectionNotesInput',
].forEach((id) => {
const field = document.getElementById(id);
if (field) field.value = '';
});
document.getElementById('connectionStatusInput').value = 'active';
document.getElementById('connectionAllocationInput').value = 'dedicated';
document.getElementById('connectionValueTypeInput').value = 'other';
document.getElementById('connectionValueLabelInput').value = 'Mangler klassifikation';
document.getElementById('connectionSubscriptionInput').value = '';
document.getElementById('connectionSubscriptionIdInput').value = '';
toggleCreateValueFields();
await loadInternetPage();
} catch (error) {
feedback.textContent = error?.message || 'Netværksfejl under gem.';
} finally {
if (saveButton) saveButton.disabled = false;
}
}
function resetFilters() {
document.getElementById('searchInput').value = '';
document.getElementById('providerFilter').value = '';
document.getElementById('statusFilter').value = '';
document.getElementById('valueTypeFilter').value = '';
loadInternetPage();
}
function setActiveTab(tab) {
activeTab = tab;
document.getElementById('tabAll').classList.toggle('active', tab === 'all');
document.getElementById('tabShared').classList.toggle('active', tab === 'shared');
document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet');
document.getElementById('tabDedicated').classList.toggle('active', tab === 'dedicated');
document.getElementById('tabUnallocated').classList.toggle('active', tab === 'unallocated');
document.getElementById('tabInvoices').classList.toggle('active', tab === 'invoices');
const invoiceMode = tab === 'invoices';
document.getElementById('connectionsMetrics').classList.toggle('d-none', invoiceMode);
document.getElementById('connectionsOverview').classList.toggle('d-none', invoiceMode);
document.getElementById('createConnectionBlock').classList.add('collapse');
document.getElementById('createConnectionBlock').classList.remove('show');
document.getElementById('newConnectionBtn').classList.toggle('d-none', invoiceMode);
document.getElementById('invoiceProcessingOverview').classList.toggle('d-none', !invoiceMode);
if (invoiceMode) loadInvoiceSyncRuns();
else loadInternetPage();
}
function refreshActiveTab() {
if (activeTab === 'invoices') return loadInvoiceSyncRuns();
return loadInternetPage();
}
function toggleCreateValueFields() {
const valueType = document.getElementById('connectionValueTypeInput').value;
document.getElementById('createValueLabelWrap').classList.toggle('d-none', valueType !== 'other');
document.getElementById('createSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription');
}
function buildSubscriptionLabel(item) {
return `${item.id} · ${item.subscription_number || '-'} · ${item.product_name || '-'} · ${item.customer_name || '-'}`;
}
function resolveSubscriptionId(value) {
const raw = String(value || '').trim();
if (!raw) return null;
const idMatch = raw.match(/^(\d+)\b/);
if (idMatch) return Number(idMatch[1]);
const matched = subscriptionOptions.find((item) => buildSubscriptionLabel(item).toLowerCase() === raw.toLowerCase());
return matched ? Number(matched.id) : null;
}
async function loadSubscriptionOptions() {
try {
const response = await fetch('/api/v1/internet-connections/subscription-options');
subscriptionOptions = response.ok ? await response.json() : [];
} catch (error) {
subscriptionOptions = [];
}
document.getElementById('subscriptionLookupList').innerHTML = subscriptionOptions
.map((item) => `<option value="${buildSubscriptionLabel(item)}"></option>`)
.join('');
}
async function loadInternetVendors() {
const select = document.getElementById('connectionVendorInput');
try {
const response = await fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100');
const vendors = response.ok ? await response.json() : [];
select.innerHTML = '<option value="">Vælg internetleverandør</option>' + vendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
const quickVendor = document.getElementById('quickVendor');
if (quickVendor) quickVendor.innerHTML = '<option value="">Vælg leverandør…</option>' + vendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
} catch (error) {
select.innerHTML = '<option value="">Kunne ikke hente leverandører</option>';
}
}
function quickCustomerLabel(item) {
return `${item.id} · ${item.name || '-'}`;
}
function resolveQuickCustomerId() {
const raw = document.getElementById('quickCustomerLookup').value.trim();
const normalized = raw.toLowerCase();
const match = quickCustomerOptions.find((item) =>
quickCustomerLabel(item).toLowerCase() === normalized
|| String(item.name || '').trim().toLowerCase() === normalized
|| String(item.id) === raw
);
const id = match ? Number(match.id) : null;
document.getElementById('quickCustomerId').value = id || '';
if (match && !document.getElementById('quickConnectionName').value.trim()) {
document.getElementById('quickConnectionName').value = `Internet - ${match.name}`;
}
if (match) {
const customerAddress = [match.address, match.postal_code, match.city].filter(Boolean).join(', ');
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
suggestQuickSharedHead();
}
updateQuickSetupSummary();
return id;
}
async function searchQuickCustomers(query) {
const q = String(query || '').trim();
const results = document.getElementById('quickCustomerResults');
if (q.length < 2) {
quickCustomerOptions = [];
results.innerHTML = '';
results.classList.add('d-none');
return;
}
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(q)}`);
if (document.getElementById('quickCustomerLookup').value.trim() !== q) return;
if (response.status === 401) {
quickCustomerOptions = [];
results.innerHTML = '<div class="p-3 text-danger"><strong>Din session er udløbet.</strong><br><a href="/login">Log ind igen</a> og åbn derefter guiden på ny.</div>';
results.classList.remove('d-none');
return;
}
if (!response.ok) {
quickCustomerOptions = [];
results.innerHTML = `<div class="p-3 text-danger">Kunderne kunne ikke hentes (fejl ${response.status}).</div>`;
results.classList.remove('d-none');
return;
}
quickCustomerOptions = response.ok ? await response.json() : [];
results.innerHTML = quickCustomerOptions.length
? quickCustomerOptions.map((item) => `
<button class="quick-customer-result" type="button" data-quick-customer-id="${item.id}">
<strong>${escapeHtml(item.name || '-')}</strong>
<span class="small text-muted ms-2">${item.cvr_nummer ? `CVR ${escapeHtml(item.cvr_nummer)}` : `ID ${item.id}`}</span>
${item.address ? `<div class="small text-muted">${escapeHtml([item.address, item.postal_code, item.city].filter(Boolean).join(', '))}</div>` : ''}
</button>`).join('')
: '<div class="small text-muted p-3">Ingen kunder fundet</div>';
results.classList.remove('d-none');
}
function selectQuickCustomer(customerId) {
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(customerId));
if (!customer) return;
document.getElementById('quickCustomerLookup').value = customer.name || quickCustomerLabel(customer);
document.getElementById('quickCustomerId').value = String(customer.id);
document.getElementById('quickCustomerResults').classList.add('d-none');
if (!document.getElementById('quickConnectionName').value.trim()) {
document.getElementById('quickConnectionName').value = `Internet - ${customer.name}`;
}
const customerAddress = [customer.address, customer.postal_code, customer.city].filter(Boolean).join(', ');
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
suggestQuickSharedHead();
updateQuickSetupSummary();
}
async function loadQuickProducts() {
const response = await fetch('/api/v1/products?limit=1000&is_active=true');
const products = response.ok ? await response.json() : [];
quickProductOptions = products.filter((item) => {
const text = `${item.name || ''} ${item.short_description || ''}`.toLowerCase();
const network = item.attributes_json?.network || {};
return network.kind === 'internet_access' || /internet|fiber|bredb/.test(text);
});
document.getElementById('quickProduct').innerHTML = '<option value="">Vælg internetprodukt…</option>'
+ quickProductOptions.map((item) => `<option value="${item.id}" data-price="${Number(item.sales_price || 0)}">${escapeHtml(item.name || '-')} · ${formatDKK(item.sales_price || 0)}</option>`).join('');
}
function selectQuickSetupType(type) {
quickSetupType = type;
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.toggle('active', button.dataset.quickType === type));
document.getElementById('quickTypeHint').classList.add('d-none');
document.getElementById('quickSetupFields').classList.remove('d-none');
document.getElementById('quickSetupSubmit').classList.remove('d-none');
const isBmcnet = type === 'bmcnet';
const isShared = type === 'shared';
document.getElementById('quickSharedHeadWrap').classList.toggle('d-none', !isBmcnet);
document.getElementById('quickConnectionModeWrap').classList.toggle('d-none', isBmcnet || isShared);
document.getElementById('quickCustomerWrap').classList.toggle('d-none', isShared);
document.getElementById('quickOwnerWrap').classList.toggle('d-none', !isShared);
document.querySelectorAll('.quick-subscription-field').forEach((node) => node.classList.toggle('d-none', isShared));
document.getElementById('quickConnectionStepTitle').textContent = isShared ? '2. Forbindelse' : '3. Forbindelse';
document.getElementById('quickAdvancedConnectionFields').classList.toggle('d-none', isBmcnet);
document.getElementById('quickBmcnetNotesWrap').classList.toggle('d-none', !isBmcnet);
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', isBmcnet));
if (isBmcnet || isShared) document.getElementById('quickConnectionMode').value = 'new';
document.getElementById('quickSetupSubmit').innerHTML = isBmcnet
? '<i class="bi bi-check2-circle me-1"></i>Opret BMCnet og abonnement'
: isShared
? '<i class="bi bi-check2-circle me-1"></i>Opret delt hovedfiber'
: '<i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement';
toggleQuickConnectionMode();
}
function toggleQuickConnectionMode() {
const existing = document.getElementById('quickConnectionMode').value === 'existing';
document.getElementById('quickExistingWrap').classList.toggle('d-none', !existing);
document.querySelectorAll('.quick-new-field').forEach((node) => node.classList.toggle('d-none', existing));
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', existing || quickSetupType === 'bmcnet'));
updateQuickSetupSummary();
}
async function populateQuickConnections() {
let connections = [];
try {
const response = await fetch('/api/v1/internet-connections');
connections = response.ok ? await response.json() : [];
} catch (error) {
connections = allConnections;
}
document.getElementById('quickExistingConnection').innerHTML = '<option value="">Vælg forbindelse…</option>'
+ connections.filter((item) => !item.subscription_id).map((item) => `<option value="${item.id}">${escapeHtml(item.name || `#${item.id}`)} · ${escapeHtml(item.customer_name || 'Ingen kunde')}</option>`).join('');
quickSharedHeadOptions = connections.filter((item) => item.is_shared_head);
document.getElementById('quickSharedHead').innerHTML = '<option value="">Vælg hovedforbindelse…</option>'
+ quickSharedHeadOptions.map((item) => `<option value="${item.id}">${escapeHtml(item.name || `#${item.id}`)}${item.address ? ` · ${escapeHtml(item.address)}` : ''}</option>`).join('');
}
function normalizeQuickAddress(value) {
return String(value || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function quickEditDistance(left, right) {
const previous = Array.from({length: right.length + 1}, (_, index) => index);
for (let i = 1; i <= left.length; i += 1) {
let diagonal = previous[0];
previous[0] = i;
for (let j = 1; j <= right.length; j += 1) {
const above = previous[j];
previous[j] = Math.min(
previous[j] + 1,
previous[j - 1] + 1,
diagonal + (left[i - 1] === right[j - 1] ? 0 : 1),
);
diagonal = above;
}
}
return previous[right.length];
}
function quickTokenSimilarity(left, right) {
if (left === right) return 1;
return 1 - (quickEditDistance(left, right) / Math.max(left.length, right.length, 1));
}
function quickAddressMatchScore(input, candidate) {
const wanted = normalizeQuickAddress(input);
const existing = normalizeQuickAddress(candidate);
if (!wanted || !existing) return 0;
if (wanted === existing) return 100;
const wantedParts = wanted.split(' ').filter((part) => part.length > 1 && !/^\d+$/.test(part));
const existingParts = existing.split(' ').filter((part) => part.length > 1 && !/^\d+$/.test(part));
const tokenScore = wantedParts.length
? wantedParts.reduce((sum, part) => sum + Math.max(0, ...existingParts.map((candidatePart) => quickTokenSimilarity(part, candidatePart))), 0) / wantedParts.length
: 0;
const wantedNumbers = wanted.match(/\b\d+[a-z]?\b/g) || [];
const existingNumbers = new Set(existing.match(/\b\d+[a-z]?\b/g) || []);
const wantedPostal = wantedNumbers.find((part) => /^\d{4}$/.test(part));
const wantedHouse = wantedNumbers.find((part) => part !== wantedPostal);
const samePostal = Boolean(wantedPostal && existingNumbers.has(wantedPostal));
const sameHouseNumber = Boolean(wantedHouse && existingNumbers.has(wantedHouse));
return Math.round((tokenScore * 65) + (sameHouseNumber ? 25 : 0) + (samePostal ? 10 : 0));
}
function suggestQuickSharedHead() {
if (quickSetupType !== 'bmcnet') return;
const address = document.getElementById('quickAddress').value.trim();
const select = document.getElementById('quickSharedHead');
const hint = document.getElementById('quickSharedHeadSuggestion');
if (address.length < 4) {
hint.className = 'small text-muted mt-1';
hint.textContent = 'Skriv installationsadressen, så foreslår systemet en hovedforbindelse.';
return;
}
const matches = quickSharedHeadOptions
.map((item) => ({item, score: quickAddressMatchScore(address, item.address)}))
.filter((match) => match.score >= 55)
.sort((a, b) => b.score - a.score);
if (!matches.length) {
hint.className = 'small text-warning mt-1';
hint.textContent = 'Ingen delt hovedforbindelse matcher adressen. Vælg manuelt.';
return;
}
const best = matches[0];
select.value = String(best.item.id);
hint.className = 'small text-success fw-semibold mt-1';
hint.innerHTML = `<i class="bi bi-check-circle me-1"></i>Foreslået ud fra adressen: ${escapeHtml(best.item.name || `#${best.item.id}`)}${best.item.address ? ` · ${escapeHtml(best.item.address)}` : ''}`;
updateQuickSetupSummary();
}
function updateQuickSetupSummary() {
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(document.getElementById('quickCustomerId').value || 0));
const product = quickProductOptions.find((item) => Number(item.id) === Number(document.getElementById('quickProduct').value || 0));
const existing = document.getElementById('quickConnectionMode').value === 'existing';
const connectionText = existing
? document.getElementById('quickExistingConnection').selectedOptions[0]?.textContent || 'vælg forbindelse'
: document.getElementById('quickConnectionName').value.trim() || 'ny forbindelse';
const owner = quickSetupType === 'shared' ? 'BMC Networks (delefiber)' : (customer?.name || 'vælg kunde');
document.getElementById('quickSetupSummary').innerHTML = quickSetupType === 'shared'
? `<strong>Kontrol:</strong> ${escapeHtml(owner)} · ${escapeHtml(connectionText)}. Der oprettes kun en delt hovedforbindelse — intet produkt eller abonnement.`
: `<strong>Kontrol:</strong> ${escapeHtml(owner)} · ${escapeHtml(product?.name || 'vælg produkt')} · ${escapeHtml(connectionText)}. Abonnementet oprettes som kladde.`;
}
async function submitQuickSetup() {
const feedback = document.getElementById('quickSetupFeedback');
const button = document.getElementById('quickSetupSubmit');
const customerId = resolveQuickCustomerId();
const productId = Number(document.getElementById('quickProduct').value || 0) || null;
const existing = document.getElementById('quickConnectionMode').value === 'existing';
const payload = {
customer_id: customerId,
product_id: productId,
start_date: document.getElementById('quickStartDate').value,
connection_id: existing ? (Number(document.getElementById('quickExistingConnection').value || 0) || null) : null,
connection_name: existing ? null : document.getElementById('quickConnectionName').value.trim(),
vendor_id: existing ? null : (Number(document.getElementById('quickVendor').value || 0) || null),
circuit_number: document.getElementById('quickCircuit').value.trim() || null,
address: document.getElementById('quickAddress').value.trim() || null,
technology: document.getElementById('quickTechnology').value.trim() || null,
download_mbps: Number(document.getElementById('quickDownload').value || 0) || null,
upload_mbps: Number(document.getElementById('quickUpload').value || 0) || null,
monthly_cost: Number(document.getElementById('quickCost').value || 0),
unit_price: document.getElementById('quickPrice').value === '' ? null : Number(document.getElementById('quickPrice').value),
billing_interval: document.getElementById('quickInterval').value,
billing_day: Number(document.getElementById('quickBillingDay').value || 1),
notes: (quickSetupType === 'bmcnet' ? document.getElementById('quickBmcnetNotes').value : document.getElementById('quickNotes').value).trim() || null,
shared_fiber: quickSetupType === 'shared',
};
if (!quickSetupType) {
feedback.textContent = 'Vælg først fysisk forbindelse eller BMCnet.';
return;
}
const missingSubscription = quickSetupType !== 'shared' && (!payload.customer_id || !payload.product_id || !payload.start_date);
if (missingSubscription || (existing ? !payload.connection_id : !payload.connection_name)) {
feedback.textContent = quickSetupType === 'shared'
? 'Skriv et navn til hovedforbindelsen.'
: 'Vælg kunde, produkt, startdato og forbindelse.';
return;
}
button.disabled = true;
feedback.textContent = 'Opretter og forbinder…';
try {
const sharedHeadId = Number(document.getElementById('quickSharedHead').value || 0) || null;
if (quickSetupType === 'bmcnet' && !sharedHeadId) throw new Error('Vælg en delt hovedforbindelse.');
const endpoint = quickSetupType === 'bmcnet'
? `/api/v1/internet-connections/${sharedHeadId}/bmcnet-connections`
: '/api/v1/internet-connections/quick-setup';
const requestPayload = quickSetupType === 'bmcnet' ? {
customer_id: payload.customer_id,
case_title: payload.connection_name,
service_label: payload.connection_name,
address: payload.address,
billing_interval: payload.billing_interval,
billing_day: payload.billing_day,
start_date: payload.start_date,
internet_product_id: payload.product_id,
internet_unit_price: payload.unit_price,
notes: payload.notes,
} : payload;
const response = await fetch(endpoint, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(requestPayload)});
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
const connectionLink = `<a href="/economy/internet-connections/${result.connection.id}">Åbn forbindelsen</a>`;
const subscriptionLink = result.sag?.id ? ` · <a href="/sag/${result.sag.id}/v3?tab=subscription">Åbn abonnementet</a>` : '';
feedback.innerHTML = `Oprettet. ${connectionLink}${subscriptionLink}`;
await loadInternetPage();
} catch (error) {
feedback.textContent = `Kunne ikke oprette: ${error.message}`;
} finally {
button.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadInternetPage();
});
document.getElementById('connectionSubscriptionInput').addEventListener('change', () => {
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
});
document.getElementById('quickCustomerLookup').addEventListener('input', (event) => {
document.getElementById('quickCustomerId').value = '';
clearTimeout(quickCustomerTimer);
quickCustomerTimer = setTimeout(() => searchQuickCustomers(event.target.value), 220);
});
document.getElementById('quickCustomerLookup').addEventListener('change', resolveQuickCustomerId);
document.getElementById('quickCustomerResults').addEventListener('click', (event) => {
const button = event.target.closest('[data-quick-customer-id]');
if (button) selectQuickCustomer(Number(button.dataset.quickCustomerId));
});
document.getElementById('quickProduct').addEventListener('change', (event) => {
const option = event.target.selectedOptions[0];
document.getElementById('quickPrice').value = option?.value ? option.dataset.price || '' : '';
updateQuickSetupSummary();
});
document.getElementById('quickAddress').addEventListener('input', suggestQuickSharedHead);
['quickExistingConnection','quickConnectionName','quickStartDate','quickInterval','quickBillingDay'].forEach((id) => document.getElementById(id)?.addEventListener('change', updateQuickSetupSummary));
document.getElementById('quickSetupModal').addEventListener('show.bs.modal', async () => {
quickSetupType = null;
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.remove('active'));
document.getElementById('quickTypeHint').classList.remove('d-none');
document.getElementById('quickSetupFields').classList.add('d-none');
document.getElementById('quickSetupSubmit').classList.add('d-none');
if (!document.getElementById('quickStartDate').value) document.getElementById('quickStartDate').value = new Date().toISOString().slice(0, 10);
await loadQuickProducts();
await populateQuickConnections();
toggleQuickConnectionMode();
});
toggleCreateValueFields();
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
});
</script>
<datalist id="subscriptionLookupList"></datalist>
{% endblock %}