Compare commits

..

No commits in common. "6133823ade712e26e1f93a4945c22873d3fda6f1" and "dee82af2ea60145c422464d0a3097fcb1a193c1c" have entirely different histories.

6 changed files with 179 additions and 17514 deletions

View File

@ -7,7 +7,7 @@ import hashlib
import base64 import base64
import html import html
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from typing import List, Optional, Dict from typing import List, Optional, Dict
from uuid import uuid4 from uuid import uuid4
@ -876,14 +876,6 @@ async def mark_sag_recent_open(sag_id: int, request: Request):
if not case_row: if not case_row:
raise HTTPException(status_code=404, detail="Case not found") raise HTTPException(status_code=404, detail="Case not found")
if not _table_exists("sag_recent_cases"):
logger.warning("⚠️ sag_recent_cases table missing; skipping recent-open persistence")
return {
"sag_id": sag_id,
"titel": case_row.get("titel"),
"opened_at": None,
}
upserted = execute_query( upserted = execute_query(
""" """
INSERT INTO sag_recent_cases (user_id, sag_id, opened_at) INSERT INTO sag_recent_cases (user_id, sag_id, opened_at)
@ -929,10 +921,6 @@ async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10
user_id = _get_user_id_from_request(request) user_id = _get_user_id_from_request(request)
try: try:
if not _table_exists("sag_recent_cases"):
logger.warning("⚠️ sag_recent_cases table missing; returning empty recent cases")
return []
rows = execute_query( rows = execute_query(
""" """
SELECT SELECT
@ -1175,18 +1163,13 @@ async def update_sag(sag_id: int, updates: dict):
try: try:
# Check if case exists # Check if case exists
check = execute_query( check = execute_query(
""" "SELECT id, status FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
SELECT id, status, template_key, priority, ansvarlig_bruger_id, assigned_group_id
FROM sag_sager
WHERE id = %s AND deleted_at IS NULL
""",
(sag_id,), (sag_id,),
) )
if not check: if not check:
raise HTTPException(status_code=404, detail="Case not found") raise HTTPException(status_code=404, detail="Case not found")
previous_row = check[0] or {} previous_status = str((check[0] or {}).get("status") or "").strip().lower()
previous_status = str(previous_row.get("status") or "").strip().lower()
# Backwards compatibility: frontend sends "type", DB stores "template_key" # Backwards compatibility: frontend sends "type", DB stores "template_key"
if "type" in updates and "template_key" not in updates: if "type" in updates and "template_key" not in updates:
@ -1266,102 +1249,12 @@ async def update_sag(sag_id: int, updates: dict):
result = execute_query(query, tuple(params)) result = execute_query(query, tuple(params))
if result: if result:
updated_row = result[0] or {}
if "status" in updates: if "status" in updates:
new_status = str(updated_row.get("status") or "").strip().lower() new_status = str((result[0] or {}).get("status") or "").strip().lower()
if new_status and new_status != previous_status: if new_status and new_status != previous_status:
_activate_waiting_cases_by_status(sag_id, previous_status, new_status) _activate_waiting_cases_by_status(sag_id, previous_status, new_status)
try:
def _norm(value: Optional[object]) -> str:
return str(value or "").strip()
def _resolve_user_name(user_id: Optional[object]) -> str:
if user_id in (None, ""):
return "Ingen"
row = execute_query_single(
"""
SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), ''), CONCAT('Bruger #', user_id::text)) AS name
FROM users
WHERE user_id = %s
""",
(int(user_id),),
)
return str((row or {}).get("name") or f"Bruger #{int(user_id)}")
def _resolve_group_name(group_id: Optional[object]) -> str:
if group_id in (None, ""):
return "Ingen"
row = execute_query_single(
"SELECT COALESCE(NULLIF(TRIM(name), ''), CONCAT('Gruppe #', id::text)) AS name FROM groups WHERE id = %s",
(int(group_id),),
)
return str((row or {}).get("name") or f"Gruppe #{int(group_id)}")
type_labels = {
"ticket": "Ticket",
"pipeline": "Pipeline",
"opgave": "Opgave",
"ordre": "Ordre",
"projekt": "Projekt",
"service": "Service",
}
change_messages: List[str] = []
if "status" in updates:
old_value = _norm(previous_row.get("status")) or "-"
new_value = _norm(updated_row.get("status")) or "-"
if old_value.lower() != new_value.lower():
change_messages.append(f"🔄 Status ændret: {old_value}{new_value}")
if "template_key" in updates or "type" in updates:
old_type_key = _norm(previous_row.get("template_key")).lower() or "ticket"
new_type_key = _norm(updated_row.get("template_key")).lower() or "ticket"
if old_type_key != new_type_key:
old_type = type_labels.get(old_type_key, old_type_key.capitalize())
new_type = type_labels.get(new_type_key, new_type_key.capitalize())
change_messages.append(f"🧩 Type ændret: {old_type}{new_type}")
if "priority" in updates:
old_priority = _norm(previous_row.get("priority") or "normal").lower()
new_priority = _norm(updated_row.get("priority") or "normal").lower()
if old_priority != new_priority:
priority_labels = {
"low": "Lav",
"normal": "Normal",
"high": "Høj",
"urgent": "Akut",
}
old_label = priority_labels.get(old_priority, old_priority.capitalize() or "-")
new_label = priority_labels.get(new_priority, new_priority.capitalize() or "-")
change_messages.append(f"⚠️ Prioritet ændret: {old_label}{new_label}")
if "ansvarlig_bruger_id" in updates:
old_user = _resolve_user_name(previous_row.get("ansvarlig_bruger_id"))
new_user = _resolve_user_name(updated_row.get("ansvarlig_bruger_id"))
if old_user != new_user:
change_messages.append(f"👤 Ansvarlig ændret: {old_user}{new_user}")
if "assigned_group_id" in updates:
old_group = _resolve_group_name(previous_row.get("assigned_group_id"))
new_group = _resolve_group_name(updated_row.get("assigned_group_id"))
if old_group != new_group:
change_messages.append(f"👥 Gruppe ændret: {old_group}{new_group}")
for message in change_messages:
execute_query(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
""",
(sag_id, "System", message, True),
)
except Exception as comment_log_error:
logger.warning("⚠️ Could not log field-change comments for case %s: %s", sag_id, comment_log_error)
logger.info("✅ Case updated: %s", sag_id) logger.info("✅ Case updated: %s", sag_id)
return updated_row return result[0]
raise HTTPException(status_code=500, detail="Failed to update case") raise HTTPException(status_code=500, detail="Failed to update case")
except HTTPException: except HTTPException:
raise raise
@ -2824,275 +2717,6 @@ async def get_kommentarer(sag_id: int):
logger.error("❌ Error getting comments: %s", e) logger.error("❌ Error getting comments: %s", e)
raise HTTPException(status_code=500, detail="Failed to get comments") raise HTTPException(status_code=500, detail="Failed to get comments")
@router.get("/sag/{sag_id}/timeline")
async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
"""Return a unified timeline for the case with optional child cases."""
try:
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Case not found")
if include_subcases:
case_tree_query = """
WITH RECURSIVE normalized_relations AS (
SELECT
CASE
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id
ELSE kilde_sag_id
END AS parent_id,
CASE
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id
ELSE målsag_id
END AS child_id
FROM sag_relationer
WHERE deleted_at IS NULL
),
case_tree AS (
SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL
UNION
SELECT nr.child_id
FROM normalized_relations nr
JOIN case_tree ct ON nr.parent_id = ct.id
)
SELECT s.id, s.titel
FROM sag_sager s
JOIN case_tree ct ON s.id = ct.id
WHERE s.deleted_at IS NULL
ORDER BY s.id
"""
case_rows = execute_query(case_tree_query, (sag_id,)) or []
else:
case_rows = execute_query(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,)
) or []
case_ids = [row.get("id") for row in case_rows if row.get("id") is not None]
if not case_ids:
return {"case_tree": [], "events": [], "total": 0}
placeholders = ",".join(["%s"] * len(case_ids))
comments_query = f"""
SELECT c.*, s.titel AS source_sag_titel
FROM sag_kommentarer c
LEFT JOIN sag_sager s ON s.id = c.sag_id
WHERE c.deleted_at IS NULL
AND c.sag_id IN ({placeholders})
ORDER BY c.created_at DESC
LIMIT 800
"""
comments = execute_query(comments_query, tuple(case_ids)) or []
beskrivelse_history = []
if _table_exists("sag_beskrivelse_history"):
history_query = f"""
SELECT h.id, h.sag_id, h.beskrivelse_before, h.beskrivelse_after,
h.changed_by_name, h.changed_at, s.titel AS source_sag_titel
FROM sag_beskrivelse_history h
LEFT JOIN sag_sager s ON s.id = h.sag_id
WHERE h.sag_id IN ({placeholders})
ORDER BY h.changed_at DESC
LIMIT 400
"""
beskrivelse_history = execute_query(history_query, tuple(case_ids)) or []
def _detect_source(text: Optional[str]) -> str:
value = str(text or "").lower()
if re.search(r"\bemail\b|\be-mail\b|✉️|📧|email-kladde|mail\b|indgaaende email|udgaaende email|indgående email|udgående email", value):
return "email"
if re.search(r"\bsms\b|💬", value):
return "sms"
if re.search(r"\bopkald\b|\bring\b|\bringet\b|📞|click-to-call|yealink", value):
return "call"
return "module"
def _detect_field_change_subtype(text: Optional[str]) -> Optional[str]:
value = str(text or "").lower()
if re.search(r"\bstatus\b.*\b(ændret|ændring|skiftet|opdateret|sat til)\b|\bændret status\b|\bstatus:\s*.*->|\bstatus\s*->", value):
return "status"
if re.search(r"\b(type|sagstype|template_key|template key)\b.*\b(ændret|ændring|skiftet|opdateret|sat til)\b|\bændret type\b|\btype:\s*.*->|\btemplate_key\b", value):
return "type"
if re.search(r"\b(prioritet|priority)\b.*\b(ændret|ændring|skiftet|opdateret|sat til)\b|\bændret prioritet\b|\bprioritet:\s*.*->", value):
return "priority"
if re.search(r"\b(ansvarlig|assignee|owner|ansvarlig_bruger_id|ansvarlig bruger)\b.*\b(ændret|ændring|skiftet|opdateret|sat til|tildelt)\b|\bændret ansvarlig\b|\bansvarlig:\s*.*->|\bassign(ed)? to\b", value):
return "assignee"
if re.search(r"\b(gruppe|team|assigned_group_id|assigned group|gruppe-id)\b.*\b(ændret|ændring|skiftet|opdateret|sat til|tildelt)\b|\bændret gruppe\b|\bgruppe:\s*.*->", value):
return "group"
return None
events = []
for row in comments:
created_at = row.get("created_at")
if not created_at:
continue
if isinstance(created_at, datetime):
ts = created_at.isoformat()
else:
ts = str(created_at)
is_system = bool(row.get("er_system_besked"))
is_internal = bool(row.get("er_intern"))
subtype = "system" if is_system else ("internal" if is_internal else "external")
content = str(row.get("indhold") or "")
author = str(row.get("forfatter") or "Bruger")
field_change_subtype = _detect_field_change_subtype(content)
comment_id = row.get("id") or row.get("kommentar_id") or len(events) + 1
events.append({
"id": f"comment:{comment_id}",
"event_type": "field_change" if field_change_subtype else "comment",
"event_subtype": field_change_subtype or subtype,
"source": "case" if field_change_subtype else _detect_source(content),
"timestamp": ts,
"sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"),
"forfatter": author,
"title": f"Feltændring · {author}" if field_change_subtype else f"Kommentar · {author}",
"description": content,
})
for row in beskrivelse_history:
changed_at = row.get("changed_at")
if not changed_at:
continue
if isinstance(changed_at, datetime):
ts = changed_at.isoformat()
else:
ts = str(changed_at)
changed_by = str(row.get("changed_by_name") or "Ukendt")
before = str(row.get("beskrivelse_before") or "")
after = str(row.get("beskrivelse_after") or "")
before_short = (before[:240] + "...") if len(before) > 240 else before
after_short = (after[:240] + "...") if len(after) > 240 else after
description = f"Før: {before_short or 'tom'}\nEfter: {after_short or 'tom'}"
events.append({
"id": f"description:{row.get('id') or len(events) + 1}",
"event_type": "description",
"event_subtype": "edit",
"source": "module",
"timestamp": ts,
"sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"),
"forfatter": changed_by,
"title": f"Beskrivelse opdateret · {changed_by}",
"description": description,
})
if _table_exists("sag_reminders"):
reminders_query = f"""
SELECT r.id, r.sag_id, r.title, r.message, r.event_type,
r.next_check_at, r.scheduled_at,
s.titel AS source_sag_titel
FROM sag_reminders r
LEFT JOIN sag_sager s ON s.id = r.sag_id
WHERE r.deleted_at IS NULL
AND r.sag_id IN ({placeholders})
ORDER BY COALESCE(r.next_check_at, r.scheduled_at) DESC
LIMIT 400
"""
reminders = execute_query(reminders_query, tuple(case_ids)) or []
for row in reminders:
start_value = row.get("next_check_at") or row.get("scheduled_at")
if not start_value:
continue
ts = start_value.isoformat() if isinstance(start_value, datetime) else str(start_value)
title = str(row.get("title") or "Påmindelse")
message = str(row.get("message") or "")
event_kind = str(row.get("event_type") or "reminder")
events.append({
"id": f"reminder:{row.get('id') or len(events) + 1}",
"event_type": "reminder",
"event_subtype": event_kind,
"source": "reminder",
"timestamp": ts,
"sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"),
"forfatter": "Reminder",
"title": f"Påmindelse · {title}",
"description": message,
})
if _table_exists("tmodule_times"):
time_query = f"""
SELECT t.id, t.sag_id, t.description, t.entry_type,
t.worked_date, t.created_at, t.approved_hours, t.original_hours,
s.titel AS source_sag_titel
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.sag_id IN ({placeholders})
ORDER BY COALESCE(t.worked_date::timestamp, t.created_at) DESC, t.id DESC
LIMIT 600
"""
time_entries = execute_query(time_query, tuple(case_ids)) or []
for row in time_entries:
raw_ts = row.get("worked_date") or row.get("created_at")
if not raw_ts:
continue
ts = raw_ts.isoformat() if isinstance(raw_ts, datetime) else str(raw_ts)
hours = row.get("approved_hours")
if hours is None:
hours = row.get("original_hours")
hours_text = f"{float(hours):.2f} t" if hours is not None else "ukendt tid"
description = str(row.get("description") or "")
entry_type = str(row.get("entry_type") or "arbejde")
events.append({
"id": f"time:{row.get('id') or len(events) + 1}",
"event_type": "time",
"event_subtype": entry_type,
"source": "time",
"timestamp": ts,
"sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"),
"forfatter": "Tid",
"title": f"Tidsregistrering · {hours_text}",
"description": description,
})
def _sort_key(item: Dict) -> datetime:
raw = str(item.get("timestamp") or "")
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00").replace(" ", "T"))
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
except Exception:
return datetime.min
events.sort(key=_sort_key, reverse=True)
return {
"case_tree": case_rows,
"events": events,
"total": len(events),
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error loading case timeline: %s", e)
raise HTTPException(status_code=500, detail="Failed to load case timeline")
@router.post("/sag/{sag_id}/kommentarer") @router.post("/sag/{sag_id}/kommentarer")
async def add_kommentar(sag_id: int, data: dict, request: Request): async def add_kommentar(sag_id: int, data: dict, request: Request):
"""Add a comment to a case.""" """Add a comment to a case."""

View File

@ -779,332 +779,6 @@ async def sag_detaljer(request: Request, sag_id: int):
raise HTTPException(status_code=500, detail="Failed to load case details") raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
async def sag_detaljer_v3(request: Request, sag_id: int):
"""Display case details."""
try:
# Fetch main case
sag_query = """
SELECT s.*,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
WHERE s.id = %s AND s.deleted_at IS NULL
"""
sag_result = execute_query(sag_query, (sag_id,))
if not sag_result:
raise HTTPException(status_code=404, detail="Case not found")
sag = sag_result[0]
# Fetch tags (Support both Legacy sag_tags and New entity_tags)
# First try the new system (entity_tags) which the valid frontend uses
tags_query = """
SELECT t.name as tag_navn
FROM tags t
JOIN entity_tags et ON t.id = et.tag_id
WHERE et.entity_type = 'case' AND et.entity_id = %s
"""
tags = execute_query(tags_query, (sag_id,))
# If empty, try legacy table fallback
if not tags:
tags_query_legacy = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
tags = execute_query(tags_query_legacy, (sag_id,))
# Fetch relations
relationer_query = """
SELECT sr.*,
ss_kilde.titel as kilde_titel,
ss_mål.titel as mål_titel
FROM sag_relationer sr
JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id
JOIN sag_sager ss_mål ON sr.målsag_id = ss_mål.id
WHERE (sr.kilde_sag_id = %s OR sr.målsag_id = %s)
AND sr.deleted_at IS NULL
ORDER BY sr.created_at DESC
"""
relationer = execute_query(relationer_query, (sag_id, sag_id))
# --- Relation Tree Construction ---
relation_tree = []
try:
from app.modules.sag.services.relation_service import RelationService
relation_tree = RelationService.get_relation_tree(sag_id)
except Exception as e:
logger.error(f"Error building relation tree: {e}")
relation_tree = []
# Fallback: if tree builder fails/returns empty but relations exist, render a minimal flat tree.
if not relation_tree and relationer:
try:
root_node = {
"case": {
"id": sag.get("id"),
"titel": sag.get("titel"),
"status": sag.get("status"),
"type": sag.get("type"),
"template_key": sag.get("template_key"),
},
"relation_type": None,
"relation_id": None,
"is_current": True,
"children": [],
}
seen_related = set()
for rel in relationer or []:
source_id = rel.get("kilde_sag_id")
target_id = rel.get("målsag_id")
related_id = target_id if source_id == sag_id else source_id
if not related_id or related_id in seen_related:
continue
seen_related.add(related_id)
root_node["children"].append({
"case": {
"id": related_id,
"titel": rel.get("mål_titel") if source_id == sag_id else rel.get("kilde_titel"),
"status": None,
"type": None,
"template_key": None,
},
"relation_type": rel.get("relationstype") or "Relateret til",
"relation_id": rel.get("id"),
"is_current": False,
"children": [],
})
relation_tree = [root_node]
except Exception as fallback_err:
logger.warning("⚠️ Could not build fallback relation tree: %s", fallback_err)
# Fetch customer info if customer_id exists
customer = None
hovedkontakt = None
if sag.get('customer_id'):
customer_query = "SELECT * FROM customers WHERE id = %s"
customer_result = execute_query(customer_query, (sag['customer_id'],))
if customer_result:
customer = customer_result[0]
# Fetch hovedkontakt (primary contact) for case via sag_kontakter
kontakt_query = """
SELECT c.*
FROM contacts c
JOIN sag_kontakter sk ON c.id = sk.contact_id
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL AND sk.is_primary = TRUE
LIMIT 1
"""
kontakt_result = execute_query(kontakt_query, (sag_id,))
if kontakt_result:
hovedkontakt = kontakt_result[0]
else:
fallback_query = """
SELECT c.*
FROM contacts c
JOIN sag_kontakter sk ON c.id = sk.contact_id
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
ORDER BY sk.created_at ASC
LIMIT 1
"""
fallback_result = execute_query(fallback_query, (sag_id,))
if fallback_result:
hovedkontakt = fallback_result[0]
# Fetch prepaid cards for customer
# Cast remaining_hours to float to avoid Jinja formatting issues with Decimal
# DEBUG: Logging customer ID
prepaid_cards = []
if sag.get('customer_id'):
cid = sag.get('customer_id')
logger.info(f"🔎 Looking up prepaid cards for Sag {sag_id}, Customer ID: {cid} (Type: {type(cid)})")
pc_query = """
SELECT id, card_number, CAST(remaining_hours AS FLOAT) as remaining_hours, expires_at
FROM tticket_prepaid_cards
WHERE customer_id = %s
AND status = 'active'
AND remaining_hours > 0
ORDER BY created_at DESC
"""
prepaid_cards = execute_query(pc_query, (cid,))
logger.info(f"💳 Found {len(prepaid_cards)} prepaid cards for customer {cid}")
# Fetch fixed-price agreements for customer
fixed_price_agreements = []
if sag.get('customer_id'):
cid = sag.get('customer_id')
logger.info(f"🔎 Looking up fixed-price agreements for Sag {sag_id}, Customer ID: {cid}")
fpa_query = """
SELECT
a.id,
a.agreement_number,
a.monthly_hours,
COALESCE(bp.remaining_hours, a.monthly_hours) as remaining_hours_this_month
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON (
a.id = bp.agreement_id
AND bp.period_start <= CURRENT_DATE
AND bp.period_end >= CURRENT_DATE
)
WHERE a.customer_id = %s
AND a.status = 'active'
AND (a.end_date IS NULL OR a.end_date >= CURRENT_DATE)
ORDER BY a.created_at DESC
"""
fixed_price_agreements = execute_query(fpa_query, (cid,))
logger.info(f"📋 Found {len(fixed_price_agreements)} fixed-price agreements for customer {cid}")
# Fetch Nextcloud Instance for this customer
nextcloud_instance = None
if customer:
nc_query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL"
nc_result = execute_query(nc_query, (customer['id'],))
if nc_result:
nextcloud_instance = nc_result[0]
# Fetch linked contacts
contacts_query = """
SELECT
sk.*,
c.first_name || ' ' || c.last_name as contact_name,
c.email as contact_email,
c.phone,
c.mobile,
c.title,
company.customer_name
FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL (
SELECT cu.name AS customer_name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC, cu.name
LIMIT 1
) company ON TRUE
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
"""
contacts = execute_query(contacts_query, (sag_id,))
# Fetch linked customers
customers_query = """
SELECT sk.*, c.name as customer_name, c.email as customer_email
FROM sag_kunder sk
JOIN customers c ON sk.customer_id = c.id
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
"""
customers = execute_query(customers_query, (sag_id,))
# Fetch comments
comments_query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
comments = execute_query(comments_query, (sag_id,))
# Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None
# Fetch Time Entries
time_query = "SELECT * FROM tmodule_times WHERE sag_id = %s ORDER BY worked_date DESC"
time_entries = execute_query(time_query, (sag_id,))
# Fetch linked telephony call history
call_history_query = """
SELECT
t.id,
t.callid,
t.direction,
t.ekstern_nummer,
t.started_at,
t.ended_at,
t.duration_sec,
u.username,
u.full_name,
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
LEFT JOIN contacts c ON c.id = t.kontakt_id
WHERE t.sag_id = %s
ORDER BY t.started_at DESC
LIMIT 200
"""
call_history = execute_query(call_history_query, (sag_id,))
# Check for nextcloud integration (case-insensitive, insensitive to whitespace)
logger.info(f"Checking tags for Nextcloud on case {sag_id}: {tags}")
is_nextcloud = any(t['tag_navn'] and t['tag_navn'].strip().lower() == 'nextcloud' for t in tags)
logger.info(f"is_nextcloud result: {is_nextcloud}")
related_case_options = []
try:
related_ids = set()
for rel in relationer or []:
related_ids.add(rel["kilde_sag_id"])
related_ids.add(rel["målsag_id"])
related_ids.discard(sag_id)
if related_ids:
placeholders = ",".join(["%s"] * len(related_ids))
related_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders}) AND deleted_at IS NULL"
related_case_options = execute_query(related_query, tuple(related_ids))
except Exception as e:
logger.error("❌ Error building related case options: %s", e)
related_case_options = []
pipeline_stages = []
try:
pipeline_stages = execute_query(
"SELECT id, name, color, sort_order FROM pipeline_stages ORDER BY sort_order ASC, id ASC",
(),
)
except Exception as e:
logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = []
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_v3.html", {
"request": request,
"case": sag,
"customer": customer,
"hovedkontakt": hovedkontakt,
"contacts": contacts,
"customers": customers,
"prepaid_cards": prepaid_cards,
"fixed_price_agreements": fixed_price_agreements,
"tags": tags,
"relationer": relationer,
"relation_tree": relation_tree,
"comments": comments,
"solution": solution,
"time_entries": time_entries,
"call_history": call_history,
"is_nextcloud": is_nextcloud,
"nextcloud_instance": nextcloud_instance,
"related_case_options": related_case_options,
"pipeline_stages": pipeline_stages,
"status_options": status_options,
"is_deadline_overdue": is_deadline_overdue,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
})
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error displaying case details: %s", e)
raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/edit", response_class=HTMLResponse) @router.get("/sag/{sag_id}/edit", response_class=HTMLResponse)
async def sag_rediger(request: Request, sag_id: int): async def sag_rediger(request: Request, sag_id: int):
"""Display edit case form.""" """Display edit case form."""

View File

@ -864,11 +864,11 @@
/* Forslag 1: Opgavebeskrivelse + kommentarspor (venstre side) */ /* Forslag 1: Opgavebeskrivelse + kommentarspor (venstre side) */
.narrative-description { .narrative-description {
border: 0; border: 1px solid rgba(15, 76, 117, 0.22);
background: transparent; background: linear-gradient(165deg, rgba(15, 76, 117, 0.11), rgba(15, 76, 117, 0.04));
border-radius: 0; border-radius: 12px;
padding: 0.25rem 0.1rem 0; padding: 1.1rem 1.1rem 1rem;
box-shadow: none; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.52), 0 3px 10px rgba(15, 76, 117, 0.06);
} }
#beskrivelse-section { #beskrivelse-section {
@ -914,10 +914,10 @@
} }
#beskrivelse-comments-wrap { #beskrivelse-comments-wrap {
border: 0; border: 1px solid rgba(15, 76, 117, 0.2);
border-radius: 0; border-radius: 12px;
background: transparent; background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 3%, var(--bg-card)), var(--bg-card));
padding: 0; padding: 0.95rem;
} }
.narrative-lead { .narrative-lead {
@ -1163,51 +1163,14 @@
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9); box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9);
} }
.case-details-shell {
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
}
#case-left-column > .row.g-4,
#inner-center-col > .row.mb-3 {
--bs-gutter-x: 0;
--bs-gutter-y: 0;
margin-left: 0 !important;
margin-right: 0 !important;
}
#inner-left-col,
#inner-center-col,
#inner-center-col > .row.mb-3 > .col-12 {
padding-left: 0 !important;
padding-right: 0 !important;
}
#inner-center-col > .row.mb-3 > .col-12 {
margin-top: 0 !important;
margin-bottom: 0.75rem !important;
}
#inner-center-col .case-details-shell > .card-body {
padding: 0 !important;
}
#beskrivelse-section {
margin-top: 0 !important;
padding-top: 0 !important;
border-top: 0 !important;
}
[data-bs-theme="dark"] .case-tab-count-badge { [data-bs-theme="dark"] .case-tab-count-badge {
box-shadow: 0 0 0 2px rgba(20, 28, 36, 0.95); box-shadow: 0 0 0 2px rgba(20, 28, 36, 0.95);
} }
[data-bs-theme="dark"] .narrative-description { [data-bs-theme="dark"] .narrative-description {
border-color: transparent; border-color: rgba(117, 194, 239, 0.32);
background: transparent; background: linear-gradient(180deg, rgba(117, 194, 239, 0.2), rgba(117, 194, 239, 0.08));
box-shadow: none; box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 4px 12px rgba(5, 18, 30, 0.28);
} }
[data-bs-theme="dark"] .case-left-section-title { [data-bs-theme="dark"] .case-left-section-title {
@ -1219,7 +1182,8 @@
} }
[data-bs-theme="dark"] .case-left-panel, [data-bs-theme="dark"] .case-left-panel,
[data-bs-theme="dark"] #beskrivelse-history-wrap { [data-bs-theme="dark"] #beskrivelse-history-wrap,
[data-bs-theme="dark"] #beskrivelse-comments-wrap {
border-color: rgba(117, 194, 239, 0.28); border-color: rgba(117, 194, 239, 0.28);
background: linear-gradient(180deg, rgba(117, 194, 239, 0.08), rgba(17, 24, 33, 0.94)); background: linear-gradient(180deg, rgba(117, 194, 239, 0.08), rgba(17, 24, 33, 0.94));
} }
@ -1272,17 +1236,13 @@
border-bottom: 1px solid rgba(15, 76, 117, 0.08); border-bottom: 1px solid rgba(15, 76, 117, 0.08);
} }
#caseTabsContent .tab-pane { #caseTabsContent > .tab-pane {
display: none !important; display: none;
opacity: 1 !important;
transition: none !important;
} }
#caseTabsContent .tab-pane.active, #caseTabsContent > .tab-pane.active,
#caseTabsContent .tab-pane.show.active { #caseTabsContent > .tab-pane.show.active {
display: block !important; display: block;
opacity: 1 !important;
visibility: visible !important;
} }
.todo-step-item { .todo-step-item {
@ -1645,10 +1605,6 @@
gap: 0.75rem; gap: 0.75rem;
} }
#beskrivelse-section {
gap: 0.75rem;
}
.module-priority-low { .module-priority-low {
--module-accent: #64748b; --module-accent: #64748b;
} }
@ -1674,30 +1630,9 @@
box-shadow: 0 4px 14px rgba(15, 76, 117, 0.08); box-shadow: 0 4px 14px rgba(15, 76, 117, 0.08);
} }
.left-module-card { .right-module-card .card-header {
border: 1px solid rgba(15, 76, 117, 0.14);
border-left: 4px solid var(--module-accent, var(--accent));
border-radius: 12px;
overflow: hidden;
background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, var(--accent)) 6%, var(--bg-card)) 0%, var(--bg-card) 100%);
box-shadow: 0 4px 14px rgba(15, 76, 117, 0.08);
}
.right-module-card .card-header,
.left-module-card .card-header {
border-bottom: 1px solid color-mix(in srgb, var(--module-accent, var(--accent)) 22%, #d1d5db); border-bottom: 1px solid color-mix(in srgb, var(--module-accent, var(--accent)) 22%, #d1d5db);
background: color-mix(in srgb, var(--module-accent, var(--accent)) 7%, var(--bg-card)); background: color-mix(in srgb, var(--module-accent, var(--accent)) 7%, var(--bg-card));
padding: 0.35rem 0.6rem;
min-height: 0;
}
.right-module-card > .card-body,
.left-module-card > .card-body {
padding: 0.4rem !important;
}
#beskrivelse-section .left-module-card + .left-module-card {
margin-top: 0.75rem;
} }
.module-title { .module-title {
@ -2097,26 +2032,11 @@
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.10); box-shadow: 0 4px 12px rgba(15, 76, 117, 0.10);
} }
.left-module-card,
.right-module-card {
border: 2px solid rgba(15, 76, 117, 0.28) !important;
border-left: 2px solid rgba(15, 76, 117, 0.28) !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.10) !important;
}
[data-bs-theme="dark"] .card[data-module] { [data-bs-theme="dark"] .card[data-module] {
border-color: rgba(117, 167, 204, 0.45) !important; border-color: rgba(117, 167, 204, 0.45) !important;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28);
} }
[data-bs-theme="dark"] .left-module-card,
[data-bs-theme="dark"] .right-module-card {
border: 2px solid rgba(117, 167, 204, 0.45) !important;
border-left: 2px solid rgba(117, 167, 204, 0.45) !important;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28) !important;
}
[data-bs-theme="dark"] .topbar-company-edit-btn { [data-bs-theme="dark"] .topbar-company-edit-btn {
border-color: rgba(170,205,245,0.5); border-color: rgba(170,205,245,0.5);
box-shadow: 0 1px 6px rgba(75,145,255,0.35); box-shadow: 0 1px 6px rgba(75,145,255,0.35);
@ -2137,24 +2057,11 @@
background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, #69a6d5) 12%, rgba(18, 28, 40, 0.94)) 0%, rgba(18, 28, 40, 0.94) 100%); background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, #69a6d5) 12%, rgba(18, 28, 40, 0.94)) 0%, rgba(18, 28, 40, 0.94) 100%);
} }
[data-bs-theme="dark"] .left-module-card { [data-bs-theme="dark"] .right-module-card .card-header {
border-color: rgba(140, 182, 219, 0.25);
box-shadow: 0 4px 16px rgba(5, 22, 40, 0.45);
background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, #69a6d5) 12%, rgba(18, 28, 40, 0.94)) 0%, rgba(18, 28, 40, 0.94) 100%);
}
[data-bs-theme="dark"] .right-module-card .card-header,
[data-bs-theme="dark"] .left-module-card .card-header {
border-bottom-color: color-mix(in srgb, var(--module-accent, #69a6d5) 45%, #4b5563); border-bottom-color: color-mix(in srgb, var(--module-accent, #69a6d5) 45%, #4b5563);
background: color-mix(in srgb, var(--module-accent, #69a6d5) 18%, rgba(18, 28, 40, 0.98)); background: color-mix(in srgb, var(--module-accent, #69a6d5) 18%, rgba(18, 28, 40, 0.98));
} }
[data-bs-theme="dark"] .case-details-shell {
border-color: transparent;
box-shadow: none;
background: transparent;
}
[data-bs-theme="dark"] .module-title { [data-bs-theme="dark"] .module-title {
color: #e5edf5; color: #e5edf5;
} }
@ -3081,73 +2988,37 @@
window.caseTypeModuleDefaults = caseTypeModuleDefaults; window.caseTypeModuleDefaults = caseTypeModuleDefaults;
window.caseTypeKey = window.caseTypeKey || {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }}; window.caseTypeKey = window.caseTypeKey || {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
window.syncCaseTabPaneVisibility = window.syncCaseTabPaneVisibility || function(activeTabId) { window.forceCaseTabActivation = window.forceCaseTabActivation || function(tabId) {
if (!activeTabId) return; if (!tabId) return;
const tabContent = document.getElementById('caseTabsContent'); const tabContent = document.getElementById('caseTabsContent');
if (!tabContent) return; const targetPane = document.getElementById(tabId);
const paneIds = Array.from(document.querySelectorAll('#caseTabs [data-bs-target^="#"]')) if (!tabContent || !targetPane) return;
.map((btn) => (btn.getAttribute('data-bs-target') || '').replace('#', ''))
.filter(Boolean);
if (!paneIds.length) return;
paneIds.forEach((paneId) => { tabContent.querySelectorAll(':scope > .tab-pane').forEach((pane) => {
const pane = document.getElementById(paneId); pane.classList.remove('show', 'active');
if (pane && pane.parentElement !== tabContent) { pane.style.display = 'none';
tabContent.appendChild(pane);
}
}); });
paneIds.forEach((paneId) => { targetPane.classList.add('show', 'active');
const pane = document.getElementById(paneId); targetPane.style.display = 'block';
if (!pane) return;
const isActive = pane.id === activeTabId;
pane.classList.toggle('active', isActive);
pane.classList.toggle('show', isActive);
pane.classList.remove('d-none');
pane.style.display = isActive ? 'block' : 'none';
pane.style.opacity = '1';
pane.style.visibility = isActive ? 'visible' : 'hidden';
pane.hidden = !isActive;
});
const tabButtons = document.querySelectorAll('#caseTabs [data-bs-target]'); const tabButtons = document.querySelectorAll('#caseTabs [data-bs-target]');
tabButtons.forEach((btn) => { tabButtons.forEach((btn) => {
const isActive = btn.getAttribute('data-bs-target') === `#${activeTabId}`; btn.classList.toggle('active', btn.getAttribute('data-bs-target') === `#${tabId}`);
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
btn.tabIndex = isActive ? 0 : -1;
}); });
}; };
window.forceCaseTabActivation = window.forceCaseTabActivation || function(tabId) {
if (!tabId) return;
const trigger = document.querySelector(`#caseTabs [data-bs-target="#${tabId}"]`);
if (!trigger) return;
window.syncCaseTabPaneVisibility(tabId);
trigger.dispatchEvent(new Event('shown.bs.tab', { bubbles: true }));
if (typeof window.loadCaseTabData === 'function') window.loadCaseTabData(tabId);
};
window.activateCaseTabFromButton = window.activateCaseTabFromButton || function(event, tabId) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
window.forceCaseTabActivation(tabId);
return false;
};
</script> </script>
<!-- Tabs Navigation --> <!-- Tabs Navigation -->
<ul class="nav nav-tabs mb-4" id="caseTabs" role="tablist"> <ul class="nav nav-tabs mb-4" id="caseTabs" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="details-tab" data-bs-target="#details" type="button" role="tab" onclick="return activateCaseTabFromButton(event, 'details')"> <button class="nav-link active" id="details-tab" data-bs-toggle="tab" data-bs-target="#details" type="button" role="tab" onclick="forceCaseTabActivation('details', this)">
<i class="bi bi-card-text me-2"></i>Sagsdetaljer <i class="bi bi-card-text me-2"></i>Sagsdetaljer
<span class="case-tab-count-badge" id="detailsTabCountBadge"></span> <span class="case-tab-count-badge" id="detailsTabCountBadge"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="solution-tab" data-bs-target="#solution" type="button" role="tab" data-module-tab="solution" onclick="return activateCaseTabFromButton(event, 'solution')"> <button class="nav-link" id="solution-tab" data-bs-toggle="tab" data-bs-target="#solution" type="button" role="tab" data-module-tab="solution" onclick="forceCaseTabActivation('solution', this)">
<i class="bi bi-lightbulb me-2"></i>Løsning <i class="bi bi-lightbulb me-2"></i>Løsning
<span class="case-tab-count-badge" id="solutionTabCountBadge"></span> <span class="case-tab-count-badge" id="solutionTabCountBadge"></span>
{% if solution %} {% if solution %}
@ -3156,38 +3027,38 @@
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="emails-tab" data-bs-target="#emails" type="button" role="tab" data-module-tab="emails" onclick="return activateCaseTabFromButton(event, 'emails')"> <button class="nav-link" id="emails-tab" data-bs-toggle="tab" data-bs-target="#emails" type="button" role="tab" data-module-tab="emails" onclick="forceCaseTabActivation('emails', this)">
<i class="bi bi-envelope me-2"></i>E-mail <i class="bi bi-envelope me-2"></i>E-mail
<span class="case-tab-count-badge" id="emailsTabCountBadge"></span> <span class="case-tab-count-badge" id="emailsTabCountBadge"></span>
<span id="emailTabUnreadBadge" class="email-tab-unread-badge" aria-label="Ulæste emails"></span> <span id="emailTabUnreadBadge" class="email-tab-unread-badge" aria-label="Ulæste emails"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="sales-tab" data-bs-target="#sales" type="button" role="tab" data-module-tab="sales" onclick="return activateCaseTabFromButton(event, 'sales')"> <button class="nav-link" id="sales-tab" data-bs-toggle="tab" data-bs-target="#sales" type="button" role="tab" data-module-tab="sales" onclick="forceCaseTabActivation('sales', this)">
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg <i class="bi bi-basket3 me-2"></i>Varekøb & Salg
<span class="case-tab-count-badge" id="salesTabCountBadge"></span> <span class="case-tab-count-badge" id="salesTabCountBadge"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="supplier-tab" data-bs-target="#supplier" type="button" role="tab" data-module-tab="supplier" onclick="return activateCaseTabFromButton(event, 'supplier')"> <button class="nav-link" id="supplier-tab" data-bs-toggle="tab" data-bs-target="#supplier" type="button" role="tab" data-module-tab="supplier" onclick="forceCaseTabActivation('supplier', this)">
<i class="bi bi-receipt-cutoff me-2"></i>Leverandør <i class="bi bi-receipt-cutoff me-2"></i>Leverandør
<span class="case-tab-count-badge" id="supplierTabCountBadge"></span> <span class="case-tab-count-badge" id="supplierTabCountBadge"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="timetracking-tab" data-bs-target="#timetracking" type="button" role="tab" onclick="return activateCaseTabFromButton(event, 'timetracking')"> <button class="nav-link" id="timetracking-tab" data-bs-toggle="tab" data-bs-target="#timetracking" type="button" role="tab" onclick="forceCaseTabActivation('timetracking', this)">
<i class="bi bi-clock-history me-2"></i>Tidsforbrug <i class="bi bi-clock-history me-2"></i>Tidsforbrug
<span class="case-tab-count-badge" id="timetrackingTabCountBadge"></span> <span class="case-tab-count-badge" id="timetrackingTabCountBadge"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="subscription-tab" data-bs-target="#subscription" type="button" role="tab" data-module-tab="subscription" onclick="return activateCaseTabFromButton(event, 'subscription')"> <button class="nav-link" id="subscription-tab" data-bs-toggle="tab" data-bs-target="#subscription" type="button" role="tab" data-module-tab="subscription" onclick="forceCaseTabActivation('subscription', this)">
<i class="bi bi-repeat me-2"></i>Abonnement <i class="bi bi-repeat me-2"></i>Abonnement
<span class="case-tab-count-badge" id="subscriptionTabCountBadge"></span> <span class="case-tab-count-badge" id="subscriptionTabCountBadge"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="reminders-tab" data-bs-target="#reminders" type="button" role="tab" data-module-tab="reminders" onclick="return activateCaseTabFromButton(event, 'reminders')"> <button class="nav-link" id="reminders-tab" data-bs-toggle="tab" data-bs-target="#reminders" type="button" role="tab" data-module-tab="reminders" onclick="forceCaseTabActivation('reminders', this)">
<i class="bi bi-bell me-2"></i>Påmindelser <i class="bi bi-bell me-2"></i>Påmindelser
<span class="case-tab-count-badge" id="remindersTabCountBadge"></span> <span class="case-tab-count-badge" id="remindersTabCountBadge"></span>
</button> </button>
@ -3212,10 +3083,10 @@
<!-- ROW 1: Main Info --> <!-- ROW 1: Main Info -->
<div class="row mb-3"> <div class="row mb-3">
<!-- MAIN HERO CARD: Titel & Beskrivelse --> <!-- MAIN HERO CARD: Titel & Beskrivelse -->
<div class="col-12 mb-3 mt-1"> <div class="col-12 mb-4 mt-2">
<div class="case-details-shell"> <div class="card shadow-sm border-0 border-start border-4 border-primary" style="background-color: var(--bg-card); border-radius: 8px;">
<div class="card-body p-3 position-relative"> <div class="card-body p-4 pt-4 pb-5 position-relative">
<div class="d-flex justify-content-between align-items-start mb-1 d-none"> <div class="d-flex justify-content-between align-items-start mb-4">
<div class="w-100 pe-3"> <div class="w-100 pe-3">
<!-- Title view --> <!-- Title view -->
<div id="sag-titel-view" class="d-flex align-items-center gap-2"> <div id="sag-titel-view" class="d-flex align-items-center gap-2">
@ -3235,114 +3106,106 @@
</div> </div>
<div class="pt-2" id="beskrivelse-section"> <div class="mt-4 pt-3 border-top border-light" id="beskrivelse-section">
<div class="card left-module-card module-priority-normal"> <div class="case-left-panel">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="case-left-section-title"><i class="bi bi-card-text"></i>Opgavebeskrivelse</div>
<h6 class="module-title"><i class="bi bi-card-text module-icon"></i>{{ case.titel }}</h6> <!-- View mode -->
<button class="btn btn-sm btn-link text-info p-0" onclick="openManualHelp('Sag')" title="Hjælp til sagsbehandling"><i class="bi bi-question-circle"></i></button> <div id="beskrivelse-view" class="narrative-description" style="min-height: 120px; cursor: pointer;" ondblclick="startBeskrivelsEdit()">
<div class="narrative-lead">{{ case.titel }}</div>
<div id="beskrivelse-text" class="prose" style="white-space: pre-wrap;">{{ case.beskrivelse or '' }}</div>
{% if not case.beskrivelse %}
<div id="beskrivelse-empty" class="text-center p-3">
<p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p>
<span class="text-muted small"><i class="bi bi-pencil me-1"></i>Dobbeltklik for at tilføje</span>
</div>
{% endif %}
</div> </div>
<div class="card-body"> </div>
<!-- View mode -->
<div id="beskrivelse-view" class="narrative-description" style="min-height: 120px; cursor: pointer;" ondblclick="startBeskrivelsEdit()">
<div id="beskrivelse-text" class="prose" style="white-space: pre-wrap;">{{ case.beskrivelse or '' }}</div>
{% if not case.beskrivelse %}
<div id="beskrivelse-empty" class="text-center p-3">
<p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p>
<span class="text-muted small"><i class="bi bi-pencil me-1"></i>Dobbeltklik for at tilføje</span>
</div>
{% endif %}
</div>
<!-- Edit mode (hidden by default) --> <!-- Edit mode (hidden by default) -->
<div id="beskrivelse-editor" class="d-none mt-1"> <div id="beskrivelse-editor" class="d-none mt-1">
<textarea id="beskrivelse-textarea" class="form-control" <textarea id="beskrivelse-textarea" class="form-control"
rows="8" style="font-size: 1rem; line-height: 1.7; resize: vertical; min-height: 150px;"></textarea> rows="8" style="font-size: 1rem; line-height: 1.7; resize: vertical; min-height: 150px;"></textarea>
<div class="d-flex justify-content-between align-items-center mt-2"> <div class="d-flex justify-content-between align-items-center mt-2">
<span class="text-muted small"><i class="bi bi-keyboard me-1"></i>Ctrl+Enter for at gemme · Esc for at annullere</span> <span class="text-muted small"><i class="bi bi-keyboard me-1"></i>Ctrl+Enter for at gemme · Esc for at annullere</span>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="cancelBeskrivelsEdit()"> <button class="btn btn-sm btn-outline-secondary" onclick="cancelBeskrivelsEdit()">
<i class="bi bi-x me-1"></i>Annuller <i class="bi bi-x me-1"></i>Annuller
</button>
<button id="beskrivelse-rewrite-btn" type="button" class="btn btn-sm btn-outline-primary" onclick="rewriteCaseDescriptionWithApproval()">
<i class="bi bi-magic me-1"></i>Renskriv med AI
</button>
<button id="beskrivelse-save-btn" class="btn btn-sm btn-primary" onclick="saveBeskrivelsEdit()">
<i class="bi bi-check2 me-1"></i>Gem
</button>
</div>
</div>
</div>
<!-- History accordion -->
<div id="beskrivelse-history-wrap" class="mt-3 d-none">
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
type="button" data-bs-toggle="collapse" data-bs-target="#beskrivelse-history-collapse"
onclick="loadBeskrivelsHistory()">
<i class="bi bi-clock-history me-1"></i><span id="beskrivelse-history-label">Historik</span>
</button> </button>
<div class="collapse mt-2" id="beskrivelse-history-collapse"> <button id="beskrivelse-rewrite-btn" type="button" class="btn btn-sm btn-outline-primary" onclick="rewriteCaseDescriptionWithApproval()">
<div id="beskrivelse-history-list" class="list-group list-group-flush small border rounded"> <i class="bi bi-magic me-1"></i>Renskriv med AI
<div class="list-group-item text-muted text-center py-3"> </button>
<span class="spinner-border spinner-border-sm me-1"></span> Indlæser... <button id="beskrivelse-save-btn" class="btn btn-sm btn-primary" onclick="saveBeskrivelsEdit()">
</div> <i class="bi bi-check2 me-1"></i>Gem
</div> </button>
</div>
</div>
</div>
<!-- History accordion -->
<div id="beskrivelse-history-wrap" class="mt-3 d-none">
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
type="button" data-bs-toggle="collapse" data-bs-target="#beskrivelse-history-collapse"
onclick="loadBeskrivelsHistory()">
<i class="bi bi-clock-history me-1"></i><span id="beskrivelse-history-label">Historik</span>
</button>
<div class="collapse mt-2" id="beskrivelse-history-collapse">
<div id="beskrivelse-history-list" class="list-group list-group-flush small border rounded">
<div class="list-group-item text-muted text-center py-3">
<span class="spinner-border spinner-border-sm me-1"></span> Indlæser...
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="card left-module-card module-priority-low"> <div id="beskrivelse-comments-wrap">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="module-title"><i class="bi bi-chat-left-text module-icon"></i>Kommentarer</h6> <h6 class="text-muted text-uppercase small mb-0 fw-bold" style="letter-spacing: 0.05em;">
<span id="commentsTotalCountBadge" class="badge bg-secondary">{{ comments|length if comments else 0 }}</span> <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>
<div class="card-body">
<div id="beskrivelse-comments-wrap">
<div class="comment-thread" id="comments-container"> <div class="comment-thread" id="comments-container">
{% if comments %} {% if comments %}
{% for comment in comments %} {% for comment in comments %}
{% if comment.er_system_besked or comment.forfatter == 'System' %} {% if comment.er_system_besked or comment.forfatter == 'System' %}
<div class="comment-item comment-system" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}"> <div class="comment-item comment-system" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}">
{% elif comment.er_intern %} {% elif comment.er_intern %}
<div class="comment-item comment-internal" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}"> <div class="comment-item comment-internal" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}">
{% else %} {% else %}
<div class="comment-item comment-external" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}"> <div class="comment-item comment-external" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}">
{% endif %} {% endif %}
<div class="comment-meta"> <div class="comment-meta">
<span class="comment-avatar">{{ (comment.forfatter or 'Bruger')[:2]|upper }}</span> <span class="comment-avatar">{{ (comment.forfatter or 'Bruger')[:2]|upper }}</span>
<b>{{ comment.forfatter }}</b> <b>{{ comment.forfatter }}</b>
<span class="comment-time">{{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}</span> <span class="comment-time">{{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}</span>
</div> </div>
<div class="comment-body" data-comment-raw="{{ comment.indhold|e }}">{{ comment.indhold|replace('\n', '<br>')|safe }}</div> <div class="comment-body" data-comment-raw="{{ comment.indhold|e }}">{{ comment.indhold|replace('\n', '<br>')|safe }}</div>
</div>
{% endfor %}
{% else %}
<p class="text-center text-muted my-3">Ingen kommentarer endnu.</p>
{% endif %}
</div> </div>
{% endfor %}
{% else %}
<p class="text-center text-muted my-3">Ingen kommentarer endnu.</p>
{% endif %}
</div>
<div id="comment-quick-reply-host"></div> <div id="comment-quick-reply-host"></div>
<div class="mt-3"> <div class="mt-3">
<form id="comment-form" onsubmit="submitComment(event)"> <form id="comment-form" onsubmit="submitComment(event)">
<div class="comment-composer"> <div class="comment-composer">
<textarea class="form-control" name="indhold" required placeholder="Skriv en kommentar..." rows="2" style="resize: none;"></textarea> <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 justify-content-center comment-send"> <button type="submit" class="btn btn-primary d-flex align-items-center justify-content-center comment-send">
<i class="bi bi-send me-1"></i>Send <i class="bi bi-send me-1"></i>Send
</button> </button>
</div>
</form>
</div> </div>
</form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
</div> </div>
<!-- ROW 1B: Pipeline --> <!-- ROW 1B: Pipeline -->
@ -4013,7 +3876,6 @@
let selectedRelationCaseId = null; let selectedRelationCaseId = null;
let customerSearchMode = 'link'; let customerSearchMode = 'link';
const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }}; const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
const initialCaseTagsSnapshot = {{ (tags or [])|tojson }};
async function markCaseAsRecentlyOpened() { async function markCaseAsRecentlyOpened() {
try { try {
@ -4092,11 +3954,22 @@
function forceCaseTabActivation(tabId) { function forceCaseTabActivation(tabId) {
if (!tabId) return; if (!tabId) return;
const trigger = document.querySelector(`#caseTabs [data-bs-target="#${tabId}"]`); const tabContent = document.getElementById('caseTabsContent');
if (!trigger) return; const targetPane = document.getElementById(tabId);
window.syncCaseTabPaneVisibility(tabId); if (!tabContent || !targetPane) return;
trigger.dispatchEvent(new Event('shown.bs.tab', { bubbles: true }));
if (typeof window.loadCaseTabData === 'function') window.loadCaseTabData(tabId); tabContent.querySelectorAll(':scope > .tab-pane').forEach((pane) => {
pane.classList.remove('show', 'active');
pane.style.display = 'none';
});
targetPane.classList.add('show', 'active');
targetPane.style.display = 'block';
const tabButtons = document.querySelectorAll('#caseTabs [data-bs-target]');
tabButtons.forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-bs-target') === `#${tabId}`);
});
} }
window.moduleDisplayNames = { window.moduleDisplayNames = {
@ -4220,8 +4093,11 @@
const caseTabs = document.getElementById('caseTabs'); const caseTabs = document.getElementById('caseTabs');
if (caseTabs) { if (caseTabs) {
const loadCaseTabData = async (tabId) => { caseTabs.addEventListener('shown.bs.tab', async (event) => {
if (!tabId) return; const targetSelector = event?.target?.getAttribute('data-bs-target') || '';
const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector;
forceCaseTabActivation(tabId);
try { try {
if (tabId === 'sales' && typeof loadVarekobSalg === 'function') { if (tabId === 'sales' && typeof loadVarekobSalg === 'function') {
@ -4237,27 +4113,20 @@
} catch (tabLoadError) { } catch (tabLoadError) {
console.error('Tab data reload failed:', tabLoadError); console.error('Tab data reload failed:', tabLoadError);
} }
}; });
window.loadCaseTabData = loadCaseTabData;
caseTabs.addEventListener('click', async (event) => { caseTabs.addEventListener('click', (event) => {
const trigger = event.target.closest('[data-bs-target]'); const btn = event.target.closest('[data-bs-target]');
if (!trigger || !caseTabs.contains(trigger)) return; if (!btn) return;
const targetSelector = btn.getAttribute('data-bs-target') || '';
event.preventDefault();
event.stopImmediatePropagation();
const targetSelector = trigger.getAttribute('data-bs-target') || '';
const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector; const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector;
if (!tabId) return; if (tabId) {
setTimeout(() => forceCaseTabActivation(tabId), 0);
window.syncCaseTabPaneVisibility(tabId); }
trigger.dispatchEvent(new Event('shown.bs.tab', { bubbles: true })); });
await loadCaseTabData(tabId);
}, true);
} }
window.syncCaseTabPaneVisibility('details'); forceCaseTabActivation('details');
// Focus on title when create modal opens // Focus on title when create modal opens
const createModalEl = document.getElementById('createRelatedCaseModal'); const createModalEl = document.getElementById('createRelatedCaseModal');
@ -5362,12 +5231,6 @@
tags = await loadLegacyTags(); tags = await loadLegacyTags();
} }
// Final fallback: use server-rendered tags from page context when API calls return empty.
if ((!Array.isArray(tags) || tags.length === 0) && Array.isArray(initialCaseTagsSnapshot) && initialCaseTagsSnapshot.length > 0) {
tags = normalizeLegacyTags(initialCaseTagsSnapshot);
usingLegacyCaseTags = true;
}
if (!Array.isArray(tags) || tags.length === 0) { if (!Array.isArray(tags) || tags.length === 0) {
moduleContainer.innerHTML = '<div class="p-3 text-center text-muted small">Ingen tags paaa sagen endnu</div>'; moduleContainer.innerHTML = '<div class="p-3 text-center text-muted small">Ingen tags paaa sagen endnu</div>';
setModuleContentState('tags', false); setModuleContentState('tags', false);
@ -5377,9 +5240,9 @@
moduleContainer.innerHTML = tags.map((tag) => ` moduleContainer.innerHTML = tags.map((tag) => `
<span class="badge me-1 mb-1" style="background-color: ${tag.color};"> <span class="badge me-1 mb-1" style="background-color: ${tag.color};">
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name)} ${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name)}
${tag.id ? `<button type="button" class="btn-close btn-close-white btn-sm ms-1" <button type="button" class="btn-close btn-close-white btn-sm ms-1"
onclick="removeCaseTagAndSync(${tag.id})" onclick="removeCaseTagAndSync(${tag.id})"
style="font-size: 0.6rem; vertical-align: middle;"></button>` : ''} style="font-size: 0.6rem; vertical-align: middle;"></button>
</span> </span>
`).join(''); `).join('');
@ -6098,9 +5961,8 @@
</div> </div>
</div> </div>
</div> <!-- /#inner-center-col --> </div>
</div> <!-- /.row.g-4 (inner) --> </div></div><!-- slut inner cols -->
</div> <!-- /#case-left-column -->
<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 module-priority-normal" data-module="locations" data-has-content="unknown"> <div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="locations" data-has-content="unknown">
@ -6120,8 +5982,8 @@
<div class="card h-100 d-flex flex-column right-module-card module-priority-low" data-module="tags" data-has-content="unknown"> <div class="card h-100 d-flex flex-column right-module-card module-priority-low" data-module="tags" 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="module-title"><i class="bi bi-tags-fill module-icon"></i>TAGS</h6> <h6 class="module-title"><i class="bi bi-tags-fill module-icon"></i>TAGS</h6>
<button type="button" class="btn btn-sm btn-outline-primary" <button class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation(); window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi()); return false;" onclick="window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi())"
title="Tilføj tag"> title="Tilføj tag">
<i class="bi bi-plus-lg"></i> <i class="bi bi-plus-lg"></i>
</button> </button>
@ -6431,42 +6293,6 @@
</div> </div>
</div> <!-- End Details Tab --> </div> <!-- End Details Tab -->
<script>
(function () {
function normalizeDetailsColumns() {
const detailsPane = document.getElementById('details');
if (!detailsPane) return;
let detailsRow = null;
try {
detailsRow = detailsPane.querySelector(':scope > .row.g-4');
} catch (e) {
detailsRow = detailsPane.querySelector('.row.g-4');
}
const leftCol = detailsPane.querySelector('#case-left-column');
const rightCol = detailsPane.querySelector('#case-right-column');
if (!detailsRow || !leftCol || !rightCol) return;
// Defensive: ensure right column is a direct sibling inside the outer details row.
if (rightCol.parentElement !== detailsRow) {
detailsRow.appendChild(rightCol);
}
if (!leftCol.classList.contains('col-xl-8')) leftCol.classList.add('col-xl-8');
if (!leftCol.classList.contains('col-lg-8')) leftCol.classList.add('col-lg-8');
if (!rightCol.classList.contains('col-xl-4')) rightCol.classList.add('col-xl-4');
if (!rightCol.classList.contains('col-lg-4')) rightCol.classList.add('col-lg-4');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', normalizeDetailsColumns);
} else {
normalizeDetailsColumns();
}
})();
</script>
<!-- E-mail Tab --> <!-- E-mail Tab -->
<div class="tab-pane fade" id="emails" role="tabpanel" tabindex="0" data-module="emails" data-has-content="unknown" style="display:none;"> <div class="tab-pane fade" id="emails" role="tabpanel" tabindex="0" data-module="emails" data-has-content="unknown" style="display:none;">
<div class="card mb-3"> <div class="card mb-3">
@ -7784,8 +7610,7 @@
function _refreshCommentCountBadge() { function _refreshCommentCountBadge() {
const container = document.getElementById('comments-container'); const container = document.getElementById('comments-container');
const badge = document.getElementById('commentsTotalCountBadge') const badge = document.querySelector('#beskrivelse-comments-wrap .badge.bg-secondary');
|| document.querySelector('#beskrivelse-comments-wrap .badge.bg-secondary');
if (!container || !badge) return; if (!container || !badge) return;
badge.textContent = String(container.querySelectorAll('.comment-item').length); badge.textContent = String(container.querySelectorAll('.comment-item').length);
} }
@ -12139,23 +11964,10 @@
} }
function updateRightColumnVisibility() { function updateRightColumnVisibility() {
const detailsPane = document.getElementById('details');
const rightColumn = document.getElementById('case-right-column'); const rightColumn = document.getElementById('case-right-column');
const leftColumn = document.getElementById('case-left-column'); const leftColumn = document.getElementById('case-left-column');
if (!rightColumn || !leftColumn) return; if (!rightColumn || !leftColumn) return;
if (detailsPane) {
let detailsRow = null;
try {
detailsRow = detailsPane.querySelector(':scope > .row.g-4');
} catch (e) {
detailsRow = detailsPane.querySelector('.row.g-4');
}
if (detailsRow && rightColumn.parentElement !== detailsRow) {
detailsRow.appendChild(rightColumn);
}
}
const visibleRightModules = rightColumn.querySelectorAll('.right-module-card:not(.d-none)'); const visibleRightModules = rightColumn.querySelectorAll('.right-module-card:not(.d-none)');
if (visibleRightModules.length === 0) { if (visibleRightModules.length === 0) {
rightColumn.classList.add('d-none'); rightColumn.classList.add('d-none');
@ -13071,7 +12883,8 @@
function openCaseEmailTab() { function openCaseEmailTab() {
const trigger = document.getElementById('emails-tab'); const trigger = document.getElementById('emails-tab');
if (!trigger) return; if (!trigger) return;
forceCaseTabActivation('emails'); const instance = bootstrap.Tab.getOrCreateInstance(trigger);
instance.show();
} }
window.quickReplyToEmailFromComment = async function(emailId) { window.quickReplyToEmailFromComment = async function(emailId) {
@ -14150,7 +13963,7 @@
async function loadSubscriptionForCase() { async function loadSubscriptionForCase() {
try { try {
const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}?allow_missing=true`); const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}`);
if (res.status === 404) { if (res.status === 404) {
showSubscriptionCreateForm(); showSubscriptionCreateForm();
setModuleContentState('subscription', false); setModuleContentState('subscription', false);
@ -14159,15 +13972,7 @@
if (!res.ok) { if (!res.ok) {
throw new Error('Kunne ikke hente abonnement'); throw new Error('Kunne ikke hente abonnement');
} }
const payload = await res.json(); const subscription = await res.json();
const subscription = payload && Object.prototype.hasOwnProperty.call(payload, 'subscription')
? payload.subscription
: payload;
if (!subscription || !subscription.id) {
showSubscriptionCreateForm();
setModuleContentState('subscription', false);
return;
}
renderSubscription(subscription); renderSubscription(subscription);
setModuleContentState('subscription', true); setModuleContentState('subscription', true);
} catch (e) { } catch (e) {
@ -14348,9 +14153,8 @@
|| document.querySelector(`[data-module-tab="${tabParam}"]`); || document.querySelector(`[data-module-tab="${tabParam}"]`);
if (tabBtn) { if (tabBtn) {
setTimeout(() => { setTimeout(() => {
const targetSelector = tabBtn.getAttribute('data-bs-target') || ''; bootstrap.Tab.getOrCreateInstance(tabBtn).show();
const targetTabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : tabParam; forceCaseTabActivation(tabParam);
forceCaseTabActivation(targetTabId);
}, 300); }, 300);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -208,7 +208,7 @@ def _auto_map_customer(account_id: Optional[str], customer_name: Optional[str],
@router.get("/sag-subscriptions/by-sag/{sag_id}", response_model=Dict[str, Any]) @router.get("/sag-subscriptions/by-sag/{sag_id}", response_model=Dict[str, Any])
async def get_subscription_by_sag(sag_id: int, allow_missing: bool = Query(False)): async def get_subscription_by_sag(sag_id: int):
"""Get latest subscription for a case.""" """Get latest subscription for a case."""
try: try:
query = """ query = """
@ -236,8 +236,6 @@ async def get_subscription_by_sag(sag_id: int, allow_missing: bool = Query(False
""" """
subscription = execute_query_single(query, (sag_id,)) subscription = execute_query_single(query, (sag_id,))
if not subscription: if not subscription:
if allow_missing:
return {"subscription": None, "line_items": []}
raise HTTPException(status_code=404, detail="Subscription not found") raise HTTPException(status_code=404, detail="Subscription not found")
items = execute_query( items = execute_query(
""" """

View File

@ -273,47 +273,16 @@ class TagPicker {
}); });
if (!response.ok) { if (!response.ok) {
let errorDetail = ''; const error = await response.json();
try { if (error.detail.includes('already exists')) {
const error = await response.json();
errorDetail = String(error?.detail || '');
} catch (parseError) {
errorDetail = '';
}
if (response.status === 409 || errorDetail.toLowerCase().includes('already exists')) {
alert('Dette tag er allerede tilføjet'); alert('Dette tag er allerede tilføjet');
return; return;
} }
throw new Error(error.detail);
// Backward compatibility: some case pages still persist tags via /sag/{id}/tags.
if (String(this.contextType).toLowerCase() === 'case') {
const legacyResponse = await fetch(`/api/v1/sag/${this.contextId}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ tag_navn: tag.name })
});
if (legacyResponse.ok) {
this.showSuccess(tag.name);
} else {
let legacyErrorDetail = '';
try {
const legacyError = await legacyResponse.json();
legacyErrorDetail = String(legacyError?.detail || '');
} catch (parseError) {
legacyErrorDetail = '';
}
throw new Error(legacyErrorDetail || errorDetail || 'Kunne ikke tilføje tag');
}
} else {
throw new Error(errorDetail || 'Kunne ikke tilføje tag');
}
} else {
// Show success feedback
this.showSuccess(tag.name);
} }
// Show success feedback
this.showSuccess(tag.name);
} catch (error) { } catch (error) {
console.error('Error adding tag:', error); console.error('Error adding tag:', error);
alert('Fejl ved tilføjelse af tag: ' + error.message); alert('Fejl ved tilføjelse af tag: ' + error.message);