Compare commits
2 Commits
dee82af2ea
...
6133823ade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6133823ade | ||
|
|
5bd54a27dc |
@ -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
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@ -876,6 +876,14 @@ 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)
|
||||||
@ -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)
|
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
|
||||||
@ -1163,13 +1175,18 @@ 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_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"
|
# 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:
|
||||||
@ -1249,12 +1266,102 @@ 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((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:
|
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 result[0]
|
return updated_row
|
||||||
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
|
||||||
@ -2717,6 +2824,275 @@ 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."""
|
||||||
|
|||||||
@ -779,6 +779,332 @@ 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."""
|
||||||
|
|||||||
@ -864,11 +864,11 @@
|
|||||||
|
|
||||||
/* Forslag 1: Opgavebeskrivelse + kommentarspor (venstre side) */
|
/* Forslag 1: Opgavebeskrivelse + kommentarspor (venstre side) */
|
||||||
.narrative-description {
|
.narrative-description {
|
||||||
border: 1px solid rgba(15, 76, 117, 0.22);
|
border: 0;
|
||||||
background: linear-gradient(165deg, rgba(15, 76, 117, 0.11), rgba(15, 76, 117, 0.04));
|
background: transparent;
|
||||||
border-radius: 12px;
|
border-radius: 0;
|
||||||
padding: 1.1rem 1.1rem 1rem;
|
padding: 0.25rem 0.1rem 0;
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.52), 0 3px 10px rgba(15, 76, 117, 0.06);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#beskrivelse-section {
|
#beskrivelse-section {
|
||||||
@ -914,10 +914,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#beskrivelse-comments-wrap {
|
#beskrivelse-comments-wrap {
|
||||||
border: 1px solid rgba(15, 76, 117, 0.2);
|
border: 0;
|
||||||
border-radius: 12px;
|
border-radius: 0;
|
||||||
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 3%, var(--bg-card)), var(--bg-card));
|
background: transparent;
|
||||||
padding: 0.95rem;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.narrative-lead {
|
.narrative-lead {
|
||||||
@ -1163,14 +1163,51 @@
|
|||||||
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: rgba(117, 194, 239, 0.32);
|
border-color: transparent;
|
||||||
background: linear-gradient(180deg, rgba(117, 194, 239, 0.2), rgba(117, 194, 239, 0.08));
|
background: transparent;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 4px 12px rgba(5, 18, 30, 0.28);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="dark"] .case-left-section-title {
|
[data-bs-theme="dark"] .case-left-section-title {
|
||||||
@ -1182,8 +1219,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[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));
|
||||||
}
|
}
|
||||||
@ -1236,13 +1272,17 @@
|
|||||||
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;
|
display: none !important;
|
||||||
|
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;
|
display: block !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-step-item {
|
.todo-step-item {
|
||||||
@ -1605,6 +1645,10 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#beskrivelse-section {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.module-priority-low {
|
.module-priority-low {
|
||||||
--module-accent: #64748b;
|
--module-accent: #64748b;
|
||||||
}
|
}
|
||||||
@ -1630,9 +1674,30 @@
|
|||||||
box-shadow: 0 4px 14px rgba(15, 76, 117, 0.08);
|
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);
|
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 {
|
||||||
@ -2032,11 +2097,26 @@
|
|||||||
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);
|
||||||
@ -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%);
|
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);
|
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;
|
||||||
}
|
}
|
||||||
@ -2988,37 +3081,73 @@
|
|||||||
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.forceCaseTabActivation = window.forceCaseTabActivation || function(tabId) {
|
window.syncCaseTabPaneVisibility = window.syncCaseTabPaneVisibility || function(activeTabId) {
|
||||||
if (!tabId) return;
|
if (!activeTabId) return;
|
||||||
const tabContent = document.getElementById('caseTabsContent');
|
const tabContent = document.getElementById('caseTabsContent');
|
||||||
const targetPane = document.getElementById(tabId);
|
if (!tabContent) return;
|
||||||
if (!tabContent || !targetPane) 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) => {
|
paneIds.forEach((paneId) => {
|
||||||
pane.classList.remove('show', 'active');
|
const pane = document.getElementById(paneId);
|
||||||
pane.style.display = 'none';
|
if (pane && pane.parentElement !== tabContent) {
|
||||||
|
tabContent.appendChild(pane);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
targetPane.classList.add('show', 'active');
|
paneIds.forEach((paneId) => {
|
||||||
targetPane.style.display = 'block';
|
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]');
|
const tabButtons = document.querySelectorAll('#caseTabs [data-bs-target]');
|
||||||
tabButtons.forEach((btn) => {
|
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>
|
</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-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
|
<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-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
|
<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 %}
|
||||||
@ -3027,38 +3156,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-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
|
<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-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
|
<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-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
|
<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-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
|
<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-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
|
<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-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
|
<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>
|
||||||
@ -3083,10 +3212,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-4 mt-2">
|
<div class="col-12 mb-3 mt-1">
|
||||||
<div class="card shadow-sm border-0 border-start border-4 border-primary" style="background-color: var(--bg-card); border-radius: 8px;">
|
<div class="case-details-shell">
|
||||||
<div class="card-body p-4 pt-4 pb-5 position-relative">
|
<div class="card-body p-3 position-relative">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
<div class="d-flex justify-content-between align-items-start mb-1 d-none">
|
||||||
<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">
|
||||||
@ -3106,106 +3235,114 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 pt-3 border-top border-light" id="beskrivelse-section">
|
<div class="pt-2" id="beskrivelse-section">
|
||||||
<div class="case-left-panel">
|
<div class="card left-module-card module-priority-normal">
|
||||||
<div class="case-left-section-title"><i class="bi bi-card-text"></i>Opgavebeskrivelse</div>
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<!-- View mode -->
|
<h6 class="module-title"><i class="bi bi-card-text module-icon"></i>{{ case.titel }}</h6>
|
||||||
<div id="beskrivelse-view" class="narrative-description" style="min-height: 120px; cursor: pointer;" ondblclick="startBeskrivelsEdit()">
|
<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 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>
|
<div class="card-body">
|
||||||
|
<!-- View mode -->
|
||||||
<!-- Edit mode (hidden by default) -->
|
<div id="beskrivelse-view" class="narrative-description" style="min-height: 120px; cursor: pointer;" ondblclick="startBeskrivelsEdit()">
|
||||||
<div id="beskrivelse-editor" class="d-none mt-1">
|
<div id="beskrivelse-text" class="prose" style="white-space: pre-wrap;">{{ case.beskrivelse or '' }}</div>
|
||||||
<textarea id="beskrivelse-textarea" class="form-control"
|
{% if not case.beskrivelse %}
|
||||||
rows="8" style="font-size: 1rem; line-height: 1.7; resize: vertical; min-height: 150px;"></textarea>
|
<div id="beskrivelse-empty" class="text-center p-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mt-2">
|
<p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p>
|
||||||
<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-pencil me-1"></i>Dobbeltklik for at tilføje</span>
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="cancelBeskrivelsEdit()">
|
|
||||||
<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>
|
|
||||||
<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 id="beskrivelse-comments-wrap">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h6 class="text-muted text-uppercase small mb-0 fw-bold" style="letter-spacing: 0.05em;">
|
|
||||||
<i class="bi bi-chat-left-text me-1"></i>Kommentarer
|
|
||||||
</h6>
|
|
||||||
<span class="badge bg-secondary">{{ comments|length if comments else 0 }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="comment-thread" id="comments-container">
|
|
||||||
{% if comments %}
|
|
||||||
{% for comment in comments %}
|
|
||||||
{% 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 }}">
|
|
||||||
{% elif comment.er_intern %}
|
|
||||||
<div class="comment-item comment-internal" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}">
|
|
||||||
{% else %}
|
|
||||||
<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>
|
||||||
<span class="comment-avatar">{{ (comment.forfatter or 'Bruger')[:2]|upper }}</span>
|
|
||||||
<b>{{ comment.forfatter }}</b>
|
<!-- Edit mode (hidden by default) -->
|
||||||
<span class="comment-time">{{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}</span>
|
<div id="beskrivelse-editor" class="d-none mt-1">
|
||||||
|
<textarea id="beskrivelse-textarea" class="form-control"
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="cancelBeskrivelsEdit()">
|
||||||
|
<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 class="comment-body" data-comment-raw="{{ comment.indhold|e }}">{{ comment.indhold|replace('\n', '<br>')|safe }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
{% else %}
|
|
||||||
<p class="text-center text-muted my-3">Ingen kommentarer endnu.</p>
|
<!-- History accordion -->
|
||||||
{% endif %}
|
<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 id="comment-quick-reply-host"></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="mt-3">
|
<div class="comment-thread" id="comments-container">
|
||||||
<form id="comment-form" onsubmit="submitComment(event)">
|
{% if comments %}
|
||||||
<div class="comment-composer">
|
{% for comment in comments %}
|
||||||
<textarea class="form-control" name="indhold" required placeholder="Skriv en kommentar..." rows="2" style="resize: none;"></textarea>
|
{% if comment.er_system_besked or comment.forfatter == 'System' %}
|
||||||
<button type="submit" class="btn btn-primary d-flex align-items-center justify-content-center comment-send">
|
<div class="comment-item comment-system" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}">
|
||||||
<i class="bi bi-send me-1"></i>Send
|
{% elif comment.er_intern %}
|
||||||
</button>
|
<div class="comment-item comment-internal" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="comment-item comment-external" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}">
|
||||||
|
{% endif %}
|
||||||
|
<div class="comment-meta">
|
||||||
|
<span class="comment-avatar">{{ (comment.forfatter or 'Bruger')[:2]|upper }}</span>
|
||||||
|
<b>{{ comment.forfatter }}</b>
|
||||||
|
<span class="comment-time">{{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}</span>
|
||||||
|
</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 id="comment-quick-reply-host"></div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<form id="comment-form" onsubmit="submitComment(event)">
|
||||||
|
<div class="comment-composer">
|
||||||
|
<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">
|
||||||
|
<i class="bi bi-send me-1"></i>Send
|
||||||
|
</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 -->
|
||||||
@ -3876,6 +4013,7 @@
|
|||||||
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 {
|
||||||
@ -3954,22 +4092,11 @@
|
|||||||
function forceCaseTabActivation(tabId) {
|
function forceCaseTabActivation(tabId) {
|
||||||
if (!tabId) return;
|
if (!tabId) return;
|
||||||
|
|
||||||
const tabContent = document.getElementById('caseTabsContent');
|
const trigger = document.querySelector(`#caseTabs [data-bs-target="#${tabId}"]`);
|
||||||
const targetPane = document.getElementById(tabId);
|
if (!trigger) return;
|
||||||
if (!tabContent || !targetPane) return;
|
window.syncCaseTabPaneVisibility(tabId);
|
||||||
|
trigger.dispatchEvent(new Event('shown.bs.tab', { bubbles: true }));
|
||||||
tabContent.querySelectorAll(':scope > .tab-pane').forEach((pane) => {
|
if (typeof window.loadCaseTabData === 'function') window.loadCaseTabData(tabId);
|
||||||
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 = {
|
||||||
@ -4093,11 +4220,8 @@
|
|||||||
|
|
||||||
const caseTabs = document.getElementById('caseTabs');
|
const caseTabs = document.getElementById('caseTabs');
|
||||||
if (caseTabs) {
|
if (caseTabs) {
|
||||||
caseTabs.addEventListener('shown.bs.tab', async (event) => {
|
const loadCaseTabData = async (tabId) => {
|
||||||
const targetSelector = event?.target?.getAttribute('data-bs-target') || '';
|
if (!tabId) return;
|
||||||
const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector;
|
|
||||||
|
|
||||||
forceCaseTabActivation(tabId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (tabId === 'sales' && typeof loadVarekobSalg === 'function') {
|
if (tabId === 'sales' && typeof loadVarekobSalg === 'function') {
|
||||||
@ -4113,20 +4237,27 @@
|
|||||||
} catch (tabLoadError) {
|
} catch (tabLoadError) {
|
||||||
console.error('Tab data reload failed:', tabLoadError);
|
console.error('Tab data reload failed:', tabLoadError);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
window.loadCaseTabData = loadCaseTabData;
|
||||||
|
|
||||||
caseTabs.addEventListener('click', (event) => {
|
caseTabs.addEventListener('click', async (event) => {
|
||||||
const btn = event.target.closest('[data-bs-target]');
|
const trigger = event.target.closest('[data-bs-target]');
|
||||||
if (!btn) return;
|
if (!trigger || !caseTabs.contains(trigger)) 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) {
|
if (!tabId) return;
|
||||||
setTimeout(() => forceCaseTabActivation(tabId), 0);
|
|
||||||
}
|
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
|
// Focus on title when create modal opens
|
||||||
const createModalEl = document.getElementById('createRelatedCaseModal');
|
const createModalEl = document.getElementById('createRelatedCaseModal');
|
||||||
@ -5231,6 +5362,12 @@
|
|||||||
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);
|
||||||
@ -5240,9 +5377,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)}
|
||||||
<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})"
|
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('');
|
||||||
|
|
||||||
@ -5961,8 +6098,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div> <!-- /#inner-center-col -->
|
||||||
</div></div><!-- slut inner cols -->
|
</div> <!-- /.row.g-4 (inner) -->
|
||||||
|
</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">
|
||||||
@ -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 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 class="btn btn-sm btn-outline-primary"
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||||
onclick="window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi())"
|
onclick="event.stopPropagation(); window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi()); return false;"
|
||||||
title="Tilføj tag">
|
title="Tilføj tag">
|
||||||
<i class="bi bi-plus-lg"></i>
|
<i class="bi bi-plus-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -6293,6 +6431,42 @@
|
|||||||
</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">
|
||||||
@ -7610,7 +7784,8 @@
|
|||||||
|
|
||||||
function _refreshCommentCountBadge() {
|
function _refreshCommentCountBadge() {
|
||||||
const container = document.getElementById('comments-container');
|
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;
|
if (!container || !badge) return;
|
||||||
badge.textContent = String(container.querySelectorAll('.comment-item').length);
|
badge.textContent = String(container.querySelectorAll('.comment-item').length);
|
||||||
}
|
}
|
||||||
@ -11964,10 +12139,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
@ -12883,8 +13071,7 @@
|
|||||||
function openCaseEmailTab() {
|
function openCaseEmailTab() {
|
||||||
const trigger = document.getElementById('emails-tab');
|
const trigger = document.getElementById('emails-tab');
|
||||||
if (!trigger) return;
|
if (!trigger) return;
|
||||||
const instance = bootstrap.Tab.getOrCreateInstance(trigger);
|
forceCaseTabActivation('emails');
|
||||||
instance.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.quickReplyToEmailFromComment = async function(emailId) {
|
window.quickReplyToEmailFromComment = async function(emailId) {
|
||||||
@ -13963,7 +14150,7 @@
|
|||||||
|
|
||||||
async function loadSubscriptionForCase() {
|
async function loadSubscriptionForCase() {
|
||||||
try {
|
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) {
|
if (res.status === 404) {
|
||||||
showSubscriptionCreateForm();
|
showSubscriptionCreateForm();
|
||||||
setModuleContentState('subscription', false);
|
setModuleContentState('subscription', false);
|
||||||
@ -13972,7 +14159,15 @@
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error('Kunne ikke hente abonnement');
|
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);
|
renderSubscription(subscription);
|
||||||
setModuleContentState('subscription', true);
|
setModuleContentState('subscription', true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -14153,8 +14348,9 @@
|
|||||||
|| document.querySelector(`[data-module-tab="${tabParam}"]`);
|
|| document.querySelector(`[data-module-tab="${tabParam}"]`);
|
||||||
if (tabBtn) {
|
if (tabBtn) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
bootstrap.Tab.getOrCreateInstance(tabBtn).show();
|
const targetSelector = tabBtn.getAttribute('data-bs-target') || '';
|
||||||
forceCaseTabActivation(tabParam);
|
const targetTabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : tabParam;
|
||||||
|
forceCaseTabActivation(targetTabId);
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16404
app/modules/sag/templates/detail_v3.html
Normal file
16404
app/modules/sag/templates/detail_v3.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -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):
|
async def get_subscription_by_sag(sag_id: int, allow_missing: bool = Query(False)):
|
||||||
"""Get latest subscription for a case."""
|
"""Get latest subscription for a case."""
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
@ -236,6 +236,8 @@ async def get_subscription_by_sag(sag_id: int):
|
|||||||
"""
|
"""
|
||||||
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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -273,16 +273,47 @@ class TagPicker {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
let errorDetail = '';
|
||||||
if (error.detail.includes('already exists')) {
|
try {
|
||||||
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show success feedback
|
// Backward compatibility: some case pages still persist tags via /sag/{id}/tags.
|
||||||
this.showSuccess(tag.name);
|
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) {
|
} 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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user