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:
parent
92b888b78f
commit
a8eaf6e2a9
@ -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]:
|
||||||
|
|||||||
@ -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", {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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('"', '"') }}"
|
|
||||||
data-title="{{ contact.title|default('', true)|replace('"', '"') }}"
|
|
||||||
data-company="{{ contact.customer_name|default('', true)|replace('"', '"') }}"
|
|
||||||
data-email="{{ contact.contact_email|default('', true)|replace('"', '"') }}"
|
|
||||||
data-phone="{{ contact.phone|default('', true)|replace('"', '"') }}"
|
|
||||||
data-mobile="{{ contact.mobile|default('', true)|replace('"', '"') }}"
|
|
||||||
data-role="{{ contact.role|default('Kontakt')|replace('"', '"') }}"
|
|
||||||
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;">▶ #{{ 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('"', '"') }}"
|
||||||
|
data-title="{{ contact.title|default('', true)|replace('"', '"') }}"
|
||||||
|
data-company="{{ contact.customer_name|default('', true)|replace('"', '"') }}"
|
||||||
|
data-email="{{ contact.contact_email|default('', true)|replace('"', '"') }}"
|
||||||
|
data-phone="{{ contact.phone|default('', true)|replace('"', '"') }}"
|
||||||
|
data-mobile="{{ contact.mobile|default('', true)|replace('"', '"') }}"
|
||||||
|
data-role="{{ contact.role|default('Kontakt')|replace('"', '"') }}"
|
||||||
|
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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user