2026-01-31 23:16:24 +01:00
{% extends "shared/frontend/base.html" %}
{% block title %}{{ location.name }} - BMC Hub{% endblock %}
2026-05-06 07:01:43 +02:00
{% block extra_css %}
< style >
2026-07-17 10:23:57 +02:00
.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.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)); } }
2026-05-06 07:01:43 +02:00
.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 %}
2026-01-31 23:16:24 +01:00
{% block content %}
2026-05-06 07:01:43 +02:00
< div class = "container-fluid px-4 py-4 locations-detail-page" >
2026-01-31 23:16:24 +01:00
<!-- 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" >
2026-05-06 07:01:43 +02:00
< 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 >
2026-01-31 23:16:24 +01:00
{% set type_label = {
'kompleks': 'Kompleks',
'bygning': 'Bygning',
'etage': 'Etage',
'rum': 'Rum',
2026-02-09 15:30:07 +01:00
'kantine': 'Kantine',
'moedelokale': 'Mødelokale',
2026-01-31 23:16:24 +01:00
'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',
2026-02-09 15:30:07 +01:00
'kantine': '#d35400',
'moedelokale': '#16a085',
2026-01-31 23:16:24 +01:00
'customer_site': '#9b59b6',
'vehicle': '#8e44ad'
}.get(location.location_type, '#6c757d') %}
2026-05-06 07:01:43 +02:00
< span class = "case-type-chip" style = "--tcolor: {{ type_color }};" >
2026-01-31 23:16:24 +01:00
{{ type_label }}
< / span >
2026-07-17 10:23:57 +02:00
{% 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 %}
2026-01-31 23:16:24 +01:00
{% if location.is_active %}
2026-05-06 07:01:43 +02:00
< span class = "case-status-chip open" >
< span class = "case-status-dot" > < / span > Aktiv
< / span >
2026-01-31 23:16:24 +01:00
{% else %}
2026-05-06 07:01:43 +02:00
< span class = "case-status-chip closed" >
< span class = "case-status-dot" > < / span > Inaktiv
< / span >
2026-01-31 23:16:24 +01:00
{% endif %}
< / div >
2026-05-06 07:01:43 +02:00
< 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 >
2026-01-31 23:16:24 +01:00
< / div >
2026-05-06 07:01:43 +02:00
< 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 >
2026-01-31 23:16:24 +01:00
< / div >
< / div >
< / div >
< / div >
<!-- Tabs Navigation -->
2026-05-06 07:01:43 +02:00
< ul class = "nav nav-tabs mb-4" id = "locationTabs" role = "tablist" >
2026-01-31 23:16:24 +01:00
< 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
2026-05-06 07:01:43 +02:00
< span class = "location-tab-count-badge ms-1" > {{ location.contacts|length if location.contacts else 0 }}< / span >
2026-01-31 23:16:24 +01:00
< / 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
2026-05-06 07:01:43 +02:00
< span class = "location-tab-count-badge ms-1" > {{ location.services|length if location.services else 0 }}< / span >
2026-01-31 23:16:24 +01:00
< / 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
2026-05-06 07:01:43 +02:00
< span class = "location-tab-count-badge ms-1" > {{ location.capacity|length if location.capacity else 0 }}< / span >
2026-01-31 23:16:24 +01:00
< / button >
< / li >
2026-07-17 10:23:57 +02:00
{% 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 %}
2026-01-31 23:16:24 +01:00
< 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
2026-05-06 07:01:43 +02:00
< span class = "location-tab-count-badge ms-1" > {{ location.hardware|length if location.hardware else 0 }}< / span >
2026-01-31 23:16:24 +01:00
< / 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" >
2026-05-06 07:01:43 +02:00
< 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 >
2026-01-31 23:16:24 +01:00
< 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" >
2026-07-17 01:58:02 +02:00
< 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 >
2026-01-31 23:16:24 +01:00
< / div >
< div class = "card-body" >
2026-07-17 01:58:02 +02:00
< 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 >
2026-01-31 23:16:24 +01:00
< 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 }}" >
2026-02-09 15:30:07 +01:00
{% 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 %}
2026-01-31 23:16:24 +01:00
< / 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 %}
2026-07-17 01:58:02 +02:00
< option value = "{{ customer.id }}" { % if location . customer_id = = customer . id % } selected { % endif % } > {{ customer.name }}< / option >
2026-01-31 23:16:24 +01:00
{% 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 >
2026-07-17 10:23:57 +02:00
< 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 }}< / 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 }}" 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 >
2026-01-31 23:16:24 +01:00
< 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 >
2026-07-17 10:23:57 +02:00
{% 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" > {{ field.port_count }} porte< / span > < / div > < 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-notes = "{{ field.notes or '' }}" > < i class = "bi bi-pencil" > < / i > Rediger< / button > < / div >
< div class = "patch-panel" > < div class = "patch-panel-grid" > {% 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 %}
2026-01-31 23:16:24 +01:00
<!-- 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 d-flex justify-content-between align-items-center" >
< div >
< div class = "fw-600" > {{ hw.brand }} {{ hw.model }}< / div >
< div class = "text-muted small" > {{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}< / div >
< / div >
< span class = "badge bg-secondary" > {{ hw.status }}< / span >
< / 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" >
2026-05-06 07:01:43 +02:00
< 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 >
2026-01-31 23:16:24 +01:00
< / div >
2026-05-06 07:01:43 +02:00
< 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 >
2026-01-31 23:16:24 +01:00
< / div >
2026-05-06 07:01:43 +02:00
< input type = "hidden" id = "existingContactId" value = "" >
2026-01-31 23:16:24 +01:00
< div class = "mb-3" >
2026-05-06 07:01:43 +02:00
< 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 >
2026-01-31 23:16:24 +01:00
< / 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 >
2026-07-17 10:23:57 +02:00
<!-- 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 24" > < / 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 = "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" required placeholder = "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-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" placeholder = "Fx Switch 3" > < / div >
< div class = "col-md-6" > < label class = "form-label" > Switch-port< / label > < input class = "form-control" id = "outletSwitchPort" placeholder = "Fx Gi1/0/12" > < / 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 >
2026-01-31 23:16:24 +01:00
<!-- 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 }}';
2026-05-06 07:01:43 +02:00
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 = '';
});
2026-01-31 23:16:24 +01:00
// 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
2026-05-06 07:01:43 +02:00
addContactForm.addEventListener('submit', function(e) {
2026-01-31 23:16:24 +01:00
e.preventDefault();
2026-05-06 07:01:43 +02:00
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...';
}
2026-01-31 23:16:24 +01:00
const contactData = {
location_id: locationId,
2026-05-06 07:01:43 +02:00
role: contactRoleInput.value,
existing_contact_id: existingContactIdInput.value ? parseInt(existingContactIdInput.value, 10) : null,
2026-01-31 23:16:24 +01:00
is_primary: document.getElementById('isPrimaryContact').checked
};
fetch(`/api/v1/locations/${locationId}/contacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contactData)
})
2026-05-06 07:01:43 +02:00
.then(async response => {
2026-01-31 23:16:24 +01:00
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide();
setTimeout(() => location.reload(), 300);
2026-05-06 07:01:43 +02:00
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
2026-01-31 23:16:24 +01:00
}
2026-05-06 07:01:43 +02:00
alert(detail);
2026-01-31 23:16:24 +01:00
})
2026-05-06 07:01:43 +02:00
.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';
}
});
2026-01-31 23:16:24 +01:00
});
// 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();
});
}
});
});
2026-07-17 10:23:57 +02:00
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('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('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 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({location_id: locationId, name: document.getElementById('crossFieldName').value, port_count: Number(document.getElementById('crossFieldPortCount').value), notes: document.getElementById('crossFieldNotes').value || null})});
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Krydsfeltet kunne ikke oprettes'); }
});
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;
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 openOutletModal(outlet = null, selectedPort = null) {
if (!outletModal) return;
await Promise.all([loadCrossFieldPorts(), loadOutletLocations()]);
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 || '';
document.getElementById('outletSwitch').value = outlet?.switchName || '';
document.getElementById('outletSwitchPort').value = outlet?.switchPort || '';
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, 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'), 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_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
};
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.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');
});
2026-01-31 23:16:24 +01:00
});
< / script >
{% endblock %}