diff --git a/MDfile/RELEASE_NOTES_v2.7.0.md b/MDfile/RELEASE_NOTES_v2.7.0.md new file mode 100644 index 0000000..4a005d1 --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.7.0.md @@ -0,0 +1,25 @@ +# Release Notes: v2.7.0 + +**Dato:** 25. august 2026 + +## Overblik + +Version 2.7.0 gør linkede telefonopkald og AnyDesk-sessioner synlige i både sagens tidsregistrering og samlede historik. + +## Tidsregistrering + +- Ny oversigt over linkede opkald og AnyDesk-sessioner på sagen. +- Aktiviteter viser type, tidspunkt, varighed, kontakt/nummer og medarbejder. +- Aktiviteterne vises som kildedata og opretter ikke automatisk fakturerbar tid. + +## Historik + +- Linkede indgående og udgående opkald indgår som selvstændige historikhændelser. +- Linkede AnyDesk-sessioner indgår med status, varighed og ansvarlig medarbejder. +- Historik med undersager inkluderer også aktiviteter linket til undersagerne. + +## Verifikation + +- Sag-modul: **33 bestået**. +- Telefoni: **5 bestået**. +- Python-kompilering og diff-kontrol gennemført. diff --git a/VERSION b/VERSION index 097a15a..24ba9a3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.6.2 +2.7.0 diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 1c2f8c8..b692c2a 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -8,7 +8,7 @@ import base64 import html from pathlib import Path from datetime import datetime, timedelta, timezone -from typing import List, Optional, Dict +from typing import Any, List, Optional, Dict from uuid import uuid4 from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends @@ -3469,6 +3469,97 @@ async def get_kommentarer(sag_id: int): raise HTTPException(status_code=500, detail="Failed to get comments") +def _get_case_linked_activities(case_ids: List[int]) -> List[Dict[str, Any]]: + """Return non-billable source activities linked directly to cases.""" + if not case_ids: + return [] + + placeholders = ",".join(["%s"] * len(case_ids)) + activities: List[Dict[str, Any]] = [] + + if _table_exists("telefoni_opkald"): + call_query = f""" + SELECT t.id, t.sag_id, t.direction, t.ekstern_nummer, t.intern_extension, + t.started_at, t.ended_at, t.duration_sec, + COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Ukendt medarbejder') AS actor_name, + NULLIF(TRIM(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 IN ({placeholders}) + ORDER BY t.started_at DESC, t.id DESC + LIMIT 600 + """ + for row in execute_query(call_query, tuple(case_ids)) or []: + started_at = row.get("started_at") + if not started_at: + continue + direction = str(row.get("direction") or "inbound").lower() + duration_seconds = row.get("duration_sec") + if duration_seconds is None and row.get("ended_at") and isinstance(started_at, datetime) and isinstance(row.get("ended_at"), datetime): + duration_seconds = max(0, int((row["ended_at"] - started_at).total_seconds())) + activities.append({ + "id": f"call:{row.get('id')}", + "source_id": row.get("id"), + "activity_type": "call", + "sag_id": row.get("sag_id"), + "timestamp": started_at.isoformat() if isinstance(started_at, datetime) else str(started_at), + "ended_at": row.get("ended_at").isoformat() if isinstance(row.get("ended_at"), datetime) else row.get("ended_at"), + "duration_minutes": round(float(duration_seconds or 0) / 60, 1), + "actor_name": str(row.get("actor_name") or "Ukendt medarbejder"), + "contact_name": row.get("contact_name"), + "number": row.get("ekstern_nummer"), + "direction": direction, + "title": "Udgående opkald" if direction == "outbound" else "Indgående opkald", + "status": "completed" if row.get("ended_at") else "started", + }) + + if _table_exists("anydesk_sessions"): + anydesk_query = f""" + SELECT a.id, a.anydesk_session_id, a.sag_id, a.started_at, a.ended_at, + a.duration_minutes, a.status, + COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Ukendt medarbejder') AS actor_name, + NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name + FROM anydesk_sessions a + LEFT JOIN users u ON u.user_id = a.created_by_user_id + LEFT JOIN contacts c ON c.id = a.contact_id + WHERE a.sag_id IN ({placeholders}) + ORDER BY a.started_at DESC, a.id DESC + LIMIT 600 + """ + for row in execute_query(anydesk_query, tuple(case_ids)) or []: + started_at = row.get("started_at") + if not started_at: + continue + duration_minutes = row.get("duration_minutes") + if duration_minutes is None and row.get("ended_at") and isinstance(started_at, datetime) and isinstance(row.get("ended_at"), datetime): + duration_minutes = max(0, round((row["ended_at"] - started_at).total_seconds() / 60, 1)) + activities.append({ + "id": f"anydesk:{row.get('id')}", + "source_id": row.get("id"), + "external_id": row.get("anydesk_session_id"), + "activity_type": "anydesk", + "sag_id": row.get("sag_id"), + "timestamp": started_at.isoformat() if isinstance(started_at, datetime) else str(started_at), + "ended_at": row.get("ended_at").isoformat() if isinstance(row.get("ended_at"), datetime) else row.get("ended_at"), + "duration_minutes": float(duration_minutes or 0), + "actor_name": str(row.get("actor_name") or "Ukendt medarbejder"), + "contact_name": row.get("contact_name"), + "title": "AnyDesk-session", + "status": str(row.get("status") or "unknown"), + }) + + activities.sort(key=lambda item: str(item.get("timestamp") or ""), reverse=True) + return activities + + +@router.get("/sag/{sag_id}/linked-activities") +async def get_case_linked_activities(sag_id: int): + _assert_sag_exists(sag_id) + activities = _get_case_linked_activities([sag_id]) + return {"activities": activities, "total": len(activities)} + + @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.""" @@ -3576,6 +3667,27 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)): events = [] + for activity in _get_case_linked_activities(case_ids): + duration = float(activity.get("duration_minutes") or 0) + duration_text = f"{duration:g} min" if duration > 0 else "Varighed ikke registreret" + contact = str(activity.get("contact_name") or activity.get("number") or "Ukendt kontakt") + activity_type = str(activity.get("activity_type") or "activity") + description = f"{contact}\n{duration_text}" + if activity_type == "call" and activity.get("number") and activity.get("contact_name"): + description = f"{contact} · {activity.get('number')}\n{duration_text}" + events.append({ + "id": activity.get("id"), + "event_type": activity_type, + "event_subtype": activity.get("direction") or activity.get("status"), + "source": "call" if activity_type == "call" else "anydesk", + "timestamp": activity.get("timestamp"), + "sag_id": activity.get("sag_id"), + "sag_titel": next((row.get("titel") for row in case_rows if row.get("id") == activity.get("sag_id")), None), + "forfatter": activity.get("actor_name") or "System", + "title": activity.get("title"), + "description": description, + }) + case_details_query = f""" SELECT s.id, s.titel, s.created_at, COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'System') AS created_by_name diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html index 75c2ace..d52c4ec 100644 --- a/app/modules/sag/templates/detail_v3.html +++ b/app/modules/sag/templates/detail_v3.html @@ -8755,6 +8755,16 @@ +