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:
parent
ca6640c33c
commit
3452472ba9
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -3728,6 +3728,19 @@
|
||||
let customerSearchMode = 'link';
|
||||
const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
|
||||
|
||||
async function markCaseAsRecentlyOpened() {
|
||||
try {
|
||||
await fetch(`/api/v1/sag/recent/open/${caseId}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
} catch (_err) {
|
||||
// Best effort only; page should not break if this call fails.
|
||||
}
|
||||
}
|
||||
|
||||
function escapeCaseTopAlertHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@ -3839,6 +3852,7 @@
|
||||
|
||||
// Initialize everything when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
markCaseAsRecentlyOpened();
|
||||
hydrateTopbarStatusOptions();
|
||||
loadCaseCustomerTopAlerts();
|
||||
applyTopbarImportanceBubbles();
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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(...),
|
||||
|
||||
13
migrations/1004_sag_recent_cases.sql
Normal file
13
migrations/1004_sag_recent_cases.sql
Normal 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);
|
||||
13
migrations/179_timetracking_pause_resume_support.sql
Normal file
13
migrations/179_timetracking_pause_resume_support.sql
Normal 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;
|
||||
36
migrations/180_user_notes.sql
Normal file
36
migrations/180_user_notes.sql
Normal 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
Loading…
Reference in New Issue
Block a user