diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 821508b..df9fadb 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -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.""" diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index 295fc36..0d60a40 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -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.""" diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index 49bdeb7..4f5f944 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -13963,7 +13963,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 +13972,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) { diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html new file mode 100644 index 0000000..85fe5bc --- /dev/null +++ b/app/modules/sag/templates/detail_v3.html @@ -0,0 +1,16218 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}{{ case.titel }} - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + +
+
+
+
+ #{{ case.id }} + + {{ case.status|capitalize if case.status else 'Γ…ben' }} + + {% set tkey = (case.template_key or case.type or 'ticket')|lower %} + {% set type_icons = {'ticket': 'bi-ticket-perforated', 'pipeline': 'bi-graph-up-arrow', 'opgave': 'bi-puzzle', 'ordre': 'bi-receipt', 'projekt': 'bi-folder2-open', 'service': 'bi-tools'} %} + {% set type_labels = {'ticket': 'Ticket', 'pipeline': 'Pipeline', 'opgave': 'Opgave', 'ordre': 'Ordre', 'projekt': 'Projekt', 'service': 'Service'} %} + {% set type_colors = {'ticket': '#6366f1', 'pipeline': '#0ea5e9', 'opgave': '#f59e0b', 'ordre': '#10b981', 'projekt': '#8b5cf6', 'service': '#ef4444'} %} + {% set tcolor = type_colors.get(tkey, '#0f4c75') %} + {% set ticon = type_icons.get(tkey, 'bi-card-text') %} + {% set tlabel = type_labels.get(tkey, tkey|capitalize) %} + + {{ tlabel }} + +
+
+
+ Næste todo + Henter næste todo... + - +
+ +
+
+ +
+
+

{{ case.titel }}

+ +
+
+ +
+ + +
+
+
+

+ {{ customer.name if customer else 'Ingen kunde valgt' }} + | + {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }} + | + {{ customer.department if customer and customer.department else 'Ingen afdeling' }} + | + Oprettet: {{ case.created_at.strftime('%d. %b %Y') if case.created_at else '-' }} +

+
+
+ + +
+
+ + +
+
+
+
Klassifikation
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + +
+
+
+
Tildeling
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+
+ + +
+
+
+
Tidslinje
+ +
+ + +
+ +
+ +
+ + + +
+
+ +
+ +
+ + +
+
+ +
+ {% if case.deferred_until_case_id and case.deferred_until_status %} + Trigger #{{ case.deferred_until_case_id }} β†’ {{ case.deferred_until_status|replace('__any_change__', 'Skift') }} + {% endif %} +
+
+
+
+ + +
+
+
+
System Info
+ +
+ +
+ + +
+
+
+
+ +
+
+ + + + + + + + +
+ +
+
+
+
+ +
+
+ +
+ + + + + +
+ +
+
+
+
+
+
+

Sagsbeskrivelse

+ +
+
+ +
+ +
+
+
Opgavebeskrivelse
+ +
+
{{ case.titel }}
+
{{ case.beskrivelse or '' }}
+ {% if not case.beskrivelse %} +
+

Ingen opgavebeskrivelse tilfΓΈjet endnu.

+ Dobbeltklik for at tilfΓΈje +
+ {% endif %} +
+
+ + +
+ +
+ Ctrl+Enter for at gemme Β· Esc for at annullere +
+ + + +
+
+
+ +
+
+
Kommentarer
+
+ 0/0 vises + {{ comments|length if comments else 0 }} +
+ + Kilde + + + + +
+ +
+ {% if comments %} + {% for comment in comments %} + {% if comment.er_system_besked or comment.forfatter == 'System' %} +
+ {% elif comment.er_intern %} +
+ {% else %} +
+ {% endif %} +
+ {{ (comment.forfatter or 'Bruger')[:2]|upper }} + {{ comment.forfatter }} + {% if comment.er_system_besked or comment.forfatter == 'System' %} + System + {% elif comment.er_intern %} + Intern + {% else %} + Ekstern + {% endif %} + {{ comment.created_at.strftime('%d/%m-%Y %H:%M') }} +
+
{{ comment.indhold|replace('\n', '
')|safe }}
+
+ {% endfor %} + {% else %} +

Ingen kommentarer endnu.

+ {% endif %} +
+
Ingen kommentarer matcher de valgte filtre.
+ +
+ +
+
+
+ + +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+
+
+
+
Relationer
+ +
+
+ + +
+
+
+ {% macro render_relation_rows(nodes, level=0) %} + {% for node in nodes %} + + {{ level }} + + #{{ node.case.id }} + + + {% if node.is_current %} + {{ node.case.titel }} + Aktuel + {% else %} + {{ node.case.titel }} + {% endif %} + + + {% set row_status = (node.case.status or '')|lower %} + + {{ node.case.status or '-' }} + + + + {{ node.case.template_key or node.case.type or 'ticket' }} + + + {% set rel_raw = (node.relation_type or '')|trim %} + {% set rel_key = rel_raw|lower %} + {% if node.is_current %} + Rodsag + {% elif rel_key == 'afledt af' %} + Kommer fra +
(Afledt af)
+ {% elif rel_key == 'Γ₯rsag til' %} + Skaber fΓΈlge-sag +
(Γ…rsag til)
+ {% elif rel_key == 'blokkerer' %} + Blokerer + {% else %} + Koblet til +
(Relateret til)
+ {% endif %} + {% if node.is_repeated %} + + {% endif %} + + +
+ {% if node.relation_id %} + + {% endif %} + + +
+ + + {% if node.children %} + {{ render_relation_rows(node.children, level + 1) }} + {% endif %} + {% endfor %} + {% endmacro %} + + {% set has_relations = relation_tree and (relation_tree|length > 1 or (relation_tree|length == 1 and relation_tree[0].children)) %} + {% if has_relations %} +
+ + + + + + + + + + + + + + {{ render_relation_rows(relation_tree) }} + +
Niv.SagTitelStatusTypeSammenhængHandling
+
+ {% else %} +

Ingen relaterede sager

+ {% endif %} +
+
+
+
+ + +
+ +
+
+
+
Filer & Dokumenter
+ + +
+ +
+
+ +

Træk filer hertil for at uploade

+
+
+
Ingen filer fundet...
+
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
Tid & Fakturering
+
+ +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+ + + + + + + + + + + {% for entry in time_entries %} + + + + + + + {% else %} + + + + {% endfor %} + +
DatoBeskrivelseBrugerTimer
{{ entry.worked_date }}{{ entry.description or '-' }}{{ entry.user_name }}{{ entry.original_hours }}
+ Ingen tid registreret endnu +
+
+ + + {% if prepaid_cards %} +
+
Aktive Klippekort
+
+ {% for card in prepaid_cards %} +
+
+
Kort #{{ card.card_number or card.id }}
+
{{ '%.2f' % card.remaining_hours }} timer tilbage
+
+
+ {% endfor %} +
+
+ {% endif %} +
+
+ +
+
+
+
+
+
+
Lokationer
+ +
+
+
+
Henter lokationer...
+
+
+
+ +
+
+
TAGS
+ +
+
+
+
Indlaeser tags...
+
+
+
Forslag (brand/type)
+
+
Indlaeser forslag...
+
+
+
+
+ +
+
+
Kunder
+ +
+
+ {% if customers %} +
+ Navn + Rolle + E-mail + Slet +
+ {% for customer in customers %} +
+ + {{ customer.role or '-' }} + {{ customer.customer_email or '-' }} + +
+ {% endfor %} + {% else %} +

Ingen kunder

+ {% endif %} +
+
+ +
+
+
Kontakter
+ +
+
+ {% if contacts %} +
+ Navn + Titel + Kunde + Slet +
+ {% for contact in contacts %} +
+
{{ contact.contact_name }}
+ {{ contact.title or '-' }} + {{ contact.customer_name or '-' }} + +
+ {% endfor %} + {% else %} +

Ingen kontakter

+ {% endif %} +
+
+ +
+
+
Hardware
+
+ + +
+
+
+
+
Henter hardware...
+
+
+
+ +
+
+
Salgspipeline
+
+ + +
+
+
+
+
+
+
+
Stage
+
+ {% set ns = namespace(selected_stage=None) %} + {% for stage in pipeline_stages or [] %} + {% if case.pipeline_stage_id == stage.id %} + {% set ns.selected_stage = stage %} + {% endif %} + {% endfor %} + {% if ns.selected_stage %} + {{ ns.selected_stage.name }} + {% else %} + Ikke sat + {% endif %} +
+
+
+
+
+
Sandsynlighed
+
{{ case.pipeline_probability if case.pipeline_probability is not none else 0 }}%
+
+
+
+
+
BelΓΈb
+
+ {% if case.pipeline_amount is not none %} + {{ "{:,.2f}".format(case.pipeline_amount|float).replace(',', 'X').replace('.', ',').replace('X', '.') }} kr. + {% else %} + Ikke sat + {% endif %} +
+
+
+
+
+
+
Beskrivelse
+
{{ case.pipeline_description or 'Ingen beskrivelse' }}
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ +
+
+
Opkaldshistorik
+ + + +
+
+ {% if call_history and call_history|length > 0 %} +
+ + + + + + + + + + + + {% for call in call_history %} + + + + + + + + {% endfor %} + +
DatoRetningNummerBrugerVarighed
{{ call.started_at.strftime('%d/%m/%Y %H:%M') if call.started_at else '-' }}{{ 'UdgΓ₯ende' if call.direction == 'outbound' else 'IndgΓ₯ende' }} + {% if call.ekstern_nummer %} +
+ {{ call.ekstern_nummer }} + +
+ {% else %} + - + {% endif %} +
{{ call.full_name or call.username or '-' }} + {% if call.duration_sec is not none %} + {{ (call.duration_sec // 60)|int }}:{{ '%02d'|format((call.duration_sec % 60)|int) }} + {% elif call.ended_at %} + - + {% else %} + I gang + {% endif %} +
+
+ {% else %} +
Ingen opkald linket til denne sag
+ {% endif %} +
+
+ +
+
+
Todo-opgaver
+ +
+
+
+ + +
+ + +
+
+
+
Ingen opgaver endnu
+
+
+
+ +
+
+
Kunde-wiki
+
+
+
+ +
+
+
Henter wiki...
+
+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if nextcloud_instance %} + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+{% endblock %} diff --git a/app/subscriptions/backend/router.py b/app/subscriptions/backend/router.py index 4fb563d..1b2100b 100644 --- a/app/subscriptions/backend/router.py +++ b/app/subscriptions/backend/router.py @@ -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( """