feat: Add bottom bar functionality with real-time updates and manual endpoint tests
- Implemented a new bottom bar feature in `bottom-bar.js` that fetches and displays various notifications and statuses in real-time. - Added functions for handling visibility, state updates, and user interactions within the bottom bar. - Introduced WebSocket connection for real-time updates and fallback polling mechanism. - Created a manual testing script `test_manual.py` to validate API endpoints for the manual module. - Included tests for various paths to ensure expected responses from the server.
This commit is contained in:
parent
270af0e277
commit
ceb560e2f2
@ -287,6 +287,9 @@ 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,6 +1004,8 @@
|
|||||||
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>
|
||||||
|
|||||||
0
app/modules/bottom_bar/__init__.py
Normal file
0
app/modules/bottom_bar/__init__.py
Normal file
0
app/modules/bottom_bar/backend/__init__.py
Normal file
0
app/modules/bottom_bar/backend/__init__.py
Normal file
121
app/modules/bottom_bar/backend/public_router.py
Normal file
121
app/modules/bottom_bar/backend/public_router.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
from .service import get_active_timer, get_dashboard_status, get_notifications
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_id_from_request(request: Request) -> Optional[int]:
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if user_id is not None:
|
||||||
|
try:
|
||||||
|
return int(user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id_param = request.query_params.get("user_id")
|
||||||
|
if user_id_param:
|
||||||
|
try:
|
||||||
|
return int(user_id_param)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ws_payload(websocket: WebSocket) -> Optional[dict]:
|
||||||
|
token = websocket.query_params.get("token")
|
||||||
|
auth_header = (websocket.headers.get("authorization") or "").strip()
|
||||||
|
if not token and auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
|
||||||
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
if not payload:
|
||||||
|
access_cookie_token = (websocket.cookies.get("access_token") or "").strip() or None
|
||||||
|
payload = AuthService.verify_token(access_cookie_token) if access_cookie_token else None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/dashboard/status")
|
||||||
|
async def get_dashboard_status_endpoint() -> dict:
|
||||||
|
return get_dashboard_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/timer/active")
|
||||||
|
async def get_active_timer_endpoint(request: Request) -> dict:
|
||||||
|
user_id = _resolve_user_id_from_request(request)
|
||||||
|
return get_active_timer(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/notifications")
|
||||||
|
async def get_notifications_endpoint(request: Request, limit: int = 20) -> dict:
|
||||||
|
user_id = _resolve_user_id_from_request(request)
|
||||||
|
return get_notifications(user_id, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/bottom-bar/ws")
|
||||||
|
async def bottom_bar_ws(websocket: WebSocket):
|
||||||
|
payload = _resolve_ws_payload(websocket)
|
||||||
|
if not payload:
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = int(payload.get("sub")) if payload.get("sub") is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
initial_status = get_dashboard_status()
|
||||||
|
initial_notifications = get_notifications(user_id, limit=20)
|
||||||
|
await websocket.send_json({"event": "status_delta", "data": initial_status})
|
||||||
|
await websocket.send_json({"event": "notification_delta", "data": initial_notifications})
|
||||||
|
|
||||||
|
last_status_json = json.dumps(initial_status, sort_keys=True, default=str)
|
||||||
|
last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str)
|
||||||
|
last_timer_elapsed = -1
|
||||||
|
status_tick = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
timer = get_active_timer(user_id)
|
||||||
|
elapsed = int(timer.get("elapsed") or 0)
|
||||||
|
if elapsed != last_timer_elapsed:
|
||||||
|
await websocket.send_json({"event": "timer_tick", "data": timer})
|
||||||
|
last_timer_elapsed = elapsed
|
||||||
|
|
||||||
|
status_tick += 1
|
||||||
|
if status_tick >= 5:
|
||||||
|
status = get_dashboard_status()
|
||||||
|
notifications = get_notifications(user_id, limit=20)
|
||||||
|
|
||||||
|
status_json = json.dumps(status, sort_keys=True, default=str)
|
||||||
|
if status_json != last_status_json:
|
||||||
|
await websocket.send_json({"event": "status_delta", "data": status})
|
||||||
|
last_status_json = status_json
|
||||||
|
|
||||||
|
notifications_json = json.dumps(notifications, sort_keys=True, default=str)
|
||||||
|
if notifications_json != last_notifications_json:
|
||||||
|
await websocket.send_json({"event": "notification_delta", "data": notifications})
|
||||||
|
last_notifications_json = notifications_json
|
||||||
|
|
||||||
|
status_tick = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("Bottom bar websocket disconnected user_id=%s", user_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Bottom bar websocket error user_id=%s error=%s", user_id, exc)
|
||||||
288
app/modules/bottom_bar/backend/router.py
Normal file
288
app/modules/bottom_bar/backend/router.py
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
from app.core.auth_dependencies import get_current_user
|
||||||
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
|
||||||
|
from .service import build_bottom_bar_state
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class BossAssignPayload(BaseModel):
|
||||||
|
case_id: int
|
||||||
|
assignee_user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class BossAssignNextPayload(BaseModel):
|
||||||
|
assignee_user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_id_from_request(request: Request) -> Optional[int]:
|
||||||
|
state_user_id = getattr(request.state, "user_id", None)
|
||||||
|
if state_user_id is not None:
|
||||||
|
try:
|
||||||
|
return int(state_user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
user_id_param = request.query_params.get("user_id")
|
||||||
|
if user_id_param:
|
||||||
|
try:
|
||||||
|
return int(user_id_param)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
token = (request.cookies.get("access_token") or "").strip() or None
|
||||||
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
sub_claim = payload.get("sub") if payload else None
|
||||||
|
if sub_claim is not None:
|
||||||
|
try:
|
||||||
|
return int(sub_claim)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
@router.get("/state")
|
||||||
|
async def get_bottom_bar_state(request: Request, current_user: dict = Depends(get_current_user)):
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
user_id = int(current_user_id)
|
||||||
|
force_boss_access = bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin"))
|
||||||
|
context_path = request.query_params.get("context") or ""
|
||||||
|
return build_bottom_bar_state(user_id, context_path=context_path, force_boss_access=force_boss_access)
|
||||||
|
|
||||||
|
from app.services.task_routing import TaskRouter
|
||||||
|
from app.services.m365_calendar import M365CalendarService
|
||||||
|
|
||||||
|
|
||||||
|
def _has_boss_access(current_user: dict) -> bool:
|
||||||
|
if bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")):
|
||||||
|
return True
|
||||||
|
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT LOWER(g.name) AS name
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN groups g ON g.id = ug.group_id
|
||||||
|
WHERE ug.user_id = %s
|
||||||
|
""",
|
||||||
|
(int(current_user_id),),
|
||||||
|
) or []
|
||||||
|
names = [str(r.get("name") or "") for r in rows]
|
||||||
|
tokens = ("admin", "manager", "leder", "chef", "teknik", "technician", "support")
|
||||||
|
return any(any(token in name for token in tokens) for name in names)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_user_exists(user_id: int) -> None:
|
||||||
|
user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Bruger ikke fundet")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_next_unassigned_case() -> Optional[dict]:
|
||||||
|
return execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id, titel, priority
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND ansvarlig_bruger_id IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0
|
||||||
|
WHEN LOWER(COALESCE(priority, 'normal')) IN ('high', 'høj') THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
COALESCE(updated_at, created_at) ASC,
|
||||||
|
id ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/next_task")
|
||||||
|
async def assign_next_task(
|
||||||
|
request: Request,
|
||||||
|
user_id: int | None = Query(default=None),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Prefer authenticated user context; allow explicit user_id for controlled testing.
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
resolved_user_id = user_id
|
||||||
|
if resolved_user_id is None and current_user_id is not None:
|
||||||
|
resolved_user_id = int(current_user_id)
|
||||||
|
if resolved_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required for task assignment")
|
||||||
|
|
||||||
|
# Kombinerer de nye services
|
||||||
|
router_svc = TaskRouter()
|
||||||
|
cal = M365CalendarService()
|
||||||
|
|
||||||
|
# Henter hvor meget fri tid medarbejderen har lige nu
|
||||||
|
free_mins = await cal.get_user_free_time("now", 2)
|
||||||
|
|
||||||
|
# Bed the engine allocate the next best task
|
||||||
|
task = await router_svc.get_next_best_task(resolved_user_id)
|
||||||
|
task = task or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "assigned",
|
||||||
|
"task": task,
|
||||||
|
"free_time_calculated": free_mins,
|
||||||
|
"message": f"Fandt Næste Opgave (SLA: {task.get('assigned_reason')} - {task.get('estimated_minutes')}m. Du har {free_mins}m frit). "
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/boss/auto-assign-next")
|
||||||
|
async def boss_auto_assign_next(current_user: dict = Depends(get_current_user)):
|
||||||
|
if not _has_boss_access(current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
|
||||||
|
|
||||||
|
next_case = _get_next_unassigned_case()
|
||||||
|
if not next_case:
|
||||||
|
return {
|
||||||
|
"status": "noop",
|
||||||
|
"message": "Ingen ufordelte åbne sager at fordele.",
|
||||||
|
}
|
||||||
|
|
||||||
|
assignee = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
|
||||||
|
COUNT(s.id)::int AS open_cases,
|
||||||
|
COUNT(CASE WHEN LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'critical', 'kritisk', 'high', 'høj') THEN 1 END)::int AS hot_cases
|
||||||
|
FROM users u
|
||||||
|
JOIN user_groups ug ON ug.user_id = u.user_id
|
||||||
|
JOIN groups g ON g.id = ug.group_id
|
||||||
|
LEFT JOIN sag_sager s
|
||||||
|
ON s.ansvarlig_bruger_id = u.user_id
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
WHERE LOWER(g.name) LIKE ANY(ARRAY['%admin%', '%manager%', '%leder%', '%chef%', '%teknik%', '%technician%', '%support%'])
|
||||||
|
GROUP BY u.user_id, u.full_name, u.username
|
||||||
|
ORDER BY hot_cases ASC, open_cases ASC, owner_name ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if not assignee:
|
||||||
|
raise HTTPException(status_code=409, detail="Ingen kvalificeret medarbejder fundet til auto-fordeling")
|
||||||
|
|
||||||
|
updated = execute_query_single(
|
||||||
|
"""
|
||||||
|
UPDATE sag_sager
|
||||||
|
SET ansvarlig_bruger_id = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, titel, priority, ansvarlig_bruger_id
|
||||||
|
""",
|
||||||
|
(int(assignee["user_id"]), int(next_case["id"])),
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=500, detail="Kunne ikke opdatere sag")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "assigned",
|
||||||
|
"message": "Sagen blev auto-fordelt.",
|
||||||
|
"case": {
|
||||||
|
"id": updated.get("id"),
|
||||||
|
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
|
||||||
|
"priority": updated.get("priority") or "normal",
|
||||||
|
},
|
||||||
|
"assignee": {
|
||||||
|
"user_id": assignee.get("user_id"),
|
||||||
|
"name": assignee.get("owner_name") or f"Bruger #{assignee.get('user_id')}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/boss/assign-case")
|
||||||
|
async def boss_assign_case(payload: BossAssignPayload, current_user: dict = Depends(get_current_user)):
|
||||||
|
if not _has_boss_access(current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
|
||||||
|
|
||||||
|
_ensure_user_exists(int(payload.assignee_user_id))
|
||||||
|
|
||||||
|
case_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id, titel, priority
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE id = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
""",
|
||||||
|
(int(payload.case_id),),
|
||||||
|
)
|
||||||
|
if not case_row:
|
||||||
|
raise HTTPException(status_code=404, detail="Sag ikke fundet eller er afsluttet")
|
||||||
|
|
||||||
|
updated = execute_query_single(
|
||||||
|
"""
|
||||||
|
UPDATE sag_sager
|
||||||
|
SET ansvarlig_bruger_id = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, titel, priority, ansvarlig_bruger_id
|
||||||
|
""",
|
||||||
|
(int(payload.assignee_user_id), int(payload.case_id)),
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=500, detail="Kunne ikke tildele sag")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "assigned",
|
||||||
|
"message": "Sagen blev tildelt.",
|
||||||
|
"case": {
|
||||||
|
"id": updated.get("id"),
|
||||||
|
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
|
||||||
|
"priority": updated.get("priority") or "normal",
|
||||||
|
},
|
||||||
|
"assignee_user_id": int(payload.assignee_user_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/boss/assign-next-to-user")
|
||||||
|
async def boss_assign_next_to_user(payload: BossAssignNextPayload, current_user: dict = Depends(get_current_user)):
|
||||||
|
if not _has_boss_access(current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
|
||||||
|
|
||||||
|
_ensure_user_exists(int(payload.assignee_user_id))
|
||||||
|
|
||||||
|
next_case = _get_next_unassigned_case()
|
||||||
|
if not next_case:
|
||||||
|
return {
|
||||||
|
"status": "noop",
|
||||||
|
"message": "Ingen ufordelte åbne sager at tildele.",
|
||||||
|
}
|
||||||
|
|
||||||
|
updated = execute_query_single(
|
||||||
|
"""
|
||||||
|
UPDATE sag_sager
|
||||||
|
SET ansvarlig_bruger_id = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, titel, priority, ansvarlig_bruger_id
|
||||||
|
""",
|
||||||
|
(int(payload.assignee_user_id), int(next_case["id"])),
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=500, detail="Kunne ikke tildele næste sag")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "assigned",
|
||||||
|
"message": "Næste ufordelte sag blev tildelt.",
|
||||||
|
"case": {
|
||||||
|
"id": updated.get("id"),
|
||||||
|
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
|
||||||
|
"priority": updated.get("priority") or "normal",
|
||||||
|
},
|
||||||
|
"assignee_user_id": int(payload.assignee_user_id),
|
||||||
|
}
|
||||||
656
app/modules/bottom_bar/backend/service.py
Normal file
656
app/modules/bottom_bar/backend/service.py
Normal file
@ -0,0 +1,656 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CLOSED_CASE_STATUSES = ("lukket", "løst", "closed", "resolved")
|
||||||
|
URGENT_PRIORITIES = ("urgent", "high", "kritisk", "critical")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_count(row: Optional[dict], key: str = "count") -> int:
|
||||||
|
if not row:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(row.get(key) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _format_elapsed(seconds: int) -> str:
|
||||||
|
total = max(0, int(seconds or 0))
|
||||||
|
hours = total // 3600
|
||||||
|
minutes = (total % 3600) // 60
|
||||||
|
secs = total % 60
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _priority_rank(priority: str) -> int:
|
||||||
|
normalized = str(priority or "").strip().lower()
|
||||||
|
if normalized in {"urgent", "critical", "kritisk"}:
|
||||||
|
return 3
|
||||||
|
if normalized in {"high", "høj"}:
|
||||||
|
return 2
|
||||||
|
if normalized in {"normal", "medium", "middel"}:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_group_names(user_id: Optional[int]) -> List[str]:
|
||||||
|
if user_id is None:
|
||||||
|
return []
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT LOWER(g.name) AS name
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN groups g ON g.id = ug.group_id
|
||||||
|
WHERE ug.user_id = %s
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
) or []
|
||||||
|
return [str(r.get("name") or "").strip() for r in rows if r.get("name")]
|
||||||
|
|
||||||
|
|
||||||
|
def _can_view_boss_tab(user_id: Optional[int]) -> bool:
|
||||||
|
if user_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
group_names = _get_user_group_names(user_id)
|
||||||
|
if not group_names:
|
||||||
|
# Fail-open for authenticated users if group mapping is missing.
|
||||||
|
return True
|
||||||
|
|
||||||
|
leadership_tokens = (
|
||||||
|
"admin",
|
||||||
|
"manager",
|
||||||
|
"leder",
|
||||||
|
"chef",
|
||||||
|
"teknik",
|
||||||
|
"technician",
|
||||||
|
"support",
|
||||||
|
"drift",
|
||||||
|
"it",
|
||||||
|
)
|
||||||
|
return any(
|
||||||
|
any(token in group for token in leadership_tokens)
|
||||||
|
for group in group_names
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_bottom_bar_enabled(user_id: Optional[int]) -> bool:
|
||||||
|
setting = execute_query_single("SELECT value FROM settings WHERE key = %s", ("bottom_bar_enabled",))
|
||||||
|
setting_value = str((setting or {}).get("value") or "").strip().lower()
|
||||||
|
if setting_value not in {"1", "true", "yes", "on"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
pref = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT enabled
|
||||||
|
FROM user_module_preferences
|
||||||
|
WHERE user_id = %s AND module_name = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id, "bottom_bar"),
|
||||||
|
)
|
||||||
|
if pref and pref.get("enabled") is not None:
|
||||||
|
return bool(pref.get("enabled"))
|
||||||
|
|
||||||
|
role = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT mrs.enabled
|
||||||
|
FROM module_role_settings mrs
|
||||||
|
JOIN user_groups ug ON ug.group_id = mrs.group_id
|
||||||
|
WHERE ug.user_id = %s
|
||||||
|
AND mrs.module_name = %s
|
||||||
|
ORDER BY mrs.enabled DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id, "bottom_bar"),
|
||||||
|
)
|
||||||
|
if role and role.get("enabled") is not None:
|
||||||
|
return bool(role.get("enabled"))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_dashboard_status() -> Dict[str, int]:
|
||||||
|
mails_unread = _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM email_messages
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND COALESCE(is_read, FALSE) = FALSE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sager_open = _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sager_urgent = _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sager_unassigned = _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND ansvarlig_bruger_id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mails_unread": mails_unread,
|
||||||
|
"sager_open": sager_open,
|
||||||
|
"sager_urgent": sager_urgent,
|
||||||
|
"sager_unassigned": sager_unassigned,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_timer(user_id: Optional[int]) -> Dict[str, Any]:
|
||||||
|
if user_id is None:
|
||||||
|
return {
|
||||||
|
"active": False,
|
||||||
|
"sag_id": None,
|
||||||
|
"sag_navn": None,
|
||||||
|
"start_tid": None,
|
||||||
|
"elapsed": 0,
|
||||||
|
"elapsed_hhmmss": "00:00:00",
|
||||||
|
"time_entry_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.sag_id,
|
||||||
|
s.titel AS sag_navn,
|
||||||
|
t.start_tid,
|
||||||
|
GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int, 0) AS elapsed
|
||||||
|
FROM tmodule_times t
|
||||||
|
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||||
|
WHERE t.medarbejder_id = %s
|
||||||
|
AND t.aktiv_timer = TRUE
|
||||||
|
AND t.slut_tid IS NULL
|
||||||
|
ORDER BY t.start_tid DESC NULLS LAST, t.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not timer:
|
||||||
|
return {
|
||||||
|
"active": False,
|
||||||
|
"sag_id": None,
|
||||||
|
"sag_navn": None,
|
||||||
|
"start_tid": None,
|
||||||
|
"elapsed": 0,
|
||||||
|
"elapsed_hhmmss": "00:00:00",
|
||||||
|
"time_entry_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed = int(timer.get("elapsed") or 0)
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"sag_id": timer.get("sag_id"),
|
||||||
|
"sag_navn": timer.get("sag_navn"),
|
||||||
|
"start_tid": timer.get("start_tid"),
|
||||||
|
"elapsed": elapsed,
|
||||||
|
"elapsed_hhmmss": _format_elapsed(elapsed),
|
||||||
|
"time_entry_id": timer.get("id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]:
|
||||||
|
if user_id is None:
|
||||||
|
return {"items": [], "count": 0}
|
||||||
|
|
||||||
|
limit_safe = max(1, min(int(limit or 20), 100))
|
||||||
|
|
||||||
|
reminders = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.sag_id,
|
||||||
|
r.title,
|
||||||
|
r.message,
|
||||||
|
r.priority,
|
||||||
|
r.event_type,
|
||||||
|
r.next_check_at,
|
||||||
|
s.titel AS case_title,
|
||||||
|
c.name AS customer_name
|
||||||
|
FROM sag_reminders r
|
||||||
|
JOIN sag_sager s ON r.sag_id = s.id
|
||||||
|
JOIN customers c ON s.customer_id = c.id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id, snoozed_until, status, triggered_at
|
||||||
|
FROM sag_reminder_logs
|
||||||
|
WHERE reminder_id = r.id AND user_id = %s
|
||||||
|
ORDER BY triggered_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) l ON true
|
||||||
|
WHERE r.is_active = TRUE
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
AND r.next_check_at <= CURRENT_TIMESTAMP
|
||||||
|
AND %s = ANY(r.recipient_user_ids)
|
||||||
|
AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP)
|
||||||
|
AND (l.status IS NULL OR l.status != 'dismissed')
|
||||||
|
ORDER BY
|
||||||
|
CASE LOWER(COALESCE(r.priority, 'normal'))
|
||||||
|
WHEN 'urgent' THEN 1
|
||||||
|
WHEN 'high' THEN 2
|
||||||
|
WHEN 'normal' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
r.next_check_at ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(user_id, user_id, limit_safe),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
unread_mail_count = _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM email_messages em
|
||||||
|
WHERE em.deleted_at IS NULL
|
||||||
|
AND COALESCE(em.is_read, FALSE) = FALSE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if unread_mail_count > 0:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": f"mail-unread-{unread_mail_count}",
|
||||||
|
"type": "mail",
|
||||||
|
"severity": "medium" if unread_mail_count < 10 else "high",
|
||||||
|
"title": f"{unread_mail_count} ulæste mails",
|
||||||
|
"message": "Der er ulæste mails i indbakken",
|
||||||
|
"action": "/emails",
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in reminders:
|
||||||
|
priority = str(row.get("priority") or "normal").lower()
|
||||||
|
severity = "low"
|
||||||
|
if priority in {"high", "høj"}:
|
||||||
|
severity = "medium"
|
||||||
|
if priority in {"urgent", "critical", "kritisk"}:
|
||||||
|
severity = "high"
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": f"reminder-{row.get('id')}",
|
||||||
|
"type": row.get("event_type") or "reminder",
|
||||||
|
"severity": severity,
|
||||||
|
"title": row.get("title") or "Påmindelse",
|
||||||
|
"message": row.get("message") or row.get("case_title") or "",
|
||||||
|
"sag_id": row.get("sag_id"),
|
||||||
|
"case_title": row.get("case_title"),
|
||||||
|
"customer_name": row.get("customer_name"),
|
||||||
|
"action": f"/sag/{row.get('sag_id')}" if row.get("sag_id") else "/sag",
|
||||||
|
"created_at": row.get("next_check_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
items.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
{"high": 0, "medium": 1, "low": 2}.get(str(item.get("severity") or "low"), 3),
|
||||||
|
str(item.get("created_at") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"items": items[:limit_safe], "count": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
def _context_actions_for_path(context_path: str) -> Dict[str, Any]:
|
||||||
|
normalized = str(context_path or "").strip().lower()
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"context_key": "global",
|
||||||
|
"global": [
|
||||||
|
{"id": "new_case", "label": "Ny sag", "action": "/sag"},
|
||||||
|
{"id": "new_mail", "label": "Ny mail", "action": "/emails"},
|
||||||
|
{"id": "start_timer", "label": "Start timer", "action": "/timetracking"},
|
||||||
|
{"id": "log_time", "label": "Log tid", "action": "/timetracking"},
|
||||||
|
{"id": "add_note", "label": "Tilføj note", "action": "/sag"},
|
||||||
|
],
|
||||||
|
"context": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.startswith("/sag"):
|
||||||
|
payload["context_key"] = "sag"
|
||||||
|
payload["context"] = [
|
||||||
|
{"id": "case_time", "label": "Tid", "action": "/timetracking"},
|
||||||
|
{"id": "case_mail", "label": "Mail", "action": "/emails"},
|
||||||
|
{"id": "case_relation", "label": "Relation", "action": "/customers"},
|
||||||
|
{"id": "case_tag", "label": "Tag", "action": "/tags"},
|
||||||
|
]
|
||||||
|
elif normalized.startswith("/hardware"):
|
||||||
|
payload["context_key"] = "hardware"
|
||||||
|
payload["context"] = [
|
||||||
|
{"id": "hardware_new", "label": "Ny enhed", "action": "/hardware"},
|
||||||
|
{"id": "hardware_history", "label": "Historik", "action": "/hardware"},
|
||||||
|
{"id": "hardware_link_case", "label": "Tilknyt sag", "action": "/sag"},
|
||||||
|
]
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_bottom_bar_state(
|
||||||
|
user_id: Optional[int],
|
||||||
|
context_path: str = "",
|
||||||
|
force_boss_access: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
enabled = is_bottom_bar_enabled(user_id)
|
||||||
|
if not enabled:
|
||||||
|
return {"enabled": False, "sections": {}}
|
||||||
|
|
||||||
|
status = get_dashboard_status()
|
||||||
|
timer = get_active_timer(user_id)
|
||||||
|
notifications = get_notifications(user_id, limit=10)
|
||||||
|
|
||||||
|
urgent_cases = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, titel
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||||
|
ORDER BY updated_at DESC NULLS LAST, id DESC
|
||||||
|
LIMIT 5
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
open_cases = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, titel
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
ORDER BY updated_at DESC NULLS LAST, id DESC
|
||||||
|
LIMIT 5
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
timer_list: List[Dict[str, Any]] = []
|
||||||
|
if timer.get("active"):
|
||||||
|
timer_list.append(
|
||||||
|
{
|
||||||
|
"id": timer.get("time_entry_id"),
|
||||||
|
"sag_id": timer.get("sag_id"),
|
||||||
|
"desc": timer.get("sag_navn") or f"Sag #{timer.get('sag_id')}",
|
||||||
|
"elapsed": timer.get("elapsed"),
|
||||||
|
"elapsed_hhmmss": timer.get("elapsed_hhmmss"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"from": "System",
|
||||||
|
"text": f"{notifications.get('count', 0)} aktive notifikationer",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for n in (notifications.get("items") or [])[:5]:
|
||||||
|
tasks.append(
|
||||||
|
{
|
||||||
|
"title": n.get("title") or "Notifikation",
|
||||||
|
"deadline": n.get("severity") or "info",
|
||||||
|
"action": n.get("action") or "/",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
context_actions = _context_actions_for_path(context_path)
|
||||||
|
can_view_boss = bool(force_boss_access) or _can_view_boss_tab(user_id)
|
||||||
|
|
||||||
|
team_workload: List[Dict[str, Any]] = []
|
||||||
|
technicians_today: List[Dict[str, Any]] = []
|
||||||
|
escalation_cases: List[Dict[str, Any]] = []
|
||||||
|
unassigned_cases: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if can_view_boss:
|
||||||
|
team_workload = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
|
||||||
|
COUNT(s.id)::int AS open_cases,
|
||||||
|
COUNT(CASE WHEN LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN sag_sager s
|
||||||
|
ON s.ansvarlig_bruger_id = u.user_id
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
GROUP BY u.user_id, u.full_name, u.username
|
||||||
|
HAVING COUNT(s.id) > 0
|
||||||
|
ORDER BY urgent_cases DESC, open_cases DESC, owner_name ASC
|
||||||
|
LIMIT 8
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
technicians_today = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
|
||||||
|
COUNT(s.id)::int AS open_cases,
|
||||||
|
COUNT(CASE WHEN s.deadline::date = CURRENT_DATE THEN 1 END)::int AS due_today_cases,
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', t.id,
|
||||||
|
'title', t.titel,
|
||||||
|
'priority', COALESCE(t.priority, 'normal'),
|
||||||
|
'deadline', t.deadline
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN t.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END,
|
||||||
|
COALESCE(t.deadline, t.updated_at, t.created_at) ASC,
|
||||||
|
t.id ASC
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT s2.id, s2.titel, s2.priority, s2.deadline, s2.updated_at, s2.created_at
|
||||||
|
FROM sag_sager s2
|
||||||
|
WHERE s2.ansvarlig_bruger_id = u.user_id
|
||||||
|
AND s2.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s2.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND (
|
||||||
|
s2.deadline::date = CURRENT_DATE
|
||||||
|
OR s2.created_at::date = CURRENT_DATE
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN s2.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END,
|
||||||
|
COALESCE(s2.deadline, s2.updated_at, s2.created_at) ASC,
|
||||||
|
s2.id ASC
|
||||||
|
LIMIT 6
|
||||||
|
) t
|
||||||
|
),
|
||||||
|
'[]'::json
|
||||||
|
) AS today_tasks
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN sag_sager s
|
||||||
|
ON s.ansvarlig_bruger_id = u.user_id
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN groups g ON g.id = ug.group_id
|
||||||
|
WHERE ug.user_id = u.user_id
|
||||||
|
AND LOWER(g.name) LIKE ANY(ARRAY['%teknik%', '%technician%', '%support%'])
|
||||||
|
)
|
||||||
|
GROUP BY u.user_id, u.full_name, u.username
|
||||||
|
ORDER BY due_today_cases DESC, open_cases DESC, owner_name ASC
|
||||||
|
LIMIT 10
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
escalation_cases = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.priority,
|
||||||
|
s.updated_at,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - COALESCE(s.updated_at, s.created_at)))::int AS age_seconds,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username, 'Ikke tildelt') AS owner_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||||
|
AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours'
|
||||||
|
ORDER BY COALESCE(s.updated_at, s.created_at) ASC
|
||||||
|
LIMIT 8
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
unassigned_cases = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.priority,
|
||||||
|
s.created_at,
|
||||||
|
s.updated_at
|
||||||
|
FROM sag_sager s
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND s.ansvarlig_bruger_id IS NULL
|
||||||
|
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
||||||
|
LIMIT 8
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
sections = {
|
||||||
|
"mail": {
|
||||||
|
"unread": status.get("mails_unread", 0),
|
||||||
|
"customer_reply_needed": status.get("mails_unread", 0),
|
||||||
|
},
|
||||||
|
"cases": {
|
||||||
|
"open": status.get("sager_open", 0),
|
||||||
|
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in open_cases],
|
||||||
|
},
|
||||||
|
"urgent": {
|
||||||
|
"count": status.get("sager_urgent", 0),
|
||||||
|
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in urgent_cases],
|
||||||
|
},
|
||||||
|
"unassigned": {
|
||||||
|
"count": status.get("sager_unassigned", 0),
|
||||||
|
},
|
||||||
|
"timer": {
|
||||||
|
"active_count": 1 if timer.get("active") else 0,
|
||||||
|
"list": timer_list,
|
||||||
|
"active": timer,
|
||||||
|
},
|
||||||
|
"kuma": {
|
||||||
|
"down": 0,
|
||||||
|
"list": [],
|
||||||
|
},
|
||||||
|
"eset": {
|
||||||
|
"incidents": 0,
|
||||||
|
"list": [],
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"count": len(messages),
|
||||||
|
"list": messages,
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"count": len(tasks),
|
||||||
|
"list": tasks,
|
||||||
|
},
|
||||||
|
"boss": {
|
||||||
|
"can_view": can_view_boss,
|
||||||
|
"stats": {
|
||||||
|
"unassigned": status.get("sager_unassigned", 0),
|
||||||
|
"active_employees": _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer = TRUE AND slut_tid IS NULL"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"open_cases": status.get("sager_open", 0),
|
||||||
|
"urgent_cases": status.get("sager_urgent", 0),
|
||||||
|
"stale_urgent_cases": len(escalation_cases),
|
||||||
|
}
|
||||||
|
,
|
||||||
|
"team_workload": [
|
||||||
|
{
|
||||||
|
"user_id": row.get("user_id"),
|
||||||
|
"owner_name": row.get("owner_name"),
|
||||||
|
"open_cases": int(row.get("open_cases") or 0),
|
||||||
|
"urgent_cases": int(row.get("urgent_cases") or 0),
|
||||||
|
}
|
||||||
|
for row in team_workload
|
||||||
|
],
|
||||||
|
"technicians_today": [
|
||||||
|
{
|
||||||
|
"user_id": row.get("user_id"),
|
||||||
|
"owner_name": row.get("owner_name"),
|
||||||
|
"open_cases": int(row.get("open_cases") or 0),
|
||||||
|
"due_today_cases": int(row.get("due_today_cases") or 0),
|
||||||
|
"today_tasks": row.get("today_tasks") or [],
|
||||||
|
}
|
||||||
|
for row in technicians_today
|
||||||
|
],
|
||||||
|
"escalations": [
|
||||||
|
{
|
||||||
|
"id": row.get("id"),
|
||||||
|
"title": row.get("titel") or f"Sag #{row.get('id')}",
|
||||||
|
"priority": row.get("priority") or "normal",
|
||||||
|
"owner_name": row.get("owner_name") or "Ikke tildelt",
|
||||||
|
"age_seconds": int(row.get("age_seconds") or 0),
|
||||||
|
}
|
||||||
|
for row in escalation_cases
|
||||||
|
],
|
||||||
|
"unassigned_cases": [
|
||||||
|
{
|
||||||
|
"id": row.get("id"),
|
||||||
|
"title": row.get("titel") or f"Sag #{row.get('id')}",
|
||||||
|
"priority": row.get("priority") or "normal",
|
||||||
|
}
|
||||||
|
for row in unassigned_cases
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"context_actions": context_actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"sections": sections,
|
||||||
|
"status": status,
|
||||||
|
"active_timer": timer,
|
||||||
|
"notifications": notifications,
|
||||||
|
}
|
||||||
11
app/modules/bottom_bar/module.json
Normal file
11
app/modules/bottom_bar/module.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "bottom_bar",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Global activity bottom bar module",
|
||||||
|
"author": "BMC Networks",
|
||||||
|
"enabled": true,
|
||||||
|
"dependencies": [],
|
||||||
|
"table_prefix": "bottom_bar_",
|
||||||
|
"api_prefix": "/api/v1",
|
||||||
|
"tags": ["Bottom Bar"]
|
||||||
|
}
|
||||||
@ -221,52 +221,130 @@ 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 all hardware."""
|
"""Display list of BMC-owned assets only."""
|
||||||
query = "SELECT * FROM hardware_assets WHERE deleted_at IS NULL"
|
query = """
|
||||||
|
SELECT
|
||||||
|
ha.*,
|
||||||
|
c.name AS customer_name,
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM subscription_asset_bindings b
|
||||||
|
WHERE b.asset_id = ha.id
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
AND b.status = 'active'
|
||||||
|
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
|
||||||
|
) THEN true
|
||||||
|
ELSE false
|
||||||
|
END AS is_currently_rented
|
||||||
|
FROM hardware_assets ha
|
||||||
|
LEFT JOIN customers c ON c.id = ha.current_owner_customer_id
|
||||||
|
WHERE ha.deleted_at IS NULL
|
||||||
|
AND ha.current_owner_type = 'bmc'
|
||||||
|
"""
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query += " AND status = %s"
|
query += " AND ha.status = %s"
|
||||||
params.append(status)
|
params.append(status)
|
||||||
if asset_type:
|
if asset_type:
|
||||||
query += " AND asset_type = %s"
|
query += " AND ha.asset_type = %s"
|
||||||
params.append(asset_type)
|
params.append(asset_type)
|
||||||
if customer_id:
|
if customer_id:
|
||||||
query += " AND current_owner_customer_id = %s"
|
query += " AND ha.current_owner_customer_id = %s"
|
||||||
params.append(customer_id)
|
params.append(customer_id)
|
||||||
if q:
|
if q:
|
||||||
query += " AND (serial_number ILIKE %s OR model ILIKE %s OR brand ILIKE %s)"
|
query += " AND (ha.serial_number ILIKE %s OR ha.model ILIKE %s OR ha.brand ILIKE %s OR ha.internal_asset_id ILIKE %s)"
|
||||||
search_param = f"%{q}%"
|
search_param = f"%{q}%"
|
||||||
params.extend([search_param, search_param, search_param])
|
params.extend([search_param, search_param, search_param, search_param])
|
||||||
|
|
||||||
query += " ORDER BY created_at DESC"
|
if rental_scope == "rented":
|
||||||
|
query += """
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM subscription_asset_bindings b
|
||||||
|
WHERE b.asset_id = ha.id
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
AND b.status = 'active'
|
||||||
|
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
elif rental_scope == "not_rented":
|
||||||
|
query += """
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM subscription_asset_bindings b
|
||||||
|
WHERE b.asset_id = ha.id
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
AND b.status = 'active'
|
||||||
|
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
query += " ORDER BY ha.created_at DESC"
|
||||||
hardware = execute_query(query, tuple(params))
|
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."""
|
||||||
@ -358,7 +436,7 @@ async def hardware_eset_import(request: Request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hardware/{hardware_id}", response_class=HTMLResponse)
|
@router.get("/hardware/{hardware_id:int}", response_class=HTMLResponse)
|
||||||
async def hardware_detail(request: Request, hardware_id: int):
|
async def hardware_detail(request: Request, hardware_id: int):
|
||||||
"""Display hardware details."""
|
"""Display hardware details."""
|
||||||
# Get hardware
|
# Get hardware
|
||||||
@ -507,7 +585,7 @@ async def hardware_detail(request: Request, hardware_id: int):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hardware/{hardware_id}/edit", response_class=HTMLResponse)
|
@router.get("/hardware/{hardware_id:int}/edit", response_class=HTMLResponse)
|
||||||
async def edit_hardware_form(request: Request, hardware_id: int):
|
async def edit_hardware_form(request: Request, hardware_id: int):
|
||||||
"""Display edit hardware form."""
|
"""Display edit hardware form."""
|
||||||
# Get hardware
|
# Get hardware
|
||||||
@ -528,7 +606,7 @@ async def edit_hardware_form(request: Request, hardware_id: int):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/hardware/{hardware_id}/location")
|
@router.post("/hardware/{hardware_id:int}/location")
|
||||||
async def update_hardware_location(
|
async def update_hardware_location(
|
||||||
request: Request,
|
request: Request,
|
||||||
hardware_id: int,
|
hardware_id: int,
|
||||||
@ -574,7 +652,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}/owner")
|
@router.post("/hardware/{hardware_id:int}/owner")
|
||||||
async def update_hardware_owner(
|
async def update_hardware_owner(
|
||||||
request: Request,
|
request: Request,
|
||||||
hardware_id: int,
|
hardware_id: int,
|
||||||
@ -649,7 +727,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}/contacts/add")
|
@router.post("/hardware/{hardware_id:int}/contacts/add")
|
||||||
async def add_hardware_contact(
|
async def add_hardware_contact(
|
||||||
request: Request,
|
request: Request,
|
||||||
hardware_id: int,
|
hardware_id: int,
|
||||||
@ -671,7 +749,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}/contacts/{contact_id}/delete")
|
@router.post("/hardware/{hardware_id:int}/contacts/{contact_id:int}/delete")
|
||||||
async def remove_hardware_contact(
|
async def remove_hardware_contact(
|
||||||
request: Request,
|
request: Request,
|
||||||
hardware_id: int,
|
hardware_id: int,
|
||||||
|
|||||||
104
app/modules/hardware/templates/customers.html
Normal file
104
app/modules/hardware/templates/customers.html
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Kundehardware - BMC Hub{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-4">
|
||||||
|
<h1 class="h3 mb-0">Kundehardware</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/hardware" class="btn btn-outline-secondary btn-sm">BMC Assets</a>
|
||||||
|
<a href="/hardware/new" class="btn btn-primary btn-sm">Nyt Hardware</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" action="/hardware/customers" class="row g-3 align-items-end">
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label for="asset_type" class="form-label">Type</label>
|
||||||
|
<select name="asset_type" id="asset_type" class="form-select form-select-sm">
|
||||||
|
<option value="">Alle typer</option>
|
||||||
|
<option value="pc" {% if current_asset_type == 'pc' %}selected{% endif %}>PC</option>
|
||||||
|
<option value="laptop" {% if current_asset_type == 'laptop' %}selected{% endif %}>Laptop</option>
|
||||||
|
<option value="printer" {% if current_asset_type == 'printer' %}selected{% endif %}>Printer</option>
|
||||||
|
<option value="skærm" {% if current_asset_type == 'skærm' %}selected{% endif %}>Skærm</option>
|
||||||
|
<option value="telefon" {% if current_asset_type == 'telefon' %}selected{% endif %}>Telefon</option>
|
||||||
|
<option value="server" {% if current_asset_type == 'server' %}selected{% endif %}>Server</option>
|
||||||
|
<option value="netværk" {% if current_asset_type == 'netværk' %}selected{% endif %}>Netværk</option>
|
||||||
|
<option value="andet" {% if current_asset_type == 'andet' %}selected{% endif %}>Andet</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label for="status" class="form-label">Status</label>
|
||||||
|
<select name="status" id="status" class="form-select form-select-sm">
|
||||||
|
<option value="">Alle status</option>
|
||||||
|
<option value="active" {% if current_status == 'active' %}selected{% endif %}>Aktiv</option>
|
||||||
|
<option value="faulty_reported" {% if current_status == 'faulty_reported' %}selected{% endif %}>Fejl rapporteret</option>
|
||||||
|
<option value="in_repair" {% if current_status == 'in_repair' %}selected{% endif %}>Under reparation</option>
|
||||||
|
<option value="replaced" {% if current_status == 'replaced' %}selected{% endif %}>Udskiftet</option>
|
||||||
|
<option value="retired" {% if current_status == 'retired' %}selected{% endif %}>Udtjent</option>
|
||||||
|
<option value="unsupported" {% if current_status == 'unsupported' %}selected{% endif %}>Ikke supporteret</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<label for="q" class="form-label">Søg</label>
|
||||||
|
<input type="text" name="q" id="q" class="form-control form-control-sm" value="{{ search_query or '' }}" placeholder="Model, serial, kunde...">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm w-100">Filtrer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if hardware and hardware|length > 0 %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Hardware</th>
|
||||||
|
<th>Kunde</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Serienr.</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>AnyDesk</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in hardware %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ item.brand or 'Unknown' }} {{ item.model or '' }}</td>
|
||||||
|
<td>{{ item.customer_name or 'Ukendt kunde' }}</td>
|
||||||
|
<td>{{ item.asset_type|title }}</td>
|
||||||
|
<td>{{ item.serial_number or 'Ingen serienummer' }}</td>
|
||||||
|
<td>{{ item.status|replace('_', ' ')|title }}</td>
|
||||||
|
<td>
|
||||||
|
{% if item.anydesk_link %}
|
||||||
|
<a href="{{ item.anydesk_link }}" target="_blank">{{ item.anydesk_id or 'Åbn' }}</a>
|
||||||
|
{% elif item.anydesk_id %}
|
||||||
|
<a href="anydesk://{{ item.anydesk_id }}" target="_blank">{{ item.anydesk_id }}</a>
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="/hardware/{{ item.id }}" class="btn btn-outline-secondary btn-sm">Se</a>
|
||||||
|
<a href="/hardware/{{ item.id }}/edit" class="btn btn-outline-primary btn-sm">Rediger</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<h5>Ingen kundehardware fundet</h5>
|
||||||
|
<p class="mb-0">Der er ingen kundeejede enheder, der matcher filtre.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@ -242,8 +242,12 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>🖥️ Hardware Oversigt</h1>
|
<h1>🗂️ BMC Assets Oversigt (Kun vores egne)</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
|
||||||
@ -285,6 +289,15 @@
|
|||||||
</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 '' }}">
|
||||||
@ -362,6 +375,16 @@
|
|||||||
<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">
|
||||||
@ -387,6 +410,7 @@
|
|||||||
<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>
|
||||||
@ -399,6 +423,13 @@
|
|||||||
<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 }}
|
||||||
@ -426,17 +457,18 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">🖥️</div>
|
<div class="empty-state-icon">🖥️</div>
|
||||||
<h3>Ingen hardware fundet</h3>
|
<h3>Ingen BMC assets fundet</h3>
|
||||||
<p>Opret dit første hardware asset for at komme i gang.</p>
|
<p>Opret dit første interne asset for at komme i gang.</p>
|
||||||
<a href="/hardware/new" class="btn-new-hardware" style="margin-top: 1rem;">➕ Opret Hardware</a>
|
<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').forEach(select => {
|
document.querySelectorAll('#asset_type, #status, #rental_scope').forEach(select => {
|
||||||
select.addEventListener('change', () => {
|
select.addEventListener('change', () => {
|
||||||
select.form.submit();
|
select.form.submit();
|
||||||
});
|
});
|
||||||
|
|||||||
32
app/modules/manual/backend/cache.py
Normal file
32
app/modules/manual/backend/cache.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import time
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
class ManualCache:
|
||||||
|
"""
|
||||||
|
Very simple in-memory TTL cache for the Manual MVP.
|
||||||
|
Stores GET lists and details. Clears fully on any mutation.
|
||||||
|
"""
|
||||||
|
def __init__(self, ttl_seconds: int = 300):
|
||||||
|
self.ttl = ttl_seconds
|
||||||
|
self._store = {}
|
||||||
|
|
||||||
|
def get(self, key: str) -> Optional[Any]:
|
||||||
|
item = self._store.get(key)
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
if time.time() > item["expires"]:
|
||||||
|
del self._store[key]
|
||||||
|
return None
|
||||||
|
return item["value"]
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any):
|
||||||
|
self._store[key] = {
|
||||||
|
"value": value,
|
||||||
|
"expires": time.time() + self.ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self._store.clear()
|
||||||
|
|
||||||
|
# Global singleton instance for the app
|
||||||
|
manual_cache = ManualCache(ttl_seconds=300) # 5 min cache
|
||||||
@ -3,9 +3,10 @@ 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 APIRouter, HTTPException, Query
|
from fastapi import BackgroundTasks, 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__)
|
||||||
@ -278,7 +279,13 @@ async def contextual_manual_suggestions(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/manual/{slug}")
|
@router.get("/manual/{slug}")
|
||||||
async def get_manual_article(slug: str):
|
async def get_manual_article(slug: str, background_tasks: BackgroundTasks):
|
||||||
|
cache_key = f"slug:{slug}"
|
||||||
|
cached = manual_cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
background_tasks.add_task(_increment_use_count, cached["id"])
|
||||||
|
return cached
|
||||||
article = execute_query_single(
|
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,6 +7,7 @@ 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__)
|
||||||
@ -36,52 +37,54 @@ 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),
|
||||||
):
|
):
|
||||||
where_parts = ["deleted_at IS NULL"]
|
cache_key = f"views:list:{module}:{difficulty}:{tag}:{search}"
|
||||||
params: List[Any] = []
|
cached = manual_cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
rows, modules, unique_tags = cached
|
||||||
|
else:
|
||||||
|
filters = ["deleted_at IS NULL"]
|
||||||
|
params = []
|
||||||
|
if module:
|
||||||
|
filters.append("module = %s")
|
||||||
|
params.append(module)
|
||||||
|
if difficulty:
|
||||||
|
filters.append("difficulty = %s")
|
||||||
|
params.append(difficulty)
|
||||||
|
if tag:
|
||||||
|
filters.append("tags @> %s::jsonb")
|
||||||
|
params.append(f'["{tag}"]')
|
||||||
|
if search:
|
||||||
|
filters.append("(title ILIKE %s OR content ILIKE %s)")
|
||||||
|
params.extend([f"%{search}%", f"%{search}%"])
|
||||||
|
|
||||||
if module:
|
where_clause = " AND ".join(filters)
|
||||||
where_parts.append("LOWER(module) = LOWER(%s)")
|
|
||||||
params.append(module.strip())
|
|
||||||
|
|
||||||
if difficulty in {"beginner", "advanced"}:
|
rows = execute_query(
|
||||||
where_parts.append("difficulty = %s")
|
f"SELECT id, slug, title, content, module, tags, difficulty, use_count, updated_at "
|
||||||
params.append(difficulty)
|
f"FROM manual_articles WHERE {where_clause} ORDER BY updated_at DESC",
|
||||||
|
tuple(params)
|
||||||
|
) or []
|
||||||
|
|
||||||
if search:
|
modules = execute_query(
|
||||||
needle = f"%{search.strip()}%"
|
"SELECT DISTINCT module FROM manual_articles WHERE deleted_at IS NULL ORDER BY module ASC"
|
||||||
where_parts.append("(title ILIKE %s OR summary ILIKE %s OR content ILIKE %s)")
|
) or []
|
||||||
params.extend([needle, needle, needle])
|
|
||||||
|
|
||||||
if tag:
|
all_tags: List[str] = []
|
||||||
where_parts.append(
|
for row in rows:
|
||||||
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(tags, '[]'::jsonb)) t(tag) WHERE LOWER(t.tag) = LOWER(%s))"
|
if "tags" in row and row["tags"]:
|
||||||
)
|
try:
|
||||||
params.append(tag.strip())
|
import json
|
||||||
|
if isinstance(row["tags"], str):
|
||||||
rows = execute_query(
|
t = json.loads(row["tags"])
|
||||||
f"""
|
if isinstance(t, list):
|
||||||
SELECT id, title, slug, summary, module, difficulty, tags, use_count, updated_at
|
all_tags.extend(t)
|
||||||
FROM manual_articles
|
elif isinstance(row["tags"], list):
|
||||||
WHERE {' AND '.join(where_parts)}
|
all_tags.extend(row["tags"])
|
||||||
ORDER BY use_count DESC, updated_at DESC
|
except Exception:
|
||||||
LIMIT 300
|
pass
|
||||||
""",
|
unique_tags = sorted(list(set(all_tags)))
|
||||||
tuple(params),
|
|
||||||
) or []
|
manual_cache.set(cache_key, (rows, modules, unique_tags))
|
||||||
|
|
||||||
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",
|
||||||
|
|||||||
0
app/modules/rentals/__init__.py
Normal file
0
app/modules/rentals/__init__.py
Normal file
0
app/modules/rentals/backend/__init__.py
Normal file
0
app/modules/rentals/backend/__init__.py
Normal file
997
app/modules/rentals/backend/router.py
Normal file
997
app/modules/rentals/backend/router.py
Normal file
@ -0,0 +1,997 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.jobs.process_subscriptions import process_subscriptions
|
||||||
|
from app.subscriptions.backend.router import create_subscription as create_sag_subscription
|
||||||
|
from app.subscriptions.backend.router import update_subscription as update_sag_subscription
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
PRICE_TYPE_FIELD_MAP = {
|
||||||
|
"day": "rental_price_day",
|
||||||
|
"week": "rental_price_week",
|
||||||
|
"month": "rental_price_month",
|
||||||
|
"year": "rental_price_year",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _economic_safety_state() -> Dict[str, bool]:
|
||||||
|
return {
|
||||||
|
"economic_enabled": bool(getattr(settings, "ECONOMIC_ENABLED", False)),
|
||||||
|
"economic_read_only": bool(getattr(settings, "ECONOMIC_READ_ONLY", True)),
|
||||||
|
"economic_dry_run": bool(getattr(settings, "ECONOMIC_DRY_RUN", True)),
|
||||||
|
"ordre_economic_read_only": bool(getattr(settings, "ORDRE_ECONOMIC_READ_ONLY", True)),
|
||||||
|
"ordre_economic_dry_run": bool(getattr(settings, "ORDRE_ECONOMIC_DRY_RUN", True)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_interval(start: date, interval: str) -> date:
|
||||||
|
normalized = (interval or "monthly").strip().lower()
|
||||||
|
if normalized == "daily":
|
||||||
|
return start + relativedelta(days=1)
|
||||||
|
if normalized == "biweekly":
|
||||||
|
return start + relativedelta(weeks=2)
|
||||||
|
if normalized == "quarterly":
|
||||||
|
return start + relativedelta(months=3)
|
||||||
|
if normalized == "yearly":
|
||||||
|
return start + relativedelta(years=1)
|
||||||
|
return start + relativedelta(months=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_asset_booking_available(
|
||||||
|
asset_id: int,
|
||||||
|
start_date: date,
|
||||||
|
end_date: Optional[date],
|
||||||
|
exclude_subscription_id: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
where_extra = ""
|
||||||
|
params: List[Any] = [asset_id, start_date, end_date]
|
||||||
|
if exclude_subscription_id is not None:
|
||||||
|
where_extra = " AND b.subscription_id != %s"
|
||||||
|
params.append(exclude_subscription_id)
|
||||||
|
|
||||||
|
overlap = execute_query_single(
|
||||||
|
f"""
|
||||||
|
SELECT b.id, b.subscription_id, b.start_date, b.end_date
|
||||||
|
FROM subscription_asset_bindings b
|
||||||
|
WHERE b.asset_id = %s
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
AND b.status = 'active'
|
||||||
|
{where_extra}
|
||||||
|
AND NOT (
|
||||||
|
COALESCE(b.end_date, DATE '9999-12-31') < %s
|
||||||
|
OR b.start_date > COALESCE(%s, DATE '9999-12-31')
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
|
||||||
|
if overlap:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"Asset {asset_id} is already booked in subscription "
|
||||||
|
f"{overlap.get('subscription_id')} from {overlap.get('start_date')} to {overlap.get('end_date')}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_product_map(product_ids: List[int]) -> Dict[int, Dict[str, Any]]:
|
||||||
|
if not product_ids:
|
||||||
|
return {}
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
sales_price,
|
||||||
|
rental_price_day,
|
||||||
|
rental_price_week,
|
||||||
|
rental_price_month,
|
||||||
|
rental_price_year
|
||||||
|
FROM products
|
||||||
|
WHERE id = ANY(%s)
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(product_ids,),
|
||||||
|
) or []
|
||||||
|
return {int(row["id"]): row for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_unit_price(
|
||||||
|
product: Optional[Dict[str, Any]],
|
||||||
|
price_type: str,
|
||||||
|
custom_override: bool,
|
||||||
|
provided_unit_price: Optional[float],
|
||||||
|
) -> float:
|
||||||
|
if provided_unit_price is not None and custom_override:
|
||||||
|
return float(provided_unit_price)
|
||||||
|
|
||||||
|
normalized_type = (price_type or "manual").strip().lower()
|
||||||
|
if normalized_type in PRICE_TYPE_FIELD_MAP and not custom_override:
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=400, detail="product_id is required for period price types")
|
||||||
|
price_field = PRICE_TYPE_FIELD_MAP[normalized_type]
|
||||||
|
period_price = product.get(price_field)
|
||||||
|
if period_price is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Product {product.get('id')} has no {price_field} configured",
|
||||||
|
)
|
||||||
|
return float(period_price)
|
||||||
|
|
||||||
|
if provided_unit_price is not None:
|
||||||
|
return float(provided_unit_price)
|
||||||
|
if product and product.get("sales_price") is not None:
|
||||||
|
return float(product.get("sales_price"))
|
||||||
|
raise HTTPException(status_code=400, detail="unit_price is required")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_line_items(
|
||||||
|
line_items: List[SubscriptionLineInput],
|
||||||
|
default_price_type: str,
|
||||||
|
default_custom_override: bool,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
product_ids = [line.product_id for line in line_items if line.product_id is not None]
|
||||||
|
product_map = _resolve_product_map([int(pid) for pid in product_ids])
|
||||||
|
normalized: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for line in line_items:
|
||||||
|
line_data = line.model_dump(exclude_none=True)
|
||||||
|
line_price_type = (line_data.get("price_type") or default_price_type or "manual").strip().lower()
|
||||||
|
line_custom_override = bool(line_data.get("custom_price_override", default_custom_override))
|
||||||
|
product = product_map.get(int(line.product_id)) if line.product_id is not None else None
|
||||||
|
|
||||||
|
unit_price = _derive_unit_price(
|
||||||
|
product=product,
|
||||||
|
price_type=line_price_type,
|
||||||
|
custom_override=line_custom_override,
|
||||||
|
provided_unit_price=line_data.get("unit_price"),
|
||||||
|
)
|
||||||
|
|
||||||
|
line_data["unit_price"] = unit_price
|
||||||
|
line_data["quantity"] = float(line_data.get("quantity", 1))
|
||||||
|
line_data["price_type"] = line_price_type
|
||||||
|
line_data["custom_price_override"] = line_custom_override
|
||||||
|
|
||||||
|
if isinstance(line_data.get("period_from"), date):
|
||||||
|
line_data["period_from"] = line_data["period_from"].isoformat()
|
||||||
|
if isinstance(line_data.get("period_to"), date):
|
||||||
|
line_data["period_to"] = line_data["period_to"].isoformat()
|
||||||
|
|
||||||
|
normalized.append(line_data)
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _build_due_subscription_preview(as_of: date, customer_id: Optional[int]) -> Dict[str, Any]:
|
||||||
|
where = [
|
||||||
|
"s.status = 'active'",
|
||||||
|
"s.next_invoice_date <= %s",
|
||||||
|
"COALESCE(s.billing_blocked, false) = false",
|
||||||
|
]
|
||||||
|
params: List[Any] = [as_of]
|
||||||
|
if customer_id is not None:
|
||||||
|
where.append("s.customer_id = %s")
|
||||||
|
params.append(customer_id)
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.customer_id,
|
||||||
|
c.name AS customer_name,
|
||||||
|
s.invoice_merge_key,
|
||||||
|
s.billing_direction,
|
||||||
|
s.next_invoice_date,
|
||||||
|
s.period_start,
|
||||||
|
s.billing_interval,
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', i.id,
|
||||||
|
'line_total', i.line_total,
|
||||||
|
'billing_blocked', i.billing_blocked,
|
||||||
|
'period_from', i.period_from,
|
||||||
|
'period_to', i.period_to
|
||||||
|
) ORDER BY i.id ASC
|
||||||
|
)
|
||||||
|
FROM sag_subscription_items i
|
||||||
|
WHERE i.subscription_id = s.id
|
||||||
|
),
|
||||||
|
'[]'::json
|
||||||
|
) AS line_items
|
||||||
|
FROM sag_subscriptions s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
ORDER BY s.customer_id, s.next_invoice_date, s.id
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
groups: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
merge_key = row.get("invoice_merge_key") or f"cust-{row['customer_id']}"
|
||||||
|
group_id = f"{row['customer_id']}|{merge_key}|{row.get('next_invoice_date')}|{row.get('billing_direction') or 'forward'}"
|
||||||
|
grp = groups.setdefault(
|
||||||
|
group_id,
|
||||||
|
{
|
||||||
|
"customer_id": row["customer_id"],
|
||||||
|
"customer_name": row.get("customer_name"),
|
||||||
|
"invoice_merge_key": merge_key,
|
||||||
|
"invoice_date": str(row.get("next_invoice_date")),
|
||||||
|
"billing_direction": row.get("billing_direction") or "forward",
|
||||||
|
"subscription_ids": [],
|
||||||
|
"coverage_start": None,
|
||||||
|
"coverage_end": None,
|
||||||
|
"line_count": 0,
|
||||||
|
"amount_total": 0.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
grp["subscription_ids"].append(int(row["id"]))
|
||||||
|
period_start = row.get("period_start") or row.get("next_invoice_date")
|
||||||
|
period_end = _add_interval(period_start, row.get("billing_interval") or "monthly")
|
||||||
|
grp["coverage_start"] = (
|
||||||
|
str(period_start)
|
||||||
|
if grp["coverage_start"] is None or str(period_start) < grp["coverage_start"]
|
||||||
|
else grp["coverage_start"]
|
||||||
|
)
|
||||||
|
grp["coverage_end"] = (
|
||||||
|
str(period_end)
|
||||||
|
if grp["coverage_end"] is None or str(period_end) > grp["coverage_end"]
|
||||||
|
else grp["coverage_end"]
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in row.get("line_items") or []:
|
||||||
|
if item.get("billing_blocked"):
|
||||||
|
continue
|
||||||
|
grp["line_count"] += 1
|
||||||
|
grp["amount_total"] += float(item.get("line_total") or 0)
|
||||||
|
|
||||||
|
group_values = list(groups.values())
|
||||||
|
return {
|
||||||
|
"as_of": str(as_of),
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"groups": group_values,
|
||||||
|
"group_count": len(group_values),
|
||||||
|
"amount_total": round(sum(float(g.get("amount_total") or 0) for g in group_values), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_due_invoices_for_customer(as_of: date, customer_id: int) -> Dict[str, Any]:
|
||||||
|
conn = get_db_connection()
|
||||||
|
created_drafts = 0
|
||||||
|
touched_subscriptions = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.customer_id,
|
||||||
|
c.name AS customer_name,
|
||||||
|
s.billing_interval,
|
||||||
|
s.billing_direction,
|
||||||
|
s.next_invoice_date,
|
||||||
|
s.period_start,
|
||||||
|
s.invoice_merge_key,
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'description', i.description,
|
||||||
|
'quantity', i.quantity,
|
||||||
|
'unit_price', i.unit_price,
|
||||||
|
'line_total', i.line_total,
|
||||||
|
'product_id', i.product_id,
|
||||||
|
'asset_id', i.asset_id,
|
||||||
|
'period_from', i.period_from,
|
||||||
|
'period_to', i.period_to,
|
||||||
|
'billing_blocked', i.billing_blocked
|
||||||
|
) ORDER BY i.id ASC
|
||||||
|
)
|
||||||
|
FROM sag_subscription_items i
|
||||||
|
WHERE i.subscription_id = s.id
|
||||||
|
),
|
||||||
|
'[]'::json
|
||||||
|
) AS line_items
|
||||||
|
FROM sag_subscriptions s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
WHERE s.status = 'active'
|
||||||
|
AND s.customer_id = %s
|
||||||
|
AND s.next_invoice_date <= %s
|
||||||
|
AND COALESCE(s.billing_blocked, false) = false
|
||||||
|
ORDER BY s.next_invoice_date ASC, s.id ASC
|
||||||
|
""",
|
||||||
|
(customer_id, as_of),
|
||||||
|
)
|
||||||
|
subscriptions = cursor.fetchall() or []
|
||||||
|
if not subscriptions:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "No due subscriptions for customer",
|
||||||
|
"created_drafts": 0,
|
||||||
|
"processed_subscriptions": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
for sub in subscriptions:
|
||||||
|
merge_key = sub.get("invoice_merge_key") or f"cust-{sub['customer_id']}"
|
||||||
|
group_key = (
|
||||||
|
f"{sub['customer_id']}|{merge_key}|{sub.get('next_invoice_date')}|"
|
||||||
|
f"{sub.get('billing_direction') or 'forward'}"
|
||||||
|
)
|
||||||
|
grouped.setdefault(group_key, []).append(sub)
|
||||||
|
|
||||||
|
for group in grouped.values():
|
||||||
|
first = group[0]
|
||||||
|
merge_key = first.get("invoice_merge_key") or f"cust-{first['customer_id']}"
|
||||||
|
billing_direction = first.get("billing_direction") or "forward"
|
||||||
|
customer_name = first.get("customer_name") or f"Customer #{first['customer_id']}"
|
||||||
|
source_ids: List[int] = []
|
||||||
|
lines: List[Dict[str, Any]] = []
|
||||||
|
coverage_start: Optional[date] = None
|
||||||
|
coverage_end: Optional[date] = None
|
||||||
|
|
||||||
|
for sub in group:
|
||||||
|
source_ids.append(int(sub["id"]))
|
||||||
|
period_start = sub.get("period_start") or sub.get("next_invoice_date")
|
||||||
|
period_end = _add_interval(period_start, sub.get("billing_interval") or "monthly")
|
||||||
|
coverage_start = period_start if coverage_start is None or period_start < coverage_start else coverage_start
|
||||||
|
coverage_end = period_end if coverage_end is None or period_end > coverage_end else coverage_end
|
||||||
|
|
||||||
|
for item in sub.get("line_items") or []:
|
||||||
|
if item.get("billing_blocked"):
|
||||||
|
continue
|
||||||
|
lines.append(
|
||||||
|
{
|
||||||
|
"product": {
|
||||||
|
"productNumber": str(item.get("product_id") or "SUB"),
|
||||||
|
"description": item.get("description") or "",
|
||||||
|
},
|
||||||
|
"quantity": float(item.get("quantity") or 1),
|
||||||
|
"unitNetPrice": float(item.get("unit_price") or 0),
|
||||||
|
"totalNetAmount": float(item.get("line_total") or 0),
|
||||||
|
"discountPercentage": 0,
|
||||||
|
"metadata": {
|
||||||
|
"subscription_id": int(sub["id"]),
|
||||||
|
"asset_id": item.get("asset_id"),
|
||||||
|
"period_from": str(item.get("period_from") or period_start),
|
||||||
|
"period_to": str(item.get("period_to") or period_end),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM ordre_drafts
|
||||||
|
WHERE customer_id = %s
|
||||||
|
AND invoice_aggregate_key = %s
|
||||||
|
AND coverage_start = %s
|
||||||
|
AND coverage_end = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND sync_status IN ('pending', 'exported', 'posted', 'paid')
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(first["customer_id"], merge_key, coverage_start, coverage_end),
|
||||||
|
)
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ordre_drafts (
|
||||||
|
title,
|
||||||
|
customer_id,
|
||||||
|
lines_json,
|
||||||
|
notes,
|
||||||
|
coverage_start,
|
||||||
|
coverage_end,
|
||||||
|
billing_direction,
|
||||||
|
source_subscription_ids,
|
||||||
|
invoice_aggregate_key,
|
||||||
|
layout_number,
|
||||||
|
created_by_user_id,
|
||||||
|
sync_status,
|
||||||
|
export_status_json,
|
||||||
|
updated_at
|
||||||
|
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
f"Abonnementer: {customer_name}",
|
||||||
|
first["customer_id"],
|
||||||
|
json.dumps(lines, ensure_ascii=False),
|
||||||
|
(
|
||||||
|
"Aggregated abonnement faktura\n"
|
||||||
|
f"Kunde: {customer_name}\n"
|
||||||
|
f"Coverage: {coverage_start} til {coverage_end}\n"
|
||||||
|
f"Subscription IDs: {', '.join(str(sid) for sid in source_ids)}"
|
||||||
|
),
|
||||||
|
coverage_start,
|
||||||
|
coverage_end,
|
||||||
|
billing_direction,
|
||||||
|
source_ids,
|
||||||
|
merge_key,
|
||||||
|
1,
|
||||||
|
None,
|
||||||
|
"pending",
|
||||||
|
json.dumps({"source": "subscription", "subscription_ids": source_ids}, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created_drafts += 1
|
||||||
|
|
||||||
|
for sub in group:
|
||||||
|
period_start = sub.get("period_start") or sub.get("next_invoice_date")
|
||||||
|
new_period_start = _add_interval(period_start, sub.get("billing_interval") or "monthly")
|
||||||
|
new_next_invoice_date = _add_interval(new_period_start, sub.get("billing_interval") or "monthly")
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE sag_subscriptions
|
||||||
|
SET period_start = %s,
|
||||||
|
next_invoice_date = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(new_period_start, new_next_invoice_date, sub["id"]),
|
||||||
|
)
|
||||||
|
touched_subscriptions += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Customer-scoped invoice generation completed",
|
||||||
|
"created_drafts": created_drafts,
|
||||||
|
"processed_subscriptions": touched_subscriptions,
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"as_of": str(as_of),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionLineInput(BaseModel):
|
||||||
|
product_id: Optional[int] = None
|
||||||
|
description: str
|
||||||
|
quantity: float = 1
|
||||||
|
unit_price: Optional[float] = None
|
||||||
|
asset_id: Optional[int] = None
|
||||||
|
period_from: Optional[date] = None
|
||||||
|
period_to: Optional[date] = None
|
||||||
|
serial_number: Optional[str] = None
|
||||||
|
price_type: Literal["manual", "day", "week", "month", "year"] = "manual"
|
||||||
|
custom_price_override: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionCreateInput(BaseModel):
|
||||||
|
customer_id: int
|
||||||
|
sag_id: int
|
||||||
|
product_name: str = Field(min_length=1)
|
||||||
|
price: float = Field(ge=0)
|
||||||
|
billing_interval: Literal["daily", "biweekly", "monthly", "quarterly", "yearly"] = "monthly"
|
||||||
|
billing_day: int = Field(default=1, ge=1, le=31)
|
||||||
|
start_date: date
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
binding_months: int = Field(default=0, ge=0)
|
||||||
|
billing_direction: Literal["forward", "backward"] = "forward"
|
||||||
|
price_type: Literal["manual", "day", "week", "month", "year"] = "manual"
|
||||||
|
custom_price_override: bool = False
|
||||||
|
first_invoice_policy: Literal["start_date", "next_cycle"] = "start_date"
|
||||||
|
invoice_merge_key: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
line_items: List[SubscriptionLineInput] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionUpdateInput(BaseModel):
|
||||||
|
product_name: Optional[str] = None
|
||||||
|
price: Optional[float] = Field(default=None, ge=0)
|
||||||
|
billing_interval: Optional[Literal["daily", "biweekly", "monthly", "quarterly", "yearly"]] = None
|
||||||
|
billing_day: Optional[int] = Field(default=None, ge=1, le=31)
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
binding_months: Optional[int] = Field(default=None, ge=0)
|
||||||
|
billing_direction: Optional[Literal["forward", "backward"]] = None
|
||||||
|
price_type: Optional[Literal["manual", "day", "week", "month", "year"]] = None
|
||||||
|
custom_price_override: Optional[bool] = None
|
||||||
|
first_invoice_policy: Optional[Literal["start_date", "next_cycle"]] = None
|
||||||
|
invoice_merge_key: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
status: Optional[Literal["draft", "active", "paused", "cancelled"]] = None
|
||||||
|
line_items: Optional[List[SubscriptionLineInput]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AssetStatusInput(BaseModel):
|
||||||
|
status: Literal["ledig", "udlejet", "defekt", "retur"]
|
||||||
|
status_reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AssetBindingCreateInput(BaseModel):
|
||||||
|
asset_id: int
|
||||||
|
start_date: date
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
binding_months: int = Field(default=0, ge=0)
|
||||||
|
shared_binding_key: Optional[str] = None
|
||||||
|
notice_period_days: int = Field(default=30, ge=0)
|
||||||
|
sag_id: Optional[int] = None
|
||||||
|
created_by_user_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceGenerateInput(BaseModel):
|
||||||
|
preview: bool = False
|
||||||
|
customer_id: Optional[int] = None
|
||||||
|
as_of: Optional[date] = None
|
||||||
|
push_to_economic: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assets", response_model=List[Dict[str, Any]])
|
||||||
|
async def list_assets(
|
||||||
|
status: str = Query("all"),
|
||||||
|
customer_id: Optional[int] = Query(default=None),
|
||||||
|
only_rental_enabled: bool = Query(default=False),
|
||||||
|
):
|
||||||
|
"""Alias endpoint for rental-focused asset listing."""
|
||||||
|
where = ["ha.deleted_at IS NULL"]
|
||||||
|
params: List[Any] = []
|
||||||
|
|
||||||
|
if status != "all":
|
||||||
|
where.append("COALESCE(ha.rental_status, 'ledig') = %s")
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
if customer_id is not None:
|
||||||
|
where.append("ha.current_owner_customer_id = %s")
|
||||||
|
params.append(customer_id)
|
||||||
|
|
||||||
|
if only_rental_enabled:
|
||||||
|
where.append(
|
||||||
|
"EXISTS (SELECT 1 FROM products p WHERE p.rental_asset_enabled = true AND p.deleted_at IS NULL)"
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
ha.id,
|
||||||
|
ha.asset_type,
|
||||||
|
ha.brand,
|
||||||
|
ha.model,
|
||||||
|
ha.serial_number,
|
||||||
|
ha.customer_asset_id,
|
||||||
|
ha.internal_asset_id,
|
||||||
|
ha.current_owner_customer_id AS customer_id,
|
||||||
|
c.name AS customer_name,
|
||||||
|
COALESCE(ha.rental_status, 'ledig') AS rental_status,
|
||||||
|
ha.status AS hardware_status,
|
||||||
|
ha.status_reason,
|
||||||
|
ha.updated_at
|
||||||
|
FROM hardware_assets ha
|
||||||
|
LEFT JOIN customers c ON c.id = ha.current_owner_customer_id
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
ORDER BY ha.updated_at DESC, ha.id DESC
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
) or []
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/assets/{asset_id}/status", response_model=Dict[str, Any])
|
||||||
|
async def update_asset_rental_status(asset_id: int, payload: AssetStatusInput):
|
||||||
|
"""Alias endpoint for updating rental status without changing core hardware lifecycle status."""
|
||||||
|
row = execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE hardware_assets
|
||||||
|
SET rental_status = %s,
|
||||||
|
status_reason = COALESCE(%s, status_reason),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s AND deleted_at IS NULL
|
||||||
|
RETURNING id, COALESCE(rental_status, 'ledig') AS status, status_reason, updated_at
|
||||||
|
""",
|
||||||
|
(payload.status, payload.status_reason, asset_id),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Asset not found")
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscriptions", response_model=List[Dict[str, Any]])
|
||||||
|
async def list_subscriptions_alias(status: str = Query("all")):
|
||||||
|
where = ""
|
||||||
|
params: List[Any] = []
|
||||||
|
if status != "all":
|
||||||
|
where = "WHERE s.status = %s"
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.subscription_number,
|
||||||
|
s.customer_id,
|
||||||
|
c.name AS customer_name,
|
||||||
|
s.sag_id,
|
||||||
|
s.product_name,
|
||||||
|
s.price,
|
||||||
|
s.billing_interval,
|
||||||
|
s.billing_direction,
|
||||||
|
s.start_date,
|
||||||
|
s.end_date,
|
||||||
|
s.status,
|
||||||
|
s.binding_months,
|
||||||
|
s.invoice_merge_key,
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT json_agg(json_build_object(
|
||||||
|
'id', i.id,
|
||||||
|
'description', i.description,
|
||||||
|
'quantity', i.quantity,
|
||||||
|
'unit_price', i.unit_price,
|
||||||
|
'line_total', i.line_total,
|
||||||
|
'asset_id', i.asset_id,
|
||||||
|
'period_from', i.period_from,
|
||||||
|
'period_to', i.period_to,
|
||||||
|
'serial_number', i.serial_number
|
||||||
|
) ORDER BY i.line_no ASC, i.id ASC)
|
||||||
|
FROM sag_subscription_items i
|
||||||
|
WHERE i.subscription_id = s.id
|
||||||
|
),
|
||||||
|
'[]'::json
|
||||||
|
) AS line_items
|
||||||
|
FROM sag_subscriptions s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
{where}
|
||||||
|
ORDER BY s.start_date DESC, s.id DESC
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
) or []
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscriptions", response_model=Dict[str, Any])
|
||||||
|
async def create_subscription_alias(payload: SubscriptionCreateInput):
|
||||||
|
"""Alias endpoint that maps to existing sag subscription engine."""
|
||||||
|
normalized_lines = _normalize_line_items(
|
||||||
|
payload.line_items,
|
||||||
|
default_price_type=payload.price_type,
|
||||||
|
default_custom_override=payload.custom_price_override,
|
||||||
|
)
|
||||||
|
|
||||||
|
for line in normalized_lines:
|
||||||
|
asset_id = line.get("asset_id")
|
||||||
|
if asset_id is None:
|
||||||
|
continue
|
||||||
|
_assert_asset_booking_available(
|
||||||
|
int(asset_id),
|
||||||
|
start_date=payload.start_date,
|
||||||
|
end_date=payload.end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
body: Dict[str, Any] = {
|
||||||
|
"customer_id": payload.customer_id,
|
||||||
|
"sag_id": payload.sag_id,
|
||||||
|
"product_name": payload.product_name,
|
||||||
|
"price": payload.price,
|
||||||
|
"billing_interval": payload.billing_interval,
|
||||||
|
"billing_day": payload.billing_day,
|
||||||
|
"start_date": payload.start_date.isoformat(),
|
||||||
|
"end_date": payload.end_date.isoformat() if payload.end_date else None,
|
||||||
|
"binding_months": payload.binding_months,
|
||||||
|
"billing_direction": payload.billing_direction,
|
||||||
|
"price_type": payload.price_type,
|
||||||
|
"custom_price_override": payload.custom_price_override,
|
||||||
|
"first_invoice_policy": payload.first_invoice_policy,
|
||||||
|
"invoice_merge_key": payload.invoice_merge_key,
|
||||||
|
"notes": payload.notes,
|
||||||
|
"line_items": normalized_lines,
|
||||||
|
}
|
||||||
|
return await create_sag_subscription(body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/subscriptions/{subscription_id}", response_model=Dict[str, Any])
|
||||||
|
async def update_subscription_alias(subscription_id: int, payload: SubscriptionUpdateInput):
|
||||||
|
body = payload.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
current = execute_query_single(
|
||||||
|
"SELECT id, start_date, end_date, price_type, custom_price_override FROM sag_subscriptions WHERE id = %s",
|
||||||
|
(subscription_id,),
|
||||||
|
)
|
||||||
|
if not current:
|
||||||
|
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||||
|
|
||||||
|
effective_start = payload.start_date or current.get("start_date")
|
||||||
|
effective_end = payload.end_date if payload.end_date is not None else current.get("end_date")
|
||||||
|
effective_price_type = payload.price_type or current.get("price_type") or "manual"
|
||||||
|
effective_override = (
|
||||||
|
payload.custom_price_override
|
||||||
|
if payload.custom_price_override is not None
|
||||||
|
else bool(current.get("custom_price_override"))
|
||||||
|
)
|
||||||
|
|
||||||
|
if "line_items" in body:
|
||||||
|
normalized_lines = _normalize_line_items(
|
||||||
|
payload.line_items or [],
|
||||||
|
default_price_type=effective_price_type,
|
||||||
|
default_custom_override=effective_override,
|
||||||
|
)
|
||||||
|
for line in normalized_lines:
|
||||||
|
asset_id = line.get("asset_id")
|
||||||
|
if asset_id is None:
|
||||||
|
continue
|
||||||
|
_assert_asset_booking_available(
|
||||||
|
int(asset_id),
|
||||||
|
start_date=effective_start,
|
||||||
|
end_date=effective_end,
|
||||||
|
exclude_subscription_id=subscription_id,
|
||||||
|
)
|
||||||
|
body["line_items"] = normalized_lines
|
||||||
|
|
||||||
|
if isinstance(body.get("start_date"), date):
|
||||||
|
body["start_date"] = body["start_date"].isoformat()
|
||||||
|
if isinstance(body.get("end_date"), date):
|
||||||
|
body["end_date"] = body["end_date"].isoformat()
|
||||||
|
|
||||||
|
return await update_sag_subscription(subscription_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscriptions/{subscription_id}/asset-bindings", response_model=Dict[str, Any])
|
||||||
|
async def create_subscription_asset_binding_alias(subscription_id: int, payload: AssetBindingCreateInput):
|
||||||
|
_assert_asset_booking_available(
|
||||||
|
asset_id=payload.asset_id,
|
||||||
|
start_date=payload.start_date,
|
||||||
|
end_date=payload.end_date,
|
||||||
|
exclude_subscription_id=subscription_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
end_date = payload.end_date
|
||||||
|
if end_date is None and payload.binding_months > 0:
|
||||||
|
end_date = payload.start_date + relativedelta(months=payload.binding_months)
|
||||||
|
|
||||||
|
row = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO subscription_asset_bindings (
|
||||||
|
subscription_id,
|
||||||
|
asset_id,
|
||||||
|
shared_binding_key,
|
||||||
|
binding_months,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
notice_period_days,
|
||||||
|
status,
|
||||||
|
sag_id,
|
||||||
|
created_by_user_id
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'active', %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
subscription_id,
|
||||||
|
payload.asset_id,
|
||||||
|
payload.shared_binding_key,
|
||||||
|
payload.binding_months,
|
||||||
|
payload.start_date,
|
||||||
|
end_date,
|
||||||
|
payload.notice_period_days,
|
||||||
|
payload.sag_id,
|
||||||
|
payload.created_by_user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create binding")
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/invoices", response_model=List[Dict[str, Any]])
|
||||||
|
async def list_invoices_alias(
|
||||||
|
status: str = Query("all"),
|
||||||
|
customer_id: Optional[int] = Query(default=None),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
):
|
||||||
|
where = ["1=1"]
|
||||||
|
params: List[Any] = []
|
||||||
|
if status != "all":
|
||||||
|
where.append("d.sync_status = %s")
|
||||||
|
params.append(status)
|
||||||
|
if customer_id is not None:
|
||||||
|
where.append("d.customer_id = %s")
|
||||||
|
params.append(customer_id)
|
||||||
|
|
||||||
|
params.append(limit)
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
d.id,
|
||||||
|
d.customer_id,
|
||||||
|
c.name AS customer_name,
|
||||||
|
d.title,
|
||||||
|
d.invoice_date,
|
||||||
|
d.due_date,
|
||||||
|
d.coverage_start,
|
||||||
|
d.coverage_end,
|
||||||
|
d.total_amount,
|
||||||
|
d.sync_status AS status,
|
||||||
|
d.economic_order_number,
|
||||||
|
d.economic_invoice_number,
|
||||||
|
d.last_sync_at,
|
||||||
|
d.created_at,
|
||||||
|
d.updated_at
|
||||||
|
FROM ordre_drafts d
|
||||||
|
LEFT JOIN customers c ON c.id = d.customer_id
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
ORDER BY d.created_at DESC, d.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
) or []
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invoices/generate", response_model=Dict[str, Any])
|
||||||
|
async def generate_invoices_alias(payload: InvoiceGenerateInput | None = None):
|
||||||
|
"""Generate invoices globally or scoped by customer, with preview support."""
|
||||||
|
req = payload or InvoiceGenerateInput()
|
||||||
|
as_of = req.as_of or date.today()
|
||||||
|
economic_safety = _economic_safety_state()
|
||||||
|
|
||||||
|
if req.push_to_economic:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": "Direct e-conomic sync is blocked in this alias endpoint to protect live accounting.",
|
||||||
|
"action": "Use draft generation here and run dedicated sync flow manually.",
|
||||||
|
"safety": economic_safety,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if req.preview:
|
||||||
|
preview = _build_due_subscription_preview(as_of=as_of, customer_id=req.customer_id)
|
||||||
|
return {"status": "preview", "economic_sync_attempted": False, "safety": economic_safety, **preview}
|
||||||
|
|
||||||
|
if req.customer_id is not None:
|
||||||
|
try:
|
||||||
|
result = _generate_due_invoices_for_customer(as_of=as_of, customer_id=req.customer_id)
|
||||||
|
result["economic_sync_attempted"] = False
|
||||||
|
result["safety"] = economic_safety
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed customer-scoped invoice generation: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Invoice generation failed: {exc}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await process_subscriptions()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Invoice generation job completed (drafts only, no e-conomic sync)",
|
||||||
|
"economic_sync_attempted": False,
|
||||||
|
"safety": economic_safety,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed running invoice generation job: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Invoice generation failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/invoices/sync-safety", response_model=Dict[str, Any])
|
||||||
|
async def get_invoice_sync_safety_status(
|
||||||
|
customer_id: Optional[int] = Query(default=None),
|
||||||
|
):
|
||||||
|
"""Read-only safety overview for invoice sync readiness. Does not call e-conomic."""
|
||||||
|
safety = _economic_safety_state()
|
||||||
|
|
||||||
|
where = ["d.deleted_at IS NULL"]
|
||||||
|
params: List[Any] = []
|
||||||
|
if customer_id is not None:
|
||||||
|
where.append("d.customer_id = %s")
|
||||||
|
params.append(customer_id)
|
||||||
|
|
||||||
|
stats_rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(d.sync_status, 'unknown') AS sync_status,
|
||||||
|
COUNT(*)::int AS count
|
||||||
|
FROM ordre_drafts d
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
GROUP BY COALESCE(d.sync_status, 'unknown')
|
||||||
|
ORDER BY 1
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
sample_rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
d.id,
|
||||||
|
d.customer_id,
|
||||||
|
c.name AS customer_name,
|
||||||
|
d.title,
|
||||||
|
d.sync_status,
|
||||||
|
d.economic_order_number,
|
||||||
|
d.economic_invoice_number,
|
||||||
|
d.last_sync_at,
|
||||||
|
d.created_at
|
||||||
|
FROM ordre_drafts d
|
||||||
|
LEFT JOIN customers c ON c.id = d.customer_id
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
AND COALESCE(d.sync_status, 'pending') IN ('pending', 'failed', 'exported')
|
||||||
|
ORDER BY d.created_at DESC, d.id DESC
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
stats: Dict[str, int] = {row["sync_status"]: int(row["count"]) for row in stats_rows}
|
||||||
|
draft_sync_allowed = not (safety["ordre_economic_read_only"] or safety["ordre_economic_dry_run"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"mode": "live_write_enabled" if draft_sync_allowed else "safe_no_write",
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"safety": safety,
|
||||||
|
"sync_write_allowed": draft_sync_allowed,
|
||||||
|
"sync_write_block_reason": None if draft_sync_allowed else "ORDRE_ECONOMIC_READ_ONLY or ORDRE_ECONOMIC_DRY_RUN is enabled",
|
||||||
|
"stats": stats,
|
||||||
|
"candidate_count": len(sample_rows),
|
||||||
|
"candidates": sample_rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/invoices/{draft_id}/sync-preview", response_model=Dict[str, Any])
|
||||||
|
async def preview_invoice_sync(draft_id: int):
|
||||||
|
"""Preview sync intent for one draft without sending data to e-conomic."""
|
||||||
|
safety = _economic_safety_state()
|
||||||
|
draft = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
d.id,
|
||||||
|
d.customer_id,
|
||||||
|
c.name AS customer_name,
|
||||||
|
d.title,
|
||||||
|
d.sync_status,
|
||||||
|
d.economic_order_number,
|
||||||
|
d.economic_invoice_number,
|
||||||
|
d.last_sync_at,
|
||||||
|
d.created_at,
|
||||||
|
d.updated_at
|
||||||
|
FROM ordre_drafts d
|
||||||
|
LEFT JOIN customers c ON c.id = d.customer_id
|
||||||
|
WHERE d.id = %s
|
||||||
|
AND d.deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(draft_id,),
|
||||||
|
)
|
||||||
|
if not draft:
|
||||||
|
raise HTTPException(status_code=404, detail="Invoice draft not found")
|
||||||
|
|
||||||
|
next_action = "none"
|
||||||
|
if draft.get("sync_status") in {"pending", "failed"}:
|
||||||
|
next_action = "export_order"
|
||||||
|
elif draft.get("sync_status") == "exported":
|
||||||
|
next_action = "book_invoice"
|
||||||
|
|
||||||
|
draft_sync_allowed = not (safety["ordre_economic_read_only"] or safety["ordre_economic_dry_run"])
|
||||||
|
return {
|
||||||
|
"status": "preview",
|
||||||
|
"draft": draft,
|
||||||
|
"next_action": next_action,
|
||||||
|
"will_sync": False,
|
||||||
|
"safety": safety,
|
||||||
|
"sync_write_allowed": draft_sync_allowed,
|
||||||
|
"sync_write_block_reason": None if draft_sync_allowed else "ORDRE_ECONOMIC_READ_ONLY or ORDRE_ECONOMIC_DRY_RUN is enabled",
|
||||||
|
"message": "Preview only. No e-conomic API call has been made.",
|
||||||
|
}
|
||||||
@ -2735,6 +2735,7 @@
|
|||||||
<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">
|
||||||
|
|||||||
54
app/routers/bottom_bar.py
Normal file
54
app/routers/bottom_bar.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/state")
|
||||||
|
async def get_bottom_bar_state():
|
||||||
|
# MVP Mock Data for the Bottom Bar
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"sections": {
|
||||||
|
"mail": {
|
||||||
|
"unread": 5,
|
||||||
|
"customer_reply_needed": 2
|
||||||
|
},
|
||||||
|
"cases": {
|
||||||
|
"open": 12,
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"urgent": {
|
||||||
|
"count": 1,
|
||||||
|
"list": [{"id": 999, "title": "Server nede hos Kunde A"}]
|
||||||
|
},
|
||||||
|
"timer": {
|
||||||
|
"active_count": 1,
|
||||||
|
"list": [{"desc": "Fejlfinding WiFi", "elapsed": 1200}]
|
||||||
|
},
|
||||||
|
"kuma": {
|
||||||
|
"down": 3,
|
||||||
|
"list": ["Switch-Odense", "Printer-Aarhus", "FW-Kbh"]
|
||||||
|
},
|
||||||
|
"eset": {
|
||||||
|
"incidents": 0,
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"count": 2,
|
||||||
|
"list": [{"from": "Chef", "text": "Husk at ringe til Jensen"}, {"from": "System", "text": "Ny opgave tildelt."}]
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"count": 4,
|
||||||
|
"list": [
|
||||||
|
{"title": "Opsætning af ny PC", "deadline": "I dag 14:00"},
|
||||||
|
{"title": "Gennemgå ESET log", "deadline": "I dag 16:00"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"boss": {
|
||||||
|
"stats": {
|
||||||
|
"unassigned": 3,
|
||||||
|
"active_employees": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/services/m365_calendar.py
Normal file
23
app/services/m365_calendar.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class M365CalendarService:
|
||||||
|
"""
|
||||||
|
Håndterer opslag mod brugernes M365 kalendere for at se om de er:
|
||||||
|
- Optaget i møde
|
||||||
|
- Ledige de næste X minutter til at tage en opgave
|
||||||
|
Dette bruges af TaskRouter til workload-balancing (Phase 4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.is_connected = False
|
||||||
|
|
||||||
|
async def get_user_free_time(self, current_time: str, hours_ahead: int = 2) -> int:
|
||||||
|
"""
|
||||||
|
Returnerer antallet af minutter brugeren formodes ledig i den givne periode.
|
||||||
|
(Mock MVP implementering)
|
||||||
|
"""
|
||||||
|
logger.info("Slår opledig tid op i M365 (mock)")
|
||||||
|
return 90 # Mock: 1.5 timers ledig tid
|
||||||
33
app/services/task_routing.py
Normal file
33
app/services/task_routing.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class TaskRouter:
|
||||||
|
"""
|
||||||
|
Kernekomponentet for "Giv mig næste opgave"-knappen (Phase 3).
|
||||||
|
Udregner den optimale næste opgave ud fra:
|
||||||
|
1. Hastesager (SLA warnings etc.) i sags-køen
|
||||||
|
2. Medarbejderens ledige tid i kalenderen (skal integreres mod M365)
|
||||||
|
3. Kompetenceniveau (tags 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,17 +11,24 @@
|
|||||||
: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;
|
||||||
@ -35,6 +42,403 @@
|
|||||||
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);
|
||||||
@ -245,7 +649,8 @@
|
|||||||
<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>Hardware Assets</a></li>
|
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>BMC Assets</a></li>
|
||||||
|
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
|
<li><a class="dropdown-item py-2" href="/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>
|
||||||
@ -550,6 +955,75 @@
|
|||||||
</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;
|
||||||
@ -568,6 +1042,7 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=2.2"></script>
|
<script src="/static/js/telefoni.js?v=2.2"></script>
|
||||||
<script src="/static/js/sms.js?v=1.0"></script>
|
<script src="/static/js/sms.js?v=1.0"></script>
|
||||||
|
<script src="/static/js/bottom-bar.js?v=2.15"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
const darkModeToggle = document.getElementById('darkModeToggle');
|
||||||
@ -1264,6 +1739,9 @@ 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">
|
||||||
|
|||||||
105
app/shared/frontend/manual_modal.html
Normal file
105
app/shared/frontend/manual_modal.html
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<!-- Manual Help Modal -->
|
||||||
|
<div class="modal fade" id="manualHelpModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content" style="border-radius: 12px; border: none; background: var(--card-bg, #ffffff); box-shadow: 0 10px 40px rgba(0,0,0,0.2);">
|
||||||
|
<div class="modal-header border-bottom border-primary-subtle align-items-center">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="bi bi-book ms-1 me-2 text-accent"></i> Hent Hjælp
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0">
|
||||||
|
<div id="manualHelpLoading" class="text-center py-5 d-none">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
<div class="mt-3 text-muted">Søger i vejledninger...</div>
|
||||||
|
</div>
|
||||||
|
<div id="manualHelpContent" class="m-0">
|
||||||
|
<!-- Dynamic content loads here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-top border-primary-subtle bg-body-tertiary">
|
||||||
|
<a href="/manual" class="btn btn-outline-primary btn-sm me-auto">
|
||||||
|
<i class="bi bi-search me-1"></i> Alle Vejledninger
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Luk</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let manualHelpModalInstance = null;
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
const el = document.getElementById('manualHelpModal');
|
||||||
|
if (el && typeof bootstrap !== "undefined") {
|
||||||
|
manualHelpModalInstance = new bootstrap.Modal(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openManualHelp(contextModule, contextTag = '') {
|
||||||
|
if (!manualHelpModalInstance) return;
|
||||||
|
|
||||||
|
const contentDiv = document.getElementById('manualHelpContent');
|
||||||
|
const loadingDiv = document.getElementById('manualHelpLoading');
|
||||||
|
const modalTitle = document.querySelector('#manualHelpModal .modal-title');
|
||||||
|
|
||||||
|
if (modalTitle) {
|
||||||
|
modalTitle.innerHTML = `<i class="bi bi-book ms-1 me-2 text-accent"></i> Hjælp til: ${contextModule}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentDiv.classList.add('d-none');
|
||||||
|
loadingDiv.classList.remove('d-none');
|
||||||
|
manualHelpModalInstance.show();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = `/api/v1/manual/context?module=${encodeURIComponent(contextModule)}`;
|
||||||
|
if (contextTag) {
|
||||||
|
url += `&tag=${encodeURIComponent(contextTag)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error('Netværksfejl');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (!data.results || data.results.length === 0) {
|
||||||
|
html = `<div class="p-4 text-center text-muted">
|
||||||
|
<i class="bi bi-emoji-frown fs-1 d-block mb-3 opacity-50"></i>
|
||||||
|
Der blev ikke fundet nogle specifikke vejledninger til "${contextModule}".<br>
|
||||||
|
Prøv at søge i den fulde manual.
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
html += '<div class="list-group list-group-flush">';
|
||||||
|
data.results.forEach(m => {
|
||||||
|
const diffBadge = m.difficulty === 'advanced'
|
||||||
|
? '<span class="badge bg-danger-subtle text-danger ms-2">Avanceret</span>'
|
||||||
|
: '<span class="badge bg-success-subtle text-success ms-2">Begynder</span>';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<a href="/manual/${m.slug}" target="_blank" class="list-group-item list-group-item-action p-4 border-0 border-bottom border-primary-subtle hover-bg-light py-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<h6 class="mb-0 text-primary fw-bold">
|
||||||
|
<i class="bi bi-file-text me-2"></i> ${m.title}
|
||||||
|
</h6>
|
||||||
|
<div>
|
||||||
|
${diffBadge}
|
||||||
|
<i class="bi bi-arrow-up-right-square ms-2 text-muted small"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-body-secondary small mb-0 ps-4">${m.summary || 'Ingen beskrivelse tilgængelig.'}</p>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
contentDiv.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
contentDiv.innerHTML = `<div class="p-4 text-center text-danger"><i class="bi bi-exclamation-triangle mt-1 me-2"></i> Kunne ikke loade vejledninger. (${e.message})</div>`;
|
||||||
|
console.error('Fejl ved hentning af manual help:', e);
|
||||||
|
} finally {
|
||||||
|
loadingDiv.classList.add('d-none');
|
||||||
|
contentDiv.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -23,6 +23,7 @@ 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:
|
||||||
@ -74,6 +75,110 @@ 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(
|
||||||
@ -168,6 +273,9 @@ 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)
|
||||||
@ -189,13 +297,23 @@ 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,)
|
||||||
@ -229,6 +347,7 @@ 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):
|
||||||
@ -240,6 +359,10 @@ 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:
|
||||||
@ -261,6 +384,21 @@ 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"))
|
||||||
@ -286,6 +424,8 @@ 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,
|
||||||
@ -308,7 +448,6 @@ 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
|
||||||
@ -349,6 +488,9 @@ 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,
|
||||||
@ -357,7 +499,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, 'draft', %s
|
%s, %s, %s, %s, 'draft', %s
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
@ -380,6 +522,9 @@ 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,
|
||||||
@ -402,11 +547,13 @@ 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)
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
subscription["id"],
|
subscription["id"],
|
||||||
@ -419,6 +566,8 @@ 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"],
|
||||||
@ -544,6 +693,7 @@ 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -601,9 +751,10 @@ 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)
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
subscription_id, idx, description,
|
subscription_id, idx, description,
|
||||||
@ -612,6 +763,8 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
|||||||
item.get("asset_id"),
|
item.get("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")),
|
||||||
@ -1080,6 +1233,8 @@ 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 (
|
||||||
@ -1111,6 +1266,8 @@ 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
|
||||||
@ -1165,6 +1322,23 @@ async def update_subscription_asset_binding(binding_id: int, payload: Dict[str,
|
|||||||
raise HTTPException(status_code=404, detail="Binding not found")
|
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"""
|
||||||
@ -1179,7 +1353,9 @@ 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")
|
||||||
return result[0]
|
updated = 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:
|
||||||
@ -1204,6 +1380,12 @@ 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,16 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
from main import app
|
||||||
import main
|
for route in app.routes:
|
||||||
|
if hasattr(route, 'methods'):
|
||||||
print("=" * 80)
|
if '/manual' in route.path:
|
||||||
print("ALL REGISTERED ROUTES")
|
print(f"{list(route.methods)} {route.path}")
|
||||||
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)}")
|
|
||||||
|
|||||||
5
list_routes2.py
Normal file
5
list_routes2.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from main import app
|
||||||
|
for route in app.routes:
|
||||||
|
if hasattr(route, 'methods'):
|
||||||
|
if '/api/v1/manual' in route.path:
|
||||||
|
print(f"{list(route.methods)} {route.path}")
|
||||||
6
main.py
6
main.py
@ -133,6 +133,9 @@ from app.modules.orders.backend import router as orders_api
|
|||||||
from app.modules.orders.frontend import views as orders_views
|
from app.modules.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(
|
||||||
@ -446,6 +449,9 @@ app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
|
|||||||
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
app.include_router(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
|
||||||
|
|||||||
57
migrations/166_bottom_bar_module.sql
Normal file
57
migrations/166_bottom_bar_module.sql
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
-- Migration 166: Bottom bar module activation and overrides
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS module_role_settings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
module_name VARCHAR(100) NOT NULL,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (module_name, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_module_preferences (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
module_name VARCHAR(100) NOT NULL,
|
||||||
|
enabled BOOLEAN,
|
||||||
|
collapsed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, module_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES
|
||||||
|
('bottom_bar_enabled', 'false', 'bottom_bar', 'Enable or disable bottom bar globally', 'boolean', false)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Default role access: admins, managers and technicians enabled. viewers disabled.
|
||||||
|
INSERT INTO module_role_settings (module_name, group_id, enabled)
|
||||||
|
SELECT 'bottom_bar', g.id,
|
||||||
|
CASE WHEN g.name IN ('Administrators', 'Managers', 'Technicians') THEN TRUE ELSE FALSE END
|
||||||
|
FROM groups g
|
||||||
|
ON CONFLICT (module_name, group_id) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_module_role_settings_module ON module_role_settings(module_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_module_preferences_user_module ON user_module_preferences(user_id, module_name);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_module_setting_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS module_role_settings_updated_at_trigger ON module_role_settings;
|
||||||
|
CREATE TRIGGER module_role_settings_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON module_role_settings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_module_setting_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS user_module_preferences_updated_at_trigger ON user_module_preferences;
|
||||||
|
CREATE TRIGGER user_module_preferences_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON user_module_preferences
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_module_setting_updated_at();
|
||||||
35
migrations/167_rental_pricing_foundation.sql
Normal file
35
migrations/167_rental_pricing_foundation.sql
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
-- Migration 167: Rental pricing and asset rental status foundation
|
||||||
|
-- Adds minimal fields for Assets/Udlejning/Fakturering alias APIs.
|
||||||
|
-- Note: This migration is schema-only and does NOT perform any e-conomic sync.
|
||||||
|
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD COLUMN IF NOT EXISTS rental_price_day DECIMAL(10,2) CHECK (rental_price_day IS NULL OR rental_price_day >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS rental_price_week DECIMAL(10,2) CHECK (rental_price_week IS NULL OR rental_price_week >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS rental_price_month DECIMAL(10,2) CHECK (rental_price_month IS NULL OR rental_price_month >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS rental_price_year DECIMAL(10,2) CHECK (rental_price_year IS NULL OR rental_price_year >= 0);
|
||||||
|
|
||||||
|
ALTER TABLE sag_subscriptions
|
||||||
|
ADD COLUMN IF NOT EXISTS price_type VARCHAR(20) NOT NULL DEFAULT 'manual'
|
||||||
|
CHECK (price_type IN ('manual', 'day', 'week', 'month', 'year')),
|
||||||
|
ADD COLUMN IF NOT EXISTS custom_price_override BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS first_invoice_policy VARCHAR(20) NOT NULL DEFAULT 'start_date'
|
||||||
|
CHECK (first_invoice_policy IN ('start_date', 'next_cycle'));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_price_type ON sag_subscriptions(price_type);
|
||||||
|
|
||||||
|
ALTER TABLE sag_subscription_items
|
||||||
|
ADD COLUMN IF NOT EXISTS price_type VARCHAR(20) NOT NULL DEFAULT 'manual'
|
||||||
|
CHECK (price_type IN ('manual', 'day', 'week', 'month', 'year')),
|
||||||
|
ADD COLUMN IF NOT EXISTS custom_price_override BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE hardware_assets
|
||||||
|
ADD COLUMN IF NOT EXISTS rental_status VARCHAR(20) NOT NULL DEFAULT 'ledig'
|
||||||
|
CHECK (rental_status IN ('ledig', 'udlejet', 'defekt', 'retur'));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hardware_assets_rental_status
|
||||||
|
ON hardware_assets(rental_status)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN hardware_assets.rental_status IS 'Rental lifecycle status used by asset rental workflows.';
|
||||||
|
COMMENT ON COLUMN sag_subscriptions.price_type IS 'Price source type: manual or product rental periods.';
|
||||||
|
COMMENT ON COLUMN sag_subscriptions.first_invoice_policy IS 'Controls first invoice timing; default is start_date.';
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
-- Migration 168: Subscription scheduler and asset overlap indexes
|
||||||
|
-- Improves performance for due-subscription scans and asset overlap validation.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_status_next_invoice_date
|
||||||
|
ON sag_subscriptions(status, next_invoice_date);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_subscription_items_asset_period
|
||||||
|
ON sag_subscription_items(asset_id, period_from, period_to)
|
||||||
|
WHERE asset_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscription_asset_bindings_asset_dates_active
|
||||||
|
ON subscription_asset_bindings(asset_id, start_date, end_date)
|
||||||
|
WHERE deleted_at IS NULL AND status = 'active';
|
||||||
35
patch_backend_router.py
Normal file
35
patch_backend_router.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("app/modules/manual/backend/router.py", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
if "from fastapi import" in content and "BackgroundTasks" not in content:
|
||||||
|
content = content.replace("from fastapi import ", "from fastapi import BackgroundTasks, ")
|
||||||
|
|
||||||
|
if "def get_manual_article(slug: str):" in content:
|
||||||
|
content = content.replace("def get_manual_article(slug: str):", "def get_manual_article(slug: str, background_tasks: BackgroundTasks):")
|
||||||
|
|
||||||
|
bg_def = """
|
||||||
|
def _increment_use_count(manual_id: str):
|
||||||
|
try:
|
||||||
|
execute_query(
|
||||||
|
"UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s",
|
||||||
|
(manual_id,)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to increment use_count for manual {manual_id}: {e}")
|
||||||
|
|
||||||
|
@router.get("/{slug}")
|
||||||
|
"""
|
||||||
|
content = content.replace('@router.get("/{slug}")', bg_def)
|
||||||
|
|
||||||
|
target_inc = """ execute_query(
|
||||||
|
"UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s",
|
||||||
|
(article["id"],),
|
||||||
|
)"""
|
||||||
|
new_inc = """ # Increment view async to save latency
|
||||||
|
background_tasks.add_task(_increment_use_count, article["id"])"""
|
||||||
|
content = content.replace(target_inc, new_inc)
|
||||||
|
|
||||||
|
with open("app/modules/manual/backend/router.py", "w") as f:
|
||||||
|
f.write(content)
|
||||||
10
patch_base.py
Normal file
10
patch_base.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
with open("app/shared/frontend/base.html", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
target = '{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}'
|
||||||
|
replacement = target + '\n\n<!-- Manual Help Modal -->\n{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}'
|
||||||
|
|
||||||
|
text = text.replace(target, replacement)
|
||||||
|
|
||||||
|
with open("app/shared/frontend/base.html", "w") as f:
|
||||||
|
f.write(text)
|
||||||
53
patch_base_html.py
Normal file
53
patch_base_html.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
with open("app/shared/frontend/base.html", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
old_chips = """ <div id="bbCountLine" class="bb-count-line" role="status" aria-live="polite">
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="timer"><i class="bi bi-clock-history"></i> <span class="bb-chip-text">Timer: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="calls"><i class="bi bi-telephone"></i> <span class="bb-chip-text">Opkald: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-text">Mail: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="alerts"><i class="bi bi-exclamation-triangle"></i> <span class="bb-chip-text">Alerts: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="reminders"><i class="bi bi-bell"></i> <span class="bb-chip-text">Reminders: 0</span></button>
|
||||||
|
</div>"""
|
||||||
|
new_chips = """ <div id="bbCountLine" class="bb-count-line" role="status" aria-live="polite">
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-text">Mails: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="cases"><i class="bi bi-folder2-open"></i> <span class="bb-chip-text">Sager: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="urgent"><i class="bi bi-exclamation-octagon"></i> <span class="bb-chip-text">Hastesager: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="timer"><i class="bi bi-stopwatch"></i> <span class="bb-chip-text">Timere: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="kuma"><i class="bi bi-activity"></i> <span class="bb-chip-text">Kuma: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="eset"><i class="bi bi-shield-lock"></i> <span class="bb-chip-text">ESET: 0</span></button>
|
||||||
|
</div>"""
|
||||||
|
content = content.replace(old_chips, new_chips)
|
||||||
|
|
||||||
|
old_tabs = """ <div class="bb-side-tabs" role="tablist" aria-label="Bundbar kategorier">
|
||||||
|
<button class="bb-tab-btn is-active" type="button" data-bb-tab="timer" role="tab" aria-selected="true"><i class="bi bi-clock-history"></i> Timer</button>
|
||||||
|
<button class="bb-tab-btn" type="button" data-bb-tab="calls" role="tab" aria-selected="false"><i class="bi bi-telephone"></i> Opkald</button>
|
||||||
|
<button class="bb-tab-btn" type="button" data-bb-tab="mail" role="tab" aria-selected="false"><i class="bi bi-envelope"></i> Mail</button>
|
||||||
|
<button class="bb-tab-btn" type="button" data-bb-tab="alerts" role="tab" aria-selected="false"><i class="bi bi-exclamation-triangle"></i> Alerts</button>
|
||||||
|
<button class="bb-tab-btn" type="button" data-bb-tab="reminders" role="tab" aria-selected="false"><i class="bi bi-bell"></i> Reminders</button>
|
||||||
|
</div>
|
||||||
|
<div class="bb-tab-content" role="tabpanel" aria-live="polite">
|
||||||
|
<div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-clock-history me-1 text-accent"></i> <span class="bb-tab-title-text">Timer</span></div>
|
||||||
|
<ul id="bbTabList" class="bb-tab-list">
|
||||||
|
<li>Venter 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)
|
||||||
63
patch_bb_chat.py
Normal file
63
patch_bb_chat.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
# Update the rendering of the messages tab to include a user selector
|
||||||
|
chat_html = """
|
||||||
|
const replyBox = document.createElement('div');
|
||||||
|
replyBox.className = 'mt-2 border-top pt-2 border-primary-subtle';
|
||||||
|
replyBox.innerHTML = `
|
||||||
|
<div class="input-group input-group-sm mb-1">
|
||||||
|
<span class="input-group-text bg-light text-muted border-0"><i class="bi bi-person"></i></span>
|
||||||
|
<select id="chatRecipient" class="form-select border-0 bg-light">
|
||||||
|
<option value="all">Alle 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)
|
||||||
59
patch_bb_chat2.py
Normal file
59
patch_bb_chat2.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
events_html = """
|
||||||
|
if (btn.id === 'btnNextTask') {
|
||||||
|
console.log("-> Beder backend om næste opgave...");
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Omsætter kalender og SLA...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
fetch('/api/v1/bottom-bar/next_task', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const task = data.task;
|
||||||
|
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + task.title + ' (Sag #' + task.case_id + ') <span class="badge bg-light text-dark ms-2">' + data.free_time_calculated + 'm fri</span>';
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
btn.classList.remove('btn-primary');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Fejl:", err);
|
||||||
|
btn.innerHTML = "Fejl - prøv igen";
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn.id === 'btnSendMsg') {
|
||||||
|
const input = document.getElementById('chatInputQuick');
|
||||||
|
const recipientObj = document.getElementById('chatRecipient');
|
||||||
|
|
||||||
|
if (input && input.value.trim() !== '') {
|
||||||
|
const recipient = recipientObj ? recipientObj.options[recipientObj.selectedIndex].text : 'Alle';
|
||||||
|
|
||||||
|
console.log("-> Sender besked til", recipient, ":", input.value);
|
||||||
|
|
||||||
|
const msgVal = input.value;
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
const msgContainer = document.createElement('div');
|
||||||
|
msgContainer.className = 'mb-2 text-end';
|
||||||
|
msgContainer.innerHTML = '<div class="small text-muted mb-1 me-1" style="font-size:0.7rem;">Til: ' + recipient + '</div><div class="d-inline-block bg-primary text-white p-2 rounded-3 text-start shadow-sm" style="max-width: 85%;"><strong>Mig:</strong> ' + msgVal + '</div>';
|
||||||
|
|
||||||
|
const listUl = listContainer.querySelector('ul') || listContainer;
|
||||||
|
listUl.appendChild(msgContainer);
|
||||||
|
|
||||||
|
// Simple hacky scroll down
|
||||||
|
const tabInner = document.getElementById('bbTabInnerContent');
|
||||||
|
if(tabInner) {
|
||||||
|
tabInner.scrollTop = tabInner.scrollHeight + 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
pattern = r" if \(btn\.id === 'btnNextTask'\) \{.*?\n \}"
|
||||||
|
text = re.sub(pattern, events_html.strip(), text, flags=re.DOTALL)
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "w") as f:
|
||||||
|
f.write(text)
|
||||||
58
patch_bb_chat3.py
Normal file
58
patch_bb_chat3.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
# Replace the static select with a dynamic fetch container
|
||||||
|
events_html = """
|
||||||
|
const replyBox = document.createElement('div');
|
||||||
|
replyBox.className = 'mt-2 border-top pt-2 border-primary-subtle';
|
||||||
|
replyBox.innerHTML = `
|
||||||
|
<div class="input-group input-group-sm mb-1">
|
||||||
|
<span class="input-group-text bg-light text-muted border-0"><i class="bi bi-person"></i></span>
|
||||||
|
<select id="chatRecipient" class="form-select border-0 bg-light">
|
||||||
|
<option value="all">Indlæser brugere...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="chatInputQuick" class="form-control form-control-sm" placeholder="Skriv en besked...">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" id="btnSendMsg"><i class="bi bi-send"></i></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
chatContainer.appendChild(ul);
|
||||||
|
chatContainer.appendChild(replyBox);
|
||||||
|
innerContent.appendChild(chatContainer);
|
||||||
|
|
||||||
|
// Fetch users dynamically
|
||||||
|
fetch('/api/v1/users?is_active=true', { credentials: 'include' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(users => {
|
||||||
|
const sel = document.getElementById('chatRecipient');
|
||||||
|
if (sel) {
|
||||||
|
sel.innerHTML = '<option value="all">Alle på vagt</option><option value="system">System (Bot)</option>';
|
||||||
|
users.forEach(u => {
|
||||||
|
sel.innerHTML += `<option value="${u.id}">${u.name || u.email}</option>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => console.error("Error fetching users for chat:", e));
|
||||||
|
} else {
|
||||||
|
innerContent.appendChild(ul);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Find the block we inserted earlier and replace it
|
||||||
|
pattern = r" const replyBox = document\.createElement\('div'\);\n replyBox\.className = 'mt-2 border-top pt-2 border-primary-subtle';\n replyBox\.innerHTML = `[\s\S]*?innerContent\.appendChild\(chatContainer\);\n \} else \{\n innerContent\.appendChild\(ul\);\n \}"
|
||||||
|
# print(re.search(pattern, text))
|
||||||
|
text = re.sub(pattern, events_html, text)
|
||||||
|
|
||||||
|
# Then bump the cache version
|
||||||
|
with open("static/js/bottom-bar.js", "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
with open("app/shared/frontend/base.html", "r") as f:
|
||||||
|
bt = f.read()
|
||||||
|
bt = re.sub(r'bottom-bar\.js\?v=\d+\.\d+', 'bottom-bar.js?v=2.00', bt)
|
||||||
|
with open("app/shared/frontend/base.html", "w") as f2:
|
||||||
|
f2.write(bt)
|
||||||
10
patch_bb_ver2.py
Normal file
10
patch_bb_ver2.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("app/shared/frontend/base.html", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
# Update cache version
|
||||||
|
text = re.sub(r'bottom-bar\.js\?v=\d+\.\d+', 'bottom-bar.js?v=1.92', text)
|
||||||
|
|
||||||
|
with open("app/shared/frontend/base.html", "w") as f:
|
||||||
|
f.write(text)
|
||||||
64
patch_bottombar_router.py
Normal file
64
patch_bottombar_router.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
with open("app/modules/bottom_bar/backend/router.py", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Replace the whole endpoint
|
||||||
|
import re
|
||||||
|
|
||||||
|
new_content = """from fastapi import APIRouter
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/state")
|
||||||
|
async def get_bottom_bar_state():
|
||||||
|
# MVP Mock Data for the updated Bottom Bar & Chef-overblik
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"sections": {
|
||||||
|
"mail": {
|
||||||
|
"unread": 5,
|
||||||
|
"customer_reply_needed": 2
|
||||||
|
},
|
||||||
|
"cases": {
|
||||||
|
"open": 12,
|
||||||
|
"list": [{"id": 100, "title": "Blandet sag A"}, {"id": 105, "title": "Sag B"}]
|
||||||
|
},
|
||||||
|
"urgent": {
|
||||||
|
"count": 1,
|
||||||
|
"list": [{"id": 999, "title": "Server nede hos Kunde A"}]
|
||||||
|
},
|
||||||
|
"timer": {
|
||||||
|
"active_count": 1,
|
||||||
|
"list": [{"desc": "Fejlfinding WiFi", "elapsed": 1200}]
|
||||||
|
},
|
||||||
|
"kuma": {
|
||||||
|
"down": 3,
|
||||||
|
"list": ["Switch-Odense", "Printer-Aarhus", "FW-Kbh"]
|
||||||
|
},
|
||||||
|
"eset": {
|
||||||
|
"incidents": 0,
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"count": 2,
|
||||||
|
"list": [{"from": "Chef", "text": "Husk at ringe til Jensen"}, {"from": "System", "text": "Ny opgave tildelt."}]
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"count": 4,
|
||||||
|
"list": [
|
||||||
|
{"title": "Opsætning af ny PC", "deadline": "I dag 14:00"},
|
||||||
|
{"title": "Gennemgå ESET log", "deadline": "I dag 16:00"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"boss": {
|
||||||
|
"stats": {
|
||||||
|
"unassigned": 3,
|
||||||
|
"active_employees": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
with open("app/modules/bottom_bar/backend/router.py", "w") as f:
|
||||||
|
f.write(new_content)
|
||||||
97
patch_chip_filter.py
Normal file
97
patch_chip_filter.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Insert a filter state
|
||||||
|
if "let overviewFilter" not in content:
|
||||||
|
content = content.replace("let activeKey = 'timer';", "let activeKey = 'timer';\n let overviewFilter = null;")
|
||||||
|
|
||||||
|
# Update listFor overview so it can show filtered items
|
||||||
|
old_overview = """ if (key === 'overview') {
|
||||||
|
let out = [];
|
||||||
|
if (urgent.count > 0) out.push('🚨 Hastesager: ' + urgent.count + ' aktive');
|
||||||
|
if (mail.unread > 0) out.push('📧 Ubesvarede mails: ' + mail.unread + ' (' + mail.customer_reply_needed + ' kræver svar)');
|
||||||
|
if (cases.open > 0) out.push('📂 Åbne sager i alt: ' + cases.open);
|
||||||
|
if (kuma.down > 0) out.push('📉 Uptime Kuma nedetid: ' + kuma.down + ' enheder');
|
||||||
|
if (eset.incidents > 0) out.push('🔐 ESET incidents: ' + eset.incidents);
|
||||||
|
|
||||||
|
if (out.length === 0) {
|
||||||
|
out.push('🎉 Alt ser grønt ud! Intet kritisk lige nu.');
|
||||||
|
out.push('👉 Klik på fanerne til venstre for mere info.');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}"""
|
||||||
|
new_overview = """ if (key === 'overview') {
|
||||||
|
if (overviewFilter === 'urgent') return urgent.list ? urgent.list.map(u => '🚨 Hastesag: ' + u.title) : ['Ingen hastesager.'];
|
||||||
|
if (overviewFilter === 'kuma') return kuma.list ? kuma.list.map(k => '📉 Nede: ' + k) : ['Alle systemer oppe.'];
|
||||||
|
if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '🔐 Incident: ' + e) : ['Ingen ESET incidents.'];
|
||||||
|
if (overviewFilter === 'cases') return cases.list ? cases.list.map(c => '📂 ' + c.title) : ['Ingen åbne sager.'];
|
||||||
|
if (overviewFilter === 'mail') return ['📧 ' + mail.unread + ' ulæste mails. ' + mail.customer_reply_needed + ' kundesvar krævet.'];
|
||||||
|
|
||||||
|
let out = [];
|
||||||
|
if (urgent.count > 0) out.push('🚨 Hastesager: ' + urgent.count + ' aktive');
|
||||||
|
if (mail.unread > 0) out.push('📧 Ubesvarede mails: ' + mail.unread + ' (' + mail.customer_reply_needed + ' kræver svar)');
|
||||||
|
if (cases.open > 0) out.push('📂 Åbne sager i alt: ' + cases.open);
|
||||||
|
if (kuma.down > 0) out.push('📉 Uptime Kuma nedetid: ' + kuma.down + ' enheder');
|
||||||
|
if (eset.incidents > 0) out.push('🔐 ESET incidents: ' + eset.incidents);
|
||||||
|
|
||||||
|
if (out.length === 0) {
|
||||||
|
out.push('🎉 Alt ser grønt ud! Intet kritisk lige nu.');
|
||||||
|
out.push('👉 Klik på fanerne til venstre for mere info.');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_overview, new_overview)
|
||||||
|
|
||||||
|
# Update chip click to set filter
|
||||||
|
old_chip = """ // Map top chip keys to side tabs if applicable, else 'overview'
|
||||||
|
let targetTab = 'overview';
|
||||||
|
if (key === 'timer') targetTab = 'timer';
|
||||||
|
|
||||||
|
const tabBtn = document.querySelector('.bb-tab-btn[data-bb-tab="' + targetTab + '"]');
|
||||||
|
if (tabBtn) tabBtn.click();
|
||||||
|
});"""
|
||||||
|
new_chip = """ // Set filter if clicking a generic chip
|
||||||
|
let targetTab = 'overview';
|
||||||
|
if (key === 'timer') {
|
||||||
|
targetTab = 'timer';
|
||||||
|
overviewFilter = null;
|
||||||
|
} else if (['urgent', 'kuma', 'eset', 'cases', 'mail'].indexOf(key) !== -1) {
|
||||||
|
targetTab = 'overview';
|
||||||
|
overviewFilter = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabBtn = document.querySelector('.bb-tab-btn[data-bb-tab="' + targetTab + '"]');
|
||||||
|
if (tabBtn) tabBtn.click();
|
||||||
|
});"""
|
||||||
|
content = content.replace(old_chip, new_chip)
|
||||||
|
|
||||||
|
# Clear filter if a generic side tab is clicked
|
||||||
|
old_bind_tab = """ buttons[i].addEventListener('click', function () {
|
||||||
|
for (let j = 0; j < buttons.length; j++) {
|
||||||
|
buttons[j].classList.remove('is-active');
|
||||||
|
buttons[j].setAttribute('aria-selected', 'false');
|
||||||
|
}
|
||||||
|
this.classList.add('is-active');
|
||||||
|
this.setAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
activeKey = this.getAttribute('data-bb-tab');
|
||||||
|
renderTabPanel();"""
|
||||||
|
new_bind_tab = """ buttons[i].addEventListener('click', function (e) {
|
||||||
|
// Clear filter on direct human click of the button, unless we programmatically called click()
|
||||||
|
if (e.isTrusted) overviewFilter = null;
|
||||||
|
|
||||||
|
for (let j = 0; j < buttons.length; j++) {
|
||||||
|
buttons[j].classList.remove('is-active');
|
||||||
|
buttons[j].setAttribute('aria-selected', 'false');
|
||||||
|
}
|
||||||
|
this.classList.add('is-active');
|
||||||
|
this.setAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
activeKey = this.getAttribute('data-bb-tab');
|
||||||
|
renderTabPanel();"""
|
||||||
|
content = content.replace(old_bind_tab, new_bind_tab)
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "w") as f:
|
||||||
|
f.write(content)
|
||||||
92
patch_frontend.py
Normal file
92
patch_frontend.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("app/modules/manual/frontend/views.py", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
# Make sure cache is imported
|
||||||
|
if "manual_cache" not in text:
|
||||||
|
text = text.replace("from app.core.database import", "from app.modules.manual.backend.cache import manual_cache\nfrom app.core.database import")
|
||||||
|
|
||||||
|
new_func = """async def manual_index(
|
||||||
|
request: Request,
|
||||||
|
module: Optional[str] = Query(default=None),
|
||||||
|
difficulty: Optional[str] = Query(default=None),
|
||||||
|
tag: Optional[str] = Query(default=None),
|
||||||
|
search: Optional[str] = Query(default=None),
|
||||||
|
):
|
||||||
|
cache_key = f"views:list:{module}:{difficulty}:{tag}:{search}"
|
||||||
|
cached = manual_cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
rows, modules, unique_tags = cached
|
||||||
|
else:
|
||||||
|
filters = ["deleted_at IS NULL", "status = %s"]
|
||||||
|
params = ["published"]
|
||||||
|
# ... logic mapping ...
|
||||||
|
if module:
|
||||||
|
filters.append("module = %s")
|
||||||
|
params.append(module)
|
||||||
|
if difficulty:
|
||||||
|
filters.append("difficulty = %s")
|
||||||
|
params.append(difficulty)
|
||||||
|
if tag:
|
||||||
|
filters.append("tags @> %s")
|
||||||
|
params.append(f'["{tag}"]')
|
||||||
|
if search:
|
||||||
|
filters.append("(title ILIKE %s OR content ILIKE %s)")
|
||||||
|
params.extend([f"%{search}%", f"%{search}%"])
|
||||||
|
|
||||||
|
where_clause = " AND ".join(filters)
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
f"SELECT id, slug, title, content, module, tags, difficulty, use_count, updated_at "
|
||||||
|
f"FROM manual_articles WHERE {where_clause} ORDER BY updated_at DESC",
|
||||||
|
tuple(params)
|
||||||
|
) or []
|
||||||
|
|
||||||
|
modules = execute_query(
|
||||||
|
"SELECT DISTINCT module FROM manual_articles WHERE deleted_at IS NULL ORDER BY module ASC"
|
||||||
|
) or []
|
||||||
|
|
||||||
|
all_tags: List[str] = []
|
||||||
|
for row in rows:
|
||||||
|
if "tags" in row and row["tags"]:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
if isinstance(row["tags"], str):
|
||||||
|
t = json.loads(row["tags"])
|
||||||
|
if isinstance(t, list):
|
||||||
|
all_tags.extend(t)
|
||||||
|
elif isinstance(row["tags"], list):
|
||||||
|
all_tags.extend(row["tags"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unique_tags = sorted(list(set(all_tags)))
|
||||||
|
|
||||||
|
manual_cache.set(cache_key, (rows, modules, unique_tags))
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/manual/templates/list.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"articles": rows,
|
||||||
|
"available_modules": modules,
|
||||||
|
"available_tags": unique_tags,
|
||||||
|
"filters": {
|
||||||
|
"module": module or "",
|
||||||
|
"difficulty": difficulty or "",
|
||||||
|
"tag": tag or "",
|
||||||
|
"search": search or "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r'async def manual_index\(.*?return templates\.TemplateResponse\([\s\S]*?\n \)\n',
|
||||||
|
new_func,
|
||||||
|
text,
|
||||||
|
flags=re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
with open("app/modules/manual/frontend/views.py", "w") as f:
|
||||||
|
f.write(text)
|
||||||
53
patch_frontend_cache.py
Normal file
53
patch_frontend_cache.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("app/modules/manual/frontend/views.py", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
# Make sure cache is imported
|
||||||
|
if "manual_cache" not in text:
|
||||||
|
text = text.replace("from app.core.database import", "from app.modules.manual.backend.cache import manual_cache\nfrom app.core.database import")
|
||||||
|
|
||||||
|
# GET /manual
|
||||||
|
list_patch = """async def manual_index(
|
||||||
|
request: Request,
|
||||||
|
module: Optional[str] = Query(default=None),
|
||||||
|
difficulty: Optional[str] = Query(default=None),
|
||||||
|
tag: Optional[str] = Query(default=None),
|
||||||
|
search: Optional[str] = Query(default=None),
|
||||||
|
):
|
||||||
|
cache_key = f"views:list:{module}:{difficulty}:{tag}:{search}"
|
||||||
|
cached = manual_cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
# FastAPI TemplateResponse returns HTMLResponse which we can't easily cache natively
|
||||||
|
# But we can cache the context dictionary to avoid DB queries
|
||||||
|
rows, modules, unique_tags = cached
|
||||||
|
else:"""
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r'async def manual_index\([^)]*\):',
|
||||||
|
list_patch,
|
||||||
|
text
|
||||||
|
)
|
||||||
|
|
||||||
|
# Need to indent the whole db fetch logic
|
||||||
|
# Then we save it to cache.
|
||||||
|
# Alternatively, I can just use a simple python caching decorator in `views.py`. Since we share the manual_cache instance, we can just clear it.
|
||||||
|
text = text.replace(' return templates.TemplateResponse(', ' manual_cache.set(cache_key, (rows, modules, unique_tags))\n return templates.TemplateResponse(')
|
||||||
|
# Indenting the execute_query part cleanly using re.sub is hard. Let's do it using Python string replacement
|
||||||
|
lines = text.split('\n')
|
||||||
|
in_else = False
|
||||||
|
new_lines = []
|
||||||
|
for line in lines:
|
||||||
|
if 'else:' in line and 'manual_index' not in line: # crude check
|
||||||
|
if 'rows, modules, unique_tags = cached' in '\n'.join(new_lines[-5:]):
|
||||||
|
in_else = True
|
||||||
|
if in_else:
|
||||||
|
if line.startswith(' return templates.TemplateResponse('):
|
||||||
|
in_else = False
|
||||||
|
else:
|
||||||
|
if line.startswith(' '):
|
||||||
|
line = ' ' + line
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
with open("app/modules/manual/frontend/views.py", "w") as f:
|
||||||
|
f.write("\n".join(new_lines))
|
||||||
10
patch_hw_help.py
Normal file
10
patch_hw_help.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
with open("app/modules/hardware/templates/detail.html", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
target = ' <a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary" style="opacity: 0.8;">'
|
||||||
|
replacement = ' <button onclick="openManualHelp(\'Hardware\')" class="btn btn-sm btn-outline-info" title="Hjælp til Hardware"><i class="bi bi-question-lg"></i></button>\n' + target
|
||||||
|
|
||||||
|
content = content.replace(target, replacement)
|
||||||
|
|
||||||
|
with open("app/modules/hardware/templates/detail.html", "w") as f:
|
||||||
|
f.write(content)
|
||||||
54
patch_js_events.py
Normal file
54
patch_js_events.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
events = """
|
||||||
|
function bindDynamicActions() {
|
||||||
|
const listContainer = byId('bbDynamicList');
|
||||||
|
if (!listContainer) return;
|
||||||
|
|
||||||
|
listContainer.addEventListener('click', function (e) {
|
||||||
|
const target = e.target;
|
||||||
|
const btn = target.closest('button');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
if (btn.id === 'btnNextTask') {
|
||||||
|
console.log("-> Beder backend om næste opgave...");
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Henter...';
|
||||||
|
btn.disabled = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt Sag #8192!';
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
btn.classList.remove('btn-primary');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn.id === 'btnSendMsg') {
|
||||||
|
const input = byId('chatInputQuick');
|
||||||
|
if (input && input.value.trim() !== '') {
|
||||||
|
console.log("-> Sender besked:", input.value);
|
||||||
|
input.value = '';
|
||||||
|
const msgDiv = document.createElement('div');
|
||||||
|
msgDiv.className = 'mb-1 text-end';
|
||||||
|
msgDiv.innerHTML = '<div class="d-inline-block bg-primary text-white p-2 rounded-3" style="max-width: 80%;"><strong>Mig:</strong> ' + msgDiv.textContent + ' (Mock)</div>';
|
||||||
|
listContainer.insertBefore(msgDiv, listContainer.lastElementChild);
|
||||||
|
listContainer.scrollTop = listContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {"""
|
||||||
|
|
||||||
|
content = re.sub(r' function init\(\) \{', events, content)
|
||||||
|
|
||||||
|
init_bindings = """
|
||||||
|
bindSideTabs();
|
||||||
|
bindDynamicActions();
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = re.sub(r' bindSideTabs\(\);', init_bindings, content)
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "w") as f:
|
||||||
|
f.write(content)
|
||||||
44
patch_js_events2.py
Normal file
44
patch_js_events2.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
events = """
|
||||||
|
function bindDynamicActions() {
|
||||||
|
const listContainer = byId('bbTabsContent');
|
||||||
|
if (!listContainer) return;
|
||||||
|
|
||||||
|
listContainer.addEventListener('click', function (e) {
|
||||||
|
const target = e.target;
|
||||||
|
const btn = target.closest('button');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
if (btn.id === 'btnNextTask') {
|
||||||
|
console.log("-> Beder backend om næste opgave...");
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Omsætter kalender og SLA...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
fetch('/api/v1/bottom_bar/next_task', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const task = data.task;
|
||||||
|
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + task.title + ' (Sag #' + task.case_id + ') <span class="badge bg-light text-dark ms-2">' + data.free_time_calculated + 'm fri</span>';
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
btn.classList.remove('btn-primary');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Fejl:", err);
|
||||||
|
btn.innerHTML = "Fejl - prøv igen";
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {"""
|
||||||
|
|
||||||
|
text = text.replace(" document.addEventListener('DOMContentLoaded', function () {", events)
|
||||||
|
text = text.replace(" bindSheetToggle();", " bindSheetToggle();\n bindDynamicActions();")
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "w") as f:
|
||||||
|
f.write(text)
|
||||||
112
patch_js_icons.py
Normal file
112
patch_js_icons.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Fix updateBar
|
||||||
|
old_update_bar = """ function updateBar(sections) {
|
||||||
|
const counts = getCounts(sections);
|
||||||
|
const keys = Object.keys(counts);
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
const chip = document.querySelector('.bb-chip[data-bb-key="' + key + '"]');
|
||||||
|
if (chip) {
|
||||||
|
const label = key.charAt(0).toUpperCase() + key.slice(1);
|
||||||
|
const val = counts[key];
|
||||||
|
if (key === 'timer') {
|
||||||
|
chip.textContent = 'Timer: ' + val;
|
||||||
|
} else if (key === 'calls') {
|
||||||
|
chip.textContent = 'Opkald: ' + val;
|
||||||
|
} else if (key === 'mail') {
|
||||||
|
chip.textContent = 'Mail: ' + val;
|
||||||
|
} else if (key === 'alerts') {
|
||||||
|
chip.textContent = 'Alerts: ' + val;
|
||||||
|
} else {
|
||||||
|
chip.textContent = 'Reminders: ' + val;
|
||||||
|
}
|
||||||
|
|
||||||
|
chip.classList.toggle('has-items', val > 0);
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
new_update_bar = """ function updateBar(sections) {
|
||||||
|
const counts = getCounts(sections);
|
||||||
|
const keys = Object.keys(counts);
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
const chipText = document.querySelector('.bb-chip[data-bb-key="' + key + '"] .bb-chip-text');
|
||||||
|
const chip = document.querySelector('.bb-chip[data-bb-key="' + key + '"]');
|
||||||
|
if (chipText && chip) {
|
||||||
|
const val = counts[key];
|
||||||
|
if (key === 'timer') {
|
||||||
|
chipText.textContent = 'Timer: ' + val;
|
||||||
|
} else if (key === 'calls') {
|
||||||
|
chipText.textContent = 'Opkald: ' + val;
|
||||||
|
} else if (key === 'mail') {
|
||||||
|
chipText.textContent = 'Mail: ' + val;
|
||||||
|
} else if (key === 'alerts') {
|
||||||
|
chipText.textContent = 'Alerts: ' + val;
|
||||||
|
} else {
|
||||||
|
chipText.textContent = 'Reminders: ' + val;
|
||||||
|
}
|
||||||
|
|
||||||
|
chip.classList.toggle('has-items', val > 0);
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_update_bar, new_update_bar)
|
||||||
|
|
||||||
|
# Fix renderTabPanel Title text
|
||||||
|
old_render_tab = """ function renderTabPanel() {
|
||||||
|
const title = byId('bbTabTitle');
|
||||||
|
const list = byId('bbTabList');
|
||||||
|
if (!title || !list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleByKey = {
|
||||||
|
timer: 'Timer',
|
||||||
|
calls: 'Opkald',
|
||||||
|
mail: 'Mail',
|
||||||
|
alerts: 'Alerts',
|
||||||
|
reminders: 'Reminders'
|
||||||
|
};
|
||||||
|
|
||||||
|
title.textContent = (titleByKey[activeKey] || 'Info') + ' info';"""
|
||||||
|
new_render_tab = """ function renderTabPanel() {
|
||||||
|
const titleContainer = byId('bbTabTitle');
|
||||||
|
const list = byId('bbTabList');
|
||||||
|
if (!titleContainer || !list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleText = titleContainer.querySelector('.bb-tab-title-text');
|
||||||
|
|
||||||
|
const titleByKey = {
|
||||||
|
timer: 'Timer info',
|
||||||
|
calls: 'Opkald info',
|
||||||
|
mail: 'Mail info',
|
||||||
|
alerts: 'Alerts info',
|
||||||
|
reminders: 'Reminders info'
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconByKey = {
|
||||||
|
timer: 'bi-clock-history',
|
||||||
|
calls: 'bi-telephone',
|
||||||
|
mail: 'bi-envelope',
|
||||||
|
alerts: 'bi-exclamation-triangle',
|
||||||
|
reminders: 'bi-bell'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (titleText) {
|
||||||
|
titleText.textContent = (titleByKey[activeKey] || 'Info');
|
||||||
|
} else {
|
||||||
|
titleContainer.textContent = (titleByKey[activeKey] || 'Info');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconSpan = titleContainer.querySelector('.bi');
|
||||||
|
if (iconSpan) {
|
||||||
|
iconSpan.className = 'bi ' + (iconByKey[activeKey] || 'bi-info-circle') + ' me-2 text-accent';
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_render_tab, new_render_tab)
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "w") as f:
|
||||||
|
f.write(content)
|
||||||
182
patch_js_rich_ui.py
Normal file
182
patch_js_rich_ui.py
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Replace the innerList rendering logic to handle raw HTML from our templates securely
|
||||||
|
old_render_tab = """ // Render lists
|
||||||
|
const lines = listFor(activeKey, latestSections);
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
ul.className = 'bb-tab-list';
|
||||||
|
lines.forEach(function (line) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.innerHTML = String(line)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.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
Normal file
457
patch_lækker.py
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("app/shared/frontend/base.html", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Update root variables with RGB versions for glassmorphism
|
||||||
|
if "--bg-card-rgb" not in content:
|
||||||
|
content = content.replace(
|
||||||
|
"--bg-card: #ffffff;",
|
||||||
|
"--bg-card: #ffffff;\n --bg-card-rgb: 255, 255, 255;"
|
||||||
|
)
|
||||||
|
content = content.replace(
|
||||||
|
"--bg-card: #2c3034;",
|
||||||
|
"--bg-card: #2c3034;\n --bg-card-rgb: 44, 48, 52;"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update CSS for .global-bottom-bar
|
||||||
|
old_bottom_bar_css = """ .global-bottom-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: var(--bottom-bar-zindex);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.06);
|
||||||
|
min-height: var(--bottom-bar-height);
|
||||||
|
padding: 0.35rem 1rem calc(0.35rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
transform: translateY(calc(100% + 12px));
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.3s ease, opacity 0.25s ease;
|
||||||
|
}"""
|
||||||
|
new_bottom_bar_css = """ .global-bottom-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: var(--bottom-bar-zindex);
|
||||||
|
background: rgba(var(--bg-card-rgb), 0.85); /* Glassmorphism */
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-top: 1px solid rgba(var(--text-primary-rgb), 0.1);
|
||||||
|
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.08);
|
||||||
|
border-top-left-radius: 16px;
|
||||||
|
border-top-right-radius: 16px;
|
||||||
|
min-height: var(--bottom-bar-height);
|
||||||
|
padding: 0.5rem 1rem calc(0.5rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
transform: translateY(calc(100% + 12px));
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_bottom_bar_css, new_bottom_bar_css)
|
||||||
|
|
||||||
|
# Ensure --text-primary-rgb exists
|
||||||
|
if "--text-primary-rgb" not in content:
|
||||||
|
content = content.replace(
|
||||||
|
"--text-primary: #2c3e50;",
|
||||||
|
"--text-primary: #2c3e50;\n --text-primary-rgb: 44, 62, 80;"
|
||||||
|
)
|
||||||
|
content = content.replace(
|
||||||
|
"--text-primary: #f8f9fa;",
|
||||||
|
"--text-primary: #f8f9fa;\n --text-primary-rgb: 248, 249, 250;"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix Count Line CSS
|
||||||
|
old_count_line_css = """ .global-bottom-bar .bb-count-line {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}"""
|
||||||
|
new_count_line_css = """ .global-bottom-bar .bb-count-line {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none; /* Cleaner look without scrollbar */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.global-bottom-bar .bb-count-line::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_count_line_css, new_count_line_css)
|
||||||
|
|
||||||
|
# Fix Chip CSS
|
||||||
|
old_chip_css = """ .global-bottom-bar .bb-chip {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.79rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}"""
|
||||||
|
new_chip_css = """ .global-bottom-bar .bb-chip {
|
||||||
|
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_chip_css, new_chip_css)
|
||||||
|
|
||||||
|
old_chip_hover = """ .global-bottom-bar .bb-chip:hover,
|
||||||
|
.global-bottom-bar .bb-chip.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}"""
|
||||||
|
new_chip_hover = """ .global-bottom-bar .bb-chip:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,0.08);
|
||||||
|
border-color: rgba(var(--text-primary-rgb), 0.2);
|
||||||
|
}
|
||||||
|
.global-bottom-bar .bb-chip.is-active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
|
||||||
|
}
|
||||||
|
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.is-active {
|
||||||
|
color: #fff;
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_chip_hover, new_chip_hover)
|
||||||
|
|
||||||
|
# Toggle styling
|
||||||
|
old_toggle_css = """ .global-bottom-bar .bb-sheet-toggle {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.42rem 0.7rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}"""
|
||||||
|
new_toggle_css = """ .global-bottom-bar .bb-sheet-toggle {
|
||||||
|
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.global-bottom-bar .bb-sheet-toggle:hover {
|
||||||
|
background: var(--accent-light);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.global-bottom-bar .bb-sheet-toggle span { display: none; }"""
|
||||||
|
content = content.replace(old_toggle_css, new_toggle_css)
|
||||||
|
|
||||||
|
# Adjust inner sheet panel styling
|
||||||
|
old_sheet_inner = """ .global-bottom-bar .bb-sheet-inner {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px minmax(0, 1fr);
|
||||||
|
min-height: 210px;
|
||||||
|
max-height: min(50vh, 420px);
|
||||||
|
overflow: hidden;
|
||||||
|
}"""
|
||||||
|
new_sheet_inner = """ .global-bottom-bar .bb-sheet-inner {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
|
||||||
|
border-radius: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px minmax(0, 1fr);
|
||||||
|
min-height: 240px;
|
||||||
|
max-height: min(52vh, 420px);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 2px 10px rgba(0,0,0,0.02);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_sheet_inner, new_sheet_inner)
|
||||||
|
|
||||||
|
old_side_tabs = """ .global-bottom-bar .bb-side-tabs {
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
background: var(--accent-light);
|
||||||
|
padding: 0.45rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-content: start;
|
||||||
|
}"""
|
||||||
|
new_side_tabs = """ .global-bottom-bar .bb-side-tabs {
|
||||||
|
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
|
||||||
|
background: rgba(var(--text-primary-rgb), 0.03);
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-content: start;
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_side_tabs, new_side_tabs)
|
||||||
|
|
||||||
|
|
||||||
|
old_tab_btn = """ .global-bottom-bar .bb-tab-btn {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bottom-bar .bb-tab-btn.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}"""
|
||||||
|
new_tab_btn = """ .global-bottom-bar .bb-tab-btn {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.global-bottom-bar .bb-tab-btn i {
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.global-bottom-bar .bb-tab-btn:hover {
|
||||||
|
background: rgba(var(--text-primary-rgb), 0.05);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bottom-bar .bb-tab-btn.is-active {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.global-bottom-bar .bb-tab-btn.is-active i {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_tab_btn, new_tab_btn)
|
||||||
|
|
||||||
|
old_tab_content = """ .global-bottom-bar .bb-tab-content {
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bottom-bar .bb-tab-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bottom-bar .bb-tab-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bottom-bar .bb-tab-list li {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.38rem 0.55rem;
|
||||||
|
font-size: 0.81rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}"""
|
||||||
|
new_tab_content = """ .global-bottom-bar .bb-tab-content {
|
||||||
|
padding: 1.2rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bottom-bar .bb-tab-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bottom-bar .bb-tab-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bottom-bar .bb-tab-list li {
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-radius: 6px 8px 8px 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.global-bottom-bar .bb-tab-list li:hover {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_tab_content, new_tab_content)
|
||||||
|
|
||||||
|
old_detail_line = """ .global-bottom-bar .bb-detail-line {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}"""
|
||||||
|
new_detail_line = """ .global-bottom-bar .bb-detail-line {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.global-bottom-bar.is-expanded .bb-detail-line {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_detail_line, new_detail_line)
|
||||||
|
|
||||||
|
# Now fix the markup icons
|
||||||
|
old_markup = """<div id="globalBottomBar" class="global-bottom-bar" hidden>
|
||||||
|
<div class="bb-header">
|
||||||
|
<div id="bbCountLine" class="bb-count-line" role="status" aria-live="polite">
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="timer">Timer: 0</button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="calls">Opkald: 0</button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="mail">Mail: 0</button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="alerts">Alerts: 0</button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="reminders">Reminders: 0</button>
|
||||||
|
</div>
|
||||||
|
<button id="bbSheetToggle" class="bb-sheet-toggle" type="button" aria-expanded="false" aria-controls="bbSheetPanel">
|
||||||
|
Info
|
||||||
|
<i class="bi bi-chevron-up" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="bbCountDetail" class="bb-detail-line" role="status" aria-live="polite">Klik pa en kategori for detaljer</div>
|
||||||
|
<div id="bbSheetPanel" class="bb-sheet-panel" aria-hidden="true">
|
||||||
|
<div class="bb-sheet-inner">
|
||||||
|
<div class="bb-side-tabs" role="tablist" aria-label="Bundbar kategorier">
|
||||||
|
<button class="bb-tab-btn is-active" type="button" data-bb-tab="timer" role="tab" aria-selected="true">Timer</button>
|
||||||
|
<button class="bb-tab-btn" type="button" data-bb-tab="calls" role="tab" aria-selected="false">Opkald</button>
|
||||||
|
<button class="bb-tab-btn" type="button" data-bb-tab="mail" role="tab" aria-selected="false">Mail</button>
|
||||||
|
<button class="bb-tab-btn" type="button" data-bb-tab="alerts" role="tab" aria-selected="false">Alerts</button>
|
||||||
|
<button class="bb-tab-btn" type="button" data-bb-tab="reminders" role="tab" aria-selected="false">Reminders</button>
|
||||||
|
</div>
|
||||||
|
<div class="bb-tab-content" role="tabpanel" aria-live="polite">
|
||||||
|
<div id="bbTabTitle" class="bb-tab-title">Timer</div>
|
||||||
|
<ul id="bbTabList" class="bb-tab-list">
|
||||||
|
<li>Venter pa data...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
new_markup = """<div id="globalBottomBar" class="global-bottom-bar" hidden>
|
||||||
|
<div class="bb-header">
|
||||||
|
<div id="bbCountLine" class="bb-count-line" role="status" aria-live="polite">
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="timer"><i class="bi bi-clock-history"></i> <span class="bb-chip-text">Timer: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="calls"><i class="bi bi-telephone"></i> <span class="bb-chip-text">Opkald: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-text">Mail: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="alerts"><i class="bi bi-exclamation-triangle"></i> <span class="bb-chip-text">Alerts: 0</span></button>
|
||||||
|
<button class="bb-chip" type="button" data-bb-key="reminders"><i class="bi bi-bell"></i> <span class="bb-chip-text">Reminders: 0</span></button>
|
||||||
|
</div>
|
||||||
|
<button id="bbSheetToggle" class="bb-sheet-toggle" type="button" aria-expanded="false" aria-controls="bbSheetPanel" aria-label="Toggle detaljer">
|
||||||
|
<span>Info</span>
|
||||||
|
<i class="bi bi-chevron-up" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="bbCountDetail" class="bb-detail-line" role="status" aria-live="polite"><i class="bi bi-info-circle me-1 opacity-75"></i> Klik 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)
|
||||||
10
patch_mail_help.py
Normal file
10
patch_mail_help.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
with open("app/emails/frontend/emails.html", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
target = ' <button class="btn btn-outline-primary" onclick="openWorkflowManager()" title="Workflow Management">\n <i class="bi bi-diagram-3"></i>\n </button>'
|
||||||
|
replacement = target + ' <button class="btn btn-outline-info rounded-circle px-2 ms-2" onclick="openManualHelp(\'Mail\')" title="Hjælp til Mail">\n <i class="bi bi-question-lg"></i>\n </button>'
|
||||||
|
|
||||||
|
content = content.replace(target, replacement)
|
||||||
|
|
||||||
|
with open("app/emails/frontend/emails.html", "w") as f:
|
||||||
|
f.write(content)
|
||||||
95
patch_manual_backend.py
Normal file
95
patch_manual_backend.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("app/modules/manual/backend/router.py", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
# Make sure to import cache
|
||||||
|
if "from app.modules.manual.backend.cache import manual_cache" not in text:
|
||||||
|
text = text.replace("from app.core.database import", "from app.modules.manual.backend.cache import manual_cache\nfrom app.core.database import")
|
||||||
|
|
||||||
|
# Patch GET list:
|
||||||
|
# It has def get_manual_articles( ... ) -> Dict[str, Any]:
|
||||||
|
# Need to inject at the top of that function.
|
||||||
|
patch_list = """async def get_manual_articles(
|
||||||
|
module: Optional[str] = Query(None, description="Filtrer på modul"),
|
||||||
|
difficulty: Optional[DifficultyType] = Query(None, description="Filtrer på sværhedsgrad"),
|
||||||
|
tag: Optional[str] = Query(None, description="Filtrer på et bestemt tag"),
|
||||||
|
search: Optional[str] = Query(None, description="Søg i titel, summary og content"),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
cache_key = f"list:{module}:{difficulty}:{tag}:{search}:{limit}:{offset}"
|
||||||
|
cached = manual_cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached"""
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r'async def get_manual_articles\([^)]*\)\s*->\s*Dict\[str, Any\]:',
|
||||||
|
patch_list,
|
||||||
|
text
|
||||||
|
)
|
||||||
|
|
||||||
|
# And at the end of get_manual_articles:
|
||||||
|
# return {"items": rows, "total": total}
|
||||||
|
# Replace with setting cache
|
||||||
|
text = text.replace(
|
||||||
|
'return {"items": rows, "total": total}',
|
||||||
|
'result = {"items": rows, "total": total}\n manual_cache.set(cache_key, result)\n return result'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patch Context:
|
||||||
|
patch_context = """async def contextual_manual_suggestions(
|
||||||
|
module: str = Query(..., description="Modulet der søges hjælp ud fra, fx 'Sag'"),
|
||||||
|
tag: Optional[str] = Query(None, description="Kategori eller tag fra konteksten"),
|
||||||
|
limit: int = Query(5, ge=1, le=10)
|
||||||
|
):
|
||||||
|
cache_key = f"context:{module}:{tag}:{limit}"
|
||||||
|
cached = manual_cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached"""
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r'async def contextual_manual_suggestions\([^)]*\):',
|
||||||
|
patch_context,
|
||||||
|
text
|
||||||
|
)
|
||||||
|
# return {"results": rows}
|
||||||
|
text = text.replace(
|
||||||
|
'return {"results": rows}',
|
||||||
|
'result = {"results": rows}\n manual_cache.set(cache_key, result)\n return result'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patch Get Slug:
|
||||||
|
patch_slug = """async def get_manual_article(slug: str, background_tasks: BackgroundTasks):
|
||||||
|
cache_key = f"slug:{slug}"
|
||||||
|
cached = manual_cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
background_tasks.add_task(_increment_use_count, cached["id"])
|
||||||
|
return cached"""
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r'async def get_manual_article\(slug: str.*?BackgroundTasks[^)]*\):',
|
||||||
|
patch_slug,
|
||||||
|
text
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r'return \{\s*"article": article,\s*"steps": steps,\s*"relations": related\s*\}',
|
||||||
|
'result = {"article": article, "steps": steps, "relations": related}\n manual_cache.set(cache_key, result)\n return result',
|
||||||
|
text
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invalidation:
|
||||||
|
mutations = ['async def create_manual_article', 'async def update_manual_article', 'async def delete_manual_article']
|
||||||
|
for m in mutations:
|
||||||
|
text = text.replace(m, "@manual_cache.clear\n" + m)
|
||||||
|
|
||||||
|
# Actually, the python decorator is not made in cache.py, let me just add manual_cache.clear() inside them instead.
|
||||||
|
text = text.replace('@manual_cache.clear\n', '')
|
||||||
|
text = text.replace('return {"status": "success"', 'manual_cache.clear()\n return {"status": "success"')
|
||||||
|
text = text.replace('return {"status": "created"', 'manual_cache.clear()\n return {"status": "created"')
|
||||||
|
|
||||||
|
with open("app/modules/manual/backend/router.py", "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
37
patch_next_task.py
Normal file
37
patch_next_task.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
replacement = """
|
||||||
|
if (btn.id === 'btnNextTask') {
|
||||||
|
console.log("-> Beder backend om næste opgave...");
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Omsætter kalender og SLA...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
fetch('/api/v1/bottom_bar/next_task', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const task = data.task;
|
||||||
|
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + task.title + ' (Sag #' + task.case_id + ')';
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
btn.classList.remove('btn-primary');
|
||||||
|
|
||||||
|
// Notificer bruger ift kalender
|
||||||
|
console.log("Routing besked:", data.message);
|
||||||
|
alert("Opgave allokeret. " + data.message);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Fejl:", err);
|
||||||
|
btn.innerHTML = "Fejl - prøv igen";
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
text = re.sub(r'if \(btn\.id === \'btnNextTask\'\) \{.*?btn\.classList\.remove\(\'btn-primary\'\);\n \}, 1000\);\n \}', replacement.strip(), text, flags=re.DOTALL)
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "w") as f:
|
||||||
|
f.write(text)
|
||||||
27
patch_overview_logic.py
Normal file
27
patch_overview_logic.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
old_overview = """ if (key === 'overview') {
|
||||||
|
return [
|
||||||
|
'Velkommen til dit overblik',
|
||||||
|
'Her vises det vigtigste på tværs af systemet',
|
||||||
|
'Næste opgave kl. 14:00'
|
||||||
|
];
|
||||||
|
}"""
|
||||||
|
new_overview = """ if (key === 'overview') {
|
||||||
|
let out = [];
|
||||||
|
if (urgent.count > 0) out.push('🚨 Hastesager: ' + urgent.count + ' aktive');
|
||||||
|
if (mail.unread > 0) out.push('📧 Ubesvarede mails: ' + mail.unread + ' (' + mail.customer_reply_needed + ' kræver svar)');
|
||||||
|
if (cases.open > 0) out.push('📂 Åbne sager i alt: ' + cases.open);
|
||||||
|
if (kuma.down > 0) out.push('📉 Uptime Kuma nedetid: ' + kuma.down + ' enheder');
|
||||||
|
if (eset.incidents > 0) out.push('🔐 ESET incidents: ' + eset.incidents);
|
||||||
|
|
||||||
|
if (out.length === 0) {
|
||||||
|
out.push('🎉 Alt ser grønt ud! Intet kritisk lige nu.');
|
||||||
|
out.push('👉 Klik på fanerne til venstre for mere info.');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_overview, new_overview)
|
||||||
|
with open("static/js/bottom-bar.js", "w") as f:
|
||||||
|
f.write(content)
|
||||||
10
patch_sag_help.py
Normal file
10
patch_sag_help.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
with open("app/modules/sag/templates/detail.html", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
target = '<button class="btn btn-sm btn-link text-muted p-0 mb-1" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>'
|
||||||
|
replacement = target + '\n <button class="btn btn-sm btn-link text-info p-0 mb-1 ms-2" onclick="openManualHelp(\'Sag\')" title="Hjælp til sagsbehandling"><i class="bi bi-question-circle fs-5"></i></button>'
|
||||||
|
|
||||||
|
content = content.replace(target, replacement)
|
||||||
|
|
||||||
|
with open("app/modules/sag/templates/detail.html", "w") as f:
|
||||||
|
f.write(content)
|
||||||
22
patch_urls.py
Normal file
22
patch_urls.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
# Fix static JS calls
|
||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
text = text.replace("/api/v1/bottom_bar/next_task", "/api/v1/bottom-bar/next_task")
|
||||||
|
|
||||||
|
with open("static/js/bottom-bar.js", "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
# Fix main.py prefix
|
||||||
|
with open("main.py", "r") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
# Replace prefix
|
||||||
|
text = re.sub(r'app\.include_router\(bottom_bar_api\.router, prefix="/api/v1", tags=\["Bottom Bar"\]\)',
|
||||||
|
'app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])',
|
||||||
|
text)
|
||||||
|
|
||||||
|
with open("main.py", "w") as f:
|
||||||
|
f.write(text)
|
||||||
40
patch_views_fastapi.py
Normal file
40
patch_views_fastapi.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("app/modules/manual/frontend/views.py", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# 1. Ensure BackgroundTasks is imported
|
||||||
|
if "from fastapi import" in content and "BackgroundTasks" not in content:
|
||||||
|
content = content.replace("from fastapi import ", "from fastapi import BackgroundTasks, ")
|
||||||
|
|
||||||
|
# 2. Add BackgroundTasks to the manual_detail function signature
|
||||||
|
target_def = "async def manual_detail(request: Request, slug: str):"
|
||||||
|
new_def = "async def manual_detail(request: Request, slug: str, background_tasks: BackgroundTasks):"
|
||||||
|
content = content.replace(target_def, new_def)
|
||||||
|
|
||||||
|
# 3. Define the background method if not existing
|
||||||
|
bg_def = """
|
||||||
|
def _increment_use_count(manual_id: str):
|
||||||
|
try:
|
||||||
|
execute_query(
|
||||||
|
"UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s",
|
||||||
|
(manual_id,)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to increment use_count for manual {manual_id}: {e}")
|
||||||
|
|
||||||
|
@router.get("/manual/{slug}", response_class=HTMLResponse)
|
||||||
|
"""
|
||||||
|
content = content.replace('@router.get("/manual/{slug}", response_class=HTMLResponse)', bg_def)
|
||||||
|
|
||||||
|
# 4. Replace the execute_query in the endpoint
|
||||||
|
target_inc = """ execute_query(
|
||||||
|
"UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s",
|
||||||
|
(article["id"],),
|
||||||
|
)"""
|
||||||
|
new_inc = """ # Increment view async to save latency
|
||||||
|
background_tasks.add_task(_increment_use_count, article["id"])"""
|
||||||
|
content = content.replace(target_inc, new_inc)
|
||||||
|
|
||||||
|
with open("app/modules/manual/frontend/views.py", "w") as f:
|
||||||
|
f.write(content)
|
||||||
264
rewrite_bottom_bar.py
Normal file
264
rewrite_bottom_bar.py
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
with open("static/js/bottom-bar.js", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# We replace everything from getCounts downwards.
|
||||||
|
# Let's find the start of getCounts
|
||||||
|
start_idx = content.find("function getCounts")
|
||||||
|
|
||||||
|
new_logic = """function getCounts(sections) {
|
||||||
|
const mail = sections.mail || {};
|
||||||
|
const cases = sections.cases || {};
|
||||||
|
const urgent = sections.urgent || {};
|
||||||
|
const timer = sections.timer || {};
|
||||||
|
const kuma = sections.kuma || {};
|
||||||
|
const eset = sections.eset || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mail: Number(mail.unread || 0),
|
||||||
|
cases: Number(cases.open || 0),
|
||||||
|
urgent: Number(urgent.count || 0),
|
||||||
|
timer: Number(timer.active_count || 0),
|
||||||
|
kuma: Number(kuma.down || 0),
|
||||||
|
eset: Number(eset.incidents || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailTextFor(key, sections) {
|
||||||
|
const counts = getCounts(sections);
|
||||||
|
const nameMap = {
|
||||||
|
mail: 'Ubesvarede mails',
|
||||||
|
cases: 'Åbne sager',
|
||||||
|
urgent: 'Hastesager',
|
||||||
|
timer: 'Aktive timere',
|
||||||
|
kuma: 'Kuma alerts',
|
||||||
|
eset: 'ESET incidents'
|
||||||
|
};
|
||||||
|
const val = counts[key] || 0;
|
||||||
|
return nameMap[key] + ': ' + val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFor(key, sections) {
|
||||||
|
const mail = sections.mail || {};
|
||||||
|
const cases = sections.cases || {};
|
||||||
|
const urgent = sections.urgent || {};
|
||||||
|
const timer = sections.timer || {};
|
||||||
|
const kuma = sections.kuma || {};
|
||||||
|
const eset = sections.eset || {};
|
||||||
|
const messages = sections.messages || {};
|
||||||
|
const tasks = sections.tasks || {};
|
||||||
|
const boss = sections.boss || {};
|
||||||
|
|
||||||
|
if (key === 'overview') {
|
||||||
|
return [
|
||||||
|
'Velkommen til dit overblik',
|
||||||
|
'Her vises det vigtigste på tværs af systemet',
|
||||||
|
'Næste opgave kl. 14:00'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'timer') {
|
||||||
|
if (timer.active_count > 0) {
|
||||||
|
return (timer.list || []).map(t => 'Timer aktiv: ' + t.description);
|
||||||
|
}
|
||||||
|
return ['Ingen aktive timere lige nu.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'messages') {
|
||||||
|
if (messages.count > 0) {
|
||||||
|
return (messages.list || []).map(m => m.from + ': ' + m.text);
|
||||||
|
}
|
||||||
|
return ['Ingen nye beskeder.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'tasks') {
|
||||||
|
if (tasks.count > 0) {
|
||||||
|
return (tasks.list || []).map(t => t.title + ' (Deadline: ' + t.deadline + ')');
|
||||||
|
}
|
||||||
|
return ['Ingen aktuelle opgaver.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'boss') {
|
||||||
|
if (boss.stats) {
|
||||||
|
return [
|
||||||
|
'Ufordelte opgaver: ' + boss.stats.unassigned,
|
||||||
|
'Medarbejdere aktive: ' + boss.stats.active_employees
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return ['Henter chef-overblik...'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['Klik rundt i menuen for at se data for ' + key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBar(sections) {
|
||||||
|
const counts = getCounts(sections);
|
||||||
|
const keys = Object.keys(counts);
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
const chipText = document.querySelector('.bb-chip[data-bb-key="' + key + '"] .bb-chip-text');
|
||||||
|
const chip = document.querySelector('.bb-chip[data-bb-key="' + key + '"]');
|
||||||
|
if (chipText && chip) {
|
||||||
|
const val = counts[key];
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
mail: 'Mails',
|
||||||
|
cases: 'Sager',
|
||||||
|
urgent: 'Hastesager',
|
||||||
|
timer: 'Timere',
|
||||||
|
kuma: 'Kuma',
|
||||||
|
eset: 'ESET'
|
||||||
|
};
|
||||||
|
|
||||||
|
chipText.textContent = labels[key] + ': ' + val;
|
||||||
|
chip.classList.toggle('has-items', val > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabPanel() {
|
||||||
|
const titleContainer = byId('bbTabTitle');
|
||||||
|
const innerContent = byId('bbTabInnerContent');
|
||||||
|
if (!titleContainer || !innerContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleText = titleContainer.querySelector('.bb-tab-title-text');
|
||||||
|
|
||||||
|
const titleByKey = {
|
||||||
|
overview: 'Overblik',
|
||||||
|
timer: 'Timere',
|
||||||
|
messages: 'Beskeder',
|
||||||
|
tasks: 'Opgaver',
|
||||||
|
boss: 'Chef Dashboard'
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconByKey = {
|
||||||
|
overview: 'bi-bell',
|
||||||
|
timer: 'bi-stopwatch',
|
||||||
|
messages: 'bi-chat-dots',
|
||||||
|
tasks: 'bi-calendar-check',
|
||||||
|
boss: 'bi-person-workspace'
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeTitle = titleByKey[activeKey] || 'Info';
|
||||||
|
if (titleText) {
|
||||||
|
titleText.textContent = activeTitle;
|
||||||
|
} else {
|
||||||
|
titleContainer.textContent = activeTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconSpan = titleContainer.querySelector('.bi');
|
||||||
|
if (iconSpan) {
|
||||||
|
iconSpan.className = 'bi ' + (iconByKey[activeKey] || 'bi-info-circle') + ' me-2 text-accent';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render lists
|
||||||
|
const lines = listFor(activeKey, latestSections);
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
ul.className = 'bb-tab-list';
|
||||||
|
lines.forEach(function (line) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.innerHTML = String(line)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.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)
|
||||||
93
static/js/## Plan: Manualmodul MVP i BMC Hub.prompt.md
Normal file
93
static/js/## Plan: Manualmodul MVP i BMC Hub.prompt.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
## Plan: Manualmodul MVP i BMC Hub
|
||||||
|
|
||||||
|
Lever et API-first manualmodul med UUID-baseret datamodel, dedikeret oversigt/artikelsider, kontekstuel modalhjælp og admin CRUD i samme MVP. Implementer søgning med ILIKE nu for hurtig leverance og lav en klar fase-2 bane til PostgreSQL full-text + synonymer, så performance og AI-ready retning bevares uden at blokere MVP.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. Fase 1: Datamodel + migrationer (blokkerende)
|
||||||
|
2. Definér og opret tabellerne manual_articles, manual_steps og manual_relations med UUID primary keys, timestamps og soft delete (deleted_at). Tilføj nødvendige indeks for slug-opslag, modulfiltrering, sværhedsgrad, og relation-opslag. Markér slug som unik.
|
||||||
|
3. Beslut relation mellem manualer og eksisterende tagsystem: brug enten direkte JSONB tags i manual_articles (hurtigst) eller kobling til eksisterende tags/entity_tags via entity_type=manual. For MVP anbefales begge: behold tags JSONB til hurtig filtrering og tilføj relation hooks til entity_tags for fremtidig konsolidering. Depends on step 2.
|
||||||
|
4. Fase 2: Backend API + services
|
||||||
|
5. Opret ny vertical slice for manualmodulet med router, models og service-lag efter eksisterende featurestruktur. Router skal registreres under /api/v1/manual i main app startup. Depends on fase 1.
|
||||||
|
6. Implementer endpoints: GET liste (filtre: module, tags, difficulty, search), GET by slug, POST create, PUT update, DELETE soft delete. Tilføj validering for slug-kollisioner og payload-format for steps/relations. Parallel with step 7 once models are in place.
|
||||||
|
7. Implementer nested håndtering af steps (sorteret efter step_number) og related-manuals retrieval i artikel-endpoint, så frontend kan rendere én samlet artikelvisning uden ekstra roundtrips. Parallel with step 6.
|
||||||
|
8. Implementer kontekst-endpoint (fx query param context_module eller context_tag) så ?-ikoner kan hente relevante manualer direkte fra aktivt modul. Depends on step 6.
|
||||||
|
9. Fase 3: Frontend visning + kontekstuel hjælp
|
||||||
|
10. Opret manualsider i eksisterende template-system: en oversigtsside og en artikelside. Bevar Nordic Top, eksisterende dark mode og CSS variable-tema. Depends on fase 2.
|
||||||
|
11. Implementer UI i oversigten: søgefelt, modulfilter, tagfilter, sværhedsgrad og sortering med mest brugte øverst (baseret på visningscounter eller fallback created/updated sort indtil metrics er tilføjet). Parallel with step 12.
|
||||||
|
12. Implementer artikelside med titel, summary, step-by-step blokke, media (lazy loading), relaterede manualer og Åbn i kontekst-links. Parallel with step 11.
|
||||||
|
13. Integrer ?-ikon i prioriterede moduler (Sag, Hardware, Mail) med modal viewer, der loader relevant manual via context query. Brug eksisterende Bootstrap modal-mønster i base layout. Depends on step 8 and step 10.
|
||||||
|
14. Fase 4: Admin CRUD UI (MVP-krav)
|
||||||
|
15. Opret admin-side til opret/rediger/slet manualer med markdown editor, preview, step-editor og relation-editor. Brug server-side forms + eksisterende komponentmønstre for hurtig stabil levering. Depends on fase 2.
|
||||||
|
16. Implementer upload flow for billeder/video med validering af filtype/størrelse og lagring via eksisterende uploadmønster i repo. Sørg for at manual media kan references fra manual_steps. Depends on step 15.
|
||||||
|
17. Fase 5: Performance + observability + hardening
|
||||||
|
18. Tilføj målrettede indeks og query tuning for liste/slug/kontekst-opslag så listekald kan holde sig under 200ms i normal drift. Depends on fase 2.
|
||||||
|
19. Tilføj simpel cache for populære manualer (fx memory/cache-lag på GET liste og GET by slug), med sikker invalidation ved POST/PUT/DELETE. Depends on step 6.
|
||||||
|
20. Tilføj audit/logging for admin-ændringer og basis metrics (views/use_count) til ranking af Mest brugte. Parallel with step 19.
|
||||||
|
21. Fase 6: Verifikation + release
|
||||||
|
22. Backend tests: endpoint-kontrakter, validering, soft delete, filtrering, søgning og slug-unikhed.
|
||||||
|
23. Frontend tests/smoke: dark/light mode, responsive layouts, modal-konteksthjælp i Sag/Hardware/Mail, lazy loading af media.
|
||||||
|
24. Performance test: mål API-responstid for GET /api/v1/manual under realistisk dataset og justér indeks/cache til målopfyldelse.
|
||||||
|
25. Dokumentér MVP scope, begrænsninger og fase-2 roadmap for full-text + synonym mapping + AI forslag.
|
||||||
|
|
||||||
|
**Relevant files**
|
||||||
|
- /Users/christianthomas/DEV/bmc_hub_dev/main.py — registrering af nye manual-routere under API v1.
|
||||||
|
- /Users/christianthomas/DEV/bmc_hub_dev/app/core/database.py — query-patterns, execute_query helpers, transaction-adfærd.
|
||||||
|
- /Users/christianthomas/DEV/bmc_hub_dev/app/tags/backend/router.py — reference for CRUD/filter/search endpoint struktur.
|
||||||
|
- /Users/christianthomas/DEV/bmc_hub_dev/app/modules/search/backend/router.py — reference for nuværende ILIKE-baseret søgeadfærd.
|
||||||
|
- /Users/christianthomas/DEV/bmc_hub_dev/app/shared/frontend/base.html — dark mode toggle, navbar, modal patterns og theming variables.
|
||||||
|
- /Users/christianthomas/DEV/bmc_hub_dev/app/modules/orders/templates/list.html — reference for liste-layout med filter/toolbar/stats.
|
||||||
|
- /Users/christianthomas/DEV/bmc_hub_dev/app/modules/hardware/templates/detail.html — reference for detailside mønster og infoblokke.
|
||||||
|
- /Users/christianthomas/DEV/bmc_hub_dev/migrations/027_tag_system.sql — eksisterende tag/entity_tags arkitektur til manual relation.
|
||||||
|
|
||||||
|
**Verification**
|
||||||
|
1. Kør backend tests for alle manual-endpoints inkl. edge cases: manglende slug, dobbelt slug, tomme steps, soft-deleted records.
|
||||||
|
2. Kør integrationstest med seedet data: filtrering (module/tags/difficulty), kontekst-lookup og related manuals.
|
||||||
|
3. Kør UI-smoke på desktop + mobil: manual oversigt, artikelside, admin editor, ?-ikon modal i 3 moduler.
|
||||||
|
4. Mål responstid på GET liste + GET slug med og uden cache; dokumentér baseline og efteroptimering.
|
||||||
|
5. Verificér accessibility basics: focus states, kontrast i dark/light, keyboard-lukning af modal.
|
||||||
|
|
||||||
|
**Decisions**
|
||||||
|
- UUID vælges til manualtabellerne, selvom øvrige features ofte bruger serial id.
|
||||||
|
- MVP inkluderer både dedikerede manualsider og modalbaseret konteksthjælp.
|
||||||
|
- MVP inkluderer fuld admin CRUD UI inkl. markdown, preview og media-upload.
|
||||||
|
- MVP bruger simpel ILIKE-søgning; FTS + synonym-mapping flyttes til fase 2.
|
||||||
|
|
||||||
|
**Scope boundaries**
|
||||||
|
- Inkluderet: manualliste, artikel, relationer til modul/tag/sag-type, kontekstuel ?-hjælp, admin CRUD, simpel søgning.
|
||||||
|
- Ekskluderet i MVP: AI chatbot, auto-generering af manualer, heatmaps, avanceret synonym-ranking i runtime.
|
||||||
|
|
||||||
|
**Further Considerations**
|
||||||
|
1. Fase-2 datamodel for synonymer bør designes nu (table for canonical_term/alias/context), men aktiveres først efter MVP-go-live.
|
||||||
|
2. Overvej at gøre use_count events asynkrone for at undgå write-overhead på hvert view-request.
|
||||||
|
3. Afklar tidligt om markdown rendering skal sanitizes ekstra hårdt (XSS policy) i både preview og public view.
|
||||||
|
|
||||||
|
**Sprint-opdeling (forslag)**
|
||||||
|
1. Sprint 1 (5-7 dage): Fundament + API basis
|
||||||
|
- Leverancer: migrationer (UUID), manual core models, CRUD endpoints (liste/by slug/create/update/delete), simpel ILIKE søgning, baseline tests for API kontrakter.
|
||||||
|
- Exit-kriterier: GET liste og GET slug stabile i testmiljø, soft delete virker, slug-unikhed håndhæves.
|
||||||
|
- Estimat: 30-40 timer.
|
||||||
|
|
||||||
|
2. Sprint 2 (5-7 dage): Frontend brugerflow + konteksthjælp
|
||||||
|
- Leverancer: /manual oversigt, /manual/{slug} artikel, filter/search UI, relaterede manualer, ?-ikon modal i Sag/Hardware/Mail med kontekst-filtrering.
|
||||||
|
- Exit-kriterier: Bruger kan finde manual på maks 2 klik fra modul, dark/light mode og mobil virker uden regressions.
|
||||||
|
- Estimat: 35-45 timer.
|
||||||
|
|
||||||
|
3. Sprint 3 (5-7 dage): Admin CRUD + media + hardening
|
||||||
|
- Leverancer: admin editor (markdown + preview), steps/relation editor, billede/video upload, caching af populære manualer, metrics/audit logging.
|
||||||
|
- Exit-kriterier: Admin kan vedligeholde manualer end-to-end uden DB-manualarbejde, liste-load holder performance mål i normal drift.
|
||||||
|
- Estimat: 40-55 timer.
|
||||||
|
|
||||||
|
4. Sprint 4 (2-4 dage): Stabilisering + release
|
||||||
|
- Leverancer: performance tuning (<200ms mål), integration/smoke tests, accessibility baseline, release notes + fase-2 backlog (FTS/synonymer/AI).
|
||||||
|
- Exit-kriterier: Produktionsklar MVP med dokumenteret teststatus og kendte begrænsninger.
|
||||||
|
- Estimat: 16-28 timer.
|
||||||
|
|
||||||
|
**Samlet estimat**
|
||||||
|
- Lavt/højt spænd: 121-168 timer.
|
||||||
|
- Anbefalet bemanding: 1 backend + 1 frontend (delvist parallelt) eller 1 fullstack med 20-30% længere kalenderforløb.
|
||||||
|
|
||||||
|
**Parallelisering pr. sprint**
|
||||||
|
1. Sprint 1: DB migration + API modelarbejde kan starte parallelt; endpoint wiring afhænger af migration.
|
||||||
|
2. Sprint 2: Oversigtsside og artikelside kan bygges parallelt; modal-integration afhænger af kontekst-endpoint.
|
||||||
|
3. Sprint 3: Admin editor og caching/metrics kan køre parallelt; upload flow afhænger af valgt storage pattern.
|
||||||
|
4. Sprint 4: Testautomatisering og performance profiling kan køre parallelt frem mod release cut.
|
||||||
1011
static/js/bottom-bar.js
Normal file
1011
static/js/bottom-bar.js
Normal file
File diff suppressed because it is too large
Load Diff
23
test_manual.py
Normal file
23
test_manual.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
URL = "http://127.0.0.1:8000"
|
||||||
|
|
||||||
|
def test_endpoint(path, expected_status=200):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(URL + path)
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
if response.status == expected_status:
|
||||||
|
print(f"✅ {path} returned {response.status}")
|
||||||
|
return json.loads(response.read().decode())
|
||||||
|
else:
|
||||||
|
print(f"❌ {path} returned {response.status}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {path} failed: {e}")
|
||||||
|
|
||||||
|
print("Running Manual Module Smoke Tests...")
|
||||||
|
test_endpoint("/api/v1/manual")
|
||||||
|
test_endpoint("/api/v1/manual?limit=1")
|
||||||
|
test_endpoint("/api/v1/manual?search=test")
|
||||||
|
print("Done!")
|
||||||
Loading…
Reference in New Issue
Block a user