Add migrations for recent cases, time tracking pause/resume, and user notes

- Created `sag_recent_cases` table to persist recently opened cases per user for quick access in the bottom bar.
- Added pause/resume support in `tmodule_times` by introducing `paused_at` and `pause_total_seconds` columns.
- Established `user_notes` table for personal user notes with indexing for active and updated notes, along with a trigger to update the `updated_at` timestamp on modifications.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Christian 2026-04-24 11:28:12 +02:00
parent ca6640c33c
commit 3452472ba9
12 changed files with 2455 additions and 83 deletions

View File

@ -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

View File

@ -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,
}

View File

@ -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."""

View File

@ -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)

View File

@ -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, '&amp;')
@ -3839,6 +3852,7 @@
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
markCaseAsRecentlyOpened();
hydrateTopbarStatusOptions();
loadCaseCustomerTopAlerts();
applyTopbarImportanceBubbles();

View File

@ -443,6 +443,7 @@
<div style="min-width: 220px;">
<select class="form-select" name="ansvarlig_bruger_id" id="assigneeFilter">
<option value="">Alle medarbejdere</option>
<option value="__UNASSIGNED__" {% if current_unassigned %}selected{% endif %}>Uden ansvarlig</option>
{% for user in assignment_users or [] %}
<option value="{{ user.user_id }}" {% if current_ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
{% endfor %}

View File

@ -68,6 +68,18 @@
transform: translateY(calc(100% + 12px));
opacity: 0;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
isolation: isolate;
}
.global-bottom-bar::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
background: linear-gradient(120deg, rgba(15, 76, 117, 0.08), rgba(15, 76, 117, 0.02) 35%, rgba(255, 255, 255, 0));
z-index: -1;
}
.global-bottom-bar.is-visible {
@ -163,6 +175,7 @@
font-size: 0.8rem;
font-weight: 600;
line-height: 1.2;
transition: all 0.2s ease;
}
.global-bottom-bar .dropdown-menu {
@ -443,10 +456,40 @@
line-height: 1.4;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
transition: transform 0.2s ease;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.global-bottom-bar .bb-tab-list li:hover {
transform: translateX(2px);
box-shadow: 0 4px 14px rgba(0,0,0,0.08);
}
#bbSwitchCaseModal .modal-content {
border: 1px solid rgba(var(--text-primary-rgb), 0.12);
border-radius: 14px;
background: var(--bg-card);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
}
#bbSwitchCaseModal .modal-header {
border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.08);
}
#bbSwitchCaseModal .list-group-item {
border-color: rgba(var(--text-primary-rgb), 0.08);
transition: background-color 0.2s ease;
}
#bbSwitchCaseModal .list-group-item:hover {
background-color: rgba(var(--text-primary-rgb), 0.03);
}
#bbQuickNoteInput {
border-color: rgba(var(--text-primary-rgb), 0.12);
}
#bbQuickNoteInput:focus {
border-color: var(--accent);
box-shadow: 0 0 0 0.2rem rgba(15, 76, 117, 0.12);
}
@media (max-width: 767.98px) {
@ -1009,18 +1052,6 @@
<button class="bb-chip" type="button" data-bb-key="unassigned"><i class="bi bi-person-x"></i> <span class="bb-chip-label">Uden ansvarlig</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Uden ansvarlig: 0</span></button>
</div>
<div class="bb-zone bb-zone-center">
<div class="dropdown">
<button id="bbQuickCreateBtn" class="bb-action-btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-plus-circle me-1"></i> Opret
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item" type="button" data-bb-create="new_case">Ny sag</button></li>
<li><button class="dropdown-item" type="button" data-bb-create="new_mail">Ny mail</button></li>
<li><button class="dropdown-item" type="button" data-bb-create="start_timer">Start timer</button></li>
<li><button class="dropdown-item" type="button" data-bb-create="log_time">Log tid</button></li>
<li><button class="dropdown-item" type="button" data-bb-create="add_note">Tilføj note</button></li>
</ul>
</div>
<button id="bbSearchBtn" class="bb-action-btn bb-search-btn" type="button" title="Søg (Cmd/Ctrl+K)">
<i class="bi bi-search"></i>
</button>
@ -1034,10 +1065,6 @@
<i class="bi bi-bell"></i>
<span id="bbNotificationsCount" class="bb-notification-count">0</span>
</button>
<button id="bbUserStatusBtn" class="bb-activity-chip" type="button" title="Profil">
<i class="bi bi-person-circle"></i>
<span id="bbUserStatusText">Bruger</span>
</button>
<button id="bbTimerPauseBtn" class="bb-action-btn" type="button" title="Pause timer"><i class="bi bi-pause-fill"></i></button>
<button id="bbTimerStopBtn" class="bb-action-btn" type="button" title="Stop timer"><i class="bi bi-stop-fill"></i></button>
<button id="bbTimerSwitchBtn" class="bb-action-btn" type="button" title="Skift sag"><i class="bi bi-arrow-left-right"></i></button>
@ -1055,6 +1082,7 @@
<button class="bb-tab-btn" type="button" data-bb-tab="timer" role="tab" aria-selected="false"><i class="bi bi-stopwatch"></i> Timer</button>
<button class="bb-tab-btn" type="button" data-bb-tab="messages" role="tab" aria-selected="false"><i class="bi bi-chat-dots"></i> Beskeder</button>
<button class="bb-tab-btn" type="button" data-bb-tab="tasks" role="tab" aria-selected="false"><i class="bi bi-calendar-check"></i> Opgaver</button>
<button class="bb-tab-btn" type="button" data-bb-tab="notes" role="tab" aria-selected="false"><i class="bi bi-journal-text"></i> Noter</button>
<!-- Vises kun for chefer, men her i markup -->
<button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-person-workspace"></i> Chef</button>
</div>
@ -1070,6 +1098,79 @@
</div>
</div>
<div class="modal fade" id="bbSwitchCaseModal" tabindex="-1" aria-hidden="true" aria-labelledby="bbSwitchCaseModalLabel">
<div class="modal-dialog modal-dialog-scrollable modal-lg modal-dialog-bottom">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bbSwitchCaseModalLabel"><i class="bi bi-arrow-left-right me-2"></i>Skift sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="bbSwitchCaseStatus" class="small text-muted mb-3">Henter data...</div>
<div id="bbSwitchTimerActions" class="mb-3 d-none">
<div class="fw-semibold mb-2">Aktiv timer fundet</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-warning btn-sm" data-bb-switch-action="pause-now"><i class="bi bi-pause-fill me-1"></i>Pause nu</button>
<button type="button" class="btn btn-outline-danger btn-sm" data-bb-switch-action="stop-now"><i class="bi bi-stop-fill me-1"></i>Stop nu</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bb-switch-action="continue-unchanged">Fortsæt uændret</button>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-lg-6">
<h6 class="mb-2">Dine aktive/pausede timere</h6>
<div id="bbSwitchTimersList" class="list-group small">
<div class="list-group-item text-muted">Henter timere...</div>
</div>
</div>
<div class="col-12 col-lg-6">
<h6 class="mb-2">Seneste sager</h6>
<div id="bbSwitchRecentCasesList" class="list-group small">
<div class="list-group-item text-muted">Henter sager...</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="bbNoteTargetModal" tabindex="-1" aria-hidden="true" aria-labelledby="bbNoteTargetModalLabel">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bbNoteTargetModalLabel"><i class="bi bi-journal-plus me-2"></i>Indsæt note</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="bbNoteTargetStatus" class="small text-muted mb-3">Vælg mål og indsæt tekst.</div>
<div class="mb-3">
<label for="bbNoteTargetIdInput" id="bbNoteTargetIdLabel" class="form-label">Mål ID</label>
<input type="number" class="form-control" id="bbNoteTargetIdInput" min="1" step="1" placeholder="ID">
</div>
<div class="mb-3 d-none" id="bbNoteTargetFieldWrap">
<label for="bbNoteTargetFieldSelect" id="bbNoteTargetFieldLabel" class="form-label">Felt</label>
<select id="bbNoteTargetFieldSelect" class="form-select"></select>
</div>
<div class="mb-3">
<label for="bbNoteTargetTextInput" class="form-label">Tekst der indsættes</label>
<textarea id="bbNoteTargetTextInput" class="form-control" rows="6" placeholder="Tekst fra note"></textarea>
</div>
<div class="form-text">Tip: brug kun den del af noten du vil gemme i målet.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="bbNoteTargetSubmitBtn">Indsæt</button>
</div>
</div>
</div>
</div>
<script>
window.addEventListener('unhandledrejection', function(event) {
const reason = event && event.reason;
@ -1088,7 +1189,7 @@ window.addEventListener('unhandledrejection', function(event) {
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.2"></script>
<script src="/static/js/sms.js?v=1.0"></script>
<script src="/static/js/bottom-bar.js?v=2.15"></script>
<script src="/static/js/bottom-bar.js?v=2.22"></script>
<script>
// Dark Mode Toggle Logic
const darkModeToggle = document.getElementById('darkModeToggle');

View File

@ -91,6 +91,35 @@ def _minutes_between(start: Optional[datetime], end: Optional[datetime]) -> Opti
return max(0, diff_seconds // 60)
def _seconds_between(start: Optional[datetime], end: Optional[datetime]) -> int:
if not start or not end:
return 0
return max(0, int((end - start).total_seconds()))
def _elapsed_minutes_excluding_pause(entry: Dict[str, Any], end: datetime) -> int:
start_tid = entry.get("start_tid")
if not start_tid:
return 0
total_seconds = _seconds_between(start_tid, end)
paused_seconds = int(entry.get("pause_total_seconds") or 0)
paused_at = entry.get("paused_at")
if paused_at:
paused_seconds += _seconds_between(paused_at, end)
effective_seconds = max(0, total_seconds - paused_seconds)
return effective_seconds // 60
def _pause_total_seconds_at(entry: Dict[str, Any], end: datetime) -> int:
paused_seconds = int(entry.get("pause_total_seconds") or 0)
paused_at = entry.get("paused_at")
if paused_at:
paused_seconds += _seconds_between(paused_at, end)
return max(0, paused_seconds)
def _round_up_minutes(minutes: int, block_minutes: int = 30) -> int:
safe_minutes = max(0, int(minutes or 0))
safe_block = max(1, int(block_minutes or 30))
@ -2043,7 +2072,7 @@ async def start_live_timer_v1(
now = datetime.now()
existing = execute_query_single(
"""
SELECT id, start_tid, round_block_min
SELECT id, start_tid, round_block_min, pause_total_seconds, paused_at
FROM tmodule_times
WHERE medarbejder_id = %s AND aktiv_timer = TRUE AND slut_tid IS NULL
ORDER BY start_tid DESC NULLS LAST, id DESC
@ -2054,13 +2083,16 @@ async def start_live_timer_v1(
paused_entry = None
if existing:
actual_minutes = _minutes_between(existing.get("start_tid"), now) or 0
actual_minutes = _elapsed_minutes_excluding_pause(existing, now)
rounded_minutes = _round_up_minutes(actual_minutes, existing.get("round_block_min") or 30)
pause_total_seconds = _pause_total_seconds_at(existing, now)
execute_update(
"""
UPDATE tmodule_times
SET slut_tid = %s,
aktiv_timer = FALSE,
paused_at = NULL,
pause_total_seconds = %s,
faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01),
@ -2070,7 +2102,16 @@ async def start_live_timer_v1(
status = 'pending'
WHERE id = %s
""",
(now, actual_minutes, rounded_minutes, actual_minutes, rounded_minutes, existing.get("round_block_min") or 30, existing["id"])
(
now,
pause_total_seconds,
actual_minutes,
rounded_minutes,
actual_minutes,
rounded_minutes,
existing.get("round_block_min") or 30,
existing["id"],
)
)
paused_entry = existing["id"]
@ -2165,11 +2206,11 @@ async def stop_live_timer_v1(
if not entry:
raise HTTPException(status_code=404, detail="No active timer found")
start_tid = entry.get("start_tid")
actual_minutes = payload.get("faktisk_tid_min")
if actual_minutes is None:
actual_minutes = _minutes_between(start_tid, now)
actual_minutes = _elapsed_minutes_excluding_pause(entry, now)
actual_minutes = max(0, int(actual_minutes or 0))
pause_total_seconds = _pause_total_seconds_at(entry, now)
block_minutes = int(payload.get("round_block_min") or entry.get("round_block_min") or 30)
manual_billable = payload.get("fakturerbar_tid_min")
@ -2182,6 +2223,8 @@ async def stop_live_timer_v1(
UPDATE tmodule_times
SET slut_tid = %s,
aktiv_timer = FALSE,
paused_at = NULL,
pause_total_seconds = %s,
faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01),
@ -2196,6 +2239,7 @@ async def stop_live_timer_v1(
""",
(
now,
pause_total_seconds,
actual_minutes,
billable_minutes,
actual_minutes,
@ -2216,6 +2260,186 @@ async def stop_live_timer_v1(
raise HTTPException(status_code=500, detail="Failed to stop timer")
@router.post("/time/pause", tags=["Internal"])
async def pause_live_timer_v1(
current_user: Optional[dict] = Depends(get_optional_user)
):
"""Pause current active timer for authenticated user without closing the entry."""
try:
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
raise HTTPException(status_code=401, detail="Authentication required")
now = datetime.now()
entry = execute_query_single(
"""
SELECT *
FROM tmodule_times
WHERE medarbejder_id = %s
AND aktiv_timer = TRUE
AND slut_tid IS NULL
ORDER BY start_tid DESC NULLS LAST, id DESC
LIMIT 1
""",
(bruger_id,),
)
if not entry:
raise HTTPException(status_code=404, detail="No active timer found")
if entry.get("paused_at"):
raise HTTPException(status_code=409, detail="Timer is already paused")
paused = execute_query(
"""
UPDATE tmodule_times
SET paused_at = %s,
aktiv_timer = FALSE
WHERE id = %s AND medarbejder_id = %s
RETURNING *
""",
(now, entry["id"], bruger_id),
)
return paused[0] if paused else None
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error pausing live timer: %s", e)
raise HTTPException(status_code=500, detail="Failed to pause timer")
@router.post("/time/resume", tags=["Internal"])
async def resume_live_timer_v1(
payload: Dict[str, Any] = Body(default={}),
current_user: Optional[dict] = Depends(get_optional_user)
):
"""Resume paused timer for authenticated user on the same entry."""
try:
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
raise HTTPException(status_code=401, detail="Authentication required")
now = datetime.now()
time_id = payload.get("time_id")
active = execute_query_single(
"""
SELECT id
FROM tmodule_times
WHERE medarbejder_id = %s
AND aktiv_timer = TRUE
AND slut_tid IS NULL
ORDER BY start_tid DESC NULLS LAST, id DESC
LIMIT 1
""",
(bruger_id,),
)
if active:
raise HTTPException(status_code=409, detail="An active timer already exists")
if time_id:
entry = execute_query_single(
"""
SELECT *
FROM tmodule_times
WHERE id = %s
AND medarbejder_id = %s
AND slut_tid IS NULL
""",
(time_id, bruger_id),
)
else:
entry = execute_query_single(
"""
SELECT *
FROM tmodule_times
WHERE medarbejder_id = %s
AND paused_at IS NOT NULL
AND aktiv_timer = FALSE
AND slut_tid IS NULL
ORDER BY paused_at DESC, id DESC
LIMIT 1
""",
(bruger_id,),
)
if not entry:
raise HTTPException(status_code=404, detail="No paused timer found")
if not entry.get("paused_at"):
raise HTTPException(status_code=409, detail="Timer is not paused")
updated_pause_total = _pause_total_seconds_at(entry, now)
resumed = execute_query(
"""
UPDATE tmodule_times
SET pause_total_seconds = %s,
paused_at = NULL,
aktiv_timer = TRUE
WHERE id = %s AND medarbejder_id = %s
RETURNING *
""",
(updated_pause_total, entry["id"], bruger_id),
)
return resumed[0] if resumed else None
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error resuming live timer: %s", e)
raise HTTPException(status_code=500, detail="Failed to resume timer")
@router.get("/time/my-switchable", tags=["Internal"])
async def list_my_switchable_timers_v1(
current_user: Optional[dict] = Depends(get_optional_user)
):
"""List authenticated user's currently active and paused timers for switch-case UI."""
try:
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
raise HTTPException(status_code=401, detail="Authentication required")
active = execute_query(
"""
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
FROM tmodule_times t
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s
AND t.slut_tid IS NULL
AND t.aktiv_timer = TRUE
AND t.paused_at IS NULL
ORDER BY t.start_tid DESC NULLS LAST, t.id DESC
""",
(bruger_id,),
)
paused = execute_query(
"""
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
FROM tmodule_times t
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s
AND t.slut_tid IS NULL
AND t.paused_at IS NOT NULL
AND t.aktiv_timer = FALSE
ORDER BY t.paused_at DESC, t.id DESC
""",
(bruger_id,),
)
return {
"active": active or [],
"paused": paused or [],
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error listing switchable timers: %s", e)
raise HTTPException(status_code=500, detail="Failed to list switchable timers")
@router.post("/time/manual", tags=["Internal"])
async def create_manual_time_v1(
payload: Dict[str, Any] = Body(...),

View File

@ -0,0 +1,13 @@
-- Persist recently opened cases per user for bottom bar quick access
CREATE TABLE IF NOT EXISTS sag_recent_cases (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
opened_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_sag_recent_cases_user_sag UNIQUE (user_id, sag_id)
);
CREATE INDEX IF NOT EXISTS idx_sag_recent_cases_user_opened
ON sag_recent_cases (user_id, opened_at DESC, id DESC);

View File

@ -0,0 +1,13 @@
-- Migration 179: Add pause/resume support for live timers
-- Date: 2026-04-23
ALTER TABLE tmodule_times
ADD COLUMN IF NOT EXISTS paused_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS pause_total_seconds INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_tmodule_times_active_unfinished
ON tmodule_times (medarbejder_id, aktiv_timer, slut_tid);
CREATE INDEX IF NOT EXISTS idx_tmodule_times_paused_unfinished
ON tmodule_times (medarbejder_id, paused_at, slut_tid)
WHERE paused_at IS NOT NULL AND slut_tid IS NULL;

View File

@ -0,0 +1,36 @@
-- Migration 180: Personal user notes for bottom bar
-- Date: 2026-04-24
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
);
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;
CREATE INDEX IF NOT EXISTS idx_user_notes_user_updated
ON user_notes (user_id, updated_at DESC)
WHERE deleted_at IS NULL;
CREATE OR REPLACE FUNCTION update_user_notes_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_user_notes_updated_at ON user_notes;
CREATE TRIGGER trg_user_notes_updated_at
BEFORE UPDATE ON user_notes
FOR EACH ROW
EXECUTE FUNCTION update_user_notes_updated_at();

File diff suppressed because it is too large Load Diff