feat: enhance tag management and search functionality

- Updated the index.html template to include a new column for "Næste todo" in the sag table.
- Added new JavaScript functions to load and manage case statuses in settings.html, including normalization and rendering of statuses.
- Introduced a new tag search feature in tags_admin.html, allowing users to filter tags by name, type, and module with pagination support.
- Enhanced the backend router.py to include a new endpoint for listing tag usage across modules with server-side filtering and pagination.
- Improved the overall UI and UX of the tag administration page, including responsive design adjustments and better error handling.
This commit is contained in:
Christian 2026-03-20 18:43:45 +01:00
parent 92b888b78f
commit a8eaf6e2a9
9 changed files with 1211 additions and 304 deletions

View File

@ -1,6 +1,7 @@
import logging import logging
import os import os
import shutil import shutil
import json
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
@ -50,15 +51,64 @@ def _get_user_id_from_request(request: Request) -> int:
def _normalize_case_status(status_value: Optional[str]) -> str: def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = []
seen = set()
def _add_status(value: Optional[str]) -> None:
candidate = str(value or "").strip()
if not candidate:
return
key = candidate.lower()
if key in seen:
return
seen.add(key)
allowed_statuses.append(candidate)
try:
setting_row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("case_statuses",))
if setting_row and setting_row.get("value"):
parsed = json.loads(setting_row.get("value") or "[]")
for item in parsed if isinstance(parsed, list) else []:
if isinstance(item, str):
value = item.strip()
elif isinstance(item, dict):
value = str(item.get("value") or "").strip()
else:
value = ""
_add_status(value)
except Exception:
pass
# Include historical/current DB statuses so legacy values remain valid
try:
rows = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
for row in rows:
_add_status(row.get("status"))
except Exception:
pass
if not allowed_statuses:
allowed_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
allowed_map = {s.lower(): s for s in allowed_statuses}
if not status_value: if not status_value:
return "åben" return allowed_map.get("åben", allowed_statuses[0])
normalized = str(status_value).strip().lower() normalized = str(status_value).strip().lower()
if normalized == "afventer": if normalized in allowed_map:
return "åben" return allowed_map[normalized]
if normalized in {"åben", "lukket"}:
return normalized # Backward compatibility for legacy mapping
return "åben" if normalized == "afventer" and "åben" in allowed_map:
return allowed_map["åben"]
# Do not force unknown values back to default; preserve user-entered/custom DB values
raw_value = str(status_value).strip()
if raw_value:
return raw_value
return allowed_map.get("åben", allowed_statuses[0])
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]: def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:

View File

@ -1,4 +1,5 @@
import logging import logging
import json
from datetime import date, datetime from datetime import date, datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
@ -56,6 +57,50 @@ def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
return None return None
def _fetch_case_status_options() -> list[str]:
default_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
values = []
seen = set()
def _add(value: Optional[str]) -> None:
candidate = str(value or "").strip()
if not candidate:
return
key = candidate.lower()
if key in seen:
return
seen.add(key)
values.append(candidate)
setting_row = execute_query(
"SELECT value FROM settings WHERE key = %s",
("case_statuses",)
)
if setting_row and setting_row[0].get("value"):
try:
parsed = json.loads(setting_row[0].get("value") or "[]")
for item in parsed if isinstance(parsed, list) else []:
value = ""
if isinstance(item, str):
value = item.strip()
elif isinstance(item, dict):
value = str(item.get("value") or "").strip()
_add(value)
except Exception:
pass
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
for row in statuses:
_add(row.get("status"))
for default in default_statuses:
_add(default)
return values
@router.get("/sag", response_class=HTMLResponse) @router.get("/sag", response_class=HTMLResponse)
async def sager_liste( async def sager_liste(
request: Request, request: Request,
@ -77,7 +122,9 @@ async def sager_liste(
c.name as customer_name, c.name as customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn, CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn, COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name g.name AS assigned_group_name,
nt.title AS next_todo_title,
nt.due_date AS next_todo_due_date
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
@ -90,6 +137,22 @@ async def sager_liste(
LIMIT 1 LIMIT 1
) cc_first ON true ) cc_first ON true
LEFT JOIN contacts cont ON cc_first.contact_id = cont.id LEFT JOIN contacts cont ON cc_first.contact_id = cont.id
LEFT JOIN LATERAL (
SELECT t.title, t.due_date
FROM sag_todo_steps t
WHERE t.sag_id = s.id
AND t.deleted_at IS NULL
AND t.is_done = FALSE
ORDER BY
CASE
WHEN t.is_next THEN 0
WHEN t.due_date IS NOT NULL THEN 1
ELSE 2
END,
t.due_date ASC NULLS LAST,
t.created_at ASC
LIMIT 1
) nt ON true
LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
""" """
@ -162,29 +225,11 @@ async def sager_liste(
sager = [s for s in sager if s['id'] in tagged_ids] sager = [s for s in sager if s['id'] in tagged_ids]
# Fetch all distinct statuses and tags for filters # Fetch all distinct statuses and tags for filters
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) status_options = _fetch_case_status_options()
status_options = []
seen_statuses = set()
for row in statuses or []: current_status = str(status or "").strip()
status_value = str(row.get("status") or "").strip() if current_status and current_status.lower() not in {s.lower() for s in status_options}:
if not status_value:
continue
key = status_value.lower()
if key in seen_statuses:
continue
seen_statuses.add(key)
status_options.append(status_value)
current_status = str(sag.get("status") or "").strip()
if current_status and current_status.lower() not in seen_statuses:
seen_statuses.add(current_status.lower())
status_options.append(current_status) status_options.append(current_status)
for default_status in ["åben", "under behandling", "afventer", "løst", "lukket"]:
if default_status.lower() not in seen_statuses:
seen_statuses.add(default_status.lower())
status_options.append(default_status)
all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ()) all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ())
toggle_include_deferred_url = str( toggle_include_deferred_url = str(
@ -196,7 +241,7 @@ async def sager_liste(
"sager": sager, "sager": sager,
"relations_map": relations_map, "relations_map": relations_map,
"child_ids": list(child_ids), "child_ids": list(child_ids),
"statuses": [s['status'] for s in statuses], "statuses": status_options,
"all_tags": [t['tag_navn'] for t in all_tags], "all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status, "current_status": status,
"current_tag": tag, "current_tag": tag,
@ -473,7 +518,10 @@ async def sag_detaljer(request: Request, sag_id: int):
logger.warning("⚠️ Could not load pipeline stages: %s", e) logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = [] pipeline_stages = []
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) status_options = _fetch_case_status_options()
current_status = str(sag.get("status") or "").strip()
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline")) is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
return templates.TemplateResponse("modules/sag/templates/detail.html", { return templates.TemplateResponse("modules/sag/templates/detail.html", {

View File

@ -33,7 +33,7 @@ class RelationService:
# 2. Fetch details for these cases # 2. Fetch details for these cases
placeholders = ','.join(['%s'] * len(tree_ids)) placeholders = ','.join(['%s'] * len(tree_ids))
tree_cases_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders})" tree_cases_query = f"SELECT id, titel, status, type, template_key FROM sag_sager WHERE id IN ({placeholders})"
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))} tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
# 3. Fetch all edges between these cases # 3. Fetch all edges between these cases

View File

@ -1984,86 +1984,6 @@
</div> </div>
</div> </div>
</div></div> </div></div>
<div class="mb-3"><div class="card h-100 d-flex flex-column right-module-card" data-module="customers" data-has-content="{{ 'true' if customers else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">🏢 Kunder</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('customer')">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto" id="customers-container" style="max-height: 180px;">
{% if customers %}
<div class="customer-list-header">
<span>Navn</span>
<span>Rolle</span>
<span>E-mail</span>
<span>Slet</span>
</div>
{% for customer in customers %}
<div class="customer-row">
<div>
<a href="/customers/{{ customer.customer_id }}" class="text-decoration-none fw-semibold">
{{ customer.customer_name }}
</a>
</div>
<small>{{ customer.role or '-' }}</small>
<small>{{ customer.customer_email or '-' }}</small>
<button onclick="removeCustomer({{ case.id }}, {{ customer.customer_id }})" class="btn btn-sm btn-delete" title="Slet"></button>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">Ingen kunder</p>
{% endif %}
</div>
</div></div>
<div class="mb-3"><div class="card h-100 d-flex flex-column right-module-card" data-module="contacts" data-has-content="{{ 'true' if contacts else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">👥 Kontakter</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('contact')">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto" id="contacts-container" style="max-height: 180px;">
{% if contacts %}
<div class="contact-list-header">
<span>Navn</span>
<span>Titel</span>
<span>Kunde</span>
<span>Slet</span>
</div>
{% for contact in contacts %}
<div
class="contact-row"
role="button"
tabindex="0"
onclick="showContactInfoModal(this)"
data-contact-id="{{ contact.contact_id }}"
data-name="{{ contact.contact_name|replace('"', '&quot;') }}"
data-title="{{ contact.title|default('', true)|replace('"', '&quot;') }}"
data-company="{{ contact.customer_name|default('', true)|replace('"', '&quot;') }}"
data-email="{{ contact.contact_email|default('', true)|replace('"', '&quot;') }}"
data-phone="{{ contact.phone|default('', true)|replace('"', '&quot;') }}"
data-mobile="{{ contact.mobile|default('', true)|replace('"', '&quot;') }}"
data-role="{{ contact.role|default('Kontakt')|replace('"', '&quot;') }}"
data-is-primary="{{ 'true' if contact.is_primary else 'false' }}"
>
<div class="contact-name">{{ contact.contact_name }}</div>
<small>{{ contact.title or '-' }}</small>
<small>{{ contact.customer_name or '-' }}</small>
<button
class="btn btn-sm btn-delete"
onclick="event.stopPropagation(); removeContact({{ case.id }}, {{ contact.contact_id }})"
title="Slet"
>
</button>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">Ingen kontakter</p>
{% endif %}
</div>
</div></div>
</div> </div>
<!-- TREDELT-2: Hero, Info --> <!-- TREDELT-2: Hero, Info -->
<div class="col-12" id="inner-center-col"> <div class="col-12" id="inner-center-col">
@ -2153,6 +2073,46 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 pt-3 border-top border-light" id="beskrivelse-comments-wrap">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted text-uppercase small mb-0 fw-bold" style="letter-spacing: 0.05em;">
<i class="bi bi-chat-left-text me-1"></i>Kommentarer
</h6>
<span class="badge bg-secondary">{{ comments|length if comments else 0 }}</span>
</div>
<div class="rounded border bg-light p-3" style="max-height: 360px; overflow-y: auto;" id="comments-container">
{% if comments %}
{% for comment in comments %}
<div class="d-flex mb-3 {{ 'justify-content-end' if comment.forfatter == 'System' else '' }}">
<div class="card {{ 'border-info' if comment.forfatter == 'System' else '' }}" style="max-width: 80%; width: fit-content;">
<div class="card-header py-1 px-3 small {{ 'bg-info text-white' if comment.forfatter == 'System' else 'bg-secondary text-white' }} d-flex justify-content-between align-items-center gap-3">
<strong>{{ comment.forfatter }}</strong>
<span>{{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}</span>
</div>
<div class="card-body py-2 px-3">
{{ comment.indhold|replace('\n', '<br>')|safe }}
</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-center text-muted my-3">Ingen kommentarer endnu.</p>
{% endif %}
</div>
<div class="mt-3">
<form id="comment-form" onsubmit="submitComment(event)">
<div class="input-group">
<textarea class="form-control" name="indhold" required placeholder="Skriv en kommentar..." rows="2" style="resize: none;"></textarea>
<button type="submit" class="btn btn-primary d-flex align-items-center">
<i class="bi bi-send me-2"></i> Send
</button>
</div>
</form>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -2237,104 +2197,77 @@
</div> </div>
</div> </div>
<div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;"> <div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;">
{% macro render_tree(nodes) %} {% macro render_relation_rows(nodes, level=0) %}
<ul class="relation-tree">
{% for node in nodes %} {% for node in nodes %}
<li class="relation-node"> <tr class="{{ 'table-primary' if node.is_current else '' }}">
<div class="d-flex align-items-center py-1"> <td class="small text-muted">{{ level }}</td>
<div class="relation-node-card w-100 rounded px-2 pt-1 pb-1" <td>
data-case-id="{{ node.case.id }}" <span class="badge bg-light text-dark border">#{{ node.case.id }}</span>
data-case-title="{{ node.case.titel | e }}" </td>
<td>
{% if node.is_current %} {% if node.is_current %}
style="border-left: 3px solid var(--accent,#0f4c75); background: rgba(15,76,117,0.06);" <span class="fw-semibold" style="color: var(--accent);">{{ node.case.titel }}</span>
{% endif %}> <span class="badge ms-1" style="background: var(--accent);">Aktuel</span>
<!-- Top row: type badge + link + status + actions -->
<div class="d-flex align-items-center" style="min-height:32px;">
<!-- Relation Type Icon/Badge -->
{% if node.relation_type %}
{% set rel_icon = 'bi-link-45deg' %}
{% set rel_color = 'text-muted' %}
{% set rel_help = 'Faglig kobling uden direkte afhængighed' %}
{% if node.relation_type == 'Afledt af' %}
{% set rel_icon = 'bi-arrow-return-right' %}
{% set rel_color = 'text-info' %}
{% set rel_help = 'Denne sag er opstået på baggrund af en anden sag' %}
{% elif node.relation_type == 'Årsag til' %}
{% set rel_icon = 'bi-arrow-right-circle' %}
{% set rel_color = 'text-primary' %}
{% set rel_help = 'Denne sag er årsagen til en anden sag' %}
{% elif node.relation_type == 'Blokkerer' %}
{% set rel_icon = 'bi-slash-circle' %}
{% set rel_color = 'text-danger' %}
{% set rel_help = 'Arbejdet i denne sag blokerer den anden' %}
{% endif %}
<span class="relation-type-badge {{ rel_color }}" title="{{ node.relation_type }}: {{ rel_help }}">
<i class="bi {{ rel_icon }}"></i>
<span class="d-none d-md-inline ms-1" style="font-size: 0.7rem;">{{ node.relation_type }}</span>
</span>
{% endif %}
<!-- Case Link -->
{% if node.is_current %}
<span class="d-flex align-items-center text-truncate" style="pointer-events:none;flex:1;min-width:0;">
<span class="badge me-2 rounded-pill fw-bold" style="font-size:0.65rem;background:var(--accent,#0f4c75);color:#fff;white-space:nowrap;">&#9654; #{{ node.case.id }}</span>
<span class="text-truncate fw-semibold" style="color:var(--accent,#0f4c75);">{{ node.case.titel }}</span>
</span>
{% else %} {% else %}
<a href="/sag/{{ node.case.id }}" class="text-decoration-none d-flex align-items-center text-secondary text-truncate" style="flex:1;min-width:0;"> <a href="/sag/{{ node.case.id }}" class="text-decoration-none fw-semibold">{{ node.case.titel }}</a>
<span class="badge bg-secondary me-2 rounded-pill" style="font-size: 0.65rem; opacity: 0.8;">#{{ node.case.id }}</span>
<span class="text-truncate">{{ node.case.titel }}</span>
</a>
{% endif %} {% endif %}
</td>
<!-- Status Dot --> <td>
<span class="status-dot status-{{ node.case.status }} ms-2" title="{{ node.case.status }}"></span> {% set row_status = (node.case.status or '')|lower %}
<span class="badge {{ 'bg-success-subtle text-success border border-success-subtle' if row_status == 'åben' else 'bg-secondary-subtle text-secondary border border-secondary-subtle' }}">
<!-- Duplicate/Reference Indicator --> {{ node.case.status or '-' }}
</span>
</td>
<td>
<span class="badge bg-light text-dark border">{{ node.case.template_key or node.case.type or 'ticket' }}</span>
</td>
<td>
<span class="small">{{ node.relation_type or ( 'Rodsag' if node.is_current else '-' ) }}</span>
{% if node.is_repeated %} {% if node.is_repeated %}
<span class="text-muted ms-2" title="Denne sag vises også et andet sted i træet"><i class="bi bi-arrow-repeat"></i></span> <i class="bi bi-arrow-repeat text-muted ms-1" title="Vises flere steder i relationstræet"></i>
{% endif %} {% endif %}
</td>
<!-- Quick action buttons (right side) --> <td class="text-end">
<div class="rel-row-actions"> <div class="btn-group btn-group-sm" role="group">
{% if node.relation_id %} {% if node.relation_id %}
<button onclick="deleteRelation({{ node.relation_id }})" class="btn-rel-action" title="Fjern relation" style="color:#dc3545;"> <button onclick="deleteRelation({{ node.relation_id }})" class="btn btn-outline-danger" title="Fjern relation">
<i class="bi bi-x-lg"></i> <i class="bi bi-x-lg"></i>
</button> </button>
{% endif %} {% endif %}
<button class="btn-rel-action" title="Tags" onclick="openRelTagPopover({{ node.case.id }})"> <button class="btn btn-outline-secondary" title="Tags" onclick="openRelTagPopover({{ node.case.id }})">
<i class="bi bi-tag"></i> <i class="bi bi-tag"></i>
</button> </button>
<button class="btn-rel-action" title="Quick action" onclick="openRelQaMenu({{ node.case.id }}, '{{ node.case.titel | e }}', this)"> <button class="btn btn-outline-primary" title="Quick action" onclick="openRelQaMenu({{ node.case.id }}, '{{ node.case.titel | e }}', this)">
<i class="bi bi-plus-lg"></i> <i class="bi bi-plus-lg"></i>
</button> </button>
</div> </div>
</div><!-- /top row --> </td>
</tr>
<!-- Tag pills row (loaded async) -->
<div class="rel-tag-row" id="rel-tags-{{ node.case.id }}"></div>
</div>
</div>
{% if node.children %} {% if node.children %}
<div class="relation-children"> {{ render_relation_rows(node.children, level + 1) }}
{{ render_tree(node.children) }}
</div>
{% endif %} {% endif %}
</li>
{% endfor %} {% endfor %}
</ul>
{% endmacro %} {% endmacro %}
{# Only show tree when there is more than the lone current case #}
{% set has_relations = relation_tree and (relation_tree|length > 1 or (relation_tree|length == 1 and relation_tree[0].children)) %} {% set has_relations = relation_tree and (relation_tree|length > 1 or (relation_tree|length == 1 and relation_tree[0].children)) %}
{% if has_relations %} {% if has_relations %}
<div class="relation-tree-container"> <div class="table-responsive">
{{ render_tree(relation_tree) }} <table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light sticky-top">
<tr>
<th style="width: 56px;">Niv.</th>
<th style="width: 88px;">Sag</th>
<th>Titel</th>
<th style="width: 120px;">Status</th>
<th style="width: 130px;">Type</th>
<th style="width: 140px;">Relation</th>
<th class="text-end" style="width: 130px;">Handling</th>
</tr>
</thead>
<tbody>
{{ render_relation_rows(relation_tree) }}
</tbody>
</table>
</div> </div>
{% else %} {% else %}
<p class="text-muted text-center pt-3"><i class="bi bi-diagram-3 me-1"></i>Ingen relaterede sager</p> <p class="text-muted text-center pt-3"><i class="bi bi-diagram-3 me-1"></i>Ingen relaterede sager</p>
@ -2735,6 +2668,7 @@
// Initialize everything when DOM is ready // Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
hydrateTopbarStatusOptions();
// Initialize modals // Initialize modals
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal')); contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal')); customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
@ -4229,6 +4163,110 @@
</div></div><!-- slut inner cols --> </div></div><!-- slut inner cols -->
<div class="col-xl-4 col-lg-4" id="case-right-column"> <div class="col-xl-4 col-lg-4" id="case-right-column">
<div class="right-modules-grid"> <div class="right-modules-grid">
<div class="card h-100 d-flex flex-column right-module-card" data-module="tags" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">🏷️ TAGS</h6>
<button class="btn btn-sm btn-outline-primary"
onclick="window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi())"
title="Tilføj tag">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body" style="max-height: 240px; overflow: auto;">
<div id="case-tags-module" class="mb-2">
<div class="p-2 text-muted small">Indlaeser tags...</div>
</div>
<div class="border-top pt-2">
<div class="small text-muted fw-semibold mb-2">Forslag (brand/type)</div>
<div id="case-tag-suggestions">
<div class="text-muted small">Indlaeser forslag...</div>
</div>
</div>
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="customers" data-has-content="{{ 'true' if customers else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">🏢 Kunder</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('customer')">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto" id="customers-container" style="max-height: 180px;">
{% if customers %}
<div class="customer-list-header">
<span>Navn</span>
<span>Rolle</span>
<span>E-mail</span>
<span>Slet</span>
</div>
{% for customer in customers %}
<div class="customer-row">
<div>
<a href="/customers/{{ customer.customer_id }}" class="text-decoration-none fw-semibold">
{{ customer.customer_name }}
</a>
</div>
<small>{{ customer.role or '-' }}</small>
<small>{{ customer.customer_email or '-' }}</small>
<button onclick="removeCustomer({{ case.id }}, {{ customer.customer_id }})" class="btn btn-sm btn-delete" title="Slet"></button>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">Ingen kunder</p>
{% endif %}
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="contacts" data-has-content="{{ 'true' if contacts else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">👥 Kontakter</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('contact')">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto" id="contacts-container" style="max-height: 180px;">
{% if contacts %}
<div class="contact-list-header">
<span>Navn</span>
<span>Titel</span>
<span>Kunde</span>
<span>Slet</span>
</div>
{% for contact in contacts %}
<div
class="contact-row"
role="button"
tabindex="0"
onclick="showContactInfoModal(this)"
data-contact-id="{{ contact.contact_id }}"
data-name="{{ contact.contact_name|replace('"', '&quot;') }}"
data-title="{{ contact.title|default('', true)|replace('"', '&quot;') }}"
data-company="{{ contact.customer_name|default('', true)|replace('"', '&quot;') }}"
data-email="{{ contact.contact_email|default('', true)|replace('"', '&quot;') }}"
data-phone="{{ contact.phone|default('', true)|replace('"', '&quot;') }}"
data-mobile="{{ contact.mobile|default('', true)|replace('"', '&quot;') }}"
data-role="{{ contact.role|default('Kontakt')|replace('"', '&quot;') }}"
data-is-primary="{{ 'true' if contact.is_primary else 'false' }}"
>
<div class="contact-name">{{ contact.contact_name }}</div>
<small>{{ contact.title or '-' }}</small>
<small>{{ contact.customer_name or '-' }}</small>
<button
class="btn btn-sm btn-delete"
onclick="event.stopPropagation(); removeContact({{ case.id }}, {{ contact.contact_id }})"
title="Slet"
>
</button>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">Ingen kontakter</p>
{% endif %}
</div>
</div>
<div class="card d-flex flex-column h-100 right-module-card" data-module="hardware" data-has-content="unknown"> <div class="card d-flex flex-column h-100 right-module-card" data-module="hardware" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">💻 Hardware</h6> <h6 class="mb-0" style="color: var(--accent);">💻 Hardware</h6>
@ -4410,34 +4448,6 @@
</div> </div>
</div> </div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="tags" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">🏷️ TAGS</h6>
<button class="btn btn-sm btn-outline-primary"
onclick="window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi())"
title="Tilføj tag">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body" style="max-height: 240px; overflow: auto;">
<div id="case-tags-module" class="mb-2">
<div class="p-2 text-muted small">Indlaeser tags...</div>
</div>
<div class="border-top pt-2">
<div class="small text-muted fw-semibold mb-2">Forslag (brand/type)</div>
<div id="case-tag-suggestions">
<div class="text-muted small">Indlaeser forslag...</div>
</div>
</div>
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="wiki" data-has-content="unknown"> <div class="card h-100 d-flex flex-column right-module-card" data-module="wiki" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent); font-size: 0.85rem;">Kunde-wiki</h6> <h6 class="mb-0" style="color: var(--accent); font-size: 0.85rem;">Kunde-wiki</h6>
@ -5207,47 +5217,6 @@
</div> </div>
</div> </div>
<!-- Global Comments Section (Visible on all tabs) -->
<div class="row mb-4 mt-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-chat-left-text me-2"></i>Kommentarer</h5>
<span class="badge bg-secondary">{{ comments|length if comments else 0 }}</span>
</div>
<div class="card-body bg-light" style="max-height: 500px; overflow-y: auto;" id="comments-container">
{% if comments %}
{% for comment in comments %}
<div class="d-flex mb-3 {{ 'justify-content-end' if comment.forfatter == 'System' else '' }}">
<div class="card {{ 'border-info' if comment.forfatter == 'System' else '' }}" style="max-width: 80%; width: fit-content;">
<div class="card-header py-1 px-3 small {{ 'bg-info text-white' if comment.forfatter == 'System' else 'bg-secondary text-white' }} d-flex justify-content-between align-items-center gap-3">
<strong>{{ comment.forfatter }}</strong>
<span>{{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}</span>
</div>
<div class="card-body py-2 px-3">
{{ comment.indhold|replace('\n', '<br>')|safe }}
</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-center text-muted my-3">Ingen kommentarer endnu.</p>
{% endif %}
</div>
<div class="card-footer bg-white">
<form id="comment-form" onsubmit="submitComment(event)">
<div class="input-group">
<textarea class="form-control" name="indhold" required placeholder="Skriv en kommentar..." rows="2" style="resize: none;"></textarea>
<button type="submit" class="btn btn-primary d-flex align-items-center">
<i class="bi bi-send me-2"></i> Send
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script> <script>
async function submitComment(event) { async function submitComment(event) {
event.preventDefault(); event.preventDefault();
@ -7186,6 +7155,51 @@
} }
} }
async function hydrateTopbarStatusOptions() {
const select = document.getElementById('topbarStatusSelect');
if (!select) return;
const initialValue = String(select.value || '').trim();
const known = new Map();
const addStatus = (raw) => {
const value = String(raw || '').trim();
if (!value) return;
const key = value.toLowerCase();
if (!known.has(key)) {
known.set(key, value);
}
};
Array.from(select.options || []).forEach((opt) => addStatus(opt.value));
try {
const response = await fetch('/api/v1/sag?include_deferred=true', { credentials: 'include' });
if (response.ok) {
const cases = await response.json();
(Array.isArray(cases) ? cases : []).forEach((c) => addStatus(c?.status));
}
} catch (error) {
console.warn('Could not hydrate status options from cases API', error);
}
['åben', 'under behandling', 'afventer', 'løst', 'lukket'].forEach(addStatus);
addStatus(initialValue);
const sortedValues = Array.from(known.values()).sort((a, b) =>
a.localeCompare(b, 'da', { sensitivity: 'base' })
);
select.innerHTML = sortedValues.map((value) => {
const selected = initialValue && value.toLowerCase() === initialValue.toLowerCase();
return `<option value="${value}" ${selected ? 'selected' : ''}>${value.charAt(0).toUpperCase()}${value.slice(1)}</option>`;
}).join('');
if (initialValue) {
select.value = Array.from(known.values()).find((v) => v.toLowerCase() === initialValue.toLowerCase()) || initialValue;
}
}
function saveCaseTypeFromTopbar() { function saveCaseTypeFromTopbar() {
const select = document.getElementById('topbarTypeSelect'); const select = document.getElementById('topbarTypeSelect');
if (!select) return; if (!select) return;

View File

@ -24,7 +24,7 @@
.sag-table { .sag-table {
width: 100%; width: 100%;
min-width: 1550px; min-width: 1760px;
margin: 0; margin: 0;
} }
@ -362,6 +362,7 @@
<th style="width: 110px;">Prioritet</th> <th style="width: 110px;">Prioritet</th>
<th style="width: 160px;">Ansvarl.</th> <th style="width: 160px;">Ansvarl.</th>
<th style="width: 170px;">Gruppe/Level</th> <th style="width: 170px;">Gruppe/Level</th>
<th style="width: 240px;">Næste todo</th>
<th style="width: 120px;">Opret.</th> <th style="width: 120px;">Opret.</th>
<th style="width: 120px;">Start arbejde</th> <th style="width: 120px;">Start arbejde</th>
<th style="width: 140px;">Start inden</th> <th style="width: 140px;">Start inden</th>
@ -406,6 +407,16 @@
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }} {{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
{% if sag.next_todo_title %}
<div>{{ sag.next_todo_title }}</div>
{% if sag.next_todo_due_date %}
<div class="small text-muted">Forfald: {{ sag.next_todo_due_date.strftime('%d/%m-%Y') }}</div>
{% endif %}
{% else %}
-
{% endif %}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);"> <td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }} {{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
</td> </td>
@ -457,6 +468,16 @@
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }} {{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
{% if related_sag.next_todo_title %}
<div>{{ related_sag.next_todo_title }}</div>
{% if related_sag.next_todo_due_date %}
<div class="small text-muted">Forfald: {{ related_sag.next_todo_due_date.strftime('%d/%m-%Y') }}</div>
{% endif %}
{% else %}
-
{% endif %}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }} {{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
</td> </td>

View File

@ -1689,6 +1689,8 @@ async function loadSettings() {
displaySettingsByCategory(); displaySettingsByCategory();
renderTelefoniSettings(); renderTelefoniSettings();
await loadCaseTypesSetting(); await loadCaseTypesSetting();
await loadCaseStatusesSetting();
await loadTagsManagement();
await loadNextcloudInstances(); await loadNextcloudInstances();
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
@ -2058,6 +2060,132 @@ const CASE_MODULE_LABELS = {
}; };
let caseTypeModuleDefaultsCache = {}; let caseTypeModuleDefaultsCache = {};
let caseStatusesCache = [];
function normalizeCaseStatuses(raw) {
const normalized = [];
const seen = new Set();
const source = Array.isArray(raw) ? raw : [];
source.forEach((item) => {
const row = typeof item === 'string'
? { value: item, is_closed: false }
: (item && typeof item === 'object' ? item : null);
if (!row) return;
const value = String(row.value || '').trim();
if (!value) return;
const key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
normalized.push({
value,
is_closed: Boolean(row.is_closed)
});
});
const defaults = [
{ value: 'åben', is_closed: false },
{ value: 'under behandling', is_closed: false },
{ value: 'afventer', is_closed: false },
{ value: 'løst', is_closed: true },
{ value: 'lukket', is_closed: true }
];
defaults.forEach((item) => {
const key = item.value.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
normalized.push(item);
}
});
return normalized;
}
function renderCaseStatuses(rows) {
const tbody = document.getElementById('caseStatusesTableBody');
if (!tbody) return;
if (!Array.isArray(rows) || !rows.length) {
tbody.innerHTML = '<tr><td colspan="3" class="text-muted">Ingen statusværdier defineret</td></tr>';
return;
}
tbody.innerHTML = rows.map((row, index) => `
<tr>
<td><span class="fw-semibold">${escapeHtml(row.value)}</span></td>
<td class="text-center">
<div class="form-check form-switch d-inline-flex">
<input class="form-check-input" type="checkbox" id="caseStatusClosed_${index}" ${row.is_closed ? 'checked' : ''}
onchange="toggleCaseStatusClosed(${index}, this.checked)">
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeCaseStatus(${index})" title="Slet status">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
async function loadCaseStatusesSetting() {
try {
const response = await fetch('/api/v1/settings/case_statuses');
if (!response.ok) {
caseStatusesCache = normalizeCaseStatuses([]);
renderCaseStatuses(caseStatusesCache);
return;
}
const setting = await response.json();
const parsed = JSON.parse(setting.value || '[]');
caseStatusesCache = normalizeCaseStatuses(parsed);
renderCaseStatuses(caseStatusesCache);
} catch (error) {
console.error('Error loading case statuses:', error);
caseStatusesCache = normalizeCaseStatuses([]);
renderCaseStatuses(caseStatusesCache);
}
}
async function saveCaseStatuses() {
await updateSetting('case_statuses', JSON.stringify(caseStatusesCache));
renderCaseStatuses(caseStatusesCache);
}
async function addCaseStatus() {
const input = document.getElementById('caseStatusInput');
if (!input) return;
const value = input.value.trim();
if (!value) return;
const exists = caseStatusesCache.some((row) => String(row.value || '').toLowerCase() === value.toLowerCase());
if (!exists) {
caseStatusesCache.push({ value, is_closed: false });
await saveCaseStatuses();
}
input.value = '';
}
async function removeCaseStatus(index) {
caseStatusesCache = caseStatusesCache.filter((_, i) => i !== index);
if (!caseStatusesCache.length) {
caseStatusesCache = normalizeCaseStatuses([]);
}
await saveCaseStatuses();
}
async function toggleCaseStatusClosed(index, checked) {
if (!caseStatusesCache[index]) return;
caseStatusesCache[index].is_closed = Boolean(checked);
await saveCaseStatuses();
}
function normalizeCaseTypeModuleDefaults(raw, caseTypes) { function normalizeCaseTypeModuleDefaults(raw, caseTypes) {
const normalized = {}; const normalized = {};
@ -3112,6 +3240,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
// Load data for tab // Load data for tab
if (tab === 'users') { if (tab === 'users') {
loadUsers(); loadUsers();
} else if (tab === 'tags') {
loadTagsManagement();
} else if (tab === 'telefoni') { } else if (tab === 'telefoni') {
renderTelefoniSettings(); renderTelefoniSettings();
} else if (tab === 'ai-prompts') { } else if (tab === 'ai-prompts') {
@ -3186,13 +3316,19 @@ let showInactive = false;
async function loadTagsManagement() { async function loadTagsManagement() {
try { try {
const response = await fetch('/api/v1/tags'); const response = await fetch('/api/v1/tags');
if (!response.ok) throw new Error('Failed to load tags'); if (!response.ok) {
const msg = await getErrorMessage(response, 'Kunne ikke indlæse tags');
throw new Error(msg);
}
allTagsData = await response.json(); allTagsData = await response.json();
updateTagsStats(); updateTagsStats();
renderTagsGrid(); renderTagsGrid();
} catch (error) { } catch (error) {
console.error('Error loading tags:', error); console.error('Error loading tags:', error);
showNotification('Fejl ved indlæsning af tags', 'error'); allTagsData = [];
updateTagsStats();
renderTagsGrid();
showNotification('Fejl ved indlæsning af tags: ' + (error.message || 'ukendt fejl'), 'error');
} }
} }

View File

@ -253,6 +253,7 @@
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li> <li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li> <li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li> <li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
</ul> </ul>
</li> </li>
@ -281,21 +282,6 @@
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li> <li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li> <li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li class="dropdown-submenu">
<a class="dropdown-item dropdown-toggle py-2" href="#" data-submenu-toggle="timetracking">
<span><i class="bi bi-clock-history me-2"></i>Timetracking</span>
<i class="bi bi-chevron-right small opacity-75"></i>
</a>
<ul class="dropdown-menu" data-submenu="timetracking">
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li> <li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
</ul> </ul>
</li> </li>
@ -306,6 +292,19 @@
</li> </li>
</ul> </ul>
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
<div class="dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-clock-history me-2"></i>Data migration
</a>
<ul class="dropdown-menu dropdown-menu-end mt-2">
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul>
</div>
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)"> <button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
<i class="bi bi-plus-circle-fill fs-5"></i> <i class="bi bi-plus-circle-fill fs-5"></i>
</button> </button>
@ -321,6 +320,7 @@
<ul class="dropdown-menu dropdown-menu-end mt-2"> <ul class="dropdown-menu dropdown-menu-end mt-2">
<li><a class="dropdown-item py-2" href="#" data-bs-toggle="modal" data-bs-target="#profileModal">Profil</a></li> <li><a class="dropdown-item py-2" href="#" data-bs-toggle="modal" data-bs-target="#profileModal">Profil</a></li>
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li> <li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li> <li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li>
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li> <li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>

View File

@ -1,9 +1,10 @@
""" """
Tag system API endpoints Tag system API endpoints
""" """
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional from typing import List, Optional
import json import json
import re
from app.tags.backend.models import ( from app.tags.backend.models import (
Tag, TagCreate, TagUpdate, Tag, TagCreate, TagUpdate,
EntityTag, EntityTagCreate, EntityTag, EntityTagCreate,
@ -15,6 +16,140 @@ from app.core.database import execute_query, execute_query_single, execute_updat
router = APIRouter(prefix="/tags") router = APIRouter(prefix="/tags")
MODULE_LABELS = {
"case": "Sager",
"email": "Email",
"ticket": "Tickets",
"customer": "Kunder",
"contact": "Kontakter",
"time_entry": "Tid",
"order": "Ordrer",
"comment": "Ticket kommentarer",
"worklog": "Ticket worklog",
}
def _module_label_for_entity_type(entity_type: Optional[str]) -> str:
key = str(entity_type or "").strip().lower()
if not key:
return "Ukendt modul"
return MODULE_LABELS.get(key, f"Ukendt modul ({key})")
def _entity_reference_payload(entity_type: Optional[str], entity_id: Optional[int]) -> dict:
etype = str(entity_type or "").strip().lower()
eid = int(entity_id or 0)
default_label = f"#{eid}" if eid else "Ukendt"
if not etype or not eid:
return {"entity_title": default_label, "entity_url": None}
try:
if etype == "case":
row = execute_query_single(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(eid,),
)
if row:
title = str(row.get("titel") or "Sag").strip()
return {"entity_title": title, "entity_url": f"/sag/{eid}"}
elif etype == "email":
row = execute_query_single(
"SELECT id, subject FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(eid,),
)
if row:
title = str(row.get("subject") or "Email").strip()
return {"entity_title": title, "entity_url": f"/emails?id={eid}"}
elif etype == "ticket":
row = execute_query_single(
"SELECT id, ticket_number, subject FROM tticket_tickets WHERE id = %s",
(eid,),
)
if row:
ticket_number = str(row.get("ticket_number") or "").strip()
subject = str(row.get("subject") or "Ticket").strip()
title = f"{ticket_number} - {subject}" if ticket_number else subject
return {"entity_title": title, "entity_url": f"/ticket/tickets/{eid}"}
elif etype == "customer":
row = execute_query_single("SELECT id, name FROM customers WHERE id = %s", (eid,))
if row:
title = str(row.get("name") or "Kunde").strip()
return {"entity_title": title, "entity_url": f"/customers/{eid}"}
elif etype == "contact":
row = execute_query_single(
"SELECT id, first_name, last_name, email FROM contacts WHERE id = %s",
(eid,),
)
if row:
name = " ".join(
[str(row.get("first_name") or "").strip(), str(row.get("last_name") or "").strip()]
).strip()
title = name or str(row.get("email") or "Kontakt").strip()
return {"entity_title": title, "entity_url": f"/contacts/{eid}"}
elif etype == "time_entry":
row = execute_query_single(
"SELECT id, description, worked_date FROM tmodule_times WHERE id = %s",
(eid,),
)
if row:
description = str(row.get("description") or "Tidsregistrering").strip()
return {"entity_title": description[:90], "entity_url": "/timetracking/registrations"}
elif etype == "order":
row = execute_query_single(
"SELECT id, order_number, total_amount FROM tmodule_orders WHERE id = %s",
(eid,),
)
if row:
order_number = str(row.get("order_number") or "Ordre").strip()
total_amount = row.get("total_amount")
suffix = f" ({total_amount} kr.)" if total_amount is not None else ""
return {"entity_title": f"{order_number}{suffix}", "entity_url": "/timetracking/orders"}
elif etype == "worklog":
row = execute_query_single(
"""
SELECT w.id, w.description, w.ticket_id, t.ticket_number
FROM tticket_worklog w
LEFT JOIN tticket_tickets t ON t.id = w.ticket_id
WHERE w.id = %s
""",
(eid,),
)
if row:
ticket_id = row.get("ticket_id")
ticket_number = str(row.get("ticket_number") or "Ticket").strip()
description = str(row.get("description") or "Worklog").strip()
url = f"/ticket/tickets/{ticket_id}" if ticket_id else None
return {"entity_title": f"{ticket_number} - {description[:70]}", "entity_url": url}
elif etype == "comment":
row = execute_query_single(
"""
SELECT c.id, c.comment_text, c.ticket_id, t.ticket_number
FROM tticket_comments c
LEFT JOIN tticket_tickets t ON t.id = c.ticket_id
WHERE c.id = %s
""",
(eid,),
)
if row:
ticket_id = row.get("ticket_id")
ticket_number = str(row.get("ticket_number") or "Ticket").strip()
comment_text = str(row.get("comment_text") or "Kommentar").strip()
url = f"/ticket/tickets/{ticket_id}" if ticket_id else None
return {"entity_title": f"{ticket_number} - {comment_text[:70]}", "entity_url": url}
except Exception:
pass
return {"entity_title": default_label, "entity_url": None}
def _normalize_catch_words(value) -> List[str]: def _normalize_catch_words(value) -> List[str]:
"""Normalize catch words from JSON/text/list to a clean lowercase list.""" """Normalize catch words from JSON/text/list to a clean lowercase list."""
@ -55,6 +190,20 @@ def _tag_row_to_response(row: dict) -> dict:
if not row: if not row:
return row return row
out = dict(row) out = dict(row)
valid_types = {"workflow", "status", "category", "priority", "billing", "brand", "type"}
tag_type = str(out.get("type") or "").strip().lower()
if tag_type not in valid_types:
tag_type = "category"
out["type"] = tag_type
color = str(out.get("color") or "").strip()
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", color):
out["color"] = "#0f4c75"
if not out.get("name"):
out["name"] = "Unnamed tag"
out["catch_words"] = _normalize_catch_words(out.get("catch_words")) out["catch_words"] = _normalize_catch_words(out.get("catch_words"))
return out return out
@ -78,6 +227,118 @@ async def create_tag_group(group: TagGroupCreate):
# ============= TAG CRUD ============= # ============= TAG CRUD =============
@router.get("/usage")
async def list_tag_usage(
tag_name: Optional[str] = Query(None),
tag_type: Optional[TagType] = Query(None),
module: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=200),
sort_by: str = Query("tagged_at"),
sort_dir: str = Query("desc"),
):
"""List tag usage across modules with server-side filtering and pagination."""
where_parts = ["1=1"]
params: List[object] = []
if tag_name:
where_parts.append("LOWER(t.name) LIKE LOWER(%s)")
params.append(f"%{tag_name.strip()}%")
if tag_type:
where_parts.append("t.type = %s")
params.append(tag_type)
if module:
where_parts.append("LOWER(et.entity_type) = LOWER(%s)")
params.append(module.strip())
where_clause = " AND ".join(where_parts)
sortable = {
"tagged_at": "et.tagged_at",
"tag_name": "t.name",
"tag_type": "t.type",
"module": "et.entity_type",
"entity_id": "et.entity_id",
}
order_column = sortable.get(sort_by, "et.tagged_at")
order_direction = "ASC" if str(sort_dir).lower() == "asc" else "DESC"
count_query = f"""
SELECT COUNT(*) AS total
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE {where_clause}
"""
count_row = execute_query_single(count_query, tuple(params)) or {"total": 0}
total = int(count_row.get("total") or 0)
offset = (page - 1) * page_size
data_query = f"""
SELECT
et.id AS entity_tag_id,
et.entity_type,
et.entity_id,
et.tagged_at,
t.id AS tag_id,
t.name AS tag_name,
t.type AS tag_type,
t.color AS tag_color,
t.is_active AS tag_is_active
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE {where_clause}
ORDER BY {order_column} {order_direction}, et.id DESC
LIMIT %s OFFSET %s
"""
rows = execute_query(data_query, tuple(params + [page_size, offset])) or []
items = []
for row in rows:
entity_type = row.get("entity_type")
entity_ref = _entity_reference_payload(entity_type, row.get("entity_id"))
items.append(
{
"entity_tag_id": row.get("entity_tag_id"),
"tag_id": row.get("tag_id"),
"tag_name": row.get("tag_name"),
"tag_type": row.get("tag_type"),
"tag_color": row.get("tag_color"),
"tag_is_active": bool(row.get("tag_is_active")),
"module": _module_label_for_entity_type(entity_type),
"entity_type": entity_type,
"entity_id": row.get("entity_id"),
"entity_title": entity_ref.get("entity_title"),
"entity_url": entity_ref.get("entity_url"),
"tagged_at": row.get("tagged_at"),
}
)
module_rows = execute_query(
"SELECT DISTINCT entity_type FROM entity_tags ORDER BY entity_type",
(),
) or []
module_options = [
{
"value": row.get("entity_type"),
"label": _module_label_for_entity_type(row.get("entity_type")),
}
for row in module_rows
]
return {
"items": items,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": (total + page_size - 1) // page_size if total else 0,
},
"sort": {"sort_by": sort_by, "sort_dir": order_direction.lower()},
"module_options": module_options,
}
@router.get("", response_model=List[Tag]) @router.get("", response_model=List[Tag])
async def list_tags( async def list_tags(
type: Optional[TagType] = None, type: Optional[TagType] = None,
@ -308,7 +569,7 @@ async def suggest_entity_tags(entity_type: str, entity_id: int):
return [] return []
case_row = execute_query_single( case_row = execute_query_single(
"SELECT id, titel, beskrivelse, type, template_key FROM sag_sager WHERE id = %s", "SELECT id, titel, beskrivelse, template_key FROM sag_sager WHERE id = %s",
(entity_id,), (entity_id,),
) )
if not case_row: if not case_row:
@ -337,7 +598,6 @@ async def suggest_entity_tags(entity_type: str, entity_id: int):
[ [
str(case_row.get("titel") or ""), str(case_row.get("titel") or ""),
str(case_row.get("beskrivelse") or ""), str(case_row.get("beskrivelse") or ""),
str(case_row.get("type") or ""),
str(case_row.get("template_key") or ""), str(case_row.get("template_key") or ""),
] ]
).lower() ).lower()

View File

@ -1,11 +1,8 @@
<!DOCTYPE html> {% extends "shared/frontend/base.html" %}
<html lang="da">
<head> {% block title %}Tag Administration - BMC Hub{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block extra_css %}
<title>Tag Administration - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style> <style>
:root { :root {
--primary-color: #0f4c75; --primary-color: #0f4c75;
@ -66,9 +63,68 @@
border-radius: 8px; border-radius: 8px;
border: 2px solid #dee2e6; border: 2px solid #dee2e6;
} }
.section-tabs .nav-link {
color: var(--primary-color);
font-weight: 600;
}
.section-tabs .nav-link.active {
background-color: var(--primary-color);
color: #fff;
border-color: var(--primary-color);
}
.module-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
background: #e7f1f8;
color: #0b3552;
border: 1px solid #c7dceb;
}
.usage-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: #fff;
white-space: nowrap;
}
.usage-table .filter-cell {
min-width: 160px;
}
.usage-sort-btn {
border: 0;
background: transparent;
color: inherit;
font-weight: 600;
padding: 0;
}
.usage-sort-btn .bi {
font-size: 0.75rem;
opacity: 0.55;
}
.usage-sort-btn.active .bi {
opacity: 1;
}
@media (max-width: 991px) {
.usage-table .filter-cell {
min-width: 130px;
}
}
</style> </style>
</head> {% endblock %}
<body>
{% block content %}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="row mb-4"> <div class="row mb-4">
<div class="col"> <div class="col">
@ -82,6 +138,17 @@
</div> </div>
</div> </div>
<ul class="nav nav-pills section-tabs mb-4" id="sectionTabs">
<li class="nav-item">
<button type="button" class="nav-link active" data-section="admin">Tag administration</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link" data-section="search">Tag søgning</button>
</li>
</ul>
<div id="tagAdminSection">
<!-- Type Filter Tabs --> <!-- Type Filter Tabs -->
<ul class="nav nav-tabs mb-4" id="typeFilter"> <ul class="nav nav-tabs mb-4" id="typeFilter">
<li class="nav-item"> <li class="nav-item">
@ -138,6 +205,98 @@
</div> </div>
</div> </div>
<div id="tagSearchSection" class="d-none">
<div class="card mb-3">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1">Tag søgning på tværs af moduler</h5>
<p class="text-muted mb-0 small">Filtrer efter tag-navn, type og modul. Hver række viser tydeligt hvilket modul tagningen kommer fra.</p>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" id="resetUsageFiltersBtn">
<i class="bi bi-arrow-counterclockwise"></i> Nulstil filtre
</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle usage-table mb-2">
<thead>
<tr>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="tag_name">
Tag <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="tag_type">
Type <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="module">
Modul <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>Objekt</th>
<th>Entity type</th>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="entity_id">
Entity ID <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>
<button type="button" class="usage-sort-btn active" data-sort-by="tagged_at">
Tagget <i class="bi bi-sort-down"></i>
</button>
</th>
</tr>
<tr>
<th class="filter-cell">
<input id="usageFilterTagName" type="search" class="form-control form-control-sm" placeholder="Søg tag-navn">
</th>
<th class="filter-cell">
<select id="usageFilterTagType" class="form-select form-select-sm">
<option value="">Alle typer</option>
<option value="workflow">workflow</option>
<option value="status">status</option>
<option value="category">category</option>
<option value="priority">priority</option>
<option value="billing">billing</option>
<option value="brand">brand</option>
<option value="type">type</option>
</select>
</th>
<th class="filter-cell">
<select id="usageFilterModule" class="form-select form-select-sm">
<option value="">Alle moduler</option>
</select>
</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody id="usageTableBody">
<tr>
<td colspan="7" class="text-center text-muted py-4">Indlæser...</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="small text-muted" id="usageSummary">-</div>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" id="usagePrevBtn">Forrige</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="usageNextBtn">Næste</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Tag Modal --> <!-- Create/Edit Tag Modal -->
<div class="modal fade" id="createTagModal" tabindex="-1"> <div class="modal fade" id="createTagModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
@ -210,19 +369,59 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> {% block extra_js %}
<script> <script>
let allTags = []; let allTags = [];
let currentFilter = 'all'; let currentFilter = 'all';
let usageDebounceTimer = null;
const usageState = {
filters: {
tag_name: '',
tag_type: '',
module: ''
},
page: 1,
page_size: 25,
sort_by: 'tagged_at',
sort_dir: 'desc',
total: 0,
total_pages: 0
};
// Load tags on page load // Load tags on page load
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadTags(); loadTags();
loadTagUsage();
setupEventListeners(); setupEventListeners();
const initialSection = window.location.hash === '#search' ? 'search' : 'admin';
switchTagSection(initialSection, false);
}); });
function switchTagSection(section, updateHash = true) {
const normalized = section === 'search' ? 'search' : 'admin';
document.querySelectorAll('#sectionTabs .nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.section === normalized);
});
document.getElementById('tagAdminSection').classList.toggle('d-none', normalized !== 'admin');
document.getElementById('tagSearchSection').classList.toggle('d-none', normalized !== 'search');
if (updateHash) {
const hash = normalized === 'search' ? '#search' : '#admin';
window.history.replaceState(null, '', hash);
}
}
function setupEventListeners() { function setupEventListeners() {
// Section tabs
document.querySelectorAll('#sectionTabs button').forEach(btn => {
btn.addEventListener('click', () => {
switchTagSection(btn.dataset.section);
});
});
// Type filter tabs // Type filter tabs
document.querySelectorAll('#typeFilter a').forEach(tab => { document.querySelectorAll('#typeFilter a').forEach(tab => {
tab.addEventListener('click', (e) => { tab.addEventListener('click', (e) => {
@ -266,6 +465,61 @@
// Save button // Save button
document.getElementById('saveTagBtn').addEventListener('click', saveTag); document.getElementById('saveTagBtn').addEventListener('click', saveTag);
// Usage filters
document.getElementById('usageFilterTagName').addEventListener('input', () => {
usageState.filters.tag_name = document.getElementById('usageFilterTagName').value.trim();
usageState.page = 1;
debounceUsageLoad();
});
document.getElementById('usageFilterTagType').addEventListener('change', () => {
usageState.filters.tag_type = document.getElementById('usageFilterTagType').value;
usageState.page = 1;
loadTagUsage();
});
document.getElementById('usageFilterModule').addEventListener('change', () => {
usageState.filters.module = document.getElementById('usageFilterModule').value;
usageState.page = 1;
loadTagUsage();
});
document.getElementById('resetUsageFiltersBtn').addEventListener('click', () => {
usageState.filters = { tag_name: '', tag_type: '', module: '' };
usageState.page = 1;
document.getElementById('usageFilterTagName').value = '';
document.getElementById('usageFilterTagType').value = '';
document.getElementById('usageFilterModule').value = '';
loadTagUsage();
});
document.getElementById('usagePrevBtn').addEventListener('click', () => {
if (usageState.page > 1) {
usageState.page -= 1;
loadTagUsage();
}
});
document.getElementById('usageNextBtn').addEventListener('click', () => {
if (usageState.page < usageState.total_pages) {
usageState.page += 1;
loadTagUsage();
}
});
document.querySelectorAll('.usage-sort-btn').forEach(btn => {
btn.addEventListener('click', () => {
const sortBy = btn.dataset.sortBy;
if (usageState.sort_by === sortBy) {
usageState.sort_dir = usageState.sort_dir === 'asc' ? 'desc' : 'asc';
} else {
usageState.sort_by = sortBy;
usageState.sort_dir = sortBy === 'tagged_at' ? 'desc' : 'asc';
}
usageState.page = 1;
updateSortIndicators();
loadTagUsage();
});
});
// Modal reset on close // Modal reset on close
document.getElementById('createTagModal').addEventListener('hidden.bs.modal', () => { document.getElementById('createTagModal').addEventListener('hidden.bs.modal', () => {
document.getElementById('tagForm').reset(); document.getElementById('tagForm').reset();
@ -290,6 +544,131 @@
} }
} }
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function debounceUsageLoad() {
if (usageDebounceTimer) {
clearTimeout(usageDebounceTimer);
}
usageDebounceTimer = setTimeout(() => loadTagUsage(), 280);
}
function updateSortIndicators() {
document.querySelectorAll('.usage-sort-btn').forEach(btn => {
const icon = btn.querySelector('i');
if (!icon) return;
btn.classList.remove('active');
icon.className = 'bi bi-chevron-expand';
if (btn.dataset.sortBy === usageState.sort_by) {
btn.classList.add('active');
icon.className = usageState.sort_dir === 'asc' ? 'bi bi-sort-up' : 'bi bi-sort-down';
}
});
}
function renderUsageTable(items) {
const tbody = document.getElementById('usageTableBody');
if (!Array.isArray(items) || !items.length) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Ingen taggede rækker matcher filtrene.</td></tr>';
return;
}
tbody.innerHTML = items.map(row => {
const taggedAt = row.tagged_at ? new Date(row.tagged_at).toLocaleString('da-DK') : '-';
const color = /^#[0-9A-Fa-f]{6}$/.test(String(row.tag_color || '')) ? row.tag_color : '#0f4c75';
const inactiveBadge = row.tag_is_active ? '' : '<span class="badge bg-secondary ms-2">Inaktiv</span>';
const entityTitle = escapeHtml(row.entity_title || `#${row.entity_id || ''}`);
const entityCell = row.entity_url
? `<a href="${escapeHtml(row.entity_url)}" class="text-decoration-none fw-semibold">${entityTitle}</a>`
: `<span class="fw-semibold">${entityTitle}</span>`;
return `
<tr>
<td>
<span class="tag-badge" style="background:${color}; color:#fff; margin:0;">${escapeHtml(row.tag_name)}</span>
${inactiveBadge}
</td>
<td><span class="badge bg-light text-dark text-uppercase">${escapeHtml(row.tag_type)}</span></td>
<td><span class="module-badge"><i class="bi bi-box"></i>${escapeHtml(row.module)}</span></td>
<td>${entityCell}</td>
<td><span class="text-muted">${escapeHtml(row.entity_type)}</span></td>
<td><strong>#${escapeHtml(row.entity_id)}</strong></td>
<td class="small text-muted">${escapeHtml(taggedAt)}</td>
</tr>
`;
}).join('');
}
function renderUsageSummary() {
const summary = document.getElementById('usageSummary');
const prevBtn = document.getElementById('usagePrevBtn');
const nextBtn = document.getElementById('usageNextBtn');
const total = usageState.total;
const page = usageState.page;
const pageSize = usageState.page_size;
const from = total ? ((page - 1) * pageSize + 1) : 0;
const to = total ? Math.min(page * pageSize, total) : 0;
summary.textContent = `Viser ${from}-${to} af ${total} rækker`;
prevBtn.disabled = page <= 1;
nextBtn.disabled = page >= usageState.total_pages;
}
function fillModuleFilter(options) {
const select = document.getElementById('usageFilterModule');
const currentValue = usageState.filters.module;
const base = '<option value="">Alle moduler</option>';
const rows = (options || []).map(option => {
return `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`;
}).join('');
select.innerHTML = `${base}${rows}`;
select.value = currentValue || '';
}
async function loadTagUsage() {
const tbody = document.getElementById('usageTableBody');
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Indlæser...</td></tr>';
try {
const params = new URLSearchParams({
page: String(usageState.page),
page_size: String(usageState.page_size),
sort_by: usageState.sort_by,
sort_dir: usageState.sort_dir
});
if (usageState.filters.tag_name) params.set('tag_name', usageState.filters.tag_name);
if (usageState.filters.tag_type) params.set('tag_type', usageState.filters.tag_type);
if (usageState.filters.module) params.set('module', usageState.filters.module);
const response = await fetch(`/api/v1/tags/usage?${params.toString()}`);
if (!response.ok) {
throw new Error('Kunne ikke hente tag søgning');
}
const payload = await response.json();
usageState.total = Number(payload?.pagination?.total || 0);
usageState.total_pages = Number(payload?.pagination?.total_pages || 0);
usageState.page = Number(payload?.pagination?.page || usageState.page);
fillModuleFilter(payload.module_options || []);
renderUsageTable(payload.items || []);
renderUsageSummary();
updateSortIndicators();
} catch (error) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-4">Fejl ved indlæsning af tag søgning: ${escapeHtml(error.message)}</td></tr>`;
document.getElementById('usageSummary').textContent = 'Fejl ved datahentning';
}
}
function renderTags() { function renderTags() {
const container = document.getElementById('tagsList'); const container = document.getElementById('tagsList');
const filteredTags = currentFilter === 'all' const filteredTags = currentFilter === 'all'
@ -406,5 +785,4 @@
} }
} }
</script> </script>
</body> {% endblock %}
</html>