Compare commits

..

2 Commits

Author SHA1 Message Date
Christian
ceb560e2f2 feat: Add bottom bar functionality with real-time updates and manual endpoint tests
- Implemented a new bottom bar feature in `bottom-bar.js` that fetches and displays various notifications and statuses in real-time.
- Added functions for handling visibility, state updates, and user interactions within the bottom bar.
- Introduced WebSocket connection for real-time updates and fallback polling mechanism.
- Created a manual testing script `test_manual.py` to validate API endpoints for the manual module.
- Included tests for various paths to ensure expected responses from the server.
2026-04-12 02:27:01 +02:00
Christian
270af0e277 feat(anydesk): Implement multi-ID support for AnyDesk cases
- Added endpoints to list, upsert, and delete AnyDesk IDs associated with cases.
- Introduced normalization for AnyDesk IDs and ensured case existence checks.
- Enhanced session management with quick-connect functionality and local session synchronization.
- Created a new job for syncing AnyDesk sessions from a local endpoint.
- Added database migration for the new `sag_anydesk_ids` table to store AnyDesk IDs per case.
2026-04-06 12:46:04 +02:00
62 changed files with 7791 additions and 263 deletions

View File

@ -253,6 +253,11 @@ class Settings(BaseSettings):
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
ANYDESK_TIMEOUT_SECONDS: int = 30
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested
ANYDESK_LOCAL_SESSIONS_URL: str = "http://localhost:8001/anydesk/sessions"
ANYDESK_LOCAL_SYNC_ENABLED: bool = True
ANYDESK_LOCAL_SYNC_INTERVAL_MINUTES: int = 15
ANYDESK_LOCAL_SYNC_TIMEOUT_SECONDS: int = 20
ANYDESK_LOCAL_SYNC_DRY_RUN: bool = False
# Telefoni (Yealink) Integration
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=...
@ -282,6 +287,9 @@ class Settings(BaseSettings):
SMS_USERNAME: str = ""
SMS_SENDER: str = "BMC Networks"
SMS_WEBHOOK_SECRET: str = ""
# Bottom bar module
BOTTOM_BAR_ENABLED: bool = False
# Dev-only shortcuts
DEV_ALLOW_ARCHIVED_IMPORT: bool = False

View File

@ -1004,6 +1004,8 @@
style="border-radius: 20px;">
<button class="btn btn-outline-primary" onclick="openWorkflowManager()" title="Workflow Management">
<i class="bi bi-diagram-3"></i>
</button> <button class="btn btn-outline-info rounded-circle px-2 ms-2" onclick="openManualHelp('Mail')" title="Hjælp til Mail">
<i class="bi bi-question-lg"></i>
</button>
</div>
</div>

View File

@ -0,0 +1,41 @@
"""
AnyDesk local sessions sync job.
Polls local AnyDesk bridge endpoint and enriches local session rows.
"""
import logging
from app.core.config import settings
from app.services.anydesk import AnyDeskService
logger = logging.getLogger(__name__)
anydesk_service = AnyDeskService()
async def sync_anydesk_local_sessions():
"""Sync AnyDesk sessions from local endpoint every N minutes."""
if not settings.ANYDESK_LOCAL_SYNC_ENABLED:
return
try:
logger.info("🔄 AnyDesk local sync started")
result = await anydesk_service.fetch_sessions_from_local_endpoint(
endpoint_url=settings.ANYDESK_LOCAL_SESSIONS_URL,
timeout_seconds=settings.ANYDESK_LOCAL_SYNC_TIMEOUT_SECONDS,
dry_run=settings.ANYDESK_LOCAL_SYNC_DRY_RUN,
)
if result.get("error"):
logger.error("❌ AnyDesk local sync failed: %s", result["error"])
return
logger.info(
"✅ AnyDesk local sync completed: total=%s imported=%s updated=%s matched=%s errors=%s",
result.get("total", 0),
result.get("imported", 0),
result.get("updated", 0),
result.get("matched", 0),
len(result.get("errors") or []),
)
except Exception as exc:
logger.error("❌ Unexpected AnyDesk local sync error: %s", exc)

View File

View File

@ -0,0 +1,121 @@
import asyncio
import json
import logging
from typing import Optional
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from app.core.auth_service import AuthService
from .service import get_active_timer, get_dashboard_status, get_notifications
logger = logging.getLogger(__name__)
router = APIRouter()
def _resolve_user_id_from_request(request: Request) -> Optional[int]:
user_id = getattr(request.state, "user_id", None)
if user_id is not None:
try:
return int(user_id)
except (TypeError, ValueError):
return None
user_id_param = request.query_params.get("user_id")
if user_id_param:
try:
return int(user_id_param)
except (TypeError, ValueError):
return None
return None
def _resolve_ws_payload(websocket: WebSocket) -> Optional[dict]:
token = websocket.query_params.get("token")
auth_header = (websocket.headers.get("authorization") or "").strip()
if not token and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
payload = AuthService.verify_token(token) if token else None
if not payload:
access_cookie_token = (websocket.cookies.get("access_token") or "").strip() or None
payload = AuthService.verify_token(access_cookie_token) if access_cookie_token else None
return payload
@router.get("/api/v1/dashboard/status")
async def get_dashboard_status_endpoint() -> dict:
return get_dashboard_status()
@router.get("/api/v1/timer/active")
async def get_active_timer_endpoint(request: Request) -> dict:
user_id = _resolve_user_id_from_request(request)
return get_active_timer(user_id)
@router.get("/api/v1/notifications")
async def get_notifications_endpoint(request: Request, limit: int = 20) -> dict:
user_id = _resolve_user_id_from_request(request)
return get_notifications(user_id, limit=limit)
@router.websocket("/api/v1/bottom-bar/ws")
async def bottom_bar_ws(websocket: WebSocket):
payload = _resolve_ws_payload(websocket)
if not payload:
await websocket.close(code=1008)
return
try:
user_id = int(payload.get("sub")) if payload.get("sub") is not None else None
except (TypeError, ValueError):
await websocket.close(code=1008)
return
await websocket.accept()
initial_status = get_dashboard_status()
initial_notifications = get_notifications(user_id, limit=20)
await websocket.send_json({"event": "status_delta", "data": initial_status})
await websocket.send_json({"event": "notification_delta", "data": initial_notifications})
last_status_json = json.dumps(initial_status, sort_keys=True, default=str)
last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str)
last_timer_elapsed = -1
status_tick = 0
try:
while True:
timer = get_active_timer(user_id)
elapsed = int(timer.get("elapsed") or 0)
if elapsed != last_timer_elapsed:
await websocket.send_json({"event": "timer_tick", "data": timer})
last_timer_elapsed = elapsed
status_tick += 1
if status_tick >= 5:
status = get_dashboard_status()
notifications = get_notifications(user_id, limit=20)
status_json = json.dumps(status, sort_keys=True, default=str)
if status_json != last_status_json:
await websocket.send_json({"event": "status_delta", "data": status})
last_status_json = status_json
notifications_json = json.dumps(notifications, sort_keys=True, default=str)
if notifications_json != last_notifications_json:
await websocket.send_json({"event": "notification_delta", "data": notifications})
last_notifications_json = notifications_json
status_tick = 0
try:
await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
except TimeoutError:
continue
except WebSocketDisconnect:
logger.info("Bottom bar websocket disconnected user_id=%s", user_id)
except Exception as exc:
logger.warning("Bottom bar websocket error user_id=%s error=%s", user_id, exc)

View File

@ -0,0 +1,288 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from app.core.auth_service import AuthService
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query, execute_query_single
from .service import build_bottom_bar_state
router = APIRouter()
class BossAssignPayload(BaseModel):
case_id: int
assignee_user_id: int
class BossAssignNextPayload(BaseModel):
assignee_user_id: int
def _resolve_user_id_from_request(request: Request) -> Optional[int]:
state_user_id = getattr(request.state, "user_id", None)
if state_user_id is not None:
try:
return int(state_user_id)
except (TypeError, ValueError):
pass
user_id_param = request.query_params.get("user_id")
if user_id_param:
try:
return int(user_id_param)
except (TypeError, ValueError):
pass
token = (request.cookies.get("access_token") or "").strip() or None
payload = AuthService.verify_token(token) if token else None
sub_claim = payload.get("sub") if payload else None
if sub_claim is not None:
try:
return int(sub_claim)
except (TypeError, ValueError):
return None
return None
@router.get("/state")
async def get_bottom_bar_state(request: Request, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = int(current_user_id)
force_boss_access = bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin"))
context_path = request.query_params.get("context") or ""
return build_bottom_bar_state(user_id, context_path=context_path, force_boss_access=force_boss_access)
from app.services.task_routing import TaskRouter
from app.services.m365_calendar import M365CalendarService
def _has_boss_access(current_user: dict) -> bool:
if bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")):
return True
current_user_id = current_user.get("id")
if current_user_id is None:
return False
rows = execute_query(
"""
SELECT LOWER(g.name) AS name
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = %s
""",
(int(current_user_id),),
) or []
names = [str(r.get("name") or "") for r in rows]
tokens = ("admin", "manager", "leder", "chef", "teknik", "technician", "support")
return any(any(token in name for token in tokens) for name in names)
def _ensure_user_exists(user_id: int) -> None:
user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
if not user:
raise HTTPException(status_code=404, detail="Bruger ikke fundet")
def _get_next_unassigned_case() -> Optional[dict]:
return execute_query_single(
"""
SELECT id, titel, priority
FROM sag_sager
WHERE deleted_at IS NULL
AND ansvarlig_bruger_id IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
ORDER BY
CASE
WHEN LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0
WHEN LOWER(COALESCE(priority, 'normal')) IN ('high', 'høj') THEN 1
ELSE 2
END,
COALESCE(updated_at, created_at) ASC,
id ASC
LIMIT 1
"""
)
@router.post("/next_task")
async def assign_next_task(
request: Request,
user_id: int | None = Query(default=None),
current_user: dict = Depends(get_current_user),
):
# Prefer authenticated user context; allow explicit user_id for controlled testing.
current_user_id = current_user.get("id")
resolved_user_id = user_id
if resolved_user_id is None and current_user_id is not None:
resolved_user_id = int(current_user_id)
if resolved_user_id is None:
raise HTTPException(status_code=401, detail="Authentication required for task assignment")
# Kombinerer de nye services
router_svc = TaskRouter()
cal = M365CalendarService()
# Henter hvor meget fri tid medarbejderen har lige nu
free_mins = await cal.get_user_free_time("now", 2)
# Bed the engine allocate the next best task
task = await router_svc.get_next_best_task(resolved_user_id)
task = task or {}
return {
"status": "assigned",
"task": task,
"free_time_calculated": free_mins,
"message": f"Fandt Næste Opgave (SLA: {task.get('assigned_reason')} - {task.get('estimated_minutes')}m. Du har {free_mins}m frit). "
}
@router.post("/boss/auto-assign-next")
async def boss_auto_assign_next(current_user: dict = Depends(get_current_user)):
if not _has_boss_access(current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
next_case = _get_next_unassigned_case()
if not next_case:
return {
"status": "noop",
"message": "Ingen ufordelte åbne sager at fordele.",
}
assignee = execute_query_single(
"""
SELECT
u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases,
COUNT(CASE WHEN LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'critical', 'kritisk', 'high', 'høj') THEN 1 END)::int AS hot_cases
FROM users u
JOIN user_groups ug ON ug.user_id = u.user_id
JOIN groups g ON g.id = ug.group_id
LEFT JOIN sag_sager s
ON s.ansvarlig_bruger_id = u.user_id
AND s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
WHERE LOWER(g.name) LIKE ANY(ARRAY['%admin%', '%manager%', '%leder%', '%chef%', '%teknik%', '%technician%', '%support%'])
GROUP BY u.user_id, u.full_name, u.username
ORDER BY hot_cases ASC, open_cases ASC, owner_name ASC
LIMIT 1
"""
)
if not assignee:
raise HTTPException(status_code=409, detail="Ingen kvalificeret medarbejder fundet til auto-fordeling")
updated = execute_query_single(
"""
UPDATE sag_sager
SET ansvarlig_bruger_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, titel, priority, ansvarlig_bruger_id
""",
(int(assignee["user_id"]), int(next_case["id"])),
)
if not updated:
raise HTTPException(status_code=500, detail="Kunne ikke opdatere sag")
return {
"status": "assigned",
"message": "Sagen blev auto-fordelt.",
"case": {
"id": updated.get("id"),
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
"priority": updated.get("priority") or "normal",
},
"assignee": {
"user_id": assignee.get("user_id"),
"name": assignee.get("owner_name") or f"Bruger #{assignee.get('user_id')}",
},
}
@router.post("/boss/assign-case")
async def boss_assign_case(payload: BossAssignPayload, current_user: dict = Depends(get_current_user)):
if not _has_boss_access(current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
_ensure_user_exists(int(payload.assignee_user_id))
case_row = execute_query_single(
"""
SELECT id, titel, priority
FROM sag_sager
WHERE id = %s
AND deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
""",
(int(payload.case_id),),
)
if not case_row:
raise HTTPException(status_code=404, detail="Sag ikke fundet eller er afsluttet")
updated = execute_query_single(
"""
UPDATE sag_sager
SET ansvarlig_bruger_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, titel, priority, ansvarlig_bruger_id
""",
(int(payload.assignee_user_id), int(payload.case_id)),
)
if not updated:
raise HTTPException(status_code=500, detail="Kunne ikke tildele sag")
return {
"status": "assigned",
"message": "Sagen blev tildelt.",
"case": {
"id": updated.get("id"),
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
"priority": updated.get("priority") or "normal",
},
"assignee_user_id": int(payload.assignee_user_id),
}
@router.post("/boss/assign-next-to-user")
async def boss_assign_next_to_user(payload: BossAssignNextPayload, current_user: dict = Depends(get_current_user)):
if not _has_boss_access(current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
_ensure_user_exists(int(payload.assignee_user_id))
next_case = _get_next_unassigned_case()
if not next_case:
return {
"status": "noop",
"message": "Ingen ufordelte åbne sager at tildele.",
}
updated = execute_query_single(
"""
UPDATE sag_sager
SET ansvarlig_bruger_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, titel, priority, ansvarlig_bruger_id
""",
(int(payload.assignee_user_id), int(next_case["id"])),
)
if not updated:
raise HTTPException(status_code=500, detail="Kunne ikke tildele næste sag")
return {
"status": "assigned",
"message": "Næste ufordelte sag blev tildelt.",
"case": {
"id": updated.get("id"),
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
"priority": updated.get("priority") or "normal",
},
"assignee_user_id": int(payload.assignee_user_id),
}

View File

@ -0,0 +1,656 @@
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
CLOSED_CASE_STATUSES = ("lukket", "løst", "closed", "resolved")
URGENT_PRIORITIES = ("urgent", "high", "kritisk", "critical")
def _safe_count(row: Optional[dict], key: str = "count") -> int:
if not row:
return 0
try:
return int(row.get(key) or 0)
except (TypeError, ValueError):
return 0
def _format_elapsed(seconds: int) -> str:
total = max(0, int(seconds or 0))
hours = total // 3600
minutes = (total % 3600) // 60
secs = total % 60
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
def _priority_rank(priority: str) -> int:
normalized = str(priority or "").strip().lower()
if normalized in {"urgent", "critical", "kritisk"}:
return 3
if normalized in {"high", "høj"}:
return 2
if normalized in {"normal", "medium", "middel"}:
return 1
return 0
def _get_user_group_names(user_id: Optional[int]) -> List[str]:
if user_id is None:
return []
rows = execute_query(
"""
SELECT LOWER(g.name) AS name
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = %s
""",
(user_id,),
) or []
return [str(r.get("name") or "").strip() for r in rows if r.get("name")]
def _can_view_boss_tab(user_id: Optional[int]) -> bool:
if user_id is None:
return False
group_names = _get_user_group_names(user_id)
if not group_names:
# Fail-open for authenticated users if group mapping is missing.
return True
leadership_tokens = (
"admin",
"manager",
"leder",
"chef",
"teknik",
"technician",
"support",
"drift",
"it",
)
return any(
any(token in group for token in leadership_tokens)
for group in group_names
)
def is_bottom_bar_enabled(user_id: Optional[int]) -> bool:
setting = execute_query_single("SELECT value FROM settings WHERE key = %s", ("bottom_bar_enabled",))
setting_value = str((setting or {}).get("value") or "").strip().lower()
if setting_value not in {"1", "true", "yes", "on"}:
return False
if user_id is None:
return True
pref = execute_query_single(
"""
SELECT enabled
FROM user_module_preferences
WHERE user_id = %s AND module_name = %s
LIMIT 1
""",
(user_id, "bottom_bar"),
)
if pref and pref.get("enabled") is not None:
return bool(pref.get("enabled"))
role = execute_query_single(
"""
SELECT mrs.enabled
FROM module_role_settings mrs
JOIN user_groups ug ON ug.group_id = mrs.group_id
WHERE ug.user_id = %s
AND mrs.module_name = %s
ORDER BY mrs.enabled DESC
LIMIT 1
""",
(user_id, "bottom_bar"),
)
if role and role.get("enabled") is not None:
return bool(role.get("enabled"))
return True
def get_dashboard_status() -> Dict[str, int]:
mails_unread = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM email_messages
WHERE deleted_at IS NULL
AND COALESCE(is_read, FALSE) = FALSE
"""
)
)
sager_open = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
"""
)
)
sager_urgent = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
"""
)
)
sager_unassigned = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND ansvarlig_bruger_id IS NULL
"""
)
)
return {
"mails_unread": mails_unread,
"sager_open": sager_open,
"sager_urgent": sager_urgent,
"sager_unassigned": sager_unassigned,
}
def get_active_timer(user_id: Optional[int]) -> Dict[str, Any]:
if user_id is None:
return {
"active": False,
"sag_id": None,
"sag_navn": None,
"start_tid": None,
"elapsed": 0,
"elapsed_hhmmss": "00:00:00",
"time_entry_id": None,
}
timer = execute_query_single(
"""
SELECT
t.id,
t.sag_id,
s.titel AS sag_navn,
t.start_tid,
GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int, 0) AS elapsed
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.medarbejder_id = %s
AND t.aktiv_timer = TRUE
AND t.slut_tid IS NULL
ORDER BY t.start_tid DESC NULLS LAST, t.id DESC
LIMIT 1
""",
(user_id,),
)
if not timer:
return {
"active": False,
"sag_id": None,
"sag_navn": None,
"start_tid": None,
"elapsed": 0,
"elapsed_hhmmss": "00:00:00",
"time_entry_id": None,
}
elapsed = int(timer.get("elapsed") or 0)
return {
"active": True,
"sag_id": timer.get("sag_id"),
"sag_navn": timer.get("sag_navn"),
"start_tid": timer.get("start_tid"),
"elapsed": elapsed,
"elapsed_hhmmss": _format_elapsed(elapsed),
"time_entry_id": timer.get("id"),
}
def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]:
if user_id is None:
return {"items": [], "count": 0}
limit_safe = max(1, min(int(limit or 20), 100))
reminders = execute_query(
"""
SELECT
r.id,
r.sag_id,
r.title,
r.message,
r.priority,
r.event_type,
r.next_check_at,
s.titel AS case_title,
c.name AS customer_name
FROM sag_reminders r
JOIN sag_sager s ON r.sag_id = s.id
JOIN customers c ON s.customer_id = c.id
LEFT JOIN LATERAL (
SELECT id, snoozed_until, status, triggered_at
FROM sag_reminder_logs
WHERE reminder_id = r.id AND user_id = %s
ORDER BY triggered_at DESC
LIMIT 1
) l ON true
WHERE r.is_active = TRUE
AND r.deleted_at IS NULL
AND r.next_check_at <= CURRENT_TIMESTAMP
AND %s = ANY(r.recipient_user_ids)
AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP)
AND (l.status IS NULL OR l.status != 'dismissed')
ORDER BY
CASE LOWER(COALESCE(r.priority, 'normal'))
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'normal' THEN 3
ELSE 4
END,
r.next_check_at ASC
LIMIT %s
""",
(user_id, user_id, limit_safe),
) or []
unread_mail_count = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM email_messages em
WHERE em.deleted_at IS NULL
AND COALESCE(em.is_read, FALSE) = FALSE
"""
)
)
items: List[Dict[str, Any]] = []
if unread_mail_count > 0:
items.append(
{
"id": f"mail-unread-{unread_mail_count}",
"type": "mail",
"severity": "medium" if unread_mail_count < 10 else "high",
"title": f"{unread_mail_count} ulæste mails",
"message": "Der er ulæste mails i indbakken",
"action": "/emails",
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
for row in reminders:
priority = str(row.get("priority") or "normal").lower()
severity = "low"
if priority in {"high", "høj"}:
severity = "medium"
if priority in {"urgent", "critical", "kritisk"}:
severity = "high"
items.append(
{
"id": f"reminder-{row.get('id')}",
"type": row.get("event_type") or "reminder",
"severity": severity,
"title": row.get("title") or "Påmindelse",
"message": row.get("message") or row.get("case_title") or "",
"sag_id": row.get("sag_id"),
"case_title": row.get("case_title"),
"customer_name": row.get("customer_name"),
"action": f"/sag/{row.get('sag_id')}" if row.get("sag_id") else "/sag",
"created_at": row.get("next_check_at"),
}
)
items.sort(
key=lambda item: (
{"high": 0, "medium": 1, "low": 2}.get(str(item.get("severity") or "low"), 3),
str(item.get("created_at") or ""),
)
)
return {"items": items[:limit_safe], "count": len(items)}
def _context_actions_for_path(context_path: str) -> Dict[str, Any]:
normalized = str(context_path or "").strip().lower()
payload: Dict[str, Any] = {
"context_key": "global",
"global": [
{"id": "new_case", "label": "Ny sag", "action": "/sag"},
{"id": "new_mail", "label": "Ny mail", "action": "/emails"},
{"id": "start_timer", "label": "Start timer", "action": "/timetracking"},
{"id": "log_time", "label": "Log tid", "action": "/timetracking"},
{"id": "add_note", "label": "Tilføj note", "action": "/sag"},
],
"context": [],
}
if normalized.startswith("/sag"):
payload["context_key"] = "sag"
payload["context"] = [
{"id": "case_time", "label": "Tid", "action": "/timetracking"},
{"id": "case_mail", "label": "Mail", "action": "/emails"},
{"id": "case_relation", "label": "Relation", "action": "/customers"},
{"id": "case_tag", "label": "Tag", "action": "/tags"},
]
elif normalized.startswith("/hardware"):
payload["context_key"] = "hardware"
payload["context"] = [
{"id": "hardware_new", "label": "Ny enhed", "action": "/hardware"},
{"id": "hardware_history", "label": "Historik", "action": "/hardware"},
{"id": "hardware_link_case", "label": "Tilknyt sag", "action": "/sag"},
]
return payload
def build_bottom_bar_state(
user_id: Optional[int],
context_path: str = "",
force_boss_access: bool = False,
) -> Dict[str, Any]:
enabled = is_bottom_bar_enabled(user_id)
if not enabled:
return {"enabled": False, "sections": {}}
status = get_dashboard_status()
timer = get_active_timer(user_id)
notifications = get_notifications(user_id, limit=10)
urgent_cases = execute_query(
"""
SELECT id, titel
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 5
"""
) or []
open_cases = execute_query(
"""
SELECT id, titel
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 5
"""
) or []
timer_list: List[Dict[str, Any]] = []
if timer.get("active"):
timer_list.append(
{
"id": timer.get("time_entry_id"),
"sag_id": timer.get("sag_id"),
"desc": timer.get("sag_navn") or f"Sag #{timer.get('sag_id')}",
"elapsed": timer.get("elapsed"),
"elapsed_hhmmss": timer.get("elapsed_hhmmss"),
}
)
messages = [
{
"from": "System",
"text": f"{notifications.get('count', 0)} aktive notifikationer",
}
]
tasks = []
for n in (notifications.get("items") or [])[:5]:
tasks.append(
{
"title": n.get("title") or "Notifikation",
"deadline": n.get("severity") or "info",
"action": n.get("action") or "/",
}
)
context_actions = _context_actions_for_path(context_path)
can_view_boss = bool(force_boss_access) or _can_view_boss_tab(user_id)
team_workload: List[Dict[str, Any]] = []
technicians_today: List[Dict[str, Any]] = []
escalation_cases: List[Dict[str, Any]] = []
unassigned_cases: List[Dict[str, Any]] = []
if can_view_boss:
team_workload = execute_query(
"""
SELECT
u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases,
COUNT(CASE WHEN LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
FROM users u
LEFT JOIN sag_sager s
ON s.ansvarlig_bruger_id = u.user_id
AND s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
GROUP BY u.user_id, u.full_name, u.username
HAVING COUNT(s.id) > 0
ORDER BY urgent_cases DESC, open_cases DESC, owner_name ASC
LIMIT 8
"""
) or []
technicians_today = execute_query(
"""
SELECT
u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases,
COUNT(CASE WHEN s.deadline::date = CURRENT_DATE THEN 1 END)::int AS due_today_cases,
COALESCE(
(
SELECT json_agg(
json_build_object(
'id', t.id,
'title', t.titel,
'priority', COALESCE(t.priority, 'normal'),
'deadline', t.deadline
)
ORDER BY
CASE WHEN t.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END,
COALESCE(t.deadline, t.updated_at, t.created_at) ASC,
t.id ASC
)
FROM (
SELECT s2.id, s2.titel, s2.priority, s2.deadline, s2.updated_at, s2.created_at
FROM sag_sager s2
WHERE s2.ansvarlig_bruger_id = u.user_id
AND s2.deleted_at IS NULL
AND LOWER(COALESCE(s2.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND (
s2.deadline::date = CURRENT_DATE
OR s2.created_at::date = CURRENT_DATE
)
ORDER BY
CASE WHEN s2.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END,
COALESCE(s2.deadline, s2.updated_at, s2.created_at) ASC,
s2.id ASC
LIMIT 6
) t
),
'[]'::json
) AS today_tasks
FROM users u
LEFT JOIN sag_sager s
ON s.ansvarlig_bruger_id = u.user_id
AND s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
WHERE EXISTS (
SELECT 1
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = u.user_id
AND LOWER(g.name) LIKE ANY(ARRAY['%teknik%', '%technician%', '%support%'])
)
GROUP BY u.user_id, u.full_name, u.username
ORDER BY due_today_cases DESC, open_cases DESC, owner_name ASC
LIMIT 10
"""
) or []
escalation_cases = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.updated_at,
EXTRACT(EPOCH FROM (NOW() - COALESCE(s.updated_at, s.created_at)))::int AS age_seconds,
COALESCE(NULLIF(u.full_name, ''), u.username, 'Ikke tildelt') AS owner_name
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours'
ORDER BY COALESCE(s.updated_at, s.created_at) ASC
LIMIT 8
"""
) or []
unassigned_cases = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.created_at,
s.updated_at
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT 8
"""
) or []
sections = {
"mail": {
"unread": status.get("mails_unread", 0),
"customer_reply_needed": status.get("mails_unread", 0),
},
"cases": {
"open": status.get("sager_open", 0),
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in open_cases],
},
"urgent": {
"count": status.get("sager_urgent", 0),
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in urgent_cases],
},
"unassigned": {
"count": status.get("sager_unassigned", 0),
},
"timer": {
"active_count": 1 if timer.get("active") else 0,
"list": timer_list,
"active": timer,
},
"kuma": {
"down": 0,
"list": [],
},
"eset": {
"incidents": 0,
"list": [],
},
"messages": {
"count": len(messages),
"list": messages,
},
"tasks": {
"count": len(tasks),
"list": tasks,
},
"boss": {
"can_view": can_view_boss,
"stats": {
"unassigned": status.get("sager_unassigned", 0),
"active_employees": _safe_count(
execute_query_single(
"SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer = TRUE AND slut_tid IS NULL"
)
),
"open_cases": status.get("sager_open", 0),
"urgent_cases": status.get("sager_urgent", 0),
"stale_urgent_cases": len(escalation_cases),
}
,
"team_workload": [
{
"user_id": row.get("user_id"),
"owner_name": row.get("owner_name"),
"open_cases": int(row.get("open_cases") or 0),
"urgent_cases": int(row.get("urgent_cases") or 0),
}
for row in team_workload
],
"technicians_today": [
{
"user_id": row.get("user_id"),
"owner_name": row.get("owner_name"),
"open_cases": int(row.get("open_cases") or 0),
"due_today_cases": int(row.get("due_today_cases") or 0),
"today_tasks": row.get("today_tasks") or [],
}
for row in technicians_today
],
"escalations": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
"owner_name": row.get("owner_name") or "Ikke tildelt",
"age_seconds": int(row.get("age_seconds") or 0),
}
for row in escalation_cases
],
"unassigned_cases": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
}
for row in unassigned_cases
],
},
"context_actions": context_actions,
}
return {
"enabled": True,
"sections": sections,
"status": status,
"active_timer": timer,
"notifications": notifications,
}

View File

@ -0,0 +1,11 @@
{
"name": "bottom_bar",
"version": "1.0.0",
"description": "Global activity bottom bar module",
"author": "BMC Networks",
"enabled": true,
"dependencies": [],
"table_prefix": "bottom_bar_",
"api_prefix": "/api/v1",
"tags": ["Bottom Bar"]
}

View File

@ -221,52 +221,130 @@ async def hardware_list(
request: Request,
status: str = Query(None),
asset_type: str = Query(None),
rental_scope: str = Query(None),
customer_id: int = Query(None),
q: str = Query(None)
):
"""Display list of all hardware."""
query = "SELECT * FROM hardware_assets WHERE deleted_at IS NULL"
"""Display list of BMC-owned assets only."""
query = """
SELECT
ha.*,
c.name AS customer_name,
CASE
WHEN EXISTS (
SELECT 1
FROM subscription_asset_bindings b
WHERE b.asset_id = ha.id
AND b.deleted_at IS NULL
AND b.status = 'active'
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
) THEN true
ELSE false
END AS is_currently_rented
FROM hardware_assets ha
LEFT JOIN customers c ON c.id = ha.current_owner_customer_id
WHERE ha.deleted_at IS NULL
AND ha.current_owner_type = 'bmc'
"""
params = []
if status:
query += " AND status = %s"
query += " AND ha.status = %s"
params.append(status)
if asset_type:
query += " AND asset_type = %s"
query += " AND ha.asset_type = %s"
params.append(asset_type)
if customer_id:
query += " AND current_owner_customer_id = %s"
query += " AND ha.current_owner_customer_id = %s"
params.append(customer_id)
if q:
query += " AND (serial_number ILIKE %s OR model ILIKE %s OR brand ILIKE %s)"
query += " AND (ha.serial_number ILIKE %s OR ha.model ILIKE %s OR ha.brand ILIKE %s OR ha.internal_asset_id ILIKE %s)"
search_param = f"%{q}%"
params.extend([search_param, search_param, search_param])
params.extend([search_param, search_param, search_param, search_param])
query += " ORDER BY created_at DESC"
if rental_scope == "rented":
query += """
AND EXISTS (
SELECT 1
FROM subscription_asset_bindings b
WHERE b.asset_id = ha.id
AND b.deleted_at IS NULL
AND b.status = 'active'
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
)
"""
elif rental_scope == "not_rented":
query += """
AND NOT EXISTS (
SELECT 1
FROM subscription_asset_bindings b
WHERE b.asset_id = ha.id
AND b.deleted_at IS NULL
AND b.status = 'active'
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
)
"""
query += " ORDER BY ha.created_at DESC"
hardware = execute_query(query, tuple(params))
# Get customer names for display
if hardware:
customer_ids = [h['current_owner_customer_id'] for h in hardware if h.get('current_owner_customer_id')]
if customer_ids:
customer_query = "SELECT id, name AS navn FROM customers WHERE id = ANY(%s)"
customers = execute_query(customer_query, (customer_ids,))
customer_map = {c['id']: c['navn'] for c in customers} if customers else {}
# Add customer names to hardware
for h in hardware:
if h.get('current_owner_customer_id'):
h['customer_name'] = customer_map.get(h['current_owner_customer_id'], 'Unknown')
return templates.TemplateResponse("modules/hardware/templates/index.html", {
"request": request,
"hardware": hardware,
"current_status": status,
"current_asset_type": asset_type,
"current_rental_scope": rental_scope,
"search_query": q
})
@router.get("/hardware/customers", response_class=HTMLResponse)
async def customer_hardware_list(
request: Request,
status: str = Query(None),
asset_type: str = Query(None),
customer_id: int = Query(None),
q: str = Query(None)
):
"""Display customer-owned hardware on dedicated page."""
query = """
SELECT
ha.*,
c.name AS customer_name
FROM hardware_assets ha
LEFT JOIN customers c ON c.id = ha.current_owner_customer_id
WHERE ha.deleted_at IS NULL
AND ha.current_owner_type = 'customer'
"""
params = []
if status:
query += " AND ha.status = %s"
params.append(status)
if asset_type:
query += " AND ha.asset_type = %s"
params.append(asset_type)
if customer_id:
query += " AND ha.current_owner_customer_id = %s"
params.append(customer_id)
if q:
query += " AND (ha.serial_number ILIKE %s OR ha.model ILIKE %s OR ha.brand ILIKE %s OR ha.internal_asset_id ILIKE %s OR c.name ILIKE %s)"
search_param = f"%{q}%"
params.extend([search_param, search_param, search_param, search_param, search_param])
query += " ORDER BY c.name ASC NULLS LAST, ha.created_at DESC"
customer_hardware = execute_query(query, tuple(params))
return templates.TemplateResponse("modules/hardware/templates/customers.html", {
"request": request,
"hardware": customer_hardware,
"current_status": status,
"current_asset_type": asset_type,
"search_query": q,
"current_customer_id": customer_id,
})
@router.get("/hardware/new", response_class=HTMLResponse)
async def create_hardware_form(request: Request):
"""Display create hardware form."""
@ -358,7 +436,7 @@ async def hardware_eset_import(request: Request):
})
@router.get("/hardware/{hardware_id}", response_class=HTMLResponse)
@router.get("/hardware/{hardware_id:int}", response_class=HTMLResponse)
async def hardware_detail(request: Request, hardware_id: int):
"""Display hardware details."""
# Get hardware
@ -507,7 +585,7 @@ async def hardware_detail(request: Request, hardware_id: int):
})
@router.get("/hardware/{hardware_id}/edit", response_class=HTMLResponse)
@router.get("/hardware/{hardware_id:int}/edit", response_class=HTMLResponse)
async def edit_hardware_form(request: Request, hardware_id: int):
"""Display edit hardware form."""
# Get hardware
@ -528,7 +606,7 @@ async def edit_hardware_form(request: Request, hardware_id: int):
})
@router.post("/hardware/{hardware_id}/location")
@router.post("/hardware/{hardware_id:int}/location")
async def update_hardware_location(
request: Request,
hardware_id: int,
@ -574,7 +652,7 @@ async def update_hardware_location(
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id}/owner")
@router.post("/hardware/{hardware_id:int}/owner")
async def update_hardware_owner(
request: Request,
hardware_id: int,
@ -649,7 +727,7 @@ async def update_hardware_owner(
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id}/contacts/add")
@router.post("/hardware/{hardware_id:int}/contacts/add")
async def add_hardware_contact(
request: Request,
hardware_id: int,
@ -671,7 +749,7 @@ async def add_hardware_contact(
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id}/contacts/{contact_id}/delete")
@router.post("/hardware/{hardware_id:int}/contacts/{contact_id:int}/delete")
async def remove_hardware_contact(
request: Request,
hardware_id: int,

View File

@ -0,0 +1,104 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Kundehardware - BMC Hub{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-4">
<h1 class="h3 mb-0">Kundehardware</h1>
<div class="d-flex gap-2">
<a href="/hardware" class="btn btn-outline-secondary btn-sm">BMC Assets</a>
<a href="/hardware/new" class="btn btn-primary btn-sm">Nyt Hardware</a>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<form method="get" action="/hardware/customers" class="row g-3 align-items-end">
<div class="col-12 col-md-3">
<label for="asset_type" class="form-label">Type</label>
<select name="asset_type" id="asset_type" class="form-select form-select-sm">
<option value="">Alle typer</option>
<option value="pc" {% if current_asset_type == 'pc' %}selected{% endif %}>PC</option>
<option value="laptop" {% if current_asset_type == 'laptop' %}selected{% endif %}>Laptop</option>
<option value="printer" {% if current_asset_type == 'printer' %}selected{% endif %}>Printer</option>
<option value="skærm" {% if current_asset_type == 'skærm' %}selected{% endif %}>Skærm</option>
<option value="telefon" {% if current_asset_type == 'telefon' %}selected{% endif %}>Telefon</option>
<option value="server" {% if current_asset_type == 'server' %}selected{% endif %}>Server</option>
<option value="netværk" {% if current_asset_type == 'netværk' %}selected{% endif %}>Netværk</option>
<option value="andet" {% if current_asset_type == 'andet' %}selected{% endif %}>Andet</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="status" class="form-label">Status</label>
<select name="status" id="status" class="form-select form-select-sm">
<option value="">Alle status</option>
<option value="active" {% if current_status == 'active' %}selected{% endif %}>Aktiv</option>
<option value="faulty_reported" {% if current_status == 'faulty_reported' %}selected{% endif %}>Fejl rapporteret</option>
<option value="in_repair" {% if current_status == 'in_repair' %}selected{% endif %}>Under reparation</option>
<option value="replaced" {% if current_status == 'replaced' %}selected{% endif %}>Udskiftet</option>
<option value="retired" {% if current_status == 'retired' %}selected{% endif %}>Udtjent</option>
<option value="unsupported" {% if current_status == 'unsupported' %}selected{% endif %}>Ikke supporteret</option>
</select>
</div>
<div class="col-12 col-md-4">
<label for="q" class="form-label">Søg</label>
<input type="text" name="q" id="q" class="form-control form-control-sm" value="{{ search_query or '' }}" placeholder="Model, serial, kunde...">
</div>
<div class="col-12 col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100">Filtrer</button>
</div>
</form>
</div>
</div>
{% if hardware and hardware|length > 0 %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Hardware</th>
<th>Kunde</th>
<th>Type</th>
<th>Serienr.</th>
<th>Status</th>
<th>AnyDesk</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
{% for item in hardware %}
<tr>
<td class="fw-semibold">{{ item.brand or 'Unknown' }} {{ item.model or '' }}</td>
<td>{{ item.customer_name or 'Ukendt kunde' }}</td>
<td>{{ item.asset_type|title }}</td>
<td>{{ item.serial_number or 'Ingen serienummer' }}</td>
<td>{{ item.status|replace('_', ' ')|title }}</td>
<td>
{% if item.anydesk_link %}
<a href="{{ item.anydesk_link }}" target="_blank">{{ item.anydesk_id or 'Åbn' }}</a>
{% elif item.anydesk_id %}
<a href="anydesk://{{ item.anydesk_id }}" target="_blank">{{ item.anydesk_id }}</a>
{% else %}
{% endif %}
</td>
<td class="text-end">
<a href="/hardware/{{ item.id }}" class="btn btn-outline-secondary btn-sm">Se</a>
<a href="/hardware/{{ item.id }}/edit" class="btn btn-outline-primary btn-sm">Rediger</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<h5>Ingen kundehardware fundet</h5>
<p class="mb-0">Der er ingen kundeejede enheder, der matcher filtre.</p>
</div>
{% endif %}
{% endblock %}

View File

@ -242,8 +242,12 @@
{% block content %}
<div class="page-header">
<h1>🖥️ Hardware Oversigt</h1>
<h1>🗂️ BMC Assets Oversigt (Kun vores egne)</h1>
<div class="d-flex gap-2">
<a href="/hardware/customers" class="btn-new-hardware" style="background-color: #6c757d;">
<i class="bi bi-building"></i>
Kundehardware
</a>
<a href="/hardware/eset" class="btn-new-hardware" style="background-color: #0f4c75;">
<i class="bi bi-shield-check"></i>
ESET Oversigt
@ -285,6 +289,15 @@
</select>
</div>
<div class="filter-group">
<label for="rental_scope">Udlejning</label>
<select name="rental_scope" id="rental_scope">
<option value="" {% if not current_rental_scope %}selected{% endif %}>Alle assets</option>
<option value="rented" {% if current_rental_scope == 'rented' %}selected{% endif %}>Kun udlejede</option>
<option value="not_rented" {% if current_rental_scope == 'not_rented' %}selected{% endif %}>Kun ikke-udlejede</option>
</select>
</div>
<div class="filter-group">
<label for="q">Søg</label>
<input type="text" name="q" id="q" placeholder="Serial, model, mærke..." value="{{ search_query or '' }}">
@ -362,6 +375,16 @@
<span class="hardware-detail-value">{{ item.internal_asset_id }}</span>
</div>
{% endif %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">Udlejning:</span>
<span class="hardware-detail-value">
{% if item.is_currently_rented %}
Udlejet
{% else %}
Ledig/Intern
{% endif %}
</span>
</div>
</div>
<div class="hardware-footer">
@ -387,6 +410,7 @@
<th>Type</th>
<th>Serienr.</th>
<th>Ejer</th>
<th>Udlejning</th>
<th>Status</th>
<th>AnyDesk</th>
<th class="text-end">Handling</th>
@ -399,6 +423,13 @@
<td>{{ item.asset_type|title }}</td>
<td>{{ item.serial_number or 'Ingen serienummer' }}</td>
<td>{{ item.customer_name or (item.current_owner_type|title if item.current_owner_type else '—') }}</td>
<td>
{% if item.is_currently_rented %}
<span class="status-badge" style="background-color:#0f4c75;color:#fff;">Udlejet</span>
{% else %}
<span class="status-badge" style="background-color:#6c757d;color:#fff;">Ledig/Intern</span>
{% endif %}
</td>
<td>
<span class="status-badge status-{{ item.status }}">
{{ item.status|replace('_', ' ')|title }}
@ -426,17 +457,18 @@
{% else %}
<div class="empty-state">
<div class="empty-state-icon">🖥️</div>
<h3>Ingen hardware fundet</h3>
<p>Opret dit første hardware asset for at komme i gang.</p>
<h3>Ingen BMC assets fundet</h3>
<p>Opret dit første interne asset for at komme i gang.</p>
<a href="/hardware/new" class="btn-new-hardware" style="margin-top: 1rem;"> Opret Hardware</a>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// Auto-submit filter form on change
document.querySelectorAll('#asset_type, #status').forEach(select => {
document.querySelectorAll('#asset_type, #status, #rental_scope').forEach(select => {
select.addEventListener('change', () => {
select.form.submit();
});

View File

@ -0,0 +1,32 @@
import time
from typing import Any, Optional
class ManualCache:
"""
Very simple in-memory TTL cache for the Manual MVP.
Stores GET lists and details. Clears fully on any mutation.
"""
def __init__(self, ttl_seconds: int = 300):
self.ttl = ttl_seconds
self._store = {}
def get(self, key: str) -> Optional[Any]:
item = self._store.get(key)
if item is None:
return None
if time.time() > item["expires"]:
del self._store[key]
return None
return item["value"]
def set(self, key: str, value: Any):
self._store[key] = {
"value": value,
"expires": time.time() + self.ttl
}
def clear(self):
self._store.clear()
# Global singleton instance for the app
manual_cache = ManualCache(ttl_seconds=300) # 5 min cache

View File

@ -3,9 +3,10 @@ import json
import re
from typing import Any, Dict, List, Literal, Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi import BackgroundTasks, APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from app.modules.manual.backend.cache import manual_cache
from app.core.database import execute_query, execute_query_single, execute_update
logger = logging.getLogger(__name__)
@ -278,7 +279,13 @@ async def contextual_manual_suggestions(
@router.get("/manual/{slug}")
async def get_manual_article(slug: str):
async def get_manual_article(slug: str, background_tasks: BackgroundTasks):
cache_key = f"slug:{slug}"
cached = manual_cache.get(cache_key)
if cached:
background_tasks.add_task(_increment_use_count, cached["id"])
return cached
article = execute_query_single(
"""
SELECT id, title, slug, content, summary, module, tags, difficulty, use_count, created_at, updated_at

View File

@ -7,6 +7,7 @@ from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.modules.manual.backend.cache import manual_cache
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
@ -36,52 +37,54 @@ async def manual_index(
tag: Optional[str] = Query(default=None),
search: Optional[str] = Query(default=None),
):
where_parts = ["deleted_at IS NULL"]
params: List[Any] = []
cache_key = f"views:list:{module}:{difficulty}:{tag}:{search}"
cached = manual_cache.get(cache_key)
if cached:
rows, modules, unique_tags = cached
else:
filters = ["deleted_at IS NULL"]
params = []
if module:
filters.append("module = %s")
params.append(module)
if difficulty:
filters.append("difficulty = %s")
params.append(difficulty)
if tag:
filters.append("tags @> %s::jsonb")
params.append(f'["{tag}"]')
if search:
filters.append("(title ILIKE %s OR content ILIKE %s)")
params.extend([f"%{search}%", f"%{search}%"])
if module:
where_parts.append("LOWER(module) = LOWER(%s)")
params.append(module.strip())
where_clause = " AND ".join(filters)
if difficulty in {"beginner", "advanced"}:
where_parts.append("difficulty = %s")
params.append(difficulty)
rows = execute_query(
f"SELECT id, slug, title, content, module, tags, difficulty, use_count, updated_at "
f"FROM manual_articles WHERE {where_clause} ORDER BY updated_at DESC",
tuple(params)
) or []
if search:
needle = f"%{search.strip()}%"
where_parts.append("(title ILIKE %s OR summary ILIKE %s OR content ILIKE %s)")
params.extend([needle, needle, needle])
modules = execute_query(
"SELECT DISTINCT module FROM manual_articles WHERE deleted_at IS NULL ORDER BY module ASC"
) or []
if tag:
where_parts.append(
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(tags, '[]'::jsonb)) t(tag) WHERE LOWER(t.tag) = LOWER(%s))"
)
params.append(tag.strip())
rows = execute_query(
f"""
SELECT id, title, slug, summary, module, difficulty, tags, use_count, updated_at
FROM manual_articles
WHERE {' AND '.join(where_parts)}
ORDER BY use_count DESC, updated_at DESC
LIMIT 300
""",
tuple(params),
) or []
modules = execute_query(
"""
SELECT DISTINCT module
FROM manual_articles
WHERE deleted_at IS NULL
ORDER BY module ASC
"""
) or []
all_tags: List[str] = []
for row in rows:
all_tags.extend(_normalize_tag_list(row.get("tags")))
unique_tags = sorted(list(set(all_tags)))
all_tags: List[str] = []
for row in rows:
if "tags" in row and row["tags"]:
try:
import json
if isinstance(row["tags"], str):
t = json.loads(row["tags"])
if isinstance(t, list):
all_tags.extend(t)
elif isinstance(row["tags"], list):
all_tags.extend(row["tags"])
except Exception:
pass
unique_tags = sorted(list(set(all_tags)))
manual_cache.set(cache_key, (rows, modules, unique_tags))
return templates.TemplateResponse(
"modules/manual/templates/list.html",

View File

View File

View File

@ -0,0 +1,997 @@
from __future__ import annotations
import logging
import json
from datetime import date
from typing import Any, Dict, List, Literal, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from psycopg2.extras import RealDictCursor
from dateutil.relativedelta import relativedelta
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
from app.core.config import settings
from app.jobs.process_subscriptions import process_subscriptions
from app.subscriptions.backend.router import create_subscription as create_sag_subscription
from app.subscriptions.backend.router import update_subscription as update_sag_subscription
logger = logging.getLogger(__name__)
router = APIRouter()
PRICE_TYPE_FIELD_MAP = {
"day": "rental_price_day",
"week": "rental_price_week",
"month": "rental_price_month",
"year": "rental_price_year",
}
def _economic_safety_state() -> Dict[str, bool]:
return {
"economic_enabled": bool(getattr(settings, "ECONOMIC_ENABLED", False)),
"economic_read_only": bool(getattr(settings, "ECONOMIC_READ_ONLY", True)),
"economic_dry_run": bool(getattr(settings, "ECONOMIC_DRY_RUN", True)),
"ordre_economic_read_only": bool(getattr(settings, "ORDRE_ECONOMIC_READ_ONLY", True)),
"ordre_economic_dry_run": bool(getattr(settings, "ORDRE_ECONOMIC_DRY_RUN", True)),
}
def _add_interval(start: date, interval: str) -> date:
normalized = (interval or "monthly").strip().lower()
if normalized == "daily":
return start + relativedelta(days=1)
if normalized == "biweekly":
return start + relativedelta(weeks=2)
if normalized == "quarterly":
return start + relativedelta(months=3)
if normalized == "yearly":
return start + relativedelta(years=1)
return start + relativedelta(months=1)
def _assert_asset_booking_available(
asset_id: int,
start_date: date,
end_date: Optional[date],
exclude_subscription_id: Optional[int] = None,
) -> None:
where_extra = ""
params: List[Any] = [asset_id, start_date, end_date]
if exclude_subscription_id is not None:
where_extra = " AND b.subscription_id != %s"
params.append(exclude_subscription_id)
overlap = execute_query_single(
f"""
SELECT b.id, b.subscription_id, b.start_date, b.end_date
FROM subscription_asset_bindings b
WHERE b.asset_id = %s
AND b.deleted_at IS NULL
AND b.status = 'active'
{where_extra}
AND NOT (
COALESCE(b.end_date, DATE '9999-12-31') < %s
OR b.start_date > COALESCE(%s, DATE '9999-12-31')
)
LIMIT 1
""",
tuple(params),
)
if overlap:
raise HTTPException(
status_code=409,
detail=(
f"Asset {asset_id} is already booked in subscription "
f"{overlap.get('subscription_id')} from {overlap.get('start_date')} to {overlap.get('end_date')}"
),
)
def _resolve_product_map(product_ids: List[int]) -> Dict[int, Dict[str, Any]]:
if not product_ids:
return {}
rows = execute_query(
"""
SELECT
id,
sales_price,
rental_price_day,
rental_price_week,
rental_price_month,
rental_price_year
FROM products
WHERE id = ANY(%s)
AND deleted_at IS NULL
""",
(product_ids,),
) or []
return {int(row["id"]): row for row in rows}
def _derive_unit_price(
product: Optional[Dict[str, Any]],
price_type: str,
custom_override: bool,
provided_unit_price: Optional[float],
) -> float:
if provided_unit_price is not None and custom_override:
return float(provided_unit_price)
normalized_type = (price_type or "manual").strip().lower()
if normalized_type in PRICE_TYPE_FIELD_MAP and not custom_override:
if not product:
raise HTTPException(status_code=400, detail="product_id is required for period price types")
price_field = PRICE_TYPE_FIELD_MAP[normalized_type]
period_price = product.get(price_field)
if period_price is None:
raise HTTPException(
status_code=400,
detail=f"Product {product.get('id')} has no {price_field} configured",
)
return float(period_price)
if provided_unit_price is not None:
return float(provided_unit_price)
if product and product.get("sales_price") is not None:
return float(product.get("sales_price"))
raise HTTPException(status_code=400, detail="unit_price is required")
def _normalize_line_items(
line_items: List[SubscriptionLineInput],
default_price_type: str,
default_custom_override: bool,
) -> List[Dict[str, Any]]:
product_ids = [line.product_id for line in line_items if line.product_id is not None]
product_map = _resolve_product_map([int(pid) for pid in product_ids])
normalized: List[Dict[str, Any]] = []
for line in line_items:
line_data = line.model_dump(exclude_none=True)
line_price_type = (line_data.get("price_type") or default_price_type or "manual").strip().lower()
line_custom_override = bool(line_data.get("custom_price_override", default_custom_override))
product = product_map.get(int(line.product_id)) if line.product_id is not None else None
unit_price = _derive_unit_price(
product=product,
price_type=line_price_type,
custom_override=line_custom_override,
provided_unit_price=line_data.get("unit_price"),
)
line_data["unit_price"] = unit_price
line_data["quantity"] = float(line_data.get("quantity", 1))
line_data["price_type"] = line_price_type
line_data["custom_price_override"] = line_custom_override
if isinstance(line_data.get("period_from"), date):
line_data["period_from"] = line_data["period_from"].isoformat()
if isinstance(line_data.get("period_to"), date):
line_data["period_to"] = line_data["period_to"].isoformat()
normalized.append(line_data)
return normalized
def _build_due_subscription_preview(as_of: date, customer_id: Optional[int]) -> Dict[str, Any]:
where = [
"s.status = 'active'",
"s.next_invoice_date <= %s",
"COALESCE(s.billing_blocked, false) = false",
]
params: List[Any] = [as_of]
if customer_id is not None:
where.append("s.customer_id = %s")
params.append(customer_id)
rows = execute_query(
f"""
SELECT
s.id,
s.customer_id,
c.name AS customer_name,
s.invoice_merge_key,
s.billing_direction,
s.next_invoice_date,
s.period_start,
s.billing_interval,
COALESCE(
(
SELECT json_agg(
json_build_object(
'id', i.id,
'line_total', i.line_total,
'billing_blocked', i.billing_blocked,
'period_from', i.period_from,
'period_to', i.period_to
) ORDER BY i.id ASC
)
FROM sag_subscription_items i
WHERE i.subscription_id = s.id
),
'[]'::json
) AS line_items
FROM sag_subscriptions s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE {' AND '.join(where)}
ORDER BY s.customer_id, s.next_invoice_date, s.id
""",
tuple(params),
) or []
groups: Dict[str, Dict[str, Any]] = {}
for row in rows:
merge_key = row.get("invoice_merge_key") or f"cust-{row['customer_id']}"
group_id = f"{row['customer_id']}|{merge_key}|{row.get('next_invoice_date')}|{row.get('billing_direction') or 'forward'}"
grp = groups.setdefault(
group_id,
{
"customer_id": row["customer_id"],
"customer_name": row.get("customer_name"),
"invoice_merge_key": merge_key,
"invoice_date": str(row.get("next_invoice_date")),
"billing_direction": row.get("billing_direction") or "forward",
"subscription_ids": [],
"coverage_start": None,
"coverage_end": None,
"line_count": 0,
"amount_total": 0.0,
},
)
grp["subscription_ids"].append(int(row["id"]))
period_start = row.get("period_start") or row.get("next_invoice_date")
period_end = _add_interval(period_start, row.get("billing_interval") or "monthly")
grp["coverage_start"] = (
str(period_start)
if grp["coverage_start"] is None or str(period_start) < grp["coverage_start"]
else grp["coverage_start"]
)
grp["coverage_end"] = (
str(period_end)
if grp["coverage_end"] is None or str(period_end) > grp["coverage_end"]
else grp["coverage_end"]
)
for item in row.get("line_items") or []:
if item.get("billing_blocked"):
continue
grp["line_count"] += 1
grp["amount_total"] += float(item.get("line_total") or 0)
group_values = list(groups.values())
return {
"as_of": str(as_of),
"customer_id": customer_id,
"groups": group_values,
"group_count": len(group_values),
"amount_total": round(sum(float(g.get("amount_total") or 0) for g in group_values), 2),
}
def _generate_due_invoices_for_customer(as_of: date, customer_id: int) -> Dict[str, Any]:
conn = get_db_connection()
created_drafts = 0
touched_subscriptions = 0
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
SELECT
s.id,
s.customer_id,
c.name AS customer_name,
s.billing_interval,
s.billing_direction,
s.next_invoice_date,
s.period_start,
s.invoice_merge_key,
COALESCE(
(
SELECT json_agg(
json_build_object(
'description', i.description,
'quantity', i.quantity,
'unit_price', i.unit_price,
'line_total', i.line_total,
'product_id', i.product_id,
'asset_id', i.asset_id,
'period_from', i.period_from,
'period_to', i.period_to,
'billing_blocked', i.billing_blocked
) ORDER BY i.id ASC
)
FROM sag_subscription_items i
WHERE i.subscription_id = s.id
),
'[]'::json
) AS line_items
FROM sag_subscriptions s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.status = 'active'
AND s.customer_id = %s
AND s.next_invoice_date <= %s
AND COALESCE(s.billing_blocked, false) = false
ORDER BY s.next_invoice_date ASC, s.id ASC
""",
(customer_id, as_of),
)
subscriptions = cursor.fetchall() or []
if not subscriptions:
return {
"status": "ok",
"message": "No due subscriptions for customer",
"created_drafts": 0,
"processed_subscriptions": 0,
}
grouped: Dict[str, List[Dict[str, Any]]] = {}
for sub in subscriptions:
merge_key = sub.get("invoice_merge_key") or f"cust-{sub['customer_id']}"
group_key = (
f"{sub['customer_id']}|{merge_key}|{sub.get('next_invoice_date')}|"
f"{sub.get('billing_direction') or 'forward'}"
)
grouped.setdefault(group_key, []).append(sub)
for group in grouped.values():
first = group[0]
merge_key = first.get("invoice_merge_key") or f"cust-{first['customer_id']}"
billing_direction = first.get("billing_direction") or "forward"
customer_name = first.get("customer_name") or f"Customer #{first['customer_id']}"
source_ids: List[int] = []
lines: List[Dict[str, Any]] = []
coverage_start: Optional[date] = None
coverage_end: Optional[date] = None
for sub in group:
source_ids.append(int(sub["id"]))
period_start = sub.get("period_start") or sub.get("next_invoice_date")
period_end = _add_interval(period_start, sub.get("billing_interval") or "monthly")
coverage_start = period_start if coverage_start is None or period_start < coverage_start else coverage_start
coverage_end = period_end if coverage_end is None or period_end > coverage_end else coverage_end
for item in sub.get("line_items") or []:
if item.get("billing_blocked"):
continue
lines.append(
{
"product": {
"productNumber": str(item.get("product_id") or "SUB"),
"description": item.get("description") or "",
},
"quantity": float(item.get("quantity") or 1),
"unitNetPrice": float(item.get("unit_price") or 0),
"totalNetAmount": float(item.get("line_total") or 0),
"discountPercentage": 0,
"metadata": {
"subscription_id": int(sub["id"]),
"asset_id": item.get("asset_id"),
"period_from": str(item.get("period_from") or period_start),
"period_to": str(item.get("period_to") or period_end),
},
}
)
if not lines:
continue
cursor.execute(
"""
SELECT id
FROM ordre_drafts
WHERE customer_id = %s
AND invoice_aggregate_key = %s
AND coverage_start = %s
AND coverage_end = %s
AND deleted_at IS NULL
AND sync_status IN ('pending', 'exported', 'posted', 'paid')
LIMIT 1
""",
(first["customer_id"], merge_key, coverage_start, coverage_end),
)
existing = cursor.fetchone()
if existing:
continue
cursor.execute(
"""
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
coverage_start,
coverage_end,
billing_direction,
source_subscription_ids,
invoice_aggregate_key,
layout_number,
created_by_user_id,
sync_status,
export_status_json,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
""",
(
f"Abonnementer: {customer_name}",
first["customer_id"],
json.dumps(lines, ensure_ascii=False),
(
"Aggregated abonnement faktura\n"
f"Kunde: {customer_name}\n"
f"Coverage: {coverage_start} til {coverage_end}\n"
f"Subscription IDs: {', '.join(str(sid) for sid in source_ids)}"
),
coverage_start,
coverage_end,
billing_direction,
source_ids,
merge_key,
1,
None,
"pending",
json.dumps({"source": "subscription", "subscription_ids": source_ids}, ensure_ascii=False),
),
)
created_drafts += 1
for sub in group:
period_start = sub.get("period_start") or sub.get("next_invoice_date")
new_period_start = _add_interval(period_start, sub.get("billing_interval") or "monthly")
new_next_invoice_date = _add_interval(new_period_start, sub.get("billing_interval") or "monthly")
cursor.execute(
"""
UPDATE sag_subscriptions
SET period_start = %s,
next_invoice_date = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(new_period_start, new_next_invoice_date, sub["id"]),
)
touched_subscriptions += 1
conn.commit()
return {
"status": "ok",
"message": "Customer-scoped invoice generation completed",
"created_drafts": created_drafts,
"processed_subscriptions": touched_subscriptions,
"customer_id": customer_id,
"as_of": str(as_of),
}
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
class SubscriptionLineInput(BaseModel):
product_id: Optional[int] = None
description: str
quantity: float = 1
unit_price: Optional[float] = None
asset_id: Optional[int] = None
period_from: Optional[date] = None
period_to: Optional[date] = None
serial_number: Optional[str] = None
price_type: Literal["manual", "day", "week", "month", "year"] = "manual"
custom_price_override: bool = False
class SubscriptionCreateInput(BaseModel):
customer_id: int
sag_id: int
product_name: str = Field(min_length=1)
price: float = Field(ge=0)
billing_interval: Literal["daily", "biweekly", "monthly", "quarterly", "yearly"] = "monthly"
billing_day: int = Field(default=1, ge=1, le=31)
start_date: date
end_date: Optional[date] = None
binding_months: int = Field(default=0, ge=0)
billing_direction: Literal["forward", "backward"] = "forward"
price_type: Literal["manual", "day", "week", "month", "year"] = "manual"
custom_price_override: bool = False
first_invoice_policy: Literal["start_date", "next_cycle"] = "start_date"
invoice_merge_key: Optional[str] = None
notes: Optional[str] = None
line_items: List[SubscriptionLineInput] = Field(default_factory=list)
class SubscriptionUpdateInput(BaseModel):
product_name: Optional[str] = None
price: Optional[float] = Field(default=None, ge=0)
billing_interval: Optional[Literal["daily", "biweekly", "monthly", "quarterly", "yearly"]] = None
billing_day: Optional[int] = Field(default=None, ge=1, le=31)
start_date: Optional[date] = None
end_date: Optional[date] = None
binding_months: Optional[int] = Field(default=None, ge=0)
billing_direction: Optional[Literal["forward", "backward"]] = None
price_type: Optional[Literal["manual", "day", "week", "month", "year"]] = None
custom_price_override: Optional[bool] = None
first_invoice_policy: Optional[Literal["start_date", "next_cycle"]] = None
invoice_merge_key: Optional[str] = None
notes: Optional[str] = None
status: Optional[Literal["draft", "active", "paused", "cancelled"]] = None
line_items: Optional[List[SubscriptionLineInput]] = None
class AssetStatusInput(BaseModel):
status: Literal["ledig", "udlejet", "defekt", "retur"]
status_reason: Optional[str] = None
class AssetBindingCreateInput(BaseModel):
asset_id: int
start_date: date
end_date: Optional[date] = None
binding_months: int = Field(default=0, ge=0)
shared_binding_key: Optional[str] = None
notice_period_days: int = Field(default=30, ge=0)
sag_id: Optional[int] = None
created_by_user_id: Optional[int] = None
class InvoiceGenerateInput(BaseModel):
preview: bool = False
customer_id: Optional[int] = None
as_of: Optional[date] = None
push_to_economic: bool = False
@router.get("/assets", response_model=List[Dict[str, Any]])
async def list_assets(
status: str = Query("all"),
customer_id: Optional[int] = Query(default=None),
only_rental_enabled: bool = Query(default=False),
):
"""Alias endpoint for rental-focused asset listing."""
where = ["ha.deleted_at IS NULL"]
params: List[Any] = []
if status != "all":
where.append("COALESCE(ha.rental_status, 'ledig') = %s")
params.append(status)
if customer_id is not None:
where.append("ha.current_owner_customer_id = %s")
params.append(customer_id)
if only_rental_enabled:
where.append(
"EXISTS (SELECT 1 FROM products p WHERE p.rental_asset_enabled = true AND p.deleted_at IS NULL)"
)
rows = execute_query(
f"""
SELECT
ha.id,
ha.asset_type,
ha.brand,
ha.model,
ha.serial_number,
ha.customer_asset_id,
ha.internal_asset_id,
ha.current_owner_customer_id AS customer_id,
c.name AS customer_name,
COALESCE(ha.rental_status, 'ledig') AS rental_status,
ha.status AS hardware_status,
ha.status_reason,
ha.updated_at
FROM hardware_assets ha
LEFT JOIN customers c ON c.id = ha.current_owner_customer_id
WHERE {' AND '.join(where)}
ORDER BY ha.updated_at DESC, ha.id DESC
""",
tuple(params),
) or []
return rows
@router.put("/assets/{asset_id}/status", response_model=Dict[str, Any])
async def update_asset_rental_status(asset_id: int, payload: AssetStatusInput):
"""Alias endpoint for updating rental status without changing core hardware lifecycle status."""
row = execute_query(
"""
UPDATE hardware_assets
SET rental_status = %s,
status_reason = COALESCE(%s, status_reason),
updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id, COALESCE(rental_status, 'ledig') AS status, status_reason, updated_at
""",
(payload.status, payload.status_reason, asset_id),
)
if not row:
raise HTTPException(status_code=404, detail="Asset not found")
return row[0]
@router.get("/subscriptions", response_model=List[Dict[str, Any]])
async def list_subscriptions_alias(status: str = Query("all")):
where = ""
params: List[Any] = []
if status != "all":
where = "WHERE s.status = %s"
params.append(status)
rows = execute_query(
f"""
SELECT
s.id,
s.subscription_number,
s.customer_id,
c.name AS customer_name,
s.sag_id,
s.product_name,
s.price,
s.billing_interval,
s.billing_direction,
s.start_date,
s.end_date,
s.status,
s.binding_months,
s.invoice_merge_key,
COALESCE(
(
SELECT json_agg(json_build_object(
'id', i.id,
'description', i.description,
'quantity', i.quantity,
'unit_price', i.unit_price,
'line_total', i.line_total,
'asset_id', i.asset_id,
'period_from', i.period_from,
'period_to', i.period_to,
'serial_number', i.serial_number
) ORDER BY i.line_no ASC, i.id ASC)
FROM sag_subscription_items i
WHERE i.subscription_id = s.id
),
'[]'::json
) AS line_items
FROM sag_subscriptions s
LEFT JOIN customers c ON c.id = s.customer_id
{where}
ORDER BY s.start_date DESC, s.id DESC
""",
tuple(params),
) or []
return rows
@router.post("/subscriptions", response_model=Dict[str, Any])
async def create_subscription_alias(payload: SubscriptionCreateInput):
"""Alias endpoint that maps to existing sag subscription engine."""
normalized_lines = _normalize_line_items(
payload.line_items,
default_price_type=payload.price_type,
default_custom_override=payload.custom_price_override,
)
for line in normalized_lines:
asset_id = line.get("asset_id")
if asset_id is None:
continue
_assert_asset_booking_available(
int(asset_id),
start_date=payload.start_date,
end_date=payload.end_date,
)
body: Dict[str, Any] = {
"customer_id": payload.customer_id,
"sag_id": payload.sag_id,
"product_name": payload.product_name,
"price": payload.price,
"billing_interval": payload.billing_interval,
"billing_day": payload.billing_day,
"start_date": payload.start_date.isoformat(),
"end_date": payload.end_date.isoformat() if payload.end_date else None,
"binding_months": payload.binding_months,
"billing_direction": payload.billing_direction,
"price_type": payload.price_type,
"custom_price_override": payload.custom_price_override,
"first_invoice_policy": payload.first_invoice_policy,
"invoice_merge_key": payload.invoice_merge_key,
"notes": payload.notes,
"line_items": normalized_lines,
}
return await create_sag_subscription(body)
@router.put("/subscriptions/{subscription_id}", response_model=Dict[str, Any])
async def update_subscription_alias(subscription_id: int, payload: SubscriptionUpdateInput):
body = payload.model_dump(exclude_none=True)
current = execute_query_single(
"SELECT id, start_date, end_date, price_type, custom_price_override FROM sag_subscriptions WHERE id = %s",
(subscription_id,),
)
if not current:
raise HTTPException(status_code=404, detail="Subscription not found")
effective_start = payload.start_date or current.get("start_date")
effective_end = payload.end_date if payload.end_date is not None else current.get("end_date")
effective_price_type = payload.price_type or current.get("price_type") or "manual"
effective_override = (
payload.custom_price_override
if payload.custom_price_override is not None
else bool(current.get("custom_price_override"))
)
if "line_items" in body:
normalized_lines = _normalize_line_items(
payload.line_items or [],
default_price_type=effective_price_type,
default_custom_override=effective_override,
)
for line in normalized_lines:
asset_id = line.get("asset_id")
if asset_id is None:
continue
_assert_asset_booking_available(
int(asset_id),
start_date=effective_start,
end_date=effective_end,
exclude_subscription_id=subscription_id,
)
body["line_items"] = normalized_lines
if isinstance(body.get("start_date"), date):
body["start_date"] = body["start_date"].isoformat()
if isinstance(body.get("end_date"), date):
body["end_date"] = body["end_date"].isoformat()
return await update_sag_subscription(subscription_id, body)
@router.post("/subscriptions/{subscription_id}/asset-bindings", response_model=Dict[str, Any])
async def create_subscription_asset_binding_alias(subscription_id: int, payload: AssetBindingCreateInput):
_assert_asset_booking_available(
asset_id=payload.asset_id,
start_date=payload.start_date,
end_date=payload.end_date,
exclude_subscription_id=subscription_id,
)
end_date = payload.end_date
if end_date is None and payload.binding_months > 0:
end_date = payload.start_date + relativedelta(months=payload.binding_months)
row = execute_query(
"""
INSERT INTO subscription_asset_bindings (
subscription_id,
asset_id,
shared_binding_key,
binding_months,
start_date,
end_date,
notice_period_days,
status,
sag_id,
created_by_user_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'active', %s, %s)
RETURNING *
""",
(
subscription_id,
payload.asset_id,
payload.shared_binding_key,
payload.binding_months,
payload.start_date,
end_date,
payload.notice_period_days,
payload.sag_id,
payload.created_by_user_id,
),
)
if not row:
raise HTTPException(status_code=500, detail="Failed to create binding")
return row[0]
@router.get("/invoices", response_model=List[Dict[str, Any]])
async def list_invoices_alias(
status: str = Query("all"),
customer_id: Optional[int] = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
):
where = ["1=1"]
params: List[Any] = []
if status != "all":
where.append("d.sync_status = %s")
params.append(status)
if customer_id is not None:
where.append("d.customer_id = %s")
params.append(customer_id)
params.append(limit)
rows = execute_query(
f"""
SELECT
d.id,
d.customer_id,
c.name AS customer_name,
d.title,
d.invoice_date,
d.due_date,
d.coverage_start,
d.coverage_end,
d.total_amount,
d.sync_status AS status,
d.economic_order_number,
d.economic_invoice_number,
d.last_sync_at,
d.created_at,
d.updated_at
FROM ordre_drafts d
LEFT JOIN customers c ON c.id = d.customer_id
WHERE {' AND '.join(where)}
ORDER BY d.created_at DESC, d.id DESC
LIMIT %s
""",
tuple(params),
) or []
return rows
@router.post("/invoices/generate", response_model=Dict[str, Any])
async def generate_invoices_alias(payload: InvoiceGenerateInput | None = None):
"""Generate invoices globally or scoped by customer, with preview support."""
req = payload or InvoiceGenerateInput()
as_of = req.as_of or date.today()
economic_safety = _economic_safety_state()
if req.push_to_economic:
raise HTTPException(
status_code=409,
detail={
"message": "Direct e-conomic sync is blocked in this alias endpoint to protect live accounting.",
"action": "Use draft generation here and run dedicated sync flow manually.",
"safety": economic_safety,
},
)
if req.preview:
preview = _build_due_subscription_preview(as_of=as_of, customer_id=req.customer_id)
return {"status": "preview", "economic_sync_attempted": False, "safety": economic_safety, **preview}
if req.customer_id is not None:
try:
result = _generate_due_invoices_for_customer(as_of=as_of, customer_id=req.customer_id)
result["economic_sync_attempted"] = False
result["safety"] = economic_safety
return result
except Exception as exc:
logger.error("Failed customer-scoped invoice generation: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail=f"Invoice generation failed: {exc}")
try:
await process_subscriptions()
return {
"status": "ok",
"message": "Invoice generation job completed (drafts only, no e-conomic sync)",
"economic_sync_attempted": False,
"safety": economic_safety,
}
except Exception as exc:
logger.error("Failed running invoice generation job: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail=f"Invoice generation failed: {exc}")
@router.get("/invoices/sync-safety", response_model=Dict[str, Any])
async def get_invoice_sync_safety_status(
customer_id: Optional[int] = Query(default=None),
):
"""Read-only safety overview for invoice sync readiness. Does not call e-conomic."""
safety = _economic_safety_state()
where = ["d.deleted_at IS NULL"]
params: List[Any] = []
if customer_id is not None:
where.append("d.customer_id = %s")
params.append(customer_id)
stats_rows = execute_query(
f"""
SELECT
COALESCE(d.sync_status, 'unknown') AS sync_status,
COUNT(*)::int AS count
FROM ordre_drafts d
WHERE {' AND '.join(where)}
GROUP BY COALESCE(d.sync_status, 'unknown')
ORDER BY 1
""",
tuple(params),
) or []
sample_rows = execute_query(
f"""
SELECT
d.id,
d.customer_id,
c.name AS customer_name,
d.title,
d.sync_status,
d.economic_order_number,
d.economic_invoice_number,
d.last_sync_at,
d.created_at
FROM ordre_drafts d
LEFT JOIN customers c ON c.id = d.customer_id
WHERE {' AND '.join(where)}
AND COALESCE(d.sync_status, 'pending') IN ('pending', 'failed', 'exported')
ORDER BY d.created_at DESC, d.id DESC
LIMIT 20
""",
tuple(params),
) or []
stats: Dict[str, int] = {row["sync_status"]: int(row["count"]) for row in stats_rows}
draft_sync_allowed = not (safety["ordre_economic_read_only"] or safety["ordre_economic_dry_run"])
return {
"status": "ok",
"mode": "live_write_enabled" if draft_sync_allowed else "safe_no_write",
"customer_id": customer_id,
"safety": safety,
"sync_write_allowed": draft_sync_allowed,
"sync_write_block_reason": None if draft_sync_allowed else "ORDRE_ECONOMIC_READ_ONLY or ORDRE_ECONOMIC_DRY_RUN is enabled",
"stats": stats,
"candidate_count": len(sample_rows),
"candidates": sample_rows,
}
@router.get("/invoices/{draft_id}/sync-preview", response_model=Dict[str, Any])
async def preview_invoice_sync(draft_id: int):
"""Preview sync intent for one draft without sending data to e-conomic."""
safety = _economic_safety_state()
draft = execute_query_single(
"""
SELECT
d.id,
d.customer_id,
c.name AS customer_name,
d.title,
d.sync_status,
d.economic_order_number,
d.economic_invoice_number,
d.last_sync_at,
d.created_at,
d.updated_at
FROM ordre_drafts d
LEFT JOIN customers c ON c.id = d.customer_id
WHERE d.id = %s
AND d.deleted_at IS NULL
""",
(draft_id,),
)
if not draft:
raise HTTPException(status_code=404, detail="Invoice draft not found")
next_action = "none"
if draft.get("sync_status") in {"pending", "failed"}:
next_action = "export_order"
elif draft.get("sync_status") == "exported":
next_action = "book_invoice"
draft_sync_allowed = not (safety["ordre_economic_read_only"] or safety["ordre_economic_dry_run"])
return {
"status": "preview",
"draft": draft,
"next_action": next_action,
"will_sync": False,
"safety": safety,
"sync_write_allowed": draft_sync_allowed,
"sync_write_block_reason": None if draft_sync_allowed else "ORDRE_ECONOMIC_READ_ONLY or ORDRE_ECONOMIC_DRY_RUN is enabled",
"message": "Preview only. No e-conomic API call has been made.",
}

View File

@ -1842,7 +1842,16 @@
}
.case-tabs-topbar.topbar-secondary {
grid-template-columns: repeat(8, minmax(150px, 1fr));
/* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */
grid-template-columns:
minmax(110px, 0.75fr) /* Type */
minmax(110px, 0.8fr) /* Prioritet */
minmax(105px, 0.75fr) /* Oprettet */
minmax(195px, 1.3fr) /* Arbejdsstart (2 knapper) */
minmax(195px, 1.3fr) /* Start senest (2 knapper) */
minmax(150px, 1.1fr) /* Deadline (1 knap) */
minmax(120px, 0.85fr) /* AnyDesk */
minmax(140px, 1fr) /* Dokumenter */;
}
.case-tabs-topbar-item {
@ -1966,12 +1975,6 @@
text-align: left;
}
.topbar-deferred-shortcuts {
display: flex;
gap: 0.35rem;
margin-top: 0.35rem;
flex-wrap: wrap;
}
.topbar-deferred-current {
margin-top: 0.4rem;
@ -1994,24 +1997,7 @@
background: rgba(15, 76, 117, 0.09);
}
.topbar-mini-trigger {
border: 1px solid rgba(0,0,0,0.14);
background: rgba(255,255,255,0.75);
color: var(--text-primary);
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
padding: 0.34rem 0.58rem;
min-height: 30px;
letter-spacing: 0.01em;
}
.topbar-mini-trigger:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(255,255,255,0.95);
}
.topbar-secondary-action:hover {
border-color: var(--accent);
@ -2019,31 +2005,6 @@
background: rgba(255,255,255,0.95);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-add {
background: rgba(32, 120, 72, 0.24);
border-color: rgba(92, 194, 132, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-type {
background: rgba(80, 120, 200, 0.18);
border-color: rgba(140, 180, 255, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-created {
background: rgba(40, 120, 80, 0.22);
border-color: rgba(90, 200, 140, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-priority {
background: rgba(110, 80, 170, 0.24);
border-color: rgba(180, 145, 255, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-start {
background: rgba(150, 120, 40, 0.24);
border-color: rgba(230, 190, 90, 0.35);
}
[data-bs-theme="dark"] .topbar-deferred-current {
color: rgba(236, 242, 255, 0.82);
background: rgba(255, 255, 255, 0.06);
@ -2056,16 +2017,6 @@
background: rgba(19, 100, 154, 0.24);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-start-before {
background: rgba(150, 90, 30, 0.24);
border-color: rgba(230, 160, 90, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-deadline {
background: rgba(150, 40, 40, 0.24);
border-color: rgba(240, 120, 120, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary-action {
background: rgba(20, 27, 38, 0.72);
border-color: rgba(170, 190, 216, 0.35);
@ -2081,16 +2032,7 @@
background: rgba(20, 27, 38, 0.78);
}
[data-bs-theme="dark"] .topbar-mini-trigger {
background: rgba(20, 27, 38, 0.78);
border-color: rgba(170, 190, 216, 0.4);
color: #dce8f4;
}
[data-bs-theme="dark"] .topbar-mini-trigger:hover {
border-color: #9fc4e8;
color: #9fc4e8;
}
.case-add-side-backdrop {
position: fixed;
@ -2399,43 +2341,56 @@
</div>
<div class="case-tabs-topbar-item field-start">
<div class="case-tabs-topbar-label"><i class="bi bi-play-circle"></i>Arbejdsstart</div>
<div class="topbar-secondary-inline">
<div class="topbar-secondary-inline" style="gap: 0.25rem;">
<input
id="topbarStartDateInput"
type="date"
class="case-inline-select"
value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}"
onchange="saveCaseStartDateFromTopbar()"
style="padding-left: 0.35rem; padding-right: 0.1rem;"
>
<button type="button" class="topbar-secondary-action is-icon" onclick="clearCaseStartDateFromTopbar()" title="Fjern startdato">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="topbar-deferred-shortcuts">
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(0)">I dag</button>
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(1)">+1 dag</button>
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(7)">+1 uge</button>
<button type="button" class="topbar-mini-trigger" onclick="openStartDateModal()">Flere valg</button>
</div>
<div class="topbar-deferred-shortcuts">
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModal()">Trigger</button>
<div class="dropdown">
<button class="topbar-secondary-action is-icon" style="padding: 0.35rem 0.2rem; min-width: 32px; flex: 0 0 32px;" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Menu">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); clearCaseStartDateFromTopbar();"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Udskyd til...</h6></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(0);"><i class="bi bi-calendar-event me-2 text-muted"></i>I dag</a></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(1);"><i class="bi bi-calendar-plus me-2 text-muted"></i>I morgen (+1 dag)</a></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(7);"><i class="bi bi-calendar-week me-2 text-muted"></i>Næste uge (+1 uge)</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openStartDateModal();"><i class="bi bi-gear me-2 text-muted"></i>Flere valg...</a></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openDeferredModal();"><i class="bi bi-lightning-charge me-2 text-muted"></i>Sæt afhængighed (Trigger)...</a></li>
</ul>
</div>
</div>
</div>
<div class="case-tabs-topbar-item field-start-before">
<div class="case-tabs-topbar-label"><i class="bi bi-hourglass-split"></i>Start senest</div>
<div class="topbar-secondary-inline">
<div class="topbar-secondary-inline" style="gap: 0.25rem;">
<input
id="topbarDeferredInput"
type="date"
class="case-inline-select"
value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}"
onchange="updateDeferredUntil(this.value || null)"
style="padding-left: 0.35rem; padding-right: 0.1rem;"
>
<button type="button" class="topbar-secondary-action is-icon" onclick="updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value=''" title="Fjern dato"><i class="bi bi-x-lg"></i></button>
</div>
<div class="topbar-deferred-shortcuts">
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('lukket')">Lukket</button>
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('løst')">Løst</button>
<div class="dropdown">
<button class="topbar-secondary-action is-icon" style="padding: 0.35rem 0.2rem; min-width: 32px; flex: 0 0 32px;" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Menu">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value='';"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Venter på sag...</h6></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openDeferredModalWithPresetStatus('lukket');"><i class="bi bi-check2-circle me-2 text-success"></i>Trigger: Sag Lukket</a></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openDeferredModalWithPresetStatus('løst');"><i class="bi bi-check-lg me-2 text-primary"></i>Trigger: Sag Løst</a></li>
</ul>
</div>
</div>
{% set deferred_case_ns = namespace(title='') %}
{% if case.deferred_until_case_id %}
@ -2459,21 +2414,22 @@
</div>
<div class="case-tabs-topbar-item field-deadline">
<div class="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato</div>
<div class="topbar-secondary-inline">
<div class="topbar-secondary-inline" style="gap: 0.25rem;">
<input
id="topbarDeadlineInput"
type="date"
class="case-inline-select"
value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}"
onchange="updateDeadline(this.value || null)"
style="padding-left: 0.35rem; padding-right: 0.1rem;"
>
<button type="button" class="topbar-secondary-action is-icon" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x-lg"></i></button>
<button type="button" class="topbar-secondary-action is-icon" style="padding: 0.35rem 0.2rem; min-width: 32px; flex: 0 0 32px;" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x-lg"></i></button>
</div>
</div>
<div class="case-tabs-topbar-item field-anydesk">
<div class="case-tabs-topbar-label"><i class="bi bi-display"></i>AnyDesk</div>
<button type="button" class="topbar-secondary-action is-wide" onclick="openCaseAnyDeskModal()" title="Registrer AnyDesk session for denne sag">
<i class="bi bi-plus-circle"></i> Registrer session
<button id="caseAnyDeskOpenBtn" type="button" class="topbar-secondary-action is-wide" onclick="openCaseAnyDeskModal()" title="Start AnyDesk quick connect for denne sag">
<i class="bi bi-plug"></i> Quick connect
</button>
</div>
<div class="case-tabs-topbar-item field-documents">
@ -2779,6 +2735,7 @@
<div id="sag-titel-view" class="d-flex align-items-center gap-2">
<h2 id="sag-titel-text" class="mb-2 fw-bolder" style="color: var(--accent); font-size: 1.8rem; letter-spacing: -0.5px;">{{ case.titel }}</h2>
<button class="btn btn-sm btn-link text-muted p-0 mb-1" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>
<button class="btn btn-sm btn-link text-info p-0 mb-1 ms-2" onclick="openManualHelp('Sag')" title="Hjælp til sagsbehandling"><i class="bi bi-question-circle fs-5"></i></button>
</div>
<!-- Title edit -->
<div id="sag-titel-editor" class="d-none">
@ -3487,36 +3444,41 @@
</div>
</div>
<!-- AnyDesk manual registration modal -->
<!-- AnyDesk quick-connect modal -->
<div class="modal fade" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-display me-2"></i>Registrer AnyDesk session</h5>
<h5 class="modal-title"><i class="bi bi-display me-2"></i>AnyDesk quick connect</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">AnyDesk ID</label>
<input type="text" class="form-control" id="caseAnydeskIdInput" placeholder="fx 123 456 789" />
<div class="input-group">
<input type="text" class="form-control" id="caseAnydeskIdInput" placeholder="fx 123 456 789" oninput="onCaseAnyDeskIdInputChange()" />
<a href="#" class="btn btn-outline-primary" id="caseAnydeskOpenLinkBtn" target="_self" style="display:none;">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn AnyDesk
</a>
</div>
<div class="form-text">ID gemmes automatisk på sagen når du klikker forbind.</div>
</div>
<div class="mb-3">
<label class="form-label">Hvilken enhed hjælper du med?</label>
<input type="text" class="form-control" id="caseAnydeskDeviceNameInput" placeholder="fx Reception-PC, Lager printer, Router i teknikrum" />
<label class="form-label">Gemte IDs på sagen</label>
<div id="caseAnyDeskSavedIds" class="d-flex flex-wrap gap-2">
<span class="text-muted small">Indlæser...</span>
</div>
</div>
<div class="mb-3">
<label class="form-label">Enhedstype</label>
<select class="form-select" id="caseAnydeskDeviceTypeInput">
<option value="placebo" selected>Placebo / ukendt (kan linkes senere)</option>
<option value="desktop">Desktop / PC</option>
<option value="laptop">Laptop</option>
<option value="server">Server</option>
<option value="network">Netværksudstyr</option>
<option value="printer">Printer</option>
<option value="phone">Telefon / mobil</option>
<option value="other">Andet</option>
<label class="form-label">Relatér til hardware (valgfri)</label>
<select class="form-select" id="caseAnydeskHardwareSelect">
<option value="">Ingen hardware valgt</option>
</select>
<div class="form-text">Sagens hardware vises først. Derefter hardware hos kunden.</div>
</div>
<div class="mb-3">
<label class="form-label">Kontakt (valgfri)</label>
<select class="form-select" id="caseAnydeskContactSelect">
@ -3531,12 +3493,12 @@
<textarea class="form-control" id="caseAnydeskNoteInput" rows="3" placeholder="Kort notat om supporten"></textarea>
</div>
<div class="small text-muted mt-2">
Sessionen gemmes direkte på sagen. Hvis du vælger type "Placebo", kan den senere linkes til rigtig hardware.
Når du klikker forbind oprettes sessionen på sagen med det samme, så varighed/status kan beriges via lokal AnyDesk sync.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()">Gem registrering</button>
<button id="caseAnyDeskConnectBtn" type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()"><i class="bi bi-plug me-1"></i>Forbind og gem</button>
</div>
</div>
</div>
@ -9463,6 +9425,7 @@
<script>
let currentSearchType = null;
let searchDebounceIds = null;
let caseAnyDeskConnectInFlight = false;
const caseIds = {{ case.id }};
const currentCaseTitle = {{ (case.titel or '') | tojson }};
let caseAddPanelInitialized = false;
@ -9482,60 +9445,192 @@
{ action: 'email', label: 'Send email', icon: 'bi-envelope', moduleKey: 'emails', relFn: 'openRelEmailModal' }
];
function openCaseAnyDeskModal() {
function normalizeAnyDeskIdClient(rawValue) {
const raw = String(rawValue || '').trim();
const digits = raw.replace(/\D/g, '');
return digits || raw;
}
function onCaseAnyDeskIdInputChange() {
const input = document.getElementById('caseAnydeskIdInput');
const linkBtn = document.getElementById('caseAnydeskOpenLinkBtn');
if (!input || !linkBtn) return;
const id = normalizeAnyDeskIdClient(input.value);
if (!id) {
linkBtn.style.display = 'none';
linkBtn.setAttribute('href', '#');
return;
}
input.value = id;
linkBtn.style.display = '';
linkBtn.setAttribute('href', `anydesk:${id}`);
}
function setCaseAnyDeskInputFromSaved(anydeskId) {
const input = document.getElementById('caseAnydeskIdInput');
if (!input) return;
input.value = normalizeAnyDeskIdClient(anydeskId);
onCaseAnyDeskIdInputChange();
}
function renderCaseAnyDeskSavedIds(entries) {
const container = document.getElementById('caseAnyDeskSavedIds');
if (!container) return;
if (!entries?.length) {
container.innerHTML = '<span class="text-muted small">Ingen gemte AnyDesk IDs på sagen endnu.</span>';
return;
}
container.innerHTML = entries.map((entry) => {
const primary = entry?.is_primary ? ' border-primary text-primary' : '';
const hardware = entry?.hardware_label ? ` <span class="text-muted">(${entry.hardware_label})</span>` : '';
const badge = entry?.is_primary ? ' <span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">Primær</span>' : '';
return `<button type="button" class="btn btn-sm btn-outline-secondary${primary}" onclick="setCaseAnyDeskInputFromSaved('${String(entry.anydesk_id || '').replace(/'/g, "\\'")}')">${entry.anydesk_id}${badge}${hardware}</button>`;
}).join('');
}
function renderCaseAnyDeskHardwareOptions(caseHardware, customerHardware) {
const select = document.getElementById('caseAnydeskHardwareSelect');
if (!select) return;
let html = '<option value="">Ingen hardware valgt</option>';
if (Array.isArray(caseHardware) && caseHardware.length) {
html += '<optgroup label="Hardware på sagen">';
html += caseHardware.map((row) => `<option value="${row.id}">${row.label || ('Hardware #' + row.id)}</option>`).join('');
html += '</optgroup>';
}
if (Array.isArray(customerHardware) && customerHardware.length) {
html += '<optgroup label="Andet hardware hos kunden">';
html += customerHardware.map((row) => `<option value="${row.id}">${row.label || ('Hardware #' + row.id)}</option>`).join('');
html += '</optgroup>';
}
select.innerHTML = html;
}
async function loadCaseAnyDeskContext() {
const [savedIdsRes, hardwareRes] = await Promise.all([
fetch(`/api/v1/anydesk/cases/${caseIds}/ids`, { credentials: 'include' }),
fetch(`/api/v1/anydesk/cases/${caseIds}/hardware-options`, { credentials: 'include' })
]);
if (!savedIdsRes.ok) {
throw new Error('Kunne ikke hente gemte AnyDesk IDs');
}
if (!hardwareRes.ok) {
throw new Error('Kunne ikke hente hardwarevalg');
}
const savedIdsPayload = await savedIdsRes.json();
const hardwarePayload = await hardwareRes.json();
renderCaseAnyDeskSavedIds(savedIdsPayload?.ids || []);
renderCaseAnyDeskHardwareOptions(hardwarePayload?.case_hardware || [], hardwarePayload?.customer_hardware || []);
const primary = (savedIdsPayload?.ids || []).find((item) => item?.is_primary);
if (primary?.anydesk_id) {
setCaseAnyDeskInputFromSaved(primary.anydesk_id);
}
}
async function openCaseAnyDeskModal() {
if (caseAnyDeskConnectInFlight) {
return;
}
if (!caseAnyDeskModal) {
const modalEl = document.getElementById('caseAnyDeskModal');
if (!modalEl) return;
caseAnyDeskModal = new bootstrap.Modal(modalEl);
}
const anydeskInput = document.getElementById('caseAnydeskIdInput');
const deviceInput = document.getElementById('caseAnydeskDeviceNameInput');
const typeInput = document.getElementById('caseAnydeskDeviceTypeInput');
const noteInput = document.getElementById('caseAnydeskNoteInput');
if (anydeskInput) anydeskInput.value = '';
if (deviceInput) deviceInput.value = '';
if (typeInput) typeInput.value = 'placebo';
if (noteInput) noteInput.value = '';
const saved = document.getElementById('caseAnyDeskSavedIds');
if (saved) {
saved.innerHTML = '<span class="text-muted small">Indlæser...</span>';
}
const hardwareSelect = document.getElementById('caseAnydeskHardwareSelect');
if (hardwareSelect) {
hardwareSelect.innerHTML = '<option value="">Indlæser hardware...</option>';
}
const input = document.getElementById('caseAnydeskIdInput');
if (input && !input.value) {
input.value = '';
}
onCaseAnyDeskIdInputChange();
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
if (connectBtn) {
connectBtn.disabled = false;
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
}
try {
await loadCaseAnyDeskContext();
} catch (error) {
const message = error?.message || 'Kunne ikke hente AnyDesk data';
if (saved) {
saved.innerHTML = `<span class="text-danger small">${message}</span>`;
}
}
caseAnyDeskModal.show();
}
async function registerCaseAnyDeskSession() {
const anydeskId = (document.getElementById('caseAnydeskIdInput')?.value || '').trim();
const assistedDevice = (document.getElementById('caseAnydeskDeviceNameInput')?.value || '').trim();
const deviceType = (document.getElementById('caseAnydeskDeviceTypeInput')?.value || 'placebo').trim();
if (caseAnyDeskConnectInFlight) {
return;
}
const anydeskId = normalizeAnyDeskIdClient(document.getElementById('caseAnydeskIdInput')?.value || '');
const contactIdRaw = document.getElementById('caseAnydeskContactSelect')?.value || '';
const hardwareIdRaw = document.getElementById('caseAnydeskHardwareSelect')?.value || '';
const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim();
if (!anydeskId) {
alert('Udfyld AnyDesk ID');
return;
}
if (!assistedDevice) {
alert('Udfyld hvilken enhed du hjælper med');
return;
}
const customerId = {{ customer.id if customer else 'null' }};
if (!customerId) {
alert('Sagen har ingen kunde - kan ikke registrere AnyDesk session');
alert('Sagen har ingen kunde - kan ikke starte AnyDesk session');
return;
}
const createdByUserId = await ensureCaseCurrentUserId();
const payload = {
sag_id: caseIds,
customer_id: customerId,
contact_id: contactIdRaw ? Number(contactIdRaw) : null,
anydesk_id: anydeskId,
assisted_device: assistedDevice,
device_type: deviceType,
notes,
contact_id: contactIdRaw ? Number(contactIdRaw) : null,
hardware_asset_id: hardwareIdRaw ? Number(hardwareIdRaw) : null,
note: notes,
make_primary: true,
created_by_user_id: createdByUserId || null
};
caseAnyDeskConnectInFlight = true;
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
const openBtn = document.getElementById('caseAnyDeskOpenBtn');
if (connectBtn) {
connectBtn.disabled = true;
connectBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Registrerer...';
}
if (openBtn) {
openBtn.disabled = true;
}
try {
const res = await fetch('/api/v1/anydesk/register-manual-session', {
const res = await fetch(`/api/v1/anydesk/cases/${caseIds}/connect`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include',
@ -9544,17 +9639,31 @@
if (!res.ok) {
const txt = await res.text();
throw new Error(txt || 'Kunne ikke registrere session');
throw new Error(txt || 'Kunne ikke starte AnyDesk quick connect');
}
const result = await res.json();
if (caseAnyDeskModal) caseAnyDeskModal.hide();
alert(`AnyDesk session registreret (ID: ${result?.session?.id || '-'})`);
if (result?.deep_link) {
window.location.href = result.deep_link;
}
alert(`AnyDesk forbindelse startet (session: ${result?.session?.id || '-'})`);
if (typeof loadComments === 'function') {
loadComments();
}
} catch (e) {
alert('Fejl ved registrering: ' + (e.message || 'Ukendt fejl'));
alert('Fejl ved AnyDesk quick connect: ' + (e.message || 'Ukendt fejl'));
} finally {
caseAnyDeskConnectInFlight = false;
if (connectBtn) {
connectBtn.disabled = false;
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
}
if (openBtn) {
openBtn.disabled = false;
}
}
}
@ -9578,6 +9687,10 @@
window._showRelModal = renderCaseAddWorkspaceModal;
}
// Remove legacy modal instance to avoid duplicate relQaModalEl IDs
// when side-panel rendering is active.
document.querySelectorAll('body > #relQaModalEl').forEach((el) => el.remove());
renderCaseAddActionList(caseAddActiveAction);
caseAddPanelInitialized = true;
}
@ -9594,15 +9707,28 @@
if (typeof caseAddOriginalShowRelModal === 'function') {
window._showRelModal = caseAddOriginalShowRelModal;
}
const workspace = document.getElementById('caseAddSideWorkspace');
if (workspace) {
workspace.innerHTML = '<div class="text-muted small">Vaelg en handling i venstre side.</div>';
}
}
function renderCaseAddWorkspaceModal(title, bodyHtml, footerBtns) {
const workspace = document.getElementById('caseAddSideWorkspace');
if (!workspace) return;
// Ensure only one relQaModalEl exists. Duplicate IDs can break button
// handlers and value lookups across add forms.
document.querySelectorAll('#relQaModalEl').forEach((el) => {
if (!workspace.contains(el)) {
el.remove();
}
});
workspace.innerHTML = `
<div id="relQaModalEl" class="d-flex flex-column gap-2">
<div class="section-title">${title}</div>
<div id="relQaModalTitle" class="section-title">${title}</div>
<div id="relQaModalBody">${bodyHtml}</div>
<div id="relQaModalFooter" class="d-flex justify-content-end gap-2 border-top pt-2">
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="closeCaseModuleAddPanel()">Luk</button>
@ -9681,14 +9807,15 @@
return;
}
const existingRelQaEl = document.getElementById('relQaModalEl');
if (existingRelQaEl && !workspace.contains(existingRelQaEl)) {
const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl);
if (existingModalInstance) {
existingModalInstance.hide();
document.querySelectorAll('#relQaModalEl').forEach((existingRelQaEl) => {
if (!workspace.contains(existingRelQaEl)) {
const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl);
if (existingModalInstance) {
existingModalInstance.hide();
}
existingRelQaEl.remove();
}
existingRelQaEl.remove();
}
});
try {
await Promise.resolve(relFn(caseIds, currentCaseTitle));
@ -13020,9 +13147,9 @@
function getRelQaPrimaryButton() {
const sidePanel = document.getElementById('caseAddSidePanel');
if (sidePanel && sidePanel.classList.contains('open')) {
return sidePanel.querySelector('#relQaModalFooter .btn-primary');
return document.querySelector('#caseAddSideWorkspace #relQaModalFooter .btn-primary');
}
return document.querySelector('#relQaModalEl .btn-primary');
return document.querySelector('body > #relQaModalEl .btn-primary');
}
function closeRelQaSurfaceAfterSave() {
@ -13145,21 +13272,47 @@
if (!fileInput.files.length) { if (typeof showNotification === 'function') showNotification('Vælg mindst én fil', 'warning'); return; }
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploader…'; }
let success = 0; let failed = 0;
for (const file of fileInput.files) {
try {
const fd = new FormData();
fd.append('file', file);
const desc = document.getElementById('rqf_desc').value;
if (desc) fd.append('description', desc);
const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd });
if (r.ok) success++; else failed++;
} catch { failed++; }
}
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') {
if (failed === 0) showNotification(`${success} fil(er) uploadet ✓`, 'success');
else showNotification(`${success} ok, ${failed} fejlede`, 'warning');
try {
const fd = new FormData();
for (const file of fileInput.files) {
fd.append('files', file);
}
const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd });
if (!r.ok) {
const d = await r.json().catch(() => ({}));
if (typeof showNotification === 'function') showNotification(d.detail || 'Upload fejlede', 'error');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
}
return;
}
const saved = await r.json().catch(() => []);
const uploadedCount = Array.isArray(saved) ? saved.length : 0;
if (uploadedCount === 0) {
if (typeof showNotification === 'function') showNotification('Filer blev ikke gemt. Prøv igen.', 'warning');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
}
return;
}
if (typeof loadSagFiles === 'function') {
await loadSagFiles();
}
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') {
showNotification(`${uploadedCount} fil(er) uploadet ✓`, 'success');
}
} catch {
if (typeof showNotification === 'function') showNotification('Upload fejlede', 'error');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
}
}
};
@ -13669,7 +13822,7 @@
window._submitRelNote = async function(caseId) {
const text = document.getElementById('rqn_text').value.trim();
if (!text) return;
const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch(`/api/v1/sag/${caseId}/kommentarer`, {
@ -13782,11 +13935,52 @@
};
window._submitRelReminder = async function(caseId) {
const payload = { sag_id: caseId, remind_at: document.getElementById('rqr_at').value, message: document.getElementById('rqr_msg').value };
const whenInput = document.getElementById('rqr_at')?.value || '';
const message = (document.getElementById('rqr_msg')?.value || '').trim();
if (!whenInput) {
if (typeof showNotification === 'function') showNotification('Vælg tidspunkt', 'warning');
return;
}
let reminderUserId = null;
try {
if (typeof ensureReminderUserId === 'function') {
reminderUserId = await ensureReminderUserId();
} else if (typeof getReminderUserId === 'function') {
reminderUserId = getReminderUserId();
}
} catch {}
if (!reminderUserId) {
if (typeof showNotification === 'function') showNotification('Mangler bruger-id til reminder', 'error');
return;
}
const scheduledAtIso = new Date(whenInput).toISOString();
const baseTitle = (message || 'Husk opfoelgning').trim();
const safeTitle = (baseTitle.length >= 3 ? baseTitle : 'Reminder').slice(0, 255);
const payload = {
title: safeTitle,
message: message || null,
priority: 'normal',
event_type: 'reminder',
trigger_type: 'time_based',
trigger_config: {},
recipient_user_ids: [Number(reminderUserId)],
recipient_emails: [],
notify_mattermost: false,
notify_email: false,
notify_frontend: true,
override_user_preferences: false,
recurrence_type: 'once',
recurrence_day_of_week: null,
recurrence_day_of_month: null,
scheduled_at: scheduledAtIso,
};
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch('/api/v1/reminders', {
const r = await fetch(`/api/v1/sag/${caseId}/reminders?user_id=${encodeURIComponent(reminderUserId)}`, {
method: 'POST', credentials: 'include',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
@ -13800,8 +13994,12 @@
// ── shared modal helper ───────────────────────────────────────────
window._showRelModal = function(title, bodyHtml, footerBtns) {
let el = document.getElementById('relQaModalEl');
let el = document.querySelector('body > #relQaModalEl.modal');
if (!el) {
const nonModal = document.getElementById('relQaModalEl');
if (nonModal && !nonModal.classList.contains('modal')) {
nonModal.remove();
}
el = document.createElement('div');
el.id = 'relQaModalEl';
el.className = 'modal fade';

View File

@ -5,8 +5,10 @@ REST API endpoints for managing remote support sessions
import logging
import json
import re
from uuid import uuid4
from typing import Optional
from datetime import timedelta
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
@ -26,6 +28,23 @@ router = APIRouter()
anydesk_service = AnyDeskService()
def _normalize_anydesk_id(raw_value: Optional[str]) -> str:
"""Normalize AnyDesk ID by stripping non-digits when possible."""
raw_text = str(raw_value or "").strip()
digits_only = re.sub(r"\D", "", raw_text)
return digits_only or raw_text
def _ensure_case_exists(sag_id: int) -> dict:
case_row = execute_query(
"SELECT id, customer_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")
return case_row[0]
# =====================================================
# Session Management Endpoints
# =====================================================
@ -342,6 +361,424 @@ async def register_manual_session(data: dict):
raise HTTPException(status_code=500, detail=str(e))
@router.get("/anydesk/cases/{sag_id}/ids", tags=["Remote Support"])
async def list_case_anydesk_ids(sag_id: int):
"""List saved AnyDesk IDs for a case (multi-ID support)."""
try:
_ensure_case_exists(sag_id)
rows = execute_query(
"""
SELECT
sai.id,
sai.sag_id,
sai.anydesk_id,
sai.hardware_asset_id,
sai.is_primary,
sai.note,
sai.created_by_user_id,
sai.created_at,
sai.updated_at,
h.brand,
h.model,
h.serial_number,
h.anydesk_id AS hardware_anydesk_id
FROM sag_anydesk_ids sai
LEFT JOIN hardware_assets h ON h.id = sai.hardware_asset_id
WHERE sai.sag_id = %s
AND sai.deleted_at IS NULL
ORDER BY sai.is_primary DESC, sai.updated_at DESC, sai.id DESC
""",
(sag_id,),
) or []
for row in rows:
brand = (row.get("brand") or "").strip()
model = (row.get("model") or "").strip()
serial = (row.get("serial_number") or "").strip()
fragments = [f for f in [brand, model] if f]
if serial:
fragments.append(f"SN: {serial}")
row["hardware_label"] = " - ".join(fragments) if fragments else None
return {"ids": rows}
except HTTPException:
raise
except Exception as e:
logger.error("Error listing case AnyDesk IDs: %s", e)
raise HTTPException(status_code=500, detail="Could not load case AnyDesk IDs")
@router.post("/anydesk/cases/{sag_id}/ids", tags=["Remote Support"])
async def upsert_case_anydesk_id(sag_id: int, data: dict):
"""Create or update a saved AnyDesk ID on a case."""
try:
_ensure_case_exists(sag_id)
anydesk_id = _normalize_anydesk_id(data.get("anydesk_id"))
if not anydesk_id:
raise HTTPException(status_code=400, detail="anydesk_id is required")
hardware_asset_id = data.get("hardware_asset_id")
created_by_user_id = data.get("created_by_user_id")
note = (data.get("note") or "").strip() or None
is_primary = bool(data.get("is_primary"))
if hardware_asset_id is not None:
asset = execute_query(
"SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL",
(hardware_asset_id,),
)
if not asset:
raise HTTPException(status_code=404, detail="Hardware asset not found")
row = execute_query(
"""
INSERT INTO sag_anydesk_ids (
sag_id,
anydesk_id,
hardware_asset_id,
is_primary,
note,
created_by_user_id,
deleted_at
)
VALUES (%s, %s, %s, %s, %s, %s, NULL)
ON CONFLICT (sag_id, anydesk_id)
DO UPDATE SET
hardware_asset_id = EXCLUDED.hardware_asset_id,
is_primary = EXCLUDED.is_primary,
note = COALESCE(EXCLUDED.note, sag_anydesk_ids.note),
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, sag_anydesk_ids.created_by_user_id),
deleted_at = NULL,
updated_at = NOW()
RETURNING id, sag_id, anydesk_id, hardware_asset_id, is_primary, note, created_by_user_id, created_at, updated_at
""",
(sag_id, anydesk_id, hardware_asset_id, is_primary, note, created_by_user_id),
)
if not row:
raise HTTPException(status_code=500, detail="Could not save AnyDesk ID")
if is_primary:
execute_query(
"""
UPDATE sag_anydesk_ids
SET is_primary = FALSE, updated_at = NOW()
WHERE sag_id = %s AND id != %s AND deleted_at IS NULL
""",
(sag_id, row[0]["id"]),
)
return {"ok": True, "entry": row[0]}
except HTTPException:
raise
except Exception as e:
logger.error("Error upserting case AnyDesk ID: %s", e)
raise HTTPException(status_code=500, detail="Could not save case AnyDesk ID")
@router.delete("/anydesk/cases/{sag_id}/ids/{entry_id}", tags=["Remote Support"])
async def delete_case_anydesk_id(sag_id: int, entry_id: int):
"""Soft-delete a saved AnyDesk ID from a case."""
try:
_ensure_case_exists(sag_id)
result = execute_query(
"""
UPDATE sag_anydesk_ids
SET deleted_at = NOW(), updated_at = NOW(), is_primary = FALSE
WHERE id = %s AND sag_id = %s AND deleted_at IS NULL
RETURNING id
""",
(entry_id, sag_id),
)
if not result:
raise HTTPException(status_code=404, detail="AnyDesk ID entry not found")
return {"ok": True}
except HTTPException:
raise
except Exception as e:
logger.error("Error deleting case AnyDesk ID: %s", e)
raise HTTPException(status_code=500, detail="Could not delete case AnyDesk ID")
@router.get("/anydesk/cases/{sag_id}/hardware-options", tags=["Remote Support"])
async def get_case_anydesk_hardware_options(sag_id: int):
"""Get hardware options: case-linked assets first, then customer fallback assets."""
try:
case_row = _ensure_case_exists(sag_id)
customer_id = case_row.get("customer_id")
case_hardware = execute_query(
"""
SELECT
h.id,
h.brand,
h.model,
h.serial_number,
h.anydesk_id,
h.current_owner_customer_id
FROM sag_hardware sh
JOIN hardware_assets h ON h.id = sh.hardware_id
WHERE sh.sag_id = %s
AND sh.deleted_at IS NULL
AND h.deleted_at IS NULL
ORDER BY h.brand, h.model, h.id
""",
(sag_id,),
) or []
case_ids = {row["id"] for row in case_hardware}
customer_hardware = []
if customer_id:
customer_hardware = execute_query(
"""
SELECT
h.id,
h.brand,
h.model,
h.serial_number,
h.anydesk_id,
h.current_owner_customer_id
FROM hardware_assets h
WHERE h.deleted_at IS NULL
AND h.current_owner_customer_id = %s
ORDER BY h.brand, h.model, h.id
""",
(customer_id,),
) or []
customer_hardware = [row for row in customer_hardware if row["id"] not in case_ids]
def _with_label(row: dict) -> dict:
brand = (row.get("brand") or "").strip()
model = (row.get("model") or "").strip()
serial = (row.get("serial_number") or "").strip()
aid = (row.get("anydesk_id") or "").strip()
parts = [p for p in [brand, model] if p]
if serial:
parts.append(f"SN: {serial}")
if aid:
parts.append(f"AD: {aid}")
row["label"] = " - ".join(parts) if parts else f"Hardware #{row.get('id')}"
return row
return {
"case_hardware": [_with_label(row) for row in case_hardware],
"customer_hardware": [_with_label(row) for row in customer_hardware],
}
except HTTPException:
raise
except Exception as e:
logger.error("Error loading AnyDesk hardware options: %s", e)
raise HTTPException(status_code=500, detail="Could not load hardware options")
@router.post("/anydesk/cases/{sag_id}/connect", tags=["Remote Support"])
async def connect_case_anydesk(sag_id: int, data: dict):
"""
Quick connect flow for a case:
- Save AnyDesk ID on case
- Create local session row for enrichment
- Return AnyDesk deep link for app open
"""
try:
case_row = _ensure_case_exists(sag_id)
customer_id = case_row.get("customer_id")
if not customer_id:
raise HTTPException(status_code=400, detail="Case has no customer")
anydesk_id = _normalize_anydesk_id(data.get("anydesk_id"))
if not anydesk_id:
raise HTTPException(status_code=400, detail="anydesk_id is required")
created_by_user_id = data.get("created_by_user_id")
contact_id = data.get("contact_id")
hardware_asset_id = data.get("hardware_asset_id")
note = (data.get("note") or "").strip() or None
make_primary = bool(data.get("make_primary", True))
if contact_id is not None:
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
if hardware_asset_id is not None:
asset = execute_query(
"SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL",
(hardware_asset_id,),
)
if not asset:
raise HTTPException(status_code=404, detail="Hardware asset not found")
case_anydesk_row = execute_query(
"""
INSERT INTO sag_anydesk_ids (
sag_id,
anydesk_id,
hardware_asset_id,
is_primary,
note,
created_by_user_id,
deleted_at
)
VALUES (%s, %s, %s, %s, %s, %s, NULL)
ON CONFLICT (sag_id, anydesk_id)
DO UPDATE SET
hardware_asset_id = EXCLUDED.hardware_asset_id,
is_primary = EXCLUDED.is_primary,
note = COALESCE(EXCLUDED.note, sag_anydesk_ids.note),
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, sag_anydesk_ids.created_by_user_id),
deleted_at = NULL,
updated_at = NOW()
RETURNING id
""",
(sag_id, anydesk_id, hardware_asset_id, make_primary, note, created_by_user_id),
)
case_anydesk_id = case_anydesk_row[0]["id"] if case_anydesk_row else None
if make_primary and case_anydesk_id:
execute_query(
"""
UPDATE sag_anydesk_ids
SET is_primary = FALSE, updated_at = NOW()
WHERE sag_id = %s AND id != %s AND deleted_at IS NULL
""",
(sag_id, case_anydesk_id),
)
manual_external_id = f"case-{uuid4().hex[:12]}"
deep_link = f"anydesk:{anydesk_id}"
# Idempotency guard: avoid creating duplicate rows when users double-click connect.
recent_existing = execute_query(
"""
SELECT id, anydesk_session_id, customer_id, contact_id, sag_id, status, started_at, session_link
FROM anydesk_sessions
WHERE sag_id = %s
AND status IN ('active', 'pending')
AND (
COALESCE(device_info->>'to_id', '') = %s
OR COALESCE(device_info->>'customer_machine_id', '') = %s
)
AND started_at >= NOW() - INTERVAL '10 minutes'
ORDER BY started_at DESC
LIMIT 1
""",
(sag_id, anydesk_id, anydesk_id),
)
if recent_existing:
existing = recent_existing[0]
logger.info(" Reusing in-flight AnyDesk session for case %s (session %s)", sag_id, existing.get("id"))
return {
"ok": True,
"already_registering": True,
"deep_link": existing.get("session_link") or deep_link,
"anydesk_id": anydesk_id,
"session": {
"id": existing.get("id"),
"anydesk_session_id": existing.get("anydesk_session_id"),
"customer_id": existing.get("customer_id"),
"contact_id": existing.get("contact_id"),
"sag_id": existing.get("sag_id"),
"status": existing.get("status"),
"started_at": existing.get("started_at"),
},
"case_anydesk_id": case_anydesk_id,
}
device_info = {
"to_id": anydesk_id,
"customer_machine_id": anydesk_id,
"hardware_asset_id": hardware_asset_id,
"case_anydesk_id": case_anydesk_id,
"source": "case_quick_connect",
}
metadata = {
"note": note,
"source": "case_quick_connect",
"needs_local_sync_enrichment": True,
}
created = execute_query(
"""
INSERT INTO anydesk_sessions (
anydesk_session_id,
contact_id,
customer_id,
sag_id,
session_link,
device_info,
created_by_user_id,
started_at,
ended_at,
duration_minutes,
status,
metadata,
created_at,
updated_at
)
VALUES (
%s,
%s,
%s,
%s,
%s,
%s::jsonb,
%s,
NOW(),
NULL,
NULL,
'active',
%s::jsonb,
NOW(),
NOW()
)
RETURNING id, anydesk_session_id, customer_id, contact_id, sag_id, status, started_at
""",
(
manual_external_id,
contact_id,
customer_id,
sag_id,
deep_link,
json.dumps(device_info),
created_by_user_id,
json.dumps(metadata),
),
)
if not created:
raise HTTPException(status_code=500, detail="Failed to create AnyDesk session row")
execute_query(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
""",
(
sag_id,
"System",
f"🖥️ AnyDesk quick-connect startet (ID: {anydesk_id})",
True,
),
)
logger.info("✅ AnyDesk quick-connect prepared for case %s", sag_id)
return {
"ok": True,
"deep_link": deep_link,
"anydesk_id": anydesk_id,
"session": created[0],
"case_anydesk_id": case_anydesk_id,
}
except HTTPException:
raise
except Exception as e:
logger.error("Error in AnyDesk quick-connect: %s", e)
raise HTTPException(status_code=500, detail="Could not start AnyDesk quick-connect")
@router.get("/anydesk/sessions", response_model=AnyDeskSessionHistory, tags=["Remote Support"])
async def get_session_history(
contact_id: Optional[int] = None,
@ -488,7 +925,7 @@ async def get_anydesk_stats():
"sessions_this_month": 0,
"active_sessions": 0,
"average_duration_minutes": 0,
"total_support_hours": 0
"total_support_hours": 0.0
}
# Get today's sessions
@ -676,8 +1113,162 @@ async def sessions_overview(
count_q = f"SELECT COUNT(*) AS total FROM anydesk_sessions s {where}"
total = (execute_query(count_q) or [{"total": 0}])[0]["total"]
def _is_synthetic_session_id(session_id: Optional[str]) -> bool:
sid = str(session_id or "")
return sid.startswith("case-") or sid.startswith("manual-") or sid.startswith("local-")
def _dedupe_key(row: dict) -> str:
# Prefer stable external AnyDesk session IDs when they are not synthetic.
sid = str(row.get("anydesk_session_id") or "").strip()
if sid and not _is_synthetic_session_id(sid):
return f"sid:{sid}"
machine_id = str(row.get("customer_machine_id") or row.get("remote_id") or "").strip()
tech_id = str(row.get("technician_id") or "").strip()
# Use 15-minute buckets to collapse short-lived duplicates from hub/import/local sync.
started = row.get("started_at")
if started:
minutes = int(started.timestamp() // (15 * 60))
bucket = str(minutes)
else:
bucket = "unknown"
return f"heur:{machine_id}:{tech_id}:{bucket}"
def _row_score(row: dict) -> int:
score = 0
if row.get("sag_id"):
score += 200
if row.get("customer_id"):
score += 120
if row.get("contact_id"):
score += 100
if row.get("hardware_asset_id"):
score += 80
duration = row.get("duration_minutes")
if duration is not None:
try:
if float(duration) > 0:
score += 60
else:
score += 20
except Exception:
score += 10
if row.get("ended_at"):
score += 25
if not _is_synthetic_session_id(row.get("anydesk_session_id")):
score += 40
return score
def _backfill_enrichment(winner: dict, loser: dict) -> None:
"""Copy missing enriched fields from loser into winner."""
for field in (
"tech_name",
"technician_id",
"remote_alias",
"remote_id",
"customer_machine_id",
"customer_alias",
"contact_id",
"contact_name",
"contact_email",
"customer_id",
"customer_name",
"sag_id",
"sag_titel",
"sag_status",
"hardware_asset_id",
"hw_brand",
"hw_model",
"hw_anydesk_id",
"hw_customer_id",
"notes",
):
if not winner.get(field) and loser.get(field):
winner[field] = loser.get(field)
deduped_rows: dict[str, dict] = {}
for row in (rows or []):
key = _dedupe_key(row)
if key not in deduped_rows:
deduped_rows[key] = row
continue
current = deduped_rows[key]
current_score = _row_score(current)
incoming_score = _row_score(row)
if incoming_score > current_score:
winner = row
other = current
else:
winner = current
other = row
# Merge useful enrichment from the losing row into the winner.
if not winner.get("duration_minutes") and other.get("duration_minutes") is not None:
winner["duration_minutes"] = other.get("duration_minutes")
if not winner.get("ended_at") and other.get("ended_at"):
winner["ended_at"] = other.get("ended_at")
if (winner.get("status") in (None, "", "active", "pending")) and other.get("status"):
if other.get("status") in ("completed", "failed", "cancelled"):
winner["status"] = other.get("status")
if not winner.get("notes") and other.get("notes"):
winner["notes"] = other.get("notes")
_backfill_enrichment(winner, other)
deduped_rows[key] = winner
# Second pass: merge neighboring buckets for same machine ID when timestamps are close.
# This catches duplicates where one row lands in an adjacent 15-minute bucket.
second_pass_rows = list(deduped_rows.values())
second_pass_rows.sort(key=lambda item: item.get("started_at") or "", reverse=True)
clustered: list[dict] = []
for row in second_pass_rows:
row_machine = str(row.get("customer_machine_id") or row.get("remote_id") or "").strip()
row_started = row.get("started_at")
attached = False
if row_machine and row_started:
for target in clustered:
target_machine = str(target.get("customer_machine_id") or target.get("remote_id") or "").strip()
target_started = target.get("started_at")
if not target_machine or not target_started:
continue
if row_machine != target_machine:
continue
if abs(target_started - row_started) <= timedelta(minutes=20):
target_score = _row_score(target)
row_score = _row_score(row)
winner = row if row_score > target_score else target
loser = target if winner is row else row
if not winner.get("duration_minutes") and loser.get("duration_minutes") is not None:
winner["duration_minutes"] = loser.get("duration_minutes")
if not winner.get("ended_at") and loser.get("ended_at"):
winner["ended_at"] = loser.get("ended_at")
if (winner.get("status") in (None, "", "active", "pending")) and loser.get("status"):
if loser.get("status") in ("completed", "failed", "cancelled", "registered"):
winner["status"] = loser.get("status")
if not winner.get("notes") and loser.get("notes"):
winner["notes"] = loser.get("notes")
_backfill_enrichment(winner, loser)
if winner is row:
clustered.remove(target)
clustered.append(winner)
attached = True
break
if not attached:
clustered.append(row)
merged_rows = clustered
merged_rows.sort(key=lambda item: item.get("started_at") or "", reverse=True)
sessions = []
for r in (rows or []):
for r in merged_rows:
sessions.append({
"id": r["id"],
"anydesk_session_id": r["anydesk_session_id"],
@ -715,7 +1306,8 @@ async def sessions_overview(
} if r["sag_id"] else None,
})
return JSONResponse(content={"sessions": sessions, "total": total, "limit": limit, "offset": offset})
# Return deduplicated total for UI consistency on overview endpoint.
return JSONResponse(content={"sessions": sessions, "total": len(sessions), "limit": limit, "offset": offset, "raw_total": total})
except Exception as e:
logger.error(f"Error in sessions_overview: {e}")

54
app/routers/bottom_bar.py Normal file
View File

@ -0,0 +1,54 @@
from fastapi import APIRouter
from app.core.config import settings
router = APIRouter()
@router.get("/state")
async def get_bottom_bar_state():
# MVP Mock Data for the Bottom Bar
return {
"enabled": True,
"sections": {
"mail": {
"unread": 5,
"customer_reply_needed": 2
},
"cases": {
"open": 12,
"list": []
},
"urgent": {
"count": 1,
"list": [{"id": 999, "title": "Server nede hos Kunde A"}]
},
"timer": {
"active_count": 1,
"list": [{"desc": "Fejlfinding WiFi", "elapsed": 1200}]
},
"kuma": {
"down": 3,
"list": ["Switch-Odense", "Printer-Aarhus", "FW-Kbh"]
},
"eset": {
"incidents": 0,
"list": []
},
"messages": {
"count": 2,
"list": [{"from": "Chef", "text": "Husk at ringe til Jensen"}, {"from": "System", "text": "Ny opgave tildelt."}]
},
"tasks": {
"count": 4,
"list": [
{"title": "Opsætning af ny PC", "deadline": "I dag 14:00"},
{"title": "Gennemgå ESET log", "deadline": "I dag 16:00"}
]
},
"boss": {
"stats": {
"unassigned": 3,
"active_employees": 4
}
}
}
}

View File

@ -9,8 +9,9 @@ import hashlib
import hmac
import base64
import time
from uuid import uuid4
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
import httpx
import aiohttp
@ -577,3 +578,232 @@ class AnyDeskService:
"total_from_api": len(entries),
"errors": errors,
}
@staticmethod
def _extract_local_sessions(payload: Any) -> List[dict]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
for key in ("sessions", "list", "data", "items", "results"):
value = payload.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
@staticmethod
def _parse_timestamp(value: Any) -> Optional[datetime]:
if value is None:
return None
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
if value > 10_000_000_000:
value = value / 1000
try:
return datetime.utcfromtimestamp(value)
except Exception:
return None
text = str(value).strip()
if not text:
return None
try:
if text.endswith("Z"):
text = text[:-1] + "+00:00"
return datetime.fromisoformat(text).replace(tzinfo=None)
except Exception:
return None
async def fetch_sessions_from_local_endpoint(
self,
endpoint_url: str,
timeout_seconds: int = 20,
dry_run: bool = False,
) -> Dict[str, Any]:
"""
Poll local AnyDesk bridge endpoint and upsert/enrich local sessions.
Endpoint expected: http://localhost:8001/anydesk/sessions
"""
imported = 0
updated = 0
matched = 0
errors: List[str] = []
try:
logger.info("📡 Polling local AnyDesk sessions from %s", endpoint_url)
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await client.get(endpoint_url)
response.raise_for_status()
payload = response.json()
except Exception as exc:
logger.error("❌ Local AnyDesk polling failed: %s", exc)
return {"error": str(exc), "imported": 0, "updated": 0, "matched": 0, "total": 0, "errors": [str(exc)]}
entries = self._extract_local_sessions(payload)
for entry in entries:
try:
sid = str(entry.get("sid") or entry.get("session_id") or entry.get("anydesk_session_id") or entry.get("id") or "").strip()
to_raw = entry.get("to")
to_obj = to_raw if isinstance(to_raw, dict) else {}
to_id = str(
entry.get("to_id")
or entry.get("anydesk_id")
or entry.get("customer_machine_id")
or to_obj.get("cid")
or ""
).strip()
from_raw = entry.get("from")
from_obj = from_raw if isinstance(from_raw, dict) else {}
from_id = str(entry.get("from_id") or from_obj.get("cid") or "").strip()
started = self._parse_timestamp(
entry.get("started_at")
or entry.get("start")
or entry.get("start-time")
or entry.get("start_time")
)
ended = self._parse_timestamp(
entry.get("ended_at")
or entry.get("end")
or entry.get("end-time")
or entry.get("end_time")
)
duration_minutes = entry.get("duration_minutes")
if duration_minutes is None:
duration_seconds = entry.get("duration_seconds")
if duration_seconds is None:
duration_seconds = entry.get("duration")
if duration_seconds is not None:
try:
duration_minutes = round(float(duration_seconds) / 60, 1)
except Exception:
duration_minutes = None
status = str(entry.get("status") or "").strip().lower()
if not status:
if bool(entry.get("active")):
status = "active"
elif ended or duration_minutes is not None:
status = "completed"
else:
status = "pending"
if not sid and not to_id:
continue
existing = []
if sid:
existing = execute_query(
"SELECT id FROM anydesk_sessions WHERE anydesk_session_id = %s",
(sid,),
) or []
if not existing and to_id:
existing = execute_query(
"""
SELECT id
FROM anydesk_sessions
WHERE COALESCE(device_info->>'to_id', '') = %s
AND started_at >= NOW() - INTERVAL '24 hours'
ORDER BY started_at DESC
LIMIT 1
""",
(to_id,),
) or []
device_info = {
"remote_alias": entry.get("remote_alias") or from_obj.get("alias"),
"from_id": from_id,
"to_id": to_id,
"local_alias": entry.get("local_alias") or to_obj.get("alias"),
"imported_from_local_endpoint": True,
}
metadata = {
"source": "local_anydesk_sessions",
"raw": entry,
}
if existing:
matched += 1
if dry_run:
continue
execute_query(
"""
UPDATE anydesk_sessions
SET
status = COALESCE(%s, status),
ended_at = COALESCE(%s, ended_at),
duration_minutes = COALESCE(%s, duration_minutes),
device_info = COALESCE(device_info, '{}'::jsonb) || %s::jsonb,
metadata = COALESCE(metadata, '{}'::jsonb) || %s::jsonb,
updated_at = NOW()
WHERE id = %s
""",
(
status,
ended,
duration_minutes,
json.dumps(device_info),
json.dumps(metadata),
existing[0]["id"],
),
)
updated += 1
continue
if dry_run:
continue
insert_sid = sid or f"local-{uuid4().hex[:12]}"
execute_query(
"""
INSERT INTO anydesk_sessions (
anydesk_session_id,
session_link,
started_at,
ended_at,
duration_minutes,
status,
device_info,
metadata,
created_at,
updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, NOW(), NOW())
""",
(
insert_sid,
f"anydesk:{to_id}" if to_id else None,
started or datetime.utcnow(),
ended,
duration_minutes,
status,
json.dumps(device_info),
json.dumps(metadata),
),
)
imported += 1
except Exception as exc:
logger.warning("⚠️ Failed local AnyDesk entry sync: %s", exc)
errors.append(str(exc))
logger.info(
"✅ Local AnyDesk sync done: %d imported, %d updated (%d matched), %d errors",
imported,
updated,
matched,
len(errors),
)
return {
"imported": imported,
"updated": updated,
"matched": matched,
"total": len(entries),
"errors": errors,
}

View File

@ -0,0 +1,23 @@
import logging
from typing import List, Dict, Any
logger = logging.getLogger(__name__)
class M365CalendarService:
"""
Håndterer opslag mod brugernes M365 kalendere for at se om de er:
- Optaget i møde
- Ledige de næste X minutter til at tage en opgave
Dette bruges af TaskRouter til workload-balancing (Phase 4).
"""
def __init__(self):
self.is_connected = False
async def get_user_free_time(self, current_time: str, hours_ahead: int = 2) -> int:
"""
Returnerer antallet af minutter brugeren formodes ledig i den givne periode.
(Mock MVP implementering)
"""
logger.info("Slår opledig tid op i M365 (mock)")
return 90 # Mock: 1.5 timers ledig tid

View File

@ -0,0 +1,33 @@
import logging
from typing import Dict, Any, Optional
logger = logging.getLogger(__name__)
class TaskRouter:
"""
Kernekomponentet for "Giv mig næste opgave"-knappen (Phase 3).
Udregner den optimale næste opgave ud fra:
1. Hastesager (SLA warnings etc.) i sags-køen
2. Medarbejderens ledige tid i kalenderen (skal integreres mod M365)
3. Kompetenceniveau (tags sager vs bruger)
"""
def __init__(self):
pass
async def get_next_best_task(self, user_id: int) -> Optional[Dict[str, Any]]:
"""
Beregner den næste bedste opgave.
(Dummy-implementering til MVP Bottom Bar)
"""
logger.info(f"Omsætter opgaveruter for bruger {user_id}")
# P.t. er dette blot en mock respons til at matche bottom barens forventning
# TODO: Implementér ægte opslag mod databasens sag_queue
return {
"case_id": 8192,
"title": "Netværksudfald i afd. B",
"priority": "high",
"estimated_minutes": 30,
"assigned_reason": "SLA udløber om 40 minutter"
}

View File

@ -11,17 +11,24 @@
:root {
--bg-body: #f8f9fa;
--bg-card: #ffffff;
--bg-card-rgb: 255, 255, 255;
--text-primary: #2c3e50;
--text-primary-rgb: 44, 62, 80;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--border-radius: 12px;
--bottom-bar-height: 50px;
--bottom-bar-expanded-height: 50vh;
--bottom-bar-zindex: 1030;
}
[data-bs-theme="dark"] {
--bg-body: #212529;
--bg-card: #2c3034;
--bg-card-rgb: 44, 48, 52;
--text-primary: #f8f9fa;
--text-primary-rgb: 248, 249, 250;
--text-secondary: #adb5bd;
--accent: #3d8bfd; /* Lighter blue for dark mode */
--accent-light: #373b3e;
@ -35,6 +42,403 @@
transition: background-color 0.3s, color 0.3s;
}
body.bottom-bar-visible {
padding-bottom: calc(var(--bottom-bar-height) + 10px);
}
body.bottom-bar-visible.bottom-bar-expanded {
padding-bottom: calc(var(--bottom-bar-height) + 52vh);
}
.global-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--bottom-bar-zindex);
background: rgba(var(--bg-card-rgb), 0.85); /* Glassmorphism */
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(var(--text-primary-rgb), 0.1);
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.08);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
min-height: var(--bottom-bar-height);
padding: 0.5rem 1rem calc(0.5rem + env(safe-area-inset-bottom, 0px));
transform: translateY(calc(100% + 12px));
opacity: 0;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
}
.global-bottom-bar.is-visible {
transform: translateY(0);
opacity: 1;
}
.global-bottom-bar .bb-header {
min-height: calc(var(--bottom-bar-height) - 10px);
display: flex;
align-items: center;
gap: 0.45rem;
justify-content: space-between;
}
.global-bottom-bar .bb-zone {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.global-bottom-bar .bb-zone-left {
flex: 1;
justify-content: flex-start;
overflow-x: auto;
scrollbar-width: none;
}
.global-bottom-bar .bb-zone-left::-webkit-scrollbar {
display: none;
}
.global-bottom-bar .bb-zone-center {
flex: 0 0 auto;
justify-content: center;
}
.global-bottom-bar .bb-zone-right {
flex: 0 0 auto;
justify-content: flex-end;
}
.global-bottom-bar .bb-count-line {
flex: 1;
background: transparent;
color: var(--text-primary);
padding: 0 0.25rem;
font-size: 0.84rem;
font-weight: 600;
line-height: 1.35;
white-space: nowrap;
overflow-x: auto;
scrollbar-width: none; /* Cleaner look without scrollbar */
display: flex;
align-items: center;
gap: 0.6rem;
}
.global-bottom-bar .bb-count-line::-webkit-scrollbar {
display: none;
}
.global-bottom-bar .bb-sheet-toggle {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
background: transparent;
color: var(--text-primary);
border-radius: 50%;
width: 36px;
height: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
transition: all 0.2s ease;
}
.global-bottom-bar .bb-sheet-toggle:hover {
background: var(--accent-light);
transform: translateY(-1px);
}
.global-bottom-bar .bb-sheet-toggle span { display: none; }
.global-bottom-bar .bb-sheet-toggle i {
transition: transform 0.25s ease;
}
.global-bottom-bar .bb-action-btn {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
background: var(--bg-card);
color: var(--text-primary);
border-radius: 999px;
padding: 0.3rem 0.75rem;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.2;
}
.global-bottom-bar .bb-action-btn:hover {
border-color: rgba(var(--text-primary-rgb), 0.25);
background: var(--accent-light);
color: var(--accent);
}
.global-bottom-bar .bb-search-btn {
min-width: 40px;
justify-content: center;
}
.global-bottom-bar .bb-activity-chip {
border: 1px solid rgba(var(--text-primary-rgb), 0.12);
border-radius: 999px;
padding: 0.28rem 0.7rem;
font-size: 0.8rem;
background: var(--bg-card);
color: var(--text-primary);
display: inline-flex;
align-items: center;
gap: 0.3rem;
white-space: nowrap;
}
.global-bottom-bar .bb-activity-chip.is-hidden {
display: none;
}
.global-bottom-bar .bb-notification-count {
background: var(--accent);
color: #fff;
border-radius: 999px;
min-width: 1.2rem;
height: 1.2rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
padding: 0 0.3rem;
}
.global-bottom-bar.is-expanded .bb-sheet-toggle i {
transform: rotate(180deg);
}
.global-bottom-bar .bb-chip {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
background: var(--accent-light);
color: var(--text-primary);
border-radius: 999px;
padding: 0.35rem 0.85rem;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.35rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
transition: all 0.2s ease;
}
.global-bottom-bar .bb-chip:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0,0,0,0.08);
border-color: rgba(var(--text-primary-rgb), 0.2);
}
.global-bottom-bar .bb-chip.is-active {
background: var(--accent);
color: white;
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
}
.global-bottom-bar .bb-chip.sev-ok {
background: rgba(25, 135, 84, 0.14);
border-color: rgba(25, 135, 84, 0.35);
color: #146c43;
}
.global-bottom-bar .bb-chip.sev-warn {
background: rgba(255, 193, 7, 0.22);
border-color: rgba(255, 193, 7, 0.5);
color: #8a6d00;
}
.global-bottom-bar .bb-chip.sev-critical {
background: rgba(220, 53, 69, 0.14);
border-color: rgba(220, 53, 69, 0.4);
color: #b02a37;
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.sev-ok {
color: #75d39a;
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.sev-warn {
color: #ffd166;
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.sev-critical {
color: #ff9aa2;
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.is-active {
color: #fff;
}
.global-bottom-bar .bb-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.global-bottom-bar .bb-detail-line {
margin-top: 0.5rem;
background: transparent;
padding: 0 0.5rem;
font-size: 0.82rem;
color: var(--text-secondary);
white-space: nowrap;
overflow-x: auto;
scrollbar-width: none;
transition: opacity 0.2s ease;
}
.global-bottom-bar.is-expanded .bb-detail-line {
opacity: 0;
pointer-events: none;
position: absolute;
}
.global-bottom-bar .bb-sheet-panel {
margin-top: 0.4rem;
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 0.28s ease, opacity 0.2s ease;
}
.global-bottom-bar.is-expanded .bb-sheet-panel {
max-height: min(52vh, 460px);
opacity: 1;
}
.global-bottom-bar .bb-sheet-inner {
background: var(--bg-card);
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
border-radius: 14px;
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
min-height: 240px;
max-height: min(52vh, 420px);
overflow: hidden;
box-shadow: inset 0 2px 10px rgba(0,0,0,0.02);
margin-top: 0.5rem;
}
.global-bottom-bar .bb-side-tabs {
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
background: rgba(var(--text-primary-rgb), 0.03);
padding: 0.75rem 0.5rem;
display: grid;
gap: 0.4rem;
align-content: start;
}
.global-bottom-bar .bb-tab-btn {
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
border-radius: 8px;
text-align: left;
font-size: 0.85rem;
font-weight: 600;
padding: 0.5rem 0.75rem;
line-height: 1.3;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.global-bottom-bar .bb-tab-btn i {
font-size: 1rem;
opacity: 0.7;
}
.global-bottom-bar .bb-tab-btn:hover {
background: rgba(var(--text-primary-rgb), 0.05);
color: var(--text-primary);
}
.global-bottom-bar .bb-tab-btn.is-active {
background: var(--bg-card);
color: var(--accent);
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
font-weight: 700;
}
.global-bottom-bar .bb-tab-btn.is-active i {
opacity: 1;
color: var(--accent);
}
.global-bottom-bar .bb-tab-content {
padding: 1.2rem;
overflow: auto;
}
.global-bottom-bar .bb-tab-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.global-bottom-bar .bb-tab-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.global-bottom-bar .bb-tab-list li {
border-left: 4px solid var(--accent);
background: var(--accent-light);
border-radius: 6px 8px 8px 6px;
padding: 0.75rem 1rem;
font-size: 0.88rem;
line-height: 1.4;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
transition: transform 0.2s ease;
}
.global-bottom-bar .bb-tab-list li:hover {
transform: translateX(2px);
}
@media (max-width: 767.98px) {
:root {
--bottom-bar-expanded-height: 56vh;
}
.global-bottom-bar {
min-height: 56px;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.global-bottom-bar .bb-sheet-inner {
grid-template-columns: 1fr;
min-height: 240px;
}
.global-bottom-bar .bb-header {
flex-wrap: wrap;
row-gap: 0.4rem;
}
.global-bottom-bar .bb-zone-left,
.global-bottom-bar .bb-zone-center,
.global-bottom-bar .bb-zone-right {
flex: 1 1 100%;
justify-content: flex-start;
}
.global-bottom-bar .bb-side-tabs {
border-right: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
grid-template-columns: repeat(5, minmax(90px, 1fr));
overflow-x: auto;
}
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
@ -245,7 +649,8 @@
<ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</a></li>
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>BMC Assets</a></li>
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
@ -550,6 +955,75 @@
</div>
{% endblock %}
<div id="globalBottomBar" class="global-bottom-bar" hidden>
<div class="bb-header">
<div class="bb-zone bb-zone-left" role="status" aria-live="polite">
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-text">Ulæste mails: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="urgent"><i class="bi bi-exclamation-octagon"></i> <span class="bb-chip-text">Hastesager: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="unassigned"><i class="bi bi-person-x"></i> <span class="bb-chip-text">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>
</div>
<div class="bb-zone bb-zone-right">
<button id="bbActiveTimerChip" class="bb-activity-chip is-hidden" type="button" title="Aktiv timer">
<i class="bi bi-stopwatch"></i>
<span id="bbActiveTimerText">Ingen aktiv timer</span>
</button>
<button id="bbNotificationsBtn" class="bb-activity-chip" type="button" title="Notifikationer">
<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>
<button id="bbSheetToggle" class="bb-sheet-toggle" type="button" aria-expanded="false" aria-controls="bbSheetPanel" aria-label="Toggle detaljer">
<span>Info</span>
<i class="bi bi-chevron-up" aria-hidden="true"></i>
</button>
</div>
</div>
<div id="bbCountDetail" class="bb-detail-line" role="status" aria-live="polite"><i class="bi bi-info-circle me-1 opacity-75"></i> Klik på en kategori for at se detaljer</div>
<div id="bbSheetPanel" class="bb-sheet-panel" aria-hidden="true">
<div class="bb-sheet-inner">
<div class="bb-side-tabs" role="tablist" aria-label="Bundbar kategorier">
<button class="bb-tab-btn is-active" type="button" data-bb-tab="overview" role="tab" aria-selected="true"><i class="bi bi-bell"></i> Overblik</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="tasks" role="tab" aria-selected="false"><i class="bi bi-calendar-check"></i> Opgaver</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>
<div class="bb-tab-content" role="tabpanel" aria-live="polite">
<div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-bell me-1 text-accent"></i> <span class="bb-tab-title-text">Overblik</span></div>
<div id="bbTabInnerContent">
<ul id="bbTabList" class="bb-tab-list">
<li>Venter på data...</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
window.addEventListener('unhandledrejection', function(event) {
const reason = event && event.reason;
@ -568,6 +1042,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>
// Dark Mode Toggle Logic
const darkModeToggle = document.getElementById('darkModeToggle');
@ -1264,6 +1739,9 @@ window.addEventListener('unhandledrejection', function(event) {
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
<!-- Manual Help Modal -->
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
<!-- Profile Modal -->
<div class="modal fade" id="profileModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">

View File

@ -0,0 +1,105 @@
<!-- Manual Help Modal -->
<div class="modal fade" id="manualHelpModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg modal-dialog-scrollable">
<div class="modal-content" style="border-radius: 12px; border: none; background: var(--card-bg, #ffffff); box-shadow: 0 10px 40px rgba(0,0,0,0.2);">
<div class="modal-header border-bottom border-primary-subtle align-items-center">
<h5 class="modal-title">
<i class="bi bi-book ms-1 me-2 text-accent"></i> Hent Hjælp
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div id="manualHelpLoading" class="text-center py-5 d-none">
<div class="spinner-border text-primary" role="status"></div>
<div class="mt-3 text-muted">Søger i vejledninger...</div>
</div>
<div id="manualHelpContent" class="m-0">
<!-- Dynamic content loads here -->
</div>
</div>
<div class="modal-footer border-top border-primary-subtle bg-body-tertiary">
<a href="/manual" class="btn btn-outline-primary btn-sm me-auto">
<i class="bi bi-search me-1"></i> Alle Vejledninger
</a>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<script>
let manualHelpModalInstance = null;
document.addEventListener("DOMContentLoaded", function() {
const el = document.getElementById('manualHelpModal');
if (el && typeof bootstrap !== "undefined") {
manualHelpModalInstance = new bootstrap.Modal(el);
}
});
async function openManualHelp(contextModule, contextTag = '') {
if (!manualHelpModalInstance) return;
const contentDiv = document.getElementById('manualHelpContent');
const loadingDiv = document.getElementById('manualHelpLoading');
const modalTitle = document.querySelector('#manualHelpModal .modal-title');
if (modalTitle) {
modalTitle.innerHTML = `<i class="bi bi-book ms-1 me-2 text-accent"></i> Hjælp til: ${contextModule}`;
}
contentDiv.classList.add('d-none');
loadingDiv.classList.remove('d-none');
manualHelpModalInstance.show();
try {
let url = `/api/v1/manual/context?module=${encodeURIComponent(contextModule)}`;
if (contextTag) {
url += `&tag=${encodeURIComponent(contextTag)}`;
}
const res = await fetch(url);
if (!res.ok) throw new Error('Netværksfejl');
const data = await res.json();
let html = '';
if (!data.results || data.results.length === 0) {
html = `<div class="p-4 text-center text-muted">
<i class="bi bi-emoji-frown fs-1 d-block mb-3 opacity-50"></i>
Der blev ikke fundet nogle specifikke vejledninger til "${contextModule}".<br>
Prøv at søge i den fulde manual.
</div>`;
} else {
html += '<div class="list-group list-group-flush">';
data.results.forEach(m => {
const diffBadge = m.difficulty === 'advanced'
? '<span class="badge bg-danger-subtle text-danger ms-2">Avanceret</span>'
: '<span class="badge bg-success-subtle text-success ms-2">Begynder</span>';
html += `
<a href="/manual/${m.slug}" target="_blank" class="list-group-item list-group-item-action p-4 border-0 border-bottom border-primary-subtle hover-bg-light py-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<h6 class="mb-0 text-primary fw-bold">
<i class="bi bi-file-text me-2"></i> ${m.title}
</h6>
<div>
${diffBadge}
<i class="bi bi-arrow-up-right-square ms-2 text-muted small"></i>
</div>
</div>
<p class="text-body-secondary small mb-0 ps-4">${m.summary || 'Ingen beskrivelse tilgængelig.'}</p>
</a>
`;
});
html += '</div>';
}
contentDiv.innerHTML = html;
} catch (e) {
contentDiv.innerHTML = `<div class="p-4 text-center text-danger"><i class="bi bi-exclamation-triangle mt-1 me-2"></i> Kunne ikke loade vejledninger. (${e.message})</div>`;
console.error('Fejl ved hentning af manual help:', e);
} finally {
loadingDiv.classList.add('d-none');
contentDiv.classList.remove('d-none');
}
}
</script>

View File

@ -23,6 +23,7 @@ ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"}
STAGING_KEY_SQL = "COALESCE(source_account_id, 'name:' || LOWER(COALESCE(source_customer_name, 'ukendt')))"
ALLOWED_BILLING_DIRECTIONS = {"forward", "backward"}
ALLOWED_PRICE_CHANGE_STATUSES = {"pending", "approved", "rejected", "applied"}
ALLOWED_BILLING_INTERVALS = {"daily", "biweekly", "monthly", "quarterly", "yearly"}
def _staging_status_with_mapping(status: str, has_customer: bool) -> str:
@ -74,6 +75,110 @@ def _next_invoice_date(start_date: date, interval: str) -> date:
return start_date + relativedelta(months=1)
def _ensure_asset_not_booked(asset_id: int, start_dt: date, end_dt: Optional[date], exclude_subscription_id: Optional[int] = None):
"""Prevent overlapping asset usage across active/draft/paused subscriptions."""
if end_dt and end_dt < start_dt:
raise HTTPException(status_code=400, detail="period_to cannot be before period_from")
params: List[Any] = [asset_id, start_dt, end_dt]
exclude_sql = ""
if exclude_subscription_id:
exclude_sql = " AND s.id <> %s"
params.append(exclude_subscription_id)
conflict = execute_query_single(
f"""
SELECT
s.id AS subscription_id,
i.id AS line_item_id,
COALESCE(i.period_from, s.start_date) AS existing_period_from,
COALESCE(i.period_to, s.end_date) AS existing_period_to
FROM sag_subscription_items i
JOIN sag_subscriptions s ON s.id = i.subscription_id
WHERE i.asset_id = %s
AND s.status IN ('draft', 'active', 'paused')
AND daterange(COALESCE(i.period_from, s.start_date), COALESCE(i.period_to, s.end_date, 'infinity'::date), '[]')
&& daterange(%s, COALESCE(%s, 'infinity'::date), '[]')
{exclude_sql}
LIMIT 1
""",
tuple(params),
)
if conflict:
raise HTTPException(
status_code=400,
detail=(
"Asset is already booked in overlapping period "
f"(subscription_id={conflict.get('subscription_id')}, line_item_id={conflict.get('line_item_id')})"
),
)
def _ensure_binding_not_overlapping(asset_id: int, start_dt: date, end_dt: Optional[date], exclude_binding_id: Optional[int] = None):
"""Prevent overlapping active rows in subscription_asset_bindings for the same asset."""
if end_dt and end_dt < start_dt:
raise HTTPException(status_code=400, detail="end_date cannot be before start_date")
params: List[Any] = [asset_id, start_dt, end_dt]
exclude_sql = ""
if exclude_binding_id:
exclude_sql = " AND b.id <> %s"
params.append(exclude_binding_id)
conflict = execute_query_single(
f"""
SELECT b.id
FROM subscription_asset_bindings b
WHERE b.asset_id = %s
AND b.deleted_at IS NULL
AND b.status = 'active'
AND daterange(b.start_date, COALESCE(b.end_date, 'infinity'::date), '[]')
&& daterange(%s, COALESCE(%s, 'infinity'::date), '[]')
{exclude_sql}
LIMIT 1
""",
tuple(params),
)
if conflict:
raise HTTPException(
status_code=400,
detail=f"Asset already has an overlapping active binding (binding_id={conflict.get('id')})",
)
def _sync_asset_rental_status(asset_id: int):
"""Keep hardware_assets.rental_status aligned with active subscription bindings."""
active_binding = execute_query_single(
"""
SELECT b.id
FROM subscription_asset_bindings b
JOIN sag_subscriptions s ON s.id = b.subscription_id
WHERE b.asset_id = %s
AND b.deleted_at IS NULL
AND b.status = 'active'
AND s.status IN ('draft', 'active', 'paused')
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
LIMIT 1
""",
(asset_id,),
)
target_status = "udlejet" if active_binding else "ledig"
execute_query(
"""
UPDATE hardware_assets
SET rental_status = CASE
WHEN rental_status = 'defekt' THEN rental_status
ELSE %s
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
AND deleted_at IS NULL
""",
(target_status, asset_id),
)
def _auto_map_customer(account_id: Optional[str], customer_name: Optional[str], customer_cvr: Optional[str]) -> Optional[int]:
if account_id:
row = execute_query_single(
@ -168,6 +273,9 @@ async def create_subscription(payload: Dict[str, Any]):
billing_day = payload.get("billing_day")
start_date = payload.get("start_date")
billing_direction = (payload.get("billing_direction") or "forward").strip().lower()
price_type = (payload.get("price_type") or "manual").strip().lower()
custom_price_override = bool(payload.get("custom_price_override"))
first_invoice_policy = (payload.get("first_invoice_policy") or "start_date").strip().lower()
advance_months = int(payload.get("advance_months") or 1)
first_full_period_start = payload.get("first_full_period_start")
binding_months = int(payload.get("binding_months") or 0)
@ -189,13 +297,23 @@ async def create_subscription(payload: Dict[str, Any]):
raise HTTPException(status_code=400, detail="start_date is required")
if not line_items:
raise HTTPException(status_code=400, detail="line_items is required")
if billing_interval not in ALLOWED_BILLING_INTERVALS:
raise HTTPException(status_code=400, detail="billing_interval must be daily/biweekly/monthly/quarterly/yearly")
if billing_direction not in ALLOWED_BILLING_DIRECTIONS:
raise HTTPException(status_code=400, detail="billing_direction must be forward or backward")
if price_type not in {"manual", "day", "week", "month", "year"}:
raise HTTPException(status_code=400, detail="price_type must be manual/day/week/month/year")
if first_invoice_policy not in {"start_date", "next_cycle"}:
raise HTTPException(status_code=400, detail="first_invoice_policy must be start_date or next_cycle")
if advance_months < 1 or advance_months > 24:
raise HTTPException(status_code=400, detail="advance_months must be between 1 and 24")
if binding_months < 0:
raise HTTPException(status_code=400, detail="binding_months must be >= 0")
start_dt = _safe_date(start_date)
if not start_dt:
raise HTTPException(status_code=400, detail="start_date must be a valid date")
sag = execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s",
(sag_id,)
@ -229,6 +347,7 @@ async def create_subscription(payload: Dict[str, Any]):
product_map = {row["id"]: row for row in (rows or [])}
cleaned_items = []
line_asset_ranges: Dict[int, List[tuple[date, Optional[date]]]] = {}
total_price = 0
blocked_reasons = []
for idx, item in enumerate(line_items, start=1):
@ -240,6 +359,10 @@ async def create_subscription(payload: Dict[str, Any]):
serial_number = (item.get("serial_number") or "").strip() or None
period_from = item.get("period_from")
period_to = item.get("period_to")
period_from_dt = _safe_date(period_from) or start_dt
period_to_dt = _safe_date(period_to)
if period_to_dt and period_to_dt < period_from_dt:
raise HTTPException(status_code=400, detail="line_items period_to cannot be before period_from")
product = product_map.get(product_id)
if not description and product:
@ -261,6 +384,21 @@ async def create_subscription(payload: Dict[str, Any]):
)
if not asset:
raise HTTPException(status_code=400, detail=f"asset_id {asset_id} was not found")
_ensure_asset_not_booked(asset_id, period_from_dt, period_to_dt)
existing_ranges = line_asset_ranges.get(int(asset_id), [])
for existing_start, existing_end in existing_ranges:
latest_start = max(existing_start, period_from_dt)
effective_existing_end = existing_end or date.max
effective_new_end = period_to_dt or date.max
earliest_end = min(effective_existing_end, effective_new_end)
if latest_start <= earliest_end:
raise HTTPException(
status_code=400,
detail=f"line_items contains overlapping periods for asset_id {asset_id}",
)
existing_ranges.append((period_from_dt, period_to_dt))
line_asset_ranges[int(asset_id)] = existing_ranges
requires_asset = bool(product and product.get("asset_required"))
requires_serial_number = bool(product and product.get("serial_number_required"))
@ -286,6 +424,8 @@ async def create_subscription(payload: Dict[str, Any]):
"line_total": line_total,
"period_from": period_from,
"period_to": period_to,
"price_type": (item.get("price_type") or price_type or "manual"),
"custom_price_override": bool(item.get("custom_price_override", custom_price_override)),
"requires_serial_number": requires_serial_number,
"serial_number": serial_number,
"billing_blocked": billing_blocked,
@ -308,7 +448,6 @@ async def create_subscription(payload: Dict[str, Any]):
# Calculate next_invoice_date based on billing_interval
start_dt = datetime.strptime(start_date, "%Y-%m-%d").date()
period_start = start_dt
# Calculate next invoice date
@ -349,6 +488,9 @@ async def create_subscription(payload: Dict[str, Any]):
binding_group_key,
billing_blocked,
billing_block_reason,
price_type,
custom_price_override,
first_invoice_policy,
invoice_merge_key,
price_change_case_id,
renewal_case_id,
@ -357,7 +499,7 @@ async def create_subscription(payload: Dict[str, Any]):
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, 'draft', %s
%s, %s, %s, %s, 'draft', %s
)
RETURNING *
""",
@ -380,6 +522,9 @@ async def create_subscription(payload: Dict[str, Any]):
binding_group_key,
billing_blocked,
billing_block_reason,
price_type,
custom_price_override,
first_invoice_policy,
invoice_merge_key,
price_change_case_id,
renewal_case_id,
@ -402,11 +547,13 @@ async def create_subscription(payload: Dict[str, Any]):
line_total,
period_from,
period_to,
price_type,
custom_price_override,
requires_serial_number,
serial_number,
billing_blocked,
billing_block_reason
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
subscription["id"],
@ -419,6 +566,8 @@ async def create_subscription(payload: Dict[str, Any]):
item["line_total"],
item["period_from"],
item["period_to"],
item["price_type"],
item["custom_price_override"],
item["requires_serial_number"],
item["serial_number"],
item["billing_blocked"],
@ -544,6 +693,7 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
"billing_direction", "advance_months", "first_full_period_start",
"binding_months", "binding_start_date", "binding_end_date", "binding_group_key",
"billing_blocked", "billing_block_reason", "invoice_merge_key",
"price_type", "custom_price_override", "first_invoice_policy",
"price_change_case_id", "renewal_case_id"
}
@ -601,9 +751,10 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
subscription_id, line_no, description,
quantity, unit_price, line_total, product_id,
asset_id, period_from, period_to,
price_type, custom_price_override,
requires_serial_number, serial_number,
billing_blocked, billing_block_reason
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
subscription_id, idx, description,
@ -612,6 +763,8 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
item.get("asset_id"),
item.get("period_from"),
item.get("period_to"),
item.get("price_type", "manual"),
bool(item.get("custom_price_override")),
bool(item.get("requires_serial_number")),
item.get("serial_number"),
bool(item.get("billing_blocked")),
@ -1080,6 +1233,8 @@ async def create_subscription_asset_binding(subscription_id: int, payload: Dict[
if not end_date and binding_months > 0:
end_date = start_date + relativedelta(months=binding_months)
_ensure_binding_not_overlapping(asset_id, start_date, end_date)
result = execute_query(
"""
INSERT INTO subscription_asset_bindings (
@ -1111,6 +1266,8 @@ async def create_subscription_asset_binding(subscription_id: int, payload: Dict[
if not result:
raise HTTPException(status_code=500, detail="Could not create binding")
_sync_asset_rental_status(asset_id)
execute_query(
"""
UPDATE sag_subscription_items
@ -1165,6 +1322,23 @@ async def update_subscription_asset_binding(binding_id: int, payload: Dict[str,
raise HTTPException(status_code=404, detail="Binding not found")
return existing
existing = execute_query_single(
"SELECT * FROM subscription_asset_bindings WHERE id = %s AND deleted_at IS NULL",
(binding_id,),
)
if not existing:
raise HTTPException(status_code=404, detail="Binding not found")
status_for_overlap = payload.get("status", existing.get("status"))
start_for_overlap = _safe_date(payload.get("start_date")) or _safe_date(existing.get("start_date")) or date.today()
end_for_overlap = _safe_date(payload.get("end_date"))
if end_for_overlap is None:
end_for_overlap = _safe_date(existing.get("end_date"))
asset_for_overlap = int(existing.get("asset_id"))
if status_for_overlap == "active":
_ensure_binding_not_overlapping(asset_for_overlap, start_for_overlap, end_for_overlap, exclude_binding_id=binding_id)
values.append(binding_id)
result = execute_query(
f"""
@ -1179,7 +1353,9 @@ async def update_subscription_asset_binding(binding_id: int, payload: Dict[str,
)
if not result:
raise HTTPException(status_code=404, detail="Binding not found")
return result[0]
updated = result[0]
_sync_asset_rental_status(int(updated.get("asset_id")))
return updated
except HTTPException:
raise
except Exception as e:
@ -1204,6 +1380,12 @@ async def delete_subscription_asset_binding(binding_id: int):
)
if not result:
raise HTTPException(status_code=404, detail="Binding not found")
binding = execute_query_single(
"SELECT asset_id FROM subscription_asset_bindings WHERE id = %s",
(binding_id,)
)
if binding and binding.get("asset_id"):
_sync_asset_rental_status(int(binding.get("asset_id")))
return {"status": "deleted", "id": result[0].get("id")}
except HTTPException:
raise

View File

@ -1,16 +1,5 @@
#!/usr/bin/env python3
import main
print("=" * 80)
print("ALL REGISTERED ROUTES")
print("=" * 80)
for i, route in enumerate(main.app.routes):
if hasattr(route, 'path'):
print(f"{i+1:3}. {route.path:60}")
if 'time' in route.path.lower():
print(f" ^^^ TIMETRACKING ROUTE ^^^")
else:
print(f"{i+1:3}. {route}")
print(f"\n Total routes: {len(main.app.routes)}")
from main import app
for route in app.routes:
if hasattr(route, 'methods'):
if '/manual' in route.path:
print(f"{list(route.methods)} {route.path}")

5
list_routes2.py Normal file
View File

@ -0,0 +1,5 @@
from main import app
for route in app.routes:
if hasattr(route, 'methods'):
if '/api/v1/manual' in route.path:
print(f"{list(route.methods)} {route.path}")

22
main.py
View File

@ -133,6 +133,9 @@ from app.modules.orders.backend import router as orders_api
from app.modules.orders.frontend import views as orders_views
from app.modules.manual.backend import router as manual_api
from app.modules.manual.frontend import views as manual_views
from app.modules.bottom_bar.backend import router as bottom_bar_api
from app.modules.bottom_bar.backend import public_router as bottom_bar_public_api
from app.modules.rentals.backend import router as rentals_api
# Configure logging
logging.basicConfig(
@ -213,6 +216,22 @@ async def lifespan(app: FastAPI):
)
logger.info("✅ ESET sync job scheduled (every %d minutes)", settings.ESET_SYNC_INTERVAL_MINUTES)
if settings.ANYDESK_LOCAL_SYNC_ENABLED:
from app.jobs.anydesk_local_sync import sync_anydesk_local_sessions
backup_scheduler.scheduler.add_job(
func=sync_anydesk_local_sessions,
trigger=IntervalTrigger(minutes=settings.ANYDESK_LOCAL_SYNC_INTERVAL_MINUTES),
id='anydesk_local_sync',
name='AnyDesk Local Sessions Sync',
max_instances=1,
replace_existing=True
)
logger.info(
"✅ AnyDesk local sync job scheduled (every %d minutes)",
settings.ANYDESK_LOCAL_SYNC_INTERVAL_MINUTES,
)
if settings.LINKS_MODULE_ENABLED and settings.LINKS_DEAD_LINK_CHECK_ENABLED:
from app.modules.links.jobs.dead_link_check import check_links_health
@ -430,6 +449,9 @@ app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api

View File

@ -0,0 +1,47 @@
-- Migration 165: AnyDesk IDs per case (multi-ID support)
-- Stores quick-connect IDs on cases and optional hardware relation
CREATE TABLE IF NOT EXISTS sag_anydesk_ids (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
anydesk_id VARCHAR(64) NOT NULL,
hardware_asset_id INTEGER REFERENCES hardware_assets(id) ON DELETE SET NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
note TEXT,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE,
UNIQUE (sag_id, anydesk_id)
);
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_sag_id
ON sag_anydesk_ids(sag_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_anydesk_id
ON sag_anydesk_ids(anydesk_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_hardware_asset_id
ON sag_anydesk_ids(hardware_asset_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_primary
ON sag_anydesk_ids(sag_id, is_primary)
WHERE deleted_at IS NULL;
-- Keep updated_at fresh
CREATE OR REPLACE FUNCTION update_sag_anydesk_ids_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_update_sag_anydesk_ids_updated_at ON sag_anydesk_ids;
CREATE TRIGGER trg_update_sag_anydesk_ids_updated_at
BEFORE UPDATE ON sag_anydesk_ids
FOR EACH ROW
EXECUTE FUNCTION update_sag_anydesk_ids_updated_at();

View File

@ -0,0 +1,57 @@
-- Migration 166: Bottom bar module activation and overrides
CREATE TABLE IF NOT EXISTS module_role_settings (
id SERIAL PRIMARY KEY,
module_name VARCHAR(100) NOT NULL,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (module_name, group_id)
);
CREATE TABLE IF NOT EXISTS user_module_preferences (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
module_name VARCHAR(100) NOT NULL,
enabled BOOLEAN,
collapsed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, module_name)
);
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
('bottom_bar_enabled', 'false', 'bottom_bar', 'Enable or disable bottom bar globally', 'boolean', false)
ON CONFLICT (key) DO NOTHING;
-- Default role access: admins, managers and technicians enabled. viewers disabled.
INSERT INTO module_role_settings (module_name, group_id, enabled)
SELECT 'bottom_bar', g.id,
CASE WHEN g.name IN ('Administrators', 'Managers', 'Technicians') THEN TRUE ELSE FALSE END
FROM groups g
ON CONFLICT (module_name, group_id) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_module_role_settings_module ON module_role_settings(module_name);
CREATE INDEX IF NOT EXISTS idx_user_module_preferences_user_module ON user_module_preferences(user_id, module_name);
CREATE OR REPLACE FUNCTION update_module_setting_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS module_role_settings_updated_at_trigger ON module_role_settings;
CREATE TRIGGER module_role_settings_updated_at_trigger
BEFORE UPDATE ON module_role_settings
FOR EACH ROW
EXECUTE FUNCTION update_module_setting_updated_at();
DROP TRIGGER IF EXISTS user_module_preferences_updated_at_trigger ON user_module_preferences;
CREATE TRIGGER user_module_preferences_updated_at_trigger
BEFORE UPDATE ON user_module_preferences
FOR EACH ROW
EXECUTE FUNCTION update_module_setting_updated_at();

View File

@ -0,0 +1,35 @@
-- Migration 167: Rental pricing and asset rental status foundation
-- Adds minimal fields for Assets/Udlejning/Fakturering alias APIs.
-- Note: This migration is schema-only and does NOT perform any e-conomic sync.
ALTER TABLE products
ADD COLUMN IF NOT EXISTS rental_price_day DECIMAL(10,2) CHECK (rental_price_day IS NULL OR rental_price_day >= 0),
ADD COLUMN IF NOT EXISTS rental_price_week DECIMAL(10,2) CHECK (rental_price_week IS NULL OR rental_price_week >= 0),
ADD COLUMN IF NOT EXISTS rental_price_month DECIMAL(10,2) CHECK (rental_price_month IS NULL OR rental_price_month >= 0),
ADD COLUMN IF NOT EXISTS rental_price_year DECIMAL(10,2) CHECK (rental_price_year IS NULL OR rental_price_year >= 0);
ALTER TABLE sag_subscriptions
ADD COLUMN IF NOT EXISTS price_type VARCHAR(20) NOT NULL DEFAULT 'manual'
CHECK (price_type IN ('manual', 'day', 'week', 'month', 'year')),
ADD COLUMN IF NOT EXISTS custom_price_override BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS first_invoice_policy VARCHAR(20) NOT NULL DEFAULT 'start_date'
CHECK (first_invoice_policy IN ('start_date', 'next_cycle'));
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_price_type ON sag_subscriptions(price_type);
ALTER TABLE sag_subscription_items
ADD COLUMN IF NOT EXISTS price_type VARCHAR(20) NOT NULL DEFAULT 'manual'
CHECK (price_type IN ('manual', 'day', 'week', 'month', 'year')),
ADD COLUMN IF NOT EXISTS custom_price_override BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE hardware_assets
ADD COLUMN IF NOT EXISTS rental_status VARCHAR(20) NOT NULL DEFAULT 'ledig'
CHECK (rental_status IN ('ledig', 'udlejet', 'defekt', 'retur'));
CREATE INDEX IF NOT EXISTS idx_hardware_assets_rental_status
ON hardware_assets(rental_status)
WHERE deleted_at IS NULL;
COMMENT ON COLUMN hardware_assets.rental_status IS 'Rental lifecycle status used by asset rental workflows.';
COMMENT ON COLUMN sag_subscriptions.price_type IS 'Price source type: manual or product rental periods.';
COMMENT ON COLUMN sag_subscriptions.first_invoice_policy IS 'Controls first invoice timing; default is start_date.';

View File

@ -0,0 +1,13 @@
-- Migration 168: Subscription scheduler and asset overlap indexes
-- Improves performance for due-subscription scans and asset overlap validation.
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_status_next_invoice_date
ON sag_subscriptions(status, next_invoice_date);
CREATE INDEX IF NOT EXISTS idx_sag_subscription_items_asset_period
ON sag_subscription_items(asset_id, period_from, period_to)
WHERE asset_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_subscription_asset_bindings_asset_dates_active
ON subscription_asset_bindings(asset_id, start_date, end_date)
WHERE deleted_at IS NULL AND status = 'active';

35
patch_backend_router.py Normal file
View File

@ -0,0 +1,35 @@
import re
with open("app/modules/manual/backend/router.py", "r") as f:
content = f.read()
if "from fastapi import" in content and "BackgroundTasks" not in content:
content = content.replace("from fastapi import ", "from fastapi import BackgroundTasks, ")
if "def get_manual_article(slug: str):" in content:
content = content.replace("def get_manual_article(slug: str):", "def get_manual_article(slug: str, background_tasks: BackgroundTasks):")
bg_def = """
def _increment_use_count(manual_id: str):
try:
execute_query(
"UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s",
(manual_id,)
)
except Exception as e:
logger.error(f"Failed to increment use_count for manual {manual_id}: {e}")
@router.get("/{slug}")
"""
content = content.replace('@router.get("/{slug}")', bg_def)
target_inc = """ execute_query(
"UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s",
(article["id"],),
)"""
new_inc = """ # Increment view async to save latency
background_tasks.add_task(_increment_use_count, article["id"])"""
content = content.replace(target_inc, new_inc)
with open("app/modules/manual/backend/router.py", "w") as f:
f.write(content)

10
patch_base.py Normal file
View File

@ -0,0 +1,10 @@
with open("app/shared/frontend/base.html", "r") as f:
text = f.read()
target = '{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}'
replacement = target + '\n\n<!-- Manual Help Modal -->\n{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}'
text = text.replace(target, replacement)
with open("app/shared/frontend/base.html", "w") as f:
f.write(text)

53
patch_base_html.py Normal file
View File

@ -0,0 +1,53 @@
with open("app/shared/frontend/base.html", "r") as f:
content = f.read()
old_chips = """ <div id="bbCountLine" class="bb-count-line" role="status" aria-live="polite">
<button class="bb-chip" type="button" data-bb-key="timer"><i class="bi bi-clock-history"></i> <span class="bb-chip-text">Timer: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="calls"><i class="bi bi-telephone"></i> <span class="bb-chip-text">Opkald: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-text">Mail: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="alerts"><i class="bi bi-exclamation-triangle"></i> <span class="bb-chip-text">Alerts: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="reminders"><i class="bi bi-bell"></i> <span class="bb-chip-text">Reminders: 0</span></button>
</div>"""
new_chips = """ <div id="bbCountLine" class="bb-count-line" role="status" aria-live="polite">
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-text">Mails: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="cases"><i class="bi bi-folder2-open"></i> <span class="bb-chip-text">Sager: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="urgent"><i class="bi bi-exclamation-octagon"></i> <span class="bb-chip-text">Hastesager: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="timer"><i class="bi bi-stopwatch"></i> <span class="bb-chip-text">Timere: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="kuma"><i class="bi bi-activity"></i> <span class="bb-chip-text">Kuma: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="eset"><i class="bi bi-shield-lock"></i> <span class="bb-chip-text">ESET: 0</span></button>
</div>"""
content = content.replace(old_chips, new_chips)
old_tabs = """ <div class="bb-side-tabs" role="tablist" aria-label="Bundbar kategorier">
<button class="bb-tab-btn is-active" type="button" data-bb-tab="timer" role="tab" aria-selected="true"><i class="bi bi-clock-history"></i> Timer</button>
<button class="bb-tab-btn" type="button" data-bb-tab="calls" role="tab" aria-selected="false"><i class="bi bi-telephone"></i> Opkald</button>
<button class="bb-tab-btn" type="button" data-bb-tab="mail" role="tab" aria-selected="false"><i class="bi bi-envelope"></i> Mail</button>
<button class="bb-tab-btn" type="button" data-bb-tab="alerts" role="tab" aria-selected="false"><i class="bi bi-exclamation-triangle"></i> Alerts</button>
<button class="bb-tab-btn" type="button" data-bb-tab="reminders" role="tab" aria-selected="false"><i class="bi bi-bell"></i> Reminders</button>
</div>
<div class="bb-tab-content" role="tabpanel" aria-live="polite">
<div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-clock-history me-1 text-accent"></i> <span class="bb-tab-title-text">Timer</span></div>
<ul id="bbTabList" class="bb-tab-list">
<li>Venter data...</li>
</ul>
</div>"""
new_tabs = """ <div class="bb-side-tabs" role="tablist" aria-label="Bundbar kategorier">
<button class="bb-tab-btn is-active" type="button" data-bb-tab="overview" role="tab" aria-selected="true"><i class="bi bi-bell"></i> Overblik</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="tasks" role="tab" aria-selected="false"><i class="bi bi-calendar-check"></i> Opgaver</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>
<div class="bb-tab-content" role="tabpanel" aria-live="polite">
<div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-bell me-1 text-accent"></i> <span class="bb-tab-title-text">Overblik</span></div>
<div id="bbTabInnerContent">
<ul id="bbTabList" class="bb-tab-list">
<li>Venter data...</li>
</ul>
</div>
</div>"""
content = content.replace(old_tabs, new_tabs)
with open("app/shared/frontend/base.html", "w") as f:
f.write(content)

63
patch_bb_chat.py Normal file
View File

@ -0,0 +1,63 @@
import re
with open("static/js/bottom-bar.js", "r") as f:
text = f.read()
# Update the rendering of the messages tab to include a user selector
chat_html = """
const replyBox = document.createElement('div');
replyBox.className = 'mt-2 border-top pt-2 border-primary-subtle';
replyBox.innerHTML = `
<div class="input-group input-group-sm mb-1">
<span class="input-group-text bg-light text-muted border-0"><i class="bi bi-person"></i></span>
<select id="chatRecipient" class="form-select border-0 bg-light">
<option value="all">Alle vagt</option>
<option value="system">System (Bot)</option>
<option value="chef">Chef</option>
<option value="3">Christian Thomas</option>
<option value="4">Tekniker 1</option>
</select>
</div>
<div class="input-group">
<input type="text" id="chatInputQuick" class="form-control form-control-sm" placeholder="Skriv en besked...">
<button class="btn btn-outline-primary btn-sm" id="btnSendMsg"><i class="bi bi-send"></i></button>
</div>
`;
chatContainer.appendChild(ul);
chatContainer.appendChild(replyBox);
"""
text = re.sub(
r" const replyBox = document\.createElement\('div'\);\n replyBox\.className = 'input-group mt-2 border-top pt-2 border-primary-subtle';\n replyBox\.innerHTML = '<input type=\"text\" class=\"form-control form-control-sm\" placeholder=\"Skriv en besked\.\.\.\"><button class=\"btn btn-outline-primary btn-sm\"><i class=\"bi bi-send\"></i></button>';\n \n chatContainer\.appendChild\(ul\);\n chatContainer\.appendChild\(replyBox\);",
chat_html,
text
)
# And update the logic that sends it
events_html = """
if (btn.id === 'btnSendMsg') {
const input = byId('chatInputQuick');
const recipientObj = byId('chatRecipient');
if (input && input.value.trim() !== '') {
const recipient = recipientObj ? recipientObj.options[recipientObj.selectedIndex].text : 'Alle';
console.log("-> Sender besked til " + recipient + ":", input.value);
const msgVal = input.value;
input.value = '';
const msgDiv = document.createElement('div');
msgDiv.className = 'mb-1 text-end';
msgDiv.innerHTML = '<div class="small text-muted mb-1 text-end">Til: ' + recipient + '</div><div class="d-inline-block bg-primary text-white p-2 rounded-3 text-start" style="max-width: 80%;"><strong>Mig:</strong> ' + msgVal + '</div>';
listContainer.insertBefore(msgDiv, listContainer.lastElementChild);
listContainer.scrollTop = listContainer.scrollHeight;
}
}
"""
text = re.sub(
r" if \(btn\.id === 'btnSendMsg'\) \{\n const input = byId\('chatInputQuick'\);\n if \(input && input\.value\.trim\(\) !== ''\) \{\n console\.log\(\"-> Sender besked:\", input\.value\);\n input\.value = '';\n const msgDiv = document\.createElement\('div'\);\n msgDiv\.className = 'mb-1 text-end';\n msgDiv\.innerHTML = '<div class=\"d-inline-block bg-primary text-white p-2 rounded-3\" style=\"max-width: 80%;\"><strong>Mig:</strong> ' \+ msgDiv\.textContent \+ ' \(Mock\)</div>';\n listContainer\.insertBefore\(msgDiv, listContainer\.lastElementChild\);\n listContainer\.scrollTop = listContainer\.scrollHeight;\n \}\n \}",
events_html.strip(),
text
)
with open("static/js/bottom-bar.js", "w") as f:
f.write(text)

59
patch_bb_chat2.py Normal file
View File

@ -0,0 +1,59 @@
import re
with open("static/js/bottom-bar.js", "r") as f:
text = f.read()
events_html = """
if (btn.id === 'btnNextTask') {
console.log("-> Beder backend om næste opgave...");
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Omsætter kalender og SLA...';
btn.disabled = true;
fetch('/api/v1/bottom-bar/next_task', { method: 'POST' })
.then(r => r.json())
.then(data => {
const task = data.task;
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + task.title + ' (Sag #' + task.case_id + ') <span class="badge bg-light text-dark ms-2">' + data.free_time_calculated + 'm fri</span>';
btn.classList.add('btn-success');
btn.classList.remove('btn-primary');
})
.catch(err => {
console.error("Fejl:", err);
btn.innerHTML = "Fejl - prøv igen";
btn.disabled = false;
});
}
if (btn.id === 'btnSendMsg') {
const input = document.getElementById('chatInputQuick');
const recipientObj = document.getElementById('chatRecipient');
if (input && input.value.trim() !== '') {
const recipient = recipientObj ? recipientObj.options[recipientObj.selectedIndex].text : 'Alle';
console.log("-> Sender besked til", recipient, ":", input.value);
const msgVal = input.value;
input.value = '';
const msgContainer = document.createElement('div');
msgContainer.className = 'mb-2 text-end';
msgContainer.innerHTML = '<div class="small text-muted mb-1 me-1" style="font-size:0.7rem;">Til: ' + recipient + '</div><div class="d-inline-block bg-primary text-white p-2 rounded-3 text-start shadow-sm" style="max-width: 85%;"><strong>Mig:</strong> ' + msgVal + '</div>';
const listUl = listContainer.querySelector('ul') || listContainer;
listUl.appendChild(msgContainer);
// Simple hacky scroll down
const tabInner = document.getElementById('bbTabInnerContent');
if(tabInner) {
tabInner.scrollTop = tabInner.scrollHeight + 500;
}
}
}
"""
pattern = r" if \(btn\.id === 'btnNextTask'\) \{.*?\n \}"
text = re.sub(pattern, events_html.strip(), text, flags=re.DOTALL)
with open("static/js/bottom-bar.js", "w") as f:
f.write(text)

58
patch_bb_chat3.py Normal file
View File

@ -0,0 +1,58 @@
import re
with open("static/js/bottom-bar.js", "r") as f:
text = f.read()
# Replace the static select with a dynamic fetch container
events_html = """
const replyBox = document.createElement('div');
replyBox.className = 'mt-2 border-top pt-2 border-primary-subtle';
replyBox.innerHTML = `
<div class="input-group input-group-sm mb-1">
<span class="input-group-text bg-light text-muted border-0"><i class="bi bi-person"></i></span>
<select id="chatRecipient" class="form-select border-0 bg-light">
<option value="all">Indlæser brugere...</option>
</select>
</div>
<div class="input-group">
<input type="text" id="chatInputQuick" class="form-control form-control-sm" placeholder="Skriv en besked...">
<button class="btn btn-outline-primary btn-sm" id="btnSendMsg"><i class="bi bi-send"></i></button>
</div>
`;
chatContainer.appendChild(ul);
chatContainer.appendChild(replyBox);
innerContent.appendChild(chatContainer);
// Fetch users dynamically
fetch('/api/v1/users?is_active=true', { credentials: 'include' })
.then(r => r.json())
.then(users => {
const sel = document.getElementById('chatRecipient');
if (sel) {
sel.innerHTML = '<option value="all">Alle på vagt</option><option value="system">System (Bot)</option>';
users.forEach(u => {
sel.innerHTML += `<option value="${u.id}">${u.name || u.email}</option>`;
});
}
})
.catch(e => console.error("Error fetching users for chat:", e));
} else {
innerContent.appendChild(ul);
}
"""
# Find the block we inserted earlier and replace it
pattern = r" const replyBox = document\.createElement\('div'\);\n replyBox\.className = 'mt-2 border-top pt-2 border-primary-subtle';\n replyBox\.innerHTML = `[\s\S]*?innerContent\.appendChild\(chatContainer\);\n \} else \{\n innerContent\.appendChild\(ul\);\n \}"
# print(re.search(pattern, text))
text = re.sub(pattern, events_html, text)
# Then bump the cache version
with open("static/js/bottom-bar.js", "w") as f:
f.write(text)
with open("app/shared/frontend/base.html", "r") as f:
bt = f.read()
bt = re.sub(r'bottom-bar\.js\?v=\d+\.\d+', 'bottom-bar.js?v=2.00', bt)
with open("app/shared/frontend/base.html", "w") as f2:
f2.write(bt)

10
patch_bb_ver2.py Normal file
View File

@ -0,0 +1,10 @@
import re
with open("app/shared/frontend/base.html", "r") as f:
text = f.read()
# Update cache version
text = re.sub(r'bottom-bar\.js\?v=\d+\.\d+', 'bottom-bar.js?v=1.92', text)
with open("app/shared/frontend/base.html", "w") as f:
f.write(text)

64
patch_bottombar_router.py Normal file
View File

@ -0,0 +1,64 @@
with open("app/modules/bottom_bar/backend/router.py", "r") as f:
content = f.read()
# Replace the whole endpoint
import re
new_content = """from fastapi import APIRouter
from app.core.config import settings
router = APIRouter()
@router.get("/state")
async def get_bottom_bar_state():
# MVP Mock Data for the updated Bottom Bar & Chef-overblik
return {
"enabled": True,
"sections": {
"mail": {
"unread": 5,
"customer_reply_needed": 2
},
"cases": {
"open": 12,
"list": [{"id": 100, "title": "Blandet sag A"}, {"id": 105, "title": "Sag B"}]
},
"urgent": {
"count": 1,
"list": [{"id": 999, "title": "Server nede hos Kunde A"}]
},
"timer": {
"active_count": 1,
"list": [{"desc": "Fejlfinding WiFi", "elapsed": 1200}]
},
"kuma": {
"down": 3,
"list": ["Switch-Odense", "Printer-Aarhus", "FW-Kbh"]
},
"eset": {
"incidents": 0,
"list": []
},
"messages": {
"count": 2,
"list": [{"from": "Chef", "text": "Husk at ringe til Jensen"}, {"from": "System", "text": "Ny opgave tildelt."}]
},
"tasks": {
"count": 4,
"list": [
{"title": "Opsætning af ny PC", "deadline": "I dag 14:00"},
{"title": "Gennemgå ESET log", "deadline": "I dag 16:00"}
]
},
"boss": {
"stats": {
"unassigned": 3,
"active_employees": 4
}
}
}
}
"""
with open("app/modules/bottom_bar/backend/router.py", "w") as f:
f.write(new_content)

97
patch_chip_filter.py Normal file
View File

@ -0,0 +1,97 @@
with open("static/js/bottom-bar.js", "r") as f:
content = f.read()
import re
# Insert a filter state
if "let overviewFilter" not in content:
content = content.replace("let activeKey = 'timer';", "let activeKey = 'timer';\n let overviewFilter = null;")
# Update listFor overview so it can show filtered items
old_overview = """ if (key === 'overview') {
let out = [];
if (urgent.count > 0) out.push('🚨 Hastesager: ' + urgent.count + ' aktive');
if (mail.unread > 0) out.push('📧 Ubesvarede mails: ' + mail.unread + ' (' + mail.customer_reply_needed + ' kræver svar)');
if (cases.open > 0) out.push('📂 Åbne sager i alt: ' + cases.open);
if (kuma.down > 0) out.push('📉 Uptime Kuma nedetid: ' + kuma.down + ' enheder');
if (eset.incidents > 0) out.push('🔐 ESET incidents: ' + eset.incidents);
if (out.length === 0) {
out.push('🎉 Alt ser grønt ud! Intet kritisk lige nu.');
out.push('👉 Klik på fanerne til venstre for mere info.');
}
return out;
}"""
new_overview = """ if (key === 'overview') {
if (overviewFilter === 'urgent') return urgent.list ? urgent.list.map(u => '🚨 Hastesag: ' + u.title) : ['Ingen hastesager.'];
if (overviewFilter === 'kuma') return kuma.list ? kuma.list.map(k => '📉 Nede: ' + k) : ['Alle systemer oppe.'];
if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '🔐 Incident: ' + e) : ['Ingen ESET incidents.'];
if (overviewFilter === 'cases') return cases.list ? cases.list.map(c => '📂 ' + c.title) : ['Ingen åbne sager.'];
if (overviewFilter === 'mail') return ['📧 ' + mail.unread + ' ulæste mails. ' + mail.customer_reply_needed + ' kundesvar krævet.'];
let out = [];
if (urgent.count > 0) out.push('🚨 Hastesager: ' + urgent.count + ' aktive');
if (mail.unread > 0) out.push('📧 Ubesvarede mails: ' + mail.unread + ' (' + mail.customer_reply_needed + ' kræver svar)');
if (cases.open > 0) out.push('📂 Åbne sager i alt: ' + cases.open);
if (kuma.down > 0) out.push('📉 Uptime Kuma nedetid: ' + kuma.down + ' enheder');
if (eset.incidents > 0) out.push('🔐 ESET incidents: ' + eset.incidents);
if (out.length === 0) {
out.push('🎉 Alt ser grønt ud! Intet kritisk lige nu.');
out.push('👉 Klik på fanerne til venstre for mere info.');
}
return out;
}"""
content = content.replace(old_overview, new_overview)
# Update chip click to set filter
old_chip = """ // Map top chip keys to side tabs if applicable, else 'overview'
let targetTab = 'overview';
if (key === 'timer') targetTab = 'timer';
const tabBtn = document.querySelector('.bb-tab-btn[data-bb-tab="' + targetTab + '"]');
if (tabBtn) tabBtn.click();
});"""
new_chip = """ // Set filter if clicking a generic chip
let targetTab = 'overview';
if (key === 'timer') {
targetTab = 'timer';
overviewFilter = null;
} else if (['urgent', 'kuma', 'eset', 'cases', 'mail'].indexOf(key) !== -1) {
targetTab = 'overview';
overviewFilter = key;
}
const tabBtn = document.querySelector('.bb-tab-btn[data-bb-tab="' + targetTab + '"]');
if (tabBtn) tabBtn.click();
});"""
content = content.replace(old_chip, new_chip)
# Clear filter if a generic side tab is clicked
old_bind_tab = """ buttons[i].addEventListener('click', function () {
for (let j = 0; j < buttons.length; j++) {
buttons[j].classList.remove('is-active');
buttons[j].setAttribute('aria-selected', 'false');
}
this.classList.add('is-active');
this.setAttribute('aria-selected', 'true');
activeKey = this.getAttribute('data-bb-tab');
renderTabPanel();"""
new_bind_tab = """ buttons[i].addEventListener('click', function (e) {
// Clear filter on direct human click of the button, unless we programmatically called click()
if (e.isTrusted) overviewFilter = null;
for (let j = 0; j < buttons.length; j++) {
buttons[j].classList.remove('is-active');
buttons[j].setAttribute('aria-selected', 'false');
}
this.classList.add('is-active');
this.setAttribute('aria-selected', 'true');
activeKey = this.getAttribute('data-bb-tab');
renderTabPanel();"""
content = content.replace(old_bind_tab, new_bind_tab)
with open("static/js/bottom-bar.js", "w") as f:
f.write(content)

92
patch_frontend.py Normal file
View File

@ -0,0 +1,92 @@
import re
with open("app/modules/manual/frontend/views.py", "r") as f:
text = f.read()
# Make sure cache is imported
if "manual_cache" not in text:
text = text.replace("from app.core.database import", "from app.modules.manual.backend.cache import manual_cache\nfrom app.core.database import")
new_func = """async def manual_index(
request: Request,
module: Optional[str] = Query(default=None),
difficulty: Optional[str] = Query(default=None),
tag: Optional[str] = Query(default=None),
search: Optional[str] = Query(default=None),
):
cache_key = f"views:list:{module}:{difficulty}:{tag}:{search}"
cached = manual_cache.get(cache_key)
if cached:
rows, modules, unique_tags = cached
else:
filters = ["deleted_at IS NULL", "status = %s"]
params = ["published"]
# ... logic mapping ...
if module:
filters.append("module = %s")
params.append(module)
if difficulty:
filters.append("difficulty = %s")
params.append(difficulty)
if tag:
filters.append("tags @> %s")
params.append(f'["{tag}"]')
if search:
filters.append("(title ILIKE %s OR content ILIKE %s)")
params.extend([f"%{search}%", f"%{search}%"])
where_clause = " AND ".join(filters)
rows = execute_query(
f"SELECT id, slug, title, content, module, tags, difficulty, use_count, updated_at "
f"FROM manual_articles WHERE {where_clause} ORDER BY updated_at DESC",
tuple(params)
) or []
modules = execute_query(
"SELECT DISTINCT module FROM manual_articles WHERE deleted_at IS NULL ORDER BY module ASC"
) or []
all_tags: List[str] = []
for row in rows:
if "tags" in row and row["tags"]:
try:
import json
if isinstance(row["tags"], str):
t = json.loads(row["tags"])
if isinstance(t, list):
all_tags.extend(t)
elif isinstance(row["tags"], list):
all_tags.extend(row["tags"])
except Exception:
pass
unique_tags = sorted(list(set(all_tags)))
manual_cache.set(cache_key, (rows, modules, unique_tags))
return templates.TemplateResponse(
"modules/manual/templates/list.html",
{
"request": request,
"articles": rows,
"available_modules": modules,
"available_tags": unique_tags,
"filters": {
"module": module or "",
"difficulty": difficulty or "",
"tag": tag or "",
"search": search or "",
},
},
)
"""
text = re.sub(
r'async def manual_index\(.*?return templates\.TemplateResponse\([\s\S]*?\n \)\n',
new_func,
text,
flags=re.DOTALL
)
with open("app/modules/manual/frontend/views.py", "w") as f:
f.write(text)

53
patch_frontend_cache.py Normal file
View File

@ -0,0 +1,53 @@
import re
with open("app/modules/manual/frontend/views.py", "r") as f:
text = f.read()
# Make sure cache is imported
if "manual_cache" not in text:
text = text.replace("from app.core.database import", "from app.modules.manual.backend.cache import manual_cache\nfrom app.core.database import")
# GET /manual
list_patch = """async def manual_index(
request: Request,
module: Optional[str] = Query(default=None),
difficulty: Optional[str] = Query(default=None),
tag: Optional[str] = Query(default=None),
search: Optional[str] = Query(default=None),
):
cache_key = f"views:list:{module}:{difficulty}:{tag}:{search}"
cached = manual_cache.get(cache_key)
if cached:
# FastAPI TemplateResponse returns HTMLResponse which we can't easily cache natively
# But we can cache the context dictionary to avoid DB queries
rows, modules, unique_tags = cached
else:"""
text = re.sub(
r'async def manual_index\([^)]*\):',
list_patch,
text
)
# Need to indent the whole db fetch logic
# Then we save it to cache.
# Alternatively, I can just use a simple python caching decorator in `views.py`. Since we share the manual_cache instance, we can just clear it.
text = text.replace(' return templates.TemplateResponse(', ' manual_cache.set(cache_key, (rows, modules, unique_tags))\n return templates.TemplateResponse(')
# Indenting the execute_query part cleanly using re.sub is hard. Let's do it using Python string replacement
lines = text.split('\n')
in_else = False
new_lines = []
for line in lines:
if 'else:' in line and 'manual_index' not in line: # crude check
if 'rows, modules, unique_tags = cached' in '\n'.join(new_lines[-5:]):
in_else = True
if in_else:
if line.startswith(' return templates.TemplateResponse('):
in_else = False
else:
if line.startswith(' '):
line = ' ' + line
new_lines.append(line)
with open("app/modules/manual/frontend/views.py", "w") as f:
f.write("\n".join(new_lines))

10
patch_hw_help.py Normal file
View File

@ -0,0 +1,10 @@
with open("app/modules/hardware/templates/detail.html", "r") as f:
content = f.read()
target = ' <a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary" style="opacity: 0.8;">'
replacement = ' <button onclick="openManualHelp(\'Hardware\')" class="btn btn-sm btn-outline-info" title="Hjælp til Hardware"><i class="bi bi-question-lg"></i></button>\n' + target
content = content.replace(target, replacement)
with open("app/modules/hardware/templates/detail.html", "w") as f:
f.write(content)

54
patch_js_events.py Normal file
View File

@ -0,0 +1,54 @@
import re
with open("static/js/bottom-bar.js", "r") as f:
content = f.read()
events = """
function bindDynamicActions() {
const listContainer = byId('bbDynamicList');
if (!listContainer) return;
listContainer.addEventListener('click', function (e) {
const target = e.target;
const btn = target.closest('button');
if (!btn) return;
if (btn.id === 'btnNextTask') {
console.log("-> Beder backend om næste opgave...");
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Henter...';
btn.disabled = true;
setTimeout(() => {
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt Sag #8192!';
btn.classList.add('btn-success');
btn.classList.remove('btn-primary');
}, 1000);
}
if (btn.id === 'btnSendMsg') {
const input = byId('chatInputQuick');
if (input && input.value.trim() !== '') {
console.log("-> Sender besked:", input.value);
input.value = '';
const msgDiv = document.createElement('div');
msgDiv.className = 'mb-1 text-end';
msgDiv.innerHTML = '<div class="d-inline-block bg-primary text-white p-2 rounded-3" style="max-width: 80%;"><strong>Mig:</strong> ' + msgDiv.textContent + ' (Mock)</div>';
listContainer.insertBefore(msgDiv, listContainer.lastElementChild);
listContainer.scrollTop = listContainer.scrollHeight;
}
}
});
}
function init() {"""
content = re.sub(r' function init\(\) \{', events, content)
init_bindings = """
bindSideTabs();
bindDynamicActions();
"""
content = re.sub(r' bindSideTabs\(\);', init_bindings, content)
with open("static/js/bottom-bar.js", "w") as f:
f.write(content)

44
patch_js_events2.py Normal file
View File

@ -0,0 +1,44 @@
import re
with open("static/js/bottom-bar.js", "r") as f:
text = f.read()
events = """
function bindDynamicActions() {
const listContainer = byId('bbTabsContent');
if (!listContainer) return;
listContainer.addEventListener('click', function (e) {
const target = e.target;
const btn = target.closest('button');
if (!btn) return;
if (btn.id === 'btnNextTask') {
console.log("-> Beder backend om næste opgave...");
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Omsætter kalender og SLA...';
btn.disabled = true;
fetch('/api/v1/bottom_bar/next_task', { method: 'POST' })
.then(r => r.json())
.then(data => {
const task = data.task;
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + task.title + ' (Sag #' + task.case_id + ') <span class="badge bg-light text-dark ms-2">' + data.free_time_calculated + 'm fri</span>';
btn.classList.add('btn-success');
btn.classList.remove('btn-primary');
})
.catch(err => {
console.error("Fejl:", err);
btn.innerHTML = "Fejl - prøv igen";
btn.disabled = false;
});
}
});
}
document.addEventListener('DOMContentLoaded', function () {"""
text = text.replace(" document.addEventListener('DOMContentLoaded', function () {", events)
text = text.replace(" bindSheetToggle();", " bindSheetToggle();\n bindDynamicActions();")
with open("static/js/bottom-bar.js", "w") as f:
f.write(text)

112
patch_js_icons.py Normal file
View File

@ -0,0 +1,112 @@
with open("static/js/bottom-bar.js", "r") as f:
content = f.read()
# Fix updateBar
old_update_bar = """ function updateBar(sections) {
const counts = getCounts(sections);
const keys = Object.keys(counts);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const chip = document.querySelector('.bb-chip[data-bb-key="' + key + '"]');
if (chip) {
const label = key.charAt(0).toUpperCase() + key.slice(1);
const val = counts[key];
if (key === 'timer') {
chip.textContent = 'Timer: ' + val;
} else if (key === 'calls') {
chip.textContent = 'Opkald: ' + val;
} else if (key === 'mail') {
chip.textContent = 'Mail: ' + val;
} else if (key === 'alerts') {
chip.textContent = 'Alerts: ' + val;
} else {
chip.textContent = 'Reminders: ' + val;
}
chip.classList.toggle('has-items', val > 0);
}
}"""
new_update_bar = """ function updateBar(sections) {
const counts = getCounts(sections);
const keys = Object.keys(counts);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const chipText = document.querySelector('.bb-chip[data-bb-key="' + key + '"] .bb-chip-text');
const chip = document.querySelector('.bb-chip[data-bb-key="' + key + '"]');
if (chipText && chip) {
const val = counts[key];
if (key === 'timer') {
chipText.textContent = 'Timer: ' + val;
} else if (key === 'calls') {
chipText.textContent = 'Opkald: ' + val;
} else if (key === 'mail') {
chipText.textContent = 'Mail: ' + val;
} else if (key === 'alerts') {
chipText.textContent = 'Alerts: ' + val;
} else {
chipText.textContent = 'Reminders: ' + val;
}
chip.classList.toggle('has-items', val > 0);
}
}"""
content = content.replace(old_update_bar, new_update_bar)
# Fix renderTabPanel Title text
old_render_tab = """ function renderTabPanel() {
const title = byId('bbTabTitle');
const list = byId('bbTabList');
if (!title || !list) {
return;
}
const titleByKey = {
timer: 'Timer',
calls: 'Opkald',
mail: 'Mail',
alerts: 'Alerts',
reminders: 'Reminders'
};
title.textContent = (titleByKey[activeKey] || 'Info') + ' info';"""
new_render_tab = """ function renderTabPanel() {
const titleContainer = byId('bbTabTitle');
const list = byId('bbTabList');
if (!titleContainer || !list) {
return;
}
const titleText = titleContainer.querySelector('.bb-tab-title-text');
const titleByKey = {
timer: 'Timer info',
calls: 'Opkald info',
mail: 'Mail info',
alerts: 'Alerts info',
reminders: 'Reminders info'
};
const iconByKey = {
timer: 'bi-clock-history',
calls: 'bi-telephone',
mail: 'bi-envelope',
alerts: 'bi-exclamation-triangle',
reminders: 'bi-bell'
};
if (titleText) {
titleText.textContent = (titleByKey[activeKey] || 'Info');
} else {
titleContainer.textContent = (titleByKey[activeKey] || 'Info');
}
const iconSpan = titleContainer.querySelector('.bi');
if (iconSpan) {
iconSpan.className = 'bi ' + (iconByKey[activeKey] || 'bi-info-circle') + ' me-2 text-accent';
}"""
content = content.replace(old_render_tab, new_render_tab)
with open("static/js/bottom-bar.js", "w") as f:
f.write(content)

182
patch_js_rich_ui.py Normal file
View File

@ -0,0 +1,182 @@
import re
with open("static/js/bottom-bar.js", "r") as f:
content = f.read()
# Replace the innerList rendering logic to handle raw HTML from our templates securely
old_render_tab = """ // Render lists
const lines = listFor(activeKey, latestSections);
const ul = document.createElement('ul');
ul.className = 'bb-tab-list';
lines.forEach(function (line) {
const li = document.createElement('li');
li.innerHTML = String(line)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
ul.appendChild(li);
});
innerContent.innerHTML = '';
innerContent.appendChild(ul);"""
new_render_tab = """ // Render rich UI lists
const lines = listFor(activeKey, latestSections);
const ul = document.createElement('ul');
ul.className = 'bb-tab-list';
lines.forEach(function (line) {
const li = document.createElement('li');
// Allow rich HTML (buttons, inputs) - assuming listFor provides sanitized data wrapped in markup
li.innerHTML = line;
ul.appendChild(li);
});
innerContent.innerHTML = '';
// Add specific headers/controls based on active tab
if (activeKey === 'tasks') {
const topBar = document.createElement('div');
topBar.className = 'bb-task-actions mb-3';
topBar.innerHTML = '<button class="btn btn-primary btn-sm w-100 fw-bold shadow-sm" id="btnNextTask"><i class="bi bi-box-arrow-in-down-right"></i> Giv mig næste opgave</button>';
innerContent.appendChild(topBar);
}
if (activeKey === 'messages') {
const chatContainer = document.createElement('div');
chatContainer.className = 'd-flex flex-column h-100';
ul.classList.add('flex-grow-1', 'mb-3');
const replyBox = document.createElement('div');
replyBox.className = 'input-group mt-2 border-top pt-2 border-primary-subtle';
replyBox.innerHTML = '<input type="text" class="form-control form-control-sm" placeholder="Skriv en besked..."><button class="btn btn-outline-primary btn-sm"><i class="bi bi-send"></i></button>';
chatContainer.appendChild(ul);
chatContainer.appendChild(replyBox);
innerContent.appendChild(chatContainer);
} else {
innerContent.appendChild(ul);
}"""
content = content.replace(old_render_tab, new_render_tab)
# Now enhance the listFor content with rich HTML strings
old_listFor = """ if (key === 'overview') {
if (overviewFilter === 'urgent') return urgent.list ? urgent.list.map(u => '🚨 Hastesag: ' + u.title) : ['Ingen hastesager.'];
if (overviewFilter === 'kuma') return kuma.list ? kuma.list.map(k => '📉 Nede: ' + k) : ['Alle systemer oppe.'];
if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '🔐 Incident: ' + e) : ['Ingen ESET incidents.'];
if (overviewFilter === 'cases') return cases.list ? cases.list.map(c => '📂 ' + c.title) : ['Ingen åbne sager.'];
if (overviewFilter === 'mail') return ['📧 ' + mail.unread + ' ulæste mails. ' + mail.customer_reply_needed + ' kundesvar krævet.'];
let out = [];
if (urgent.count > 0) out.push('🚨 Hastesager: ' + urgent.count + ' aktive');
if (mail.unread > 0) out.push('📧 Ubesvarede mails: ' + mail.unread + ' (' + mail.customer_reply_needed + ' kræver svar)');
if (cases.open > 0) out.push('📂 Åbne sager i alt: ' + cases.open);
if (kuma.down > 0) out.push('📉 Uptime Kuma nedetid: ' + kuma.down + ' enheder');
if (eset.incidents > 0) out.push('🔐 ESET incidents: ' + eset.incidents);
if (out.length === 0) {
out.push('🎉 Alt ser grønt ud! Intet kritisk lige nu.');
out.push('👉 Klik på fanerne til venstre for mere info.');
}
return out;
}
if (key === 'timer') {
if (timer.active_count > 0) {
return (timer.list || []).map(t => 'Timer aktiv: ' + t.description);
}
return ['Ingen aktive timere lige nu.'];
}
if (key === 'messages') {
if (messages.count > 0) {
return (messages.list || []).map(m => m.from + ': ' + m.text);
}
return ['Ingen nye beskeder.'];
}
if (key === 'tasks') {
if (tasks.count > 0) {
return (tasks.list || []).map(t => t.title + ' (Deadline: ' + t.deadline + ')');
}
return ['Ingen aktuelle opgaver.'];
}
if (key === 'boss') {
if (boss.stats) {
return [
'Ufordelte opgaver: ' + boss.stats.unassigned,
'Medarbejdere aktive: ' + boss.stats.active_employees
];
}
return ['Henter chef-overblik...'];
}
return ['Klik rundt i menuen for at se data for ' + key];"""
new_listFor = """ function esc(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
if (key === 'overview') {
if (overviewFilter === 'urgent') return urgent.list ? urgent.list.map(u => '<div><strong class="text-danger"><i class="bi bi-exclamation-octagon"></i> Hastesag:</strong> ' + esc(u.title) + ' <br><button class="btn btn-sm btn-outline-danger mt-2">Vis sag</button></div>') : ['Ingen hastesager.'];
if (overviewFilter === 'kuma') return kuma.list ? kuma.list.map(k => '<div class="d-flex justify-content-between align-items-center"><span>📉 ' + esc(k) + '</span> <div><button class="btn btn-sm btn-outline-primary me-1">Opret Sag</button> <button class="btn btn-sm btn-outline-secondary">Ignorer</button></div></div>') : ['Alle systemer oppe.'];
if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '<div class="d-flex justify-content-between align-items-center"><span>🔐 ' + esc(e) + '</span> <button class="btn btn-sm btn-outline-primary">Håndter</button></div>') : ['Ingen ESET incidents.'];
if (overviewFilter === 'cases') return cases.list ? cases.list.map(c => '<div><i class="bi bi-folder2-open text-primary"></i> ' + esc(c.title) + '</div>') : ['Ingen åbne sager.'];
if (overviewFilter === 'mail') return ['<div>📧 <strong>' + mail.unread + '</strong> ulæste mails. <br>💬 <strong>' + mail.customer_reply_needed + '</strong> kræver kundesvar. <button class="btn btn-sm btn-outline-primary mt-2">Åbn indbakke</button></div>'];
let out = [];
if (urgent.count > 0) out.push('<div><i class="bi bi-exclamation-octagon text-danger"></i> Hastesager: <strong>' + urgent.count + '</strong> aktive</div>');
if (mail.unread > 0) out.push('<div><i class="bi bi-envelope text-primary"></i> Ubesvarede mails: <strong>' + mail.unread + '</strong></div>');
if (cases.open > 0) out.push('<div><i class="bi bi-folder2-open text-primary"></i> Åbne sager i alt: <strong>' + cases.open + '</strong></div>');
if (kuma.down > 0) out.push('<div><i class="bi bi-activity text-warning"></i> Uptime Kuma nedetid: <strong>' + kuma.down + '</strong> enheder</div>');
if (eset.incidents > 0) out.push('<div><i class="bi bi-shield-lock text-danger"></i> ESET incidents: <strong>' + eset.incidents + '</strong></div>');
if (out.length === 0) {
out.push('<div>🎉 Alt ser grønt ud! Intet kritisk lige nu.</div>');
}
// Add quick note button on overview
out.push('<div class="mt-3 pt-3 border-top"><div class="input-group"><input type="text" class="form-control form-control-sm" placeholder="Skriv en quick note..."><button class="btn btn-outline-secondary btn-sm"><i class="bi bi-pencil"></i> Gem Note</button></div></div>');
return out;
}
if (key === 'timer') {
if (timer.active_count > 0) {
return (timer.list || []).map(t => '<div class="d-flex justify-content-between align-items-center"><span><i class="bi bi-stopwatch text-success"></i> ' + esc(t.desc) + ' (' + esc(t.elapsed) + 's)</span> <button class="btn btn-sm btn-danger"><i class="bi bi-stop-fill"></i> Stop</button></div>');
}
return ['Ingen aktive timere lige nu.'];
}
if (key === 'messages') {
if (messages.count > 0) {
return (messages.list || []).map(m => '<div><strong class="' + (m.from === 'System' ? 'text-primary' : 'text-accent') + '">' + esc(m.from) + ':</strong> ' + esc(m.text) + '</div>');
}
return ['Ingen nye beskeder.'];
}
if (key === 'tasks') {
if (tasks.count > 0) {
return (tasks.list || []).map(t => '<div><i class="bi bi-calendar-check text-success"></i> <strong>' + esc(t.title) + '</strong> <span class="badge bg-secondary ms-2">' + esc(t.deadline) + '</span></div>');
}
return ['Ingen aktuelle opgaver.'];
}
if (key === 'boss') {
if (boss.stats) {
return [
'<div class="d-flex justify-content-between"><span class="text-secondary">Ufordelte opgaver:</span> <strong>' + boss.stats.unassigned + '</strong></div>',
'<div class="d-flex justify-content-between"><span class="text-secondary">Medarbejdere aktive:</span> <strong>' + boss.stats.active_employees + '</strong></div>',
'<button class="btn btn-sm btn-outline-primary w-100 mt-2"><i class="bi bi-shuffle"></i> Omfordel Opgaver</button>'
];
}
return ['Henter chef-overblik...'];
}
return ['Klik rundt i menuen for at se data.'];"""
content = content.replace(old_listFor, new_listFor)
with open("static/js/bottom-bar.js", "w") as f:
f.write(content)

457
patch_lækker.py Normal file
View File

@ -0,0 +1,457 @@
import re
with open("app/shared/frontend/base.html", "r") as f:
content = f.read()
# Update root variables with RGB versions for glassmorphism
if "--bg-card-rgb" not in content:
content = content.replace(
"--bg-card: #ffffff;",
"--bg-card: #ffffff;\n --bg-card-rgb: 255, 255, 255;"
)
content = content.replace(
"--bg-card: #2c3034;",
"--bg-card: #2c3034;\n --bg-card-rgb: 44, 48, 52;"
)
# Update CSS for .global-bottom-bar
old_bottom_bar_css = """ .global-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--bottom-bar-zindex);
background: var(--bg-card);
border-top: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.06);
min-height: var(--bottom-bar-height);
padding: 0.35rem 1rem calc(0.35rem + env(safe-area-inset-bottom, 0px));
transform: translateY(calc(100% + 12px));
opacity: 0;
transition: transform 0.3s ease, opacity 0.25s ease;
}"""
new_bottom_bar_css = """ .global-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--bottom-bar-zindex);
background: rgba(var(--bg-card-rgb), 0.85); /* Glassmorphism */
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(var(--text-primary-rgb), 0.1);
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.08);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
min-height: var(--bottom-bar-height);
padding: 0.5rem 1rem calc(0.5rem + env(safe-area-inset-bottom, 0px));
transform: translateY(calc(100% + 12px));
opacity: 0;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
}"""
content = content.replace(old_bottom_bar_css, new_bottom_bar_css)
# Ensure --text-primary-rgb exists
if "--text-primary-rgb" not in content:
content = content.replace(
"--text-primary: #2c3e50;",
"--text-primary: #2c3e50;\n --text-primary-rgb: 44, 62, 80;"
)
content = content.replace(
"--text-primary: #f8f9fa;",
"--text-primary: #f8f9fa;\n --text-primary-rgb: 248, 249, 250;"
)
# Fix Count Line CSS
old_count_line_css = """ .global-bottom-bar .bb-count-line {
flex: 1;
background: var(--accent-light);
color: var(--text-primary);
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.08);
padding: 0.45rem 0.75rem;
font-size: 0.84rem;
font-weight: 600;
line-height: 1.35;
white-space: nowrap;
overflow-x: auto;
scrollbar-width: thin;
display: flex;
align-items: center;
gap: 0.4rem;
}"""
new_count_line_css = """ .global-bottom-bar .bb-count-line {
flex: 1;
background: transparent;
color: var(--text-primary);
padding: 0 0.25rem;
font-size: 0.84rem;
font-weight: 600;
line-height: 1.35;
white-space: nowrap;
overflow-x: auto;
scrollbar-width: none; /* Cleaner look without scrollbar */
display: flex;
align-items: center;
gap: 0.6rem;
}
.global-bottom-bar .bb-count-line::-webkit-scrollbar {
display: none;
}"""
content = content.replace(old_count_line_css, new_count_line_css)
# Fix Chip CSS
old_chip_css = """ .global-bottom-bar .bb-chip {
border: 1px solid rgba(0, 0, 0, 0.1);
background: var(--bg-card);
color: var(--text-primary);
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.79rem;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
flex: 0 0 auto;
}"""
new_chip_css = """ .global-bottom-bar .bb-chip {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
background: var(--accent-light);
color: var(--text-primary);
border-radius: 999px;
padding: 0.35rem 0.85rem;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.35rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
transition: all 0.2s ease;
}"""
content = content.replace(old_chip_css, new_chip_css)
old_chip_hover = """ .global-bottom-bar .bb-chip:hover,
.global-bottom-bar .bb-chip.is-active {
border-color: var(--accent);
color: var(--accent);
}"""
new_chip_hover = """ .global-bottom-bar .bb-chip:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0,0,0,0.08);
border-color: rgba(var(--text-primary-rgb), 0.2);
}
.global-bottom-bar .bb-chip.is-active {
background: var(--accent);
color: white;
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.is-active {
color: #fff;
}"""
content = content.replace(old_chip_hover, new_chip_hover)
# Toggle styling
old_toggle_css = """ .global-bottom-bar .bb-sheet-toggle {
border: 1px solid rgba(0, 0, 0, 0.1);
background: var(--accent-light);
color: var(--text-primary);
border-radius: 10px;
padding: 0.42rem 0.7rem;
font-size: 0.82rem;
font-weight: 700;
line-height: 1.2;
display: inline-flex;
align-items: center;
gap: 0.45rem;
}"""
new_toggle_css = """ .global-bottom-bar .bb-sheet-toggle {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
background: transparent;
color: var(--text-primary);
border-radius: 50%;
width: 36px;
height: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
transition: all 0.2s ease;
}
.global-bottom-bar .bb-sheet-toggle:hover {
background: var(--accent-light);
transform: translateY(-1px);
}
.global-bottom-bar .bb-sheet-toggle span { display: none; }"""
content = content.replace(old_toggle_css, new_toggle_css)
# Adjust inner sheet panel styling
old_sheet_inner = """ .global-bottom-bar .bb-sheet-inner {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
min-height: 210px;
max-height: min(50vh, 420px);
overflow: hidden;
}"""
new_sheet_inner = """ .global-bottom-bar .bb-sheet-inner {
background: var(--bg-card);
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
border-radius: 14px;
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
min-height: 240px;
max-height: min(52vh, 420px);
overflow: hidden;
box-shadow: inset 0 2px 10px rgba(0,0,0,0.02);
margin-top: 0.5rem;
}"""
content = content.replace(old_sheet_inner, new_sheet_inner)
old_side_tabs = """ .global-bottom-bar .bb-side-tabs {
border-right: 1px solid rgba(0, 0, 0, 0.08);
background: var(--accent-light);
padding: 0.45rem;
display: grid;
gap: 0.35rem;
align-content: start;
}"""
new_side_tabs = """ .global-bottom-bar .bb-side-tabs {
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
background: rgba(var(--text-primary-rgb), 0.03);
padding: 0.75rem 0.5rem;
display: grid;
gap: 0.4rem;
align-content: start;
}"""
content = content.replace(old_side_tabs, new_side_tabs)
old_tab_btn = """ .global-bottom-bar .bb-tab-btn {
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
border-radius: 8px;
text-align: left;
font-size: 0.82rem;
font-weight: 700;
padding: 0.35rem 0.5rem;
line-height: 1.2;
}
.global-bottom-bar .bb-tab-btn.is-active {
border-color: var(--accent);
color: var(--accent);
background: var(--bg-card);
}"""
new_tab_btn = """ .global-bottom-bar .bb-tab-btn {
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
border-radius: 8px;
text-align: left;
font-size: 0.85rem;
font-weight: 600;
padding: 0.5rem 0.75rem;
line-height: 1.3;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.global-bottom-bar .bb-tab-btn i {
font-size: 1rem;
opacity: 0.7;
}
.global-bottom-bar .bb-tab-btn:hover {
background: rgba(var(--text-primary-rgb), 0.05);
color: var(--text-primary);
}
.global-bottom-bar .bb-tab-btn.is-active {
background: var(--bg-card);
color: var(--accent);
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
font-weight: 700;
}
.global-bottom-bar .bb-tab-btn.is-active i {
opacity: 1;
color: var(--accent);
}"""
content = content.replace(old_tab_btn, new_tab_btn)
old_tab_content = """ .global-bottom-bar .bb-tab-content {
padding: 0.65rem 0.8rem;
overflow: auto;
}
.global-bottom-bar .bb-tab-title {
font-size: 0.9rem;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 0.45rem;
}
.global-bottom-bar .bb-tab-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.global-bottom-bar .bb-tab-list li {
border-left: 3px solid var(--accent);
background: var(--accent-light);
border-radius: 6px;
padding: 0.38rem 0.55rem;
font-size: 0.81rem;
line-height: 1.3;
color: var(--text-primary);
}"""
new_tab_content = """ .global-bottom-bar .bb-tab-content {
padding: 1.2rem;
overflow: auto;
}
.global-bottom-bar .bb-tab-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.global-bottom-bar .bb-tab-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.global-bottom-bar .bb-tab-list li {
border-left: 4px solid var(--accent);
background: var(--accent-light);
border-radius: 6px 8px 8px 6px;
padding: 0.75rem 1rem;
font-size: 0.88rem;
line-height: 1.4;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
transition: transform 0.2s ease;
}
.global-bottom-bar .bb-tab-list li:hover {
transform: translateX(2px);
}"""
content = content.replace(old_tab_content, new_tab_content)
old_detail_line = """ .global-bottom-bar .bb-detail-line {
margin-top: 0.35rem;
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
color: var(--text-secondary);
white-space: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}"""
new_detail_line = """ .global-bottom-bar .bb-detail-line {
margin-top: 0.5rem;
background: transparent;
padding: 0 0.5rem;
font-size: 0.82rem;
color: var(--text-secondary);
white-space: nowrap;
overflow-x: auto;
scrollbar-width: none;
transition: opacity 0.2s ease;
}
.global-bottom-bar.is-expanded .bb-detail-line {
opacity: 0;
pointer-events: none;
position: absolute;
}"""
content = content.replace(old_detail_line, new_detail_line)
# Now fix the markup icons
old_markup = """<div id="globalBottomBar" class="global-bottom-bar" hidden>
<div class="bb-header">
<div id="bbCountLine" class="bb-count-line" role="status" aria-live="polite">
<button class="bb-chip" type="button" data-bb-key="timer">Timer: 0</button>
<button class="bb-chip" type="button" data-bb-key="calls">Opkald: 0</button>
<button class="bb-chip" type="button" data-bb-key="mail">Mail: 0</button>
<button class="bb-chip" type="button" data-bb-key="alerts">Alerts: 0</button>
<button class="bb-chip" type="button" data-bb-key="reminders">Reminders: 0</button>
</div>
<button id="bbSheetToggle" class="bb-sheet-toggle" type="button" aria-expanded="false" aria-controls="bbSheetPanel">
Info
<i class="bi bi-chevron-up" aria-hidden="true"></i>
</button>
</div>
<div id="bbCountDetail" class="bb-detail-line" role="status" aria-live="polite">Klik pa en kategori for detaljer</div>
<div id="bbSheetPanel" class="bb-sheet-panel" aria-hidden="true">
<div class="bb-sheet-inner">
<div class="bb-side-tabs" role="tablist" aria-label="Bundbar kategorier">
<button class="bb-tab-btn is-active" type="button" data-bb-tab="timer" role="tab" aria-selected="true">Timer</button>
<button class="bb-tab-btn" type="button" data-bb-tab="calls" role="tab" aria-selected="false">Opkald</button>
<button class="bb-tab-btn" type="button" data-bb-tab="mail" role="tab" aria-selected="false">Mail</button>
<button class="bb-tab-btn" type="button" data-bb-tab="alerts" role="tab" aria-selected="false">Alerts</button>
<button class="bb-tab-btn" type="button" data-bb-tab="reminders" role="tab" aria-selected="false">Reminders</button>
</div>
<div class="bb-tab-content" role="tabpanel" aria-live="polite">
<div id="bbTabTitle" class="bb-tab-title">Timer</div>
<ul id="bbTabList" class="bb-tab-list">
<li>Venter pa data...</li>
</ul>
</div>
</div>
</div>
</div>"""
new_markup = """<div id="globalBottomBar" class="global-bottom-bar" hidden>
<div class="bb-header">
<div id="bbCountLine" class="bb-count-line" role="status" aria-live="polite">
<button class="bb-chip" type="button" data-bb-key="timer"><i class="bi bi-clock-history"></i> <span class="bb-chip-text">Timer: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="calls"><i class="bi bi-telephone"></i> <span class="bb-chip-text">Opkald: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-text">Mail: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="alerts"><i class="bi bi-exclamation-triangle"></i> <span class="bb-chip-text">Alerts: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="reminders"><i class="bi bi-bell"></i> <span class="bb-chip-text">Reminders: 0</span></button>
</div>
<button id="bbSheetToggle" class="bb-sheet-toggle" type="button" aria-expanded="false" aria-controls="bbSheetPanel" aria-label="Toggle detaljer">
<span>Info</span>
<i class="bi bi-chevron-up" aria-hidden="true"></i>
</button>
</div>
<div id="bbCountDetail" class="bb-detail-line" role="status" aria-live="polite"><i class="bi bi-info-circle me-1 opacity-75"></i> Klik en kategori for at se detaljer</div>
<div id="bbSheetPanel" class="bb-sheet-panel" aria-hidden="true">
<div class="bb-sheet-inner">
<div class="bb-side-tabs" role="tablist" aria-label="Bundbar kategorier">
<button class="bb-tab-btn is-active" type="button" data-bb-tab="timer" role="tab" aria-selected="true"><i class="bi bi-clock-history"></i> Timer</button>
<button class="bb-tab-btn" type="button" data-bb-tab="calls" role="tab" aria-selected="false"><i class="bi bi-telephone"></i> Opkald</button>
<button class="bb-tab-btn" type="button" data-bb-tab="mail" role="tab" aria-selected="false"><i class="bi bi-envelope"></i> Mail</button>
<button class="bb-tab-btn" type="button" data-bb-tab="alerts" role="tab" aria-selected="false"><i class="bi bi-exclamation-triangle"></i> Alerts</button>
<button class="bb-tab-btn" type="button" data-bb-tab="reminders" role="tab" aria-selected="false"><i class="bi bi-bell"></i> Reminders</button>
</div>
<div class="bb-tab-content" role="tabpanel" aria-live="polite">
<div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-clock-history me-1 text-accent"></i> <span class="bb-tab-title-text">Timer</span></div>
<ul id="bbTabList" class="bb-tab-list">
<li>Venter data...</li>
</ul>
</div>
</div>
</div>
</div>"""
content = content.replace(old_markup, new_markup)
with open("app/shared/frontend/base.html", "w") as f:
f.write(content)

10
patch_mail_help.py Normal file
View File

@ -0,0 +1,10 @@
with open("app/emails/frontend/emails.html", "r") as f:
content = f.read()
target = ' <button class="btn btn-outline-primary" onclick="openWorkflowManager()" title="Workflow Management">\n <i class="bi bi-diagram-3"></i>\n </button>'
replacement = target + ' <button class="btn btn-outline-info rounded-circle px-2 ms-2" onclick="openManualHelp(\'Mail\')" title="Hjælp til Mail">\n <i class="bi bi-question-lg"></i>\n </button>'
content = content.replace(target, replacement)
with open("app/emails/frontend/emails.html", "w") as f:
f.write(content)

95
patch_manual_backend.py Normal file
View File

@ -0,0 +1,95 @@
import re
with open("app/modules/manual/backend/router.py", "r") as f:
text = f.read()
# Make sure to import cache
if "from app.modules.manual.backend.cache import manual_cache" not in text:
text = text.replace("from app.core.database import", "from app.modules.manual.backend.cache import manual_cache\nfrom app.core.database import")
# Patch GET list:
# It has def get_manual_articles( ... ) -> Dict[str, Any]:
# Need to inject at the top of that function.
patch_list = """async def get_manual_articles(
module: Optional[str] = Query(None, description="Filtrer på modul"),
difficulty: Optional[DifficultyType] = Query(None, description="Filtrer på sværhedsgrad"),
tag: Optional[str] = Query(None, description="Filtrer på et bestemt tag"),
search: Optional[str] = Query(None, description="Søg i titel, summary og content"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> Dict[str, Any]:
cache_key = f"list:{module}:{difficulty}:{tag}:{search}:{limit}:{offset}"
cached = manual_cache.get(cache_key)
if cached:
return cached"""
text = re.sub(
r'async def get_manual_articles\([^)]*\)\s*->\s*Dict\[str, Any\]:',
patch_list,
text
)
# And at the end of get_manual_articles:
# return {"items": rows, "total": total}
# Replace with setting cache
text = text.replace(
'return {"items": rows, "total": total}',
'result = {"items": rows, "total": total}\n manual_cache.set(cache_key, result)\n return result'
)
# Patch Context:
patch_context = """async def contextual_manual_suggestions(
module: str = Query(..., description="Modulet der søges hjælp ud fra, fx 'Sag'"),
tag: Optional[str] = Query(None, description="Kategori eller tag fra konteksten"),
limit: int = Query(5, ge=1, le=10)
):
cache_key = f"context:{module}:{tag}:{limit}"
cached = manual_cache.get(cache_key)
if cached:
return cached"""
text = re.sub(
r'async def contextual_manual_suggestions\([^)]*\):',
patch_context,
text
)
# return {"results": rows}
text = text.replace(
'return {"results": rows}',
'result = {"results": rows}\n manual_cache.set(cache_key, result)\n return result'
)
# Patch Get Slug:
patch_slug = """async def get_manual_article(slug: str, background_tasks: BackgroundTasks):
cache_key = f"slug:{slug}"
cached = manual_cache.get(cache_key)
if cached:
background_tasks.add_task(_increment_use_count, cached["id"])
return cached"""
text = re.sub(
r'async def get_manual_article\(slug: str.*?BackgroundTasks[^)]*\):',
patch_slug,
text
)
text = re.sub(
r'return \{\s*"article": article,\s*"steps": steps,\s*"relations": related\s*\}',
'result = {"article": article, "steps": steps, "relations": related}\n manual_cache.set(cache_key, result)\n return result',
text
)
# Invalidation:
mutations = ['async def create_manual_article', 'async def update_manual_article', 'async def delete_manual_article']
for m in mutations:
text = text.replace(m, "@manual_cache.clear\n" + m)
# Actually, the python decorator is not made in cache.py, let me just add manual_cache.clear() inside them instead.
text = text.replace('@manual_cache.clear\n', '')
text = text.replace('return {"status": "success"', 'manual_cache.clear()\n return {"status": "success"')
text = text.replace('return {"status": "created"', 'manual_cache.clear()\n return {"status": "created"')
with open("app/modules/manual/backend/router.py", "w") as f:
f.write(text)

37
patch_next_task.py Normal file
View File

@ -0,0 +1,37 @@
import re
with open("static/js/bottom-bar.js", "r") as f:
text = f.read()
replacement = """
if (btn.id === 'btnNextTask') {
console.log("-> Beder backend om næste opgave...");
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Omsætter kalender og SLA...';
btn.disabled = true;
fetch('/api/v1/bottom_bar/next_task', {
method: 'POST'
})
.then(r => r.json())
.then(data => {
const task = data.task;
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + task.title + ' (Sag #' + task.case_id + ')';
btn.classList.add('btn-success');
btn.classList.remove('btn-primary');
// Notificer bruger ift kalender
console.log("Routing besked:", data.message);
alert("Opgave allokeret. " + data.message);
})
.catch(err => {
console.error("Fejl:", err);
btn.innerHTML = "Fejl - prøv igen";
btn.disabled = false;
});
}
"""
text = re.sub(r'if \(btn\.id === \'btnNextTask\'\) \{.*?btn\.classList\.remove\(\'btn-primary\'\);\n \}, 1000\);\n \}', replacement.strip(), text, flags=re.DOTALL)
with open("static/js/bottom-bar.js", "w") as f:
f.write(text)

27
patch_overview_logic.py Normal file
View File

@ -0,0 +1,27 @@
with open("static/js/bottom-bar.js", "r") as f:
content = f.read()
old_overview = """ if (key === 'overview') {
return [
'Velkommen til dit overblik',
'Her vises det vigtigste på tværs af systemet',
'Næste opgave kl. 14:00'
];
}"""
new_overview = """ if (key === 'overview') {
let out = [];
if (urgent.count > 0) out.push('🚨 Hastesager: ' + urgent.count + ' aktive');
if (mail.unread > 0) out.push('📧 Ubesvarede mails: ' + mail.unread + ' (' + mail.customer_reply_needed + ' kræver svar)');
if (cases.open > 0) out.push('📂 Åbne sager i alt: ' + cases.open);
if (kuma.down > 0) out.push('📉 Uptime Kuma nedetid: ' + kuma.down + ' enheder');
if (eset.incidents > 0) out.push('🔐 ESET incidents: ' + eset.incidents);
if (out.length === 0) {
out.push('🎉 Alt ser grønt ud! Intet kritisk lige nu.');
out.push('👉 Klik på fanerne til venstre for mere info.');
}
return out;
}"""
content = content.replace(old_overview, new_overview)
with open("static/js/bottom-bar.js", "w") as f:
f.write(content)

10
patch_sag_help.py Normal file
View File

@ -0,0 +1,10 @@
with open("app/modules/sag/templates/detail.html", "r") as f:
content = f.read()
target = '<button class="btn btn-sm btn-link text-muted p-0 mb-1" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>'
replacement = target + '\n <button class="btn btn-sm btn-link text-info p-0 mb-1 ms-2" onclick="openManualHelp(\'Sag\')" title="Hjælp til sagsbehandling"><i class="bi bi-question-circle fs-5"></i></button>'
content = content.replace(target, replacement)
with open("app/modules/sag/templates/detail.html", "w") as f:
f.write(content)

22
patch_urls.py Normal file
View File

@ -0,0 +1,22 @@
import re
# Fix static JS calls
with open("static/js/bottom-bar.js", "r") as f:
text = f.read()
text = text.replace("/api/v1/bottom_bar/next_task", "/api/v1/bottom-bar/next_task")
with open("static/js/bottom-bar.js", "w") as f:
f.write(text)
# Fix main.py prefix
with open("main.py", "r") as f:
text = f.read()
# Replace prefix
text = re.sub(r'app\.include_router\(bottom_bar_api\.router, prefix="/api/v1", tags=\["Bottom Bar"\]\)',
'app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])',
text)
with open("main.py", "w") as f:
f.write(text)

40
patch_views_fastapi.py Normal file
View File

@ -0,0 +1,40 @@
import re
with open("app/modules/manual/frontend/views.py", "r") as f:
content = f.read()
# 1. Ensure BackgroundTasks is imported
if "from fastapi import" in content and "BackgroundTasks" not in content:
content = content.replace("from fastapi import ", "from fastapi import BackgroundTasks, ")
# 2. Add BackgroundTasks to the manual_detail function signature
target_def = "async def manual_detail(request: Request, slug: str):"
new_def = "async def manual_detail(request: Request, slug: str, background_tasks: BackgroundTasks):"
content = content.replace(target_def, new_def)
# 3. Define the background method if not existing
bg_def = """
def _increment_use_count(manual_id: str):
try:
execute_query(
"UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s",
(manual_id,)
)
except Exception as e:
logger.error(f"Failed to increment use_count for manual {manual_id}: {e}")
@router.get("/manual/{slug}", response_class=HTMLResponse)
"""
content = content.replace('@router.get("/manual/{slug}", response_class=HTMLResponse)', bg_def)
# 4. Replace the execute_query in the endpoint
target_inc = """ execute_query(
"UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s",
(article["id"],),
)"""
new_inc = """ # Increment view async to save latency
background_tasks.add_task(_increment_use_count, article["id"])"""
content = content.replace(target_inc, new_inc)
with open("app/modules/manual/frontend/views.py", "w") as f:
f.write(content)

264
rewrite_bottom_bar.py Normal file
View File

@ -0,0 +1,264 @@
with open("static/js/bottom-bar.js", "r") as f:
content = f.read()
# We replace everything from getCounts downwards.
# Let's find the start of getCounts
start_idx = content.find("function getCounts")
new_logic = """function getCounts(sections) {
const mail = sections.mail || {};
const cases = sections.cases || {};
const urgent = sections.urgent || {};
const timer = sections.timer || {};
const kuma = sections.kuma || {};
const eset = sections.eset || {};
return {
mail: Number(mail.unread || 0),
cases: Number(cases.open || 0),
urgent: Number(urgent.count || 0),
timer: Number(timer.active_count || 0),
kuma: Number(kuma.down || 0),
eset: Number(eset.incidents || 0)
};
}
function detailTextFor(key, sections) {
const counts = getCounts(sections);
const nameMap = {
mail: 'Ubesvarede mails',
cases: 'Åbne sager',
urgent: 'Hastesager',
timer: 'Aktive timere',
kuma: 'Kuma alerts',
eset: 'ESET incidents'
};
const val = counts[key] || 0;
return nameMap[key] + ': ' + val;
}
function listFor(key, sections) {
const mail = sections.mail || {};
const cases = sections.cases || {};
const urgent = sections.urgent || {};
const timer = sections.timer || {};
const kuma = sections.kuma || {};
const eset = sections.eset || {};
const messages = sections.messages || {};
const tasks = sections.tasks || {};
const boss = sections.boss || {};
if (key === 'overview') {
return [
'Velkommen til dit overblik',
'Her vises det vigtigste på tværs af systemet',
'Næste opgave kl. 14:00'
];
}
if (key === 'timer') {
if (timer.active_count > 0) {
return (timer.list || []).map(t => 'Timer aktiv: ' + t.description);
}
return ['Ingen aktive timere lige nu.'];
}
if (key === 'messages') {
if (messages.count > 0) {
return (messages.list || []).map(m => m.from + ': ' + m.text);
}
return ['Ingen nye beskeder.'];
}
if (key === 'tasks') {
if (tasks.count > 0) {
return (tasks.list || []).map(t => t.title + ' (Deadline: ' + t.deadline + ')');
}
return ['Ingen aktuelle opgaver.'];
}
if (key === 'boss') {
if (boss.stats) {
return [
'Ufordelte opgaver: ' + boss.stats.unassigned,
'Medarbejdere aktive: ' + boss.stats.active_employees
];
}
return ['Henter chef-overblik...'];
}
return ['Klik rundt i menuen for at se data for ' + key];
}
function updateBar(sections) {
const counts = getCounts(sections);
const keys = Object.keys(counts);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const chipText = document.querySelector('.bb-chip[data-bb-key="' + key + '"] .bb-chip-text');
const chip = document.querySelector('.bb-chip[data-bb-key="' + key + '"]');
if (chipText && chip) {
const val = counts[key];
const labels = {
mail: 'Mails',
cases: 'Sager',
urgent: 'Hastesager',
timer: 'Timere',
kuma: 'Kuma',
eset: 'ESET'
};
chipText.textContent = labels[key] + ': ' + val;
chip.classList.toggle('has-items', val > 0);
}
}
}
function renderTabPanel() {
const titleContainer = byId('bbTabTitle');
const innerContent = byId('bbTabInnerContent');
if (!titleContainer || !innerContent) {
return;
}
const titleText = titleContainer.querySelector('.bb-tab-title-text');
const titleByKey = {
overview: 'Overblik',
timer: 'Timere',
messages: 'Beskeder',
tasks: 'Opgaver',
boss: 'Chef Dashboard'
};
const iconByKey = {
overview: 'bi-bell',
timer: 'bi-stopwatch',
messages: 'bi-chat-dots',
tasks: 'bi-calendar-check',
boss: 'bi-person-workspace'
};
const activeTitle = titleByKey[activeKey] || 'Info';
if (titleText) {
titleText.textContent = activeTitle;
} else {
titleContainer.textContent = activeTitle;
}
const iconSpan = titleContainer.querySelector('.bi');
if (iconSpan) {
iconSpan.className = 'bi ' + (iconByKey[activeKey] || 'bi-info-circle') + ' me-2 text-accent';
}
// Render lists
const lines = listFor(activeKey, latestSections);
const ul = document.createElement('ul');
ul.className = 'bb-tab-list';
lines.forEach(function (line) {
const li = document.createElement('li');
li.innerHTML = String(line)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
ul.appendChild(li);
});
innerContent.innerHTML = '';
innerContent.appendChild(ul);
}
function bindSideTabs() {
const buttons = document.querySelectorAll('.bb-tab-btn');
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function () {
for (let j = 0; j < buttons.length; j++) {
buttons[j].classList.remove('is-active');
buttons[j].setAttribute('aria-selected', 'false');
}
this.classList.add('is-active');
this.setAttribute('aria-selected', 'true');
activeKey = this.getAttribute('data-bb-tab');
renderTabPanel();
const detail = byId('bbCountDetail');
if (detail) {
detail.innerHTML = '<i class="bi bi-info-circle me-1 opacity-75"></i> Viser: ' + (activeKey.charAt(0).toUpperCase() + activeKey.slice(1));
}
});
}
}
function bindChipClicks() {
const chips = document.querySelectorAll('.bb-chip');
for (let i = 0; i < chips.length; i++) {
chips[i].addEventListener('click', function () {
const key = this.getAttribute('data-bb-key');
if (!key) return;
const detail = byId('bbCountDetail');
if (detail) {
detail.innerHTML = '<i class="bi bi-check-circle me-1 text-accent"></i> ' + detailTextFor(key, latestSections);
}
// If not expanded, expand it
const shell = byId('globalBottomBar');
if (shell && !shell.classList.contains('is-expanded')) {
setExpanded(true);
}
// Map top chip keys to side tabs if applicable, else 'overview'
let targetTab = 'overview';
if (key === 'timer') targetTab = 'timer';
const tabBtn = document.querySelector('.bb-tab-btn[data-bb-tab="' + targetTab + '"]');
if (tabBtn) tabBtn.click();
});
}
}
function bindSheetToggle() {
const toggle = byId('bbSheetToggle');
if (!toggle) return;
toggle.addEventListener('click', function () {
const shell = byId('globalBottomBar');
if (!shell) return;
const isExp = shell.classList.contains('is-expanded');
setExpanded(!isExp);
});
}
function tick() {
fetchBottomBarState().then(function (data) {
if (data && data.enabled) {
latestSections = data.sections || {};
updateBar(latestSections);
renderTabPanel();
setVisibility(true);
} else {
setVisibility(false);
}
}).catch(function (err) {
console.warn('Bottom bar poll failed', err);
}).finally(function () {
window.setTimeout(tick, 15000);
});
}
document.addEventListener('DOMContentLoaded', function () {
activeKey = 'overview'; // Default overview state
bindChipClicks();
bindSheetToggle();
bindSideTabs();
tick();
});
})();
"""
combined = content[:start_idx] + new_logic
with open("static/js/bottom-bar.js", "w") as f:
f.write(combined)

View File

@ -0,0 +1,93 @@
## Plan: Manualmodul MVP i BMC Hub
Lever et API-first manualmodul med UUID-baseret datamodel, dedikeret oversigt/artikelsider, kontekstuel modalhjælp og admin CRUD i samme MVP. Implementer søgning med ILIKE nu for hurtig leverance og lav en klar fase-2 bane til PostgreSQL full-text + synonymer, så performance og AI-ready retning bevares uden at blokere MVP.
**Steps**
1. Fase 1: Datamodel + migrationer (blokkerende)
2. Definér og opret tabellerne manual_articles, manual_steps og manual_relations med UUID primary keys, timestamps og soft delete (deleted_at). Tilføj nødvendige indeks for slug-opslag, modulfiltrering, sværhedsgrad, og relation-opslag. Markér slug som unik.
3. Beslut relation mellem manualer og eksisterende tagsystem: brug enten direkte JSONB tags i manual_articles (hurtigst) eller kobling til eksisterende tags/entity_tags via entity_type=manual. For MVP anbefales begge: behold tags JSONB til hurtig filtrering og tilføj relation hooks til entity_tags for fremtidig konsolidering. Depends on step 2.
4. Fase 2: Backend API + services
5. Opret ny vertical slice for manualmodulet med router, models og service-lag efter eksisterende featurestruktur. Router skal registreres under /api/v1/manual i main app startup. Depends on fase 1.
6. Implementer endpoints: GET liste (filtre: module, tags, difficulty, search), GET by slug, POST create, PUT update, DELETE soft delete. Tilføj validering for slug-kollisioner og payload-format for steps/relations. Parallel with step 7 once models are in place.
7. Implementer nested håndtering af steps (sorteret efter step_number) og related-manuals retrieval i artikel-endpoint, så frontend kan rendere én samlet artikelvisning uden ekstra roundtrips. Parallel with step 6.
8. Implementer kontekst-endpoint (fx query param context_module eller context_tag) så ?-ikoner kan hente relevante manualer direkte fra aktivt modul. Depends on step 6.
9. Fase 3: Frontend visning + kontekstuel hjælp
10. Opret manualsider i eksisterende template-system: en oversigtsside og en artikelside. Bevar Nordic Top, eksisterende dark mode og CSS variable-tema. Depends on fase 2.
11. Implementer UI i oversigten: søgefelt, modulfilter, tagfilter, sværhedsgrad og sortering med mest brugte øverst (baseret på visningscounter eller fallback created/updated sort indtil metrics er tilføjet). Parallel with step 12.
12. Implementer artikelside med titel, summary, step-by-step blokke, media (lazy loading), relaterede manualer og Åbn i kontekst-links. Parallel with step 11.
13. Integrer ?-ikon i prioriterede moduler (Sag, Hardware, Mail) med modal viewer, der loader relevant manual via context query. Brug eksisterende Bootstrap modal-mønster i base layout. Depends on step 8 and step 10.
14. Fase 4: Admin CRUD UI (MVP-krav)
15. Opret admin-side til opret/rediger/slet manualer med markdown editor, preview, step-editor og relation-editor. Brug server-side forms + eksisterende komponentmønstre for hurtig stabil levering. Depends on fase 2.
16. Implementer upload flow for billeder/video med validering af filtype/størrelse og lagring via eksisterende uploadmønster i repo. Sørg for at manual media kan references fra manual_steps. Depends on step 15.
17. Fase 5: Performance + observability + hardening
18. Tilføj målrettede indeks og query tuning for liste/slug/kontekst-opslag så listekald kan holde sig under 200ms i normal drift. Depends on fase 2.
19. Tilføj simpel cache for populære manualer (fx memory/cache-lag på GET liste og GET by slug), med sikker invalidation ved POST/PUT/DELETE. Depends on step 6.
20. Tilføj audit/logging for admin-ændringer og basis metrics (views/use_count) til ranking af Mest brugte. Parallel with step 19.
21. Fase 6: Verifikation + release
22. Backend tests: endpoint-kontrakter, validering, soft delete, filtrering, søgning og slug-unikhed.
23. Frontend tests/smoke: dark/light mode, responsive layouts, modal-konteksthjælp i Sag/Hardware/Mail, lazy loading af media.
24. Performance test: mål API-responstid for GET /api/v1/manual under realistisk dataset og justér indeks/cache til målopfyldelse.
25. Dokumentér MVP scope, begrænsninger og fase-2 roadmap for full-text + synonym mapping + AI forslag.
**Relevant files**
- /Users/christianthomas/DEV/bmc_hub_dev/main.py — registrering af nye manual-routere under API v1.
- /Users/christianthomas/DEV/bmc_hub_dev/app/core/database.py — query-patterns, execute_query helpers, transaction-adfærd.
- /Users/christianthomas/DEV/bmc_hub_dev/app/tags/backend/router.py — reference for CRUD/filter/search endpoint struktur.
- /Users/christianthomas/DEV/bmc_hub_dev/app/modules/search/backend/router.py — reference for nuværende ILIKE-baseret søgeadfærd.
- /Users/christianthomas/DEV/bmc_hub_dev/app/shared/frontend/base.html — dark mode toggle, navbar, modal patterns og theming variables.
- /Users/christianthomas/DEV/bmc_hub_dev/app/modules/orders/templates/list.html — reference for liste-layout med filter/toolbar/stats.
- /Users/christianthomas/DEV/bmc_hub_dev/app/modules/hardware/templates/detail.html — reference for detailside mønster og infoblokke.
- /Users/christianthomas/DEV/bmc_hub_dev/migrations/027_tag_system.sql — eksisterende tag/entity_tags arkitektur til manual relation.
**Verification**
1. Kør backend tests for alle manual-endpoints inkl. edge cases: manglende slug, dobbelt slug, tomme steps, soft-deleted records.
2. Kør integrationstest med seedet data: filtrering (module/tags/difficulty), kontekst-lookup og related manuals.
3. Kør UI-smoke på desktop + mobil: manual oversigt, artikelside, admin editor, ?-ikon modal i 3 moduler.
4. Mål responstid på GET liste + GET slug med og uden cache; dokumentér baseline og efteroptimering.
5. Verificér accessibility basics: focus states, kontrast i dark/light, keyboard-lukning af modal.
**Decisions**
- UUID vælges til manualtabellerne, selvom øvrige features ofte bruger serial id.
- MVP inkluderer både dedikerede manualsider og modalbaseret konteksthjælp.
- MVP inkluderer fuld admin CRUD UI inkl. markdown, preview og media-upload.
- MVP bruger simpel ILIKE-søgning; FTS + synonym-mapping flyttes til fase 2.
**Scope boundaries**
- Inkluderet: manualliste, artikel, relationer til modul/tag/sag-type, kontekstuel ?-hjælp, admin CRUD, simpel søgning.
- Ekskluderet i MVP: AI chatbot, auto-generering af manualer, heatmaps, avanceret synonym-ranking i runtime.
**Further Considerations**
1. Fase-2 datamodel for synonymer bør designes nu (table for canonical_term/alias/context), men aktiveres først efter MVP-go-live.
2. Overvej at gøre use_count events asynkrone for at undgå write-overhead på hvert view-request.
3. Afklar tidligt om markdown rendering skal sanitizes ekstra hårdt (XSS policy) i både preview og public view.
**Sprint-opdeling (forslag)**
1. Sprint 1 (5-7 dage): Fundament + API basis
- Leverancer: migrationer (UUID), manual core models, CRUD endpoints (liste/by slug/create/update/delete), simpel ILIKE søgning, baseline tests for API kontrakter.
- Exit-kriterier: GET liste og GET slug stabile i testmiljø, soft delete virker, slug-unikhed håndhæves.
- Estimat: 30-40 timer.
2. Sprint 2 (5-7 dage): Frontend brugerflow + konteksthjælp
- Leverancer: /manual oversigt, /manual/{slug} artikel, filter/search UI, relaterede manualer, ?-ikon modal i Sag/Hardware/Mail med kontekst-filtrering.
- Exit-kriterier: Bruger kan finde manual på maks 2 klik fra modul, dark/light mode og mobil virker uden regressions.
- Estimat: 35-45 timer.
3. Sprint 3 (5-7 dage): Admin CRUD + media + hardening
- Leverancer: admin editor (markdown + preview), steps/relation editor, billede/video upload, caching af populære manualer, metrics/audit logging.
- Exit-kriterier: Admin kan vedligeholde manualer end-to-end uden DB-manualarbejde, liste-load holder performance mål i normal drift.
- Estimat: 40-55 timer.
4. Sprint 4 (2-4 dage): Stabilisering + release
- Leverancer: performance tuning (<200ms mål), integration/smoke tests, accessibility baseline, release notes + fase-2 backlog (FTS/synonymer/AI).
- Exit-kriterier: Produktionsklar MVP med dokumenteret teststatus og kendte begrænsninger.
- Estimat: 16-28 timer.
**Samlet estimat**
- Lavt/højt spænd: 121-168 timer.
- Anbefalet bemanding: 1 backend + 1 frontend (delvist parallelt) eller 1 fullstack med 20-30% længere kalenderforløb.
**Parallelisering pr. sprint**
1. Sprint 1: DB migration + API modelarbejde kan starte parallelt; endpoint wiring afhænger af migration.
2. Sprint 2: Oversigtsside og artikelside kan bygges parallelt; modal-integration afhænger af kontekst-endpoint.
3. Sprint 3: Admin editor og caching/metrics kan køre parallelt; upload flow afhænger af valgt storage pattern.
4. Sprint 4: Testautomatisering og performance profiling kan køre parallelt frem mod release cut.

1011
static/js/bottom-bar.js Normal file

File diff suppressed because it is too large Load Diff

23
test_manual.py Normal file
View File

@ -0,0 +1,23 @@
import urllib.request
import json
import sys
URL = "http://127.0.0.1:8000"
def test_endpoint(path, expected_status=200):
try:
req = urllib.request.Request(URL + path)
with urllib.request.urlopen(req) as response:
if response.status == expected_status:
print(f"{path} returned {response.status}")
return json.loads(response.read().decode())
else:
print(f"{path} returned {response.status}")
except Exception as e:
print(f"{path} failed: {e}")
print("Running Manual Module Smoke Tests...")
test_endpoint("/api/v1/manual")
test_endpoint("/api/v1/manual?limit=1")
test_endpoint("/api/v1/manual?search=test")
print("Done!")