bmc_hub/app/modules/locations/templates/list.html

566 lines
25 KiB
HTML
Raw Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Lokaliteter - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<!-- 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="row mb-4">
<div class="col-12">
<h1 class="h2 fw-700 mb-2">Lokaliteter</h1>
<p class="text-muted small">Oversigt over alle lokationer og faciliteter</p>
</div>
</div>
<!-- Filter Card -->
<div class="card mb-4 border-0">
<div class="card-body">
<form id="filterForm" method="get" class="row g-3 align-items-end">
<div class="col-md-4">
<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 navn, hierarki eller by...">
</div>
</div>
<div class="col-md-4">
<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-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-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-funnel me-2"></i>Anvend filtre
</button>
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x-lg"></i>
</a>
</div>
</form>
</div>
</div>
<!-- Toolbar Section -->
<div class="row mb-3">
<div class="col-12 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex gap-2">
<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>
<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 class="text-muted small">
{% if total %}
Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
{% else %}
Ingen lokationer
{% endif %}
</div>
</div>
</div>
<!-- Main Content Section -->
<div class="card border-0">
<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') %}
<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="d-flex align-items-center" style="padding-left: {{ depth * 18 }}px;">
{% if node.children %}
<button type="button" class="btn btn-link btn-sm p-0 me-2 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="text-muted me-2"></span>
{% endif %}
<a href="/app/locations/{{ node.id }}" class="text-decoration-none fw-500">
{{ node.name }}
</a>
</div>
</td>
<td>
<span class="badge" style="background-color: {{ type_color }}; color: white;">
{{ 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="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Inaktiv</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary" title="Vis">
<i class="bi bi-eye"></i>
</a>
<a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary" title="Rediger">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-outline-danger 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="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>
{% 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 rows = Array.from(document.querySelectorAll('.location-row'));
const rowById = new Map();
const parentById = new Map();
const childrenById = new Map();
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();
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 (searchInput) {
let debounceTimer;
searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(applySearchFilter, 150);
});
}
// 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
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() {
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
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');
});
}
});
// Clickable rows
document.querySelectorAll('.location-row').forEach(row => {
row.addEventListener('click', function(e) {
// Don't navigate if clicking checkbox or action buttons
if (e.target.tagName === 'INPUT' || e.target.closest('.btn-group')) {
return;
}
const link = this.querySelector('a');
if (link) link.click();
});
});
});
</script>
{% endblock %}