941 lines
36 KiB
HTML
941 lines
36 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Lokaliteter - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.locations-list-page {
|
|
--loc-accent: #0f4c75;
|
|
--loc-accent-soft: rgba(15, 76, 117, 0.06);
|
|
--loc-border: rgba(15, 76, 117, 0.12);
|
|
--loc-surface: #f7f9fc;
|
|
}
|
|
|
|
.locations-list-page .locations-hero {
|
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
background: var(--bg-card);
|
|
border-radius: 0.9rem;
|
|
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.05);
|
|
}
|
|
|
|
.locations-list-page .stat-tile {
|
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
|
|
border: 1px solid rgba(15, 76, 117, 0.08);
|
|
border-radius: 0.8rem;
|
|
padding: 0.75rem 0.9rem;
|
|
}
|
|
|
|
.locations-list-page .section-label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
font-size: 0.76rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--loc-accent);
|
|
}
|
|
|
|
.locations-list-page .stat-tile .stat-value {
|
|
font-size: 1.2rem;
|
|
font-weight: 700;
|
|
color: var(--loc-accent);
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.locations-list-page .stat-tile .stat-note {
|
|
font-size: 0.76rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.locations-list-page .table thead th {
|
|
font-size: 0.76rem;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
font-weight: 700;
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
border-bottom: 1px solid rgba(15, 76, 117, 0.1);
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.locations-list-page .location-row {
|
|
transition: background-color 0.16s ease;
|
|
}
|
|
|
|
.locations-list-page .location-row:hover {
|
|
background-color: rgba(15, 76, 117, 0.03);
|
|
}
|
|
|
|
.locations-list-page .table > :not(caption) > * > * {
|
|
padding-top: 0.95rem;
|
|
padding-bottom: 0.95rem;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.locations-list-page .table tbody tr:last-child td {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.locations-list-page .toggle-row,
|
|
.locations-list-page .location-tree-spacer {
|
|
color: var(--loc-accent);
|
|
width: 1.2rem;
|
|
display: inline-flex;
|
|
justify-content: center;
|
|
flex: 0 0 1.2rem;
|
|
}
|
|
|
|
.locations-list-page .filter-shell,
|
|
.locations-list-page .content-shell {
|
|
background: var(--bg-card);
|
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
border-radius: 0.9rem;
|
|
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.04);
|
|
}
|
|
|
|
.locations-list-page .filter-shell {
|
|
padding: 1.05rem 1.1rem;
|
|
}
|
|
|
|
.locations-list-page .content-shell-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
padding: 1rem 1.1rem;
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
|
}
|
|
|
|
.locations-list-page .content-shell-toolbar,
|
|
.locations-list-page .filter-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.locations-list-page .content-shell-title {
|
|
font-size: 0.78rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
font-weight: 700;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.locations-list-page .content-shell-subtitle {
|
|
font-size: 0.92rem;
|
|
color: var(--text-primary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.locations-list-page .content-shell-subtitle span {
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.locations-list-page .location-name-link {
|
|
color: var(--text-primary);
|
|
font-weight: 600;
|
|
font-size: 0.98rem;
|
|
}
|
|
|
|
.locations-list-page .location-name-link:hover {
|
|
color: var(--loc-accent);
|
|
}
|
|
|
|
.locations-list-page .location-primary {
|
|
min-width: 0;
|
|
}
|
|
|
|
.locations-list-page .location-summary {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.7rem;
|
|
min-width: 0;
|
|
}
|
|
|
|
.locations-list-page .location-indent {
|
|
display: inline-flex;
|
|
align-items: stretch;
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.locations-list-page .location-name-block {
|
|
min-width: 0;
|
|
}
|
|
|
|
.locations-list-page .location-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.4rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.locations-list-page .location-meta-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
padding: 0.18rem 0.5rem;
|
|
border-radius: 999px;
|
|
background: rgba(15, 76, 117, 0.07);
|
|
color: #2d5670;
|
|
font-size: 0.73rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.locations-list-page .type-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.32rem 0.62rem;
|
|
border-radius: 999px;
|
|
font-size: 0.74rem;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
color: #fff;
|
|
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.18);
|
|
}
|
|
|
|
.locations-list-page .status-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
padding: 0.28rem 0.55rem;
|
|
border-radius: 999px;
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.locations-list-page .status-pill.active {
|
|
background: rgba(25, 135, 84, 0.12);
|
|
color: #146c43;
|
|
}
|
|
|
|
.locations-list-page .status-pill.inactive {
|
|
background: rgba(108, 117, 125, 0.12);
|
|
color: #495057;
|
|
}
|
|
|
|
.locations-list-page .shortcut-hint {
|
|
border: 1px dashed rgba(0, 0, 0, 0.12);
|
|
border-radius: 0.55rem;
|
|
padding: 0.3rem 0.45rem;
|
|
font-size: 0.72rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.locations-list-page .hero-actions {
|
|
align-items: center;
|
|
}
|
|
|
|
.locations-list-page .list-status-note {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
padding: 0.4rem 0.65rem;
|
|
border-radius: 999px;
|
|
background: var(--loc-surface);
|
|
color: var(--text-secondary);
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.locations-list-page .actions-inline {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
flex-wrap: nowrap;
|
|
}
|
|
|
|
.locations-list-page .actions-inline .btn {
|
|
border-radius: 0.7rem;
|
|
}
|
|
|
|
.locations-list-page .empty-state-soft {
|
|
max-width: 420px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
@media (max-width: 767.98px) {
|
|
.locations-list-page {
|
|
padding-left: 0.5rem;
|
|
padding-right: 0.5rem;
|
|
}
|
|
|
|
.locations-list-page .stat-tile { padding: 0.65rem 0.75rem; }
|
|
.locations-list-page .content-shell-header { align-items: flex-start; flex-direction: column; }
|
|
.locations-list-page .content-shell-toolbar,
|
|
.locations-list-page .filter-toolbar { align-items: flex-start; flex-direction: column; }
|
|
.locations-list-page .hero-actions { width: 100%; }
|
|
.locations-list-page .hero-actions .btn { flex: 1 1 auto; }
|
|
.locations-list-page .location-summary { gap: 0.55rem; }
|
|
.locations-list-page .location-name-link { font-size: 0.94rem; }
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid px-4 py-4 locations-list-page">
|
|
<!-- Breadcrumb -->
|
|
<nav aria-label="breadcrumb" class="mb-4">
|
|
<ol class="breadcrumb">
|
|
<li class="breadcrumb-item"><a href="/" class="text-decoration-none">Hjem</a></li>
|
|
<li class="breadcrumb-item active">Lokaliteter</li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<!-- Header Section -->
|
|
<div class="locations-hero p-3 p-lg-4 mb-4">
|
|
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
|
<div>
|
|
<div class="section-label mb-2">
|
|
<i class="bi bi-diagram-3"></i>
|
|
Lokationsstruktur
|
|
</div>
|
|
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
|
|
<p class="text-muted small mb-0">Få overblik over steder, underlokationer og status uden at miste hierarkiet.</p>
|
|
</div>
|
|
<div class="d-flex gap-2 flex-wrap hero-actions">
|
|
<a href="/app/locations/create" class="btn btn-primary btn-sm">
|
|
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
|
</a>
|
|
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
|
|
<i class="bi bi-diagram-3 me-2"></i>Wizard
|
|
</a>
|
|
<a href="/app/locations/outlets" class="btn btn-outline-secondary btn-sm">
|
|
<i class="bi bi-ethernet me-2"></i>Vægstik
|
|
</a>
|
|
<span class="shortcut-hint">Tip: Tryk / for søgning</span>
|
|
</div>
|
|
</div>
|
|
<div class="row g-2 mt-2">
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-tile">
|
|
<div class="small text-muted">Total</div>
|
|
<div class="stat-value" id="statTotal">{{ total or 0 }}</div>
|
|
<div class="stat-note">Alle registrerede lokationer</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-tile">
|
|
<div class="small text-muted">Aktive</div>
|
|
<div class="stat-value" id="statActive">0</div>
|
|
<div class="stat-note">Klar til daglig brug</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-tile">
|
|
<div class="small text-muted">Inaktive</div>
|
|
<div class="stat-value" id="statInactive">0</div>
|
|
<div class="stat-note">Skjulte eller lukkede</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-tile">
|
|
<div class="small text-muted">Synlige nu</div>
|
|
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
|
|
<div class="stat-note">Efter søgning og foldning</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filter Card -->
|
|
<div class="filter-shell mb-4">
|
|
<div class="filter-toolbar mb-3">
|
|
<div>
|
|
<div class="content-shell-title">Filtre</div>
|
|
<div class="text-muted small">Søg i navn og by, eller afgræns efter type og status.</div>
|
|
</div>
|
|
<div class="list-status-note">
|
|
<i class="bi bi-lightning-charge"></i>
|
|
Hierarkiet kan foldes direkte i listen
|
|
</div>
|
|
</div>
|
|
<form id="filterForm" method="get" class="row g-3 align-items-end">
|
|
<div class="col-lg-5">
|
|
<label for="locationSearch" class="form-label small text-muted">Søg</label>
|
|
<div class="input-group">
|
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
|
<input type="text" class="form-control" id="locationSearch" placeholder="Søg efter lokation eller by...">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-4 col-lg-3">
|
|
<label for="locationTypeFilter" class="form-label small text-muted">Type</label>
|
|
<select class="form-select" id="locationTypeFilter" name="location_type">
|
|
<option value="">Alle typer</option>
|
|
{% if location_types %}
|
|
{% for type_option in location_types %}
|
|
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
|
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
|
<option value="{{ option_value }}" {% if location_type == option_value %}selected{% endif %}>
|
|
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
|
</option>
|
|
{% endfor %}
|
|
{% endif %}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="col-md-3 col-lg-2">
|
|
<label for="statusFilter" class="form-label small text-muted">Status</label>
|
|
<select class="form-select" id="statusFilter" name="is_active">
|
|
<option value="">Alle</option>
|
|
<option value="true" {% if is_active == True %}selected{% endif %}>Aktive</option>
|
|
<option value="false" {% if is_active == False %}selected{% endif %}>Inaktive</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="col-md-5 col-lg-2 d-flex gap-2">
|
|
<button type="submit" class="btn btn-primary btn-sm w-100">
|
|
<i class="bi bi-funnel me-2"></i>Filtrer
|
|
</button>
|
|
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
|
|
<i class="bi bi-arrow-counterclockwise"></i>
|
|
</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Main Content Section -->
|
|
<div class="content-shell">
|
|
<div class="content-shell-header">
|
|
<div class="content-shell-toolbar w-100">
|
|
<div>
|
|
<div class="content-shell-title">Lokationsliste</div>
|
|
<div class="content-shell-subtitle">
|
|
{% if total %}
|
|
Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
|
|
<span>med tydeligt hierarki og status</span>
|
|
{% else %}
|
|
Ingen lokationer endnu
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-2 flex-wrap">
|
|
<span class="list-status-note">
|
|
<i class="bi bi-check2-circle"></i>
|
|
Klik kun på navn eller handlinger for at åbne
|
|
</span>
|
|
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
|
|
<i class="bi bi-trash me-2"></i>Slet valgte
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="table-responsive">
|
|
{% if location_tree %}
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width: 40px;">
|
|
<input type="checkbox" class="form-check-input" id="selectAllCheckbox">
|
|
</th>
|
|
<th>Navn</th>
|
|
<th>Type</th>
|
|
<th class="d-none d-md-table-cell">By</th>
|
|
<th>Status</th>
|
|
<th style="width: 120px;">Handlinger</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% macro render_row(node, depth, parent_id=None) %}
|
|
{% set type_label = {
|
|
'kompleks': 'Kompleks',
|
|
'bygning': 'Bygning',
|
|
'etage': 'Etage',
|
|
'customer_site': 'Kundesite',
|
|
'rum': 'Rum',
|
|
'kantine': 'Kantine',
|
|
'moedelokale': 'Mødelokale',
|
|
'vehicle': 'Køretøj'
|
|
}.get(node.location_type, node.location_type) %}
|
|
|
|
{% set type_color = {
|
|
'kompleks': '#0f4c75',
|
|
'bygning': '#1abc9c',
|
|
'etage': '#3498db',
|
|
'customer_site': '#9b59b6',
|
|
'rum': '#e67e22',
|
|
'kantine': '#d35400',
|
|
'moedelokale': '#16a085',
|
|
'vehicle': '#8e44ad'
|
|
}.get(node.location_type, '#6c757d') %}
|
|
|
|
{% set child_count = node.children|length if node.children else 0 %}
|
|
|
|
<tr class="location-row{% if node.children %} has-children{% endif %}" data-location-id="{{ node.id }}" data-parent-id="{{ parent_id if parent_id else '' }}" data-depth="{{ depth }}" data-has-children="{{ 'true' if node.children else 'false' }}">
|
|
<td>
|
|
<input type="checkbox" class="form-check-input location-checkbox" value="{{ node.id }}">
|
|
</td>
|
|
<td>
|
|
<div class="location-summary">
|
|
<div class="location-indent" style="padding-left: {{ depth * 18 }}px;">
|
|
{% if node.children %}
|
|
<button type="button" class="btn btn-link btn-sm p-0 toggle-row" data-target-id="{{ node.id }}" aria-expanded="false" title="Fold ud/ind">
|
|
<i class="bi bi-caret-right-fill"></i>
|
|
</button>
|
|
{% else %}
|
|
<span class="location-tree-spacer text-muted">•</span>
|
|
{% endif %}
|
|
</div>
|
|
<div class="location-name-block">
|
|
<a href="/app/locations/{{ node.id }}" class="text-decoration-none location-name-link">
|
|
{{ node.name }}
|
|
</a>
|
|
<div class="location-meta">
|
|
{% if depth > 0 %}
|
|
<span class="location-meta-chip"><i class="bi bi-diagram-3"></i>Niveau {{ depth + 1 }}</span>
|
|
{% endif %}
|
|
{% if child_count %}
|
|
<span class="location-meta-chip"><i class="bi bi-collection"></i>{{ child_count }} underlokationer</span>
|
|
{% endif %}
|
|
{% if node.address_city %}
|
|
<span class="location-meta-chip"><i class="bi bi-geo-alt"></i>{{ node.address_city }}</span>
|
|
{% endif %}
|
|
<span class="location-meta-chip"><i class="bi bi-hash"></i>ID {{ node.id }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span class="type-pill" style="background-color: {{ type_color }};">
|
|
{{ type_label }}
|
|
</span>
|
|
</td>
|
|
<td class="d-none d-md-table-cell text-muted small">
|
|
{{ node.address_city | default('—') }}
|
|
</td>
|
|
<td>
|
|
{% if node.is_active %}
|
|
<span class="status-pill active"><span>●</span>Aktiv</span>
|
|
{% else %}
|
|
<span class="status-pill inactive"><span>●</span>Inaktiv</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<div class="actions-inline" role="group" aria-label="Handlinger">
|
|
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary btn-sm" title="Vis">
|
|
<i class="bi bi-eye"></i>
|
|
</a>
|
|
<a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary btn-sm" title="Rediger">
|
|
<i class="bi bi-pencil"></i>
|
|
</a>
|
|
<button type="button" class="btn btn-outline-danger btn-sm delete-location-btn"
|
|
data-location-id="{{ node.id }}"
|
|
data-location-name="{{ node.name }}"
|
|
title="Slet">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% if node.children %}
|
|
{% for child in node.children %}
|
|
{{ render_row(child, depth + 1, node.id) }}
|
|
{% endfor %}
|
|
{% endif %}
|
|
{% endmacro %}
|
|
|
|
{% for node in location_tree %}
|
|
{{ render_row(node, 0) }}
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<!-- Empty State -->
|
|
<div class="text-center py-5">
|
|
<div class="empty-state-soft">
|
|
<div class="mb-3">
|
|
<i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i>
|
|
</div>
|
|
<h5 class="text-muted">Ingen lokationer endnu</h5>
|
|
<p class="text-muted small mb-3">Opret din første lokation ved at klikke på knappen nedenfor</p>
|
|
<a href="/app/locations/create" class="btn btn-primary btn-sm">
|
|
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination Section -->
|
|
{% if total_pages and total_pages > 1 %}
|
|
<nav aria-label="Page navigation" class="mt-4">
|
|
<ul class="pagination justify-content-center">
|
|
{% if page_number > 1 %}
|
|
<li class="page-item">
|
|
<a class="page-link" href="?skip={{ (page_number - 2) * limit }}&limit={{ limit }}{% if location_type %}&location_type={{ location_type }}{% endif %}{% if is_active is not none %}&is_active={{ is_active }}{% endif %}">
|
|
<i class="bi bi-chevron-left"></i> Forrige
|
|
</a>
|
|
</li>
|
|
{% else %}
|
|
<li class="page-item disabled">
|
|
<span class="page-link"><i class="bi bi-chevron-left"></i> Forrige</span>
|
|
</li>
|
|
{% endif %}
|
|
|
|
{% for page_num in range(1, total_pages + 1) %}
|
|
{% if page_num == page_number %}
|
|
<li class="page-item active">
|
|
<span class="page-link">{{ page_num }}</span>
|
|
</li>
|
|
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 1 and page_num <= page_number + 1) %}
|
|
<li class="page-item">
|
|
<a class="page-link" href="?skip={{ (page_num - 1) * limit }}&limit={{ limit }}{% if location_type %}&location_type={{ location_type }}{% endif %}{% if is_active is not none %}&is_active={{ is_active }}{% endif %}">
|
|
{{ page_num }}
|
|
</a>
|
|
</li>
|
|
{% elif page_num == page_number - 2 or page_num == page_number + 2 %}
|
|
<li class="page-item disabled">
|
|
<span class="page-link">...</span>
|
|
</li>
|
|
{% endif %}
|
|
{% endfor %}
|
|
|
|
{% if page_number < total_pages %}
|
|
<li class="page-item">
|
|
<a class="page-link" href="?skip={{ page_number * limit }}&limit={{ limit }}{% if location_type %}&location_type={{ location_type }}{% endif %}{% if is_active is not none %}&is_active={{ is_active }}{% endif %}">
|
|
Næste <i class="bi bi-chevron-right"></i>
|
|
</a>
|
|
</li>
|
|
{% else %}
|
|
<li class="page-item disabled">
|
|
<span class="page-link">Næste <i class="bi bi-chevron-right"></i></span>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</nav>
|
|
<div class="text-center text-muted small mt-3">
|
|
Side {{ page_number }} af {{ total_pages }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-bottom-0">
|
|
<h5 class="modal-title">Slet lokation?</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="mb-0">Er du sikker på, at du vil slette <strong id="deleteLocationName"></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 extra_js %}
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const searchInput = document.getElementById('locationSearch');
|
|
const visibleCount = document.getElementById('visibleCount');
|
|
const statTotal = document.getElementById('statTotal');
|
|
const statActive = document.getElementById('statActive');
|
|
const statInactive = document.getElementById('statInactive');
|
|
const statVisible = document.getElementById('statVisible');
|
|
const rows = Array.from(document.querySelectorAll('.location-row'));
|
|
|
|
const rowById = new Map();
|
|
const parentById = new Map();
|
|
const childrenById = new Map();
|
|
|
|
function updateSummaryStats() {
|
|
const total = rows.length;
|
|
const active = rows.filter(row => {
|
|
const statusText = row.querySelector('td:nth-child(5)')?.innerText?.toLowerCase() || '';
|
|
return statusText.includes('aktiv') && !statusText.includes('inaktiv');
|
|
}).length;
|
|
const inactive = Math.max(total - active, 0);
|
|
const visible = rows.filter(row => !row.classList.contains('d-none')).length;
|
|
|
|
if (statTotal) statTotal.textContent = String(total);
|
|
if (statActive) statActive.textContent = String(active);
|
|
if (statInactive) statInactive.textContent = String(inactive);
|
|
if (statVisible) statVisible.textContent = String(visible);
|
|
if (visibleCount) visibleCount.textContent = String(visible);
|
|
}
|
|
|
|
rows.forEach(row => {
|
|
const id = row.getAttribute('data-location-id');
|
|
const parentId = row.getAttribute('data-parent-id') || null;
|
|
if (id) {
|
|
rowById.set(id, row);
|
|
parentById.set(id, parentId);
|
|
if (parentId) {
|
|
const list = childrenById.get(parentId) || [];
|
|
list.push(id);
|
|
childrenById.set(parentId, list);
|
|
}
|
|
}
|
|
});
|
|
|
|
function updateToggleIcon(row, expanded) {
|
|
const icon = row.querySelector('.toggle-row i');
|
|
if (!icon) return;
|
|
icon.className = expanded ? 'bi bi-caret-down-fill' : 'bi bi-caret-right-fill';
|
|
const btn = row.querySelector('.toggle-row');
|
|
if (btn) {
|
|
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
}
|
|
}
|
|
|
|
function hideDescendants(rootId) {
|
|
const stack = (childrenById.get(rootId) || []).slice();
|
|
while (stack.length) {
|
|
const childId = stack.pop();
|
|
const childRow = rowById.get(childId);
|
|
if (childRow) {
|
|
childRow.classList.add('d-none');
|
|
updateToggleIcon(childRow, false);
|
|
}
|
|
const grandchildren = childrenById.get(childId) || [];
|
|
stack.push(...grandchildren);
|
|
}
|
|
}
|
|
|
|
function showDescendants(rootId) {
|
|
const stack = (childrenById.get(rootId) || []).slice();
|
|
while (stack.length) {
|
|
const childId = stack.pop();
|
|
const childRow = rowById.get(childId);
|
|
if (childRow) {
|
|
childRow.classList.remove('d-none');
|
|
}
|
|
const grandchildren = childrenById.get(childId) || [];
|
|
stack.push(...grandchildren);
|
|
}
|
|
}
|
|
|
|
function collapseAll() {
|
|
rows.forEach(row => {
|
|
const depth = parseInt(row.getAttribute('data-depth') || '0', 10);
|
|
if (depth > 0) {
|
|
row.classList.add('d-none');
|
|
}
|
|
updateToggleIcon(row, false);
|
|
});
|
|
}
|
|
|
|
function showAncestors(id) {
|
|
let current = parentById.get(id);
|
|
while (current) {
|
|
const row = rowById.get(current);
|
|
if (row) {
|
|
row.classList.remove('d-none');
|
|
updateToggleIcon(row, true);
|
|
}
|
|
current = parentById.get(current);
|
|
}
|
|
}
|
|
|
|
collapseAll();
|
|
updateSummaryStats();
|
|
|
|
function toggleNode(targetId) {
|
|
const row = rowById.get(targetId);
|
|
if (!row) return;
|
|
|
|
const btn = row.querySelector('.toggle-row');
|
|
const expanded = btn?.getAttribute('aria-expanded') === 'true';
|
|
if (expanded) {
|
|
hideDescendants(targetId);
|
|
updateToggleIcon(row, false);
|
|
} else {
|
|
showDescendants(targetId);
|
|
updateToggleIcon(row, true);
|
|
}
|
|
}
|
|
|
|
document.querySelectorAll('.toggle-row').forEach(btn => {
|
|
btn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const targetId = this.getAttribute('data-target-id');
|
|
toggleNode(targetId);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.location-row.has-children').forEach(row => {
|
|
row.addEventListener('click', function(e) {
|
|
const target = e.target;
|
|
if (target.closest('a') || target.closest('button') || target.closest('input')) {
|
|
return;
|
|
}
|
|
const targetId = row.getAttribute('data-location-id');
|
|
toggleNode(targetId);
|
|
});
|
|
});
|
|
|
|
function applySearchFilter() {
|
|
const query = (searchInput?.value || '').trim().toLowerCase();
|
|
let visible = 0;
|
|
|
|
if (!query) {
|
|
collapseAll();
|
|
}
|
|
|
|
rows.forEach(row => {
|
|
const name = row.querySelector('td:nth-child(2)')?.innerText || '';
|
|
const city = row.querySelector('td:nth-child(4)')?.innerText || '';
|
|
const haystack = `${name} ${city}`.toLowerCase();
|
|
|
|
if (!query) {
|
|
if (!row.classList.contains('d-none')) {
|
|
visible += 1;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (haystack.includes(query)) {
|
|
row.classList.remove('d-none');
|
|
showAncestors(row.getAttribute('data-location-id'));
|
|
visible += 1;
|
|
} else {
|
|
row.classList.add('d-none');
|
|
}
|
|
});
|
|
|
|
if (visibleCount) {
|
|
visibleCount.textContent = String(visible);
|
|
}
|
|
if (statVisible) {
|
|
statVisible.textContent = String(visible);
|
|
}
|
|
}
|
|
|
|
if (searchInput) {
|
|
let debounceTimer;
|
|
searchInput.addEventListener('input', function() {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(applySearchFilter, 150);
|
|
});
|
|
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === '/' && document.activeElement !== searchInput) {
|
|
e.preventDefault();
|
|
searchInput.focus();
|
|
searchInput.select();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Select all checkbox
|
|
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
|
const locationCheckboxes = document.querySelectorAll('.location-checkbox');
|
|
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
|
|
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
|
let currentDeleteId = null;
|
|
|
|
// Select all functionality
|
|
if (selectAllCheckbox) {
|
|
selectAllCheckbox.addEventListener('change', function() {
|
|
locationCheckboxes.forEach(checkbox => {
|
|
checkbox.checked = this.checked;
|
|
});
|
|
updateBulkDeleteButton();
|
|
});
|
|
}
|
|
|
|
// Individual checkbox functionality
|
|
locationCheckboxes.forEach(checkbox => {
|
|
checkbox.addEventListener('change', function() {
|
|
updateBulkDeleteButton();
|
|
updateSelectAllCheckbox();
|
|
});
|
|
});
|
|
|
|
function updateBulkDeleteButton() {
|
|
const selectedCount = document.querySelectorAll('.location-checkbox:checked').length;
|
|
bulkDeleteBtn.disabled = selectedCount === 0;
|
|
if (selectedCount > 0) {
|
|
bulkDeleteBtn.innerHTML = `<i class="bi bi-trash me-2"></i>Slet valgte (${selectedCount})`;
|
|
}
|
|
}
|
|
|
|
function updateSelectAllCheckbox() {
|
|
if (!selectAllCheckbox) return;
|
|
const allChecked = Array.from(locationCheckboxes).every(cb => cb.checked);
|
|
const someChecked = Array.from(locationCheckboxes).some(cb => cb.checked);
|
|
selectAllCheckbox.checked = allChecked;
|
|
selectAllCheckbox.indeterminate = someChecked && !allChecked;
|
|
}
|
|
|
|
// Delete individual location
|
|
document.querySelectorAll('.delete-location-btn').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
currentDeleteId = this.getAttribute('data-location-id');
|
|
const locationName = this.getAttribute('data-location-name');
|
|
document.getElementById('deleteLocationName').textContent = locationName;
|
|
deleteModal.show();
|
|
});
|
|
});
|
|
|
|
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
|
if (currentDeleteId) {
|
|
fetch(`/api/v1/locations/${currentDeleteId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
.then(async response => {
|
|
if (response.ok) {
|
|
deleteModal.hide();
|
|
setTimeout(() => location.reload(), 300);
|
|
} else {
|
|
const err = await response.json().catch(() => ({}));
|
|
if (response.status === 404) {
|
|
alert(err.detail || 'Lokationen er allerede slettet. Siden opdateres.');
|
|
setTimeout(() => location.reload(), 300);
|
|
return;
|
|
}
|
|
alert(err.detail || 'Fejl ved sletning af lokation');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('Fejl ved sletning af lokation');
|
|
});
|
|
}
|
|
});
|
|
|
|
// Bulk delete
|
|
if (bulkDeleteBtn) {
|
|
bulkDeleteBtn.addEventListener('click', function() {
|
|
const selectedIds = Array.from(locationCheckboxes)
|
|
.filter(cb => cb.checked)
|
|
.map(cb => cb.value);
|
|
|
|
if (selectedIds.length === 0) return;
|
|
|
|
if (confirm(`Slet ${selectedIds.length} lokation(er)? Dette kan ikke fortrydes.`)) {
|
|
Promise.all(selectedIds.map(id =>
|
|
fetch(`/api/v1/locations/${id}`, { method: 'DELETE' })
|
|
))
|
|
.then(() => location.reload())
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('Fejl ved sletning af lokationer');
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
});
|
|
</script>
|
|
{% endblock %}
|