Compare commits

..

2 Commits

Author SHA1 Message Date
Christian
6133823ade Fix tag addition error handling and add legacy support for case tags
- Improved error handling when adding tags by parsing JSON response safely.
- Added support for legacy tag addition via the /sag/{id}/tags endpoint for case context.
- Enhanced user feedback for tag addition errors and success notifications.
2026-04-27 01:12:33 +02:00
Christian
5bd54a27dc Refactor code structure for improved readability and maintainability 2026-04-26 13:14:53 +02:00
6 changed files with 17519 additions and 184 deletions

View File

@ -7,7 +7,7 @@ import hashlib
import base64
import html
from pathlib import Path
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import List, Optional, Dict
from uuid import uuid4
@ -876,6 +876,14 @@ async def mark_sag_recent_open(sag_id: int, request: Request):
if not case_row:
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(
"""
INSERT INTO sag_recent_cases (user_id, sag_id, opened_at)
@ -921,6 +929,10 @@ async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10
user_id = _get_user_id_from_request(request)
try:
if not _table_exists("sag_recent_cases"):
logger.warning("⚠️ sag_recent_cases table missing; returning empty recent cases")
return []
rows = execute_query(
"""
SELECT
@ -1163,13 +1175,18 @@ async def update_sag(sag_id: int, updates: dict):
try:
# Check if case exists
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,),
)
if not check:
raise HTTPException(status_code=404, detail="Case not found")
previous_status = str((check[0] or {}).get("status") or "").strip().lower()
previous_row = check[0] or {}
previous_status = str(previous_row.get("status") or "").strip().lower()
# Backwards compatibility: frontend sends "type", DB stores "template_key"
if "type" in updates and "template_key" not in updates:
@ -1249,12 +1266,102 @@ async def update_sag(sag_id: int, updates: dict):
result = execute_query(query, tuple(params))
if result:
updated_row = result[0] or {}
if "status" in updates:
new_status = str((result[0] or {}).get("status") or "").strip().lower()
new_status = str(updated_row.get("status") or "").strip().lower()
if new_status and new_status != previous_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)
return result[0]
return updated_row
raise HTTPException(status_code=500, detail="Failed to update case")
except HTTPException:
raise
@ -2717,6 +2824,275 @@ async def get_kommentarer(sag_id: int):
logger.error("❌ Error getting comments: %s", e)
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")
async def add_kommentar(sag_id: int, data: dict, request: Request):
"""Add a comment to a case."""

View File

@ -779,6 +779,332 @@ async def sag_detaljer(request: Request, sag_id: int):
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)
async def sag_rediger(request: Request, sag_id: int):
"""Display edit case form."""

View File

@ -864,11 +864,11 @@
/* Forslag 1: Opgavebeskrivelse + kommentarspor (venstre side) */
.narrative-description {
border: 1px solid rgba(15, 76, 117, 0.22);
background: linear-gradient(165deg, rgba(15, 76, 117, 0.11), rgba(15, 76, 117, 0.04));
border-radius: 12px;
padding: 1.1rem 1.1rem 1rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.52), 0 3px 10px rgba(15, 76, 117, 0.06);
border: 0;
background: transparent;
border-radius: 0;
padding: 0.25rem 0.1rem 0;
box-shadow: none;
}
#beskrivelse-section {
@ -914,10 +914,10 @@
}
#beskrivelse-comments-wrap {
border: 1px solid rgba(15, 76, 117, 0.2);
border-radius: 12px;
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 3%, var(--bg-card)), var(--bg-card));
padding: 0.95rem;
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
}
.narrative-lead {
@ -1163,14 +1163,51 @@
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 {
box-shadow: 0 0 0 2px rgba(20, 28, 36, 0.95);
}
[data-bs-theme="dark"] .narrative-description {
border-color: rgba(117, 194, 239, 0.32);
background: linear-gradient(180deg, rgba(117, 194, 239, 0.2), rgba(117, 194, 239, 0.08));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 4px 12px rgba(5, 18, 30, 0.28);
border-color: transparent;
background: transparent;
box-shadow: none;
}
[data-bs-theme="dark"] .case-left-section-title {
@ -1182,8 +1219,7 @@
}
[data-bs-theme="dark"] .case-left-panel,
[data-bs-theme="dark"] #beskrivelse-history-wrap,
[data-bs-theme="dark"] #beskrivelse-comments-wrap {
[data-bs-theme="dark"] #beskrivelse-history-wrap {
border-color: rgba(117, 194, 239, 0.28);
background: linear-gradient(180deg, rgba(117, 194, 239, 0.08), rgba(17, 24, 33, 0.94));
}
@ -1236,13 +1272,17 @@
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
}
#caseTabsContent > .tab-pane {
display: none;
#caseTabsContent .tab-pane {
display: none !important;
opacity: 1 !important;
transition: none !important;
}
#caseTabsContent > .tab-pane.active,
#caseTabsContent > .tab-pane.show.active {
display: block;
#caseTabsContent .tab-pane.active,
#caseTabsContent .tab-pane.show.active {
display: block !important;
opacity: 1 !important;
visibility: visible !important;
}
.todo-step-item {
@ -1605,6 +1645,10 @@
gap: 0.75rem;
}
#beskrivelse-section {
gap: 0.75rem;
}
.module-priority-low {
--module-accent: #64748b;
}
@ -1630,9 +1674,30 @@
box-shadow: 0 4px 14px rgba(15, 76, 117, 0.08);
}
.right-module-card .card-header {
.left-module-card {
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);
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 {
@ -2032,11 +2097,26 @@
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] {
border-color: rgba(117, 167, 204, 0.45) !important;
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 {
border-color: rgba(170,205,245,0.5);
box-shadow: 0 1px 6px rgba(75,145,255,0.35);
@ -2057,11 +2137,24 @@
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 {
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);
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 {
color: #e5edf5;
}
@ -2988,37 +3081,73 @@
window.caseTypeModuleDefaults = caseTypeModuleDefaults;
window.caseTypeKey = window.caseTypeKey || {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
window.forceCaseTabActivation = window.forceCaseTabActivation || function(tabId) {
if (!tabId) return;
window.syncCaseTabPaneVisibility = window.syncCaseTabPaneVisibility || function(activeTabId) {
if (!activeTabId) return;
const tabContent = document.getElementById('caseTabsContent');
const targetPane = document.getElementById(tabId);
if (!tabContent || !targetPane) return;
if (!tabContent) return;
const paneIds = Array.from(document.querySelectorAll('#caseTabs [data-bs-target^="#"]'))
.map((btn) => (btn.getAttribute('data-bs-target') || '').replace('#', ''))
.filter(Boolean);
if (!paneIds.length) return;
tabContent.querySelectorAll(':scope > .tab-pane').forEach((pane) => {
pane.classList.remove('show', 'active');
pane.style.display = 'none';
paneIds.forEach((paneId) => {
const pane = document.getElementById(paneId);
if (pane && pane.parentElement !== tabContent) {
tabContent.appendChild(pane);
}
});
targetPane.classList.add('show', 'active');
targetPane.style.display = 'block';
paneIds.forEach((paneId) => {
const pane = document.getElementById(paneId);
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]');
tabButtons.forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-bs-target') === `#${tabId}`);
const isActive = btn.getAttribute('data-bs-target') === `#${activeTabId}`;
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>
<!-- Tabs Navigation -->
<ul class="nav nav-tabs mb-4" id="caseTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="details-tab" data-bs-toggle="tab" data-bs-target="#details" type="button" role="tab" onclick="forceCaseTabActivation('details', this)">
<button class="nav-link active" id="details-tab" data-bs-target="#details" type="button" role="tab" onclick="return activateCaseTabFromButton(event, 'details')">
<i class="bi bi-card-text me-2"></i>Sagsdetaljer
<span class="case-tab-count-badge" id="detailsTabCountBadge"></span>
</button>
</li>
<li class="nav-item" role="presentation">
<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)">
<button class="nav-link" id="solution-tab" data-bs-target="#solution" type="button" role="tab" data-module-tab="solution" onclick="return activateCaseTabFromButton(event, 'solution')">
<i class="bi bi-lightbulb me-2"></i>Løsning
<span class="case-tab-count-badge" id="solutionTabCountBadge"></span>
{% if solution %}
@ -3027,38 +3156,38 @@
</button>
</li>
<li class="nav-item" role="presentation">
<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)">
<button class="nav-link" id="emails-tab" data-bs-target="#emails" type="button" role="tab" data-module-tab="emails" onclick="return activateCaseTabFromButton(event, 'emails')">
<i class="bi bi-envelope me-2"></i>E-mail
<span class="case-tab-count-badge" id="emailsTabCountBadge"></span>
<span id="emailTabUnreadBadge" class="email-tab-unread-badge" aria-label="Ulæste emails"></span>
</button>
</li>
<li class="nav-item" role="presentation">
<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)">
<button class="nav-link" id="sales-tab" data-bs-target="#sales" type="button" role="tab" data-module-tab="sales" onclick="return activateCaseTabFromButton(event, 'sales')">
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
<span class="case-tab-count-badge" id="salesTabCountBadge"></span>
</button>
</li>
<li class="nav-item" role="presentation">
<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)">
<button class="nav-link" id="supplier-tab" data-bs-target="#supplier" type="button" role="tab" data-module-tab="supplier" onclick="return activateCaseTabFromButton(event, 'supplier')">
<i class="bi bi-receipt-cutoff me-2"></i>Leverandør
<span class="case-tab-count-badge" id="supplierTabCountBadge"></span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="timetracking-tab" data-bs-toggle="tab" data-bs-target="#timetracking" type="button" role="tab" onclick="forceCaseTabActivation('timetracking', this)">
<button class="nav-link" id="timetracking-tab" data-bs-target="#timetracking" type="button" role="tab" onclick="return activateCaseTabFromButton(event, 'timetracking')">
<i class="bi bi-clock-history me-2"></i>Tidsforbrug
<span class="case-tab-count-badge" id="timetrackingTabCountBadge"></span>
</button>
</li>
<li class="nav-item" role="presentation">
<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)">
<button class="nav-link" id="subscription-tab" data-bs-target="#subscription" type="button" role="tab" data-module-tab="subscription" onclick="return activateCaseTabFromButton(event, 'subscription')">
<i class="bi bi-repeat me-2"></i>Abonnement
<span class="case-tab-count-badge" id="subscriptionTabCountBadge"></span>
</button>
</li>
<li class="nav-item" role="presentation">
<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)">
<button class="nav-link" id="reminders-tab" data-bs-target="#reminders" type="button" role="tab" data-module-tab="reminders" onclick="return activateCaseTabFromButton(event, 'reminders')">
<i class="bi bi-bell me-2"></i>Påmindelser
<span class="case-tab-count-badge" id="remindersTabCountBadge"></span>
</button>
@ -3083,10 +3212,10 @@
<!-- ROW 1: Main Info -->
<div class="row mb-3">
<!-- MAIN HERO CARD: Titel & Beskrivelse -->
<div class="col-12 mb-4 mt-2">
<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-4 pt-4 pb-5 position-relative">
<div class="d-flex justify-content-between align-items-start mb-4">
<div class="col-12 mb-3 mt-1">
<div class="case-details-shell">
<div class="card-body p-3 position-relative">
<div class="d-flex justify-content-between align-items-start mb-1 d-none">
<div class="w-100 pe-3">
<!-- Title view -->
<div id="sag-titel-view" class="d-flex align-items-center gap-2">
@ -3106,12 +3235,15 @@
</div>
<div class="mt-4 pt-3 border-top border-light" id="beskrivelse-section">
<div class="case-left-panel">
<div class="case-left-section-title"><i class="bi bi-card-text"></i>Opgavebeskrivelse</div>
<div class="pt-2" id="beskrivelse-section">
<div class="card left-module-card module-priority-normal">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="module-title"><i class="bi bi-card-text module-icon"></i>{{ case.titel }}</h6>
<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>
<div class="card-body">
<!-- View mode -->
<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">
@ -3120,7 +3252,6 @@
</div>
{% endif %}
</div>
</div>
<!-- Edit mode (hidden by default) -->
<div id="beskrivelse-editor" class="d-none mt-1">
@ -3157,14 +3288,16 @@
</div>
</div>
</div>
<div 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>
<div class="card left-module-card module-priority-low">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="module-title"><i class="bi bi-chat-left-text module-icon"></i>Kommentarer</h6>
<span id="commentsTotalCountBadge" class="badge bg-secondary">{{ comments|length if comments else 0 }}</span>
</div>
<div class="card-body">
<div id="beskrivelse-comments-wrap">
<div class="comment-thread" id="comments-container">
{% if comments %}
@ -3207,6 +3340,10 @@
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ROW 1B: Pipeline -->
@ -3876,6 +4013,7 @@
let selectedRelationCaseId = null;
let customerSearchMode = 'link';
const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
const initialCaseTagsSnapshot = {{ (tags or [])|tojson }};
async function markCaseAsRecentlyOpened() {
try {
@ -3954,22 +4092,11 @@
function forceCaseTabActivation(tabId) {
if (!tabId) return;
const tabContent = document.getElementById('caseTabsContent');
const targetPane = document.getElementById(tabId);
if (!tabContent || !targetPane) return;
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}`);
});
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.moduleDisplayNames = {
@ -4093,11 +4220,8 @@
const caseTabs = document.getElementById('caseTabs');
if (caseTabs) {
caseTabs.addEventListener('shown.bs.tab', async (event) => {
const targetSelector = event?.target?.getAttribute('data-bs-target') || '';
const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector;
forceCaseTabActivation(tabId);
const loadCaseTabData = async (tabId) => {
if (!tabId) return;
try {
if (tabId === 'sales' && typeof loadVarekobSalg === 'function') {
@ -4113,20 +4237,27 @@
} catch (tabLoadError) {
console.error('Tab data reload failed:', tabLoadError);
}
});
};
window.loadCaseTabData = loadCaseTabData;
caseTabs.addEventListener('click', (event) => {
const btn = event.target.closest('[data-bs-target]');
if (!btn) return;
const targetSelector = btn.getAttribute('data-bs-target') || '';
caseTabs.addEventListener('click', async (event) => {
const trigger = event.target.closest('[data-bs-target]');
if (!trigger || !caseTabs.contains(trigger)) return;
event.preventDefault();
event.stopImmediatePropagation();
const targetSelector = trigger.getAttribute('data-bs-target') || '';
const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector;
if (tabId) {
setTimeout(() => forceCaseTabActivation(tabId), 0);
}
});
if (!tabId) return;
window.syncCaseTabPaneVisibility(tabId);
trigger.dispatchEvent(new Event('shown.bs.tab', { bubbles: true }));
await loadCaseTabData(tabId);
}, true);
}
forceCaseTabActivation('details');
window.syncCaseTabPaneVisibility('details');
// Focus on title when create modal opens
const createModalEl = document.getElementById('createRelatedCaseModal');
@ -5231,6 +5362,12 @@
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) {
moduleContainer.innerHTML = '<div class="p-3 text-center text-muted small">Ingen tags paaa sagen endnu</div>';
setModuleContentState('tags', false);
@ -5240,9 +5377,9 @@
moduleContainer.innerHTML = tags.map((tag) => `
<span class="badge me-1 mb-1" style="background-color: ${tag.color};">
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name)}
<button type="button" class="btn-close btn-close-white btn-sm ms-1"
${tag.id ? `<button type="button" class="btn-close btn-close-white btn-sm ms-1"
onclick="removeCaseTagAndSync(${tag.id})"
style="font-size: 0.6rem; vertical-align: middle;"></button>
style="font-size: 0.6rem; vertical-align: middle;"></button>` : ''}
</span>
`).join('');
@ -5961,8 +6098,9 @@
</div>
</div>
</div>
</div></div><!-- slut inner cols -->
</div> <!-- /#inner-center-col -->
</div> <!-- /.row.g-4 (inner) -->
</div> <!-- /#case-left-column -->
<div class="col-xl-4 col-lg-4" id="case-right-column">
<div class="right-modules-grid">
<div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="locations" data-has-content="unknown">
@ -5982,8 +6120,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-header d-flex justify-content-between align-items-center">
<h6 class="module-title"><i class="bi bi-tags-fill module-icon"></i>TAGS</h6>
<button class="btn btn-sm btn-outline-primary"
onclick="window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi())"
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation(); window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi()); return false;"
title="Tilføj tag">
<i class="bi bi-plus-lg"></i>
</button>
@ -6293,6 +6431,42 @@
</div>
</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 -->
<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">
@ -7610,7 +7784,8 @@
function _refreshCommentCountBadge() {
const container = document.getElementById('comments-container');
const badge = document.querySelector('#beskrivelse-comments-wrap .badge.bg-secondary');
const badge = document.getElementById('commentsTotalCountBadge')
|| document.querySelector('#beskrivelse-comments-wrap .badge.bg-secondary');
if (!container || !badge) return;
badge.textContent = String(container.querySelectorAll('.comment-item').length);
}
@ -11964,10 +12139,23 @@
}
function updateRightColumnVisibility() {
const detailsPane = document.getElementById('details');
const rightColumn = document.getElementById('case-right-column');
const leftColumn = document.getElementById('case-left-column');
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)');
if (visibleRightModules.length === 0) {
rightColumn.classList.add('d-none');
@ -12883,8 +13071,7 @@
function openCaseEmailTab() {
const trigger = document.getElementById('emails-tab');
if (!trigger) return;
const instance = bootstrap.Tab.getOrCreateInstance(trigger);
instance.show();
forceCaseTabActivation('emails');
}
window.quickReplyToEmailFromComment = async function(emailId) {
@ -13963,7 +14150,7 @@
async function loadSubscriptionForCase() {
try {
const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}`);
const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}?allow_missing=true`);
if (res.status === 404) {
showSubscriptionCreateForm();
setModuleContentState('subscription', false);
@ -13972,7 +14159,15 @@
if (!res.ok) {
throw new Error('Kunne ikke hente abonnement');
}
const subscription = await res.json();
const payload = 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);
setModuleContentState('subscription', true);
} catch (e) {
@ -14153,8 +14348,9 @@
|| document.querySelector(`[data-module-tab="${tabParam}"]`);
if (tabBtn) {
setTimeout(() => {
bootstrap.Tab.getOrCreateInstance(tabBtn).show();
forceCaseTabActivation(tabParam);
const targetSelector = tabBtn.getAttribute('data-bs-target') || '';
const targetTabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : tabParam;
forceCaseTabActivation(targetTabId);
}, 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])
async def get_subscription_by_sag(sag_id: int):
async def get_subscription_by_sag(sag_id: int, allow_missing: bool = Query(False)):
"""Get latest subscription for a case."""
try:
query = """
@ -236,6 +236,8 @@ async def get_subscription_by_sag(sag_id: int):
"""
subscription = execute_query_single(query, (sag_id,))
if not subscription:
if allow_missing:
return {"subscription": None, "line_items": []}
raise HTTPException(status_code=404, detail="Subscription not found")
items = execute_query(
"""

View File

@ -273,16 +273,47 @@ class TagPicker {
});
if (!response.ok) {
let errorDetail = '';
try {
const error = await response.json();
if (error.detail.includes('already exists')) {
errorDetail = String(error?.detail || '');
} catch (parseError) {
errorDetail = '';
}
if (response.status === 409 || errorDetail.toLowerCase().includes('already exists')) {
alert('Dette tag er allerede tilføjet');
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);
}
} catch (error) {
console.error('Error adding tag:', error);
alert('Fejl ved tilføjelse af tag: ' + error.message);