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 os
import shutil
import json
from pathlib import Path
from datetime import datetime
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:
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:
return "åben"
return allowed_map.get("åben", allowed_statuses[0])
normalized = str(status_value).strip().lower()
if normalized == "afventer":
return "åben"
if normalized in {"åben", "lukket"}:
return normalized
return "åben"
if normalized in allowed_map:
return allowed_map[normalized]
# Backward compatibility for legacy mapping
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]:

View File

@ -1,4 +1,5 @@
import logging
import json
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
@ -56,6 +57,50 @@ def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
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)
async def sager_liste(
request: Request,
@ -77,7 +122,9 @@ async def sager_liste(
c.name as customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_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
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
@ -90,6 +137,22 @@ async def sager_liste(
LIMIT 1
) cc_first ON true
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
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]
# 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 = []
seen_statuses = set()
status_options = _fetch_case_status_options()
for row in statuses or []:
status_value = str(row.get("status") or "").strip()
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())
current_status = str(status or "").strip()
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
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", ())
toggle_include_deferred_url = str(
@ -196,7 +241,7 @@ async def sager_liste(
"sager": sager,
"relations_map": relations_map,
"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],
"current_status": status,
"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)
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"))
return templates.TemplateResponse("modules/sag/templates/detail.html", {

View File

@ -33,7 +33,7 @@ class RelationService:
# 2. Fetch details for these cases
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))}
# 3. Fetch all edges between these cases

View File

@ -1984,86 +1984,6 @@
</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>
<!-- TREDELT-2: Hero, Info -->
<div class="col-12" id="inner-center-col">
@ -2153,6 +2073,46 @@
</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>
@ -2237,104 +2197,77 @@
</div>
</div>
<div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;">
{% macro render_tree(nodes) %}
<ul class="relation-tree">
{% macro render_relation_rows(nodes, level=0) %}
{% for node in nodes %}
<li class="relation-node">
<div class="d-flex align-items-center py-1">
<div class="relation-node-card w-100 rounded px-2 pt-1 pb-1"
data-case-id="{{ node.case.id }}"
data-case-title="{{ node.case.titel | e }}"
<tr class="{{ 'table-primary' if node.is_current else '' }}">
<td class="small text-muted">{{ level }}</td>
<td>
<span class="badge bg-light text-dark border">#{{ node.case.id }}</span>
</td>
<td>
{% if node.is_current %}
style="border-left: 3px solid var(--accent,#0f4c75); background: rgba(15,76,117,0.06);"
{% endif %}>
<!-- 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>
<span class="fw-semibold" style="color: var(--accent);">{{ node.case.titel }}</span>
<span class="badge ms-1" style="background: var(--accent);">Aktuel</span>
{% 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;">
<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>
<a href="/sag/{{ node.case.id }}" class="text-decoration-none fw-semibold">{{ node.case.titel }}</a>
{% endif %}
<!-- Status Dot -->
<span class="status-dot status-{{ node.case.status }} ms-2" title="{{ node.case.status }}"></span>
<!-- Duplicate/Reference Indicator -->
</td>
<td>
{% 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' }}">
{{ 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 %}
<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 %}
<!-- Quick action buttons (right side) -->
<div class="rel-row-actions">
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
{% 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>
</button>
{% 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>
</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>
</button>
</div>
</div><!-- /top row -->
<!-- Tag pills row (loaded async) -->
<div class="rel-tag-row" id="rel-tags-{{ node.case.id }}"></div>
</div>
</div>
</td>
</tr>
{% if node.children %}
<div class="relation-children">
{{ render_tree(node.children) }}
</div>
{{ render_relation_rows(node.children, level + 1) }}
{% endif %}
</li>
{% endfor %}
</ul>
{% 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)) %}
{% if has_relations %}
<div class="relation-tree-container">
{{ render_tree(relation_tree) }}
<div class="table-responsive">
<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>
{% else %}
<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
document.addEventListener('DOMContentLoaded', () => {
hydrateTopbarStatusOptions();
// Initialize modals
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
@ -4229,6 +4163,110 @@
</div></div><!-- slut inner cols -->
<div class="col-xl-4 col-lg-4" id="case-right-column">
<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-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">💻 Hardware</h6>
@ -4410,34 +4448,6 @@
</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-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent); font-size: 0.85rem;">Kunde-wiki</h6>
@ -5207,47 +5217,6 @@
</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>
async function submitComment(event) {
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() {
const select = document.getElementById('topbarTypeSelect');
if (!select) return;

View File

@ -24,7 +24,7 @@
.sag-table {
width: 100%;
min-width: 1550px;
min-width: 1760px;
margin: 0;
}
@ -362,6 +362,7 @@
<th style="width: 110px;">Prioritet</th>
<th style="width: 160px;">Ansvarl.</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;">Start arbejde</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;">
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
</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);">
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
</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;">
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
</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);">
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
</td>

View File

@ -1689,6 +1689,8 @@ async function loadSettings() {
displaySettingsByCategory();
renderTelefoniSettings();
await loadCaseTypesSetting();
await loadCaseStatusesSetting();
await loadTagsManagement();
await loadNextcloudInstances();
} catch (error) {
console.error('Error loading settings:', error);
@ -2058,6 +2060,132 @@ const CASE_MODULE_LABELS = {
};
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) {
const normalized = {};
@ -3112,6 +3240,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
// Load data for tab
if (tab === 'users') {
loadUsers();
} else if (tab === 'tags') {
loadTagsManagement();
} else if (tab === 'telefoni') {
renderTelefoniSettings();
} else if (tab === 'ai-prompts') {
@ -3186,13 +3316,19 @@ let showInactive = false;
async function loadTagsManagement() {
try {
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();
updateTagsStats();
renderTagsGrid();
} catch (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="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></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>
</ul>
</li>
@ -281,21 +282,6 @@
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
<li><a class="dropdown-item py-2" href="#">Betalinger</a></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>
</ul>
</li>
@ -306,6 +292,19 @@
</li>
</ul>
<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)">
<i class="bi bi-plus-circle-fill fs-5"></i>
</button>
@ -321,6 +320,7 @@
<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="/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="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
<li><hr class="dropdown-divider"></li>

View File

@ -1,9 +1,10 @@
"""
Tag system API endpoints
"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional
import json
import re
from app.tags.backend.models import (
Tag, TagCreate, TagUpdate,
EntityTag, EntityTagCreate,
@ -15,6 +16,140 @@ from app.core.database import execute_query, execute_query_single, execute_updat
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]:
"""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:
return 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"))
return out
@ -78,6 +227,118 @@ async def create_tag_group(group: TagGroupCreate):
# ============= 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])
async def list_tags(
type: Optional[TagType] = None,
@ -308,7 +569,7 @@ async def suggest_entity_tags(entity_type: str, entity_id: int):
return []
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,),
)
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("beskrivelse") or ""),
str(case_row.get("type") or ""),
str(case_row.get("template_key") or ""),
]
).lower()

View File

@ -1,11 +1,8 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
{% extends "shared/frontend/base.html" %}
{% block title %}Tag Administration - BMC Hub{% endblock %}
{% block extra_css %}
<style>
:root {
--primary-color: #0f4c75;
@ -66,9 +63,68 @@
border-radius: 8px;
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>
</head>
<body>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col">
@ -82,6 +138,17 @@
</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 -->
<ul class="nav nav-tabs mb-4" id="typeFilter">
<li class="nav-item">
@ -138,6 +205,98 @@
</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 -->
<div class="modal fade" id="createTagModal" tabindex="-1">
<div class="modal-dialog">
@ -210,19 +369,59 @@
</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>
let allTags = [];
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
document.addEventListener('DOMContentLoaded', () => {
loadTags();
loadTagUsage();
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() {
// Section tabs
document.querySelectorAll('#sectionTabs button').forEach(btn => {
btn.addEventListener('click', () => {
switchTagSection(btn.dataset.section);
});
});
// Type filter tabs
document.querySelectorAll('#typeFilter a').forEach(tab => {
tab.addEventListener('click', (e) => {
@ -266,6 +465,61 @@
// Save button
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
document.getElementById('createTagModal').addEventListener('hidden.bs.modal', () => {
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() {
const container = document.getElementById('tagsList');
const filteredTags = currentFilter === 'all'
@ -406,5 +785,4 @@
}
}
</script>
</body>
</html>
{% endblock %}