Compare commits
No commits in common. "ceb560e2f24236fd5f65c0ae1b1126559b6a061e" and "ee8c517acc6a80b896341558f63aed421554d5d5" have entirely different histories.
ceb560e2f2
...
ee8c517acc
@ -253,11 +253,6 @@ class Settings(BaseSettings):
|
|||||||
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
|
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
|
||||||
ANYDESK_TIMEOUT_SECONDS: int = 30
|
ANYDESK_TIMEOUT_SECONDS: int = 30
|
||||||
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested
|
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 (Yealink) Integration
|
||||||
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=...
|
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=...
|
||||||
@ -287,9 +282,6 @@ class Settings(BaseSettings):
|
|||||||
SMS_USERNAME: str = ""
|
SMS_USERNAME: str = ""
|
||||||
SMS_SENDER: str = "BMC Networks"
|
SMS_SENDER: str = "BMC Networks"
|
||||||
SMS_WEBHOOK_SECRET: str = ""
|
SMS_WEBHOOK_SECRET: str = ""
|
||||||
|
|
||||||
# Bottom bar module
|
|
||||||
BOTTOM_BAR_ENABLED: bool = False
|
|
||||||
|
|
||||||
# Dev-only shortcuts
|
# Dev-only shortcuts
|
||||||
DEV_ALLOW_ARCHIVED_IMPORT: bool = False
|
DEV_ALLOW_ARCHIVED_IMPORT: bool = False
|
||||||
|
|||||||
@ -1004,8 +1004,6 @@
|
|||||||
style="border-radius: 20px;">
|
style="border-radius: 20px;">
|
||||||
<button class="btn btn-outline-primary" onclick="openWorkflowManager()" title="Workflow Management">
|
<button class="btn btn-outline-primary" onclick="openWorkflowManager()" title="Workflow Management">
|
||||||
<i class="bi bi-diagram-3"></i>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,288 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@ -1,656 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@ -221,130 +221,52 @@ async def hardware_list(
|
|||||||
request: Request,
|
request: Request,
|
||||||
status: str = Query(None),
|
status: str = Query(None),
|
||||||
asset_type: str = Query(None),
|
asset_type: str = Query(None),
|
||||||
rental_scope: str = Query(None),
|
|
||||||
customer_id: int = Query(None),
|
customer_id: int = Query(None),
|
||||||
q: str = Query(None)
|
q: str = Query(None)
|
||||||
):
|
):
|
||||||
"""Display list of BMC-owned assets only."""
|
"""Display list of all hardware."""
|
||||||
query = """
|
query = "SELECT * FROM hardware_assets WHERE deleted_at IS NULL"
|
||||||
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 = []
|
params = []
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query += " AND ha.status = %s"
|
query += " AND status = %s"
|
||||||
params.append(status)
|
params.append(status)
|
||||||
if asset_type:
|
if asset_type:
|
||||||
query += " AND ha.asset_type = %s"
|
query += " AND asset_type = %s"
|
||||||
params.append(asset_type)
|
params.append(asset_type)
|
||||||
if customer_id:
|
if customer_id:
|
||||||
query += " AND ha.current_owner_customer_id = %s"
|
query += " AND current_owner_customer_id = %s"
|
||||||
params.append(customer_id)
|
params.append(customer_id)
|
||||||
if q:
|
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)"
|
query += " AND (serial_number ILIKE %s OR model ILIKE %s OR brand ILIKE %s)"
|
||||||
search_param = f"%{q}%"
|
search_param = f"%{q}%"
|
||||||
params.extend([search_param, search_param, search_param, search_param])
|
params.extend([search_param, search_param, search_param])
|
||||||
|
|
||||||
if rental_scope == "rented":
|
query += " ORDER BY created_at DESC"
|
||||||
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))
|
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", {
|
return templates.TemplateResponse("modules/hardware/templates/index.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"hardware": hardware,
|
"hardware": hardware,
|
||||||
"current_status": status,
|
"current_status": status,
|
||||||
"current_asset_type": asset_type,
|
"current_asset_type": asset_type,
|
||||||
"current_rental_scope": rental_scope,
|
|
||||||
"search_query": q
|
"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)
|
@router.get("/hardware/new", response_class=HTMLResponse)
|
||||||
async def create_hardware_form(request: Request):
|
async def create_hardware_form(request: Request):
|
||||||
"""Display create hardware form."""
|
"""Display create hardware form."""
|
||||||
@ -436,7 +358,7 @@ async def hardware_eset_import(request: Request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hardware/{hardware_id:int}", response_class=HTMLResponse)
|
@router.get("/hardware/{hardware_id}", response_class=HTMLResponse)
|
||||||
async def hardware_detail(request: Request, hardware_id: int):
|
async def hardware_detail(request: Request, hardware_id: int):
|
||||||
"""Display hardware details."""
|
"""Display hardware details."""
|
||||||
# Get hardware
|
# Get hardware
|
||||||
@ -585,7 +507,7 @@ async def hardware_detail(request: Request, hardware_id: int):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hardware/{hardware_id:int}/edit", response_class=HTMLResponse)
|
@router.get("/hardware/{hardware_id}/edit", response_class=HTMLResponse)
|
||||||
async def edit_hardware_form(request: Request, hardware_id: int):
|
async def edit_hardware_form(request: Request, hardware_id: int):
|
||||||
"""Display edit hardware form."""
|
"""Display edit hardware form."""
|
||||||
# Get hardware
|
# Get hardware
|
||||||
@ -606,7 +528,7 @@ async def edit_hardware_form(request: Request, hardware_id: int):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/hardware/{hardware_id:int}/location")
|
@router.post("/hardware/{hardware_id}/location")
|
||||||
async def update_hardware_location(
|
async def update_hardware_location(
|
||||||
request: Request,
|
request: Request,
|
||||||
hardware_id: int,
|
hardware_id: int,
|
||||||
@ -652,7 +574,7 @@ async def update_hardware_location(
|
|||||||
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
|
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/hardware/{hardware_id:int}/owner")
|
@router.post("/hardware/{hardware_id}/owner")
|
||||||
async def update_hardware_owner(
|
async def update_hardware_owner(
|
||||||
request: Request,
|
request: Request,
|
||||||
hardware_id: int,
|
hardware_id: int,
|
||||||
@ -727,7 +649,7 @@ async def update_hardware_owner(
|
|||||||
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
|
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/hardware/{hardware_id:int}/contacts/add")
|
@router.post("/hardware/{hardware_id}/contacts/add")
|
||||||
async def add_hardware_contact(
|
async def add_hardware_contact(
|
||||||
request: Request,
|
request: Request,
|
||||||
hardware_id: int,
|
hardware_id: int,
|
||||||
@ -749,7 +671,7 @@ async def add_hardware_contact(
|
|||||||
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
|
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/hardware/{hardware_id:int}/contacts/{contact_id:int}/delete")
|
@router.post("/hardware/{hardware_id}/contacts/{contact_id}/delete")
|
||||||
async def remove_hardware_contact(
|
async def remove_hardware_contact(
|
||||||
request: Request,
|
request: Request,
|
||||||
hardware_id: int,
|
hardware_id: int,
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -242,12 +242,8 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>🗂️ BMC Assets Oversigt (Kun vores egne)</h1>
|
<h1>🖥️ Hardware Oversigt</h1>
|
||||||
<div class="d-flex gap-2">
|
<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;">
|
<a href="/hardware/eset" class="btn-new-hardware" style="background-color: #0f4c75;">
|
||||||
<i class="bi bi-shield-check"></i>
|
<i class="bi bi-shield-check"></i>
|
||||||
ESET Oversigt
|
ESET Oversigt
|
||||||
@ -289,15 +285,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="filter-group">
|
||||||
<label for="q">Søg</label>
|
<label for="q">Søg</label>
|
||||||
<input type="text" name="q" id="q" placeholder="Serial, model, mærke..." value="{{ search_query or '' }}">
|
<input type="text" name="q" id="q" placeholder="Serial, model, mærke..." value="{{ search_query or '' }}">
|
||||||
@ -375,16 +362,6 @@
|
|||||||
<span class="hardware-detail-value">{{ item.internal_asset_id }}</span>
|
<span class="hardware-detail-value">{{ item.internal_asset_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
|
||||||
<div class="hardware-footer">
|
<div class="hardware-footer">
|
||||||
@ -410,7 +387,6 @@
|
|||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Serienr.</th>
|
<th>Serienr.</th>
|
||||||
<th>Ejer</th>
|
<th>Ejer</th>
|
||||||
<th>Udlejning</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>AnyDesk</th>
|
<th>AnyDesk</th>
|
||||||
<th class="text-end">Handling</th>
|
<th class="text-end">Handling</th>
|
||||||
@ -423,13 +399,6 @@
|
|||||||
<td>{{ item.asset_type|title }}</td>
|
<td>{{ item.asset_type|title }}</td>
|
||||||
<td>{{ item.serial_number or 'Ingen serienummer' }}</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>{{ 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>
|
<td>
|
||||||
<span class="status-badge status-{{ item.status }}">
|
<span class="status-badge status-{{ item.status }}">
|
||||||
{{ item.status|replace('_', ' ')|title }}
|
{{ item.status|replace('_', ' ')|title }}
|
||||||
@ -457,18 +426,17 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">🖥️</div>
|
<div class="empty-state-icon">🖥️</div>
|
||||||
<h3>Ingen BMC assets fundet</h3>
|
<h3>Ingen hardware fundet</h3>
|
||||||
<p>Opret dit første interne asset for at komme i gang.</p>
|
<p>Opret dit første hardware asset for at komme i gang.</p>
|
||||||
<a href="/hardware/new" class="btn-new-hardware" style="margin-top: 1rem;">➕ Opret Hardware</a>
|
<a href="/hardware/new" class="btn-new-hardware" style="margin-top: 1rem;">➕ Opret Hardware</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
// Auto-submit filter form on change
|
// Auto-submit filter form on change
|
||||||
document.querySelectorAll('#asset_type, #status, #rental_scope').forEach(select => {
|
document.querySelectorAll('#asset_type, #status').forEach(select => {
|
||||||
select.addEventListener('change', () => {
|
select.addEventListener('change', () => {
|
||||||
select.form.submit();
|
select.form.submit();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
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
|
|
||||||
@ -3,10 +3,9 @@ import json
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Literal, Optional
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
from fastapi import BackgroundTasks, APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
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
|
from app.core.database import execute_query, execute_query_single, execute_update
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -279,13 +278,7 @@ async def contextual_manual_suggestions(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/manual/{slug}")
|
@router.get("/manual/{slug}")
|
||||||
async def get_manual_article(slug: str, background_tasks: BackgroundTasks):
|
async def get_manual_article(slug: str):
|
||||||
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(
|
article = execute_query_single(
|
||||||
"""
|
"""
|
||||||
SELECT id, title, slug, content, summary, module, tags, difficulty, use_count, created_at, updated_at
|
SELECT id, title, slug, content, summary, module, tags, difficulty, use_count, created_at, updated_at
|
||||||
|
|||||||
@ -7,7 +7,6 @@ from fastapi import APIRouter, Query, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.modules.manual.backend.cache import manual_cache
|
|
||||||
from app.core.database import execute_query, execute_query_single
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -37,54 +36,52 @@ async def manual_index(
|
|||||||
tag: Optional[str] = Query(default=None),
|
tag: Optional[str] = Query(default=None),
|
||||||
search: Optional[str] = Query(default=None),
|
search: Optional[str] = Query(default=None),
|
||||||
):
|
):
|
||||||
cache_key = f"views:list:{module}:{difficulty}:{tag}:{search}"
|
where_parts = ["deleted_at IS NULL"]
|
||||||
cached = manual_cache.get(cache_key)
|
params: List[Any] = []
|
||||||
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}%"])
|
|
||||||
|
|
||||||
where_clause = " AND ".join(filters)
|
if module:
|
||||||
|
where_parts.append("LOWER(module) = LOWER(%s)")
|
||||||
|
params.append(module.strip())
|
||||||
|
|
||||||
rows = execute_query(
|
if difficulty in {"beginner", "advanced"}:
|
||||||
f"SELECT id, slug, title, content, module, tags, difficulty, use_count, updated_at "
|
where_parts.append("difficulty = %s")
|
||||||
f"FROM manual_articles WHERE {where_clause} ORDER BY updated_at DESC",
|
params.append(difficulty)
|
||||||
tuple(params)
|
|
||||||
) or []
|
|
||||||
|
|
||||||
modules = execute_query(
|
if search:
|
||||||
"SELECT DISTINCT module FROM manual_articles WHERE deleted_at IS NULL ORDER BY module ASC"
|
needle = f"%{search.strip()}%"
|
||||||
) or []
|
where_parts.append("(title ILIKE %s OR summary ILIKE %s OR content ILIKE %s)")
|
||||||
|
params.extend([needle, needle, needle])
|
||||||
|
|
||||||
all_tags: List[str] = []
|
if tag:
|
||||||
for row in rows:
|
where_parts.append(
|
||||||
if "tags" in row and row["tags"]:
|
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(tags, '[]'::jsonb)) t(tag) WHERE LOWER(t.tag) = LOWER(%s))"
|
||||||
try:
|
)
|
||||||
import json
|
params.append(tag.strip())
|
||||||
if isinstance(row["tags"], str):
|
|
||||||
t = json.loads(row["tags"])
|
rows = execute_query(
|
||||||
if isinstance(t, list):
|
f"""
|
||||||
all_tags.extend(t)
|
SELECT id, title, slug, summary, module, difficulty, tags, use_count, updated_at
|
||||||
elif isinstance(row["tags"], list):
|
FROM manual_articles
|
||||||
all_tags.extend(row["tags"])
|
WHERE {' AND '.join(where_parts)}
|
||||||
except Exception:
|
ORDER BY use_count DESC, updated_at DESC
|
||||||
pass
|
LIMIT 300
|
||||||
unique_tags = sorted(list(set(all_tags)))
|
""",
|
||||||
|
tuple(params),
|
||||||
manual_cache.set(cache_key, (rows, modules, unique_tags))
|
) 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)))
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"modules/manual/templates/list.html",
|
"modules/manual/templates/list.html",
|
||||||
|
|||||||
@ -1,997 +0,0 @@
|
|||||||
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.",
|
|
||||||
}
|
|
||||||
@ -1842,16 +1842,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.case-tabs-topbar.topbar-secondary {
|
.case-tabs-topbar.topbar-secondary {
|
||||||
/* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */
|
grid-template-columns: repeat(8, minmax(150px, 1fr));
|
||||||
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 {
|
.case-tabs-topbar-item {
|
||||||
@ -1975,6 +1966,12 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-deferred-shortcuts {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-deferred-current {
|
.topbar-deferred-current {
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
@ -1997,7 +1994,24 @@
|
|||||||
background: rgba(15, 76, 117, 0.09);
|
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 {
|
.topbar-secondary-action:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
@ -2005,6 +2019,31 @@
|
|||||||
background: rgba(255,255,255,0.95);
|
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 {
|
[data-bs-theme="dark"] .topbar-deferred-current {
|
||||||
color: rgba(236, 242, 255, 0.82);
|
color: rgba(236, 242, 255, 0.82);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
@ -2017,6 +2056,16 @@
|
|||||||
background: rgba(19, 100, 154, 0.24);
|
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 {
|
[data-bs-theme="dark"] .topbar-secondary-action {
|
||||||
background: rgba(20, 27, 38, 0.72);
|
background: rgba(20, 27, 38, 0.72);
|
||||||
border-color: rgba(170, 190, 216, 0.35);
|
border-color: rgba(170, 190, 216, 0.35);
|
||||||
@ -2032,7 +2081,16 @@
|
|||||||
background: rgba(20, 27, 38, 0.78);
|
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 {
|
.case-add-side-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -2341,56 +2399,43 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="case-tabs-topbar-item field-start">
|
<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="case-tabs-topbar-label"><i class="bi bi-play-circle"></i>Arbejdsstart</div>
|
||||||
<div class="topbar-secondary-inline" style="gap: 0.25rem;">
|
<div class="topbar-secondary-inline">
|
||||||
<input
|
<input
|
||||||
id="topbarStartDateInput"
|
id="topbarStartDateInput"
|
||||||
type="date"
|
type="date"
|
||||||
class="case-inline-select"
|
class="case-inline-select"
|
||||||
value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}"
|
value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}"
|
||||||
onchange="saveCaseStartDateFromTopbar()"
|
onchange="saveCaseStartDateFromTopbar()"
|
||||||
style="padding-left: 0.35rem; padding-right: 0.1rem;"
|
|
||||||
>
|
>
|
||||||
<div class="dropdown">
|
<button type="button" class="topbar-secondary-action is-icon" onclick="clearCaseStartDateFromTopbar()" title="Fjern startdato">
|
||||||
<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-x-lg"></i>
|
||||||
<i class="bi bi-three-dots-vertical"></i>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
<div class="topbar-deferred-shortcuts">
|
||||||
<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>
|
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(0)">I dag</button>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(1)">+1 dag</button>
|
||||||
<li><h6 class="dropdown-header">Udskyd til...</h6></li>
|
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(7)">+1 uge</button>
|
||||||
<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>
|
<button type="button" class="topbar-mini-trigger" onclick="openStartDateModal()">Flere valg</button>
|
||||||
<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>
|
</div>
|
||||||
<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>
|
<div class="topbar-deferred-shortcuts">
|
||||||
<li><hr class="dropdown-divider"></li>
|
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModal()">Trigger</button>
|
||||||
<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>
|
</div>
|
||||||
<div class="case-tabs-topbar-item field-start-before">
|
<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="case-tabs-topbar-label"><i class="bi bi-hourglass-split"></i>Start senest</div>
|
||||||
<div class="topbar-secondary-inline" style="gap: 0.25rem;">
|
<div class="topbar-secondary-inline">
|
||||||
<input
|
<input
|
||||||
id="topbarDeferredInput"
|
id="topbarDeferredInput"
|
||||||
type="date"
|
type="date"
|
||||||
class="case-inline-select"
|
class="case-inline-select"
|
||||||
value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}"
|
value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}"
|
||||||
onchange="updateDeferredUntil(this.value || null)"
|
onchange="updateDeferredUntil(this.value || null)"
|
||||||
style="padding-left: 0.35rem; padding-right: 0.1rem;"
|
|
||||||
>
|
>
|
||||||
<div class="dropdown">
|
<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>
|
||||||
<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">
|
</div>
|
||||||
<i class="bi bi-three-dots-vertical"></i>
|
<div class="topbar-deferred-shortcuts">
|
||||||
</button>
|
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('lukket')">Lukket</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('løst')">Løst</button>
|
||||||
<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>
|
</div>
|
||||||
{% set deferred_case_ns = namespace(title='') %}
|
{% set deferred_case_ns = namespace(title='') %}
|
||||||
{% if case.deferred_until_case_id %}
|
{% if case.deferred_until_case_id %}
|
||||||
@ -2414,22 +2459,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="case-tabs-topbar-item field-deadline">
|
<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="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato</div>
|
||||||
<div class="topbar-secondary-inline" style="gap: 0.25rem;">
|
<div class="topbar-secondary-inline">
|
||||||
<input
|
<input
|
||||||
id="topbarDeadlineInput"
|
id="topbarDeadlineInput"
|
||||||
type="date"
|
type="date"
|
||||||
class="case-inline-select"
|
class="case-inline-select"
|
||||||
value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}"
|
value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}"
|
||||||
onchange="updateDeadline(this.value || null)"
|
onchange="updateDeadline(this.value || null)"
|
||||||
style="padding-left: 0.35rem; padding-right: 0.1rem;"
|
|
||||||
>
|
>
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="case-tabs-topbar-item field-anydesk">
|
<div class="case-tabs-topbar-item field-anydesk">
|
||||||
<div class="case-tabs-topbar-label"><i class="bi bi-display"></i>AnyDesk</div>
|
<div class="case-tabs-topbar-label"><i class="bi bi-display"></i>AnyDesk</div>
|
||||||
<button id="caseAnyDeskOpenBtn" type="button" class="topbar-secondary-action is-wide" onclick="openCaseAnyDeskModal()" title="Start AnyDesk quick connect for denne sag">
|
<button type="button" class="topbar-secondary-action is-wide" onclick="openCaseAnyDeskModal()" title="Registrer AnyDesk session for denne sag">
|
||||||
<i class="bi bi-plug"></i> Quick connect
|
<i class="bi bi-plus-circle"></i> Registrer session
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="case-tabs-topbar-item field-documents">
|
<div class="case-tabs-topbar-item field-documents">
|
||||||
@ -2735,7 +2779,6 @@
|
|||||||
<div id="sag-titel-view" class="d-flex align-items-center gap-2">
|
<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>
|
<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-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>
|
</div>
|
||||||
<!-- Title edit -->
|
<!-- Title edit -->
|
||||||
<div id="sag-titel-editor" class="d-none">
|
<div id="sag-titel-editor" class="d-none">
|
||||||
@ -3444,41 +3487,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AnyDesk quick-connect modal -->
|
<!-- AnyDesk manual registration modal -->
|
||||||
<div class="modal fade" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title"><i class="bi bi-display me-2"></i>AnyDesk quick connect</h5>
|
<h5 class="modal-title"><i class="bi bi-display me-2"></i>Registrer AnyDesk session</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">AnyDesk ID</label>
|
<label class="form-label">AnyDesk ID</label>
|
||||||
<div class="input-group">
|
<input type="text" class="form-control" id="caseAnydeskIdInput" placeholder="fx 123 456 789" />
|
||||||
<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>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Gemte IDs på sagen</label>
|
<label class="form-label">Hvilken enhed hjælper du med?</label>
|
||||||
<div id="caseAnyDeskSavedIds" class="d-flex flex-wrap gap-2">
|
<input type="text" class="form-control" id="caseAnydeskDeviceNameInput" placeholder="fx Reception-PC, Lager printer, Router i teknikrum" />
|
||||||
<span class="text-muted small">Indlæser...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Relatér til hardware (valgfri)</label>
|
<label class="form-label">Enhedstype</label>
|
||||||
<select class="form-select" id="caseAnydeskHardwareSelect">
|
<select class="form-select" id="caseAnydeskDeviceTypeInput">
|
||||||
<option value="">Ingen hardware valgt</option>
|
<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>
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">Sagens hardware vises først. Derefter hardware hos kunden.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Kontakt (valgfri)</label>
|
<label class="form-label">Kontakt (valgfri)</label>
|
||||||
<select class="form-select" id="caseAnydeskContactSelect">
|
<select class="form-select" id="caseAnydeskContactSelect">
|
||||||
@ -3493,12 +3531,12 @@
|
|||||||
<textarea class="form-control" id="caseAnydeskNoteInput" rows="3" placeholder="Kort notat om supporten"></textarea>
|
<textarea class="form-control" id="caseAnydeskNoteInput" rows="3" placeholder="Kort notat om supporten"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted mt-2">
|
<div class="small text-muted mt-2">
|
||||||
Når du klikker forbind oprettes sessionen på sagen med det samme, så varighed/status kan beriges via lokal AnyDesk sync.
|
Sessionen gemmes direkte på sagen. Hvis du vælger type "Placebo", kan den senere linkes til rigtig hardware.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
|
||||||
<button id="caseAnyDeskConnectBtn" type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()"><i class="bi bi-plug me-1"></i>Forbind og gem</button>
|
<button type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()">Gem registrering</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -9425,7 +9463,6 @@
|
|||||||
<script>
|
<script>
|
||||||
let currentSearchType = null;
|
let currentSearchType = null;
|
||||||
let searchDebounceIds = null;
|
let searchDebounceIds = null;
|
||||||
let caseAnyDeskConnectInFlight = false;
|
|
||||||
const caseIds = {{ case.id }};
|
const caseIds = {{ case.id }};
|
||||||
const currentCaseTitle = {{ (case.titel or '') | tojson }};
|
const currentCaseTitle = {{ (case.titel or '') | tojson }};
|
||||||
let caseAddPanelInitialized = false;
|
let caseAddPanelInitialized = false;
|
||||||
@ -9445,192 +9482,60 @@
|
|||||||
{ action: 'email', label: 'Send email', icon: 'bi-envelope', moduleKey: 'emails', relFn: 'openRelEmailModal' }
|
{ action: 'email', label: 'Send email', icon: 'bi-envelope', moduleKey: 'emails', relFn: 'openRelEmailModal' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function normalizeAnyDeskIdClient(rawValue) {
|
function openCaseAnyDeskModal() {
|
||||||
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) {
|
if (!caseAnyDeskModal) {
|
||||||
const modalEl = document.getElementById('caseAnyDeskModal');
|
const modalEl = document.getElementById('caseAnyDeskModal');
|
||||||
if (!modalEl) return;
|
if (!modalEl) return;
|
||||||
caseAnyDeskModal = new bootstrap.Modal(modalEl);
|
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');
|
const noteInput = document.getElementById('caseAnydeskNoteInput');
|
||||||
|
if (anydeskInput) anydeskInput.value = '';
|
||||||
|
if (deviceInput) deviceInput.value = '';
|
||||||
|
if (typeInput) typeInput.value = 'placebo';
|
||||||
if (noteInput) noteInput.value = '';
|
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();
|
caseAnyDeskModal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerCaseAnyDeskSession() {
|
async function registerCaseAnyDeskSession() {
|
||||||
if (caseAnyDeskConnectInFlight) {
|
const anydeskId = (document.getElementById('caseAnydeskIdInput')?.value || '').trim();
|
||||||
return;
|
const assistedDevice = (document.getElementById('caseAnydeskDeviceNameInput')?.value || '').trim();
|
||||||
}
|
const deviceType = (document.getElementById('caseAnydeskDeviceTypeInput')?.value || 'placebo').trim();
|
||||||
|
|
||||||
const anydeskId = normalizeAnyDeskIdClient(document.getElementById('caseAnydeskIdInput')?.value || '');
|
|
||||||
const contactIdRaw = document.getElementById('caseAnydeskContactSelect')?.value || '';
|
const contactIdRaw = document.getElementById('caseAnydeskContactSelect')?.value || '';
|
||||||
const hardwareIdRaw = document.getElementById('caseAnydeskHardwareSelect')?.value || '';
|
|
||||||
const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim();
|
const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim();
|
||||||
|
|
||||||
if (!anydeskId) {
|
if (!anydeskId) {
|
||||||
alert('Udfyld AnyDesk ID');
|
alert('Udfyld AnyDesk ID');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!assistedDevice) {
|
||||||
|
alert('Udfyld hvilken enhed du hjælper med');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const customerId = {{ customer.id if customer else 'null' }};
|
const customerId = {{ customer.id if customer else 'null' }};
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
alert('Sagen har ingen kunde - kan ikke starte AnyDesk session');
|
alert('Sagen har ingen kunde - kan ikke registrere AnyDesk session');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdByUserId = await ensureCaseCurrentUserId();
|
const createdByUserId = await ensureCaseCurrentUserId();
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
anydesk_id: anydeskId,
|
sag_id: caseIds,
|
||||||
|
customer_id: customerId,
|
||||||
contact_id: contactIdRaw ? Number(contactIdRaw) : null,
|
contact_id: contactIdRaw ? Number(contactIdRaw) : null,
|
||||||
hardware_asset_id: hardwareIdRaw ? Number(hardwareIdRaw) : null,
|
anydesk_id: anydeskId,
|
||||||
note: notes,
|
assisted_device: assistedDevice,
|
||||||
make_primary: true,
|
device_type: deviceType,
|
||||||
|
notes,
|
||||||
created_by_user_id: createdByUserId || null
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/v1/anydesk/cases/${caseIds}/connect`, {
|
const res = await fetch('/api/v1/anydesk/register-manual-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@ -9639,31 +9544,17 @@
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const txt = await res.text();
|
const txt = await res.text();
|
||||||
throw new Error(txt || 'Kunne ikke starte AnyDesk quick connect');
|
throw new Error(txt || 'Kunne ikke registrere session');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (caseAnyDeskModal) caseAnyDeskModal.hide();
|
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') {
|
if (typeof loadComments === 'function') {
|
||||||
loadComments();
|
loadComments();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Fejl ved AnyDesk quick connect: ' + (e.message || 'Ukendt fejl'));
|
alert('Fejl ved registrering: ' + (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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -9687,10 +9578,6 @@
|
|||||||
window._showRelModal = renderCaseAddWorkspaceModal;
|
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);
|
renderCaseAddActionList(caseAddActiveAction);
|
||||||
caseAddPanelInitialized = true;
|
caseAddPanelInitialized = true;
|
||||||
}
|
}
|
||||||
@ -9707,28 +9594,15 @@
|
|||||||
if (typeof caseAddOriginalShowRelModal === 'function') {
|
if (typeof caseAddOriginalShowRelModal === 'function') {
|
||||||
window._showRelModal = caseAddOriginalShowRelModal;
|
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) {
|
function renderCaseAddWorkspaceModal(title, bodyHtml, footerBtns) {
|
||||||
const workspace = document.getElementById('caseAddSideWorkspace');
|
const workspace = document.getElementById('caseAddSideWorkspace');
|
||||||
if (!workspace) return;
|
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 = `
|
workspace.innerHTML = `
|
||||||
<div id="relQaModalEl" class="d-flex flex-column gap-2">
|
<div id="relQaModalEl" class="d-flex flex-column gap-2">
|
||||||
<div id="relQaModalTitle" class="section-title">${title}</div>
|
<div class="section-title">${title}</div>
|
||||||
<div id="relQaModalBody">${bodyHtml}</div>
|
<div id="relQaModalBody">${bodyHtml}</div>
|
||||||
<div id="relQaModalFooter" class="d-flex justify-content-end gap-2 border-top pt-2">
|
<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>
|
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="closeCaseModuleAddPanel()">Luk</button>
|
||||||
@ -9807,15 +9681,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('#relQaModalEl').forEach((existingRelQaEl) => {
|
const existingRelQaEl = document.getElementById('relQaModalEl');
|
||||||
if (!workspace.contains(existingRelQaEl)) {
|
if (existingRelQaEl && !workspace.contains(existingRelQaEl)) {
|
||||||
const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl);
|
const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl);
|
||||||
if (existingModalInstance) {
|
if (existingModalInstance) {
|
||||||
existingModalInstance.hide();
|
existingModalInstance.hide();
|
||||||
}
|
|
||||||
existingRelQaEl.remove();
|
|
||||||
}
|
}
|
||||||
});
|
existingRelQaEl.remove();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.resolve(relFn(caseIds, currentCaseTitle));
|
await Promise.resolve(relFn(caseIds, currentCaseTitle));
|
||||||
@ -13147,9 +13020,9 @@
|
|||||||
function getRelQaPrimaryButton() {
|
function getRelQaPrimaryButton() {
|
||||||
const sidePanel = document.getElementById('caseAddSidePanel');
|
const sidePanel = document.getElementById('caseAddSidePanel');
|
||||||
if (sidePanel && sidePanel.classList.contains('open')) {
|
if (sidePanel && sidePanel.classList.contains('open')) {
|
||||||
return document.querySelector('#caseAddSideWorkspace #relQaModalFooter .btn-primary');
|
return sidePanel.querySelector('#relQaModalFooter .btn-primary');
|
||||||
}
|
}
|
||||||
return document.querySelector('body > #relQaModalEl .btn-primary');
|
return document.querySelector('#relQaModalEl .btn-primary');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeRelQaSurfaceAfterSave() {
|
function closeRelQaSurfaceAfterSave() {
|
||||||
@ -13272,47 +13145,21 @@
|
|||||||
if (!fileInput.files.length) { if (typeof showNotification === 'function') showNotification('Vælg mindst én fil', 'warning'); return; }
|
if (!fileInput.files.length) { if (typeof showNotification === 'function') showNotification('Vælg mindst én fil', 'warning'); return; }
|
||||||
const saveBtn = getRelQaPrimaryButton();
|
const saveBtn = getRelQaPrimaryButton();
|
||||||
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploader…'; }
|
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploader…'; }
|
||||||
try {
|
let success = 0; let failed = 0;
|
||||||
const fd = new FormData();
|
for (const file of fileInput.files) {
|
||||||
for (const file of fileInput.files) {
|
try {
|
||||||
fd.append('files', file);
|
const fd = new FormData();
|
||||||
}
|
fd.append('file', file);
|
||||||
|
const desc = document.getElementById('rqf_desc').value;
|
||||||
const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd });
|
if (desc) fd.append('description', desc);
|
||||||
if (!r.ok) {
|
const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd });
|
||||||
const d = await r.json().catch(() => ({}));
|
if (r.ok) success++; else failed++;
|
||||||
if (typeof showNotification === 'function') showNotification(d.detail || 'Upload fejlede', 'error');
|
} catch { failed++; }
|
||||||
if (saveBtn) {
|
}
|
||||||
saveBtn.disabled = false;
|
closeRelQaSurfaceAfterSave();
|
||||||
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
|
if (typeof showNotification === 'function') {
|
||||||
}
|
if (failed === 0) showNotification(`${success} fil(er) uploadet ✓`, 'success');
|
||||||
return;
|
else showNotification(`${success} ok, ${failed} fejlede`, 'warning');
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -13822,7 +13669,7 @@
|
|||||||
window._submitRelNote = async function(caseId) {
|
window._submitRelNote = async function(caseId) {
|
||||||
const text = document.getElementById('rqn_text').value.trim();
|
const text = document.getElementById('rqn_text').value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
const saveBtn = getRelQaPrimaryButton();
|
const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
|
||||||
if (saveBtn) { saveBtn.disabled = true; }
|
if (saveBtn) { saveBtn.disabled = true; }
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/v1/sag/${caseId}/kommentarer`, {
|
const r = await fetch(`/api/v1/sag/${caseId}/kommentarer`, {
|
||||||
@ -13935,52 +13782,11 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
window._submitRelReminder = async function(caseId) {
|
window._submitRelReminder = async function(caseId) {
|
||||||
const whenInput = document.getElementById('rqr_at')?.value || '';
|
const payload = { sag_id: caseId, remind_at: document.getElementById('rqr_at').value, message: document.getElementById('rqr_msg').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();
|
const saveBtn = getRelQaPrimaryButton();
|
||||||
if (saveBtn) { saveBtn.disabled = true; }
|
if (saveBtn) { saveBtn.disabled = true; }
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/v1/sag/${caseId}/reminders?user_id=${encodeURIComponent(reminderUserId)}`, {
|
const r = await fetch('/api/v1/reminders', {
|
||||||
method: 'POST', credentials: 'include',
|
method: 'POST', credentials: 'include',
|
||||||
headers: {'Content-Type':'application/json'},
|
headers: {'Content-Type':'application/json'},
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@ -13994,12 +13800,8 @@
|
|||||||
|
|
||||||
// ── shared modal helper ───────────────────────────────────────────
|
// ── shared modal helper ───────────────────────────────────────────
|
||||||
window._showRelModal = function(title, bodyHtml, footerBtns) {
|
window._showRelModal = function(title, bodyHtml, footerBtns) {
|
||||||
let el = document.querySelector('body > #relQaModalEl.modal');
|
let el = document.getElementById('relQaModalEl');
|
||||||
if (!el) {
|
if (!el) {
|
||||||
const nonModal = document.getElementById('relQaModalEl');
|
|
||||||
if (nonModal && !nonModal.classList.contains('modal')) {
|
|
||||||
nonModal.remove();
|
|
||||||
}
|
|
||||||
el = document.createElement('div');
|
el = document.createElement('div');
|
||||||
el.id = 'relQaModalEl';
|
el.id = 'relQaModalEl';
|
||||||
el.className = 'modal fade';
|
el.className = 'modal fade';
|
||||||
|
|||||||
@ -5,10 +5,8 @@ REST API endpoints for managing remote support sessions
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import timedelta
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
@ -28,23 +26,6 @@ router = APIRouter()
|
|||||||
anydesk_service = AnyDeskService()
|
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
|
# Session Management Endpoints
|
||||||
# =====================================================
|
# =====================================================
|
||||||
@ -361,424 +342,6 @@ async def register_manual_session(data: dict):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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"])
|
@router.get("/anydesk/sessions", response_model=AnyDeskSessionHistory, tags=["Remote Support"])
|
||||||
async def get_session_history(
|
async def get_session_history(
|
||||||
contact_id: Optional[int] = None,
|
contact_id: Optional[int] = None,
|
||||||
@ -925,7 +488,7 @@ async def get_anydesk_stats():
|
|||||||
"sessions_this_month": 0,
|
"sessions_this_month": 0,
|
||||||
"active_sessions": 0,
|
"active_sessions": 0,
|
||||||
"average_duration_minutes": 0,
|
"average_duration_minutes": 0,
|
||||||
"total_support_hours": 0.0
|
"total_support_hours": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get today's sessions
|
# Get today's sessions
|
||||||
@ -1113,162 +676,8 @@ async def sessions_overview(
|
|||||||
count_q = f"SELECT COUNT(*) AS total FROM anydesk_sessions s {where}"
|
count_q = f"SELECT COUNT(*) AS total FROM anydesk_sessions s {where}"
|
||||||
total = (execute_query(count_q) or [{"total": 0}])[0]["total"]
|
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 = []
|
sessions = []
|
||||||
for r in merged_rows:
|
for r in (rows or []):
|
||||||
sessions.append({
|
sessions.append({
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"anydesk_session_id": r["anydesk_session_id"],
|
"anydesk_session_id": r["anydesk_session_id"],
|
||||||
@ -1306,8 +715,7 @@ async def sessions_overview(
|
|||||||
} if r["sag_id"] else None,
|
} if r["sag_id"] else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Return deduplicated total for UI consistency on overview endpoint.
|
return JSONResponse(content={"sessions": sessions, "total": total, "limit": limit, "offset": offset})
|
||||||
return JSONResponse(content={"sessions": sessions, "total": len(sessions), "limit": limit, "offset": offset, "raw_total": total})
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in sessions_overview: {e}")
|
logger.error(f"Error in sessions_overview: {e}")
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,9 +9,8 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import base64
|
import base64
|
||||||
import time
|
import time
|
||||||
from uuid import uuid4
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any
|
||||||
import httpx
|
import httpx
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
@ -578,232 +577,3 @@ class AnyDeskService:
|
|||||||
"total_from_api": len(entries),
|
"total_from_api": len(entries),
|
||||||
"errors": errors,
|
"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,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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 på 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"
|
|
||||||
}
|
|
||||||
@ -11,24 +11,17 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-body: #f8f9fa;
|
--bg-body: #f8f9fa;
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--bg-card-rgb: 255, 255, 255;
|
|
||||||
--text-primary: #2c3e50;
|
--text-primary: #2c3e50;
|
||||||
--text-primary-rgb: 44, 62, 80;
|
|
||||||
--text-secondary: #6c757d;
|
--text-secondary: #6c757d;
|
||||||
--accent: #0f4c75;
|
--accent: #0f4c75;
|
||||||
--accent-light: #eef2f5;
|
--accent-light: #eef2f5;
|
||||||
--border-radius: 12px;
|
--border-radius: 12px;
|
||||||
--bottom-bar-height: 50px;
|
|
||||||
--bottom-bar-expanded-height: 50vh;
|
|
||||||
--bottom-bar-zindex: 1030;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="dark"] {
|
[data-bs-theme="dark"] {
|
||||||
--bg-body: #212529;
|
--bg-body: #212529;
|
||||||
--bg-card: #2c3034;
|
--bg-card: #2c3034;
|
||||||
--bg-card-rgb: 44, 48, 52;
|
|
||||||
--text-primary: #f8f9fa;
|
--text-primary: #f8f9fa;
|
||||||
--text-primary-rgb: 248, 249, 250;
|
|
||||||
--text-secondary: #adb5bd;
|
--text-secondary: #adb5bd;
|
||||||
--accent: #3d8bfd; /* Lighter blue for dark mode */
|
--accent: #3d8bfd; /* Lighter blue for dark mode */
|
||||||
--accent-light: #373b3e;
|
--accent-light: #373b3e;
|
||||||
@ -42,403 +35,6 @@
|
|||||||
transition: background-color 0.3s, color 0.3s;
|
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 {
|
.navbar {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
||||||
@ -649,8 +245,7 @@
|
|||||||
<ul class="dropdown-menu mt-2">
|
<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="/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="/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>BMC Assets</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/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="/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="/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>
|
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
|
||||||
@ -955,75 +550,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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>
|
<script>
|
||||||
window.addEventListener('unhandledrejection', function(event) {
|
window.addEventListener('unhandledrejection', function(event) {
|
||||||
const reason = event && event.reason;
|
const reason = event && event.reason;
|
||||||
@ -1042,7 +568,6 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=2.2"></script>
|
<script src="/static/js/telefoni.js?v=2.2"></script>
|
||||||
<script src="/static/js/sms.js?v=1.0"></script>
|
<script src="/static/js/sms.js?v=1.0"></script>
|
||||||
<script src="/static/js/bottom-bar.js?v=2.15"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
const darkModeToggle = document.getElementById('darkModeToggle');
|
||||||
@ -1739,9 +1264,6 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
|
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
|
||||||
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
|
{% 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 -->
|
<!-- Profile Modal -->
|
||||||
<div class="modal fade" id="profileModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="profileModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
<!-- 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>
|
|
||||||
@ -23,7 +23,6 @@ ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"}
|
|||||||
STAGING_KEY_SQL = "COALESCE(source_account_id, 'name:' || LOWER(COALESCE(source_customer_name, 'ukendt')))"
|
STAGING_KEY_SQL = "COALESCE(source_account_id, 'name:' || LOWER(COALESCE(source_customer_name, 'ukendt')))"
|
||||||
ALLOWED_BILLING_DIRECTIONS = {"forward", "backward"}
|
ALLOWED_BILLING_DIRECTIONS = {"forward", "backward"}
|
||||||
ALLOWED_PRICE_CHANGE_STATUSES = {"pending", "approved", "rejected", "applied"}
|
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:
|
def _staging_status_with_mapping(status: str, has_customer: bool) -> str:
|
||||||
@ -75,110 +74,6 @@ def _next_invoice_date(start_date: date, interval: str) -> date:
|
|||||||
return start_date + relativedelta(months=1)
|
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]:
|
def _auto_map_customer(account_id: Optional[str], customer_name: Optional[str], customer_cvr: Optional[str]) -> Optional[int]:
|
||||||
if account_id:
|
if account_id:
|
||||||
row = execute_query_single(
|
row = execute_query_single(
|
||||||
@ -273,9 +168,6 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
billing_day = payload.get("billing_day")
|
billing_day = payload.get("billing_day")
|
||||||
start_date = payload.get("start_date")
|
start_date = payload.get("start_date")
|
||||||
billing_direction = (payload.get("billing_direction") or "forward").strip().lower()
|
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)
|
advance_months = int(payload.get("advance_months") or 1)
|
||||||
first_full_period_start = payload.get("first_full_period_start")
|
first_full_period_start = payload.get("first_full_period_start")
|
||||||
binding_months = int(payload.get("binding_months") or 0)
|
binding_months = int(payload.get("binding_months") or 0)
|
||||||
@ -297,23 +189,13 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
raise HTTPException(status_code=400, detail="start_date is required")
|
raise HTTPException(status_code=400, detail="start_date is required")
|
||||||
if not line_items:
|
if not line_items:
|
||||||
raise HTTPException(status_code=400, detail="line_items is required")
|
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:
|
if billing_direction not in ALLOWED_BILLING_DIRECTIONS:
|
||||||
raise HTTPException(status_code=400, detail="billing_direction must be forward or backward")
|
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:
|
if advance_months < 1 or advance_months > 24:
|
||||||
raise HTTPException(status_code=400, detail="advance_months must be between 1 and 24")
|
raise HTTPException(status_code=400, detail="advance_months must be between 1 and 24")
|
||||||
if binding_months < 0:
|
if binding_months < 0:
|
||||||
raise HTTPException(status_code=400, detail="binding_months must be >= 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(
|
sag = execute_query_single(
|
||||||
"SELECT id, customer_id FROM sag_sager WHERE id = %s",
|
"SELECT id, customer_id FROM sag_sager WHERE id = %s",
|
||||||
(sag_id,)
|
(sag_id,)
|
||||||
@ -347,7 +229,6 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
product_map = {row["id"]: row for row in (rows or [])}
|
product_map = {row["id"]: row for row in (rows or [])}
|
||||||
|
|
||||||
cleaned_items = []
|
cleaned_items = []
|
||||||
line_asset_ranges: Dict[int, List[tuple[date, Optional[date]]]] = {}
|
|
||||||
total_price = 0
|
total_price = 0
|
||||||
blocked_reasons = []
|
blocked_reasons = []
|
||||||
for idx, item in enumerate(line_items, start=1):
|
for idx, item in enumerate(line_items, start=1):
|
||||||
@ -359,10 +240,6 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
serial_number = (item.get("serial_number") or "").strip() or None
|
serial_number = (item.get("serial_number") or "").strip() or None
|
||||||
period_from = item.get("period_from")
|
period_from = item.get("period_from")
|
||||||
period_to = item.get("period_to")
|
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)
|
product = product_map.get(product_id)
|
||||||
if not description and product:
|
if not description and product:
|
||||||
@ -384,21 +261,6 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
)
|
)
|
||||||
if not asset:
|
if not asset:
|
||||||
raise HTTPException(status_code=400, detail=f"asset_id {asset_id} was not found")
|
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_asset = bool(product and product.get("asset_required"))
|
||||||
requires_serial_number = bool(product and product.get("serial_number_required"))
|
requires_serial_number = bool(product and product.get("serial_number_required"))
|
||||||
@ -424,8 +286,6 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
"line_total": line_total,
|
"line_total": line_total,
|
||||||
"period_from": period_from,
|
"period_from": period_from,
|
||||||
"period_to": period_to,
|
"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,
|
"requires_serial_number": requires_serial_number,
|
||||||
"serial_number": serial_number,
|
"serial_number": serial_number,
|
||||||
"billing_blocked": billing_blocked,
|
"billing_blocked": billing_blocked,
|
||||||
@ -448,6 +308,7 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
|
|
||||||
# Calculate next_invoice_date based on billing_interval
|
# Calculate next_invoice_date based on billing_interval
|
||||||
|
|
||||||
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||||
period_start = start_dt
|
period_start = start_dt
|
||||||
|
|
||||||
# Calculate next invoice date
|
# Calculate next invoice date
|
||||||
@ -488,9 +349,6 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
binding_group_key,
|
binding_group_key,
|
||||||
billing_blocked,
|
billing_blocked,
|
||||||
billing_block_reason,
|
billing_block_reason,
|
||||||
price_type,
|
|
||||||
custom_price_override,
|
|
||||||
first_invoice_policy,
|
|
||||||
invoice_merge_key,
|
invoice_merge_key,
|
||||||
price_change_case_id,
|
price_change_case_id,
|
||||||
renewal_case_id,
|
renewal_case_id,
|
||||||
@ -499,7 +357,7 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||||
%s, %s, %s, %s, 'draft', %s
|
%s, 'draft', %s
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
@ -522,9 +380,6 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
binding_group_key,
|
binding_group_key,
|
||||||
billing_blocked,
|
billing_blocked,
|
||||||
billing_block_reason,
|
billing_block_reason,
|
||||||
price_type,
|
|
||||||
custom_price_override,
|
|
||||||
first_invoice_policy,
|
|
||||||
invoice_merge_key,
|
invoice_merge_key,
|
||||||
price_change_case_id,
|
price_change_case_id,
|
||||||
renewal_case_id,
|
renewal_case_id,
|
||||||
@ -547,13 +402,11 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
line_total,
|
line_total,
|
||||||
period_from,
|
period_from,
|
||||||
period_to,
|
period_to,
|
||||||
price_type,
|
|
||||||
custom_price_override,
|
|
||||||
requires_serial_number,
|
requires_serial_number,
|
||||||
serial_number,
|
serial_number,
|
||||||
billing_blocked,
|
billing_blocked,
|
||||||
billing_block_reason
|
billing_block_reason
|
||||||
) VALUES (%s, %s, %s, %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)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
subscription["id"],
|
subscription["id"],
|
||||||
@ -566,8 +419,6 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
item["line_total"],
|
item["line_total"],
|
||||||
item["period_from"],
|
item["period_from"],
|
||||||
item["period_to"],
|
item["period_to"],
|
||||||
item["price_type"],
|
|
||||||
item["custom_price_override"],
|
|
||||||
item["requires_serial_number"],
|
item["requires_serial_number"],
|
||||||
item["serial_number"],
|
item["serial_number"],
|
||||||
item["billing_blocked"],
|
item["billing_blocked"],
|
||||||
@ -693,7 +544,6 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
|||||||
"billing_direction", "advance_months", "first_full_period_start",
|
"billing_direction", "advance_months", "first_full_period_start",
|
||||||
"binding_months", "binding_start_date", "binding_end_date", "binding_group_key",
|
"binding_months", "binding_start_date", "binding_end_date", "binding_group_key",
|
||||||
"billing_blocked", "billing_block_reason", "invoice_merge_key",
|
"billing_blocked", "billing_block_reason", "invoice_merge_key",
|
||||||
"price_type", "custom_price_override", "first_invoice_policy",
|
|
||||||
"price_change_case_id", "renewal_case_id"
|
"price_change_case_id", "renewal_case_id"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -751,10 +601,9 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
|||||||
subscription_id, line_no, description,
|
subscription_id, line_no, description,
|
||||||
quantity, unit_price, line_total, product_id,
|
quantity, unit_price, line_total, product_id,
|
||||||
asset_id, period_from, period_to,
|
asset_id, period_from, period_to,
|
||||||
price_type, custom_price_override,
|
|
||||||
requires_serial_number, serial_number,
|
requires_serial_number, serial_number,
|
||||||
billing_blocked, billing_block_reason
|
billing_blocked, billing_block_reason
|
||||||
) VALUES (%s, %s, %s, %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)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
subscription_id, idx, description,
|
subscription_id, idx, description,
|
||||||
@ -763,8 +612,6 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
|||||||
item.get("asset_id"),
|
item.get("asset_id"),
|
||||||
item.get("period_from"),
|
item.get("period_from"),
|
||||||
item.get("period_to"),
|
item.get("period_to"),
|
||||||
item.get("price_type", "manual"),
|
|
||||||
bool(item.get("custom_price_override")),
|
|
||||||
bool(item.get("requires_serial_number")),
|
bool(item.get("requires_serial_number")),
|
||||||
item.get("serial_number"),
|
item.get("serial_number"),
|
||||||
bool(item.get("billing_blocked")),
|
bool(item.get("billing_blocked")),
|
||||||
@ -1233,8 +1080,6 @@ async def create_subscription_asset_binding(subscription_id: int, payload: Dict[
|
|||||||
if not end_date and binding_months > 0:
|
if not end_date and binding_months > 0:
|
||||||
end_date = start_date + relativedelta(months=binding_months)
|
end_date = start_date + relativedelta(months=binding_months)
|
||||||
|
|
||||||
_ensure_binding_not_overlapping(asset_id, start_date, end_date)
|
|
||||||
|
|
||||||
result = execute_query(
|
result = execute_query(
|
||||||
"""
|
"""
|
||||||
INSERT INTO subscription_asset_bindings (
|
INSERT INTO subscription_asset_bindings (
|
||||||
@ -1266,8 +1111,6 @@ async def create_subscription_asset_binding(subscription_id: int, payload: Dict[
|
|||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=500, detail="Could not create binding")
|
raise HTTPException(status_code=500, detail="Could not create binding")
|
||||||
|
|
||||||
_sync_asset_rental_status(asset_id)
|
|
||||||
|
|
||||||
execute_query(
|
execute_query(
|
||||||
"""
|
"""
|
||||||
UPDATE sag_subscription_items
|
UPDATE sag_subscription_items
|
||||||
@ -1322,23 +1165,6 @@ async def update_subscription_asset_binding(binding_id: int, payload: Dict[str,
|
|||||||
raise HTTPException(status_code=404, detail="Binding not found")
|
raise HTTPException(status_code=404, detail="Binding not found")
|
||||||
return existing
|
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)
|
values.append(binding_id)
|
||||||
result = execute_query(
|
result = execute_query(
|
||||||
f"""
|
f"""
|
||||||
@ -1353,9 +1179,7 @@ async def update_subscription_asset_binding(binding_id: int, payload: Dict[str,
|
|||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Binding not found")
|
raise HTTPException(status_code=404, detail="Binding not found")
|
||||||
updated = result[0]
|
return result[0]
|
||||||
_sync_asset_rental_status(int(updated.get("asset_id")))
|
|
||||||
return updated
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -1380,12 +1204,6 @@ async def delete_subscription_asset_binding(binding_id: int):
|
|||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Binding not found")
|
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")}
|
return {"status": "deleted", "id": result[0].get("id")}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@ -1,5 +1,16 @@
|
|||||||
from main import app
|
#!/usr/bin/env python3
|
||||||
for route in app.routes:
|
import main
|
||||||
if hasattr(route, 'methods'):
|
|
||||||
if '/manual' in route.path:
|
print("=" * 80)
|
||||||
print(f"{list(route.methods)} {route.path}")
|
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)}")
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
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
22
main.py
@ -133,9 +133,6 @@ from app.modules.orders.backend import router as orders_api
|
|||||||
from app.modules.orders.frontend import views as orders_views
|
from app.modules.orders.frontend import views as orders_views
|
||||||
from app.modules.manual.backend import router as manual_api
|
from app.modules.manual.backend import router as manual_api
|
||||||
from app.modules.manual.frontend import views as manual_views
|
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
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -216,22 +213,6 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
logger.info("✅ ESET sync job scheduled (every %d minutes)", settings.ESET_SYNC_INTERVAL_MINUTES)
|
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:
|
if settings.LINKS_MODULE_ENABLED and settings.LINKS_DEAD_LINK_CHECK_ENABLED:
|
||||||
from app.modules.links.jobs.dead_link_check import check_links_health
|
from app.modules.links.jobs.dead_link_check import check_links_health
|
||||||
|
|
||||||
@ -449,9 +430,6 @@ 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(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
||||||
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
|
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(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:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.backend import router as links_api
|
from app.modules.links.backend import router as links_api
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
-- 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();
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
-- 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();
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
-- 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.';
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
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 på 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 på 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)
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
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 på 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)
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
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))
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
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('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>');
|
|
||||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
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
457
patch_lækker.py
@ -1,457 +0,0 @@
|
|||||||
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 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="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 på 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)
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
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)
|
|
||||||
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,264 +0,0 @@
|
|||||||
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('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>');
|
|
||||||
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)
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
## 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.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,23 +0,0 @@
|
|||||||
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!")
|
|
||||||
Loading…
Reference in New Issue
Block a user