bmc_hub/app/modules/locations/templates/detail.html
Christian be68148448 feat(email): add RETURNING id to email activity log insert
feat(ollama): implement case creation rewrite with strict rules

fix(sync): include phone number in economic customer sync

fix(migrations): clean up orphan links before enforcing foreign keys in email threading schema

feat(migrations): add support for physical patch-panel layouts and update related constraints

feat(migrations): add start port number for physical patch panels

feat(migrations): introduce display order for physical panels

feat(migrations): link wall outlets to switch hardware

feat(migrations): allow optional label and customer for wall outlets

feat(migrations): create hardware network links table

feat(migrations): add location display order for hardware assets

feat(migrations): create UISP devices and link to hardware assets
2026-07-18 10:10:31 +02:00

1681 lines
99 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:not(.assigned):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.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.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-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">
{% 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 = 'assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else '') %}<button type="button" class="patch-port {{ port_class }}" {% if not port.outlet_id %}data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}"{% else %}disabled{% endif %} title="{% if port.outlet_id %}{{ port.outlet_location_name }} · {{ port.outlet_number }} ({{ port.outlet_status }}){% else %}Ledig port — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</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"><div class="input-group input-group-sm" style="width: 175px;"><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 }}"><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.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 %}<a href="/hardware/{{ hw.id }}" class="patch-port text-decoration-none {% if port.hardware_link %}hardware-linked{% elif port.outlet %}assigned{% endif %}" title="{% if port.hardware_link %}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 %}{% elif port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% else %}Ledig port — åbn switch for at tilknytte{% endif %}"><span>{{ port.port_number }}</span>{% if 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 %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>{% else %}<span class="patch-port-outlet">Ledig</span>{% endif %}</a>{% endfor %}</div></div><div class="form-text mt-2">Lilla porte er forbundet med hardware; grønne porte går til et vægstik.</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="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"><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">Kunde på porten</label><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Bruges fx hvis et stik eller en switch-port er tildelt en bestemt lejer/kunde.</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-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() {
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
const locationId = '{{ location.id }}';
const locationHardware = {{ location.hardware | tojson }};
const locationWallOutlets = {{ location.wall_outlets | 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) {
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;
}
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');
const customers = await fetch('/api/v1/customers?limit=1000').then(response => response.ok ? response.json() : []);
select.innerHTML = '<option value="">Ingen specifik kunde / brug lokationens kunde</option>';
(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);
});
}
async function openOutletModal(outlet = null, selectedPort = null) {
if (!outletModal) return;
await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
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('outletNotes').value = outlet?.notes || '';
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 = outlet ? 'Rediger vægstik' : 'Tilføj vægstik';
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !outlet);
outletModal.show();
}
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => openOutletModal(null, {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber})));
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,
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'),
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 %}