bmc_hub/app/modules/locations/templates/detail.html
Christian 56cc1bdcc4 feat: Enhance AnyDesk integration and vendor email domain management
- Updated the AnyDesk quick connect modal to improve user experience with new UI elements and functionality.
- Added support for saving and managing multiple AnyDesk IDs associated with cases, including hardware and contact information.
- Implemented backend endpoints for managing vendor email domains, allowing addition, deletion, and retrieval of domains linked to vendors.
- Created a new database table for vendor email domains to support multiple exact domains per vendor.
- Added tests for bankruptcy workflow to ensure correct case creation and alert linking based on exact CVR matches.
2026-07-30 20:11:43 +02:00

2075 lines
129 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "shared/frontend/base.html" %}
{% block title %}{{ 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 = 1A24B.</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 krydsfelt-port</label><select class="form-select" id="bulkFromPort" required></select></div>
<div class="col-md-3"><label class="form-label">Til krydsfelt-port</label><select class="form-select" id="bulkToPort" required></select></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><select class="form-select" id="bulkSwitchStart" required></select></div>
<div class="col-md-3"><label class="form-label">WAN-port i området</label><select class="form-select" id="bulkWanPort"><option value="">Ingen WAN-port</option></select></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');
let bulkCustomers = [];
function updateBulkCustomerOptions(query = '') {
const normalizedQuery = query.trim().toLocaleLowerCase('da');
const matches = normalizedQuery
? bulkCustomers.filter(customer =>
String(customer.name || customer.navn || '').toLocaleLowerCase('da').includes(normalizedQuery)
).slice(0, 25)
: [];
bulkCustomerSelect.innerHTML = '<option value="">Ingen specifik kunde</option>' + matches
.map(customer => `<option value="${customer.id}">${customer.name || customer.navn || `Kunde #${customer.id}`}</option>`)
.join('');
}
function selectedBulkField() {
return (locationCrossFields || []).find(field => Number(field.id) === Number(bulkFieldSelect.value));
}
function selectableBulkFieldPorts(field) {
return (field?.ports || []).filter(port => port.is_active !== false);
}
function selectableSwitchPorts(hardware) {
return (hardware?.switch_ports || []).filter(port => port && port.port_number !== null && port.port_number !== undefined);
}
function populateBulkFieldPorts() {
const ports = selectableBulkFieldPorts(selectedBulkField());
document.getElementById('bulkFromPort').innerHTML = ports
.map((port, index) => `<option value="${index}">${port.port_number}</option>`)
.join('');
document.getElementById('bulkFromPort').value = ports.length ? '0' : '';
populateBulkEndPorts(Math.min(23, ports.length - 1));
}
function populateBulkEndPorts(preferredIndex = null) {
const ports = selectableBulkFieldPorts(selectedBulkField());
const from = Number(document.getElementById('bulkFromPort').value);
const firstIndex = Number.isInteger(from) && from >= 0 ? from : 0;
const endSelect = document.getElementById('bulkToPort');
endSelect.innerHTML = ports
.map((port, index) => ({port, index}))
.filter(item => item.index >= firstIndex)
.map(item => `<option value="${item.index}">${item.port.port_number}</option>`)
.join('');
const requested = Number(preferredIndex);
const target = Number.isInteger(requested) && requested >= firstIndex && requested < ports.length
? requested
: firstIndex;
endSelect.value = ports.length ? String(target) : '';
}
function populateBulkSwitchPorts() {
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const ports = selectableSwitchPorts(hardware);
document.getElementById('bulkSwitchStart').innerHTML = ports
.map((port, index) => `<option value="${index}">${port.port_number}</option>`)
.join('');
}
function populateBulkWanPorts() {
const ports = selectableBulkFieldPorts(selectedBulkField());
const from = Number(document.getElementById('bulkFromPort').value);
const to = Number(document.getElementById('bulkToPort').value);
const selectedPorts = Number.isInteger(from) && Number.isInteger(to) && to >= from
? ports.slice(from, to + 1)
: [];
const wanSelect = document.getElementById('bulkWanPort');
const previous = wanSelect.value;
wanSelect.innerHTML = '<option value="">Ingen WAN-port</option>' + selectedPorts
.map(port => `<option value="${port.id}">${port.port_number}</option>`)
.join('');
const existingWan = selectedPorts.find(port => port.is_wan);
wanSelect.value = selectedPorts.some(port => String(port.id) === previous)
? previous
: (existingWan ? String(existingWan.id) : '');
}
function updateBulkPreview() {
const field = selectedBulkField();
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const fieldPorts = selectableBulkFieldPorts(field);
const switchPorts = selectableSwitchPorts(hardware);
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 selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + count);
const firstPort = fieldPorts[from]?.port_number || '—';
const lastPort = fieldPorts[to]?.port_number || '—';
const firstSwitchPort = selectedSwitchPorts[0]?.port_number || '—';
const lastSwitchPort = selectedSwitchPorts[selectedSwitchPorts.length - 1]?.port_number || '—';
document.getElementById('bulkPatchPreview').innerHTML = count && selectedSwitchPorts.length === count
? `<strong>${count} forbindelser:</strong> ${field?.name || '—'} port ${firstPort}${lastPort}${hardware ? switchDisplayName(hardware) : '—'} port ${firstSwitchPort}${lastSwitchPort}`
: count
? 'Det valgte område går ud over de oprettede switch-porte.'
: 'Vælg et gyldigt portområde.';
}
async function openBulkPatch() {
const fields = (locationCrossFields || []).filter(field => selectableBulkFieldPorts(field).length);
bulkFieldSelect.innerHTML = fields.map(field => {
const ports = selectableBulkFieldPorts(field);
const firstPort = ports[0]?.port_number || '—';
const lastPort = ports[ports.length - 1]?.port_number || '—';
return `<option value="${field.id}">${field.name} · ${firstPort}${lastPort} · ${ports.length} porte</option>`;
}).join('');
const switches = (locationHardware || []).filter(item =>
String(item.asset_type || '').toLowerCase() === 'netværk' && selectableSwitchPorts(item).length
);
bulkSwitchSelect.innerHTML = switches.map(item => `<option value="${item.id}">${switchDisplayName(item)} · ${selectableSwitchPorts(item).length} oprettede porte</option>`).join('');
populateBulkFieldPorts();
populateBulkSwitchPorts();
populateBulkWanPorts();
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
const data = response.ok ? await response.json() : [];
bulkCustomers = Array.isArray(data) ? data : (data.customers || []);
document.getElementById('bulkCustomerSearch').value = '';
updateBulkCustomerOptions();
updateBulkPreview();
bulkPatchModal?.show();
}
document.getElementById('openBulkPatchBtn')?.addEventListener('click', openBulkPatch);
bulkFieldSelect?.addEventListener('change', () => { populateBulkFieldPorts(); populateBulkWanPorts(); updateBulkPreview(); });
bulkSwitchSelect?.addEventListener('change', () => { populateBulkSwitchPorts(); updateBulkPreview(); });
document.getElementById('bulkFromPort')?.addEventListener('change', () => {
populateBulkEndPorts(Number(document.getElementById('bulkToPort').value));
populateBulkWanPorts();
updateBulkPreview();
});
document.getElementById('bulkToPort')?.addEventListener('change', () => {
populateBulkWanPorts();
updateBulkPreview();
});
document.getElementById('bulkSwitchStart')?.addEventListener('change', updateBulkPreview);
document.getElementById('bulkCustomerSearch')?.addEventListener('input', event => {
updateBulkCustomerOptions(event.target.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 fieldPorts = selectableBulkFieldPorts(field);
const switchPorts = selectableSwitchPorts(hardware);
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 < 0 || to < from || to >= fieldPorts.length) {
alert('Vælg et gyldigt krydsfelt, en switch og et portområde.');
return;
}
const ports = fieldPorts.slice(from, to + 1);
const selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + ports.length);
if (switchStart < 0 || selectedSwitchPorts.length !== ports.length) {
alert('Portområdet går ud over switchens oprettede porte.');
return;
}
const conflicts = ports.map((port, index) => switchPortConflict(
hardware,
switchDisplayName(hardware),
selectedSwitchPorts[index].port_number,
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 wanPortId = document.getElementById('bulkWanPort').value
? Number(document.getElementById('bulkWanPort').value)
: null;
const orderedPorts = [
...ports.map((port, index) => ({port, index})).filter(item => Number(item.port.id) !== wanPortId),
...ports.map((port, index) => ({port, index})).filter(item => Number(item.port.id) === wanPortId)
];
const results = [];
for (const {port, index} of orderedPorts) {
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(selectedSwitchPorts[index].port_number),
is_wan: Number(port.id) === wanPortId,
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)
});
results.push(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 %}