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 from typing import Optional
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel from pydantic import BaseModel
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
from app.core.auth_dependencies import get_current_user 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() router = APIRouter()
logger = logging.getLogger(__name__)
_USER_NOTES_SCHEMA_READY = False
class BossAssignPayload(BaseModel): class BossAssignPayload(BaseModel):
@ -21,6 +24,456 @@ class BossAssignNextPayload(BaseModel):
assignee_user_id: int 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]: def _resolve_user_id_from_request(request: Request) -> Optional[int]:
state_user_id = getattr(request.state, "user_id", None) state_user_id = getattr(request.state, "user_id", None)
if state_user_id is not 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 "" 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) 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.task_routing import TaskRouter
from app.services.m365_calendar import M365CalendarService 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') AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
ORDER BY ORDER BY
CASE CASE
WHEN LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0 WHEN LOWER(COALESCE(priority::text, '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 ('high', 'høj') THEN 1
ELSE 2 ELSE 2
END, END,
COALESCE(updated_at, created_at) ASC, 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, u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name, COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases, 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 FROM users u
JOIN user_groups ug ON ug.user_id = u.user_id JOIN user_groups ug ON ug.user_id = u.user_id
JOIN groups g ON g.id = ug.group_id JOIN groups g ON g.id = ug.group_id

View File

@ -38,6 +38,32 @@ def _priority_rank(priority: str) -> int:
return 0 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]: def _get_user_group_names(user_id: Optional[int]) -> List[str]:
if user_id is None: if user_id is None:
return [] 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]: def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]:
if user_id is None: if user_id is None:
return {"items": [], "count": 0} return {"items": [], "count": 0}
@ -367,6 +609,59 @@ def _context_actions_for_path(context_path: str) -> Dict[str, Any]:
return payload 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( def build_bottom_bar_state(
user_id: Optional[int], user_id: Optional[int],
context_path: str = "", context_path: str = "",
@ -378,7 +673,11 @@ def build_bottom_bar_state(
status = get_dashboard_status() status = get_dashboard_status()
timer = get_active_timer(user_id) timer = get_active_timer(user_id)
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
notifications = get_notifications(user_id, 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( urgent_cases = execute_query(
""" """
@ -538,22 +837,14 @@ def build_bottom_bar_state(
""" """
) or [] ) or []
unassigned_cases = execute_query( unassigned_cases = [
""" {
SELECT "id": row.get("id"),
s.id, "titel": row.get("title"),
s.titel, "priority": row.get("priority"),
s.priority, }
s.created_at, for row in (unassigned_open_cases.get("items") or [])
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 = { sections = {
"mail": { "mail": {
@ -570,11 +861,30 @@ def build_bottom_bar_state(
}, },
"unassigned": { "unassigned": {
"count": status.get("sager_unassigned", 0), "count": status.get("sager_unassigned", 0),
"list": unassigned_open_cases.get("items") or [],
"filter_meta": unassigned_open_cases.get("filter_meta") or {},
}, },
"timer": { "timer": {
"active_count": 1 if timer.get("active") else 0, "active_count": 1 if timer.get("active") else 0,
"list": timer_list, "list": timer_list,
"active": timer, "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": { "kuma": {
"down": 0, "down": 0,
@ -592,6 +902,8 @@ def build_bottom_bar_state(
"count": len(tasks), "count": len(tasks),
"list": tasks, "list": tasks,
}, },
"recent_cases": recent_cases,
"notes": notes_summary,
"boss": { "boss": {
"can_view": can_view_boss, "can_view": can_view_boss,
"stats": { "stats": {
@ -652,5 +964,7 @@ def build_bottom_bar_state(
"sections": sections, "sections": sections,
"status": status, "status": status,
"active_timer": timer, "active_timer": timer,
"own_timers": own_timers,
"recent_cases": recent_cases,
"notifications": notifications, "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") 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") @router.get("/sag/{sag_id}/modules")
async def get_case_module_prefs(sag_id: int): async def get_case_module_prefs(sag_id: int):
"""Get module visibility preferences for a case.""" """Get module visibility preferences for a case."""

View File

@ -166,13 +166,15 @@ async def sager_liste(
customer_id: str = Query(None), customer_id: str = Query(None),
ansvarlig_bruger_id: str = Query(None), ansvarlig_bruger_id: str = Query(None),
assigned_group_id: str = Query(None), assigned_group_id: str = Query(None),
unassigned: bool = Query(False),
include_deferred: bool = Query(False), include_deferred: bool = Query(False),
): ):
"""Display list of all cases.""" """Display list of all cases."""
try: try:
# Coerce string params to optional ints # Coerce string params to optional ints
customer_id_int = _coerce_optional_int(customer_id) 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) assigned_group_id_int = _coerce_optional_int(assigned_group_id)
query = """ query = """
SELECT s.*, SELECT s.*,
@ -245,7 +247,9 @@ async def sager_liste(
if customer_id_int: if customer_id_int:
query += " AND s.customer_id = %s" query += " AND s.customer_id = %s"
params.append(customer_id_int) 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" query += " AND s.ansvarlig_bruger_id = %s"
params.append(ansvarlig_bruger_id_int) params.append(ansvarlig_bruger_id_int)
if assigned_group_id_int: if assigned_group_id_int:
@ -300,7 +304,9 @@ async def sager_liste(
if customer_id_int: if customer_id_int:
fallback_query += " AND s.customer_id = %s" fallback_query += " AND s.customer_id = %s"
fallback_params.append(customer_id_int) 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_query += " AND s.ansvarlig_bruger_id = %s"
fallback_params.append(ansvarlig_bruger_id_int) fallback_params.append(ansvarlig_bruger_id_int)
@ -382,6 +388,7 @@ async def sager_liste(
"current_customer_id": customer_id_int, "current_customer_id": customer_id_int,
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
}) })
except Exception: except Exception:
logger.exception("❌ Error displaying case list") logger.exception("❌ Error displaying case list")
@ -401,6 +408,7 @@ async def sager_liste(
"current_customer_id": customer_id_int, "current_customer_id": customer_id_int,
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
}) })
@router.get("/sag/new", response_class=HTMLResponse) @router.get("/sag/new", response_class=HTMLResponse)

View File

@ -3728,6 +3728,19 @@
let customerSearchMode = 'link'; let customerSearchMode = 'link';
const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }}; 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) { function escapeCaseTopAlertHtml(value) {
return String(value ?? '') return String(value ?? '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@ -3839,6 +3852,7 @@
// Initialize everything when DOM is ready // Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
markCaseAsRecentlyOpened();
hydrateTopbarStatusOptions(); hydrateTopbarStatusOptions();
loadCaseCustomerTopAlerts(); loadCaseCustomerTopAlerts();
applyTopbarImportanceBubbles(); applyTopbarImportanceBubbles();

View File

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

View File

@ -68,6 +68,18 @@
transform: translateY(calc(100% + 12px)); transform: translateY(calc(100% + 12px));
opacity: 0; opacity: 0;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease; 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 { .global-bottom-bar.is-visible {
@ -163,6 +175,7 @@
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
transition: all 0.2s ease;
} }
.global-bottom-bar .dropdown-menu { .global-bottom-bar .dropdown-menu {
@ -443,10 +456,40 @@
line-height: 1.4; line-height: 1.4;
color: var(--text-primary); color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.03); 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 { .global-bottom-bar .bb-tab-list li:hover {
transform: translateX(2px); 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) { @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> <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>
<div class="bb-zone bb-zone-center"> <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)"> <button id="bbSearchBtn" class="bb-action-btn bb-search-btn" type="button" title="Søg (Cmd/Ctrl+K)">
<i class="bi bi-search"></i> <i class="bi bi-search"></i>
</button> </button>
@ -1034,10 +1065,6 @@
<i class="bi bi-bell"></i> <i class="bi bi-bell"></i>
<span id="bbNotificationsCount" class="bb-notification-count">0</span> <span id="bbNotificationsCount" class="bb-notification-count">0</span>
</button> </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="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="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> <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="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="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="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 --> <!-- 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> <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> </div>
@ -1070,6 +1098,79 @@
</div> </div>
</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> <script>
window.addEventListener('unhandledrejection', function(event) { window.addEventListener('unhandledrejection', function(event) {
const reason = event && event.reason; 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/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.2"></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/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> <script>
// Dark Mode Toggle Logic // Dark Mode Toggle Logic
const darkModeToggle = document.getElementById('darkModeToggle'); 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) 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: def _round_up_minutes(minutes: int, block_minutes: int = 30) -> int:
safe_minutes = max(0, int(minutes or 0)) safe_minutes = max(0, int(minutes or 0))
safe_block = max(1, int(block_minutes or 30)) safe_block = max(1, int(block_minutes or 30))
@ -2043,7 +2072,7 @@ async def start_live_timer_v1(
now = datetime.now() now = datetime.now()
existing = execute_query_single( 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 FROM tmodule_times
WHERE medarbejder_id = %s AND aktiv_timer = TRUE AND slut_tid IS NULL WHERE medarbejder_id = %s AND aktiv_timer = TRUE AND slut_tid IS NULL
ORDER BY start_tid DESC NULLS LAST, id DESC ORDER BY start_tid DESC NULLS LAST, id DESC
@ -2054,13 +2083,16 @@ async def start_live_timer_v1(
paused_entry = None paused_entry = None
if existing: 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) 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( execute_update(
""" """
UPDATE tmodule_times UPDATE tmodule_times
SET slut_tid = %s, SET slut_tid = %s,
aktiv_timer = FALSE, aktiv_timer = FALSE,
paused_at = NULL,
pause_total_seconds = %s,
faktisk_tid_min = %s, faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END, fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01), original_hours = GREATEST(%s::numeric / 60.0, 0.01),
@ -2070,7 +2102,16 @@ async def start_live_timer_v1(
status = 'pending' status = 'pending'
WHERE id = %s 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"] paused_entry = existing["id"]
@ -2165,11 +2206,11 @@ async def stop_live_timer_v1(
if not entry: if not entry:
raise HTTPException(status_code=404, detail="No active timer found") raise HTTPException(status_code=404, detail="No active timer found")
start_tid = entry.get("start_tid")
actual_minutes = payload.get("faktisk_tid_min") actual_minutes = payload.get("faktisk_tid_min")
if actual_minutes is None: 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)) 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) block_minutes = int(payload.get("round_block_min") or entry.get("round_block_min") or 30)
manual_billable = payload.get("fakturerbar_tid_min") manual_billable = payload.get("fakturerbar_tid_min")
@ -2182,6 +2223,8 @@ async def stop_live_timer_v1(
UPDATE tmodule_times UPDATE tmodule_times
SET slut_tid = %s, SET slut_tid = %s,
aktiv_timer = FALSE, aktiv_timer = FALSE,
paused_at = NULL,
pause_total_seconds = %s,
faktisk_tid_min = %s, faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END, fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01), original_hours = GREATEST(%s::numeric / 60.0, 0.01),
@ -2196,6 +2239,7 @@ async def stop_live_timer_v1(
""", """,
( (
now, now,
pause_total_seconds,
actual_minutes, actual_minutes,
billable_minutes, billable_minutes,
actual_minutes, actual_minutes,
@ -2216,6 +2260,186 @@ async def stop_live_timer_v1(
raise HTTPException(status_code=500, detail="Failed to stop timer") 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"]) @router.post("/time/manual", tags=["Internal"])
async def create_manual_time_v1( async def create_manual_time_v1(
payload: Dict[str, Any] = Body(...), 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