import logging from datetime import datetime, timezone from typing import Any, Dict, List, Optional from app.core.database import execute_query, execute_query_single logger = logging.getLogger(__name__) CLOSED_CASE_STATUSES = ("lukket", "løst", "closed", "resolved") URGENT_PRIORITIES = ("urgent", "high", "kritisk", "critical") def _safe_count(row: Optional[dict], key: str = "count") -> int: if not row: return 0 try: return int(row.get(key) or 0) except (TypeError, ValueError): return 0 def _format_elapsed(seconds: int) -> str: total = max(0, int(seconds or 0)) hours = total // 3600 minutes = (total % 3600) // 60 secs = total % 60 return f"{hours:02d}:{minutes:02d}:{secs:02d}" def _priority_rank(priority: str) -> int: normalized = str(priority or "").strip().lower() if normalized in {"urgent", "critical", "kritisk"}: return 3 if normalized in {"high", "høj"}: return 2 if normalized in {"normal", "medium", "middel"}: return 1 return 0 def _table_exists(table_name: str) -> bool: row = execute_query_single( """ SELECT EXISTS( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = %s ) AS exists """, (table_name,), ) return bool((row or {}).get("exists")) def _table_columns(table_name: str) -> List[str]: rows = execute_query( """ SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = %s """, (table_name,), ) or [] return [str(r.get("column_name") or "").strip().lower() for r in rows if r.get("column_name")] def _get_user_group_names(user_id: Optional[int]) -> List[str]: if user_id is None: return [] rows = execute_query( """ SELECT LOWER(g.name) AS name FROM user_groups ug JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = %s """, (user_id,), ) or [] return [str(r.get("name") or "").strip() for r in rows if r.get("name")] def _can_view_boss_tab(user_id: Optional[int]) -> bool: if user_id is None: return False group_names = _get_user_group_names(user_id) if not group_names: # Fail-open for authenticated users if group mapping is missing. return True leadership_tokens = ( "admin", "manager", "leder", "chef", "teknik", "technician", "support", "drift", "it", ) return any( any(token in group for token in leadership_tokens) for group in group_names ) def is_bottom_bar_enabled(user_id: Optional[int]) -> bool: setting = execute_query_single("SELECT value FROM settings WHERE key = %s", ("bottom_bar_enabled",)) setting_value = str((setting or {}).get("value") or "").strip().lower() if setting_value not in {"1", "true", "yes", "on"}: return False if user_id is None: return True pref = execute_query_single( """ SELECT enabled FROM user_module_preferences WHERE user_id = %s AND module_name = %s LIMIT 1 """, (user_id, "bottom_bar"), ) if pref and pref.get("enabled") is not None: return bool(pref.get("enabled")) role = execute_query_single( """ SELECT mrs.enabled FROM module_role_settings mrs JOIN user_groups ug ON ug.group_id = mrs.group_id WHERE ug.user_id = %s AND mrs.module_name = %s ORDER BY mrs.enabled DESC LIMIT 1 """, (user_id, "bottom_bar"), ) if role and role.get("enabled") is not None: return bool(role.get("enabled")) return True def get_dashboard_status() -> Dict[str, int]: mails_unread = _safe_count( execute_query_single( """ SELECT COUNT(*) AS count FROM email_messages WHERE deleted_at IS NULL AND COALESCE(is_read, FALSE) = FALSE """ ) ) sager_open = _safe_count( execute_query_single( """ SELECT COUNT(*) AS count FROM sag_sager WHERE deleted_at IS NULL AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') """ ) ) sager_urgent = _safe_count( execute_query_single( """ SELECT COUNT(*) AS count FROM sag_sager WHERE deleted_at IS NULL AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') """ ) ) sager_unassigned = _safe_count( execute_query_single( """ SELECT COUNT(*) AS count FROM sag_sager WHERE deleted_at IS NULL AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND ansvarlig_bruger_id IS NULL """ ) ) return { "mails_unread": mails_unread, "sager_open": sager_open, "sager_urgent": sager_urgent, "sager_unassigned": sager_unassigned, } def get_active_timer(user_id: Optional[int]) -> Dict[str, Any]: if user_id is None: return { "active": False, "sag_id": None, "sag_navn": None, "start_tid": None, "elapsed": 0, "elapsed_hhmmss": "00:00:00", "time_entry_id": None, } timer = execute_query_single( """ SELECT t.id, t.sag_id, s.titel AS sag_navn, t.start_tid, GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int, 0) AS elapsed FROM tmodule_times t LEFT JOIN sag_sager s ON s.id = t.sag_id WHERE t.medarbejder_id = %s AND t.aktiv_timer = TRUE AND t.slut_tid IS NULL ORDER BY t.start_tid DESC NULLS LAST, t.id DESC LIMIT 1 """, (user_id,), ) if not timer: return { "active": False, "sag_id": None, "sag_navn": None, "start_tid": None, "elapsed": 0, "elapsed_hhmmss": "00:00:00", "time_entry_id": None, } elapsed = int(timer.get("elapsed") or 0) return { "active": True, "sag_id": timer.get("sag_id"), "sag_navn": timer.get("sag_navn"), "start_tid": timer.get("start_tid"), "elapsed": elapsed, "elapsed_hhmmss": _format_elapsed(elapsed), "time_entry_id": timer.get("id"), } def get_own_timer_snapshot(user_id: Optional[int], paused_limit: int = 10) -> Dict[str, Any]: active = get_active_timer(user_id) if user_id is None: return { "active": active, "paused": [], "counts": {"active": 0, "paused": 0, "total": 0}, } paused_limit_safe = max(1, min(int(paused_limit or 10), 25)) paused_rows = execute_query( """ SELECT t.id, t.sag_id, s.titel AS sag_navn, t.start_tid, t.slut_tid, GREATEST( EXTRACT(EPOCH FROM (NOW() - COALESCE(t.start_tid, NOW())))::int, 0 ) AS elapsed_seconds, COALESCE(t.pause_total_seconds, 0)::int AS pause_total_seconds FROM tmodule_times t LEFT JOIN sag_sager s ON s.id = t.sag_id WHERE t.medarbejder_id = %s AND t.aktiv_timer = FALSE AND t.paused_at IS NOT NULL AND t.slut_tid IS NULL ORDER BY COALESCE(t.paused_at, t.updated_at, t.created_at) DESC, t.id DESC LIMIT %s """, (user_id, paused_limit_safe), ) or [] paused_count_row = execute_query_single( """ SELECT COUNT(*)::int AS count FROM tmodule_times t WHERE t.medarbejder_id = %s AND t.aktiv_timer = FALSE AND t.paused_at IS NOT NULL AND t.slut_tid IS NULL """, (user_id,), ) paused_count = _safe_count(paused_count_row) active_count = 1 if active.get("active") else 0 return { "active": active, "paused": [ { "time_entry_id": row.get("id"), "sag_id": row.get("sag_id"), "sag_navn": row.get("sag_navn") or f"Sag #{row.get('sag_id')}", "start_tid": row.get("start_tid"), "slut_tid": row.get("slut_tid"), "faktisk_tid_min": 0, "elapsed_hhmmss": _format_elapsed( max(0, int(row.get("elapsed_seconds") or 0) - int(row.get("pause_total_seconds") or 0)) ), } for row in paused_rows ], "counts": { "active": active_count, "paused": paused_count, "total": active_count + paused_count, }, } def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]: limit_safe = max(1, min(int(limit or 25), 100)) rows = execute_query( """ SELECT s.id, s.titel, s.priority, s.created_at, s.updated_at FROM sag_sager s WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND s.ansvarlig_bruger_id IS NULL ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC LIMIT %s """, (limit_safe,), ) or [] count_row = execute_query_single( """ SELECT COUNT(*)::int AS count FROM sag_sager s WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND s.ansvarlig_bruger_id IS NULL """ ) return { "items": [ { "id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}", "priority": row.get("priority") or "normal", "created_at": row.get("created_at"), "updated_at": row.get("updated_at"), } for row in rows ], "count": _safe_count(count_row), "filter_meta": { "route": "/api/v1/bottom-bar/boss/unassigned-cases", "query": {"limit": limit_safe, "only_open": True, "only_unassigned": True}, "sql_guarantee": [ "s.deleted_at IS NULL", "LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')", "s.ansvarlig_bruger_id IS NULL", ], }, } def _get_recent_cases(user_id: Optional[int], limit: int = 10) -> Dict[str, Any]: limit_safe = max(1, min(int(limit or 10), 20)) source = "direct_query" rows: List[Dict[str, Any]] = [] if _table_exists("sag_recent_cases"): columns = set(_table_columns("sag_recent_cases")) has_required = {"sag_id", "user_id"}.issubset(columns) if has_required: order_column = "viewed_at" if "viewed_at" in columns else "opened_at" if "opened_at" in columns else "updated_at" if "updated_at" in columns else "created_at" if order_column: source = "sag_recent_cases" rows = execute_query( f""" SELECT s.id, s.titel, s.priority, s.status, rc.{order_column} AS recent_at FROM sag_recent_cases rc JOIN sag_sager s ON s.id = rc.sag_id WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND rc.user_id = %s ORDER BY rc.{order_column} DESC, s.id DESC LIMIT %s """, (user_id, limit_safe), ) or [] if not rows and user_id is not None: source = "direct_query_user_timers" rows = execute_query( """ SELECT s.id, s.titel, s.priority, s.status, MAX(COALESCE(t.start_tid, t.updated_at, t.created_at)) AS recent_at FROM tmodule_times t JOIN sag_sager s ON s.id = t.sag_id WHERE t.medarbejder_id = %s AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') GROUP BY s.id, s.titel, s.priority, s.status ORDER BY recent_at DESC, s.id DESC LIMIT %s """, (user_id, limit_safe), ) or [] if not rows: source = "direct_query_global" rows = execute_query( """ SELECT s.id, s.titel, s.priority, s.status, COALESCE(s.updated_at, s.created_at) AS recent_at FROM sag_sager s WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC LIMIT %s """, (limit_safe,), ) or [] return { "source": source, "items": [ { "id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}", "priority": row.get("priority") or "normal", "status": row.get("status"), "recent_at": row.get("recent_at"), } for row in rows ], "count": len(rows), } def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]: if user_id is None: return {"items": [], "count": 0} limit_safe = max(1, min(int(limit or 20), 100)) reminders = execute_query( """ SELECT r.id, r.sag_id, r.title, r.message, r.priority, r.event_type, r.next_check_at, s.titel AS case_title, c.name AS customer_name FROM sag_reminders r JOIN sag_sager s ON r.sag_id = s.id JOIN customers c ON s.customer_id = c.id LEFT JOIN LATERAL ( SELECT id, snoozed_until, status, triggered_at FROM sag_reminder_logs WHERE reminder_id = r.id AND user_id = %s ORDER BY triggered_at DESC LIMIT 1 ) l ON true WHERE r.is_active = TRUE AND r.deleted_at IS NULL AND r.next_check_at <= CURRENT_TIMESTAMP AND %s = ANY(r.recipient_user_ids) AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP) AND (l.status IS NULL OR l.status != 'dismissed') ORDER BY CASE LOWER(COALESCE(r.priority::text, 'normal')) WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, r.next_check_at ASC LIMIT %s """, (user_id, user_id, limit_safe), ) or [] unread_mail_count = _safe_count( execute_query_single( """ SELECT COUNT(*) AS count FROM email_messages em WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE """ ) ) items: List[Dict[str, Any]] = [] if unread_mail_count > 0: items.append( { "id": f"mail-unread-{unread_mail_count}", "type": "mail", "severity": "medium" if unread_mail_count < 10 else "high", "title": f"{unread_mail_count} ulæste mails", "message": "Der er ulæste mails i indbakken", "action": "/emails", "created_at": datetime.now(timezone.utc).isoformat(), } ) for row in reminders: priority = str(row.get("priority") or "normal").lower() severity = "low" if priority in {"high", "høj"}: severity = "medium" if priority in {"urgent", "critical", "kritisk"}: severity = "high" items.append( { "id": f"reminder-{row.get('id')}", "type": row.get("event_type") or "reminder", "severity": severity, "title": row.get("title") or "Påmindelse", "message": row.get("message") or row.get("case_title") or "", "sag_id": row.get("sag_id"), "case_title": row.get("case_title"), "customer_name": row.get("customer_name"), "action": f"/sag/{row.get('sag_id')}/v3" if row.get("sag_id") else "/sag", "created_at": row.get("next_check_at"), } ) items.sort( key=lambda item: ( {"high": 0, "medium": 1, "low": 2}.get(str(item.get("severity") or "low"), 3), str(item.get("created_at") or ""), ) ) return {"items": items[:limit_safe], "count": len(items)} def _context_actions_for_path(context_path: str) -> Dict[str, Any]: normalized = str(context_path or "").strip().lower() payload: Dict[str, Any] = { "context_key": "global", "global": [ {"id": "new_case", "label": "Ny sag", "action": "/sag"}, {"id": "new_mail", "label": "Ny mail", "action": "/emails"}, {"id": "start_timer", "label": "Start timer", "action": "/timetracking"}, {"id": "log_time", "label": "Log tid", "action": "/timetracking"}, {"id": "add_note", "label": "Tilføj note", "action": "/sag"}, ], "context": [], } if normalized.startswith("/sag"): payload["context_key"] = "sag" payload["context"] = [ {"id": "case_time", "label": "Tid", "action": "/timetracking"}, {"id": "case_mail", "label": "Mail", "action": "/emails"}, {"id": "case_relation", "label": "Relation", "action": "/customers"}, {"id": "case_tag", "label": "Tag", "action": "/tags"}, ] elif normalized.startswith("/hardware"): payload["context_key"] = "hardware" payload["context"] = [ {"id": "hardware_new", "label": "Ny enhed", "action": "/hardware"}, {"id": "hardware_history", "label": "Historik", "action": "/hardware"}, {"id": "hardware_link_case", "label": "Tilknyt sag", "action": "/sag"}, ] return payload def get_user_notes_summary(user_id: Optional[int], limit: int = 10) -> Dict[str, Any]: if user_id is None: return {"count": 0, "list": []} limit_safe = max(1, min(int(limit or 10), 50)) rows = execute_query( """ SELECT id, title, content, is_pinned, is_archived, created_at, updated_at FROM user_notes WHERE user_id = %s AND deleted_at IS NULL AND is_archived = FALSE ORDER BY is_pinned DESC, updated_at DESC, id DESC LIMIT %s """, (user_id, limit_safe), ) or [] total_row = execute_query_single( """ SELECT COUNT(*) AS count FROM user_notes WHERE user_id = %s AND deleted_at IS NULL AND is_archived = FALSE """, (user_id,), ) return { "count": _safe_count(total_row), "list": [ { "id": row.get("id"), "title": row.get("title") or "", "content": row.get("content") or "", "is_pinned": bool(row.get("is_pinned")), "is_archived": bool(row.get("is_archived")), "created_at": row.get("created_at"), "updated_at": row.get("updated_at"), } for row in rows ], } def build_bottom_bar_state( user_id: Optional[int], context_path: str = "", force_boss_access: bool = False, ) -> Dict[str, Any]: enabled = is_bottom_bar_enabled(user_id) if not enabled: return {"enabled": False, "sections": {}} status = get_dashboard_status() timer = get_active_timer(user_id) own_timers = get_own_timer_snapshot(user_id, paused_limit=10) notifications = get_notifications(user_id, limit=10) unassigned_open_cases = get_unassigned_open_cases(limit=8) recent_cases = _get_recent_cases(user_id, limit=10) notes_summary = get_user_notes_summary(user_id, limit=10) urgent_cases = execute_query( """ SELECT id, titel FROM sag_sager WHERE deleted_at IS NULL AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') ORDER BY updated_at DESC NULLS LAST, id DESC LIMIT 5 """ ) or [] open_cases = execute_query( """ SELECT id, titel FROM sag_sager WHERE deleted_at IS NULL AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') ORDER BY updated_at DESC NULLS LAST, id DESC LIMIT 5 """ ) or [] timer_list: List[Dict[str, Any]] = [] if timer.get("active"): timer_list.append( { "id": timer.get("time_entry_id"), "sag_id": timer.get("sag_id"), "desc": timer.get("sag_navn") or f"Sag #{timer.get('sag_id')}", "elapsed": timer.get("elapsed"), "elapsed_hhmmss": timer.get("elapsed_hhmmss"), } ) messages = [ { "from": "System", "text": f"{notifications.get('count', 0)} aktive notifikationer", } ] tasks = [] for n in (notifications.get("items") or [])[:5]: tasks.append( { "title": n.get("title") or "Notifikation", "deadline": n.get("severity") or "info", "action": n.get("action") or "/", } ) context_actions = _context_actions_for_path(context_path) can_view_boss = bool(force_boss_access) or _can_view_boss_tab(user_id) team_workload: List[Dict[str, Any]] = [] technicians_today: List[Dict[str, Any]] = [] escalation_cases: List[Dict[str, Any]] = [] unassigned_cases: List[Dict[str, Any]] = [] if can_view_boss: team_workload = execute_query( """ SELECT u.user_id, COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name, COUNT(s.id)::int AS open_cases, COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases FROM users u LEFT JOIN sag_sager s ON s.ansvarlig_bruger_id = u.user_id AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') GROUP BY u.user_id, u.full_name, u.username HAVING COUNT(s.id) > 0 ORDER BY urgent_cases DESC, open_cases DESC, owner_name ASC LIMIT 8 """ ) or [] technicians_today = execute_query( """ SELECT u.user_id, COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name, COUNT(s.id)::int AS open_cases, COUNT(CASE WHEN s.deadline::date = CURRENT_DATE THEN 1 END)::int AS due_today_cases, COALESCE( ( SELECT json_agg( json_build_object( 'id', t.id, 'title', t.titel, 'priority', COALESCE(t.priority::text, 'normal'), 'deadline', t.deadline ) ORDER BY CASE WHEN t.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END, COALESCE(t.deadline, t.updated_at, t.created_at) ASC, t.id ASC ) FROM ( SELECT s2.id, s2.titel, s2.priority, s2.deadline, s2.updated_at, s2.created_at FROM sag_sager s2 WHERE s2.ansvarlig_bruger_id = u.user_id AND s2.deleted_at IS NULL AND LOWER(COALESCE(s2.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND ( s2.deadline::date = CURRENT_DATE OR s2.created_at::date = CURRENT_DATE ) ORDER BY CASE WHEN s2.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END, COALESCE(s2.deadline, s2.updated_at, s2.created_at) ASC, s2.id ASC LIMIT 6 ) t ), '[]'::json ) AS today_tasks FROM users u LEFT JOIN sag_sager s ON s.ansvarlig_bruger_id = u.user_id AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') WHERE EXISTS ( SELECT 1 FROM user_groups ug JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.user_id AND LOWER(g.name) LIKE ANY(ARRAY['%teknik%', '%technician%', '%support%']) ) GROUP BY u.user_id, u.full_name, u.username ORDER BY due_today_cases DESC, open_cases DESC, owner_name ASC LIMIT 10 """ ) or [] escalation_cases = execute_query( """ SELECT s.id, s.titel, s.priority, s.updated_at, EXTRACT(EPOCH FROM (NOW() - COALESCE(s.updated_at, s.created_at)))::int AS age_seconds, COALESCE(NULLIF(u.full_name, ''), u.username, 'Ikke tildelt') AS owner_name FROM sag_sager s LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours' ORDER BY COALESCE(s.updated_at, s.created_at) ASC LIMIT 8 """ ) or [] unassigned_cases = [ { "id": row.get("id"), "titel": row.get("title"), "priority": row.get("priority"), } for row in (unassigned_open_cases.get("items") or []) ] sections = { "mail": { "unread": status.get("mails_unread", 0), "customer_reply_needed": status.get("mails_unread", 0), }, "cases": { "open": status.get("sager_open", 0), "list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in open_cases], }, "urgent": { "count": status.get("sager_urgent", 0), "list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in urgent_cases], }, "unassigned": { "count": status.get("sager_unassigned", 0), "list": unassigned_open_cases.get("items") or [], "filter_meta": unassigned_open_cases.get("filter_meta") or {}, }, "timer": { "active_count": 1 if timer.get("active") else 0, "list": timer_list, "active": timer, "own": own_timers, "switch_case_hooks": { "fetch_own_active_paused_timers": { "route": "/api/v1/bottom-bar/timers/own", "method": "GET", "query": {"paused_limit": 10}, }, "switch_case_start_timer": { "route": "/api/v1/timetracking/time/start", "method": "POST", "payload": { "sag_id": "required:int", "medarbejder_id": "optional:int", "beskrivelse": "optional:string", }, }, }, }, "kuma": { "down": 0, "list": [], }, "eset": { "incidents": 0, "list": [], }, "messages": { "count": len(messages), "list": messages, }, "tasks": { "count": len(tasks), "list": tasks, }, "recent_cases": recent_cases, "notes": notes_summary, "boss": { "can_view": can_view_boss, "stats": { "unassigned": status.get("sager_unassigned", 0), "active_employees": _safe_count( execute_query_single( "SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer = TRUE AND slut_tid IS NULL" ) ), "open_cases": status.get("sager_open", 0), "urgent_cases": status.get("sager_urgent", 0), "stale_urgent_cases": len(escalation_cases), } , "team_workload": [ { "user_id": row.get("user_id"), "owner_name": row.get("owner_name"), "open_cases": int(row.get("open_cases") or 0), "urgent_cases": int(row.get("urgent_cases") or 0), } for row in team_workload ], "technicians_today": [ { "user_id": row.get("user_id"), "owner_name": row.get("owner_name"), "open_cases": int(row.get("open_cases") or 0), "due_today_cases": int(row.get("due_today_cases") or 0), "today_tasks": row.get("today_tasks") or [], } for row in technicians_today ], "escalations": [ { "id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}", "priority": row.get("priority") or "normal", "owner_name": row.get("owner_name") or "Ikke tildelt", "age_seconds": int(row.get("age_seconds") or 0), } for row in escalation_cases ], "unassigned_cases": [ { "id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}", "priority": row.get("priority") or "normal", } for row in unassigned_cases ], }, "context_actions": context_actions, } return { "enabled": True, "sections": sections, "status": status, "active_timer": timer, "own_timers": own_timers, "recent_cases": recent_cases, "notifications": notifications, }