release: v2.8.1 planner and system observability

This commit is contained in:
Christian 2026-09-09 10:34:49 +02:00
parent 9ed709c9e8
commit 4bf592cf0c
32 changed files with 1385 additions and 18 deletions

View File

@ -0,0 +1,39 @@
# BMC Hub v2.8.1
## Planner
- Nyt Planner-modul til fordeling af sager og overblik over teamets kapacitet.
- Dag-, uge- og månedsvisning med planlagte blokke, registreret tid og ekstern travl tid.
- Drag-and-drop af sager med kompakt forhåndsvisning, 15-minutters placering og håndterbare standardblokke.
- Bredere sagsoversigt og forbedret pladsudnyttelse i kalenderen.
- Kalender og Planlægger er samlet i en Kalender-undermenu.
## Systemlog og drift
- Ny administrativ systemlog til telefoni, e-mail og øvrige integrationshændelser.
- Udvidet hændelseslogning og adgangskontrol i indstillingerne.
## AI og fakturaskabeloner
- Rettet AI Auto-generer i Template Builder.
- Tilføjet manglende `OWN_CVR`-konfiguration og validering af Ollama-fejl.
- Ollama fakturaudtræk tvinges til struktureret JSON.
- GUI'en viser nu den konkrete AI-fejl i stedet for en generisk fejlbesked.
## Integrationer og brugerflade
- Forbedringer til telefoni, e-mail og ALSO Cloud-integrationerne.
- Mindre rettelser til login, indstillinger og den fælles navigation.
- Fremtidige udviklingsplaner har fået et fast dokumentationsområde.
## Database
- Kør `migrations/240_planner.sql`.
- Kør `migrations/241_system_event_log.sql`.
## Verifikation
- Planner: 8 tests bestået.
- Samlet testsuite: 271 bestået, 1 sprunget over.
- Fire eksisterende tests kræver databaseinitialisering eller har forældede invoice-history mocks.
- Python- og JavaScript-syntakskontrol bestået.

View File

@ -1 +1 @@
2.7.2 2.8.1

View File

@ -87,6 +87,11 @@
</div> </div>
<script> <script>
const requestedNext = new URLSearchParams(window.location.search).get('next');
const loginRedirectTarget = requestedNext && requestedNext.startsWith('/') && !requestedNext.startsWith('//')
? requestedNext
: '/';
document.getElementById('loginForm').addEventListener('submit', async (e) => { document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
@ -131,8 +136,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
return; return;
} }
// Redirect to dashboard window.location.href = loginRedirectTarget;
window.location.href = '/';
} else { } else {
// Show error // Show error
errorText.textContent = data.detail || 'Login fejlede. Tjek brugernavn og adgangskode.'; errorText.textContent = data.detail || 'Login fejlede. Tjek brugernavn og adgangskode.';
@ -169,8 +173,7 @@ if (token) {
d.setTime(d.getTime() + (24*60*60*1000)); d.setTime(d.getTime() + (24*60*60*1000));
document.cookie = `access_token=${token};expires=${d.toUTCString()};path=/;SameSite=Lax`; document.cookie = `access_token=${token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
// Redirect to dashboard window.location.href = loginRedirectTarget;
window.location.href = '/';
} else { } else {
// Token invalid, clear storage // Token invalid, clear storage
localStorage.removeItem('access_token'); localStorage.removeItem('access_token');

View File

@ -3700,10 +3700,16 @@ RETURNER KUN JSON - intet andet!"""
if not result: if not result:
raise HTTPException(status_code=500, detail="AI kunne ikke analysere fakturaen") raise HTTPException(status_code=500, detail="AI kunne ikke analysere fakturaen")
if result.get("error"):
# extract_from_text returns operational Ollama failures as a dict.
# Do not expose that dict as a successful analysis to the browser.
raise HTTPException(status_code=502, detail=str(result["error"]))
logger.info(f"✅ AI analyse gennemført: {result}") logger.info(f"✅ AI analyse gennemført: {result}")
return result return result
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"❌ AI analyse fejlede: {e}") logger.error(f"❌ AI analyse fejlede: {e}")
raise HTTPException(status_code=500, detail=f"AI analyse fejlede: {str(e)}") raise HTTPException(status_code=500, detail=f"AI analyse fejlede: {str(e)}")

View File

@ -1370,10 +1370,20 @@ async function autoGenerateTemplate() {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('AI analyse fejlede'); let detail = `AI analyse fejlede (HTTP ${response.status})`;
try {
const payload = await response.json();
if (payload?.detail) detail = payload.detail;
} catch (_) {
// Keep the HTTP status when the server did not return JSON.
}
throw new Error(detail);
} }
const result = await response.json(); const result = await response.json();
if (result?.error) {
throw new Error(result.error);
}
// Helper to extract value (handles both nested {value, pattern} and flat formats) // Helper to extract value (handles both nested {value, pattern} and flat formats)
const getValue = (field) => field?.value || field; const getValue = (field) => field?.value || field;
@ -1468,7 +1478,7 @@ async function autoGenerateTemplate() {
console.error('AI generation failed:', error); console.error('AI generation failed:', error);
btn.innerHTML = originalText; btn.innerHTML = originalText;
btn.disabled = false; btn.disabled = false;
alert('AI analyse fejlede. Prøv manuel indtastning.'); alert(`AI analyse fejlede: ${error.message || 'Ukendt fejl'}`);
} }
} }

View File

@ -123,6 +123,9 @@ class Settings(BaseSettings):
# Ollama LLM # Ollama LLM
OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434" OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
OLLAMA_MODEL: str = "llama3.2" OLLAMA_MODEL: str = "llama3.2"
# Primary company CVR used to prevent AI invoice extraction from treating
# the buyer (BMC) as the supplier.
OWN_CVR: str = "29522790"
# Email System Configuration # Email System Configuration
# IMAP Settings # IMAP Settings

View File

@ -488,6 +488,11 @@
const fetchJson = async (url, options = {}) => { const fetchJson = async (url, options = {}) => {
const res = await fetch(url, options); const res = await fetch(url, options);
if (res.status === 401) {
const next = `${window.location.pathname}${window.location.search}`;
window.location.assign(`/login?next=${encodeURIComponent(next)}`);
throw new Error('Sessionen mangler eller er udløbet. Du sendes til login.');
}
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
let message = text || `HTTP ${res.status}`; let message = text || `HTTP ${res.status}`;
@ -525,7 +530,7 @@
}; };
const loadConfig = async () => { const loadConfig = async () => {
const config = await fetchJson('/api/v1/also/config'); const config = await fetchJson('/api/v1/also/config', { cache: 'no-store' });
const label = !config.enabled const label = !config.enabled
? 'ALSO deaktiveret' ? 'ALSO deaktiveret'
: config.read_only : config.read_only

View File

@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from fastapi import APIRouter, File, Query, Request, UploadFile from fastapi import APIRouter, File, Query, Request, Response, UploadFile
from app.modules.also.backend.service import also_service from app.modules.also.backend.service import also_service
from app.modules.also.models.schemas import ( from app.modules.also.models.schemas import (
@ -34,7 +34,8 @@ def _user_id_from_request(request: Request) -> Optional[int]:
@router.get("/also/config") @router.get("/also/config")
async def also_config() -> dict: async def also_config(response: Response) -> dict:
response.headers["Cache-Control"] = "no-store"
return also_service.get_config() return also_service.get_config()

View File

@ -0,0 +1 @@
"""Case planning and capacity module."""

View File

@ -0,0 +1 @@
"""Planner API package."""

View File

@ -0,0 +1,34 @@
"""Provider-neutral, read-only busy-time interface."""
from typing import Protocol
from app.core.database import execute_query
class BusyTimeProvider(Protocol):
def get_busy(self, user_ids: list[int], starts_at, ends_at) -> list[dict]: ...
def statuses(self, user_ids: list[int]) -> list[dict]: ...
class DatabaseBusyTimeProvider:
"""Cache adapter used by Outlook/Google sync jobs without exposing meeting data."""
def get_busy(self, user_ids, starts_at, ends_at):
if not user_ids:
return []
return execute_query(
"""SELECT user_id, starts_at, ends_at, provider FROM planner_external_busy
WHERE user_id = ANY(%s) AND starts_at < %s AND ends_at > %s""",
(user_ids, ends_at, starts_at),
) or []
def statuses(self, user_ids):
if not user_ids:
return []
return execute_query(
"""SELECT user_id, provider, status, last_success_at, last_error_at
FROM planner_integrations WHERE user_id = ANY(%s)""",
(user_ids,),
) or []
busy_time_provider: BusyTimeProvider = DatabaseBusyTimeProvider()

View File

@ -0,0 +1,460 @@
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from typing import Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from psycopg2.extras import RealDictCursor, Json
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
from .providers import busy_time_provider
router = APIRouter(prefix="/planner")
TZ = ZoneInfo("Europe/Copenhagen")
CLOSED_STATUSES = {"afsluttet", "lukket", "closed", "done", "arkiveret", "archived"}
class AllocationInput(BaseModel):
sag_id: int
user_id: int
starts_at: datetime
ends_at: datetime
note: str | None = Field(default=None, max_length=2000)
source: str = Field(default="manual", max_length=40)
confirm_conflicts: bool = False
locked: bool = False
@model_validator(mode="after")
def valid_period(self):
if self.ends_at <= self.starts_at:
raise ValueError("Sluttid skal ligge efter starttid")
return self
class AllocationUpdate(BaseModel):
user_id: int | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
note: str | None = Field(default=None, max_length=2000)
version: int
confirm_conflicts: bool = False
locked: bool | None = None
class CaseEstimateInput(BaseModel):
estimated_minutes: int | None = Field(default=None, ge=0, le=525600)
class PlannerSettingsInput(BaseModel):
warning_percent: int = Field(ge=1, le=99)
full_percent: int = Field(ge=100, le=300)
default_allocation_minutes: int = Field(ge=15, le=1440)
def _permissions(user: dict) -> set[str]:
return set(user.get("permissions") or [])
def _can_view(user: dict) -> bool:
return bool(user.get("is_superadmin") or "planner.view" in _permissions(user))
def _can_edit(user: dict, target_id: int) -> bool:
if user.get("is_superadmin") or "planner.admin" in _permissions(user):
return True
if target_id == int(user["id"]) and "planner.edit_own" in _permissions(user):
return True
if "planner.edit_team" not in _permissions(user):
return False
return bool(execute_query_single(
"SELECT 1 FROM planner_team_scope WHERE editor_user_id=%s AND target_user_id=%s",
(user["id"], target_id),
))
def _require_view(user: dict):
if not _can_view(user):
raise HTTPException(403, "Du har ikke adgang til Planlæggeren")
def _aware(value: datetime) -> datetime:
return value.replace(tzinfo=TZ) if value.tzinfo is None else value
def _minutes(start, end) -> int:
return max(0, round((end - start).total_seconds() / 60))
def _merge_minutes(intervals: list[tuple[datetime, datetime]], start: datetime, end: datetime) -> int:
clipped = sorted((max(a, start), min(b, end)) for a, b in intervals if a < end and b > start)
merged: list[list[datetime]] = []
for a, b in clipped:
if not merged or a > merged[-1][1]:
merged.append([a, b])
else:
merged[-1][1] = max(merged[-1][1], b)
return sum(_minutes(a, b) for a, b in merged)
def _sum_minutes(intervals: list[tuple[datetime, datetime]], start: datetime, end: datetime) -> int:
"""Count every allocation; overlapping jobs each consume assigned capacity."""
return sum(_minutes(max(a, start), min(b, end)) for a, b in intervals if a < end and b > start)
def _capacity(user_ids: list[int], starts_at: datetime, ends_at: datetime, allocations=None) -> list[dict]:
settings = execute_query_single("SELECT * FROM planner_settings WHERE id=1") or {
"warning_percent": 80, "full_percent": 100
}
schedules = execute_query(
"""SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active
AND (valid_from IS NULL OR valid_from <= %s) AND (valid_to IS NULL OR valid_to >= %s)""",
(user_ids, ends_at.date(), starts_at.date()),
) or []
absences = execute_query(
"SELECT user_id, starts_at, ends_at FROM planner_absences WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",
(user_ids, ends_at, starts_at),
) or []
external = busy_time_provider.get_busy(user_ids, starts_at, ends_at)
registered = execute_query(
"""SELECT t.medarbejder_id AS user_id,
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at) AS registered_at,
COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes
FROM tmodule_times t JOIN sag_sager s ON s.id=t.sag_id
WHERE t.medarbejder_id=ANY(%s)
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)>=%s
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)<%s
AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)""",
(user_ids, starts_at, ends_at, list(CLOSED_STATUSES)),
) or []
if allocations is None:
allocations = execute_query(
"SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",
(user_ids, ends_at, starts_at),
) or []
result = []
current = starts_at.astimezone(TZ).date()
final = (ends_at - timedelta(microseconds=1)).astimezone(TZ).date()
schedule_map = {(r["user_id"], r["weekday"]): r for r in schedules}
while current <= final:
for uid in user_ids:
row = schedule_map.get((uid, current.weekday()))
if row:
day_start = datetime.combine(current, row["start_time"], TZ)
day_end = datetime.combine(current, row["end_time"], TZ)
gross = _minutes(day_start, day_end)
unavailable = _merge_minutes(
[( _aware(r["starts_at"]), _aware(r["ends_at"]) ) for r in absences + external if r["user_id"] == uid],
day_start, day_end,
)
available = max(0, gross - int(row["break_minutes"] or 0) - unavailable)
else:
day_start = datetime.combine(current, time(0), TZ)
day_end = day_start + timedelta(days=1)
available = 0
allocated = _sum_minutes(
[(_aware(r["starts_at"]), _aware(r["ends_at"])) for r in allocations if r["user_id"] == uid],
day_start, day_end,
)
percent = round(allocated * 100 / available) if available else (100 if allocated else 0)
registered_minutes = sum(
int(r.get("minutes") or 0) for r in registered
if r["user_id"] == uid and _aware(r["registered_at"]).astimezone(TZ).date() == current
)
state = "gray" if available == 0 else (
"red" if percent >= settings["full_percent"] else
"yellow" if percent >= settings["warning_percent"] else "green"
)
result.append({"user_id": uid, "date": current.isoformat(), "available_minutes": available,
"allocated_minutes": allocated, "percent": percent, "state": state,
"registered_minutes": registered_minutes,
"label": f"{allocated / 60:g} af {available / 60:g} timer planlagt",
"registered_label": f"{registered_minutes / 60:g} timer registreret"})
current += timedelta(days=1)
return result
def _conflicts(user_id, starts_at, ends_at, exclude_id=None) -> tuple[str, dict]:
query = """SELECT id FROM planner_allocations WHERE deleted_at IS NULL AND user_id=%s
AND starts_at < %s AND ends_at > %s"""
params: list = [user_id, ends_at, starts_at]
if exclude_id:
query += " AND id <> %s"
params.append(exclude_id)
overlaps = execute_query(query, tuple(params)) or []
prospective = (execute_query(
"SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id=%s AND starts_at<%s AND ends_at>%s" + (" AND id<>%s" if exclude_id else ""),
tuple([user_id, ends_at, starts_at] + ([exclude_id] if exclude_id else [])),
) or []) + [{"user_id": user_id, "starts_at": starts_at, "ends_at": ends_at}]
cap = _capacity([user_id], starts_at, ends_at, prospective)
overbooked = any(x["state"] == "red" and x["allocated_minutes"] > x["available_minutes"] for x in cap)
status = "overlap_and_overbooked" if overlaps and overbooked else "overlap" if overlaps else "overbooked" if overbooked else "none"
return status, {"overlapping_ids": [r["id"] for r in overlaps], "capacity": cap}
def _serialize(row: dict | None):
if not row: return None
return {k: (v.isoformat() if isinstance(v, (datetime, date, time)) else v) for k, v in row.items()}
@router.get("/metadata")
def metadata(current_user: dict = Depends(get_current_user)):
_require_view(current_user)
users = execute_query("SELECT user_id AS id, COALESCE(full_name, username) AS name FROM users WHERE is_active ORDER BY name") or []
settings = execute_query_single("SELECT warning_percent, full_percent, default_allocation_minutes FROM planner_settings WHERE id=1")
departments = execute_query("SELECT id, name FROM groups ORDER BY name") or []
statuses = busy_time_provider.statuses([r["id"] for r in users])
editable_user_ids = [r["id"] for r in users if _can_edit(current_user, r["id"])]
return {"users": users, "departments": departments, "settings": settings, "integration_statuses": statuses,
"current_user_id": current_user["id"], "permissions": list(_permissions(current_user)),
"editable_user_ids": editable_user_ids}
@router.put("/settings")
def update_settings(payload: PlannerSettingsInput, current_user: dict = Depends(get_current_user)):
if not (current_user.get("is_superadmin") or "planner.admin" in _permissions(current_user)):
raise HTTPException(403, "Kun administratorer kan ændre kapacitetsgrænser")
if payload.warning_percent >= payload.full_percent:
raise HTTPException(422, "Gul-grænsen skal ligge under rød-grænsen")
return execute_query_single(
"""UPDATE planner_settings SET warning_percent=%s,full_percent=%s,default_allocation_minutes=%s,
updated_by=%s,updated_at=NOW() WHERE id=1 RETURNING *""",
(payload.warning_percent,payload.full_percent,payload.default_allocation_minutes,current_user["id"]),
)
@router.get("/cases")
def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str] = Query(default=[]),
case_type: list[str] = Query(default=[]), responsible_id: int | None = None,
department_id: int | None = None,
planning: Literal["all", "unplanned", "partial", "planned"] = "all", mine: bool = False,
urgent: bool = False, sort: Literal["priority", "remaining", "updated"] = "priority",
limit: int = Query(100, ge=1, le=300), current_user: dict = Depends(get_current_user)):
_require_view(current_user)
sql = """SELECT s.id, s.titel AS title, s.status, s.type, s.priority::text, s.ansvarlig_bruger_id AS responsible_id,
(LOWER(COALESCE(s.status,'')) = ANY(%s)) AS is_closed,
COALESCE(u.full_name,u.username) AS responsible_name, c.name AS customer_name,
COALESCE(s.estimated_minutes, 0) AS estimated_minutes,
COALESCE(used.used_minutes, 0) AS used_minutes,
used.latest_registration_at,
COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes
FROM sag_sager s JOIN customers c ON c.id=s.customer_id
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id LEFT JOIN planner_allocations a ON a.sag_id=s.id
LEFT JOIN groups department ON department.id=s.assigned_group_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(COALESCE(t.faktisk_tid_min,
ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))),0)::int AS used_minutes,
MAX(COALESCE(t.slut_tid,t.start_tid,t.worked_date::timestamp,t.created_at)) AS latest_registration_at
FROM tmodule_times t WHERE t.sag_id=s.id
) used ON TRUE
WHERE s.deleted_at IS NULL
AND NOT (LOWER(COALESCE(s.status,'')) = ANY(%s) AND COALESCE(used.used_minutes,0) > 0)"""
params: list = [list(CLOSED_STATUSES), list(CLOSED_STATUSES)]
if q:
sql += " AND (s.id::text ILIKE %s OR s.titel ILIKE %s OR c.name ILIKE %s OR COALESCE(u.full_name,u.username,'') ILIKE %s)"
params += [f"%{q}%"] * 4
if status: sql += " AND s.status = ANY(%s)"; params.append(status)
if priority: sql += " AND s.priority::text = ANY(%s)"; params.append(priority)
if case_type: sql += " AND s.type = ANY(%s)"; params.append(case_type)
if responsible_id: sql += " AND s.ansvarlig_bruger_id=%s"; params.append(responsible_id)
if department_id: sql += " AND s.assigned_group_id=%s"; params.append(department_id)
if mine: sql += " AND s.ansvarlig_bruger_id=%s"; params.append(current_user["id"])
if urgent: sql += " AND s.priority::text IN ('urgent','high')"
sql += " GROUP BY s.id,u.full_name,u.username,c.name,department.name,used.used_minutes,used.latest_registration_at"
if planning == "unplanned": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)=0"
elif planning == "partial": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)<COALESCE(s.estimated_minutes,0)"
elif planning == "planned": sql += " HAVING COALESCE(s.estimated_minutes,0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>=s.estimated_minutes"
order = {"priority": "CASE s.priority::text WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, s.updated_at DESC",
"remaining": "(COALESCE(s.estimated_minutes,0)-COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)) DESC",
"updated": "s.updated_at DESC"}[sort]
rows = execute_query(sql + f" ORDER BY {order} LIMIT %s", tuple(params + [limit])) or []
for row in rows: row["remaining_minutes"] = max(0, row["estimated_minutes"] - row["planned_minutes"])
return {"items": rows}
@router.get("/cases/{sag_id}/time-entries")
def case_time_entries(sag_id: int, current_user: dict = Depends(get_current_user)):
"""Show actual registered time separately from planned allocations."""
_require_view(current_user)
rows = execute_query(
"""SELECT t.id, t.worked_date, t.start_tid, t.slut_tid,
COALESCE(t.faktisk_tid_min, ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes,
t.description, t.entry_status, t.status,
COALESCE(u.full_name,u.username,t.user_name,'Ukendt') AS employee_name
FROM tmodule_times t LEFT JOIN users u ON u.user_id=t.medarbejder_id
WHERE t.sag_id=%s ORDER BY COALESCE(t.slut_tid,t.start_tid,t.worked_date::timestamp,t.created_at) DESC, t.id DESC""",
(sag_id,),
) or []
return {"sag_id": sag_id, "total_minutes": sum(int(r.get("minutes") or 0) for r in rows), "items": rows}
@router.patch("/cases/{sag_id}/estimate")
def update_case_estimate(sag_id: int, payload: CaseEstimateInput, current_user: dict = Depends(get_current_user)):
permissions = _permissions(current_user)
allowed = {"planner.edit_own", "planner.edit_team", "planner.admin", "cases.edit"}
if not (current_user.get("is_superadmin") or permissions.intersection(allowed)):
raise HTTPException(403, "Du må ikke ændre forventet tid")
old = execute_query_single("SELECT id,titel,estimated_minutes FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,))
if not old:
raise HTTPException(404, "Sagen findes ikke")
row = execute_query_single(
"UPDATE sag_sager SET estimated_minutes=%s,updated_at=NOW() WHERE id=%s RETURNING id,estimated_minutes",
(payload.estimated_minutes, sag_id),
)
execute_query(
"""INSERT INTO audit_log(user_id,username,action,resource_type,resource_id,resource_name,before_value,after_value)
VALUES(%s,%s,'planner.estimate.update','case',%s,%s,%s,%s)""",
(current_user["id"], current_user.get("username"), sag_id, old.get("titel"),
str(old.get("estimated_minutes")), str(payload.estimated_minutes)),
)
return row
@router.get("/period")
def period(starts_at: datetime, ends_at: datetime, user_id: list[int] = Query(default=[]), current_user: dict = Depends(get_current_user)):
_require_view(current_user)
if ends_at <= starts_at or ends_at - starts_at > timedelta(days=370): raise HTTPException(400, "Ugyldig periode")
ids = user_id or [current_user["id"]]
allocations = execute_query(
"""SELECT a.*, s.titel AS case_title, s.priority::text AS priority, c.name AS customer_name,
COALESCE(u.full_name,u.username) AS user_name
FROM planner_allocations a JOIN sag_sager s ON s.id=a.sag_id JOIN customers c ON c.id=s.customer_id
JOIN users u ON u.user_id=a.user_id WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s)
AND a.starts_at<%s AND a.ends_at>%s ORDER BY a.starts_at""", (ids, ends_at, starts_at)) or []
time_entries = execute_query(
"""SELECT t.id, t.sag_id, t.medarbejder_id AS user_id,
(t.start_tid IS NOT NULL) AS has_time,
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at) AS starts_at,
COALESCE(t.slut_tid,
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)
+ make_interval(mins => GREATEST(COALESCE(t.faktisk_tid_min,
ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int,15))) AS ends_at,
COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes,
t.description, s.titel AS case_title, c.name AS customer_name,
COALESCE(u.full_name,u.username,t.user_name,'Ukendt') AS user_name
FROM tmodule_times t JOIN sag_sager s ON s.id=t.sag_id
JOIN customers c ON c.id=s.customer_id LEFT JOIN users u ON u.user_id=t.medarbejder_id
WHERE t.medarbejder_id=ANY(%s)
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)>=%s
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)<%s
AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)
ORDER BY starts_at""",
(ids, starts_at, ends_at, list(CLOSED_STATUSES)),
) or []
return {"allocations": allocations, "capacity": _capacity(ids, _aware(starts_at), _aware(ends_at), allocations),
"time_entries": time_entries,
"external_busy": busy_time_provider.get_busy(ids, starts_at, ends_at),
"integration_statuses": busy_time_provider.statuses(ids)}
@router.post("/allocations", status_code=201)
def create_allocation(payload: AllocationInput, current_user: dict = Depends(get_current_user)):
if not _can_edit(current_user, payload.user_id): raise HTTPException(403, "Du må ikke redigere denne medarbejders plan")
case = execute_query_single("SELECT id,status,deleted_at FROM sag_sager WHERE id=%s", (payload.sag_id,))
if not case or case["deleted_at"] or str(case["status"]).lower() in CLOSED_STATUSES: raise HTTPException(409, "Lukkede eller arkiverede sager skal genåbnes før planlægning")
conflict, details = _conflicts(payload.user_id, payload.starts_at, payload.ends_at)
if conflict != "none" and not payload.confirm_conflicts: raise HTTPException(409, detail={"message": "Tiden overlapper eller overbooker", "conflict_status": conflict, **details})
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""INSERT INTO planner_allocations(sag_id,user_id,starts_at,ends_at,note,source,conflict_status,locked,created_by,updated_by)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""",
(payload.sag_id,payload.user_id,payload.starts_at,payload.ends_at,payload.note,payload.source,conflict,payload.locked,current_user["id"],current_user["id"]))
row=cur.fetchone(); cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,after_value) VALUES(%s,%s,'created',%s,%s)", (row["id"],row["sag_id"],current_user["id"],Json(_serialize(row))))
conn.commit(); return row
except Exception: conn.rollback(); raise
finally: release_db_connection(conn)
@router.patch("/allocations/{allocation_id}")
def update_allocation(allocation_id: int, payload: AllocationUpdate, current_user: dict = Depends(get_current_user)):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL FOR UPDATE",(allocation_id,)); old=cur.fetchone()
if not old: raise HTTPException(404,"Allokeringen findes ikke")
target=payload.user_id or old["user_id"]
if not _can_edit(current_user,target): raise HTTPException(403,"Du må ikke redigere denne medarbejders plan")
if old["version"] != payload.version: raise HTTPException(409,detail={"message":"Allokeringen er ændret af en anden bruger","current":_serialize(old)})
moving = bool({"user_id", "starts_at", "ends_at"}.intersection(payload.model_fields_set))
if old.get("locked") and moving and payload.locked is not False:
raise HTTPException(409, "Tidspunktet er låst. Fjern låsen før blokken flyttes eller ændres")
start=payload.starts_at or old["starts_at"]; end=payload.ends_at or old["ends_at"]
if end<=start: raise HTTPException(422,"Sluttid skal ligge efter starttid")
conflict,details=_conflicts(target,start,end,allocation_id)
if conflict!="none" and not payload.confirm_conflicts: raise HTTPException(409,detail={"message":"Tiden overlapper eller overbooker","conflict_status":conflict,**details})
cur.execute("""UPDATE planner_allocations SET user_id=%s,starts_at=%s,ends_at=%s,note=%s,conflict_status=%s,locked=%s,
version=version+1,updated_by=%s,updated_at=NOW() WHERE id=%s AND version=%s RETURNING *""",
(target,start,end,payload.note if "note" in payload.model_fields_set else old["note"],conflict,payload.locked if payload.locked is not None else old.get("locked",False),current_user["id"],allocation_id,payload.version))
row=cur.fetchone()
if not row: raise HTTPException(409,"Allokeringen blev ændret samtidigt")
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'updated',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(old)),Json(_serialize(row))))
conn.commit(); return row
except HTTPException: conn.rollback(); raise
except Exception: conn.rollback(); raise
finally: release_db_connection(conn)
@router.delete("/allocations/{allocation_id}")
def delete_allocation(allocation_id:int, version:int, current_user:dict=Depends(get_current_user)):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL FOR UPDATE",(allocation_id,)); old=cur.fetchone()
if not old: raise HTTPException(404,"Allokeringen findes ikke")
if not _can_edit(current_user,old["user_id"]): raise HTTPException(403,"Du må ikke slette denne allokering")
if old["version"]!=version: raise HTTPException(409,"Allokeringen er ændret af en anden bruger")
if old.get("locked"): raise HTTPException(409,"Tidspunktet er låst. Fjern låsen før blokken slettes")
cur.execute("UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE id=%s AND version=%s RETURNING *",(current_user["id"],allocation_id,version)); row=cur.fetchone()
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'deleted',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(old)),Json(_serialize(row))))
conn.commit(); return {"deleted":True,"allocation":row}
except HTTPException: conn.rollback(); raise
except Exception: conn.rollback(); raise
finally: release_db_connection(conn)
@router.post("/allocations/{allocation_id}/undo")
def undo_allocation(allocation_id: int, version: int, current_user: dict = Depends(get_current_user)):
"""Undo the latest persisted allocation mutation, guarded by its current version."""
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM planner_allocations WHERE id=%s FOR UPDATE", (allocation_id,))
current = cur.fetchone()
if not current: raise HTTPException(404, "Allokeringen findes ikke")
if not _can_edit(current_user, current["user_id"]): raise HTTPException(403, "Du må ikke fortryde denne ændring")
if current["version"] != version: raise HTTPException(409, "Kan ikke fortryde: allokeringen er ændret siden")
cur.execute("SELECT * FROM planner_allocation_history WHERE allocation_id=%s ORDER BY id DESC LIMIT 1", (allocation_id,))
history = cur.fetchone()
if not history: raise HTTPException(409, "Der er ingen ændring at fortryde")
before = history.get("before_value")
if history["action"] == "created":
cur.execute("UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s,updated_at=NOW() WHERE id=%s RETURNING *", (current_user["id"],allocation_id))
elif before:
cur.execute("""UPDATE planner_allocations SET user_id=%s,starts_at=%s,ends_at=%s,note=%s,
conflict_status=%s,locked=%s,deleted_at=%s,version=version+1,updated_by=%s,updated_at=NOW()
WHERE id=%s RETURNING *""",
(before["user_id"],before["starts_at"],before["ends_at"],before.get("note"),before.get("conflict_status","none"),before.get("locked",False),before.get("deleted_at"),current_user["id"],allocation_id))
else: raise HTTPException(409, "Ændringen kan ikke fortrydes")
row=cur.fetchone()
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'undo',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(current)),Json(_serialize(row))))
conn.commit(); return row
except HTTPException: conn.rollback(); raise
except Exception: conn.rollback(); raise
finally: release_db_connection(conn)
@router.post("/allocations/{allocation_id}/copy", status_code=201)
def copy_allocation(allocation_id:int, starts_at:datetime,user_id:int|None=None,confirm_conflicts:bool=False,current_user:dict=Depends(get_current_user)):
old=execute_query_single("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL",(allocation_id,))
if not old: raise HTTPException(404,"Allokeringen findes ikke")
duration=old["ends_at"]-old["starts_at"]
return create_allocation(AllocationInput(sag_id=old["sag_id"],user_id=user_id or old["user_id"],starts_at=starts_at,ends_at=starts_at+duration,note=old["note"],source="copy",confirm_conflicts=confirm_conflicts),current_user)
@router.get("/cases/{sag_id}/history")
def case_history(sag_id:int,current_user:dict=Depends(get_current_user)):
_require_view(current_user)
return {"items":execute_query("SELECT * FROM planner_allocation_history WHERE sag_id=%s ORDER BY created_at DESC",(sag_id,)) or []}

View File

@ -0,0 +1 @@
"""Planner frontend package."""

View File

@ -0,0 +1,11 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.core.auth_dependencies import get_current_user
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/planner", response_class=HTMLResponse)
async def planner(request: Request, current_user: dict = Depends(get_current_user)):
return templates.TemplateResponse("modules/planner/templates/index.html", {"request": request, "current_user": current_user})

File diff suppressed because one or more lines are too long

View File

@ -18,6 +18,7 @@ from app.core.auth_dependencies import require_permission
from app.core.config import settings from app.core.config import settings
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single
from app.services.sms_service import SmsService from app.services.sms_service import SmsService
from app.services.system_event_logger import log_system_event
from .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest from .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest
from .service import TelefoniService from .service import TelefoniService
@ -380,6 +381,13 @@ async def yealink_established(
raw_payload=json.dumps(payload), raw_payload=json.dumps(payload),
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
) )
log_system_event(
source="voip", event_type="voip.established", status="success",
summary=f"{direction} opkald etableret: {ekstern_value or 'ukendt nummer'}",
correlation_id=resolved_callid, user_id=primary_user_id,
details={"call_row_id": row.get("id"), "extension": local_extension,
"external_number": ekstern_value, "client_ip": payload["client_ip"], "payload": payload},
)
# Send websocket notification to ALL users with this extension # Send websocket notification to ALL users with this extension
if user_ids: if user_ids:
@ -440,10 +448,19 @@ async def yealink_terminated(
if duration_value is None: if duration_value is None:
logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid) logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid)
existed = execute_query_single("SELECT id,bruger_id FROM telefoni_opkald WHERE callid=%s", (resolved_callid,))
updated = TelefoniService.terminate_call(resolved_callid, duration_value) updated = TelefoniService.terminate_call(resolved_callid, duration_value)
if not updated: if not updated:
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid) logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
time_entry_id = _register_completed_call_time(resolved_callid) if updated else None time_entry_id = _register_completed_call_time(resolved_callid) if updated else None
log_system_event(
source="voip", event_type="voip.terminated" if existed else "voip.orphan_hangup",
status="success" if existed else "warning",
summary="Opkald afsluttet" if existed else "Hangup modtaget uden etablerings-event",
correlation_id=resolved_callid, user_id=(existed or {}).get("bruger_id"),
details={"duration_raw": duration_raw, "duration_sec": duration_value, "time_entry_id": time_entry_id,
"client_ip": _get_client_ip(request)},
)
return {"status": "ok", "time_entry_id": time_entry_id} return {"status": "ok", "time_entry_id": time_entry_id}
@ -843,6 +860,7 @@ async def list_calls(
limit: int = Query(200, ge=1, le=2000), limit: int = Query(200, ge=1, le=2000),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
): ):
TelefoniService.reconcile_stale_calls()
where = [] where = []
params = [] params = []

View File

@ -9,6 +9,24 @@ logger = logging.getLogger(__name__)
class TelefoniService: class TelefoniService:
@staticmethod
def reconcile_stale_calls(max_age_hours: int = 12) -> int:
"""Close the lifecycle state for calls whose phone never sent hangup."""
rows = execute_query(
"""WITH stale AS (
UPDATE telefoni_opkald SET lifecycle_status='missing_hangup',last_event_at=NOW()
WHERE ended_at IS NULL AND lifecycle_status='active'
AND started_at < NOW()-(%s * INTERVAL '1 hour')
RETURNING id,callid,bruger_id,intern_extension,ekstern_nummer,started_at
), logged AS (
INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
SELECT 'voip','voip.stale_call','warning','Opkald mangler hangup-event',callid,bruger_id,
jsonb_build_object('call_row_id',id,'extension',intern_extension,'external_number',ekstern_nummer),NOW()
FROM stale RETURNING id
) SELECT COUNT(*)::int AS count FROM stale""", (max_age_hours,),
) or []
return int(rows[0].get("count") or 0) if rows else 0
@staticmethod @staticmethod
def find_user_by_extension(extension: Optional[str]) -> list[int]: def find_user_by_extension(extension: Optional[str]) -> list[int]:
"""Find all users with the given extension - returns list of user_ids.""" """Find all users with the given extension - returns list of user_ids."""
@ -183,6 +201,7 @@ class TelefoniService:
bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id), bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id),
kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id), kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id),
started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at) started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at)
, lifecycle_status = 'active', last_event_at = NOW()
RETURNING * RETURNING *
""" """
rows = execute_query( rows = execute_query(
@ -208,9 +227,9 @@ class TelefoniService:
rows = execute_query( rows = execute_query(
""" """
INSERT INTO telefoni_opkald INSERT INTO telefoni_opkald
(callid, direction, started_at, ended_at, duration_sec, raw_payload) (callid, direction, started_at, ended_at, duration_sec, raw_payload, lifecycle_status, termination_source, last_event_at)
VALUES VALUES
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb) (%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb, 'orphan_terminated', 'callback_without_start', NOW())
ON CONFLICT (callid) ON CONFLICT (callid)
DO UPDATE SET DO UPDATE SET
ended_at = COALESCE(telefoni_opkald.ended_at, NOW()), ended_at = COALESCE(telefoni_opkald.ended_at, NOW()),
@ -222,7 +241,7 @@ class TelefoniService:
THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int
ELSE NULL ELSE NULL
END END
) ), lifecycle_status = 'completed', termination_source = 'callback', last_event_at = NOW()
RETURNING id RETURNING id
""", """,
(callid, duration_sec), (callid, duration_sec),

View File

@ -34,6 +34,7 @@ from aiohttp import ClientSession, BasicAuth
import msal import msal
from app.core.config import settings from app.core.config import settings
from app.services.system_event_logger import log_system_event
from app.core.database import execute_query, execute_insert, execute_update from app.core.database import execute_query, execute_insert, execute_update
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1765,6 +1766,11 @@ class EmailService:
) )
if graph_ok: if graph_ok:
logger.info("✅ Email sent via Graph to %s recipient(s): %s", len(to_addresses), subject) logger.info("✅ Email sent via Graph to %s recipient(s): %s", len(to_addresses), subject)
log_system_event(
source="email", event_type="email.sent", status="success",
summary=subject or "E-mail sendt",
details={"provider": "graph", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or [])},
)
return True, graph_message return True, graph_message
graph_failure_message = graph_message graph_failure_message = graph_message
logger.warning("⚠️ Graph send failed, falling back to SMTP: %s", graph_message) logger.warning("⚠️ Graph send failed, falling back to SMTP: %s", graph_message)
@ -1825,11 +1831,21 @@ class EmailService:
) )
logger.info(f"✅ Email sent successfully to {len(to_addresses)} recipient(s): {subject}") logger.info(f"✅ Email sent successfully to {len(to_addresses)} recipient(s): {subject}")
log_system_event(
source="email", event_type="email.sent", status="success",
summary=subject or "E-mail sendt",
details={"provider": "smtp", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or [])},
)
return True, f"Email sent to {len(to_addresses)} recipient(s)" return True, f"Email sent to {len(to_addresses)} recipient(s)"
except Exception as e: except Exception as e:
error_msg = f"❌ SMTP send error: {str(e)}" error_msg = f"❌ SMTP send error: {str(e)}"
logger.error(error_msg) logger.error(error_msg)
log_system_event(
source="email", event_type="email.send_failed", status="error",
summary=subject or "E-mail kunne ikke sendes",
details={"provider": "smtp", "to": to_addresses, "error": str(e)},
)
if graph_failure_message: if graph_failure_message:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}" return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}"
return False, error_msg return False, error_msg
@ -1891,6 +1907,11 @@ class EmailService:
subject, subject,
provider_thread_key, provider_thread_key,
) )
log_system_event(
source="email", event_type="email.sent", status="success",
summary=subject or "E-mail sendt", correlation_id=generated_message_id,
details={"provider": "graph", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or []), "attachments": len(attachments or [])},
)
return True, graph_message, generated_message_id, provider_thread_key return True, graph_message, generated_message_id, provider_thread_key
graph_failure_message = graph_message graph_failure_message = graph_message
logger.warning("⚠️ Graph send with attachments failed, falling back to SMTP: %s", graph_message) logger.warning("⚠️ Graph send with attachments failed, falling back to SMTP: %s", graph_message)
@ -1970,11 +1991,21 @@ class EmailService:
len(to_addresses), len(to_addresses),
subject, subject,
) )
log_system_event(
source="email", event_type="email.sent", status="success",
summary=subject or "E-mail sendt", correlation_id=generated_message_id,
details={"provider": "smtp", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or []), "attachments": len(attachments or [])},
)
return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id, provider_thread_key return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id, provider_thread_key
except Exception as e: except Exception as e:
error_msg = f"❌ SMTP send error (attachments): {str(e)}" error_msg = f"❌ SMTP send error (attachments): {str(e)}"
logger.error(error_msg) logger.error(error_msg)
log_system_event(
source="email", event_type="email.send_failed", status="error",
summary=subject or "E-mail kunne ikke sendes", correlation_id=generated_message_id,
details={"provider": "smtp", "to": to_addresses, "attachments": len(attachments or []), "error": str(e)},
)
if graph_failure_message: if graph_failure_message:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}", generated_message_id, provider_thread_key return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}", generated_message_id, provider_thread_key
return False, error_msg, generated_message_id, provider_thread_key return False, error_msg, generated_message_id, provider_thread_key

View File

@ -413,6 +413,7 @@ REGLER FOR title:
"model": self.model, "model": self.model,
"prompt": prompt, "prompt": prompt,
"stream": False, "stream": False,
"format": "json",
"options": { "options": {
"temperature": 0.1, "temperature": 0.1,
"top_p": 0.9, "top_p": 0.9,

View File

@ -0,0 +1,22 @@
import json
import logging
from datetime import datetime
from typing import Any
from app.core.database import execute_query
logger = logging.getLogger(__name__)
def log_system_event(*, source: str, event_type: str, summary: str, status: str = "info",
correlation_id: str | None = None, user_id: int | None = None,
details: dict[str, Any] | None = None, occurred_at: datetime | None = None) -> None:
"""Best-effort operational event logging; logging must never break the source flow."""
try:
execute_query(
"""INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
VALUES(%s,%s,%s,%s,%s,%s,%s::jsonb,COALESCE(%s,NOW()))""",
(source,event_type,status,summary,correlation_id,user_id,json.dumps(details or {},default=str),occurred_at),
)
except Exception as exc:
logger.warning("Could not persist system event %s: %s", event_type, exc)

View File

@ -2,13 +2,13 @@
Settings and User Management API Router Settings and User Management API Router
""" """
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query
from typing import List, Optional, Dict from typing import List, Optional, Dict
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime from datetime import datetime, timezone
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single
from app.core.config import settings from app.core.config import settings
from app.core.auth_dependencies import require_superadmin from app.core.auth_dependencies import require_any_permission, require_superadmin
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
import argparse import argparse
import asyncio import asyncio
@ -1288,3 +1288,76 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}") logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
err = str(e) or e.__class__.__name__ err = str(e) or e.__class__.__name__
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}") raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
system_event_log_access = require_any_permission("system.event_log.view", "users.manage", "system.admin")
def _event_sort_value(event: Dict) -> float:
"""Return one comparable timestamp for naive and timezone-aware DB values."""
value = event.get("occurred_at")
if not value:
return 0.0
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
except (TypeError, ValueError):
return 0.0
@router.get("/settings/system-events/list", dependencies=[Depends(system_event_log_access)], tags=["Settings"])
async def list_system_events(
source: Optional[str] = Query(None), event_type: Optional[str] = Query(None),
status: Optional[str] = Query(None), q: Optional[str] = Query(None),
date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None),
user_id: Optional[int] = Query(None), limit: int = Query(250, ge=1, le=2000),
):
"""Unified operational log over persisted system, VoIP and email events."""
clauses, params = [], []
if source: clauses.append("l.source=%s"); params.append(source)
if event_type: clauses.append("l.event_type=%s"); params.append(event_type)
if status: clauses.append("l.status=%s"); params.append(status)
if user_id: clauses.append("l.user_id=%s"); params.append(user_id)
if date_from: clauses.append("l.occurred_at>=%s"); params.append(date_from)
if date_to: clauses.append("l.occurred_at<=%s"); params.append(date_to)
if q:
clauses.append("(l.summary ILIKE %s OR l.correlation_id ILIKE %s OR l.details::text ILIKE %s)")
params.extend([f"%{q}%"] * 3)
where = "WHERE " + " AND ".join(clauses) if clauses else ""
events = execute_query(
f"""SELECT l.id,l.source,l.event_type,l.status,l.summary,l.correlation_id,l.user_id,
l.details,l.occurred_at,COALESCE(u.full_name,u.username) AS user_name
FROM system_event_log l LEFT JOIN users u ON u.user_id=l.user_id {where}
ORDER BY l.occurred_at DESC LIMIT %s""", tuple(params + [limit]),
) or []
# Existing mail records remain searchable even before producers adopt system_event_log.
if source in (None, "email"):
email_clauses, email_params = [], []
if date_from: email_clauses.append("em.received_date>=%s"); email_params.append(date_from)
if date_to: email_clauses.append("em.received_date<=%s"); email_params.append(date_to)
if q:
email_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.recipient_email ILIKE %s)")
email_params.extend([f"%{q}%"] * 3)
email_where = "WHERE " + " AND ".join(email_clauses) if email_clauses else ""
emails = execute_query(
f"""SELECT em.id,'email' AS source,
CASE WHEN LOWER(COALESCE(em.folder,'')) LIKE '%%sent%%' THEN 'email.sent' ELSE 'email.received' END AS event_type,
'success'::text AS status,COALESCE(em.subject,'(intet emne)') AS summary,
em.message_id AS correlation_id,NULL::integer AS user_id,
jsonb_build_object('from',em.sender_email,'to',em.recipient_email,'folder',em.folder) AS details,
em.received_date AS occurred_at,NULL::text AS user_name
FROM email_messages em {email_where} ORDER BY em.received_date DESC LIMIT %s""",
tuple(email_params + [limit]),
) or []
if event_type: emails = [x for x in emails if x["event_type"] == event_type]
if status: emails = [x for x in emails if x["status"] == status]
events.extend(emails)
events.sort(key=_event_sort_value, reverse=True)
return {"items": events[:limit], "sources": ["voip", "email", "system"], "count": len(events[:limit])}

View File

@ -18,6 +18,7 @@ from app.core.auth_dependencies import require_any_permission
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
sql_console_access = require_any_permission("users.manage", "system.admin") sql_console_access = require_any_permission("users.manage", "system.admin")
system_event_log_access = require_any_permission("system.event_log.view", "users.manage", "system.admin")
CREATE_TABLE_RE = re.compile( CREATE_TABLE_RE = re.compile(
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(", r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(",
@ -265,6 +266,11 @@ async def settings_page(request: Request):
}) })
@router.get("/settings/event-log", response_class=HTMLResponse, tags=["Frontend"], dependencies=[Depends(system_event_log_access)])
async def system_event_log_page(request: Request):
return templates.TemplateResponse("settings/frontend/event_log.html", {"request": request, "title": "Systemlog"})
@router.get("/settings/migrations", response_class=HTMLResponse, tags=["Frontend"]) @router.get("/settings/migrations", response_class=HTMLResponse, tags=["Frontend"])
async def migrations_page(request: Request): async def migrations_page(request: Request):
"""Render database migrations page""" """Render database migrations page"""

View File

@ -0,0 +1,20 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Systemlog · BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="mb-1">Systemlog</h2><p class="text-muted mb-0">VoIP, e-mail og øvrige integrationshændelser</p></div><a href="/settings" class="btn btn-outline-secondary"><i class="bi bi-arrow-left"></i> Indstillinger</a></div>
<div class="card p-3 mb-3"><div class="row g-2">
<div class="col-md-2"><label class="form-label">Kilde</label><select id="eventSource" class="form-select"><option value="">Alle</option><option value="voip">VoIP</option><option value="email">E-mail</option><option value="system">System</option></select></div>
<div class="col-md-2"><label class="form-label">Hændelse</label><select id="eventType" class="form-select"><option value="">Alle</option><option value="voip.established">VoIP etableret</option><option value="voip.terminated">VoIP hangup</option><option value="voip.orphan_hangup">Hangup uden start</option><option value="voip.stale_call">Mangler hangup</option><option value="email.received">E-mail modtaget</option><option value="email.sent">E-mail sendt</option><option value="email.send_failed">E-mail sendefejl</option></select></div>
<div class="col-md-2"><label class="form-label">Status</label><select id="eventStatus" class="form-select"><option value="">Alle</option><option value="success">OK</option><option value="warning">Advarsel</option><option value="error">Fejl</option></select></div>
<div class="col-md-2"><label class="form-label">Fra</label><input id="eventFrom" type="datetime-local" class="form-control"></div><div class="col-md-2"><label class="form-label">Til</label><input id="eventTo" type="datetime-local" class="form-control"></div>
<div class="col-md-2"><label class="form-label">Søg</label><input id="eventQuery" class="form-control" placeholder="Nummer, call-id, emne…"></div>
</div></div>
<div class="card overflow-hidden"><div class="table-responsive"><table class="table table-hover mb-0"><thead><tr><th>Tid</th><th>Kilde</th><th>Hændelse</th><th>Status</th><th>Beskrivelse</th><th>Bruger</th><th>Korrelations-id</th></tr></thead><tbody id="eventRows"><tr><td colspan="7" class="text-center p-4">Henter log…</td></tr></tbody></table></div></div>
</div>
{% endblock %}
{% block extra_js %}<script>
const escapeEvent=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function loadSystemEvents(){const p=new URLSearchParams();const values={source:eventSource.value,event_type:eventType.value,status:eventStatus.value,q:eventQuery.value};Object.entries(values).forEach(([k,v])=>v&&p.set(k,v));if(eventFrom.value)p.set('date_from',new Date(eventFrom.value).toISOString());if(eventTo.value)p.set('date_to',new Date(eventTo.value).toISOString());const r=await fetch('/api/v1/settings/system-events/list?'+p,{credentials:'include'}),b=await r.json();if(!r.ok)throw new Error(b.detail||'Kunne ikke hente log');eventRows.innerHTML=b.items.length?b.items.map(x=>`<tr><td class="text-nowrap">${new Date(x.occurred_at).toLocaleString('da-DK')}</td><td><span class="badge text-bg-secondary">${escapeEvent(x.source)}</span></td><td>${escapeEvent(x.event_type)}</td><td><span class="badge ${x.status==='error'?'text-bg-danger':x.status==='warning'?'text-bg-warning':'text-bg-success'}">${escapeEvent(x.status)}</span></td><td>${escapeEvent(x.summary)}</td><td>${escapeEvent(x.user_name||'')}</td><td><code>${escapeEvent(x.correlation_id||'')}</code></td></tr>`).join(''):'<tr><td colspan="7" class="text-center p-4 text-muted">Ingen hændelser matcher filtrene</td></tr>'}
let eventTimer;document.querySelectorAll('#eventSource,#eventType,#eventStatus,#eventFrom,#eventTo').forEach(x=>x.addEventListener('change',loadSystemEvents));eventQuery.addEventListener('input',()=>{clearTimeout(eventTimer);eventTimer=setTimeout(loadSystemEvents,300)});loadSystemEvents().catch(e=>eventRows.innerHTML=`<tr><td colspan="7" class="text-danger p-4">${escapeEvent(e.message)}</td></tr>`);
</script>{% endblock %}

View File

@ -208,6 +208,9 @@
<a class="nav-link" href="#system" data-tab="system"> <a class="nav-link" href="#system" data-tab="system">
<i class="bi bi-gear me-2"></i>System <i class="bi bi-gear me-2"></i>System
</a> </a>
<a class="nav-link" href="/settings/event-log">
<i class="bi bi-journal-text me-2"></i>Systemlog
</a>
<a class="nav-link" href="/settings/migrations"> <a class="nav-link" href="/settings/migrations">
<i class="bi bi-database me-2"></i>DB Migrationer <i class="bi bi-database me-2"></i>DB Migrationer
</a> </a>
@ -6796,6 +6799,13 @@ const MENU_VISIBILITY_GROUPS = [
{ key: 'menu-datamigration', label: 'Data migration' }, { key: 'menu-datamigration', label: 'Data migration' },
], ],
}, },
{
title: 'Kalender underpunkter',
items: [
{ key: 'menu-kalender-overview', label: 'Kalenderoversigt' },
{ key: 'menu-planner', label: 'Planlægger' },
],
},
{ {
title: 'CRM underpunkter', title: 'CRM underpunkter',
items: [ items: [

View File

@ -1486,10 +1486,14 @@
<i class="bi bi-list-check me-2"></i>Sager <i class="bi bi-list-check me-2"></i>Sager
</a> </a>
</li> </li>
<li class="nav-item" data-menu-key="menu-kalender"> <li class="nav-item dropdown" data-menu-key="menu-kalender">
<a class="nav-link" href="/calendar"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-calendar3 me-2"></i>Kalender <i class="bi bi-calendar3 me-2"></i>Kalender
</a> </a>
<ul class="dropdown-menu mt-2">
<li data-menu-key="menu-kalender-overview"><a class="dropdown-item py-2" href="/calendar"><i class="bi bi-calendar3 me-2"></i>Kalenderoversigt</a></li>
<li data-menu-key="menu-planner"><a class="dropdown-item py-2" href="/planner"><i class="bi bi-calendar2-week me-2"></i>Planlægger</a></li>
</ul>
</li> </li>
<li class="nav-item dropdown" data-menu-key="menu-support"> <li class="nav-item dropdown" data-menu-key="menu-support">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
@ -3080,6 +3084,8 @@ if (bmcOriginalFetch) {
{ key: 'menu-crm', label: 'CRM' }, { key: 'menu-crm', label: 'CRM' },
{ key: 'menu-sager', label: 'Sager' }, { key: 'menu-sager', label: 'Sager' },
{ key: 'menu-kalender', label: 'Kalender' }, { key: 'menu-kalender', label: 'Kalender' },
{ key: 'menu-kalender-overview', label: 'Kalender: Kalenderoversigt' },
{ key: 'menu-planner', label: 'Planlægger' },
{ key: 'menu-support', label: 'Support' }, { key: 'menu-support', label: 'Support' },
{ key: 'menu-salg', label: 'Salg' }, { key: 'menu-salg', label: 'Salg' },
{ key: 'menu-okonomi', label: 'Økonomi' }, { key: 'menu-okonomi', label: 'Økonomi' },

View File

@ -0,0 +1,27 @@
# Fremtidige planer
Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men endnu ikke implementeret.
## Aktive planer
- [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager.
## Arbejdsgang
- Nye fremtidige planer oprettes som selvstændige Markdown-filer i denne mappe.
- Planens status skal stå øverst i filen.
- En plan skal beskrive formål, afgrænsning, løsningsforslag, test og leveringsrækkefølge.
- Når udviklingen starter, opdateres status til `Under udvikling`.
- Når løsningen er leveret, flyttes planen til undermappen `arkiv` eller markeres tydeligt som implementeret.
## Ældre plandokumenter uden for denne mappe
Følgende ældre planer blev fundet i projektet, men deres tilhørende funktioner ser allerede ud til at være implementeret. De er derfor ikke registreret som aktive fremtidsplaner:
- `MDfile/NEXTCLOUD_MODULE_PLAN.md`
- `MDfile/SALES_AND_AGGREGATION_PLAN.md`
- `docs/ORDRE_SYSTEM_IMPLEMENTATION.md`
- `plan-websiteContentAdministration.prompt.md`
- `static/js/## Plan: Manualmodul MVP i BMC Hub.prompt.md`
De bør gennemgås og eventuelt arkiveres separat, men ikke blandes sammen med endnu ikke implementerede planer.

View File

@ -0,0 +1,201 @@
# Kundestemning på indgående sager og mails
## Status
Fremtidig udviklingsplan. Ikke implementeret.
## Formål
BMC Hub skal bruge AI til semantisk analyse af kundens stemning i indgående mails og sager. Løsningen skal give medarbejderen et rådgivende signal om stemning, hast, kunderisiko og udvikling gennem en mailtråd.
Analysen må ikke baseres på simpel matching af bestemte ord, og den må i første version ikke automatisk ændre prioritet, SLA eller ansvarlig medarbejder.
## Analysemodel
AI-resultatet gemmes pr. indgående mail i den eksisterende `email_analysis`-tabel med `analysis_type = 'customer_sentiment'`.
Foreslået JSON-resultat:
```json
{
"sentiment": "frustrated",
"sentiment_score": -0.65,
"urgency": "high",
"customer_risk": "medium",
"requires_attention": true,
"confidence": 0.88,
"reason": "Kunden har rykket flere gange og oplever fortsat driftsstop.",
"signals": [
"gentagen henvendelse",
"driftspåvirkning",
"manglende løsning"
],
"text_scope": "latest_customer_message",
"model": "qwen3.5:9b",
"prompt_version": 1
}
```
Kontrollerede værdier:
- `sentiment`: `positive`, `neutral`, `concerned`, `frustrated`, `angry`
- `urgency`: `normal`, `high`, `critical`
- `customer_risk`: `low`, `medium`, `high`
- `sentiment_score`: tal fra `-1.0` til `1.0`
## Tekstforberedelse
Før teksten sendes til AI, skal systemet så vidt muligt fjerne:
- Medarbejdernes tidligere svar
- Citerede mails
- Standardsignaturer
- Fortrolighedstekster og disclaimere
- Autosvar
- HTML, tracking-links og anden teknisk støj
AI'en skal som udgangspunkt analysere kundens seneste indgående tekst. Emnefeltet kan medtages som kontekst. Tekstrensningen må gerne være regelbaseret, men selve vurderingen skal foretages semantisk af AI.
## Promptkrav
Prompten skal holde følgende dimensioner adskilt:
1. Kundens følelsesmæssige stemning
2. Henvendelsens praktiske hast
3. Kommerciel risiko eller risiko for eskalation
Eksempel: En kunde kan være venlig eller neutral, selvom et teknisk problem er kritisk.
Modellen skal instrueres i at:
- Forstå indirekte og høfligt formuleret utilfredshed
- Undgå at overfortolke almindelig høflighed
- Skelne kundens nye tekst fra citeret historik
- Undgå vurderinger eller diagnoser af personen
- Give en kort begrundelse, der er understøttet af teksten
- Bruge lav `confidence`, når teksten er tvetydig
- Returnere ren og validerbar JSON
## Afvikling
Første version skal:
- Køre asynkront ved modtagelse af en ekstern mail
- Kun analysere indgående kundekommunikation
- Springe spam, nyhedsbreve, fakturaer og automatiske systemmails over
- Genbruge den eksisterende mailklassifikation til frasortering
- Cache resultatet, så samme mail ikke analyseres flere gange
- Understøtte manuel genanalyse
- Lade mailimporten fortsætte, hvis AI er utilgængelig
## Samlet stemning på en sag
Sagens aktuelle stemning bør bygge på den seneste relevante indgående kundemail og ikke den værste historiske vurdering.
Sagen kan vise:
- Aktuel stemning fra seneste indgående mail
- Trend baseret på de seneste 2-3 indgående mails
- Aktuel kunderisiko ved tilstrækkelig høj sikkerhed
- `forværret`, hvis scoren falder mærkbart gennem tråden
- `forbedret`, hvis scoren stiger efter dialog med kunden
## GUI
### Mailoversigt
Vis en diskret badge:
- Positiv
- Neutral
- Bekymret
- Frustreret
- Vred/kritisk
Ved klik eller hover vises hast, kunderisiko, AI-begrundelse, sikkerhed, model og analysetidspunkt.
### Sagsvisning
Vis et mindre panel med:
- Aktuel kundestemning
- Trend
- Seneste begrundelse
- Link til den mail, vurderingen kommer fra
### Senere dashboardfunktioner
- Filteret `Kræver opmærksomhed`
- Filter efter stemning, hast og kunderisiko
- Oversigt over forværret kundestemning
- Visning af kritiske henvendelser
## Manuel korrektion
Medarbejderen skal kunne:
- Markere vurderingen som korrekt eller forkert
- Vælge en anden stemning
- Tilføje en kort kommentar
Den manuelle vurdering skal gemmes separat fra det oprindelige AI-resultat. Det giver sporbarhed og et evalueringsdatasæt til senere forbedring af prompt og model.
## Automatik og sikkerhed
I første version er `requires_attention` kun et visuelt signal.
Mulige senere funktioner:
- Intern notifikation ved høj risiko
- Fremhævelse på dashboardet
- Besked til ansvarlig medarbejder
- Eskalation af kritiske beskeder uden for normal arbejdstid
Systemet må ikke automatisk sende svar til kunden eller ændre sagsprioritet uden menneskelig godkendelse.
## Test og evaluering
Opret et anonymiseret testsæt på cirka 50-100 realistiske mails, herunder:
- Positive og neutrale mails
- Direkte og indirekte utilfredshed
- Vrede mails
- Kritisk drift uden vred tone
- Ironi og tvetydige formuleringer
- Meget korte beskeder
- Lange tråde med citeret historik
- Autosvar og systemmails
- Danske og engelske henvendelser
Mål især:
- Hvor ofte kritiske mails overses
- Hvor ofte neutrale mails fejlmarkeres
- Om hast og stemning adskilles korrekt
- Om begrundelsen kan dokumenteres ud fra teksten
- Svartid og stabilitet
- Forskelle mellem de installerede modeller
## Leveringsrækkefølge
1. AI-analyse og lagring pr. indgående mail
2. Admin-testside med råt AI-resultat
3. Evaluering mod menneskeligt mærkede testmails
4. Badge i mailoversigten
5. Detaljepanel på mails og sager
6. Manuel korrektion og feedback
7. Trendberegning på mailtråde
8. Dashboard og filtre
9. Eventuelle notifikationer
## Anbefalet lancering
Start med en stille pilot, hvor nye mails analyseres og gemmes, men resultaterne kun vises på en admin-testside. Aktivér først badges og alarmer bredt, når AI-vurderingerne er sammenlignet med menneskelige vurderinger.
## Beslutninger før implementering
- Vælg den endelige AI-model ud fra modeltesten
- Fastlæg grænser for `requires_attention`
- Beslut hvem der kan se og korrigere vurderinger
- Beslut hvor længe analyser og manuelle rettelser skal gemmes
- Aftal om engelsk og andre sprog skal understøttes fra første version

View File

@ -133,6 +133,8 @@ from app.modules.telefoni.backend import router as telefoni_api
from app.modules.telefoni.frontend import views as telefoni_views from app.modules.telefoni.frontend import views as telefoni_views
from app.modules.calendar.backend import router as calendar_api from app.modules.calendar.backend import router as calendar_api
from app.modules.calendar.frontend import views as calendar_views from app.modules.calendar.frontend import views as calendar_views
from app.modules.planner.backend import router as planner_api
from app.modules.planner.frontend import views as planner_views
from app.modules.orders.backend import router as orders_api 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.fedex.backend import router as fedex_api from app.modules.fedex.backend import router as fedex_api
@ -513,6 +515,7 @@ app.include_router(wiki_api.router, prefix="/api/v1/wiki", tags=["Wiki"])
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"]) app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"])
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"]) 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(planner_api.router, prefix="/api/v1", tags=["Planner"])
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(fedex_api.router, prefix="/api/v1", tags=["FedEx"]) app.include_router(fedex_api.router, prefix="/api/v1", tags=["FedEx"])
app.include_router(shipmondo_api.router, prefix="/api/v1", tags=["Shipmondo"]) app.include_router(shipmondo_api.router, prefix="/api/v1", tags=["Shipmondo"])
@ -560,6 +563,7 @@ app.include_router(locations_views.router, tags=["Frontend"])
app.include_router(devportal_views.router, tags=["Frontend"]) app.include_router(devportal_views.router, tags=["Frontend"])
app.include_router(telefoni_views.router, tags=["Frontend"]) app.include_router(telefoni_views.router, tags=["Frontend"])
app.include_router(calendar_views.router, tags=["Frontend"]) app.include_router(calendar_views.router, tags=["Frontend"])
app.include_router(planner_views.router, tags=["Frontend"])
app.include_router(orders_views.router, tags=["Frontend"]) app.include_router(orders_views.router, tags=["Frontend"])
app.include_router(fedex_views.router, tags=["Frontend"]) app.include_router(fedex_views.router, tags=["Frontend"])
app.include_router(anydesk_views.router, tags=["Frontend"]) app.include_router(anydesk_views.router, tags=["Frontend"])

134
migrations/240_planner.sql Normal file
View File

@ -0,0 +1,134 @@
-- Planner: independent case allocations, capacity sources and permissions.
ALTER TABLE sag_sager
ADD COLUMN IF NOT EXISTS estimated_minutes INTEGER;
ALTER TABLE sag_sager
ADD COLUMN IF NOT EXISTS type VARCHAR(50) NOT NULL DEFAULT 'ticket';
CREATE TABLE IF NOT EXISTS planner_settings (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
warning_percent INTEGER NOT NULL DEFAULT 80 CHECK (warning_percent BETWEEN 1 AND 99),
full_percent INTEGER NOT NULL DEFAULT 100 CHECK (full_percent >= 100),
default_allocation_minutes INTEGER NOT NULL DEFAULT 60 CHECK (default_allocation_minutes > 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by INTEGER REFERENCES users(user_id)
);
INSERT INTO planner_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS planner_work_schedules (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
weekday SMALLINT NOT NULL CHECK (weekday BETWEEN 0 AND 6),
start_time TIME NOT NULL DEFAULT '08:00',
end_time TIME NOT NULL DEFAULT '16:00',
break_minutes INTEGER NOT NULL DEFAULT 30 CHECK (break_minutes >= 0),
valid_from DATE,
valid_to DATE,
active BOOLEAN NOT NULL DEFAULT TRUE,
CHECK (end_time > start_time)
);
CREATE INDEX IF NOT EXISTS idx_planner_work_user ON planner_work_schedules(user_id, weekday);
INSERT INTO planner_work_schedules (user_id, weekday, start_time, end_time, break_minutes)
SELECT u.user_id, d.weekday, '08:00', '16:00', 30
FROM users u CROSS JOIN (VALUES (0),(1),(2),(3),(4)) AS d(weekday)
WHERE u.is_active AND NOT EXISTS (
SELECT 1 FROM planner_work_schedules w WHERE w.user_id=u.user_id AND w.weekday=d.weekday
);
CREATE TABLE IF NOT EXISTS planner_absences (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
kind VARCHAR(40) NOT NULL DEFAULT 'absence',
note TEXT,
CHECK (ends_at > starts_at)
);
CREATE INDEX IF NOT EXISTS idx_planner_absence_period ON planner_absences(user_id, starts_at, ends_at);
CREATE TABLE IF NOT EXISTS planner_external_busy (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
provider VARCHAR(40) NOT NULL,
external_id VARCHAR(255) NOT NULL,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (ends_at > starts_at),
UNIQUE(provider, external_id)
);
CREATE INDEX IF NOT EXISTS idx_planner_external_period ON planner_external_busy(user_id, starts_at, ends_at);
CREATE TABLE IF NOT EXISTS planner_integrations (
user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
provider VARCHAR(40),
status VARCHAR(30) NOT NULL DEFAULT 'not_connected',
last_success_at TIMESTAMPTZ,
last_error_at TIMESTAMPTZ,
last_error TEXT,
CHECK (status IN ('connected', 'degraded', 'not_connected'))
);
CREATE TABLE IF NOT EXISTS planner_team_scope (
editor_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
target_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
PRIMARY KEY (editor_user_id, target_user_id)
);
CREATE TABLE IF NOT EXISTS planner_allocations (
id BIGSERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id),
user_id INTEGER NOT NULL REFERENCES users(user_id),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
note TEXT,
source VARCHAR(40) NOT NULL DEFAULT 'manual',
conflict_status VARCHAR(30) NOT NULL DEFAULT 'none',
locked BOOLEAN NOT NULL DEFAULT FALSE,
version INTEGER NOT NULL DEFAULT 1,
created_by INTEGER NOT NULL REFERENCES users(user_id),
updated_by INTEGER NOT NULL REFERENCES users(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CHECK (ends_at > starts_at),
CHECK (conflict_status IN ('none', 'overlap', 'overbooked', 'overlap_and_overbooked'))
);
ALTER TABLE planner_allocations ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_planner_alloc_period ON planner_allocations(user_id, starts_at, ends_at) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_planner_alloc_case ON planner_allocations(sag_id) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS planner_allocation_history (
id BIGSERIAL PRIMARY KEY,
allocation_id BIGINT NOT NULL REFERENCES planner_allocations(id),
sag_id INTEGER NOT NULL REFERENCES sag_sager(id),
action VARCHAR(20) NOT NULL,
changed_by INTEGER NOT NULL REFERENCES users(user_id),
before_value JSONB,
after_value JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_planner_history_case ON planner_allocation_history(sag_id, created_at DESC);
INSERT INTO permissions (code, description, category) VALUES
('planner.view', 'Se relevante planer', 'planner'),
('planner.edit_own', 'Redigér egen plan', 'planner'),
('planner.edit_team', 'Redigér tilladte medarbejderes planer', 'planner'),
('planner.admin', 'Administrér kapacitet og grænser', 'planner')
ON CONFLICT (code) DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id FROM groups g CROSS JOIN permissions p
WHERE g.name = 'Administrators' AND p.category = 'planner'
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id FROM groups g CROSS JOIN permissions p
WHERE g.name = 'Managers' AND p.code IN ('planner.view', 'planner.edit_own', 'planner.edit_team')
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id FROM groups g CROSS JOIN permissions p
WHERE g.name = 'Technicians' AND p.code IN ('planner.view', 'planner.edit_own')
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id FROM groups g CROSS JOIN permissions p
WHERE g.name = 'Viewers' AND p.code = 'planner.view'
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS system_event_log (
id BIGSERIAL PRIMARY KEY,
source VARCHAR(40) NOT NULL,
event_type VARCHAR(80) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'info',
summary TEXT NOT NULL,
correlation_id VARCHAR(255),
user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_system_event_log_time ON system_event_log(occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_system_event_log_source ON system_event_log(source, event_type, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_system_event_log_correlation ON system_event_log(correlation_id);
ALTER TABLE telefoni_opkald ADD COLUMN IF NOT EXISTS lifecycle_status VARCHAR(30) NOT NULL DEFAULT 'active';
ALTER TABLE telefoni_opkald ADD COLUMN IF NOT EXISTS last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE telefoni_opkald ADD COLUMN IF NOT EXISTS termination_source VARCHAR(40);
CREATE INDEX IF NOT EXISTS idx_telefoni_open_calls ON telefoni_opkald(started_at) WHERE ended_at IS NULL;
INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
SELECT 'voip','voip.stale_call','warning','Opkald mangler hangup-event',callid,bruger_id,
jsonb_build_object('call_id',id,'extension',intern_extension,'external_number',ekstern_nummer),started_at
FROM telefoni_opkald
WHERE ended_at IS NULL AND started_at < NOW()-INTERVAL '12 hours'
AND NOT EXISTS (SELECT 1 FROM system_event_log l WHERE l.event_type='voip.stale_call' AND l.correlation_id=telefoni_opkald.callid);
UPDATE telefoni_opkald SET lifecycle_status='missing_hangup'
WHERE ended_at IS NULL AND started_at < NOW()-INTERVAL '12 hours';
INSERT INTO permissions(code,description,category) VALUES
('system.event_log.view','Se samlet systemhændelseslog','system') ON CONFLICT(code) DO NOTHING;
INSERT INTO group_permissions(group_id,permission_id)
SELECT g.id,p.id FROM groups g CROSS JOIN permissions p
WHERE g.name IN ('Administrators','Managers') AND p.code='system.event_log.view'
ON CONFLICT DO NOTHING;

44
static/js/planner.js Normal file
View File

@ -0,0 +1,44 @@
(() => {
const root=document.querySelector('.planner-page'); if(!root)return;
const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],quick:'unplanned',selected:null,caseRefresh:0};
const $=s=>document.querySelector(s), pad=n=>String(n).padStart(2,'0');
const localISO=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const dateKey=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmtMin=m=>m?`${Math.floor(m/60)}t${m%60?' '+m%60+'m':''}`:'0t';
const api=async(url,opt={})=>{const r=await fetch(url,{headers:{'Content-Type':'application/json'},...opt});let b;try{b=await r.json()}catch{b={detail:'Ukendt serverfejl'}}if(!r.ok){const e=new Error(typeof b.detail==='string'?b.detail:b.detail?.message||'Handlingen mislykkedes');e.status=r.status;e.body=b;throw e}return b};
function range(){let a=new Date(state.anchor),b;if(state.view==='day'){a.setHours(0,0,0,0);b=new Date(a);b.setDate(b.getDate()+1)}else if(state.view==='week'){a.setHours(0,0,0,0);a.setDate(a.getDate()-((a.getDay()+6)%7));b=new Date(a);b.setDate(b.getDate()+7)}else{a=new Date(a.getFullYear(),a.getMonth(),1);b=new Date(a.getFullYear(),a.getMonth()+1,1)}return[a,b]}
function selectedUsers(){return state.scope==='mine'?[Number(root.dataset.currentUser)]:state.meta.users.map(u=>u.id)}
async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}">${esc(u.name)}</option>`)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',`<option value="${d.id}">${esc(d.name)}</option>`)}bind();await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)}
function bind(){let timer;$('#caseSearch').addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(loadCases,250)});['statusFilter','priorityFilter','typeFilter','responsibleFilter','departmentFilter','sortFilter'].forEach(id=>$('#'+id).addEventListener('change',loadCases));document.querySelectorAll('[data-quick]').forEach(b=>b.onclick=()=>{state.quick=state.quick===b.dataset.quick?'':b.dataset.quick;document.querySelectorAll('[data-quick]').forEach(x=>x.classList.toggle('active',x.dataset.quick===state.quick));loadCases()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{state.view=b.dataset.view;document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});document.querySelectorAll('[data-scope]').forEach(b=>b.onclick=()=>{state.scope=b.dataset.scope;document.querySelectorAll('[data-scope]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});$('#prevPeriod').onclick=()=>shift(-1);$('#nextPeriod').onclick=()=>shift(1);$('#todayBtn').onclick=()=>{state.anchor=new Date();loadPeriod()};$('.collapse-cases').onclick=()=>$('#casePanel').classList.toggle('collapsed');$('#saveAllocation').onclick=saveSelected;$('#deleteAllocation').onclick=deleteSelected;$('#copyAllocation').onclick=copySelected}
function shift(n){if(state.view==='month')state.anchor.setMonth(state.anchor.getMonth()+n);else state.anchor.setDate(state.anchor.getDate()+n*(state.view==='week'?7:1));loadPeriod()}
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{state.cases=(await api('/api/v1/planner/cases?'+p)).items;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const current=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===current?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
function setCompactDragImage(e,el){const ghost=document.createElement('div');ghost.textContent=el.querySelector('strong')?.textContent||'Planlæg sag';ghost.style.cssText='position:fixed;left:-9999px;top:-9999px;width:210px;padding:7px 10px;border-radius:7px;border-left:4px solid #2563eb;background:#dbeafe;color:#101828;font-size:12px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 5px 14px #0003';document.body.appendChild(ghost);e.dataTransfer.setDragImage(ghost,18,16);setTimeout(()=>ghost.remove(),0)}
function renderCases(){$('#caseList').innerHTML=state.cases.length?state.cases.map(c=>`<article class="case-card" draggable="true" data-case="${c.id}" data-priority="${esc(c.priority)}"><strong>#${c.id} · ${esc(c.title)}</strong><div class="customer">${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}</div><div class="case-meta"><span>${esc(c.priority)}</span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case="${c.id}" title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${fmtMin(c.estimated_minutes)}</button></div><div class="case-meta"><span>${fmtMin(c.planned_minutes)} planlagt</span><span>${fmtMin(c.remaining_minutes)} tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case="${c.id}" title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${fmtMin(c.used_minutes)} brugt${c.latest_registration_at?' · senest '+new Date(c.latest_registration_at).toLocaleDateString('da-DK'):''}</button><div class="progress"><div class="progress-bar" style="width:${c.estimated_minutes?Math.min(100,c.planned_minutes*100/c.estimated_minutes):0}%"></div></div></article>`).join(''):'<div class="empty">Ingen sager matcher filtrene</div>';document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})}
async function setEstimate(caseId){const c=state.cases.find(x=>x.id===caseId),answer=prompt('Forventet tidsforbrug i timer (fx 2,5):',c?.estimated_minutes?String(c.estimated_minutes/60).replace('.',','):'');if(answer===null)return;const hours=Number(answer.replace(',','.'));if(!Number.isFinite(hours)||hours<0){toast('Angiv et gyldigt antal timer');return}try{await api(`/api/v1/planner/cases/${caseId}/estimate`,{method:'PATCH',body:JSON.stringify({estimated_minutes:Math.round(hours*60)})});toast('Forventet tid er gemt');await loadCases()}catch(e){toast(e.message)}}
async function showTimeEntries(caseId){const c=state.cases.find(x=>x.id===caseId);$('#timeDialogTitle').textContent=`#${caseId} · ${c?.title||'Sag'}`;$('#timeDialogTotal').textContent='Henter tidsregistreringer…';$('#timeEntryList').innerHTML='';$('#timeDialogCaseLink').href=`/sag/${caseId}/v3`;$('#timeDialog').showModal();try{const data=await api(`/api/v1/planner/cases/${caseId}/time-entries`);$('#timeDialogTotal').textContent=`I alt ${fmtMin(data.total_minutes)} brugt`;$('#timeEntryList').innerHTML=data.items.length?data.items.map(x=>{const when=x.start_tid?new Date(x.start_tid).toLocaleString('da-DK'):(x.worked_date?new Date(x.worked_date+'T12:00').toLocaleDateString('da-DK'):'Ukendt tidspunkt');return `<div class="border rounded p-2"><div class="d-flex justify-content-between"><strong>${esc(x.employee_name)}</strong><span>${fmtMin(x.minutes)}</span></div><small class="text-muted">${esc(when)}${x.slut_tid?' '+new Date(x.slut_tid).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}):''}</small>${x.description?`<div class="small mt-1">${esc(x.description)}</div>`:''}</div>`}).join(''):'<div class="text-muted">Ingen tid registreret endnu.</div>'}catch(e){$('#timeDialogTotal').textContent=e.message}}
async function loadPeriod(){const [a,b]=range(),p=new URLSearchParams({starts_at:a.toISOString(),ends_at:b.toISOString()});selectedUsers().forEach(id=>p.append('user_id',id));$('#timeline').innerHTML='<div class="empty">Henter plan…</div>';try{state.data=await api('/api/v1/planner/period?'+p);render(a,b)}catch(e){$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
function render(a,b){const opts={day:'numeric',month:'short'};$('#periodLabel').textContent=state.view==='month'?a.toLocaleDateString('da-DK',{month:'long',year:'numeric'}):`${a.toLocaleDateString('da-DK',opts)} ${new Date(b-1).toLocaleDateString('da-DK',{...opts,year:'numeric'})}`;state.view==='month'?renderMonth(a):renderTimeline(a,b)}
function cap(uid,key){return state.data.capacity.find(c=>c.user_id===uid&&c.date===key)||{state:'gray',label:'Ingen arbejdstid'}}
function canEdit(uid){return (state.meta.editable_user_ids||[]).includes(Number(uid))}
function integrationWarning(){return (state.data.integration_statuses||[]).some(x=>x.status==='degraded')?'<div class="alert alert-warning m-2 py-2"><i class="bi bi-cloud-slash me-1"></i>Ekstern travl tid kan mangle. Intern planlægning virker fortsat.</div>':''}
function blockStyle(start,end,lane={index:0,count:1}){const first=7*60,last=18*60,total=last-first,s=new Date(start),e=new Date(end),sm=s.getHours()*60+s.getMinutes(),em=e.getHours()*60+e.getMinutes(),top=Math.max(0,(sm-first)/total*660),height=Math.max(24,(Math.min(last,em)-Math.max(first,sm))/total*660),left=lane.index*(100/lane.count),leftPx=26-lane.index*(29/lane.count),width=100/lane.count,widthPx=29/lane.count;return `top:${top}px;height:${height}px;left:calc(${left}% + ${leftPx}px);right:auto;width:calc(${width}% - ${widthPx}px)`}
function overlapLayout(planned,registered){const all=[...planned.map(item=>({key:`p:${item.id}`,item})),...registered.map(item=>({key:`r:${item.id}`,item}))].sort((a,b)=>new Date(a.item.starts_at)-new Date(b.item.starts_at));const result=new Map();let group=[],groupEnd=0;const flush=()=>{if(!group.length)return;const laneEnds=[];for(const event of group){const start=+new Date(event.item.starts_at);let lane=laneEnds.findIndex(end=>end<=start);if(lane<0)lane=laneEnds.length;laneEnds[lane]=+new Date(event.item.ends_at);event.lane=lane}for(const event of group)result.set(event.key,{index:event.lane,count:laneEnds.length});group=[];groupEnd=0};for(const event of all){const start=+new Date(event.item.starts_at),end=+new Date(event.item.ends_at);if(group.length&&start>=groupEnd)flush();group.push(event);groupEnd=Math.max(groupEnd,end)}flush();return result}
function hourMarks(){return Array.from({length:12},(_,i)=>`<span class="hour-mark" style="top:${i*60}px">${pad(i+7)}:00</span>`).join('')}
function registeredHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="registered-time" style="${blockStyle(x.starts_at,x.ends_at,lane)}"><div class="time"><i class="bi bi-stopwatch me-1"></i>${pad(s.getHours())}:${pad(s.getMinutes())}${pad(e.getHours())}:${pad(e.getMinutes())} · registreret</div><strong>#${x.sag_id} ${esc(x.case_title)}</strong><div>${fmtMin(x.minutes)} brugt</div></div>`}
function untimedHtml(items){return items.length?`<div class="untimed-registers">${items.map(x=>`<div class="untimed-register" title="${esc(x.case_title)}"><i class="bi bi-stopwatch me-1"></i>Uden tidspunkt · #${x.sag_id} · ${fmtMin(x.minutes)} registreret</div>`).join('')}</div>`:''}
function renderTimeline(a,b){const users=state.meta.users.filter(u=>selectedUsers().includes(u.id)),days=[];for(let d=new Date(a);d<b;d.setDate(d.getDate()+1))days.push(new Date(d));const cols=`150px repeat(${days.length},minmax(130px,1fr))`;let html=integrationWarning()+`<div class="week-grid" style="grid-template-columns:${cols}"><div class="grid-cell grid-head employee-head">Medarbejder</div>${days.map(d=>`<div class="grid-cell grid-head">${d.toLocaleDateString('da-DK',{weekday:'short',day:'numeric',month:'short'})}</div>`).join('')}`;for(const u of users){html+=`<div class="grid-cell employee-head">${esc(u.name)}<div class="small text-muted mt-2">Arbejdsdag 0718</div></div>`;for(const d of days){const key=dateKey(d),c=cap(u.id,key),items=state.data.allocations.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),allRegistered=(state.data.time_entries||[]).filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),registered=allRegistered.filter(x=>x.has_time),untimed=allRegistered.filter(x=>!x.has_time),busy=state.data.external_busy.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),lanes=overlapLayout(items,registered);html+=`<div class="grid-cell drop-cell" data-user="${u.id}" data-date="${key}">${hourMarks()}<span class="capacity-pill"><i class="dot ${c.state}"></i>${esc(c.label)} · ${esc(c.registered_label||'0 timer registreret')}</span>${busy.map(x=>`<div class="external-busy" style="${blockStyle(x.starts_at,x.ends_at)}">Ekstern travl tid</div>`).join('')}${registered.map(x=>registeredHtml(x,lanes.get(`r:${x.id}`))).join('')}${items.map(x=>allocationHtml(x,lanes.get(`p:${x.id}`))).join('')}${untimedHtml(untimed)}</div>`}}html+='</div>';$('#timeline').innerHTML=html;bindCalendar()}
function allocationHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="allocation ${x.conflict_status!=='none'?'conflict':''} ${x.locked?'locked':''}" style="${blockStyle(x.starts_at,x.ends_at,lane)}" draggable="${x.locked?'false':'true'}" data-allocation="${x.id}"><div class="time">${x.locked?'<i class="bi bi-lock-fill me-1"></i>':''}${pad(s.getHours())}:${pad(s.getMinutes())}${pad(e.getHours())}:${pad(e.getMinutes())}</div><strong>#${x.sag_id} ${esc(x.case_title)}</strong><div>${esc(x.customer_name)}</div></div>`}
function renderMonth(a){const start=new Date(a);start.setDate(1-((start.getDay()+6)%7));let html=integrationWarning()+'<div class="month-grid">'+['Man','Tir','Ons','Tor','Fre','Lør','Søn'].map(x=>`<div class="grid-cell grid-head">${x}</div>`).join('');for(let i=0;i<42;i++){const d=new Date(start);d.setDate(d.getDate()+i);const key=dateKey(d),caps=selectedUsers().map(uid=>cap(uid,key)),alloc=state.data.allocations.filter(x=>dateKey(new Date(x.starts_at))===key),planned=caps.reduce((n,c)=>n+c.allocated_minutes,0),registered=caps.reduce((n,c)=>n+(c.registered_minutes||0),0),available=caps.reduce((n,c)=>n+c.available_minutes,0);html+=`<div class="month-day ${d.getMonth()!==a.getMonth()?'other':''}" data-date="${key}"><span class="day-num">${d.getDate()}</span><div class="month-cap">${caps.map(c=>`<span title="${esc(c.label)}"><i class="dot ${c.state}"></i></span>`).join('')} ${fmtMin(planned)} af ${fmtMin(available)} planlagt<br><span style="color:#067647"><i class="bi bi-stopwatch"></i> ${fmtMin(registered)} registreret</span></div>${alloc.slice(0,3).map(x=>`<div class="allocation" data-allocation="${x.id}">#${x.sag_id} ${esc(x.case_title)}</div>`).join('')}</div>`}html+='</div>';$('#timeline').innerHTML=html;document.querySelectorAll('.month-day').forEach(el=>el.onclick=e=>{if(e.target.closest('.allocation'))return;state.anchor=new Date(el.dataset.date+'T12:00');state.view='day';document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view==='day'));loadPeriod()});bindCalendar()}
function bindCalendar(){document.querySelectorAll('.drop-cell').forEach(el=>{if(canEdit(el.dataset.user)){el.ondragover=e=>e.preventDefault();el.ondrop=e=>drop(e,el)}});document.querySelectorAll('[data-allocation]').forEach(el=>{const item=state.data.allocations.find(x=>x.id===Number(el.dataset.allocation));el.onclick=()=>openAllocation(Number(el.dataset.allocation));el.draggable=Boolean(item&&canEdit(item.user_id)&&!item.locked);el.ondragstart=e=>{if(item&&canEdit(item.user_id)&&!item.locked)e.dataTransfer.setData('application/x-allocation',el.dataset.allocation)}})}
async function drop(e,cell){e.preventDefault();const caseId=e.dataTransfer.getData('application/x-case'),allocId=e.dataTransfer.getData('application/x-allocation');const rect=cell.getBoundingClientRect(),ratio=Math.max(0,Math.min(1,(e.clientY-rect.top)/rect.height)),minutes=Math.round((7*60+ratio*11*60)/15)*15;const start=new Date(cell.dataset.date+'T00:00');start.setMinutes(minutes);if(caseId){const c=state.cases.find(x=>x.id===Number(caseId)),defaultMins=Math.max(15,Number(state.meta.settings.default_allocation_minutes)||60),remainingMins=Math.max(15,Number(c?.remaining_minutes)||defaultMins),mins=Math.min(remainingMins,defaultMins);await mutate('/api/v1/planner/allocations','POST',{sag_id:Number(caseId),user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+mins*60000).toISOString(),source:'manual'})}else if(allocId){const x=state.data.allocations.find(a=>a.id===Number(allocId)),dur=new Date(x.ends_at)-new Date(x.starts_at);await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+dur).toISOString(),version:x.version})}}
function openAllocation(id){const x=state.data.allocations.find(a=>a.id===id);if(!x)return;state.selected=x;const editable=canEdit(x.user_id);$('#saveAllocation').classList.toggle('d-none',!editable);$('#deleteAllocation').classList.toggle('d-none',!editable||x.locked);$('#copyAllocation').classList.toggle('d-none',!editable);$('#dialogTitle').textContent=`#${x.sag_id} · ${x.case_title}`;$('#dialogCustomer').textContent=`${x.customer_name} · ${x.user_name}`;$('#dialogStart').value=localISO(new Date(x.starts_at));$('#dialogEnd').value=localISO(new Date(x.ends_at));$('#dialogNote').value=x.note||'';$('#dialogLocked').checked=Boolean(x.locked);$('#dialogStart').disabled=!editable;$('#dialogEnd').disabled=!editable;$('#dialogNote').disabled=!editable;$('#dialogLocked').disabled=!editable;$('#dialogCaseLink').href=`/sag/${x.sag_id}/v3`;$('#dialogWarning').classList.add('d-none');$('#allocationDialog').showModal()}
async function saveSelected(){const x=state.selected;await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{starts_at:new Date($('#dialogStart').value).toISOString(),ends_at:new Date($('#dialogEnd').value).toISOString(),note:$('#dialogNote').value,locked:$('#dialogLocked').checked,version:x.version},true)}
async function deleteSelected(){const x=state.selected;if(!confirm('Slet denne tidsblok? Sagen bevares.'))return;try{const result=await api(`/api/v1/planner/allocations/${x.id}?version=${x.version}`,{method:'DELETE'});$('#allocationDialog').close();const a=result.allocation;toast('Tidsblokken blev slettet','Fortryd',()=>undoMutation(a.id,a.version));await refresh()}catch(e){toast(e.message)}}
async function copySelected(){const x=state.selected,start=new Date(x.starts_at);start.setDate(start.getDate()+1);await mutate(`/api/v1/planner/allocations/${x.id}/copy?starts_at=${encodeURIComponent(start.toISOString())}`,'POST',null,true)}
async function mutate(url,method,body,close=false){try{const result=await api(url,{method,body:body?JSON.stringify(body):undefined});if(close)$('#allocationDialog').close();toast('Planen er gemt','Fortryd',result?.id&&result?.version?()=>undoMutation(result.id,result.version):null);await refresh()}catch(e){if(e.status===409&&e.body?.detail?.conflict_status&&confirm(`${e.message}. Vil du gemme alligevel?`)){body.confirm_conflicts=true;return mutate(url,method,body,close)}toast(e.message);await loadPeriod()}}
async function undoMutation(id,version){try{await api(`/api/v1/planner/allocations/${id}/undo?version=${version}`,{method:'POST'});toast('Ændringen blev fortrudt');await refresh()}catch(e){toast(e.message);await loadPeriod()}}
async function refresh(){await Promise.all([loadCases(),loadPeriod()])}
function toast(message,action,fn){const el=document.createElement('div');el.className='planner-toast';el.textContent=message;if(action&&fn){const b=document.createElement('button');b.textContent=action;b.onclick=()=>{fn();el.remove()};el.append(b)}$('#toastStack').append(el);setTimeout(()=>el.remove(),6000)}
init().catch(e=>{$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`});
})();

113
tests/test_planner.py Normal file
View File

@ -0,0 +1,113 @@
from datetime import datetime, time, timezone
from pathlib import Path
import sys
import pytest
from fastapi import HTTPException
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.modules.planner.backend import router as planner
def test_planner_migration_has_independent_versioned_allocations_and_capacity_sources():
sql = Path("migrations/240_planner.sql").read_text(encoding="utf-8")
for table in (
"planner_allocations", "planner_allocation_history", "planner_work_schedules",
"planner_absences", "planner_external_busy", "planner_integrations", "planner_settings",
):
assert f"CREATE TABLE IF NOT EXISTS {table}" in sql
assert "version INTEGER NOT NULL DEFAULT 1" in sql
assert "ALTER TABLE sag_sager" in sql
assert "estimated_minutes" in sql
assert "planner.edit_team" in sql
def test_allocation_input_rejects_zero_or_negative_duration():
with pytest.raises(Exception):
planner.AllocationInput(
sag_id=1, user_id=2,
starts_at=datetime(2026, 9, 8, 10), ends_at=datetime(2026, 9, 8, 10),
)
def test_capacity_uses_work_time_less_break_absence_and_external_busy(monkeypatch):
def fake_single(query, _params=None):
if "planner_settings" in query:
return {"warning_percent": 80, "full_percent": 100}
return None
def fake_query(query, _params=None):
if "planner_work_schedules" in query:
return [{"user_id": 7, "weekday": 1, "start_time": time(8), "end_time": time(16), "break_minutes": 30}]
if "planner_absences" in query:
return [{"user_id": 7, "starts_at": datetime(2026, 9, 8, 12, tzinfo=planner.TZ), "ends_at": datetime(2026, 9, 8, 13, tzinfo=planner.TZ)}]
return []
monkeypatch.setattr(planner, "execute_query_single", fake_single)
monkeypatch.setattr(planner, "execute_query", fake_query)
monkeypatch.setattr(planner.busy_time_provider, "get_busy", lambda *_: [{
"user_id": 7, "starts_at": datetime(2026, 9, 8, 14, tzinfo=planner.TZ),
"ends_at": datetime(2026, 9, 8, 15, tzinfo=planner.TZ), "provider": "outlook",
}])
allocations = [{"user_id": 7, "starts_at": datetime(2026, 9, 8, 8, tzinfo=planner.TZ), "ends_at": datetime(2026, 9, 8, 12, tzinfo=planner.TZ)}]
result = planner._capacity(
[7], datetime(2026, 9, 8, tzinfo=planner.TZ),
datetime(2026, 9, 9, tzinfo=planner.TZ), allocations,
)[0]
assert result["available_minutes"] == 330 # 8h - 30m break - 2h unavailable
assert result["allocated_minutes"] == 240
assert result["state"] == "green"
assert result["label"] == "4 af 5.5 timer planlagt"
assert result["registered_minutes"] == 0
assert result["registered_label"] == "0 timer registreret"
def test_own_and_team_edit_permissions_are_separate(monkeypatch):
own = {"id": 4, "permissions": ["planner.edit_own"]}
assert planner._can_edit(own, 4)
assert not planner._can_edit(own, 5)
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"allowed": 1})
assert planner._can_edit({"id": 4, "permissions": ["planner.edit_team"]}, 5)
def test_overlapping_allocations_each_consume_capacity():
start = datetime(2026, 9, 8, 8, tzinfo=planner.TZ)
end = datetime(2026, 9, 8, 12, tzinfo=planner.TZ)
assert planner._sum_minutes([(start, end), (start, end)], start, end) == 480
def test_planner_ui_exposes_views_filters_capacity_used_time_and_undo():
html = Path("app/modules/planner/templates/index.html").read_text(encoding="utf-8")
js = Path("static/js/planner.js").read_text(encoding="utf-8")
for text in ("Min plan", "Team", "Dag", "Uge", "Måned", "Ikke planlagt", "Delvist planlagt", "Mine sager", "Haster"):
assert text in html
for feature in ("application/x-case", "application/x-allocation", "confirm_conflicts", "undoMutation", "time-entries", "used_minutes", "estimated_minutes", "dialogLocked", "hourMarks", "blockStyle", "overlapLayout"):
assert feature in js
assert "Fastlås tidspunkt" in html
assert "planner.estimate.update" in Path("app/modules/planner/backend/router.py").read_text()
assert "registered-time" in html
assert "registeredHtml" in js
assert "time_entries" in js
assert "Ekstern travl tid" in js
assert "green" in html and "yellow" in html and "red" in html and "gray" in html
def test_closed_case_is_checked_before_allocation(monkeypatch):
monkeypatch.setattr(planner, "_can_edit", lambda *_: True)
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None})
payload = planner.AllocationInput(
sag_id=9, user_id=2, starts_at=datetime(2026, 9, 8, 10), ends_at=datetime(2026, 9, 8, 11)
)
with pytest.raises(HTTPException) as exc:
planner.create_allocation(payload, {"id": 2, "permissions": ["planner.edit_own"]})
assert exc.value.status_code == 409
def test_active_case_list_only_hides_closed_cases_that_already_have_time():
source = Path("app/modules/planner/backend/router.py").read_text(encoding="utf-8")
cases_query = source.split('@router.get("/cases")', 1)[1].split('@router.get("/cases/{sag_id}/time-entries")', 1)[0]
assert "NOT (LOWER(COALESCE(s.status,'')) = ANY(%s) AND COALESCE(used.used_minutes,0) > 0)" in cases_query
assert "AS is_closed" in cases_query