- Created migration scripts for AnyDesk sessions and hardware assets. - Implemented apply_migration_115.py to execute migration for AnyDesk sessions. - Added set_customer_wiki_slugs.py script to update customer wiki slugs based on a predefined folder list. - Developed run_migration.py to apply AnyDesk migration schema. - Added tests for Service Contract Wizard to ensure functionality and dry-run mode.
761 lines
35 KiB
HTML
761 lines
35 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}{{ hardware.brand }} {{ hardware.model }} - Hardware - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
/* Nordic Top / Header Styling */
|
|
.page-header {
|
|
background: linear-gradient(135deg, var(--accent) 0%, #0b3a5b 100%);
|
|
color: white;
|
|
padding: 2rem 0 3rem;
|
|
margin-bottom: -2rem; /* Overlap with content */
|
|
border-radius: 0 0 12px 12px; /* Slight curve at bottom */
|
|
}
|
|
|
|
.page-header h1 {
|
|
font-weight: 700;
|
|
font-size: 2rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.page-header .breadcrumb {
|
|
background: transparent;
|
|
padding: 0;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.page-header .breadcrumb-item,
|
|
.page-header .breadcrumb-item a {
|
|
color: rgba(255,255,255,0.8);
|
|
text-decoration: none;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.page-header .breadcrumb-item.active {
|
|
color: white;
|
|
}
|
|
|
|
/* Content Styling */
|
|
.main-content {
|
|
position: relative;
|
|
z-index: 10;
|
|
padding-bottom: 3rem;
|
|
}
|
|
|
|
.card {
|
|
border: none;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
|
margin-bottom: 1.5rem;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.card-header {
|
|
background: white;
|
|
border-bottom: 1px solid rgba(0,0,0,0.05);
|
|
padding: 1.25rem 1.5rem;
|
|
font-weight: 600;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border-radius: 12px 12px 0 0 !important;
|
|
}
|
|
|
|
.card-title-text {
|
|
color: var(--accent);
|
|
font-size: 1.1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.info-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 0.75rem 0;
|
|
border-bottom: 1px solid rgba(0,0,0,0.03);
|
|
}
|
|
|
|
.info-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.info-label {
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.info-value {
|
|
color: var(--text-primary);
|
|
font-weight: 600;
|
|
text-align: right;
|
|
}
|
|
|
|
/* Quick Action Cards */
|
|
.action-card {
|
|
background: white;
|
|
padding: 1.5rem;
|
|
border-radius: 12px;
|
|
text-align: center;
|
|
border: 1px dashed rgba(0,0,0,0.1);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.action-card:hover {
|
|
border-color: var(--accent);
|
|
background: var(--accent-light);
|
|
color: var(--accent);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.action-card i {
|
|
font-size: 2rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
/* Timeline */
|
|
.timeline {
|
|
position: relative;
|
|
padding-left: 2rem;
|
|
}
|
|
|
|
.timeline::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0.5rem;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 2px;
|
|
background: #e9ecef;
|
|
}
|
|
|
|
.timeline-item {
|
|
position: relative;
|
|
padding-bottom: 1.5rem;
|
|
}
|
|
|
|
.timeline-marker {
|
|
position: absolute;
|
|
left: -2rem;
|
|
top: 0.2rem;
|
|
width: 1rem;
|
|
height: 1rem;
|
|
border-radius: 50%;
|
|
background: white;
|
|
border: 2px solid var(--accent);
|
|
}
|
|
|
|
.timeline-item.active .timeline-marker {
|
|
background: #28a745;
|
|
border-color: #28a745;
|
|
}
|
|
|
|
.icon-box {
|
|
width: 48px;
|
|
height: 48px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 12px;
|
|
font-size: 1.5rem;
|
|
margin-right: 1rem;
|
|
}
|
|
|
|
.bg-soft-primary { background-color: rgba(13, 110, 253, 0.1); color: #0d6efd; }
|
|
.bg-soft-success { background-color: rgba(25, 135, 84, 0.1); color: #198754; }
|
|
.bg-soft-warning { background-color: rgba(255, 193, 7, 0.1); color: #ffc107; }
|
|
.bg-soft-info { background-color: rgba(13, 202, 240, 0.1); color: #0dcaf0; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- Custom Nordic Blue Header -->
|
|
<div class="page-header">
|
|
<div class="container-fluid px-4">
|
|
<nav aria-label="breadcrumb">
|
|
<ol class="breadcrumb">
|
|
<li class="breadcrumb-item"><a href="/">Forside</a></li>
|
|
<li class="breadcrumb-item"><a href="/hardware">Hardware</a></li>
|
|
<li class="breadcrumb-item active" aria-current="page">{{ hardware.serial_number or 'Detail' }}</li>
|
|
</ol>
|
|
</nav>
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div class="d-flex align-items-center">
|
|
<div class="me-3" style="font-size: 2.5rem;">
|
|
{% if hardware.asset_type == 'pc' %}🖥️
|
|
{% elif hardware.asset_type == 'laptop' %}💻
|
|
{% elif hardware.asset_type == 'printer' %}🖨️
|
|
{% elif hardware.asset_type == 'skærm' %}🖥️
|
|
{% elif hardware.asset_type == 'telefon' %}📱
|
|
{% elif hardware.asset_type == 'server' %}🗄️
|
|
{% elif hardware.asset_type == 'netværk' %}🌐
|
|
{% else %}📦
|
|
{% endif %}
|
|
</div>
|
|
<div>
|
|
<h1>{{ hardware.brand or 'Unknown' }} {{ hardware.model or '' }}</h1>
|
|
<div class="d-flex align-items-center gap-2 mt-1">
|
|
<span class="badge bg-white text-dark border">{{ hardware.serial_number or 'Ingen serienummer' }}</span>
|
|
|
|
<span class="badge {% if hardware.status == 'active' %}bg-success{% elif hardware.status == 'retired' %}bg-secondary{% elif hardware.status == 'in_repair' %}bg-primary{% else %}bg-warning{% endif %}">
|
|
{{ hardware.status|replace('_', ' ')|title }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-light text-primary fw-medium shadow-sm">
|
|
<i class="bi bi-pencil me-1"></i> Rediger
|
|
</a>
|
|
<button onclick="deleteHardware()" class="btn btn-danger text-white fw-medium shadow-sm" style="background-color: rgba(220, 53, 69, 0.9);">
|
|
<i class="bi bi-trash me-1"></i> Slet
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container-fluid px-4 main-content">
|
|
<div class="row">
|
|
<!-- Left Column: Key Info & Relations -->
|
|
<div class="col-lg-4">
|
|
|
|
<!-- Key Details Card -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<div class="card-title-text"><i class="bi bi-info-circle"></i> Stamdata</div>
|
|
</div>
|
|
<div class="card-body pt-0">
|
|
<div class="info-row">
|
|
<span class="info-label">Type</span>
|
|
<span class="info-value">{{ hardware.asset_type|title }}</span>
|
|
</div>
|
|
{% if hardware.internal_asset_id %}
|
|
<div class="info-row">
|
|
<span class="info-label">Intern ID</span>
|
|
<span class="info-value">{{ hardware.internal_asset_id }}</span>
|
|
</div>
|
|
{% endif %}
|
|
{% if hardware.customer_asset_id %}
|
|
<div class="info-row">
|
|
<span class="info-label">Kunde ID</span>
|
|
<span class="info-value">{{ hardware.customer_asset_id }}</span>
|
|
</div>
|
|
{% endif %}
|
|
{% if hardware.warranty_until %}
|
|
<div class="info-row">
|
|
<span class="info-label">Garanti Udløb</span>
|
|
<span class="info-value">{{ hardware.warranty_until }}</span>
|
|
</div>
|
|
{% endif %}
|
|
{% if hardware.end_of_life %}
|
|
<div class="info-row">
|
|
<span class="info-label">End of Life</span>
|
|
<span class="info-value">{{ hardware.end_of_life }}</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- AnyDesk Card -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<div class="card-title-text"><i class="bi bi-display"></i> AnyDesk</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="info-row">
|
|
<span class="info-label">AnyDesk ID</span>
|
|
<span class="info-value">{{ hardware.anydesk_id or '-' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">AnyDesk Link</span>
|
|
<span class="info-value">
|
|
{% if hardware.anydesk_link %}
|
|
<a href="{{ hardware.anydesk_link }}" target="_blank" class="btn btn-sm btn-outline-primary">Connect</a>
|
|
{% else %}
|
|
-
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tags Card -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<div class="card-title-text"><i class="bi bi-tags"></i> Tags</div>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="window.showTagPicker('hardware', {{ hardware.id }}, () => window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags'))">
|
|
<i class="bi bi-plus-lg"></i> Tilføj
|
|
</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="hardware-tags" class="d-flex flex-wrap">
|
|
<!-- Tags loaded via JS -->
|
|
<div class="text-center w-100 py-2">
|
|
<span class="spinner-border spinner-border-sm text-muted"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Current Location Card -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<div class="card-title-text"><i class="bi bi-geo-alt"></i> Nuværende Lokation</div>
|
|
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#locationModal">
|
|
<i class="bi bi-arrow-left-right"></i> Skift
|
|
</button>
|
|
</div>
|
|
<div class="card-body">
|
|
{% if locations and locations|length > 0 %}
|
|
{% set current_loc = locations[0] %}
|
|
{% if not current_loc.end_date %}
|
|
<div class="d-flex align-items-center">
|
|
<div class="icon-box bg-soft-primary">
|
|
<i class="bi bi-building"></i>
|
|
</div>
|
|
<div>
|
|
<h5 class="mb-1">{{ current_loc.location_name or 'Ukendt' }}</h5>
|
|
<small class="text-muted">Siden {{ current_loc.start_date }}</small>
|
|
</div>
|
|
</div>
|
|
{% if current_loc.notes %}
|
|
<div class="mt-3 p-2 bg-light rounded small text-muted">
|
|
<i class="bi bi-card-text me-1"></i> {{ current_loc.notes }}
|
|
</div>
|
|
{% endif %}
|
|
{% else %}
|
|
<div class="text-center text-muted py-3">
|
|
<i class="bi bi-geo-alt" style="font-size: 2rem; opacity: 0.5;"></i>
|
|
<p class="mt-2 text-primary">Ingen aktiv lokation</p>
|
|
</div>
|
|
{% endif %}
|
|
{% else %}
|
|
<div class="text-center py-2">
|
|
<p class="text-muted mb-3">Hardwaret er ikke tildelt en lokation</p>
|
|
<button class="btn btn-primary w-100" data-bs-toggle="modal" data-bs-target="#locationModal">
|
|
<i class="bi bi-plus-circle me-1"></i> Tildel Lokation
|
|
</button>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Current Owner Card -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<div class="card-title-text"><i class="bi bi-person"></i> Nuværende Ejer</div>
|
|
</div>
|
|
<div class="card-body">
|
|
{% if ownership and ownership|length > 0 %}
|
|
{% set current_own = ownership[0] %}
|
|
{% if not current_own.end_date %}
|
|
<div class="d-flex align-items-center">
|
|
<div class="icon-box bg-soft-success">
|
|
<i class="bi bi-person-badge"></i>
|
|
</div>
|
|
<div>
|
|
<h5 class="mb-1">
|
|
{{ current_own.customer_name or current_own.owner_type|title }}
|
|
</h5>
|
|
<small class="text-muted">Siden {{ current_own.start_date }}</small>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<p class="text-muted text-center py-2">Ingen aktiv ejer registreret</p>
|
|
{% endif %}
|
|
{% else %}
|
|
<p class="text-muted text-center py-2">Ingen ejerhistorik</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Right Column: Quick Add & History -->
|
|
<div class="col-lg-8">
|
|
|
|
<!-- Quick Actions Grid -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-3">
|
|
<div class="action-card" onclick="alert('Funktion: Opret Sag til dette hardware')">
|
|
<i class="bi bi-ticket-perforated"></i>
|
|
<div>Opret Sag</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
|
|
<i class="bi bi-geo-alt"></i>
|
|
<div>Skift Lokation</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<!-- Link to create new location, pre-filled? Or just general create -->
|
|
<a href="/app/locations" class="text-decoration-none">
|
|
<div class="action-card">
|
|
<i class="bi bi-building-add"></i>
|
|
<div>Ny Lokation</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="action-card" onclick="alert('Funktion: Upload bilag')">
|
|
<i class="bi bi-paperclip"></i>
|
|
<div>Tilføj Bilag</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs Section -->
|
|
<div class="card">
|
|
<div class="card-header p-0 border-bottom-0">
|
|
<ul class="nav nav-tabs ps-3 pt-3 pe-3 w-100" id="hwTabs" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">Historik</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" id="cases-tab" data-bs-toggle="tab" data-bs-target="#cases" type="button" role="tab">Sager ({{ cases|length }})</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab">Filer ({{ attachments|length }})</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab">Noter</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="tab-content" id="hwTabsContent">
|
|
|
|
<!-- History Tab -->
|
|
<div class="tab-pane fade show active" id="history" role="tabpanel">
|
|
<h6 class="text-secondary text-uppercase small fw-bold mb-4">Kombineret Historik</h6>
|
|
|
|
<div class="timeline">
|
|
<!-- Interleave items visually? For now just dump both lists or keep separate sections inside tab -->
|
|
<!-- Let's show Location History first -->
|
|
<div class="mb-4">
|
|
<strong class="d-block mb-3 text-primary"><i class="bi bi-geo-alt"></i> Placeringer</strong>
|
|
{% if locations %}
|
|
{% for loc in locations %}
|
|
<div class="timeline-item {% if not loc.end_date %}active{% endif %}">
|
|
<div class="timeline-marker"></div>
|
|
<div class="ms-2">
|
|
<div class="fw-bold">{{ loc.location_name or 'Ukendt' }} ({{ loc.start_date }} {% if loc.end_date %} - {{ loc.end_date }}{% else %}- nu{% endif %})</div>
|
|
{% if loc.notes %}<div class="text-muted small">{{ loc.notes }}</div>{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<p class="text-muted fst-italic">Ingen lokations historik</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<strong class="d-block mb-3 text-success"><i class="bi bi-person"></i> Ejerskab</strong>
|
|
{% if ownership %}
|
|
{% for own in ownership %}
|
|
<div class="timeline-item {% if not own.end_date %}active{% endif %}">
|
|
<div class="timeline-marker"></div>
|
|
<div class="ms-2">
|
|
<div class="fw-bold">{{ own.customer_name or own.owner_type }} ({{ own.start_date }} {% if own.end_date %} - {{ own.end_date }}{% else %}- nu{% endif %})</div>
|
|
{% if own.notes %}<div class="text-muted small">{{ own.notes }}</div>{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<p class="text-muted fst-italic">Ingen ejerskabs historik</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cases Tab -->
|
|
<div class="tab-pane fade" id="cases" role="tabpanel">
|
|
{% if cases and cases|length > 0 %}
|
|
<div class="list-group list-group-flush">
|
|
{% for case in cases %}
|
|
<a href="/sag/{{ case.case_id }}" class="list-group-item list-group-item-action py-3 px-2">
|
|
<div class="d-flex w-100 justify-content-between">
|
|
<h6 class="mb-1 text-primary">{{ case.titel }}</h6>
|
|
<small>{{ case.created_at }}</small>
|
|
</div>
|
|
<div class="d-flex justify-content-between align-items-center mt-1">
|
|
<small class="text-muted">Status: {{ case.status }}</small>
|
|
<span class="badge bg-light text-dark border">ID: {{ case.case_id }}</span>
|
|
</div>
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<div class="text-center py-5">
|
|
<i class="bi bi-clipboard-check display-4 text-muted opacity-25"></i>
|
|
<p class="mt-3 text-muted">Ingen sager tilknyttet.</p>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="alert('Opret Sag')">Opret ny sag</button>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Attachments Tab -->
|
|
<div class="tab-pane fade" id="files" role="tabpanel">
|
|
<div class="row g-3">
|
|
{% if attachments %}
|
|
{% for att in attachments %}
|
|
<div class="col-md-4 col-sm-6">
|
|
<div class="p-3 border rounded text-center bg-light h-100">
|
|
<div class="display-6 mb-2">📎</div>
|
|
<div class="text-truncate fw-bold">{{ att.file_name }}</div>
|
|
<div class="small text-muted">{{ att.uploaded_at }}</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<div class="col-12 text-center py-4 text-muted">
|
|
Ingen filer vedhæftet
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notes Tab -->
|
|
<div class="tab-pane fade" id="notes" role="tabpanel">
|
|
<div class="p-3 bg-light rounded border">
|
|
{% if hardware.notes %}
|
|
{{ hardware.notes }}
|
|
{% else %}
|
|
<span class="text-muted fst-italic">Ingen noter...</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal for Location -->
|
|
<div class="modal fade" id="locationModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Skift Lokation</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form action="/hardware/{{ hardware.id }}/location" method="post">
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label fw-bold">Vælg ny lokation</label>
|
|
<input type="text" class="form-control mb-2" id="locationSearchInput" placeholder="🔍 Søg efter lokation..." autocomplete="off">
|
|
|
|
<div class="list-group border rounded" id="locationList" style="max-height: 350px; overflow-y: auto; background: #fff;">
|
|
{% macro render_location_option(node, depth) %}
|
|
<div class="location-item-container" data-location-name="{{ node.name | lower }}">
|
|
<label class="list-group-item list-group-item-action cursor-pointer border-0 py-1 px-2" style="padding-left: {{ depth * 20 + 10 }}px !important;">
|
|
<div class="d-flex align-items-center">
|
|
{% if node.children %}
|
|
<i class="bi bi-caret-right-fill me-1 text-muted toggle-children" style="cursor: pointer; font-size: 0.8rem;" onclick="toggleLocationChildren(event, '{{ node.id }}')"></i>
|
|
{% else %}
|
|
<i class="bi bi-dot me-1 text-muted" style="width: 12px;"></i>
|
|
{% endif %}
|
|
|
|
<input class="form-check-input me-2 mt-0" type="radio" name="location_id" value="{{ node.id }}" required>
|
|
|
|
<div>
|
|
<span class="location-name fw-normal">{{ node.name }}</span>
|
|
<small class="text-muted ms-1" style="font-size: 0.75rem;">({{ node.location_type }})</small>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
|
|
{% if node.children %}
|
|
<div class="children-container collapsed" id="children-{{ node.id }}" style="display: none;">
|
|
{% for child in node.children %}
|
|
{{ render_location_option(child, depth + 1) }}
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
{% for node in location_tree %}
|
|
{{ render_location_option(node, 0) }}
|
|
{% endfor %}
|
|
|
|
<div id="noResults" class="p-3 text-center text-muted" style="display: none;">
|
|
Ingen lokationer fundet matching din søgning
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-text mt-2">
|
|
Kan du ikke finde lokationen? <a href="/locations" target="_blank" class="text-decoration-none"><i class="bi bi-plus-circle"></i> Opret ny lokation</a>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Noter til flytning</label>
|
|
<textarea class="form-control" name="notes" rows="3" placeholder="F.eks. Flyttet ifm. nyansættelse"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer bg-light">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|
<button type="submit" class="btn btn-primary">Gem Ændringer</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
// Tree Toggle Function
|
|
function toggleLocationChildren(event, nodeId) {
|
|
event.preventDefault();
|
|
event.stopPropagation(); // Prevent triggering the radio selection
|
|
|
|
const container = document.getElementById('children-' + nodeId);
|
|
const icon = event.target;
|
|
|
|
if (container.style.display === 'none') {
|
|
container.style.display = 'block';
|
|
icon.classList.remove('bi-caret-right-fill');
|
|
icon.classList.add('bi-caret-down-fill');
|
|
} else {
|
|
container.style.display = 'none';
|
|
icon.classList.remove('bi-caret-down-fill');
|
|
icon.classList.add('bi-caret-right-fill');
|
|
}
|
|
}
|
|
|
|
// Location Search Filter with Tree Support
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const searchInput = document.getElementById('locationSearchInput');
|
|
|
|
if (searchInput) {
|
|
searchInput.addEventListener('keyup', function() {
|
|
const filter = this.value.toLowerCase().trim();
|
|
const containers = document.querySelectorAll('.location-item-container');
|
|
let matchFound = false;
|
|
|
|
// Reset visualization if cleared
|
|
if (filter === "") {
|
|
// Show top-level only, collapse others check
|
|
document.querySelectorAll('.children-container').forEach(el => el.style.display = 'none');
|
|
document.querySelectorAll('.toggle-children').forEach(el => {
|
|
el.classList.remove('bi-caret-down-fill');
|
|
el.classList.add('bi-caret-right-fill');
|
|
});
|
|
containers.forEach(c => c.style.display = "");
|
|
document.getElementById('noResults').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// First pass: Find direct matches
|
|
containers.forEach(container => {
|
|
const name = container.getAttribute('data-location-name');
|
|
if (name.includes(filter)) {
|
|
container.classList.add('match');
|
|
matchFound = true;
|
|
} else {
|
|
container.classList.remove('match');
|
|
}
|
|
});
|
|
|
|
// Second pass: Show/Hide based on matches and hierarchy
|
|
containers.forEach(container => {
|
|
// If this container is a match, or contains a match inside it
|
|
const isMatch = container.classList.contains('match');
|
|
const hasChildMatch = container.querySelectorAll('.match').length > 0;
|
|
|
|
if (isMatch || hasChildMatch) {
|
|
container.style.display = 'block';
|
|
|
|
// If it has children that matched, expand it to show them
|
|
if (hasChildMatch) {
|
|
const childContainer = container.querySelector('.children-container');
|
|
if (childContainer) {
|
|
childContainer.style.display = 'block';
|
|
const toggle = container.querySelector('.toggle-children');
|
|
if (toggle) {
|
|
toggle.classList.remove('bi-caret-right-fill');
|
|
toggle.classList.add('bi-caret-down-fill');
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Hide if not a match and doesn't contain matches
|
|
// BUT be careful not to hide if a parent matched?
|
|
// Actually, search usually filters down. If parent matches, should we show all children?
|
|
// Let's stick to showing matches and path to matches.
|
|
|
|
// Important: logic is tricky with flat recursion vs nested DOM
|
|
// My macro structure is nested: .location-item-container contains children-container which contains .location-item-container
|
|
// So `container.style.display = 'block'` on a parent effectively shows the wrapper.
|
|
|
|
// If I am not a match, and I have no children that are matches...
|
|
// But wait, if my parent is a match, do I show up?
|
|
// Usually "Search" filters items out.
|
|
|
|
if (isMatch || hasChildMatch) {
|
|
container.style.display = 'block';
|
|
} else {
|
|
container.style.display = 'none';
|
|
}
|
|
}
|
|
});
|
|
|
|
document.getElementById('noResults').style.display = matchFound ? 'none' : 'block';
|
|
});
|
|
|
|
// Focus search field when modal opens
|
|
const locationModal = document.getElementById('locationModal');
|
|
locationModal.addEventListener('shown.bs.modal', function () {
|
|
searchInput.focus();
|
|
});
|
|
}
|
|
});
|
|
|
|
async function deleteHardware() {
|
|
if (!confirm('Er du sikker på at du vil slette dette hardware?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/hardware/{{ hardware.id }}', {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('Hardware slettet!');
|
|
window.location.href = '/hardware';
|
|
} else {
|
|
alert('Fejl ved sletning af hardware');
|
|
}
|
|
} catch (error) {
|
|
alert('Fejl ved sletning: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Initialize Tags
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if (window.renderEntityTags) {
|
|
window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags');
|
|
}
|
|
|
|
// Set default context for keyboard shortcuts (Option+Shift+T)
|
|
if (window.setTagPickerContext) {
|
|
window.setTagPickerContext('hardware', {{ hardware.id }}, () => window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags'));
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %} |