diff --git a/app/modules/bottom_bar/backend/router.py b/app/modules/bottom_bar/backend/router.py index 09d74e8..3bfc2b3 100644 --- a/app/modules/bottom_bar/backend/router.py +++ b/app/modules/bottom_bar/backend/router.py @@ -1,15 +1,18 @@ from typing import Optional +import logging 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 app.core.database import execute_query, execute_query_single, execute_update -from .service import build_bottom_bar_state +from .service import build_bottom_bar_state, get_own_timer_snapshot, get_unassigned_open_cases router = APIRouter() +logger = logging.getLogger(__name__) +_USER_NOTES_SCHEMA_READY = False class BossAssignPayload(BaseModel): @@ -21,6 +24,456 @@ class BossAssignNextPayload(BaseModel): assignee_user_id: int +class UserNoteCreatePayload(BaseModel): + title: Optional[str] = None + content: str + is_pinned: bool = False + + +class UserNoteUpdatePayload(BaseModel): + title: Optional[str] = None + content: Optional[str] = None + is_pinned: Optional[bool] = None + is_archived: Optional[bool] = None + + +class NoteToCaseCommentPayload(BaseModel): + sag_id: int + excerpt: Optional[str] = None + + +class NoteToContactPayload(BaseModel): + contact_id: int + field: str + value: Optional[str] = None + excerpt: Optional[str] = None + mode: str = "append" + + +class NoteToCustomerPayload(BaseModel): + customer_id: int + field: str + value: Optional[str] = None + excerpt: Optional[str] = None + mode: str = "append" + + +def _ensure_user_notes_schema() -> None: + global _USER_NOTES_SCHEMA_READY + if _USER_NOTES_SCHEMA_READY: + return + + exists = execute_query_single("SELECT to_regclass('public.user_notes') AS table_name") or {} + if exists.get("table_name"): + _USER_NOTES_SCHEMA_READY = True + return + + execute_query( + """ + CREATE TABLE IF NOT EXISTS user_notes ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL DEFAULT '', + content TEXT NOT NULL, + is_pinned BOOLEAN NOT NULL DEFAULT FALSE, + is_archived BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL + ) + """ + ) + + execute_query( + """ + CREATE INDEX IF NOT EXISTS idx_user_notes_user_active + ON user_notes (user_id, is_archived, is_pinned, updated_at DESC) + WHERE deleted_at IS NULL + """ + ) + execute_query( + """ + CREATE INDEX IF NOT EXISTS idx_user_notes_user_updated + ON user_notes (user_id, updated_at DESC) + WHERE deleted_at IS NULL + """ + ) + execute_query( + """ + CREATE OR REPLACE FUNCTION update_user_notes_updated_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; + END; + $$ LANGUAGE plpgsql + """ + ) + execute_query("DROP TRIGGER IF EXISTS trg_user_notes_updated_at ON user_notes") + execute_query( + """ + CREATE TRIGGER trg_user_notes_updated_at + BEFORE UPDATE ON user_notes + FOR EACH ROW + EXECUTE FUNCTION update_user_notes_updated_at() + """ + ) + + _USER_NOTES_SCHEMA_READY = True + logger.warning("⚠️ user_notes table was missing and has been created automatically") + + +def _resolve_current_user_display_name(current_user: dict) -> str: + current_user_id = current_user.get("id") + if current_user_id is None: + return "System" + + row = execute_query_single( + """ + SELECT full_name, username + FROM users + WHERE user_id = %s + """, + (int(current_user_id),), + ) or {} + return str(row.get("full_name") or row.get("username") or f"Bruger #{current_user_id}") + + +def _get_owned_note_or_404(note_id: int, user_id: int) -> dict: + _ensure_user_notes_schema() + row = execute_query_single( + """ + SELECT id, user_id, title, content, is_pinned, is_archived, created_at, updated_at + FROM user_notes + WHERE id = %s + AND user_id = %s + AND deleted_at IS NULL + """, + (int(note_id), int(user_id)), + ) + if not row: + raise HTTPException(status_code=404, detail="Note ikke fundet") + return row + + +def _normalize_note_text(value: Optional[str]) -> str: + return str(value or "").strip() + + +def _build_merge_value(current_value: Optional[str], incoming_value: str, mode: str) -> str: + incoming = _normalize_note_text(incoming_value) + if not incoming: + return str(current_value or "") + + current = str(current_value or "").strip() + normalized_mode = str(mode or "append").strip().lower() + if normalized_mode == "replace": + return incoming + + if not current: + return incoming + if incoming in current: + return current + return f"{current}\n{incoming}" + + +@router.get("/notes") +@router.get("/notes/") +async def list_user_notes( + include_archived: bool = Query(default=False), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + 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") + _ensure_user_notes_schema() + + 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 (%s = TRUE OR is_archived = FALSE) + ORDER BY is_pinned DESC, updated_at DESC, id DESC + LIMIT %s OFFSET %s + """, + (int(current_user_id), bool(include_archived), int(limit), int(offset)), + ) or [] + + total_row = execute_query_single( + """ + SELECT COUNT(*) AS count + FROM user_notes + WHERE user_id = %s + AND deleted_at IS NULL + AND (%s = TRUE OR is_archived = FALSE) + """, + (int(current_user_id), bool(include_archived)), + ) or {} + + return { + "items": rows, + "count": int(total_row.get("count") or 0), + } + + +@router.post("/notes") +@router.post("/notes/") +async def create_user_note(payload: UserNoteCreatePayload, 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") + _ensure_user_notes_schema() + + content = _normalize_note_text(payload.content) + if not content: + raise HTTPException(status_code=400, detail="Note-indhold kan ikke være tomt") + + title = _normalize_note_text(payload.title) + row = execute_query_single( + """ + INSERT INTO user_notes (user_id, title, content, is_pinned) + VALUES (%s, %s, %s, %s) + RETURNING id, title, content, is_pinned, is_archived, created_at, updated_at + """, + (int(current_user_id), title, content, bool(payload.is_pinned)), + ) + + return row or {} + + +@router.patch("/notes/{note_id}") +@router.patch("/notes/{note_id}/") +async def update_user_note(note_id: int, payload: UserNoteUpdatePayload, 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") + _ensure_user_notes_schema() + + _get_owned_note_or_404(note_id, int(current_user_id)) + + sets = [] + params = [] + + if payload.title is not None: + sets.append("title = %s") + params.append(_normalize_note_text(payload.title)) + if payload.content is not None: + content = _normalize_note_text(payload.content) + if not content: + raise HTTPException(status_code=400, detail="Note-indhold kan ikke være tomt") + sets.append("content = %s") + params.append(content) + if payload.is_pinned is not None: + sets.append("is_pinned = %s") + params.append(bool(payload.is_pinned)) + if payload.is_archived is not None: + sets.append("is_archived = %s") + params.append(bool(payload.is_archived)) + + if not sets: + return _get_owned_note_or_404(note_id, int(current_user_id)) + + params.extend([int(note_id), int(current_user_id)]) + row = execute_query_single( + f""" + UPDATE user_notes + SET {', '.join(sets)} + WHERE id = %s + AND user_id = %s + AND deleted_at IS NULL + RETURNING id, title, content, is_pinned, is_archived, created_at, updated_at + """, + tuple(params), + ) + if not row: + raise HTTPException(status_code=404, detail="Note ikke fundet") + return row + + +@router.delete("/notes/{note_id}") +@router.delete("/notes/{note_id}/") +async def delete_user_note(note_id: int, 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") + _ensure_user_notes_schema() + + deleted = execute_update( + """ + UPDATE user_notes + SET deleted_at = CURRENT_TIMESTAMP + WHERE id = %s + AND user_id = %s + AND deleted_at IS NULL + """, + (int(note_id), int(current_user_id)), + ) + if not deleted: + raise HTTPException(status_code=404, detail="Note ikke fundet") + return {"status": "deleted", "note_id": int(note_id)} + + +@router.post("/notes/{note_id}/actions/sag-comment") +@router.post("/notes/{note_id}/actions/sag-comment/") +async def note_to_case_comment(note_id: int, payload: NoteToCaseCommentPayload, 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") + + note = _get_owned_note_or_404(note_id, int(current_user_id)) + case_row = execute_query_single( + """ + SELECT id + FROM sag_sager + WHERE id = %s + AND deleted_at IS NULL + """, + (int(payload.sag_id),), + ) + if not case_row: + raise HTTPException(status_code=404, detail="Sag ikke fundet") + + text = _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content")) + if not text: + raise HTTPException(status_code=400, detail="Ingen tekst at indsætte") + + author = _resolve_current_user_display_name(current_user) + created = execute_query_single( + """ + INSERT INTO sag_kommentarer (sag_id, indhold, forfatter) + VALUES (%s, %s, %s) + RETURNING id, sag_id, indhold, forfatter, created_at + """, + (int(payload.sag_id), text, author), + ) or {} + + return { + "status": "inserted", + "target": "sag_comment", + "note_id": int(note_id), + "sag_id": int(payload.sag_id), + "comment": created, + } + + +@router.post("/notes/{note_id}/actions/contact-update") +@router.post("/notes/{note_id}/actions/contact-update/") +async def note_to_contact_update(note_id: int, payload: NoteToContactPayload, 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") + + note = _get_owned_note_or_404(note_id, int(current_user_id)) + allowed_fields = {"phone", "mobile", "email", "title", "department"} + field = str(payload.field or "").strip().lower() + if field not in allowed_fields: + raise HTTPException(status_code=400, detail="Ugyldigt kontaktfelt") + + contact = execute_query_single( + f"SELECT id, {field} FROM contacts WHERE id = %s", + (int(payload.contact_id),), + ) + if not contact: + raise HTTPException(status_code=404, detail="Kontakt ikke fundet") + + incoming = _normalize_note_text(payload.value) or _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content")) + if not incoming: + raise HTTPException(status_code=400, detail="Ingen tekst at indsætte") + + merged = _build_merge_value(contact.get(field), incoming, payload.mode) + updated = execute_query_single( + f""" + UPDATE contacts + SET {field} = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + RETURNING id, {field} + """, + (merged, int(payload.contact_id)), + ) or {} + + return { + "status": "updated", + "target": "contact", + "note_id": int(note_id), + "contact_id": int(payload.contact_id), + "field": field, + "value": updated.get(field), + } + + +@router.post("/notes/{note_id}/actions/customer-update") +@router.post("/notes/{note_id}/actions/customer-update/") +async def note_to_customer_update(note_id: int, payload: NoteToCustomerPayload, 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") + + note = _get_owned_note_or_404(note_id, int(current_user_id)) + field = str(payload.field or "").strip().lower() + allowed_fields = {"phone", "mobile_phone", "email", "address", "invoice_email", "note"} + if field not in allowed_fields: + raise HTTPException(status_code=400, detail="Ugyldigt firmafelt") + + customer = execute_query_single( + "SELECT id FROM customers WHERE id = %s", + (int(payload.customer_id),), + ) + if not customer: + raise HTTPException(status_code=404, detail="Firma ikke fundet") + + incoming = _normalize_note_text(payload.value) or _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content")) + if not incoming: + raise HTTPException(status_code=400, detail="Ingen tekst at indsætte") + + if field == "note": + author = _resolve_current_user_display_name(current_user) + created = execute_query_single( + """ + INSERT INTO customer_notes (customer_id, note_type, note, created_by) + VALUES (%s, %s, %s, %s) + RETURNING id, customer_id, note_type, note, created_by, created_at + """, + (int(payload.customer_id), "general", incoming, author), + ) or {} + return { + "status": "inserted", + "target": "customer_note", + "note_id": int(note_id), + "customer_id": int(payload.customer_id), + "record": created, + } + + current = execute_query_single( + f"SELECT {field} FROM customers WHERE id = %s", + (int(payload.customer_id),), + ) or {} + merged = _build_merge_value(current.get(field), incoming, payload.mode) + updated = execute_query_single( + f""" + UPDATE customers + SET {field} = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + RETURNING id, {field} + """, + (merged, int(payload.customer_id)), + ) or {} + + return { + "status": "updated", + "target": "customer", + "note_id": int(note_id), + "customer_id": int(payload.customer_id), + "field": field, + "value": updated.get(field), + } + + 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: @@ -56,6 +509,27 @@ async def get_bottom_bar_state(request: Request, current_user: dict = Depends(ge 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) + +@router.get("/timers/own") +async def get_own_timers( + paused_limit: int = Query(default=10, ge=1, le=25), + 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") + return get_own_timer_snapshot(int(current_user_id), paused_limit=paused_limit) + + +@router.get("/boss/unassigned-cases") +async def list_unassigned_open_cases( + limit: int = Query(default=25, ge=1, le=100), + 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") + return get_unassigned_open_cases(limit=limit) + from app.services.task_routing import TaskRouter from app.services.m365_calendar import M365CalendarService @@ -98,8 +572,8 @@ def _get_next_unassigned_case() -> Optional[dict]: 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 + WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0 + WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('high', 'høj') THEN 1 ELSE 2 END, COALESCE(updated_at, created_at) ASC, @@ -159,7 +633,7 @@ async def boss_auto_assign_next(current_user: dict = Depends(get_current_user)): 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 + COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, '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 diff --git a/app/modules/bottom_bar/backend/service.py b/app/modules/bottom_bar/backend/service.py index b1b737b..040e22b 100644 --- a/app/modules/bottom_bar/backend/service.py +++ b/app/modules/bottom_bar/backend/service.py @@ -38,6 +38,32 @@ def _priority_rank(priority: str) -> int: 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 [] @@ -227,6 +253,222 @@ def get_active_timer(user_id: Optional[int]) -> Dict[str, Any]: } +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} @@ -367,6 +609,59 @@ def _context_actions_for_path(context_path: str) -> Dict[str, Any]: 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 = "", @@ -378,7 +673,11 @@ def build_bottom_bar_state( 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( """ @@ -538,22 +837,14 @@ def build_bottom_bar_state( """ ) 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 [] + 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": { @@ -570,11 +861,30 @@ def build_bottom_bar_state( }, "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, @@ -592,6 +902,8 @@ def build_bottom_bar_state( "count": len(tasks), "list": tasks, }, + "recent_cases": recent_cases, + "notes": notes_summary, "boss": { "can_view": can_view_boss, "stats": { @@ -652,5 +964,7 @@ def build_bottom_bar_state( "sections": sections, "status": status, "active_timer": timer, + "own_timers": own_timers, + "recent_cases": recent_cases, "notifications": notifications, } diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 0157c0a..821508b 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -858,6 +858,98 @@ async def get_sag(sag_id: int): raise HTTPException(status_code=500, detail="Failed to get case") +@router.post("/sag/recent/open/{sag_id:int}") +async def mark_sag_recent_open(sag_id: int, request: Request): + """Persist that authenticated user opened a case (deduped, newest-first, max 10).""" + user_id = _get_user_id_from_request(request) + + try: + case_row = execute_query_single( + """ + SELECT id, titel + FROM sag_sager + WHERE id = %s + AND deleted_at IS NULL + """, + (sag_id,), + ) + if not case_row: + raise HTTPException(status_code=404, detail="Case not found") + + upserted = execute_query( + """ + INSERT INTO sag_recent_cases (user_id, sag_id, opened_at) + VALUES (%s, %s, NOW()) + ON CONFLICT (user_id, sag_id) + DO UPDATE SET opened_at = EXCLUDED.opened_at + RETURNING user_id, sag_id, opened_at + """, + (user_id, sag_id), + ) + + execute_query( + """ + DELETE FROM sag_recent_cases + WHERE user_id = %s + AND id IN ( + SELECT id + FROM sag_recent_cases + WHERE user_id = %s + ORDER BY opened_at DESC, id DESC + OFFSET 10 + ) + """, + (user_id, user_id), + ) + + row = (upserted or [{}])[0] + return { + "sag_id": row.get("sag_id", sag_id), + "titel": case_row.get("titel"), + "opened_at": row.get("opened_at"), + } + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error persisting recent case open for user %s and case %s: %s", user_id, sag_id, e) + raise HTTPException(status_code=500, detail="Failed to persist recent case open") + + +@router.get("/sag/recent") +async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10)): + """List authenticated user's recently opened cases (newest first).""" + user_id = _get_user_id_from_request(request) + + try: + rows = execute_query( + """ + SELECT + r.sag_id, + s.titel, + r.opened_at + FROM sag_recent_cases r + JOIN sag_sager s ON s.id = r.sag_id + WHERE r.user_id = %s + AND s.deleted_at IS NULL + ORDER BY r.opened_at DESC, r.id DESC + LIMIT %s + """, + (user_id, limit), + ) or [] + + return [ + { + "sag_id": row.get("sag_id"), + "titel": row.get("titel"), + "opened_at": row.get("opened_at"), + } + for row in rows + ] + except Exception as e: + logger.error("❌ Error listing recent cases for user %s: %s", user_id, e) + raise HTTPException(status_code=500, detail="Failed to list recent cases") + + @router.get("/sag/{sag_id}/modules") async def get_case_module_prefs(sag_id: int): """Get module visibility preferences for a case.""" diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index e713222..295fc36 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -166,13 +166,15 @@ async def sager_liste( customer_id: str = Query(None), ansvarlig_bruger_id: str = Query(None), assigned_group_id: str = Query(None), + unassigned: bool = Query(False), include_deferred: bool = Query(False), ): """Display list of all cases.""" try: # Coerce string params to optional ints customer_id_int = _coerce_optional_int(customer_id) - ansvarlig_bruger_id_int = _coerce_optional_int(ansvarlig_bruger_id) + requested_unassigned = bool(unassigned) or str(ansvarlig_bruger_id or "").strip().upper() == "__UNASSIGNED__" + ansvarlig_bruger_id_int = None if requested_unassigned else _coerce_optional_int(ansvarlig_bruger_id) assigned_group_id_int = _coerce_optional_int(assigned_group_id) query = """ SELECT s.*, @@ -245,7 +247,9 @@ async def sager_liste( if customer_id_int: query += " AND s.customer_id = %s" params.append(customer_id_int) - if ansvarlig_bruger_id_int: + if requested_unassigned: + query += " AND s.ansvarlig_bruger_id IS NULL" + elif ansvarlig_bruger_id_int: query += " AND s.ansvarlig_bruger_id = %s" params.append(ansvarlig_bruger_id_int) if assigned_group_id_int: @@ -300,7 +304,9 @@ async def sager_liste( if customer_id_int: fallback_query += " AND s.customer_id = %s" fallback_params.append(customer_id_int) - if ansvarlig_bruger_id_int: + if requested_unassigned: + fallback_query += " AND s.ansvarlig_bruger_id IS NULL" + elif ansvarlig_bruger_id_int: fallback_query += " AND s.ansvarlig_bruger_id = %s" fallback_params.append(ansvarlig_bruger_id_int) @@ -382,6 +388,7 @@ async def sager_liste( "current_customer_id": customer_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_assigned_group_id": assigned_group_id_int, + "current_unassigned": requested_unassigned, }) except Exception: logger.exception("❌ Error displaying case list") @@ -401,6 +408,7 @@ async def sager_liste( "current_customer_id": customer_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_assigned_group_id": assigned_group_id_int, + "current_unassigned": requested_unassigned, }) @router.get("/sag/new", response_class=HTMLResponse) diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index 1e6b52e..fb266cc 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -3728,6 +3728,19 @@ let customerSearchMode = 'link'; const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }}; + async function markCaseAsRecentlyOpened() { + try { + await fetch(`/api/v1/sag/recent/open/${caseId}`, { + method: 'POST', + credentials: 'include', + keepalive: true, + headers: { 'Accept': 'application/json' } + }); + } catch (_err) { + // Best effort only; page should not break if this call fails. + } + } + function escapeCaseTopAlertHtml(value) { return String(value ?? '') .replace(/&/g, '&') @@ -3839,6 +3852,7 @@ // Initialize everything when DOM is ready document.addEventListener('DOMContentLoaded', () => { + markCaseAsRecentlyOpened(); hydrateTopbarStatusOptions(); loadCaseCustomerTopAlerts(); applyTopbarImportanceBubbles(); diff --git a/app/modules/sag/templates/index.html b/app/modules/sag/templates/index.html index d171d58..c98692f 100644 --- a/app/modules/sag/templates/index.html +++ b/app/modules/sag/templates/index.html @@ -443,6 +443,7 @@
+
+ +
+ + +
+ +
+ + +
+ +
Tip: brug kun den del af noten du vil gemme i målet.
+ + + + + + - +