diff --git a/app/core/config.py b/app/core/config.py index b48b964..e105dd5 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -287,6 +287,9 @@ class Settings(BaseSettings): SMS_USERNAME: str = "" SMS_SENDER: str = "BMC Networks" SMS_WEBHOOK_SECRET: str = "" + + # Bottom bar module + BOTTOM_BAR_ENABLED: bool = False # Dev-only shortcuts DEV_ALLOW_ARCHIVED_IMPORT: bool = False diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html index 354afc8..e6f643b 100644 --- a/app/emails/frontend/emails.html +++ b/app/emails/frontend/emails.html @@ -1004,6 +1004,8 @@ style="border-radius: 20px;"> diff --git a/app/modules/bottom_bar/__init__.py b/app/modules/bottom_bar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/bottom_bar/backend/__init__.py b/app/modules/bottom_bar/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/bottom_bar/backend/public_router.py b/app/modules/bottom_bar/backend/public_router.py new file mode 100644 index 0000000..aced919 --- /dev/null +++ b/app/modules/bottom_bar/backend/public_router.py @@ -0,0 +1,121 @@ +import asyncio +import json +import logging +from typing import Optional + +from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect + +from app.core.auth_service import AuthService +from .service import get_active_timer, get_dashboard_status, get_notifications + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _resolve_user_id_from_request(request: Request) -> Optional[int]: + user_id = getattr(request.state, "user_id", None) + if user_id is not None: + try: + return int(user_id) + except (TypeError, ValueError): + return None + + user_id_param = request.query_params.get("user_id") + if user_id_param: + try: + return int(user_id_param) + except (TypeError, ValueError): + return None + + return None + + +def _resolve_ws_payload(websocket: WebSocket) -> Optional[dict]: + token = websocket.query_params.get("token") + auth_header = (websocket.headers.get("authorization") or "").strip() + if not token and auth_header.lower().startswith("bearer "): + token = auth_header.split(" ", 1)[1].strip() + + payload = AuthService.verify_token(token) if token else None + if not payload: + access_cookie_token = (websocket.cookies.get("access_token") or "").strip() or None + payload = AuthService.verify_token(access_cookie_token) if access_cookie_token else None + return payload + + +@router.get("/api/v1/dashboard/status") +async def get_dashboard_status_endpoint() -> dict: + return get_dashboard_status() + + +@router.get("/api/v1/timer/active") +async def get_active_timer_endpoint(request: Request) -> dict: + user_id = _resolve_user_id_from_request(request) + return get_active_timer(user_id) + + +@router.get("/api/v1/notifications") +async def get_notifications_endpoint(request: Request, limit: int = 20) -> dict: + user_id = _resolve_user_id_from_request(request) + return get_notifications(user_id, limit=limit) + + +@router.websocket("/api/v1/bottom-bar/ws") +async def bottom_bar_ws(websocket: WebSocket): + payload = _resolve_ws_payload(websocket) + if not payload: + await websocket.close(code=1008) + return + + try: + user_id = int(payload.get("sub")) if payload.get("sub") is not None else None + except (TypeError, ValueError): + await websocket.close(code=1008) + return + + await websocket.accept() + + initial_status = get_dashboard_status() + initial_notifications = get_notifications(user_id, limit=20) + await websocket.send_json({"event": "status_delta", "data": initial_status}) + await websocket.send_json({"event": "notification_delta", "data": initial_notifications}) + + last_status_json = json.dumps(initial_status, sort_keys=True, default=str) + last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str) + last_timer_elapsed = -1 + status_tick = 0 + + try: + while True: + timer = get_active_timer(user_id) + elapsed = int(timer.get("elapsed") or 0) + if elapsed != last_timer_elapsed: + await websocket.send_json({"event": "timer_tick", "data": timer}) + last_timer_elapsed = elapsed + + status_tick += 1 + if status_tick >= 5: + status = get_dashboard_status() + notifications = get_notifications(user_id, limit=20) + + status_json = json.dumps(status, sort_keys=True, default=str) + if status_json != last_status_json: + await websocket.send_json({"event": "status_delta", "data": status}) + last_status_json = status_json + + notifications_json = json.dumps(notifications, sort_keys=True, default=str) + if notifications_json != last_notifications_json: + await websocket.send_json({"event": "notification_delta", "data": notifications}) + last_notifications_json = notifications_json + + status_tick = 0 + + try: + await asyncio.wait_for(websocket.receive_text(), timeout=1.0) + except TimeoutError: + continue + except WebSocketDisconnect: + logger.info("Bottom bar websocket disconnected user_id=%s", user_id) + except Exception as exc: + logger.warning("Bottom bar websocket error user_id=%s error=%s", user_id, exc) diff --git a/app/modules/bottom_bar/backend/router.py b/app/modules/bottom_bar/backend/router.py new file mode 100644 index 0000000..09d74e8 --- /dev/null +++ b/app/modules/bottom_bar/backend/router.py @@ -0,0 +1,288 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel + +from app.core.auth_service import AuthService +from app.core.auth_dependencies import get_current_user +from app.core.database import execute_query, execute_query_single + +from .service import build_bottom_bar_state + +router = APIRouter() + + +class BossAssignPayload(BaseModel): + case_id: int + assignee_user_id: int + + +class BossAssignNextPayload(BaseModel): + assignee_user_id: int + + +def _resolve_user_id_from_request(request: Request) -> Optional[int]: + state_user_id = getattr(request.state, "user_id", None) + if state_user_id is not None: + try: + return int(state_user_id) + except (TypeError, ValueError): + pass + + user_id_param = request.query_params.get("user_id") + if user_id_param: + try: + return int(user_id_param) + except (TypeError, ValueError): + pass + + token = (request.cookies.get("access_token") or "").strip() or None + payload = AuthService.verify_token(token) if token else None + sub_claim = payload.get("sub") if payload else None + if sub_claim is not None: + try: + return int(sub_claim) + except (TypeError, ValueError): + return None + return None + +@router.get("/state") +async def get_bottom_bar_state(request: Request, current_user: dict = Depends(get_current_user)): + current_user_id = current_user.get("id") + if current_user_id is None: + raise HTTPException(status_code=401, detail="Not authenticated") + user_id = int(current_user_id) + force_boss_access = bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")) + context_path = request.query_params.get("context") or "" + return build_bottom_bar_state(user_id, context_path=context_path, force_boss_access=force_boss_access) + +from app.services.task_routing import TaskRouter +from app.services.m365_calendar import M365CalendarService + + +def _has_boss_access(current_user: dict) -> bool: + if bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")): + return True + + current_user_id = current_user.get("id") + if current_user_id is None: + return False + + 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 + """, + (int(current_user_id),), + ) or [] + names = [str(r.get("name") or "") for r in rows] + tokens = ("admin", "manager", "leder", "chef", "teknik", "technician", "support") + return any(any(token in name for token in tokens) for name in names) + + +def _ensure_user_exists(user_id: int) -> None: + user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,)) + if not user: + raise HTTPException(status_code=404, detail="Bruger ikke fundet") + + +def _get_next_unassigned_case() -> Optional[dict]: + return execute_query_single( + """ + SELECT id, titel, priority + FROM sag_sager + WHERE deleted_at IS NULL + AND ansvarlig_bruger_id IS NULL + AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') + ORDER BY + CASE + WHEN LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0 + WHEN LOWER(COALESCE(priority, 'normal')) IN ('high', 'høj') THEN 1 + ELSE 2 + END, + COALESCE(updated_at, created_at) ASC, + id ASC + LIMIT 1 + """ + ) + +@router.post("/next_task") +async def assign_next_task( + request: Request, + user_id: int | None = Query(default=None), + current_user: dict = Depends(get_current_user), +): + # Prefer authenticated user context; allow explicit user_id for controlled testing. + current_user_id = current_user.get("id") + resolved_user_id = user_id + if resolved_user_id is None and current_user_id is not None: + resolved_user_id = int(current_user_id) + if resolved_user_id is None: + raise HTTPException(status_code=401, detail="Authentication required for task assignment") + + # Kombinerer de nye services + router_svc = TaskRouter() + cal = M365CalendarService() + + # Henter hvor meget fri tid medarbejderen har lige nu + free_mins = await cal.get_user_free_time("now", 2) + + # Bed the engine allocate the next best task + task = await router_svc.get_next_best_task(resolved_user_id) + task = task or {} + + return { + "status": "assigned", + "task": task, + "free_time_calculated": free_mins, + "message": f"Fandt Næste Opgave (SLA: {task.get('assigned_reason')} - {task.get('estimated_minutes')}m. Du har {free_mins}m frit). " + } + + +@router.post("/boss/auto-assign-next") +async def boss_auto_assign_next(current_user: dict = Depends(get_current_user)): + if not _has_boss_access(current_user): + raise HTTPException(status_code=403, detail="Insufficient permissions for boss action") + + next_case = _get_next_unassigned_case() + if not next_case: + return { + "status": "noop", + "message": "Ingen ufordelte åbne sager at fordele.", + } + + assignee = execute_query_single( + """ + 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, 'normal')) IN ('urgent', 'critical', 'kritisk', 'high', 'høj') THEN 1 END)::int AS hot_cases + FROM users u + JOIN user_groups ug ON ug.user_id = u.user_id + JOIN groups g ON g.id = ug.group_id + 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 LOWER(g.name) LIKE ANY(ARRAY['%admin%', '%manager%', '%leder%', '%chef%', '%teknik%', '%technician%', '%support%']) + GROUP BY u.user_id, u.full_name, u.username + ORDER BY hot_cases ASC, open_cases ASC, owner_name ASC + LIMIT 1 + """ + ) + if not assignee: + raise HTTPException(status_code=409, detail="Ingen kvalificeret medarbejder fundet til auto-fordeling") + + updated = execute_query_single( + """ + UPDATE sag_sager + SET ansvarlig_bruger_id = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + RETURNING id, titel, priority, ansvarlig_bruger_id + """, + (int(assignee["user_id"]), int(next_case["id"])), + ) + if not updated: + raise HTTPException(status_code=500, detail="Kunne ikke opdatere sag") + + return { + "status": "assigned", + "message": "Sagen blev auto-fordelt.", + "case": { + "id": updated.get("id"), + "title": updated.get("titel") or f"Sag #{updated.get('id')}", + "priority": updated.get("priority") or "normal", + }, + "assignee": { + "user_id": assignee.get("user_id"), + "name": assignee.get("owner_name") or f"Bruger #{assignee.get('user_id')}", + }, + } + + +@router.post("/boss/assign-case") +async def boss_assign_case(payload: BossAssignPayload, current_user: dict = Depends(get_current_user)): + if not _has_boss_access(current_user): + raise HTTPException(status_code=403, detail="Insufficient permissions for boss action") + + _ensure_user_exists(int(payload.assignee_user_id)) + + case_row = execute_query_single( + """ + SELECT id, titel, priority + FROM sag_sager + WHERE id = %s + AND deleted_at IS NULL + AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') + """, + (int(payload.case_id),), + ) + if not case_row: + raise HTTPException(status_code=404, detail="Sag ikke fundet eller er afsluttet") + + updated = execute_query_single( + """ + UPDATE sag_sager + SET ansvarlig_bruger_id = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + RETURNING id, titel, priority, ansvarlig_bruger_id + """, + (int(payload.assignee_user_id), int(payload.case_id)), + ) + if not updated: + raise HTTPException(status_code=500, detail="Kunne ikke tildele sag") + + return { + "status": "assigned", + "message": "Sagen blev tildelt.", + "case": { + "id": updated.get("id"), + "title": updated.get("titel") or f"Sag #{updated.get('id')}", + "priority": updated.get("priority") or "normal", + }, + "assignee_user_id": int(payload.assignee_user_id), + } + + +@router.post("/boss/assign-next-to-user") +async def boss_assign_next_to_user(payload: BossAssignNextPayload, current_user: dict = Depends(get_current_user)): + if not _has_boss_access(current_user): + raise HTTPException(status_code=403, detail="Insufficient permissions for boss action") + + _ensure_user_exists(int(payload.assignee_user_id)) + + next_case = _get_next_unassigned_case() + if not next_case: + return { + "status": "noop", + "message": "Ingen ufordelte åbne sager at tildele.", + } + + updated = execute_query_single( + """ + UPDATE sag_sager + SET ansvarlig_bruger_id = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + RETURNING id, titel, priority, ansvarlig_bruger_id + """, + (int(payload.assignee_user_id), int(next_case["id"])), + ) + if not updated: + raise HTTPException(status_code=500, detail="Kunne ikke tildele næste sag") + + return { + "status": "assigned", + "message": "Næste ufordelte sag blev tildelt.", + "case": { + "id": updated.get("id"), + "title": updated.get("titel") or f"Sag #{updated.get('id')}", + "priority": updated.get("priority") or "normal", + }, + "assignee_user_id": int(payload.assignee_user_id), + } diff --git a/app/modules/bottom_bar/backend/service.py b/app/modules/bottom_bar/backend/service.py new file mode 100644 index 0000000..43bb00b --- /dev/null +++ b/app/modules/bottom_bar/backend/service.py @@ -0,0 +1,656 @@ +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 _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, '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_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, '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')}" 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 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) + notifications = get_notifications(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, '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, '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, '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, '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 = 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 + LIMIT 8 + """ + ) 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), + }, + "timer": { + "active_count": 1 if timer.get("active") else 0, + "list": timer_list, + "active": timer, + }, + "kuma": { + "down": 0, + "list": [], + }, + "eset": { + "incidents": 0, + "list": [], + }, + "messages": { + "count": len(messages), + "list": messages, + }, + "tasks": { + "count": len(tasks), + "list": tasks, + }, + "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, + "notifications": notifications, + } diff --git a/app/modules/bottom_bar/module.json b/app/modules/bottom_bar/module.json new file mode 100644 index 0000000..f9faefb --- /dev/null +++ b/app/modules/bottom_bar/module.json @@ -0,0 +1,11 @@ +{ + "name": "bottom_bar", + "version": "1.0.0", + "description": "Global activity bottom bar module", + "author": "BMC Networks", + "enabled": true, + "dependencies": [], + "table_prefix": "bottom_bar_", + "api_prefix": "/api/v1", + "tags": ["Bottom Bar"] +} diff --git a/app/modules/hardware/frontend/views.py b/app/modules/hardware/frontend/views.py index 4a6160f..84b592b 100644 --- a/app/modules/hardware/frontend/views.py +++ b/app/modules/hardware/frontend/views.py @@ -221,52 +221,130 @@ async def hardware_list( request: Request, status: str = Query(None), asset_type: str = Query(None), + rental_scope: str = Query(None), customer_id: int = Query(None), q: str = Query(None) ): - """Display list of all hardware.""" - query = "SELECT * FROM hardware_assets WHERE deleted_at IS NULL" + """Display list of BMC-owned assets only.""" + query = """ + SELECT + ha.*, + c.name AS customer_name, + CASE + WHEN EXISTS ( + SELECT 1 + FROM subscription_asset_bindings b + WHERE b.asset_id = ha.id + AND b.deleted_at IS NULL + AND b.status = 'active' + AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE) + ) THEN true + ELSE false + END AS is_currently_rented + FROM hardware_assets ha + LEFT JOIN customers c ON c.id = ha.current_owner_customer_id + WHERE ha.deleted_at IS NULL + AND ha.current_owner_type = 'bmc' + """ params = [] if status: - query += " AND status = %s" + query += " AND ha.status = %s" params.append(status) if asset_type: - query += " AND asset_type = %s" + query += " AND ha.asset_type = %s" params.append(asset_type) if customer_id: - query += " AND current_owner_customer_id = %s" + query += " AND ha.current_owner_customer_id = %s" params.append(customer_id) if q: - query += " AND (serial_number ILIKE %s OR model ILIKE %s OR brand ILIKE %s)" + query += " AND (ha.serial_number ILIKE %s OR ha.model ILIKE %s OR ha.brand ILIKE %s OR ha.internal_asset_id ILIKE %s)" search_param = f"%{q}%" - params.extend([search_param, search_param, search_param]) + params.extend([search_param, search_param, search_param, search_param]) - query += " ORDER BY created_at DESC" + if rental_scope == "rented": + query += """ + AND EXISTS ( + SELECT 1 + FROM subscription_asset_bindings b + WHERE b.asset_id = ha.id + AND b.deleted_at IS NULL + AND b.status = 'active' + AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE) + ) + """ + elif rental_scope == "not_rented": + query += """ + AND NOT EXISTS ( + SELECT 1 + FROM subscription_asset_bindings b + WHERE b.asset_id = ha.id + AND b.deleted_at IS NULL + AND b.status = 'active' + AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE) + ) + """ + + query += " ORDER BY ha.created_at DESC" hardware = execute_query(query, tuple(params)) - # Get customer names for display - if hardware: - customer_ids = [h['current_owner_customer_id'] for h in hardware if h.get('current_owner_customer_id')] - if customer_ids: - customer_query = "SELECT id, name AS navn FROM customers WHERE id = ANY(%s)" - customers = execute_query(customer_query, (customer_ids,)) - customer_map = {c['id']: c['navn'] for c in customers} if customers else {} - - # Add customer names to hardware - for h in hardware: - if h.get('current_owner_customer_id'): - h['customer_name'] = customer_map.get(h['current_owner_customer_id'], 'Unknown') - return templates.TemplateResponse("modules/hardware/templates/index.html", { "request": request, "hardware": hardware, "current_status": status, "current_asset_type": asset_type, + "current_rental_scope": rental_scope, "search_query": q }) +@router.get("/hardware/customers", response_class=HTMLResponse) +async def customer_hardware_list( + request: Request, + status: str = Query(None), + asset_type: str = Query(None), + customer_id: int = Query(None), + q: str = Query(None) +): + """Display customer-owned hardware on dedicated page.""" + query = """ + SELECT + ha.*, + c.name AS customer_name + FROM hardware_assets ha + LEFT JOIN customers c ON c.id = ha.current_owner_customer_id + WHERE ha.deleted_at IS NULL + AND ha.current_owner_type = 'customer' + """ + params = [] + + if status: + query += " AND ha.status = %s" + params.append(status) + if asset_type: + query += " AND ha.asset_type = %s" + params.append(asset_type) + if customer_id: + query += " AND ha.current_owner_customer_id = %s" + params.append(customer_id) + if q: + query += " AND (ha.serial_number ILIKE %s OR ha.model ILIKE %s OR ha.brand ILIKE %s OR ha.internal_asset_id ILIKE %s OR c.name ILIKE %s)" + search_param = f"%{q}%" + params.extend([search_param, search_param, search_param, search_param, search_param]) + + query += " ORDER BY c.name ASC NULLS LAST, ha.created_at DESC" + customer_hardware = execute_query(query, tuple(params)) + + return templates.TemplateResponse("modules/hardware/templates/customers.html", { + "request": request, + "hardware": customer_hardware, + "current_status": status, + "current_asset_type": asset_type, + "search_query": q, + "current_customer_id": customer_id, + }) + + @router.get("/hardware/new", response_class=HTMLResponse) async def create_hardware_form(request: Request): """Display create hardware form.""" @@ -358,7 +436,7 @@ async def hardware_eset_import(request: Request): }) -@router.get("/hardware/{hardware_id}", response_class=HTMLResponse) +@router.get("/hardware/{hardware_id:int}", response_class=HTMLResponse) async def hardware_detail(request: Request, hardware_id: int): """Display hardware details.""" # Get hardware @@ -507,7 +585,7 @@ async def hardware_detail(request: Request, hardware_id: int): }) -@router.get("/hardware/{hardware_id}/edit", response_class=HTMLResponse) +@router.get("/hardware/{hardware_id:int}/edit", response_class=HTMLResponse) async def edit_hardware_form(request: Request, hardware_id: int): """Display edit hardware form.""" # Get hardware @@ -528,7 +606,7 @@ async def edit_hardware_form(request: Request, hardware_id: int): }) -@router.post("/hardware/{hardware_id}/location") +@router.post("/hardware/{hardware_id:int}/location") async def update_hardware_location( request: Request, hardware_id: int, @@ -574,7 +652,7 @@ async def update_hardware_location( return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303) -@router.post("/hardware/{hardware_id}/owner") +@router.post("/hardware/{hardware_id:int}/owner") async def update_hardware_owner( request: Request, hardware_id: int, @@ -649,7 +727,7 @@ async def update_hardware_owner( return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303) -@router.post("/hardware/{hardware_id}/contacts/add") +@router.post("/hardware/{hardware_id:int}/contacts/add") async def add_hardware_contact( request: Request, hardware_id: int, @@ -671,7 +749,7 @@ async def add_hardware_contact( return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303) -@router.post("/hardware/{hardware_id}/contacts/{contact_id}/delete") +@router.post("/hardware/{hardware_id:int}/contacts/{contact_id:int}/delete") async def remove_hardware_contact( request: Request, hardware_id: int, diff --git a/app/modules/hardware/templates/customers.html b/app/modules/hardware/templates/customers.html new file mode 100644 index 0000000..9430d05 --- /dev/null +++ b/app/modules/hardware/templates/customers.html @@ -0,0 +1,104 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Kundehardware - BMC Hub{% endblock %} + +{% block content %} +
+

Kundehardware

+
+ BMC Assets + Nyt Hardware +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +{% if hardware and hardware|length > 0 %} +
+
+
+ + + + + + + + + + + + + + {% for item in hardware %} + + + + + + + + + + {% endfor %} + +
HardwareKundeTypeSerienr.StatusAnyDeskHandling
{{ item.brand or 'Unknown' }} {{ item.model or '' }}{{ item.customer_name or 'Ukendt kunde' }}{{ item.asset_type|title }}{{ item.serial_number or 'Ingen serienummer' }}{{ item.status|replace('_', ' ')|title }} + {% if item.anydesk_link %} + {{ item.anydesk_id or 'Åbn' }} + {% elif item.anydesk_id %} + {{ item.anydesk_id }} + {% else %} + — + {% endif %} + + Se + Rediger +
+
+
+
+{% else %} +
+
Ingen kundehardware fundet
+

Der er ingen kundeejede enheder, der matcher filtre.

+
+{% endif %} +{% endblock %} diff --git a/app/modules/hardware/templates/index.html b/app/modules/hardware/templates/index.html index 832e074..879265e 100644 --- a/app/modules/hardware/templates/index.html +++ b/app/modules/hardware/templates/index.html @@ -242,8 +242,12 @@ {% block content %}