- Implemented a comprehensive end-to-end testing script for the Sag module's HTTP API, covering various functionalities including case creation, updates, and file uploads. - Introduced a safe HTML sanitizer utility to ensure safe rendering of HTML content in the BMC Hub UI. - Added database migrations for new features including WAN connection marking for wall outlets, permanent audit trails for supplier invoices, and dedicated permissions for the Sag module. - Created a migration center for manual subscription and invoice migrations with relevant tables and indexes. - Added tests for migration center functionalities, ensuring stability and correctness of the new features.
1964 lines
124 KiB
HTML
1964 lines
124 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
||
|
||
{% block title %}{{ location.name }} - BMC Hub{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); }
|
||
.patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; }
|
||
.patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
|
||
button.patch-port:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; }
|
||
.patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; }
|
||
.patch-port.hardware-linked { background: #6f42c1; border-color: #59359f; color:#fff; }
|
||
.patch-port.wan { background: #0dcaf0; border-color: #087990; color:#052c34; box-shadow: inset 0 0 0 2px rgba(255,255,255,.45); }
|
||
.patch-port.smart-issue { background:#ffc107; border-color:#b58105; color:#332701; animation:smart-port-pulse 1.8s ease-in-out infinite; }
|
||
.patch-port.unconfigured { background:#52677d; border-color:#34495e; color:#fff; }
|
||
.patch-port.border-success { border-width:4px !important; border-color:#20c997 !important; box-shadow:0 0 0 1px rgba(255,255,255,.55); }
|
||
.patch-port.border-danger { border-width:4px !important; border-color:#ff5c6c !important; box-shadow:0 0 0 1px rgba(255,255,255,.55); }
|
||
.smart-port-hidden { display:none !important; }
|
||
@keyframes smart-port-pulse { 50% { box-shadow:0 0 0 3px rgba(255,193,7,.3); } }
|
||
.patch-port.reserved { background: #ffc107; border-color: #d39e00; color:#332701; }
|
||
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
|
||
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
|
||
.patch-port .patch-port-outlet { font-size:.58rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:0 .15rem; }
|
||
@media (max-width: 1100px) { .patch-panel-grid { grid-template-columns: repeat(12, minmax(38px, 1fr)); } }
|
||
@media (max-width: 700px) { .patch-panel-grid { grid-template-columns: repeat(6, minmax(38px, 1fr)); } }
|
||
.locations-detail-page {
|
||
--loc-accent: var(--accent, #0f4c75);
|
||
}
|
||
|
||
.locations-detail-page .case-hero {
|
||
background: var(--bg-card, #fff);
|
||
border-radius: 16px;
|
||
overflow: visible;
|
||
box-shadow:
|
||
0 0 0 1px rgba(0,0,0,0.06),
|
||
0 4px 6px -1px rgba(0,0,0,0.05),
|
||
0 16px 32px -8px rgba(15,76,117,0.10);
|
||
}
|
||
|
||
.locations-detail-page .case-hero-identity {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.75rem 1.25rem;
|
||
background: linear-gradient(135deg, rgba(15,76,117,0.05) 0%, rgba(15,76,117,0.01) 100%);
|
||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||
border-radius: 16px 16px 0 0;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.locations-detail-page .case-id-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
font-size: 1rem;
|
||
font-weight: 900;
|
||
letter-spacing: -0.4px;
|
||
color: var(--loc-accent);
|
||
background: color-mix(in srgb, var(--loc-accent) 10%, transparent);
|
||
border: 1.5px solid color-mix(in srgb, var(--loc-accent) 30%, transparent);
|
||
border-radius: 8px;
|
||
padding: 0.2em 0.65em;
|
||
}
|
||
|
||
.locations-detail-page .case-type-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
font-size: 0.73rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
color: var(--loc-accent);
|
||
background: color-mix(in srgb, var(--loc-accent) 8%, transparent);
|
||
border: 1px solid color-mix(in srgb, var(--loc-accent) 25%, transparent);
|
||
border-radius: 999px;
|
||
padding: 0.32em 0.8em;
|
||
}
|
||
|
||
.locations-detail-page .case-status-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4em;
|
||
font-size: 0.73rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
border-radius: 999px;
|
||
padding: 0.3em 0.85em;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.locations-detail-page .case-status-chip.open {
|
||
background: #dcfce7;
|
||
color: #15803d;
|
||
border-color: #86efac;
|
||
}
|
||
|
||
.locations-detail-page .case-status-chip.closed {
|
||
background: #f1f5f9;
|
||
color: #475569;
|
||
border-color: #cbd5e1;
|
||
}
|
||
|
||
.locations-detail-page .case-status-dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.locations-detail-page .case-hero-meta {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 0.55rem;
|
||
padding: 0.75rem 1rem 0.95rem;
|
||
}
|
||
|
||
.locations-detail-page .case-meta-cell {
|
||
border: 1px solid rgba(0,0,0,0.06);
|
||
border-radius: 10px;
|
||
background: color-mix(in srgb, var(--loc-accent) 4%, var(--bg-card, #fff));
|
||
padding: 0.58rem 0.7rem;
|
||
}
|
||
|
||
.locations-detail-page .hero-meta-label {
|
||
font-size: 0.62rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.07em;
|
||
font-weight: 700;
|
||
color: var(--text-secondary);
|
||
opacity: 0.7;
|
||
margin-bottom: 3px;
|
||
}
|
||
|
||
.locations-detail-page .hero-meta-value {
|
||
font-size: 0.95rem;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.locations-detail-page #locationTabs {
|
||
border-bottom: none;
|
||
gap: 0.45rem;
|
||
}
|
||
|
||
.locations-detail-page #locationTabs .nav-link {
|
||
border: 1px solid rgba(0,0,0,0.1);
|
||
border-radius: 999px;
|
||
color: var(--text-secondary);
|
||
background: var(--bg-card, #fff);
|
||
font-weight: 600;
|
||
padding: 0.44rem 0.82rem;
|
||
transition: all 0.16s ease;
|
||
}
|
||
|
||
.locations-detail-page #locationTabs .nav-link:hover,
|
||
.locations-detail-page #locationTabs .nav-link:focus-visible {
|
||
border-color: color-mix(in srgb, var(--loc-accent) 45%, transparent);
|
||
color: var(--loc-accent);
|
||
}
|
||
|
||
.locations-detail-page #locationTabs .nav-link.active {
|
||
background: color-mix(in srgb, var(--loc-accent) 12%, var(--bg-card, #fff));
|
||
border-color: color-mix(in srgb, var(--loc-accent) 40%, transparent);
|
||
color: var(--loc-accent);
|
||
}
|
||
|
||
.locations-detail-page .location-tab-count-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 1.2rem;
|
||
height: 1.2rem;
|
||
border-radius: 999px;
|
||
padding: 0 0.34rem;
|
||
font-size: 0.66rem;
|
||
font-weight: 800;
|
||
background: color-mix(in srgb, var(--loc-accent) 14%, transparent);
|
||
color: var(--loc-accent);
|
||
}
|
||
|
||
.locations-detail-page .card {
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(0,0,0,0.08) !important;
|
||
box-shadow: 0 6px 18px rgba(15, 76, 117, 0.08);
|
||
}
|
||
|
||
.locations-detail-page .list-group-item {
|
||
border-radius: 0.8rem !important;
|
||
margin-bottom: 0.45rem;
|
||
border: 1px solid rgba(15, 76, 117, 0.1);
|
||
}
|
||
|
||
.locations-detail-page .timeline-item {
|
||
position: relative;
|
||
}
|
||
|
||
.locations-detail-page .timeline-item::before {
|
||
content: "";
|
||
position: absolute;
|
||
left: 6px;
|
||
top: 20px;
|
||
bottom: -14px;
|
||
width: 1px;
|
||
background: rgba(15, 76, 117, 0.2);
|
||
}
|
||
|
||
.locations-detail-page .timeline-item:last-child::before {
|
||
display: none;
|
||
}
|
||
|
||
@media (max-width: 767.98px) {
|
||
.locations-detail-page {
|
||
padding-left: 0.5rem;
|
||
padding-right: 0.5rem;
|
||
}
|
||
|
||
.locations-detail-page .case-hero-meta {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid px-4 py-4 locations-detail-page">
|
||
<!-- Breadcrumb -->
|
||
<nav aria-label="breadcrumb" class="mb-4">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item"><a href="/" class="text-decoration-none">Hjem</a></li>
|
||
<li class="breadcrumb-item"><a href="/app/locations" class="text-decoration-none">Lokaliteter</a></li>
|
||
<li class="breadcrumb-item active">{{ location.name }}</li>
|
||
</ol>
|
||
</nav>
|
||
|
||
<!-- Header Section -->
|
||
<div class="row mb-4">
|
||
<div class="col-12">
|
||
<div class="case-hero">
|
||
<div class="case-hero-identity">
|
||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||
<span class="case-id-chip">Lokation #{{ location.id }}</span>
|
||
{% set type_label = {
|
||
'kompleks': 'Kompleks',
|
||
'bygning': 'Bygning',
|
||
'etage': 'Etage',
|
||
'rum': 'Rum',
|
||
'kantine': 'Kantine',
|
||
'moedelokale': 'Mødelokale',
|
||
'customer_site': 'Kundesite',
|
||
'vehicle': 'Køretøj'
|
||
}.get(location.location_type, location.location_type) %}
|
||
|
||
{% set type_color = {
|
||
'kompleks': '#0f4c75',
|
||
'bygning': '#1abc9c',
|
||
'etage': '#3498db',
|
||
'rum': '#e67e22',
|
||
'kantine': '#d35400',
|
||
'moedelokale': '#16a085',
|
||
'customer_site': '#9b59b6',
|
||
'vehicle': '#8e44ad'
|
||
}.get(location.location_type, '#6c757d') %}
|
||
|
||
<span class="case-type-chip" style="--tcolor: {{ type_color }};">
|
||
{{ type_label }}
|
||
</span>
|
||
{% if location.has_cross_field %}
|
||
<span class="case-type-chip" style="--tcolor: #6f42c1;"><i class="bi bi-diagram-3 me-1"></i>Krydsfelt</span>
|
||
{% endif %}
|
||
{% if location.is_active %}
|
||
<span class="case-status-chip open">
|
||
<span class="case-status-dot"></span>Aktiv
|
||
</span>
|
||
{% else %}
|
||
<span class="case-status-chip closed">
|
||
<span class="case-status-dot"></span>Inaktiv
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="d-flex gap-2">
|
||
<a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm">
|
||
<i class="bi bi-pencil me-2"></i>Rediger
|
||
</a>
|
||
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||
<i class="bi bi-trash me-2"></i>Slet
|
||
</button>
|
||
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
|
||
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="case-hero-meta">
|
||
<div class="case-meta-cell">
|
||
<div class="hero-meta-label">Navn</div>
|
||
<div class="hero-meta-value">{{ location.name }}</div>
|
||
</div>
|
||
<div class="case-meta-cell">
|
||
<div class="hero-meta-label">Overordnet</div>
|
||
{% if location.parent_location_id and location.parent_location_name %}
|
||
<a href="/app/locations/{{ location.parent_location_id }}" class="hero-meta-value text-decoration-none">
|
||
{{ location.parent_location_name }}
|
||
</a>
|
||
{% else %}
|
||
<span class="hero-meta-value text-muted">Ingen</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="case-meta-cell">
|
||
<div class="hero-meta-label">Kontakter</div>
|
||
<div class="hero-meta-value">{{ location.contacts|length if location.contacts else 0 }}</div>
|
||
</div>
|
||
<div class="case-meta-cell">
|
||
<div class="hero-meta-label">Tjenester</div>
|
||
<div class="hero-meta-value">{{ location.services|length if location.services else 0 }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabs Navigation -->
|
||
<ul class="nav nav-tabs mb-4" id="locationTabs" role="tablist">
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link active" id="infoTab" data-bs-toggle="tab" data-bs-target="#infoContent" type="button" role="tab" aria-controls="infoContent" aria-selected="true">
|
||
<i class="bi bi-info-circle me-2"></i>Oplysninger
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="contactsTab" data-bs-toggle="tab" data-bs-target="#contactsContent" type="button" role="tab" aria-controls="contactsContent" aria-selected="false">
|
||
<i class="bi bi-people me-2"></i>Kontakter
|
||
<span class="location-tab-count-badge ms-1">{{ location.contacts|length if location.contacts else 0 }}</span>
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="hoursTab" data-bs-toggle="tab" data-bs-target="#hoursContent" type="button" role="tab" aria-controls="hoursContent" aria-selected="false">
|
||
<i class="bi bi-clock me-2"></i>Åbningstider
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="servicesTab" data-bs-toggle="tab" data-bs-target="#servicesContent" type="button" role="tab" aria-controls="servicesContent" aria-selected="false">
|
||
<i class="bi bi-tools me-2"></i>Tjenester
|
||
<span class="location-tab-count-badge ms-1">{{ location.services|length if location.services else 0 }}</span>
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="capacityTab" data-bs-toggle="tab" data-bs-target="#capacityContent" type="button" role="tab" aria-controls="capacityContent" aria-selected="false">
|
||
<i class="bi bi-graph-up me-2"></i>Kapacitet
|
||
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
|
||
</button>
|
||
</li>
|
||
{% if location.location_type == 'rum' and location.has_cross_field %}
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="crossFieldTab" data-bs-toggle="tab" data-bs-target="#crossFieldContent" type="button" role="tab" aria-controls="crossFieldContent" aria-selected="false">
|
||
<i class="bi bi-diagram-3 me-2"></i>Krydsfelt
|
||
</button>
|
||
</li>
|
||
{% endif %}
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="relationsTab" data-bs-toggle="tab" data-bs-target="#relationsContent" type="button" role="tab" aria-controls="relationsContent" aria-selected="false">
|
||
<i class="bi bi-diagram-3 me-2"></i>Relationer
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="hardwareTab" data-bs-toggle="tab" data-bs-target="#hardwareContent" type="button" role="tab" aria-controls="hardwareContent" aria-selected="false">
|
||
<i class="bi bi-hdd-stack me-2"></i>Hardware på lokation
|
||
<span class="location-tab-count-badge ms-1">{{ location.hardware|length if location.hardware else 0 }}</span>
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="historyTab" data-bs-toggle="tab" data-bs-target="#historyContent" type="button" role="tab" aria-controls="historyContent" aria-selected="false">
|
||
<i class="bi bi-clock-history me-2"></i>Historik
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
|
||
<!-- Tab Content -->
|
||
<div class="tab-content">
|
||
<!-- Tab 1: Information -->
|
||
<div class="tab-pane fade show active" id="infoContent" role="tabpanel" aria-labelledby="infoTab">
|
||
<div class="row">
|
||
<div class="col-lg-6">
|
||
<div class="card border-0 mb-4">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<h5 class="card-title mb-0">Grundlæggende oplysninger</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="mb-3">
|
||
<label class="form-label text-muted small">Navn</label>
|
||
<p class="fw-500">{{ location.name }}</p>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label text-muted small">Type</label>
|
||
<p class="fw-500">{{ type_label }}</p>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label text-muted small">Kunde</label>
|
||
<p class="fw-500">
|
||
{% if location.customer_id and location.customer_name %}
|
||
<a href="/customers/{{ location.customer_id }}" class="text-decoration-none">{{ location.customer_name }}</a>
|
||
{% else %}
|
||
<span class="text-muted">—</span>
|
||
{% endif %}
|
||
</p>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label text-muted small">Status</label>
|
||
<p class="fw-500">{% if location.is_active %}Aktiv{% else %}Inaktiv{% endif %}</p>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label text-muted small">Telefon</label>
|
||
<p class="fw-500">
|
||
{% if location.phone %}
|
||
<a href="tel:{{ location.phone }}" class="text-decoration-none">{{ location.phone }}</a>
|
||
{% else %}
|
||
<span class="text-muted">—</span>
|
||
{% endif %}
|
||
</p>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label text-muted small">Email</label>
|
||
<p class="fw-500">
|
||
{% if location.email %}
|
||
<a href="mailto:{{ location.email }}" class="text-decoration-none">{{ location.email }}</a>
|
||
{% else %}
|
||
|
||
<span class="text-muted">—</span>
|
||
{% endif %}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-lg-6">
|
||
<div class="card border-0 mb-4">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<h5 class="card-title mb-0">Adresse</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="mb-3">
|
||
<label class="form-label text-muted small">Vej</label>
|
||
<p class="fw-500">{{ location.address_street | default('—') }}</p>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label text-muted small">By</label>
|
||
<p class="fw-500">{{ location.address_city | default('—') }}</p>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label text-muted small">Postnummer</label>
|
||
<p class="fw-500">{{ location.address_postal_code | default('—') }}</p>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label text-muted small">Land</label>
|
||
<p class="fw-500">{{ location.address_country | default('DK') }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card border-0 mb-4">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<h5 class="card-title mb-0">Noter</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="mb-0">{{ location.notes | default('<span class="text-muted">—</span>') }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card border-0">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<h5 class="card-title mb-0">Metadata</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<label class="form-label text-muted small">Oprettet</label>
|
||
<p class="fw-500 small">{{ location.created_at | default('—') }}</p>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label text-muted small">Sidst opdateret</label>
|
||
<p class="fw-500 small">{{ location.updated_at | default('—') }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card border-0">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<h5 class="card-title mb-0">Hierarki (træ)</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if location.hierarchy or location.children %}
|
||
<ul class="list-unstyled mb-0">
|
||
{% for node in location.hierarchy %}
|
||
<li class="mb-1">
|
||
<i class="bi bi-diagram-3 me-2 text-muted"></i>
|
||
<a href="/app/locations/{{ node.id }}" class="text-decoration-none">
|
||
{{ node.name }}{% if node.location_type %} ({{ node.location_type }}){% endif %}
|
||
</a>
|
||
</li>
|
||
{% endfor %}
|
||
<li class="mb-1 fw-600">
|
||
<i class="bi bi-pin-map me-2"></i>{{ location.name }}
|
||
</li>
|
||
{% if location.children %}
|
||
<li>
|
||
<ul class="list-unstyled ms-4 mt-2">
|
||
{% for child in location.children %}
|
||
<li class="mb-1">
|
||
<i class="bi bi-arrow-return-right me-2 text-muted"></i>
|
||
<a href="/app/locations/{{ child.id }}" class="text-decoration-none">
|
||
{{ child.name }}{% if child.location_type %} ({{ child.location_type }}){% endif %}
|
||
</a>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</li>
|
||
{% endif %}
|
||
</ul>
|
||
{% else %}
|
||
<span class="text-muted">Ingen relationer registreret</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 2: Contacts -->
|
||
<div class="tab-pane fade" id="contactsContent" role="tabpanel" aria-labelledby="contactsTab">
|
||
<div class="card border-0">
|
||
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center">
|
||
<h5 class="card-title mb-0">Kontaktpersoner</h5>
|
||
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addContactModal">
|
||
<i class="bi bi-plus-lg me-2"></i>Tilføj kontakt
|
||
</button>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if location.contacts %}
|
||
<div class="list-group">
|
||
{% for contact in location.contacts %}
|
||
<div class="list-group-item">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div class="flex-grow-1">
|
||
<h6 class="fw-600 mb-1">
|
||
{% if contact.related_contact_id %}
|
||
<a href="/contacts/{{ contact.related_contact_id }}" class="text-decoration-none">{{ contact.contact_name }}</a>
|
||
{% else %}
|
||
{{ contact.contact_name }}
|
||
{% endif %}
|
||
</h6>
|
||
<p class="small text-muted mb-2">
|
||
{% if contact.role %}{{ contact.role }}{% endif %}
|
||
{% if contact.is_primary %}<span class="badge bg-info ms-2">Primær</span>{% endif %}
|
||
</p>
|
||
{% if contact.contact_email %}
|
||
<p class="small mb-1"><a href="mailto:{{ contact.contact_email }}" class="text-decoration-none">{{ contact.contact_email }}</a></p>
|
||
{% endif %}
|
||
{% if contact.contact_phone %}
|
||
<p class="small mb-0"><a href="tel:{{ contact.contact_phone }}" class="text-decoration-none">{{ contact.contact_phone }}</a></p>
|
||
{% endif %}
|
||
</div>
|
||
<div class="btn-group btn-group-sm ms-3">
|
||
<button type="button" class="btn btn-outline-secondary edit-contact-btn" data-contact-id="{{ contact.id }}">
|
||
<i class="bi bi-pencil"></i>
|
||
</button>
|
||
<button type="button" class="btn btn-outline-danger delete-contact-btn" data-contact-id="{{ contact.id }}" data-contact-name="{{ contact.contact_name }}">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<p class="text-muted text-center py-4">Ingen kontakter registreret</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 3: Operating Hours -->
|
||
<div class="tab-pane fade" id="hoursContent" role="tabpanel" aria-labelledby="hoursTab">
|
||
<div class="card border-0">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<h5 class="card-title mb-0">Åbningstider</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if location.operating_hours %}
|
||
<div class="table-responsive">
|
||
<table class="table table-sm mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>Dag</th>
|
||
<th>Åbner</th>
|
||
<th>Lukker</th>
|
||
<th>Status</th>
|
||
<th style="width: 80px;">Handlinger</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for hours in location.operating_hours %}
|
||
<tr>
|
||
<td class="fw-500">{{ hours.day_name }}</td>
|
||
<td>{{ hours.open_time | default('—') }}</td>
|
||
<td>{{ hours.close_time | default('—') }}</td>
|
||
<td>
|
||
{% if hours.is_open %}
|
||
<span class="badge bg-success">Åben</span>
|
||
{% else %}
|
||
<span class="badge bg-secondary">Lukket</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary edit-hours-btn" data-hours-id="{{ hours.id }}">
|
||
<i class="bi bi-pencil"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<p class="text-muted text-center py-4">Ingen åbningstider registreret</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 4: Services -->
|
||
<div class="tab-pane fade" id="servicesContent" role="tabpanel" aria-labelledby="servicesTab">
|
||
<div class="card border-0">
|
||
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center">
|
||
<h5 class="card-title mb-0">Tjenester</h5>
|
||
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addServiceModal">
|
||
<i class="bi bi-plus-lg me-2"></i>Tilføj tjeneste
|
||
</button>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if location.services %}
|
||
<div class="list-group">
|
||
{% for service in location.services %}
|
||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<h6 class="fw-600 mb-0">{{ service.service_name }}</h6>
|
||
</div>
|
||
<div>
|
||
<span class="badge {% if service.is_available %}bg-success{% else %}bg-secondary{% endif %} me-2">
|
||
{% if service.is_available %}Tilgængelig{% else %}Ikke tilgængelig{% endif %}
|
||
</span>
|
||
<button type="button" class="btn btn-sm btn-outline-danger delete-service-btn" data-service-id="{{ service.id }}" data-service-name="{{ service.service_name }}">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<p class="text-muted text-center py-4">Ingen tjenester registreret</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 5: Capacity -->
|
||
<div class="tab-pane fade" id="capacityContent" role="tabpanel" aria-labelledby="capacityTab">
|
||
<div class="card border-0">
|
||
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center">
|
||
<h5 class="card-title mb-0">Kapacitetssporing</h5>
|
||
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addCapacityModal">
|
||
<i class="bi bi-plus-lg me-2"></i>Tilføj kapacitet
|
||
</button>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if location.capacity %}
|
||
<div class="list-group">
|
||
{% for cap in location.capacity %}
|
||
{% set usage_pct = ((cap.used_capacity / cap.total_capacity) * 100) | int %}
|
||
<div class="list-group-item">
|
||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||
<h6 class="fw-600 mb-0">{{ cap.capacity_type }}</h6>
|
||
<span class="small text-muted">{{ cap.used_capacity }} / {{ cap.total_capacity }}</span>
|
||
</div>
|
||
<div class="progress mb-2" style="height: 6px;">
|
||
<div class="progress-bar" role="progressbar" style="width: {{ usage_pct }}%" aria-valuenow="{{ usage_pct }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||
</div>
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<small class="text-muted">{{ usage_pct }}% i brug</small>
|
||
<button type="button" class="btn btn-sm btn-outline-danger delete-capacity-btn" data-capacity-id="{{ cap.id }}" data-capacity-type="{{ cap.capacity_type }}">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<p class="text-muted text-center py-4">Ingen kapacitetsdata registreret</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 6: Relations -->
|
||
<div class="tab-pane fade" id="relationsContent" role="tabpanel" aria-labelledby="relationsTab">
|
||
<div class="row">
|
||
<div class="col-lg-6">
|
||
<div class="card border-0 mb-4">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<h5 class="card-title mb-0">Overordnet lokation</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if location.parent_location_id and location.parent_location_name %}
|
||
<a href="/app/locations/{{ location.parent_location_id }}" class="text-decoration-none">
|
||
{{ location.parent_location_name }}
|
||
</a>
|
||
{% else %}
|
||
<span class="text-muted">Ingen (øverste niveau)</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-6">
|
||
<div class="card border-0 mb-4">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<h5 class="card-title mb-0">Underlokationer</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if location.children %}
|
||
<ul class="list-unstyled mb-0">
|
||
{% for child in location.children %}
|
||
<li class="mb-2">
|
||
<a href="/app/locations/{{ child.id }}" class="text-decoration-none">
|
||
{{ child.name }}{% if child.location_type %} ({{ child.location_type }}){% endif %}
|
||
</a>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
{% else %}
|
||
<span class="text-muted">Ingen underlokationer</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="card border-0">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||
<h5 class="card-title mb-0">Tilføj underlokation</h5>
|
||
<a href="/app/locations/create?parent_location_id={{ location.id }}{% if location.customer_id %}&customer_id={{ location.customer_id }}{% endif %}" class="btn btn-outline-primary btn-sm">
|
||
<i class="bi bi-box-arrow-up-right me-1"></i>Fuld formular
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="text-muted small mb-3">Den hurtige formular opretter direkte under <strong>{{ location.name }}</strong>. Brug fuld formular, hvis du også vil sætte adresse, noter eller GPS med det samme.</p>
|
||
<form action="/api/v1/locations" method="post" class="row g-2">
|
||
<input type="hidden" name="parent_location_id" value="{{ location.id }}">
|
||
<input type="hidden" name="redirect_to" value="/app/locations/{id}">
|
||
<input type="hidden" name="is_active" value="on">
|
||
<div class="col-12">
|
||
<label class="form-label small text-muted" for="childName">Navn *</label>
|
||
<input type="text" class="form-control" id="childName" name="name" required maxlength="255" placeholder="f.eks. Bygning A, 1 sal, Møderum 1">
|
||
</div>
|
||
<div class="col-12">
|
||
<label class="form-label small text-muted" for="childType">Type *</label>
|
||
<select class="form-select" id="childType" name="location_type" required>
|
||
<option value="">Vælg type</option>
|
||
{% if location_types %}
|
||
{% for type_option in location_types %}
|
||
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
||
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
||
<option value="{{ option_value }}">
|
||
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
||
</option>
|
||
{% endfor %}
|
||
{% endif %}
|
||
</select>
|
||
</div>
|
||
<div class="col-12">
|
||
<label class="form-label small text-muted" for="childCustomer">Kunde (valgfri)</label>
|
||
<select class="form-select" id="childCustomer" name="customer_id">
|
||
<option value="">Ingen</option>
|
||
{% if customers %}
|
||
{% for customer in customers %}
|
||
<option value="{{ customer.id }}" {% if location.customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
|
||
{% endfor %}
|
||
{% endif %}
|
||
</select>
|
||
</div>
|
||
<div class="col-12 d-flex justify-content-end">
|
||
<button type="submit" class="btn btn-sm btn-primary">
|
||
<i class="bi bi-plus-lg me-1"></i>Opret
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card border-0 mt-4">
|
||
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center gap-2">
|
||
<div>
|
||
<h5 class="card-title mb-0">Vægstik</h5>
|
||
<div class="small text-muted">Netværksstik registreret direkte på denne lokation.</div>
|
||
</div>
|
||
<div class="d-flex gap-2">
|
||
<a class="btn btn-outline-secondary btn-sm" href="/app/locations/outlets"><i class="bi bi-list-ul me-1"></i>Oversigt</a>
|
||
{% if location.location_type in ['customer_site', 'bygning', 'etage', 'rum'] %}
|
||
<button type="button" class="btn btn-primary btn-sm" id="addOutletBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj stik</button>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if location.location_type not in ['customer_site', 'bygning', 'etage', 'rum'] %}
|
||
<span class="text-muted">Vægstik kan oprettes på kundesites, bygninger, etager og rum.</span>
|
||
{% elif location.wall_outlets %}
|
||
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody>
|
||
{% for outlet in location.wall_outlets %}
|
||
<tr><td><strong>{{ outlet.outlet_number or 'Ikke navngivet' }}</strong>{% if outlet.is_wan %} <span class="badge bg-info text-dark">WAN</span>{% endif %}{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number or '' }}" data-customer-id="{{ outlet.customer_id or '' }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-is-wan="{{ 'true' if outlet.is_wan else 'false' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
|
||
{% endfor %}
|
||
</tbody></table></div>
|
||
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card border-0 mt-4">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<h5 class="card-title mb-0">Hierarki (træ)</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if location.hierarchy or location.children %}
|
||
<ul class="list-unstyled mb-0">
|
||
{% for node in location.hierarchy %}
|
||
<li class="mb-1">
|
||
<i class="bi bi-diagram-3 me-2 text-muted"></i>
|
||
<a href="/app/locations/{{ node.id }}" class="text-decoration-none">
|
||
{{ node.name }}{% if node.location_type %} ({{ node.location_type }}){% endif %}
|
||
</a>
|
||
</li>
|
||
{% endfor %}
|
||
<li class="mb-1 fw-600">
|
||
<i class="bi bi-pin-map me-2"></i>{{ location.name }}
|
||
</li>
|
||
{% if location.children %}
|
||
<li>
|
||
<ul class="list-unstyled ms-4 mt-2">
|
||
{% for child in location.children %}
|
||
<li class="mb-1">
|
||
<i class="bi bi-arrow-return-right me-2 text-muted"></i>
|
||
<a href="/app/locations/{{ child.id }}" class="text-decoration-none">
|
||
{{ child.name }}{% if child.location_type %} ({{ child.location_type }}){% endif %}
|
||
</a>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</li>
|
||
{% endif %}
|
||
</ul>
|
||
{% else %}
|
||
<span class="text-muted">Ingen relationer registreret</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% if location.location_type == 'rum' and location.has_cross_field %}
|
||
<div class="tab-pane fade" id="crossFieldContent" role="tabpanel" aria-labelledby="crossFieldTab">
|
||
<div class="card border-0">
|
||
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center">
|
||
<div><h5 class="card-title mb-0">Krydsfelt</h5><div class="small text-muted">Patchfelter og porte i dette rum.</div></div>
|
||
<div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm" id="addCrossFieldHardwareBtn"><i class="bi bi-hdd-network me-1"></i>Tilføj switch</button><button type="button" class="btn btn-primary btn-sm" id="addCrossFieldBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj krydsfelt</button></div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4 p-3 rounded border bg-light">
|
||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||
<strong class="small">Vis porte:</strong>
|
||
<div class="btn-group btn-group-sm" role="group" aria-label="Filtrer porte">
|
||
<button type="button" class="btn btn-primary smart-port-filter active" data-filter="all">Alle</button>
|
||
<button type="button" class="btn btn-outline-warning smart-port-filter" data-filter="issue">Kun fejl <span class="badge text-bg-warning ms-1" id="smartIssueCount">0</span></button>
|
||
<button type="button" class="btn btn-outline-info smart-port-filter" data-filter="wan">WAN</button>
|
||
<button type="button" class="btn btn-outline-secondary smart-port-filter" data-filter="free">Ledige</button>
|
||
</div>
|
||
<span class="small text-muted">Grøn = aktiv · Gul = fejl · Turkis = WAN · Lilla = hardware · Blågrå = ikke konfigureret</span>
|
||
</div>
|
||
<button type="button" class="btn btn-sm btn-primary" id="openBulkPatchBtn"><i class="bi bi-diagram-3 me-1"></i>Massepatch</button>
|
||
</div>
|
||
{% for field in location.cross_fields %}
|
||
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}</span></div><div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm edit-port-labels-btn" data-id="{{ field.id }}" data-name="{{ field.name }}"><i class="bi bi-list-ol"></i> Portnumre</button><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-label-format="{{ field.port_label_format }}" data-start-number="{{ field.start_port_number }}" data-row-size="{{ field.panel_row_size }}" data-display-order="{{ field.display_order }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div></div>
|
||
<div class="patch-panel"><div class="patch-panel-grid" style="grid-template-columns: repeat({{ field.panel_row_size or 24 }}, minmax(34px, 1fr));">{% for port in field.ports %}{% set port_class = 'wan' if port.is_wan else ('smart-issue' if port.smart_state == 'issue' else ('assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else 'unconfigured'))) %}<button type="button" class="patch-port smart-port {{ port_class }}" data-smart-state="{{ port.smart_state }}" data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}" data-outlet-id="{{ port.outlet_id or '' }}" data-outlet-number="{{ port.outlet_number or '' }}" data-outlet-customer-id="{{ port.customer_id or '' }}" data-outlet-category="{{ port.category or '' }}" data-outlet-panel="{{ port.patch_panel or '' }}" data-outlet-patch-port="{{ port.patch_port or '' }}" data-outlet-switch="{{ port.switch_name or '' }}" data-outlet-switch-port="{{ port.switch_port or '' }}" data-outlet-is-wan="{{ 'true' if port.is_wan else 'false' }}" data-outlet-status="{{ port.outlet_status or '' }}" data-outlet-notes="{{ port.outlet_notes or '' }}" title="{% if port.outlet_id %}{{ port.outlet_location_name }} → {{ port.outlet_number or 'vægstik' }} → {{ field.name }} port {{ port.port_number }}{% if port.switch_name %} → {{ port.switch_name }} port {{ port.switch_port }}{% endif %}{% if port.smart_state == 'issue' %} · FEJL: registreret, men intet fysisk link{% endif %}{% else %}Ikke konfigureret — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.is_wan %}<span class="patch-port-outlet">WAN{% if port.outlet_number %} · {{ port.outlet_number }}{% endif %}</span>{% elif port.smart_state == 'issue' %}<span class="patch-port-outlet">⚠ INTET LINK</span>{% elif port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% else %}<span class="patch-port-outlet">IKKE KONFIG.</span>{% endif %}</button>{% endfor %}</div></div></div>
|
||
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Tab 7: Hardware -->
|
||
<div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab">
|
||
<div class="card border-0">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<h5 class="card-title mb-0">Hardware på lokation</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if location.hardware %}
|
||
<div class="list-group">
|
||
{% for hw in location.hardware %}
|
||
<div class="list-group-item">
|
||
<div class="d-flex justify-content-between align-items-center gap-3">
|
||
<div><div class="fw-600"><a href="/hardware/{{ hw.id }}" class="text-decoration-none">{{ hw.brand }} {{ hw.model }}</a></div><div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div></div>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<span class="badge bg-primary text-nowrap">Nr. {{ hw.location_display_order or loop.index }}</span>
|
||
<div class="input-group input-group-sm" style="width: 225px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}" aria-label="Rækkefølge for {{ hw.brand }} {{ hw.model }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div>
|
||
<span class="badge bg-secondary">{{ hw.status }}</span>
|
||
</div>
|
||
</div>
|
||
{% if hw.uisp_device %}
|
||
{% set uisp = hw.uisp_device %}
|
||
{% set overview = uisp.overview or {} %}
|
||
<div class="mt-3 p-3 rounded border bg-light">
|
||
<div class="d-flex justify-content-between align-items-center mb-2"><div class="small fw-semibold text-primary"><i class="bi bi-broadcast-pin me-1"></i>UISP live-data</div>{% if uisp.device_link %}<a href="{{ uisp.device_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn i UISP</a>{% endif %}</div>
|
||
<div class="row g-2 small">
|
||
<div class="col-sm-3"><span class="text-muted d-block">Hostnavn</span><strong>{{ uisp.hostname or uisp.display_name or uisp.name or '—' }}</strong></div>
|
||
<div class="col-sm-3"><span class="text-muted d-block">Status</span><strong>{{ uisp.status or 'Ukendt' }}</strong></div>
|
||
<div class="col-sm-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp.ip_addresses or []) | join(', ') or '—' }}</strong></div>
|
||
<div class="col-sm-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp.mac_address or '—' }}</strong></div>
|
||
<div class="col-sm-3"><span class="text-muted d-block">Senest set</span><strong>{{ uisp.last_seen or '—' }}</strong></div>
|
||
<div class="col-sm-3"><span class="text-muted d-block">Firmware</span><strong>{{ uisp.firmware.version or uisp.firmware.name or '—' }}</strong></div>
|
||
<div class="col-sm-3"><span class="text-muted d-block">Platform / rolle</span><strong>{{ uisp.platform or '—' }}{% if uisp.device_role %} · {{ uisp.device_role }}{% endif %}</strong></div>
|
||
<div class="col-sm-2"><span class="text-muted d-block">Uptime</span><strong>{{ overview.uptime or overview.serviceUptime or '—' }}</strong></div>
|
||
<div class="col-sm-2"><span class="text-muted d-block">CPU / RAM</span><strong>{{ overview.cpu or '—' }} / {{ overview.ram or '—' }}</strong></div>
|
||
<div class="col-sm-2"><span class="text-muted d-block">Temperatur</span><strong>{{ overview.temperature or '—' }}</strong></div>
|
||
<div class="col-sm-2"><span class="text-muted d-block">Synkroniseret</span><strong>{{ uisp.synced_at or '—' }}</strong></div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
{% if hw.switch_ports %}
|
||
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}
|
||
{% if port.hardware_link %}
|
||
<a href="/hardware/{{ hw.id }}" class="patch-port smart-port text-decoration-none hardware-linked{% if port.live %} {{ 'border border-success' if port.live.plugged else 'border border-danger' }}{% endif %}" data-smart-state="hardware" title="Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %} — klik for hardwaredetaljer">
|
||
{% else %}
|
||
{% set switch_state = 'wan' if port.outlet and port.outlet.is_wan else ('issue' if (port.outlet and port.live and not port.live.plugged) or (not port.outlet and port.live and port.live.plugged) else ('assigned' if port.outlet else 'free')) %}
|
||
<button type="button" class="patch-port smart-port switch-port-action{% if switch_state == 'wan' %} wan{% elif switch_state == 'issue' %} smart-issue{% elif port.outlet %} assigned{% else %} unconfigured{% endif %}{% if port.live %} {{ 'border border-success' if port.live.plugged else 'border border-danger' }}{% endif %}" data-smart-state="{{ switch_state }}" data-switch-name="{{ hw.uisp_device.hostname if hw.uisp_device and hw.uisp_device.hostname else ((hw.brand ~ ' · ' if hw.brand else '') ~ (hw.model or '') ~ (' · ' ~ hw.serial_number if hw.serial_number else '')) }}" data-switch-port="{{ port.port_number }}" data-outlet-id="{{ port.outlet.id if port.outlet else '' }}" data-outlet-number="{{ port.outlet.outlet_number if port.outlet else '' }}" data-outlet-customer-id="{{ port.outlet.customer_id if port.outlet else '' }}" data-outlet-category="{{ port.outlet.category if port.outlet else '' }}" data-outlet-panel="{{ port.outlet.patch_panel if port.outlet else '' }}" data-outlet-patch-port="{{ port.outlet.patch_port if port.outlet else '' }}" data-outlet-cross-field-port-id="{{ port.outlet.cross_field_port_id if port.outlet else '' }}" data-outlet-is-wan="{{ 'true' if port.outlet and port.outlet.is_wan else 'false' }}" data-outlet-status="{{ port.outlet.status if port.outlet else '' }}" data-outlet-notes="{{ port.outlet.notes if port.outlet else '' }}" title="{% if port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% if port.outlet.is_wan %} · WAN{% endif %}{% if switch_state == 'issue' %} · FEJL: registreret, men intet fysisk link{% endif %} — klik for at redigere{% elif switch_state == 'issue' %}UISP ser fysisk link, men porten er ikke registreret{% else %}Ikke konfigureret — klik for at tilknytte vægstik{% endif %}">
|
||
{% endif %}
|
||
<span>{{ port.port_number }}</span>
|
||
{% if port.live %}<span class="patch-port-outlet">{{ 'LIVE' if port.live.plugged else 'INTET LINK' }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}</span>
|
||
{% elif port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>
|
||
{% elif port.outlet and port.outlet.is_wan %}<span class="patch-port-outlet">WAN{% if port.outlet.outlet_number %} · {{ port.outlet.outlet_number }}{% endif %}</span>
|
||
{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>
|
||
{% else %}<span class="patch-port-outlet">IKKE KONFIG.</span>{% endif %}
|
||
{% if port.hardware_link %}</a>{% else %}</button>{% endif %}
|
||
{% endfor %}</div></div><div class="form-text mt-2">Klik på en switch-port for at oprette eller redigere tilknytningen. Grøn/rød kant viser live linkstatus fra UISP.</div></details>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<span class="text-muted">Ingen hardware registreret på denne lokation</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 8: History -->
|
||
<div class="tab-pane fade" id="historyContent" role="tabpanel" aria-labelledby="historyTab">
|
||
<div class="card border-0">
|
||
<div class="card-header bg-transparent border-bottom">
|
||
<h5 class="card-title mb-0">Ændringshistorik</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if location.audit_log %}
|
||
<div class="timeline">
|
||
{% for entry in location.audit_log | reverse %}
|
||
<div class="timeline-item mb-3 pb-3 border-bottom">
|
||
<div class="d-flex gap-3">
|
||
<div class="timeline-marker">
|
||
<i class="bi bi-circle-fill small" style="color: var(--accent);"></i>
|
||
</div>
|
||
<div class="flex-grow-1">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<h6 class="fw-600 mb-1">{{ entry.event_type }}</h6>
|
||
<p class="small text-muted mb-1">{{ entry.created_at }}</p>
|
||
</div>
|
||
{% if entry.user_id %}
|
||
<span class="badge bg-light text-dark small">{{ entry.user_id }}</span>
|
||
{% endif %}
|
||
</div>
|
||
{% if entry.changes %}
|
||
<p class="small mb-0"><code style="font-size: 11px;">{{ entry.changes }}</code></p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<p class="text-muted text-center py-4">Ingen historik tilgængelig</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add Contact Modal -->
|
||
<div class="modal fade" id="addContactModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header border-bottom-0">
|
||
<h5 class="modal-title">Tilføj kontaktperson</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||
</div>
|
||
<form id="addContactForm">
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label for="existingContactSearch" class="form-label">Søg eksisterende kontakt</label>
|
||
<input type="text" class="form-control" id="existingContactSearch" placeholder="Skriv navn, email eller telefon...">
|
||
<div class="form-text">Vælg en eksisterende kontakt for at udfylde felterne automatisk.</div>
|
||
<div id="existingContactResults" class="list-group mt-2 d-none" style="max-height: 220px; overflow-y: auto;"></div>
|
||
</div>
|
||
<div id="selectedExistingContact" class="alert alert-info py-2 px-3 d-none" role="alert">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<span id="selectedExistingContactText" class="small mb-0"></span>
|
||
<button type="button" class="btn btn-link btn-sm p-0" id="clearExistingContactBtn">Fjern</button>
|
||
</div>
|
||
</div>
|
||
<input type="hidden" id="existingContactId" value="">
|
||
<div class="mb-3">
|
||
<label class="form-label">Kontaktoplysninger</label>
|
||
<div class="form-control-plaintext small text-muted" id="selectedContactMeta">Vælg en kontakt for at se email og telefon.</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label for="contactRole" class="form-label">Rolle</label>
|
||
<input type="text" class="form-control" id="contactRole">
|
||
</div>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="checkbox" id="isPrimaryContact">
|
||
<label class="form-check-label" for="isPrimaryContact">Sæt som primær kontakt</label>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer border-top-0">
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Annuller</button>
|
||
<button type="submit" class="btn btn-primary btn-sm">Tilføj</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add Service Modal -->
|
||
<div class="modal fade" id="addServiceModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header border-bottom-0">
|
||
<h5 class="modal-title">Tilføj tjeneste</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||
</div>
|
||
<form id="addServiceForm">
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label for="serviceName" class="form-label">Tjeneste *</label>
|
||
<input type="text" class="form-control" id="serviceName" required>
|
||
</div>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="checkbox" id="serviceAvailable" checked>
|
||
<label class="form-check-label" for="serviceAvailable">Tilgængelig</label>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer border-top-0">
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Annuller</button>
|
||
<button type="submit" class="btn btn-primary btn-sm">Tilføj</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add Capacity Modal -->
|
||
<div class="modal fade" id="addCapacityModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header border-bottom-0">
|
||
<h5 class="modal-title">Tilføj kapacitet</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||
</div>
|
||
<form id="addCapacityForm">
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label for="capacityType" class="form-label">Type *</label>
|
||
<input type="text" class="form-control" id="capacityType" required>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label for="totalCapacity" class="form-label">Total kapacitet *</label>
|
||
<input type="number" class="form-control" id="totalCapacity" min="1" required>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label for="usedCapacity" class="form-label">Brugt kapacitet</label>
|
||
<input type="number" class="form-control" id="usedCapacity" min="0">
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer border-top-0">
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Annuller</button>
|
||
<button type="submit" class="btn btn-primary btn-sm">Tilføj</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Wall outlet modal -->
|
||
<div class="modal fade" id="crossFieldModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog"><form class="modal-content" id="crossFieldForm"><div class="modal-header"><h5 class="modal-title" id="crossFieldModalTitle">Tilføj krydsfelt</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||
<div class="modal-body"><div class="mb-3"><label class="form-label">Navn</label><input class="form-control" id="crossFieldName" required maxlength="100" placeholder="Fx XF-1 eller Patchpanel A"></div>
|
||
<input type="hidden" id="crossFieldId">
|
||
<div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="crossFieldPortCount" min="1" max="999" required placeholder="Fx 48"></div>
|
||
<div class="mb-3"><label class="form-label">Portmærkning</label><select class="form-select" id="crossFieldPortLabelFormat"><option value="numeric">1, 2, 3 …</option><option value="paired">1A, 1B, 2A, 2B …</option></select><div class="form-text">A/B-par kræver et lige antal porte, fx 48 porte = 1A–24B.</div></div>
|
||
<div class="mb-3"><label class="form-label">Startnummer</label><input class="form-control" type="number" id="crossFieldStartPortNumber" min="1" max="9999" value="1"><div class="form-text">Næste panel kan fx starte ved 25, så det bliver 25A, 25B …</div></div>
|
||
<div class="mb-3"><label class="form-label">Porte pr. række</label><input class="form-control" type="number" id="crossFieldPanelRowSize" min="1" max="48" value="24"><div class="form-text">Sæt fx 24 for samme brede panelopstilling som på billedet.</div></div>
|
||
<div class="mb-3"><label class="form-label">Visningsrækkefølge</label><input class="form-control" type="number" id="crossFieldDisplayOrder" min="1" max="9999" value="1"><div class="form-text">Laveste nummer vises øverst. Ændr fx et panel til 1 og et andet til 2.</div></div>
|
||
<div><label class="form-label">Note</label><textarea class="form-control" id="crossFieldNotes"></textarea></div></div>
|
||
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Opret porte</button></div></form></div>
|
||
</div>
|
||
|
||
<div class="modal fade" id="crossFieldPortLabelsModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-lg modal-dialog-scrollable"><form class="modal-content" id="crossFieldPortLabelsForm">
|
||
<div class="modal-header"><h5 class="modal-title">Rediger portnumre: <span id="crossFieldPortLabelsName"></span></h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||
<div class="modal-body"><p class="small text-muted">Ændr de fysiske mærkninger frit, fx <code>1A</code>, <code>Kontor-12</code> eller <code>Rack2-07</code>. Forbindelser bevares på den samme port.</p><div id="crossFieldPortLabelsList" class="row g-2"></div></div>
|
||
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Gem portnumre</button></div>
|
||
</form></div>
|
||
</div>
|
||
|
||
<div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div>
|
||
|
||
<div class="modal fade" id="bulkPatchModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-lg"><form class="modal-content" id="bulkPatchForm">
|
||
<div class="modal-header"><h5 class="modal-title">Massepatch porte</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||
<div class="modal-body">
|
||
<div class="alert alert-warning small"><strong>Kontroller før du gemmer.</strong> Funktionen forbinder et sammenhængende område af krydsfelt-porte med samme antal switch-porte.</div>
|
||
<div class="row g-3">
|
||
<div class="col-md-6"><label class="form-label">Krydsfelt</label><select class="form-select" id="bulkCrossField" required></select></div>
|
||
<div class="col-md-3"><label class="form-label">Fra portposition</label><input class="form-control" id="bulkFromPort" type="number" min="1" value="1" required></div>
|
||
<div class="col-md-3"><label class="form-label">Til portposition</label><input class="form-control" id="bulkToPort" type="number" min="1" value="24" required></div>
|
||
<div class="col-md-6"><label class="form-label">Switch (UISP-hostnavn)</label><select class="form-select" id="bulkSwitch" required></select></div>
|
||
<div class="col-md-3"><label class="form-label">Start switch-port</label><input class="form-control" id="bulkSwitchStart" type="number" min="1" value="1" required></div>
|
||
<div class="col-md-3 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" id="bulkIsWan" type="checkbox"><label class="form-check-label fw-semibold" for="bulkIsWan">WAN</label></div></div>
|
||
<div class="col-12"><label class="form-label">Firma</label><input class="form-control mb-2" id="bulkCustomerSearch" type="search" placeholder="Søg firma…"><select class="form-select" id="bulkCustomer"><option value="">Ingen specifik kunde</option></select></div>
|
||
<div class="col-12"><div class="p-3 rounded border bg-light small" id="bulkPatchPreview">Vælg område og switch for at se en forhåndsvisning.</div></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button type="submit" class="btn btn-primary" id="bulkPatchSubmit">Kontrollér og gem</button></div>
|
||
</form></div>
|
||
</div>
|
||
|
||
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="outletModalTitle">Tilføj vægstik</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||
<form id="outletForm"><div class="modal-body">
|
||
<input type="hidden" id="outletId"><div class="row g-3">
|
||
<div class="col-12"><div class="alert alert-primary py-2 mb-0 small" id="outletConnectionPath"><strong>Kabelsti:</strong> endnu ikke forbundet</div></div>
|
||
<div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div>
|
||
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer</label><input class="form-control" id="outletNumber" placeholder="Valgfrit, fx A-12 eller 1.23.04"></div>
|
||
<div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div>
|
||
<div class="col-12"><label class="form-label" for="outletCustomerSearch">Søg firma</label><input class="form-control mb-2" id="outletCustomerSearch" type="search" placeholder="Skriv firmanavn…"><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Søg og vælg firmaet, som porten er tildelt.</div></div>
|
||
<div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div>
|
||
<div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div>
|
||
<div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div>
|
||
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" list="outletSwitchOptions" placeholder="Vælg registreret switch eller skriv navn"><datalist id="outletSwitchOptions"></datalist></div>
|
||
<div class="col-md-6"><label class="form-label">Switch-port</label><select class="form-select" id="outletSwitchPort"><option value="">Vælg port</option></select><div class="form-text" id="outletSwitchPortHelp">Vælg først en switch.</div></div>
|
||
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div>
|
||
<div class="col-md-6 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" role="switch" id="outletIsWan"><label class="form-check-label fw-semibold" for="outletIsWan">WAN-forbindelse</label><div class="form-text">WAN-porte vises med turkis farve.</div></div></div>
|
||
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
|
||
</div>
|
||
</div><div class="modal-footer"><button type="button" class="btn btn-outline-danger me-auto d-none" id="deleteOutletBtn">Slet stik</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div></form>
|
||
</div></div>
|
||
</div>
|
||
|
||
<!-- Delete Confirmation Modal -->
|
||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-dialog-centered">
|
||
<div class="modal-content">
|
||
<div class="modal-header border-bottom-0">
|
||
<h5 class="modal-title">Slet lokation?</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="mb-0">Er du sikker på, at du vil slette <strong>{{ location.name }}</strong>?</p>
|
||
<p class="text-muted small mt-2">Denne handling kan ikke fortrydes. Lokationen vil blive soft-deleted og kan gendannes af en administrator.</p>
|
||
</div>
|
||
<div class="modal-footer border-top-0">
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Annuller</button>
|
||
<button type="button" class="btn btn-danger btn-sm" id="confirmDeleteBtn">
|
||
<i class="bi bi-trash me-2"></i>Slet
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
if (new URLSearchParams(window.location.search).get('tab') === 'cross-field') {
|
||
const crossFieldTab = document.getElementById('crossFieldTab');
|
||
if (crossFieldTab) bootstrap.Tab.getOrCreateInstance(crossFieldTab).show();
|
||
}
|
||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||
const locationId = '{{ location.id }}';
|
||
const locationHardware = {{ location.hardware | tojson }};
|
||
const locationWallOutlets = {{ location.wall_outlets | tojson }};
|
||
const locationCrossFields = {{ location.cross_fields | tojson }};
|
||
const existingContactSearchInput = document.getElementById('existingContactSearch');
|
||
const existingContactResultsContainer = document.getElementById('existingContactResults');
|
||
const existingContactIdInput = document.getElementById('existingContactId');
|
||
const selectedExistingContactAlert = document.getElementById('selectedExistingContact');
|
||
const selectedExistingContactText = document.getElementById('selectedExistingContactText');
|
||
const selectedContactMeta = document.getElementById('selectedContactMeta');
|
||
const clearExistingContactBtn = document.getElementById('clearExistingContactBtn');
|
||
const contactRoleInput = document.getElementById('contactRole');
|
||
const addContactModalElement = document.getElementById('addContactModal');
|
||
const addContactForm = document.getElementById('addContactForm');
|
||
const addContactSubmitBtn = addContactForm?.querySelector('button[type="submit"]');
|
||
let existingContactResults = [];
|
||
let contactSearchDebounceTimer = null;
|
||
let isSavingContact = false;
|
||
|
||
function clearExistingContactSelection() {
|
||
existingContactIdInput.value = '';
|
||
selectedExistingContactText.textContent = '';
|
||
selectedExistingContactAlert.classList.add('d-none');
|
||
selectedContactMeta.textContent = 'Vælg en kontakt for at se email og telefon.';
|
||
}
|
||
|
||
function hideExistingContactResults() {
|
||
existingContactResults = [];
|
||
existingContactResultsContainer.innerHTML = '';
|
||
existingContactResultsContainer.classList.add('d-none');
|
||
}
|
||
|
||
function buildFullName(contact) {
|
||
const firstName = (contact.first_name || '').trim();
|
||
const lastName = (contact.last_name || '').trim();
|
||
return `${firstName} ${lastName}`.trim();
|
||
}
|
||
|
||
function selectExistingContact(contact) {
|
||
const fullName = buildFullName(contact);
|
||
const email = contact.email || '';
|
||
const phone = contact.mobile || contact.phone || '';
|
||
|
||
existingContactIdInput.value = String(contact.id || '');
|
||
selectedExistingContactText.textContent = `Valgt: ${fullName || 'Kontakt'} (ID: ${contact.id})`;
|
||
selectedExistingContactAlert.classList.remove('d-none');
|
||
selectedContactMeta.textContent = `${email || 'Ingen email'} • ${phone || 'Ingen telefon'}`;
|
||
|
||
hideExistingContactResults();
|
||
existingContactSearchInput.value = fullName;
|
||
}
|
||
|
||
function renderExistingContactResults(results) {
|
||
if (!results || !results.length) {
|
||
hideExistingContactResults();
|
||
return;
|
||
}
|
||
|
||
existingContactResults = results;
|
||
existingContactResultsContainer.innerHTML = results.map((contact, index) => {
|
||
const fullName = buildFullName(contact) || `Kontakt #${contact.id}`;
|
||
const secondaryInfo = [contact.email, contact.mobile || contact.phone, contact.user_company]
|
||
.filter(Boolean)
|
||
.join(' • ');
|
||
return `
|
||
<button type="button" class="list-group-item list-group-item-action existing-contact-result" data-index="${index}">
|
||
<div class="fw-500">${fullName}</div>
|
||
<div class="small text-muted">${secondaryInfo || 'Ingen ekstra info'}</div>
|
||
</button>
|
||
`;
|
||
}).join('');
|
||
existingContactResultsContainer.classList.remove('d-none');
|
||
}
|
||
|
||
async function searchExistingContacts(term) {
|
||
if (!term || term.trim().length < 2) {
|
||
hideExistingContactResults();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(term.trim())}`);
|
||
if (!response.ok) {
|
||
hideExistingContactResults();
|
||
return;
|
||
}
|
||
|
||
const contacts = await response.json();
|
||
renderExistingContactResults(contacts || []);
|
||
} catch (error) {
|
||
console.error('Error searching contacts:', error);
|
||
hideExistingContactResults();
|
||
}
|
||
}
|
||
|
||
existingContactSearchInput.addEventListener('input', function(e) {
|
||
const term = e.target.value;
|
||
if (contactSearchDebounceTimer) {
|
||
clearTimeout(contactSearchDebounceTimer);
|
||
}
|
||
contactSearchDebounceTimer = setTimeout(() => {
|
||
searchExistingContacts(term);
|
||
}, 220);
|
||
});
|
||
|
||
existingContactResultsContainer.addEventListener('click', function(e) {
|
||
const button = e.target.closest('.existing-contact-result');
|
||
if (!button) return;
|
||
|
||
const index = Number(button.dataset.index);
|
||
const selectedContact = existingContactResults[index];
|
||
if (selectedContact) {
|
||
selectExistingContact(selectedContact);
|
||
}
|
||
});
|
||
|
||
clearExistingContactBtn.addEventListener('click', function() {
|
||
clearExistingContactSelection();
|
||
existingContactSearchInput.value = '';
|
||
hideExistingContactResults();
|
||
});
|
||
|
||
addContactModalElement.addEventListener('hidden.bs.modal', function() {
|
||
hideExistingContactResults();
|
||
clearExistingContactSelection();
|
||
existingContactSearchInput.value = '';
|
||
});
|
||
|
||
// Delete location
|
||
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
||
fetch(`/api/v1/locations/${locationId}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
})
|
||
.then(response => {
|
||
if (response.ok) {
|
||
deleteModal.hide();
|
||
setTimeout(() => window.location.href = '/app/locations', 300);
|
||
} else {
|
||
alert('Fejl ved sletning af lokation');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('Fejl ved sletning af lokation');
|
||
});
|
||
});
|
||
|
||
// Add contact form
|
||
addContactForm.addEventListener('submit', function(e) {
|
||
e.preventDefault();
|
||
if (isSavingContact) {
|
||
return;
|
||
}
|
||
if (!existingContactIdInput.value) {
|
||
alert('Vælg en eksisterende kontakt fra søgningen før du gemmer.');
|
||
return;
|
||
}
|
||
|
||
isSavingContact = true;
|
||
if (addContactSubmitBtn) {
|
||
addContactSubmitBtn.disabled = true;
|
||
addContactSubmitBtn.textContent = 'Gemmer...';
|
||
}
|
||
|
||
const contactData = {
|
||
location_id: locationId,
|
||
role: contactRoleInput.value,
|
||
existing_contact_id: existingContactIdInput.value ? parseInt(existingContactIdInput.value, 10) : null,
|
||
is_primary: document.getElementById('isPrimaryContact').checked
|
||
};
|
||
fetch(`/api/v1/locations/${locationId}/contacts`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(contactData)
|
||
})
|
||
.then(async response => {
|
||
if (response.ok) {
|
||
bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide();
|
||
setTimeout(() => location.reload(), 300);
|
||
return;
|
||
}
|
||
|
||
let detail = 'Fejl ved gem af kontaktlink';
|
||
try {
|
||
const payload = await response.json();
|
||
if (payload && payload.detail) {
|
||
detail = String(payload.detail);
|
||
}
|
||
} catch (e) {
|
||
// ignore parse errors and keep default message
|
||
}
|
||
alert(detail);
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('Netværksfejl ved gem af kontaktlink');
|
||
})
|
||
.finally(() => {
|
||
isSavingContact = false;
|
||
if (addContactSubmitBtn) {
|
||
addContactSubmitBtn.disabled = false;
|
||
addContactSubmitBtn.textContent = 'Tilføj';
|
||
}
|
||
});
|
||
});
|
||
|
||
// Add service form
|
||
document.getElementById('addServiceForm').addEventListener('submit', function(e) {
|
||
e.preventDefault();
|
||
const serviceData = {
|
||
location_id: locationId,
|
||
service_name: document.getElementById('serviceName').value,
|
||
is_available: document.getElementById('serviceAvailable').checked
|
||
};
|
||
fetch(`/api/v1/locations/${locationId}/services`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(serviceData)
|
||
})
|
||
.then(response => {
|
||
if (response.ok) {
|
||
bootstrap.Modal.getInstance(document.getElementById('addServiceModal')).hide();
|
||
setTimeout(() => location.reload(), 300);
|
||
}
|
||
})
|
||
.catch(error => console.error('Error:', error));
|
||
});
|
||
|
||
// Add capacity form
|
||
document.getElementById('addCapacityForm').addEventListener('submit', function(e) {
|
||
e.preventDefault();
|
||
const capacityData = {
|
||
location_id: locationId,
|
||
capacity_type: document.getElementById('capacityType').value,
|
||
total_capacity: parseInt(document.getElementById('totalCapacity').value),
|
||
used_capacity: parseInt(document.getElementById('usedCapacity').value) || 0
|
||
};
|
||
fetch(`/api/v1/locations/${locationId}/capacity`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(capacityData)
|
||
})
|
||
.then(response => {
|
||
if (response.ok) {
|
||
bootstrap.Modal.getInstance(document.getElementById('addCapacityModal')).hide();
|
||
setTimeout(() => location.reload(), 300);
|
||
}
|
||
})
|
||
.catch(error => console.error('Error:', error));
|
||
});
|
||
|
||
// Delete buttons
|
||
document.querySelectorAll('.delete-contact-btn').forEach(btn => {
|
||
btn.addEventListener('click', function() {
|
||
const contactId = this.dataset.contactId;
|
||
const contactName = this.dataset.contactName;
|
||
if (confirm(`Slet kontakt: ${contactName}?`)) {
|
||
fetch(`/api/v1/locations/${locationId}/contacts/${contactId}`, { method: 'DELETE' })
|
||
.then(response => {
|
||
if (response.ok) location.reload();
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.delete-service-btn').forEach(btn => {
|
||
btn.addEventListener('click', function() {
|
||
const serviceId = this.dataset.serviceId;
|
||
const serviceName = this.dataset.serviceName;
|
||
if (confirm(`Slet tjeneste: ${serviceName}?`)) {
|
||
fetch(`/api/v1/locations/${locationId}/services/${serviceId}`, { method: 'DELETE' })
|
||
.then(response => {
|
||
if (response.ok) location.reload();
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.delete-capacity-btn').forEach(btn => {
|
||
btn.addEventListener('click', function() {
|
||
const capacityId = this.dataset.capacityId;
|
||
const capacityType = this.dataset.capacityType;
|
||
if (confirm(`Slet kapacitet: ${capacityType}?`)) {
|
||
fetch(`/api/v1/locations/${locationId}/capacity/${capacityId}`, { method: 'DELETE' })
|
||
.then(response => {
|
||
if (response.ok) location.reload();
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
const crossFieldButton = document.getElementById('addCrossFieldBtn');
|
||
const crossFieldModalElement = document.getElementById('crossFieldModal');
|
||
const crossFieldModal = crossFieldModalElement ? new bootstrap.Modal(crossFieldModalElement) : null;
|
||
if (crossFieldButton) crossFieldButton.addEventListener('click', () => { document.getElementById('crossFieldId').value = ''; document.getElementById('crossFieldForm').reset(); document.getElementById('crossFieldPortLabelFormat').disabled = false; document.getElementById('crossFieldStartPortNumber').disabled = false; document.getElementById('crossFieldStartPortNumber').value = 1; document.getElementById('crossFieldPanelRowSize').value = 24; document.getElementById('crossFieldDisplayOrder').value = 1; document.getElementById('crossFieldModalTitle').textContent = 'Tilføj krydsfelt'; crossFieldModal.show(); });
|
||
document.querySelectorAll('.edit-cross-field-btn').forEach(button => button.addEventListener('click', () => { document.getElementById('crossFieldId').value = button.dataset.id; document.getElementById('crossFieldName').value = button.dataset.name; document.getElementById('crossFieldPortCount').value = button.dataset.portCount; document.getElementById('crossFieldPortLabelFormat').value = button.dataset.labelFormat || 'numeric'; document.getElementById('crossFieldStartPortNumber').value = button.dataset.startNumber || 1; document.getElementById('crossFieldPanelRowSize').value = button.dataset.rowSize || 24; document.getElementById('crossFieldDisplayOrder').value = button.dataset.displayOrder || 1; document.getElementById('crossFieldPortLabelFormat').disabled = true; document.getElementById('crossFieldStartPortNumber').disabled = true; document.getElementById('crossFieldNotes').value = button.dataset.notes; document.getElementById('crossFieldModalTitle').textContent = 'Rediger krydsfelt'; crossFieldModal.show(); }));
|
||
document.getElementById('crossFieldForm')?.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const crossFieldId = document.getElementById('crossFieldId').value;
|
||
const payload = {location_id: locationId, name: document.getElementById('crossFieldName').value, port_count: Number(document.getElementById('crossFieldPortCount').value), panel_row_size: Number(document.getElementById('crossFieldPanelRowSize').value), display_order: Number(document.getElementById('crossFieldDisplayOrder').value), notes: document.getElementById('crossFieldNotes').value || null};
|
||
if (!crossFieldId) { payload.port_label_format = document.getElementById('crossFieldPortLabelFormat').value; payload.start_port_number = Number(document.getElementById('crossFieldStartPortNumber').value); }
|
||
const response = await fetch(crossFieldId ? `/api/v1/locations/cross-fields/${crossFieldId}` : '/api/v1/locations/cross-fields', {method: crossFieldId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
|
||
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Krydsfeltet kunne ikke oprettes'); }
|
||
});
|
||
const crossFieldPortLabelsModalElement = document.getElementById('crossFieldPortLabelsModal');
|
||
const crossFieldPortLabelsModal = crossFieldPortLabelsModalElement ? new bootstrap.Modal(crossFieldPortLabelsModalElement) : null;
|
||
let activeCrossFieldPortLabelsId = null;
|
||
document.querySelectorAll('.edit-port-labels-btn').forEach(button => button.addEventListener('click', async () => {
|
||
try {
|
||
const response = await fetch(`/api/v1/locations/cross-fields?location_id=${locationId}`);
|
||
const fields = await response.json();
|
||
const field = Array.isArray(fields) ? fields.find(item => Number(item.id) === Number(button.dataset.id)) : null;
|
||
if (!response.ok || !field) throw new Error('Krydsfeltet kunne ikke indlæses');
|
||
activeCrossFieldPortLabelsId = field.id;
|
||
document.getElementById('crossFieldPortLabelsName').textContent = field.name;
|
||
const list = document.getElementById('crossFieldPortLabelsList');
|
||
list.innerHTML = '';
|
||
field.ports.forEach(port => {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'col-md-4';
|
||
const label = document.createElement('label');
|
||
label.className = 'form-label small mb-1';
|
||
label.textContent = `Port ${port.port_number}`;
|
||
const input = document.createElement('input');
|
||
input.className = 'form-control form-control-sm cross-field-port-label-input';
|
||
input.maxLength = 20;
|
||
input.required = true;
|
||
input.value = port.port_number;
|
||
input.dataset.portId = port.id;
|
||
wrapper.append(label, input);
|
||
list.appendChild(wrapper);
|
||
});
|
||
crossFieldPortLabelsModal.show();
|
||
} catch (error) {
|
||
alert(error.message || 'Kunne ikke indlæse portnumre');
|
||
}
|
||
}));
|
||
document.getElementById('crossFieldPortLabelsForm')?.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
if (!activeCrossFieldPortLabelsId) return;
|
||
const ports = Array.from(document.querySelectorAll('.cross-field-port-label-input')).map(input => ({id: Number(input.dataset.portId), port_number: input.value.trim()}));
|
||
const response = await fetch(`/api/v1/locations/cross-fields/${activeCrossFieldPortLabelsId}/port-labels`, {method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ports})});
|
||
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Portnumrene kunne ikke gemmes'); }
|
||
});
|
||
const crossFieldHardwareModal = new bootstrap.Modal(document.getElementById('crossFieldHardwareModal'));
|
||
document.getElementById('addCrossFieldHardwareBtn')?.addEventListener('click', () => crossFieldHardwareModal.show());
|
||
document.getElementById('crossFieldHardwareForm')?.addEventListener('submit', async (event) => { event.preventDefault(); const portCount = document.getElementById('switchPortCount').value; const response = await fetch('/api/v1/hardware', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({asset_type:'netværk', brand:outletValue('switchBrand'), model:outletValue('switchModel'), serial_number:outletValue('switchSerial'), current_location_id:locationId, status:'active', hardware_specs: portCount ? {port_count:Number(portCount)} : null})}); if (response.ok) location.reload(); else alert('Switchen kunne ikke oprettes'); });
|
||
|
||
const outletModalElement = document.getElementById('outletModal');
|
||
const outletModal = outletModalElement ? new bootstrap.Modal(outletModalElement) : null;
|
||
const outletForm = document.getElementById('outletForm');
|
||
const outletValue = (id) => document.getElementById(id).value.trim() || null;
|
||
|
||
function switchDisplayName(hardware) {
|
||
const uispHostname = hardware.uisp_device?.hostname;
|
||
if (uispHostname) return uispHostname;
|
||
return [hardware.brand, hardware.model, hardware.serial_number].filter(Boolean).join(' · ') || `Switch #${hardware.id}`;
|
||
}
|
||
|
||
function switchPortCount(hardware) {
|
||
let specs = hardware.hardware_specs || {};
|
||
if (typeof specs === 'string') {
|
||
try { specs = JSON.parse(specs); } catch (_) { specs = {}; }
|
||
}
|
||
const count = Number(specs?.port_count || specs?.ports || 0);
|
||
return Number.isInteger(count) && count > 0 ? count : 0;
|
||
}
|
||
|
||
const smartPorts = Array.from(document.querySelectorAll('.smart-port'));
|
||
document.getElementById('smartIssueCount').textContent = String(smartPorts.filter(port => port.dataset.smartState === 'issue').length);
|
||
document.querySelectorAll('.smart-port-filter').forEach(button => button.addEventListener('click', () => {
|
||
const filter = button.dataset.filter;
|
||
smartPorts.forEach(port => port.classList.toggle('smart-port-hidden', filter !== 'all' && port.dataset.smartState !== filter));
|
||
document.querySelectorAll('.smart-port-filter').forEach(item => {
|
||
item.classList.toggle('active', item === button);
|
||
item.classList.toggle('btn-primary', item === button);
|
||
if (item !== button) item.classList.remove('btn-primary');
|
||
});
|
||
}));
|
||
|
||
const bulkPatchElement = document.getElementById('bulkPatchModal');
|
||
const bulkPatchModal = bulkPatchElement ? new bootstrap.Modal(bulkPatchElement) : null;
|
||
const bulkFieldSelect = document.getElementById('bulkCrossField');
|
||
const bulkSwitchSelect = document.getElementById('bulkSwitch');
|
||
const bulkCustomerSelect = document.getElementById('bulkCustomer');
|
||
|
||
function selectedBulkField() {
|
||
return (locationCrossFields || []).find(field => Number(field.id) === Number(bulkFieldSelect.value));
|
||
}
|
||
|
||
function updateBulkPreview() {
|
||
const field = selectedBulkField();
|
||
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
|
||
const from = Number(document.getElementById('bulkFromPort').value);
|
||
const to = Number(document.getElementById('bulkToPort').value);
|
||
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
|
||
const count = Number.isInteger(from) && Number.isInteger(to) && to >= from ? to - from + 1 : 0;
|
||
const firstPort = field?.ports?.[from - 1]?.port_number || '—';
|
||
const lastPort = field?.ports?.[to - 1]?.port_number || '—';
|
||
document.getElementById('bulkPatchPreview').innerHTML = count
|
||
? `<strong>${count} forbindelser:</strong> ${field?.name || '—'} port ${firstPort}–${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${switchStart}–${switchStart + count - 1}`
|
||
: 'Vælg et gyldigt portområde.';
|
||
}
|
||
|
||
async function openBulkPatch() {
|
||
bulkFieldSelect.innerHTML = (locationCrossFields || []).map(field => `<option value="${field.id}">${field.name} · ${field.port_count} porte</option>`).join('');
|
||
const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk');
|
||
bulkSwitchSelect.innerHTML = switches.map(item => `<option value="${item.id}">${switchDisplayName(item)} · ${switchPortCount(item)} porte</option>`).join('');
|
||
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
|
||
const data = response.ok ? await response.json() : [];
|
||
const customers = Array.isArray(data) ? data : (data.customers || []);
|
||
bulkCustomerSelect.innerHTML = '<option value="">Ingen specifik kunde</option>' + customers.map(customer => `<option value="${customer.id}">${customer.name || customer.navn || `Kunde #${customer.id}`}</option>`).join('');
|
||
const firstField = selectedBulkField();
|
||
document.getElementById('bulkToPort').value = Math.min(24, firstField?.ports?.length || 1);
|
||
updateBulkPreview();
|
||
bulkPatchModal?.show();
|
||
}
|
||
|
||
document.getElementById('openBulkPatchBtn')?.addEventListener('click', openBulkPatch);
|
||
['bulkCrossField', 'bulkSwitch', 'bulkFromPort', 'bulkToPort', 'bulkSwitchStart'].forEach(id => document.getElementById(id)?.addEventListener('input', updateBulkPreview));
|
||
document.getElementById('bulkCustomerSearch')?.addEventListener('input', event => {
|
||
const query = event.target.value.trim().toLocaleLowerCase('da');
|
||
Array.from(bulkCustomerSelect.options).forEach((option, index) => {
|
||
option.hidden = index > 0 && Boolean(query) && !option.textContent.toLocaleLowerCase('da').includes(query);
|
||
});
|
||
const match = Array.from(bulkCustomerSelect.options).find((option, index) => index > 0 && !option.hidden);
|
||
if (query && match) bulkCustomerSelect.value = match.value;
|
||
});
|
||
|
||
document.getElementById('bulkPatchForm')?.addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
const field = selectedBulkField();
|
||
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
|
||
const from = Number(document.getElementById('bulkFromPort').value);
|
||
const to = Number(document.getElementById('bulkToPort').value);
|
||
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
|
||
if (!field || !hardware || !Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < from || to > field.ports.length) {
|
||
alert('Vælg et gyldigt krydsfelt, en switch og et portområde.');
|
||
return;
|
||
}
|
||
const ports = field.ports.slice(from - 1, to);
|
||
if (switchStart < 1 || switchStart + ports.length - 1 > switchPortCount(hardware)) {
|
||
alert('Portområdet går ud over switchens registrerede antal porte.');
|
||
return;
|
||
}
|
||
const conflicts = ports.map((port, index) => switchPortConflict(hardware, switchDisplayName(hardware), switchStart + index, port.outlet_id || null)).filter(Boolean);
|
||
if (conflicts.length) {
|
||
alert(`Massepatch blev stoppet: ${conflicts.length} switch-port(e) er allerede knyttet til andre vægstik.`);
|
||
return;
|
||
}
|
||
const existingCount = ports.filter(port => port.outlet_id).length;
|
||
if (!confirm(`Opret/opdatér ${ports.length} forbindelser?\n\n${existingCount} eksisterende vægstik bliver opdateret.`)) return;
|
||
const submit = document.getElementById('bulkPatchSubmit');
|
||
submit.disabled = true;
|
||
submit.textContent = 'Gemmer…';
|
||
const customerId = bulkCustomerSelect.value ? Number(bulkCustomerSelect.value) : null;
|
||
const isWan = document.getElementById('bulkIsWan').checked;
|
||
const results = await Promise.all(ports.map(async (port, index) => {
|
||
const payload = {
|
||
outlet_number: port.outlet_number || `${field.name}-${port.port_number}`,
|
||
customer_id: customerId,
|
||
category: port.category || null,
|
||
patch_panel: field.name,
|
||
patch_port: String(port.port_number),
|
||
cross_field_port_id: Number(port.id),
|
||
switch_hardware_id: Number(hardware.id),
|
||
switch_name: switchDisplayName(hardware),
|
||
switch_port: String(switchStart + index),
|
||
is_wan: isWan,
|
||
status: 'active',
|
||
notes: port.outlet_notes || null
|
||
};
|
||
if (!port.outlet_id) payload.location_id = Number(locationId);
|
||
const response = await fetch(port.outlet_id ? `/api/v1/locations/outlets/${port.outlet_id}` : '/api/v1/locations/outlets', {
|
||
method: port.outlet_id ? 'PATCH' : 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
return response.ok;
|
||
}));
|
||
const failed = results.filter(ok => !ok).length;
|
||
if (failed) {
|
||
alert(`${results.length - failed} forbindelser blev gemt, men ${failed} fejlede. Siden genindlæses.`);
|
||
}
|
||
location.reload();
|
||
});
|
||
|
||
function selectedSwitchHardwareId() {
|
||
const selectedName = document.getElementById('outletSwitch').value;
|
||
const match = (locationHardware || []).find(item => switchDisplayName(item) === selectedName);
|
||
return match ? Number(match.id) : null;
|
||
}
|
||
|
||
function normalizeSwitchName(value) {
|
||
return String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||
}
|
||
|
||
function switchPortConflict(switchHardware, switchName, portNumber, currentOutletId = null) {
|
||
if (!portNumber) return null;
|
||
return (locationWallOutlets || []).find(outlet => {
|
||
if (currentOutletId && Number(outlet.id) === Number(currentOutletId)) return false;
|
||
if (String(outlet.switch_port || '') !== String(portNumber)) return false;
|
||
if (switchHardware?.id && outlet.switch_hardware_id) return Number(outlet.switch_hardware_id) === Number(switchHardware.id);
|
||
return normalizeSwitchName(outlet.switch_name) === normalizeSwitchName(switchName);
|
||
}) || null;
|
||
}
|
||
|
||
function loadSwitchChoices(selectedName = '', selectedPort = '', currentOutletId = null) {
|
||
const switchInput = document.getElementById('outletSwitch');
|
||
const switchOptions = document.getElementById('outletSwitchOptions');
|
||
const portOptions = document.getElementById('outletSwitchPort');
|
||
const portHelp = document.getElementById('outletSwitchPortHelp');
|
||
const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk');
|
||
switchOptions.innerHTML = '';
|
||
switches.forEach(item => {
|
||
const option = document.createElement('option');
|
||
option.value = switchDisplayName(item);
|
||
option.label = switchPortCount(item) ? `${switchPortCount(item)} porte` : 'Antal porte ikke angivet';
|
||
switchOptions.appendChild(option);
|
||
});
|
||
switchInput.value = selectedName || '';
|
||
|
||
const selectedSwitch = switches.find(item => switchDisplayName(item) === switchInput.value);
|
||
const count = selectedSwitch ? switchPortCount(selectedSwitch) : 0;
|
||
portOptions.innerHTML = '<option value="">Vælg port</option>';
|
||
if (count) {
|
||
for (let number = 1; number <= count; number += 1) {
|
||
const option = document.createElement('option');
|
||
option.value = String(number);
|
||
const conflict = switchPortConflict(selectedSwitch, switchInput.value, number, currentOutletId);
|
||
option.textContent = conflict
|
||
? `Port ${number} — OPTAGET: ${conflict.outlet_number} (${conflict.status || 'ukendt'})`
|
||
: `Port ${number} — ledig`;
|
||
portOptions.appendChild(option);
|
||
}
|
||
portHelp.textContent = `${count} porte på den valgte switch.`;
|
||
} else if (switchInput.value) {
|
||
portHelp.textContent = 'Ingen portliste på switchen — du kan skrive porten manuelt.';
|
||
} else {
|
||
portHelp.textContent = switches.length ? 'Vælg en switch for at se dens porte.' : 'Ingen registrerede switches på denne lokation endnu.';
|
||
}
|
||
document.getElementById('outletSwitchPort').value = selectedPort || '';
|
||
}
|
||
|
||
document.getElementById('outletSwitch')?.addEventListener('input', () => {
|
||
loadSwitchChoices(document.getElementById('outletSwitch').value, '', document.getElementById('outletId').value || null);
|
||
});
|
||
|
||
async function loadCrossFieldPorts() {
|
||
const select = document.getElementById('outletCrossFieldPort');
|
||
const ports = await fetch('/api/v1/locations/cross-field-ports').then(r => r.ok ? r.json() : []);
|
||
select.innerHTML = '<option value="">Vælg senere / ingen kobling</option>' + ports.map(port => `<option value="${port.id}">${port.location_name} · ${port.cross_field_name} · port ${port.port_number}</option>`).join('');
|
||
}
|
||
|
||
async function loadOutletLocations() {
|
||
const select = document.getElementById('outletLocationId');
|
||
const locations = await fetch('/api/v1/locations?limit=100').then(r => r.ok ? r.json() : []);
|
||
const allowed = locations.filter(location => ['customer_site', 'bygning', 'etage', 'rum'].includes(location.location_type));
|
||
select.innerHTML = allowed.map(location => `<option value="${location.id}">${location.name}</option>`).join('');
|
||
select.value = String(locationId);
|
||
}
|
||
|
||
async function loadOutletCustomers(selectedCustomerId = null) {
|
||
const select = document.getElementById('outletCustomerId');
|
||
document.getElementById('outletCustomerSearch').value = '';
|
||
select.innerHTML = '<option value="">Ingen specifik kunde / brug lokationens kunde</option>';
|
||
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
|
||
if (!response.ok) {
|
||
select.insertAdjacentHTML('beforeend', '<option value="" disabled>Kunne ikke indlæse kunder</option>');
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
const customers = Array.isArray(data) ? data : (data.customers || []);
|
||
customers.forEach(customer => {
|
||
const option = document.createElement('option');
|
||
option.value = String(customer.id);
|
||
option.textContent = customer.name || customer.navn || `Kunde #${customer.id}`;
|
||
option.selected = String(customer.id) === String(selectedCustomerId || '');
|
||
select.appendChild(option);
|
||
});
|
||
if (!customers.length) {
|
||
select.insertAdjacentHTML('beforeend', '<option value="" disabled>Ingen kunder fundet</option>');
|
||
}
|
||
}
|
||
|
||
document.getElementById('outletCustomerSearch')?.addEventListener('input', (event) => {
|
||
const query = event.target.value.trim().toLocaleLowerCase('da');
|
||
const select = document.getElementById('outletCustomerId');
|
||
Array.from(select.options).forEach((option, index) => {
|
||
option.hidden = index > 0 && Boolean(query) && !option.textContent.toLocaleLowerCase('da').includes(query);
|
||
});
|
||
const firstMatch = Array.from(select.options).find((option, index) => index > 0 && !option.hidden && !option.disabled);
|
||
if (query && firstMatch) select.value = firstMatch.value;
|
||
});
|
||
|
||
async function openOutletModal(outlet = null, selectedPort = null) {
|
||
if (!outletModal) return;
|
||
const isExistingOutlet = Boolean(outlet?.id);
|
||
outletModal.show();
|
||
try {
|
||
await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
|
||
} catch (error) {
|
||
console.error('Kunne ikke indlæse vægstik-data', error);
|
||
}
|
||
document.getElementById('outletId').value = outlet?.id || '';
|
||
document.getElementById('outletNumber').value = outlet?.number || '';
|
||
document.getElementById('outletCategory').value = outlet?.category || '';
|
||
document.getElementById('outletPatchPanel').value = outlet?.panel || '';
|
||
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
|
||
loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null);
|
||
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
|
||
document.getElementById('outletIsWan').checked = Boolean(outlet?.isWan);
|
||
document.getElementById('outletNotes').value = outlet?.notes || '';
|
||
const pathParts = [
|
||
outlet?.number || 'Nyt vægstik',
|
||
selectedPort ? `${selectedPort.fieldName} port ${selectedPort.portNumber}` : (outlet?.panel && outlet?.patchPort ? `${outlet.panel} port ${outlet.patchPort}` : null),
|
||
outlet?.switchName && outlet?.switchPort ? `${outlet.switchName} port ${outlet.switchPort}` : null
|
||
].filter(Boolean);
|
||
document.getElementById('outletConnectionPath').textContent = `Kabelsti: ${pathParts.join(' → ')}`;
|
||
if (outlet?.crossFieldPortId) {
|
||
const portSelect = document.getElementById('outletCrossFieldPort');
|
||
portSelect.value = String(outlet.crossFieldPortId);
|
||
if (portSelect.value !== String(outlet.crossFieldPortId)) {
|
||
portSelect.insertAdjacentHTML('beforeend', `<option value="${outlet.crossFieldPortId}" selected>Tilknyttet krydsfelt-port</option>`);
|
||
}
|
||
}
|
||
if (selectedPort) {
|
||
const portSelect = document.getElementById('outletCrossFieldPort');
|
||
portSelect.value = String(selectedPort.id);
|
||
if (portSelect.value !== String(selectedPort.id)) {
|
||
portSelect.insertAdjacentHTML('beforeend', `<option value="${selectedPort.id}" selected>${selectedPort.fieldName} · port ${selectedPort.portNumber}</option>`);
|
||
}
|
||
document.getElementById('outletPatchPanel').value = selectedPort.fieldName;
|
||
document.getElementById('outletPatchPort').value = selectedPort.portNumber;
|
||
}
|
||
document.getElementById('outletModalTitle').textContent = isExistingOutlet ? 'Rediger vægstik' : 'Tilføj vægstik';
|
||
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !isExistingOutlet);
|
||
}
|
||
|
||
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
|
||
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => {
|
||
const selectedPort = {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber};
|
||
const outlet = port.dataset.outletId ? {
|
||
id: port.dataset.outletId,
|
||
number: port.dataset.outletNumber,
|
||
customerId: port.dataset.outletCustomerId,
|
||
category: port.dataset.outletCategory,
|
||
panel: port.dataset.outletPanel,
|
||
patchPort: port.dataset.outletPatchPort,
|
||
switchName: port.dataset.outletSwitch,
|
||
switchPort: port.dataset.outletSwitchPort,
|
||
isWan: port.dataset.outletIsWan === 'true',
|
||
status: port.dataset.outletStatus,
|
||
notes: port.dataset.outletNotes
|
||
} : null;
|
||
openOutletModal(outlet, selectedPort);
|
||
}));
|
||
document.querySelectorAll('.switch-port-action').forEach(port => port.addEventListener('click', () => {
|
||
const outlet = port.dataset.outletId ? {
|
||
id: port.dataset.outletId,
|
||
number: port.dataset.outletNumber,
|
||
customerId: port.dataset.outletCustomerId,
|
||
category: port.dataset.outletCategory,
|
||
panel: port.dataset.outletPanel,
|
||
patchPort: port.dataset.outletPatchPort,
|
||
crossFieldPortId: port.dataset.outletCrossFieldPortId,
|
||
switchName: port.dataset.switchName,
|
||
switchPort: port.dataset.switchPort,
|
||
isWan: port.dataset.outletIsWan === 'true',
|
||
status: port.dataset.outletStatus,
|
||
notes: port.dataset.outletNotes
|
||
} : {
|
||
switchName: port.dataset.switchName,
|
||
switchPort: port.dataset.switchPort,
|
||
isWan: false
|
||
};
|
||
openOutletModal(outlet);
|
||
}));
|
||
document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({
|
||
id: btn.dataset.id, number: btn.dataset.number, customerId: btn.dataset.customerId, category: btn.dataset.category, panel: btn.dataset.panel,
|
||
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort, isWan: btn.dataset.isWan === 'true',
|
||
status: btn.dataset.status, notes: btn.dataset.notes
|
||
})));
|
||
|
||
outletForm?.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const outletId = document.getElementById('outletId').value;
|
||
const payload = {
|
||
location_id: Number(document.getElementById('outletLocationId').value), outlet_number: outletValue('outletNumber'), customer_id: document.getElementById('outletCustomerId').value ? Number(document.getElementById('outletCustomerId').value) : null, category: outletValue('outletCategory'),
|
||
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
|
||
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
|
||
switch_hardware_id: selectedSwitchHardwareId(), switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
|
||
is_wan: document.getElementById('outletIsWan').checked,
|
||
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
|
||
};
|
||
const selectedSwitch = (locationHardware || []).find(item => Number(item.id) === selectedSwitchHardwareId());
|
||
const conflict = switchPortConflict(selectedSwitch, payload.switch_name, payload.switch_port, outletId || null);
|
||
if (conflict) {
|
||
const message = `Switch-port ${payload.switch_port} er allerede registreret på vægstik ${conflict.outlet_number}. Vil du flytte forbindelsen til dette vægstik?`;
|
||
if (!confirm(message)) return;
|
||
payload.replace_existing_switch_port = true;
|
||
}
|
||
const response = await fetch(outletId ? `/api/v1/locations/outlets/${outletId}` : '/api/v1/locations/outlets', {
|
||
method: outletId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||
});
|
||
if (response.status === 409) {
|
||
const error = await response.json().catch(() => ({}));
|
||
if (confirm(`${error.detail || 'Porten er allerede i brug.'}\n\nVil du overskrive forbindelsen?`)) {
|
||
payload.replace_existing_switch_port = true;
|
||
const retry = await fetch(outletId ? `/api/v1/locations/outlets/${outletId}` : '/api/v1/locations/outlets', {
|
||
method: outletId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||
});
|
||
if (retry.ok) { location.reload(); return; }
|
||
}
|
||
return;
|
||
}
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({}));
|
||
alert(error.detail || 'Kunne ikke gemme vægstik');
|
||
return;
|
||
}
|
||
location.reload();
|
||
});
|
||
|
||
document.getElementById('deleteOutletBtn')?.addEventListener('click', async () => {
|
||
const outletId = document.getElementById('outletId').value;
|
||
if (!outletId || !confirm('Slet dette vægstik?')) return;
|
||
const response = await fetch(`/api/v1/locations/outlets/${outletId}`, {method: 'DELETE'});
|
||
if (response.ok) location.reload();
|
||
else alert('Kunne ikke slette vægstik');
|
||
});
|
||
|
||
document.querySelectorAll('.save-hardware-order-btn').forEach(button => button.addEventListener('click', async () => {
|
||
const hardwareId = button.dataset.hardwareId;
|
||
const input = document.querySelector(`.hardware-display-order[data-hardware-id="${hardwareId}"]`);
|
||
const order = Number(input?.value);
|
||
if (!Number.isInteger(order) || order < 1) {
|
||
alert('Angiv et positivt heltal for rækkefølgen.');
|
||
return;
|
||
}
|
||
button.disabled = true;
|
||
const response = await fetch(`/api/v1/hardware/${hardwareId}`, {
|
||
method: 'PATCH', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({location_display_order: order})
|
||
});
|
||
if (response.ok) location.reload();
|
||
else {
|
||
const error = await response.json().catch(() => ({}));
|
||
alert(error.detail || 'Kunne ikke gemme rækkefølgen');
|
||
button.disabled = false;
|
||
}
|
||
}));
|
||
});
|
||
</script>
|
||
{% endblock %}
|