- Added multiple test cases for the sag module to ensure proper functionality and data handling. - Created new templates for knowledge detail and knowledge index pages to display articles and solutions. - Introduced migrations to enhance the internet connections schema, including new columns for manual sharing and SLA subscriptions. - Added a script to reconcile known internet connections with verified data. - Planned the implementation of a new website content administration module for managing customer references and operational status.
1074 lines
51 KiB
HTML
1074 lines
51 KiB
HTML
{% 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;
|
|
}
|
|
|
|
.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="collapse" data-bs-target="#createConnectionBlock">
|
|
<i class="bi bi-plus-lg me-1"></i>Ny forbindelse
|
|
</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="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 ipNordicPreview = null;
|
|
let allocationSuggestionsByConnection = new Map();
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({
|
|
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
|
}[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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
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);
|
|
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(item.customer_name || '-')}</div>
|
|
<div class="internet-mini">Kunde-ID: ${item.customer_id || '-'}</div>
|
|
${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>` : ''}
|
|
${!uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
|
|
${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('');
|
|
} catch (error) {
|
|
select.innerHTML = '<option value="">Kunne ikke hente leverandører</option>';
|
|
}
|
|
}
|
|
|
|
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) || '';
|
|
});
|
|
toggleCreateValueFields();
|
|
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
|
|
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
|
|
});
|
|
</script>
|
|
<datalist id="subscriptionLookupList"></datalist>
|
|
{% endblock %}
|