- Introduced Technician Dashboard V1 (tech_v1_overview.html) with KPI cards and new cases overview. - Implemented Technician Dashboard V2 (tech_v2_workboard.html) featuring a workboard layout for daily tasks and opportunities. - Developed Technician Dashboard V3 (tech_v3_table_focus.html) with a power table for detailed case management. - Created a dashboard selector page (technician_dashboard_selector.html) for easy navigation between dashboard versions. - Added user dashboard preferences migration (130_user_dashboard_preferences.sql) to store default dashboard paths. - Enhanced sag_sager table with assigned group ID (131_sag_assignment_group.sql) for better case management. - Updated sag_subscriptions table to include cancellation rules and billing dates (132_subscription_cancellation.sql, 134_subscription_billing_dates.sql). - Implemented subscription staging for CRM integration (136_simply_subscription_staging.sql). - Added a script to move time tracking section in detail view (move_time_section.py). - Created a test script for subscription processing (test_subscription_processing.py).
566 lines
25 KiB
HTML
566 lines
25 KiB
HTML
{% 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 %}
|