diff --git a/.env.example b/.env.example
index 2134eab..2d7b0c2 100644
--- a/.env.example
+++ b/.env.example
@@ -133,6 +133,7 @@ IMAP_USERNAME=your_email@gmail.com
IMAP_PASSWORD=your_app_password
IMAP_USE_SSL=true
IMAP_FOLDER=INBOX
+IMAP_TEST_FOLDER=BMC_TEST # Shared test inbox for all mail scenarios
IMAP_READ_ONLY=true # Safety: READ-ONLY mode
# Microsoft Graph API (Alternative to IMAP - for Office365/Outlook)
@@ -152,7 +153,9 @@ EMAIL_AI_CONFIDENCE_THRESHOLD=0.7
EMAIL_REQUIRE_MANUAL_APPROVAL=true
EMAIL_AUTO_CREATE_CASES_FROM_EMAIL=false
EMAIL_MAX_FETCH_PER_RUN=50
+EMAIL_PROCESS_ALLOW_FOLDER_OVERRIDE=true
EMAIL_PROCESS_INTERVAL_MINUTES=5
EMAIL_WORKFLOWS_ENABLED=true
+EMAIL_WORKFLOW_AUTORUN_ENABLED=false
EMAIL_MAX_UPLOAD_SIZE_MB=50
ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip
\ No newline at end of file
diff --git a/app/auth/backend/views.py b/app/auth/backend/views.py
index baf580b..0a3af3c 100644
--- a/app/auth/backend/views.py
+++ b/app/auth/backend/views.py
@@ -16,7 +16,11 @@ async def login_page(request: Request):
"""
return templates.TemplateResponse(
"auth/frontend/login.html",
- {"request": request}
+ {
+ "request": request,
+ "hide_top_nav": True,
+ "hide_bottom_bar": True,
+ }
)
@@ -27,5 +31,9 @@ async def two_factor_setup_page(request: Request):
"""
return templates.TemplateResponse(
"auth/frontend/2fa_setup.html",
- {"request": request}
+ {
+ "request": request,
+ "hide_top_nav": True,
+ "hide_bottom_bar": True,
+ }
)
diff --git a/app/core/config.py b/app/core/config.py
index b42e113..9752e4c 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -106,6 +106,7 @@ class Settings(BaseSettings):
IMAP_PASSWORD: str = ""
IMAP_USE_SSL: bool = True
IMAP_FOLDER: str = "INBOX"
+ IMAP_TEST_FOLDER: str = ""
IMAP_READ_ONLY: bool = True
# Microsoft Graph API (alternative to IMAP)
@@ -125,8 +126,10 @@ class Settings(BaseSettings):
EMAIL_REQUIRE_MANUAL_APPROVAL: bool = True # Phase 1: human approval before case creation/routing
EMAIL_AUTO_CREATE_CASES_FROM_EMAIL: bool = False
EMAIL_MAX_FETCH_PER_RUN: int = 50
+ EMAIL_PROCESS_ALLOW_FOLDER_OVERRIDE: bool = True
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
EMAIL_WORKFLOWS_ENABLED: bool = True
+ EMAIL_WORKFLOW_AUTORUN_ENABLED: bool = False
EMAIL_MAX_UPLOAD_SIZE_MB: int = 50 # Max file size for email uploads
ALLOWED_EXTENSIONS: List[str] = ["pdf", "jpg", "jpeg", "png", "gif", "doc", "docx", "xls", "xlsx", "zip"] # Allowed file extensions for uploads
diff --git a/app/dashboard/backend/mission_router.py b/app/dashboard/backend/mission_router.py
index 81e9c1e..f4b9248 100644
--- a/app/dashboard/backend/mission_router.py
+++ b/app/dashboard/backend/mission_router.py
@@ -65,6 +65,62 @@ class MissionTemperatureWebhook(BaseModel):
payload: Dict[str, Any] = Field(default_factory=dict)
+class MissionProjectCreatePayload(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255)
+ description: Optional[str] = None
+ status: Optional[str] = "planned"
+ score: Optional[int] = 0
+ started_at: Optional[datetime] = None
+ ended_at: Optional[datetime] = None
+ payload: Dict[str, Any] = Field(default_factory=dict)
+
+
+class MissionProjectUpdatePayload(BaseModel):
+ name: Optional[str] = None
+ description: Optional[str] = None
+ status: Optional[str] = None
+ score: Optional[int] = None
+ started_at: Optional[datetime] = None
+ ended_at: Optional[datetime] = None
+
+
+class MissionProjectMilestonePayload(BaseModel):
+ title: str = Field(..., min_length=1, max_length=255)
+ description: Optional[str] = None
+ status: Optional[str] = "active"
+ target_date: Optional[datetime] = None
+
+
+class MissionProjectMilestoneUpdatePayload(BaseModel):
+ title: Optional[str] = None
+ description: Optional[str] = None
+ status: Optional[str] = None
+ target_date: Optional[datetime] = None
+
+
+class MissionProjectBlockerPayload(BaseModel):
+ title: str = Field(..., min_length=1, max_length=255)
+ description: Optional[str] = None
+ status: Optional[str] = "open"
+ severity: Optional[str] = "medium"
+ resolved_at: Optional[datetime] = None
+
+
+class MissionProjectBlockerUpdatePayload(BaseModel):
+ title: Optional[str] = None
+ description: Optional[str] = None
+ status: Optional[str] = None
+ severity: Optional[str] = None
+ resolved_at: Optional[datetime] = None
+
+
+class MissionProjectLinkCasePayload(BaseModel):
+ sag_id: int
+ project_milestone_id: Optional[int] = None
+ is_project_blocker: Optional[bool] = False
+ project_task_type: Optional[str] = None
+
+
def _first_query_param(request: Request, *names: str) -> Optional[str]:
for name in names:
value = request.query_params.get(name)
@@ -295,6 +351,138 @@ async def get_mission_state():
return MissionService.get_state()
+@router.get("/mission/projects")
+async def get_mission_projects(limit: int = Query(120, ge=1, le=500)):
+ return {
+ "projects": MissionService.get_projects(limit=limit),
+ "summary": MissionService.get_projects_state_payload(limit=limit).get("summary", {}),
+ }
+
+
+@router.get("/mission/projects/workload")
+async def get_mission_projects_workload(limit: int = Query(120, ge=1, le=500)):
+ return {"workload": MissionService.get_project_workload(limit=limit)}
+
+
+@router.get("/mission/projects/{project_id}")
+async def get_mission_project_detail(project_id: int):
+ project = MissionService.get_project_detail(project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Projekt ikke fundet")
+ return project
+
+
+@router.post("/mission/projects")
+async def create_mission_project(request: Request, payload: MissionProjectCreatePayload):
+ user_payload = _require_authenticated_user(request)
+ actor_user_id = user_payload.get("sub") or user_payload.get("user_id")
+ try:
+ actor_user_id = int(actor_user_id) if actor_user_id is not None else None
+ except (TypeError, ValueError):
+ actor_user_id = None
+
+ project = MissionService.create_project(payload.model_dump(mode="json"), actor_user_id=actor_user_id)
+ if not project:
+ raise HTTPException(status_code=400, detail="Kunne ikke oprette projekt")
+
+ await mission_ws_manager.broadcast("project_created", project)
+ await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
+ return project
+
+
+@router.patch("/mission/projects/{project_id}")
+async def update_mission_project(project_id: int, request: Request, payload: MissionProjectUpdatePayload):
+ _require_authenticated_user(request)
+ project = MissionService.update_project(project_id, payload.model_dump(mode="json", exclude_none=True))
+ if not project:
+ raise HTTPException(status_code=404, detail="Projekt ikke fundet")
+
+ event_name = "project_status_changed" if "status" in payload.model_dump(exclude_none=True) else "project_updated"
+ await mission_ws_manager.broadcast(event_name, project)
+ await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
+ return project
+
+
+@router.post("/mission/projects/{project_id}/milestones")
+async def create_mission_project_milestone(project_id: int, request: Request, payload: MissionProjectMilestonePayload):
+ _require_authenticated_user(request)
+ milestone = MissionService.add_project_milestone(project_id, payload.model_dump(mode="json"))
+ if not milestone:
+ raise HTTPException(status_code=400, detail="Kunne ikke oprette milepael")
+
+ await mission_ws_manager.broadcast("project_milestone_updated", {"project_id": project_id, "milestone": milestone})
+ await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
+ return milestone
+
+
+@router.patch("/mission/projects/{project_id}/milestones/{milestone_id}")
+async def update_mission_project_milestone(
+ project_id: int,
+ milestone_id: int,
+ request: Request,
+ payload: MissionProjectMilestoneUpdatePayload,
+):
+ _require_authenticated_user(request)
+ milestone = MissionService.update_project_milestone(
+ project_id,
+ milestone_id,
+ payload.model_dump(mode="json", exclude_none=True),
+ )
+ if not milestone:
+ raise HTTPException(status_code=404, detail="Milepael ikke fundet")
+
+ await mission_ws_manager.broadcast("project_milestone_updated", {"project_id": project_id, "milestone": milestone})
+ await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
+ return milestone
+
+
+@router.post("/mission/projects/{project_id}/blockers")
+async def create_mission_project_blocker(project_id: int, request: Request, payload: MissionProjectBlockerPayload):
+ _require_authenticated_user(request)
+ blocker = MissionService.add_project_blocker(project_id, payload.model_dump(mode="json"))
+ if not blocker:
+ raise HTTPException(status_code=400, detail="Kunne ikke oprette blocker")
+
+ await mission_ws_manager.broadcast("project_blocked", {"project_id": project_id, "blocker": blocker})
+ await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
+ return blocker
+
+
+@router.patch("/mission/projects/{project_id}/blockers/{blocker_id}")
+async def update_mission_project_blocker(
+ project_id: int,
+ blocker_id: int,
+ request: Request,
+ payload: MissionProjectBlockerUpdatePayload,
+):
+ _require_authenticated_user(request)
+ blocker = MissionService.update_project_blocker(
+ project_id,
+ blocker_id,
+ payload.model_dump(mode="json", exclude_none=True),
+ )
+ if not blocker:
+ raise HTTPException(status_code=404, detail="Blocker ikke fundet")
+
+ blocker_status = str(blocker.get("status") or "").lower()
+ event_name = "project_unblocked" if blocker_status in {"resolved", "cancelled"} else "project_blocked"
+ await mission_ws_manager.broadcast(event_name, {"project_id": project_id, "blocker": blocker})
+ await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
+ return blocker
+
+
+@router.post("/mission/projects/{project_id}/link-case")
+async def link_case_to_mission_project(project_id: int, request: Request, payload: MissionProjectLinkCasePayload):
+ _require_authenticated_user(request)
+ linked_case = MissionService.link_case_to_project(project_id, payload.model_dump(mode="json"))
+ if not linked_case:
+ raise HTTPException(status_code=404, detail="Sag eller projekt ikke fundet")
+
+ await mission_ws_manager.broadcast("project_task_assigned", {"project_id": project_id, "case": linked_case})
+ await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
+ return linked_case
+
+
@router.get("/mission/camera/mjpeg")
async def mission_camera_mjpeg_stream(fps: float = Query(5.0, ge=1.0, le=15.0)):
feed_url = (MissionService.get_setting_value("mission_camera_feed_url", "") or "").strip()
diff --git a/app/dashboard/backend/mission_service.py b/app/dashboard/backend/mission_service.py
index 0b016cd..d1fe874 100644
--- a/app/dashboard/backend/mission_service.py
+++ b/app/dashboard/backend/mission_service.py
@@ -326,6 +326,694 @@ class MissionService:
result.append(item)
return result
+ @staticmethod
+ def _project_risk_level_from_score(score: int) -> str:
+ if score <= 29:
+ return "low"
+ if score <= 69:
+ return "medium"
+ if score <= 119:
+ return "high"
+ return "critical"
+
+ @staticmethod
+ def _compute_project_risk(project_row: Dict[str, Any]) -> Dict[str, Any]:
+ score = int(project_row.get("score") or 0)
+ factors: list[str] = []
+
+ overdue_tasks = int(project_row.get("overdue_tasks") or 0)
+ open_blockers = int(project_row.get("open_blockers") or 0)
+ overdue_milestones = int(project_row.get("overdue_milestones") or 0)
+
+ if overdue_tasks > 0:
+ score += min(overdue_tasks * 8, 40)
+ factors.append("overdue_tasks")
+ if overdue_milestones > 0:
+ score += min(overdue_milestones * 10, 40)
+ factors.append("overdue_milestones")
+ if open_blockers > 0:
+ score += min(open_blockers * 15, 60)
+ factors.append("open_blockers")
+
+ return {
+ "risk_score": score,
+ "risk_level": MissionService._project_risk_level_from_score(score),
+ "risk_factors": factors,
+ }
+
+ @staticmethod
+ def get_project_workload(limit: int = 100) -> list[Dict[str, Any]]:
+ if not MissionService._table_exists("mission_projects"):
+ return []
+
+ rows = execute_query(
+ """
+ SELECT
+ p.id,
+ p.name,
+ COUNT(*) FILTER (
+ WHERE s.id IS NOT NULL
+ AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
+ ) AS open_tasks,
+ COUNT(*) FILTER (
+ WHERE s.id IS NOT NULL
+ AND s.deadline IS NOT NULL
+ AND s.deadline::date < CURRENT_DATE
+ AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
+ ) AS overdue_tasks,
+ COUNT(*) FILTER (
+ WHERE s.id IS NOT NULL
+ AND s.ansvarlig_bruger_id IS NULL
+ AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
+ ) AS unassigned_tasks
+ FROM mission_projects p
+ LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL
+ GROUP BY p.id, p.name
+ ORDER BY overdue_tasks DESC, unassigned_tasks DESC, open_tasks DESC, p.id DESC
+ LIMIT %s
+ """,
+ (limit,),
+ ) or []
+
+ return [dict(row) for row in rows]
+
+ @staticmethod
+ def _get_projects_from_cases(limit: int = 120) -> list[Dict[str, Any]]:
+ if not MissionService._table_exists("sag_sager"):
+ return []
+
+ case_rows = execute_query(
+ """
+ SELECT
+ s.id,
+ s.titel AS name,
+ s.beskrivelse AS description,
+ LOWER(COALESCE(s.status, 'active')) AS status,
+ 0 AS score,
+ s.start_date AS started_at,
+ s.deadline AS ended_at,
+ s.created_at AS updated_at,
+ 0 AS active_milestones,
+ 0 AS overdue_milestones,
+ 0 AS open_blockers,
+ 1 AS open_tasks,
+ CASE WHEN s.deadline IS NOT NULL AND s.deadline::date = CURRENT_DATE THEN 1 ELSE 0 END AS due_today_tasks,
+ CASE
+ WHEN s.deadline IS NOT NULL
+ AND s.deadline::date < CURRENT_DATE
+ AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
+ THEN 1
+ ELSE 0
+ END AS overdue_tasks
+ FROM sag_sager s
+ WHERE s.deleted_at IS NULL
+ AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
+ AND (
+ LOWER(COALESCE(s.template_key, '')) IN ('projekt', 'project')
+ OR LOWER(COALESCE(s.project_task_type, '')) = 'project'
+ OR s.project_id IS NOT NULL
+ )
+ ORDER BY s.deadline ASC NULLS LAST, s.created_at DESC
+ LIMIT %s
+ """,
+ (limit,),
+ ) or []
+
+ fallback: list[Dict[str, Any]] = []
+ for row in case_rows:
+ item = dict(row)
+ item.update(MissionService._compute_project_risk(item))
+ fallback.append(item)
+ return fallback
+
+ @staticmethod
+ def get_projects(limit: int = 120) -> list[Dict[str, Any]]:
+ if not MissionService._table_exists("mission_projects"):
+ return MissionService._get_projects_from_cases(limit)
+
+ rows = execute_query(
+ """
+ SELECT
+ p.id,
+ p.name,
+ p.description,
+ p.status,
+ p.score,
+ p.started_at,
+ p.ended_at,
+ p.updated_at,
+ COUNT(DISTINCT m.id) FILTER (
+ WHERE m.status NOT IN ('completed', 'cancelled')
+ ) AS active_milestones,
+ COUNT(DISTINCT m.id) FILTER (
+ WHERE m.target_date IS NOT NULL
+ AND m.target_date < CURRENT_DATE
+ AND m.status NOT IN ('completed', 'cancelled')
+ ) AS overdue_milestones,
+ COUNT(DISTINCT b.id) FILTER (
+ WHERE b.status NOT IN ('resolved', 'cancelled')
+ ) AS open_blockers,
+ COUNT(DISTINCT s.id) FILTER (
+ WHERE s.id IS NOT NULL
+ AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
+ ) AS open_tasks,
+ COUNT(DISTINCT s.id) FILTER (
+ WHERE s.id IS NOT NULL
+ AND s.deadline IS NOT NULL
+ AND s.deadline::date = CURRENT_DATE
+ AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
+ ) AS due_today_tasks,
+ COUNT(DISTINCT s.id) FILTER (
+ WHERE s.id IS NOT NULL
+ AND s.deadline IS NOT NULL
+ AND s.deadline::date < CURRENT_DATE
+ AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
+ ) AS overdue_tasks
+ FROM mission_projects p
+ LEFT JOIN mission_project_milestones m ON m.project_id = p.id
+ LEFT JOIN mission_project_blockers b ON b.project_id = p.id
+ LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL
+ GROUP BY p.id, p.name, p.description, p.status, p.score, p.started_at, p.ended_at, p.updated_at
+ ORDER BY p.updated_at DESC, p.id DESC
+ LIMIT %s
+ """,
+ (limit,),
+ ) or []
+
+ result: list[Dict[str, Any]] = []
+ for row in rows:
+ item = dict(row)
+ item.update(MissionService._compute_project_risk(item))
+ result.append(item)
+
+ # Important fallback: migration may have created mission_projects but with zero rows.
+ if not result:
+ return MissionService._get_projects_from_cases(limit)
+ return result
+
+ @staticmethod
+ def get_project_detail(project_id: int) -> Optional[Dict[str, Any]]:
+ if not MissionService._table_exists("mission_projects"):
+ return None
+
+ rows = MissionService.get_projects(limit=200)
+ project = next((item for item in rows if int(item.get("id") or 0) == int(project_id)), None)
+ if not project:
+ return None
+
+ milestones = execute_query(
+ """
+ SELECT id, project_id, title, description, status, target_date, created_at, updated_at
+ FROM mission_project_milestones
+ WHERE project_id = %s
+ ORDER BY target_date ASC NULLS LAST, id DESC
+ """,
+ (project_id,),
+ ) or []
+
+ blockers = execute_query(
+ """
+ SELECT id, project_id, title, description, status, severity, resolved_at, created_at, updated_at
+ FROM mission_project_blockers
+ WHERE project_id = %s
+ ORDER BY
+ CASE severity
+ WHEN 'critical' THEN 4
+ WHEN 'high' THEN 3
+ WHEN 'medium' THEN 2
+ WHEN 'low' THEN 1
+ ELSE 0
+ END DESC,
+ id DESC
+ """,
+ (project_id,),
+ ) or []
+
+ tasks = execute_query(
+ """
+ SELECT
+ s.id,
+ s.titel,
+ s.status,
+ s.priority,
+ s.deadline,
+ s.ansvarlig_bruger_id,
+ s.project_milestone_id,
+ s.is_project_blocker,
+ s.project_task_type,
+ s.created_at,
+ COALESCE(ts.open_todo_count, 0) AS open_todo_count,
+ COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles
+ FROM sag_sager s
+ LEFT JOIN LATERAL (
+ SELECT
+ COUNT(*) FILTER (
+ WHERE t.deleted_at IS NULL
+ AND COALESCE(t.is_done, FALSE) = FALSE
+ ) AS open_todo_count,
+ ARRAY_REMOVE(
+ ARRAY_AGG(
+ CASE
+ WHEN t.deleted_at IS NULL
+ AND COALESCE(t.is_done, FALSE) = FALSE
+ THEN t.title
+ END
+ ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
+ ),
+ NULL
+ ) AS open_todo_titles
+ FROM sag_todo_steps t
+ WHERE t.sag_id = s.id
+ ) ts ON TRUE
+ WHERE s.deleted_at IS NULL
+ AND s.project_id = %s
+ ORDER BY
+ s.deadline ASC NULLS LAST,
+ s.created_at DESC
+ LIMIT 200
+ """,
+ (project_id,),
+ ) or []
+
+ # Fallback for case-backed projects: fetch directly related/under cases from relation table.
+ # This is used when a project is a case of type project/projekt and tasks are linked as case relations.
+ if not tasks and MissionService._table_exists("sag_relationer"):
+ tasks = execute_query(
+ """
+ WITH related AS (
+ SELECT
+ sr.målsag_id AS task_id,
+ sr.relationstype AS relation_type
+ FROM sag_relationer sr
+ WHERE sr.deleted_at IS NULL
+ AND sr.kilde_sag_id = %s
+
+ UNION ALL
+
+ SELECT
+ sr.kilde_sag_id AS task_id,
+ sr.relationstype AS relation_type
+ FROM sag_relationer sr
+ WHERE sr.deleted_at IS NULL
+ AND sr.målsag_id = %s
+ )
+ SELECT
+ s.id,
+ s.titel,
+ s.status,
+ s.priority,
+ s.deadline,
+ s.ansvarlig_bruger_id,
+ s.project_milestone_id,
+ s.is_project_blocker,
+ COALESCE(NULLIF(TRIM(s.project_task_type), ''), r.relation_type) AS project_task_type,
+ s.created_at,
+ COALESCE(ts.open_todo_count, 0) AS open_todo_count,
+ COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles
+ FROM related r
+ JOIN sag_sager s ON s.id = r.task_id
+ LEFT JOIN LATERAL (
+ SELECT
+ COUNT(*) FILTER (
+ WHERE t.deleted_at IS NULL
+ AND COALESCE(t.is_done, FALSE) = FALSE
+ ) AS open_todo_count,
+ ARRAY_REMOVE(
+ ARRAY_AGG(
+ CASE
+ WHEN t.deleted_at IS NULL
+ AND COALESCE(t.is_done, FALSE) = FALSE
+ THEN t.title
+ END
+ ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
+ ),
+ NULL
+ ) AS open_todo_titles
+ FROM sag_todo_steps t
+ WHERE t.sag_id = s.id
+ ) ts ON TRUE
+ WHERE s.deleted_at IS NULL
+ AND s.id <> %s
+ ORDER BY
+ s.deadline ASC NULLS LAST,
+ s.created_at DESC
+ LIMIT 200
+ """,
+ (project_id, project_id, project_id),
+ ) or []
+
+ return {
+ **project,
+ "milestones": [dict(row) for row in milestones],
+ "blockers": [dict(row) for row in blockers],
+ "tasks": [dict(row) for row in tasks],
+ }
+
+ @staticmethod
+ def get_projects_state_payload(limit: int = 120) -> Dict[str, Any]:
+ projects = MissionService.get_projects(limit)
+ workload = MissionService.get_project_workload(limit)
+
+ details: Dict[str, Any] = {}
+ for project in projects:
+ project_id = int(project.get("id") or 0)
+ if project_id <= 0:
+ continue
+ detail = MissionService.get_project_detail(project_id)
+ if detail:
+ details[str(project_id)] = detail
+
+ total = len(projects)
+ active = len([p for p in projects if str(p.get("status") or "").lower() == "active"])
+ high_risk = len([p for p in projects if p.get("risk_level") in {"high", "critical"}])
+ blocked = len([p for p in projects if int(p.get("open_blockers") or 0) > 0])
+ due_today = sum(int(p.get("due_today_tasks") or 0) for p in projects)
+
+ return {
+ "summary": {
+ "total": total,
+ "active": active,
+ "high_risk": high_risk,
+ "blocked": blocked,
+ "due_today": due_today,
+ },
+ "projects": projects,
+ "workload": workload,
+ "details": details,
+ }
+
+ @staticmethod
+ def create_project(payload: Dict[str, Any], actor_user_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
+ if not MissionService._table_exists("mission_projects"):
+ return None
+
+ name = str(payload.get("name") or "").strip()
+ if not name:
+ return None
+
+ description = str(payload.get("description") or "").strip() or None
+ status = str(payload.get("status") or "planned").strip().lower()
+ if status not in {"planned", "active", "paused", "completed", "cancelled"}:
+ status = "planned"
+
+ try:
+ score = int(payload.get("score") or 0)
+ except (TypeError, ValueError):
+ score = 0
+
+ started_at = payload.get("started_at")
+ ended_at = payload.get("ended_at")
+
+ rows = execute_query(
+ """
+ INSERT INTO mission_projects (
+ name,
+ description,
+ status,
+ score,
+ started_at,
+ ended_at,
+ created_by,
+ payload,
+ updated_at
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
+ RETURNING id
+ """,
+ (
+ name,
+ description,
+ status,
+ score,
+ started_at,
+ ended_at,
+ actor_user_id,
+ json.dumps(payload.get("payload") or {}),
+ ),
+ ) or []
+
+ if not rows:
+ return None
+ return MissionService.get_project_detail(int(rows[0].get("id")))
+
+ @staticmethod
+ def update_project(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ if not MissionService._table_exists("mission_projects"):
+ return None
+
+ updates: list[str] = []
+ values: list[Any] = []
+
+ if "name" in payload:
+ name = str(payload.get("name") or "").strip()
+ if name:
+ updates.append("name = %s")
+ values.append(name)
+
+ if "description" in payload:
+ updates.append("description = %s")
+ values.append(str(payload.get("description") or "").strip() or None)
+
+ if "status" in payload:
+ status = str(payload.get("status") or "").strip().lower()
+ if status in {"planned", "active", "paused", "completed", "cancelled"}:
+ updates.append("status = %s")
+ values.append(status)
+
+ if "score" in payload:
+ try:
+ updates.append("score = %s")
+ values.append(int(payload.get("score") or 0))
+ except (TypeError, ValueError):
+ pass
+
+ if "started_at" in payload:
+ updates.append("started_at = %s")
+ values.append(payload.get("started_at"))
+
+ if "ended_at" in payload:
+ updates.append("ended_at = %s")
+ values.append(payload.get("ended_at"))
+
+ if not updates:
+ return MissionService.get_project_detail(project_id)
+
+ updates.append("updated_at = NOW()")
+ values.append(project_id)
+
+ execute_query(
+ f"""
+ UPDATE mission_projects
+ SET {', '.join(updates)}
+ WHERE id = %s
+ """,
+ tuple(values),
+ )
+
+ return MissionService.get_project_detail(project_id)
+
+ @staticmethod
+ def add_project_milestone(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ if not MissionService._table_exists("mission_project_milestones"):
+ return None
+
+ title = str(payload.get("title") or "").strip()
+ if not title:
+ return None
+
+ description = str(payload.get("description") or "").strip() or None
+ status = str(payload.get("status") or "active").strip().lower()
+ if status not in {"active", "completed", "cancelled"}:
+ status = "active"
+ target_date = payload.get("target_date")
+
+ rows = execute_query(
+ """
+ INSERT INTO mission_project_milestones (project_id, title, description, status, target_date, updated_at)
+ VALUES (%s, %s, %s, %s, %s, NOW())
+ RETURNING id, project_id, title, description, status, target_date, created_at, updated_at
+ """,
+ (project_id, title, description, status, target_date),
+ ) or []
+
+ return dict(rows[0]) if rows else None
+
+ @staticmethod
+ def update_project_milestone(project_id: int, milestone_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ if not MissionService._table_exists("mission_project_milestones"):
+ return None
+
+ updates: list[str] = []
+ values: list[Any] = []
+
+ if "title" in payload:
+ title = str(payload.get("title") or "").strip()
+ if title:
+ updates.append("title = %s")
+ values.append(title)
+
+ if "description" in payload:
+ updates.append("description = %s")
+ values.append(str(payload.get("description") or "").strip() or None)
+
+ if "status" in payload:
+ status = str(payload.get("status") or "").strip().lower()
+ if status in {"active", "completed", "cancelled"}:
+ updates.append("status = %s")
+ values.append(status)
+
+ if "target_date" in payload:
+ updates.append("target_date = %s")
+ values.append(payload.get("target_date"))
+
+ if not updates:
+ return execute_query_single(
+ """
+ SELECT id, project_id, title, description, status, target_date, created_at, updated_at
+ FROM mission_project_milestones
+ WHERE id = %s AND project_id = %s
+ """,
+ (milestone_id, project_id),
+ )
+
+ updates.append("updated_at = NOW()")
+ values.extend([milestone_id, project_id])
+ rows = execute_query(
+ f"""
+ UPDATE mission_project_milestones
+ SET {', '.join(updates)}
+ WHERE id = %s AND project_id = %s
+ RETURNING id, project_id, title, description, status, target_date, created_at, updated_at
+ """,
+ tuple(values),
+ ) or []
+
+ return dict(rows[0]) if rows else None
+
+ @staticmethod
+ def add_project_blocker(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ if not MissionService._table_exists("mission_project_blockers"):
+ return None
+
+ title = str(payload.get("title") or "").strip()
+ if not title:
+ return None
+
+ description = str(payload.get("description") or "").strip() or None
+ status = str(payload.get("status") or "open").strip().lower()
+ if status not in {"open", "in_progress", "resolved", "cancelled"}:
+ status = "open"
+
+ severity = str(payload.get("severity") or "medium").strip().lower()
+ if severity not in {"low", "medium", "high", "critical"}:
+ severity = "medium"
+
+ resolved_at = payload.get("resolved_at")
+ rows = execute_query(
+ """
+ INSERT INTO mission_project_blockers (project_id, title, description, status, severity, resolved_at, updated_at)
+ VALUES (%s, %s, %s, %s, %s, %s, NOW())
+ RETURNING id, project_id, title, description, status, severity, resolved_at, created_at, updated_at
+ """,
+ (project_id, title, description, status, severity, resolved_at),
+ ) or []
+
+ return dict(rows[0]) if rows else None
+
+ @staticmethod
+ def update_project_blocker(project_id: int, blocker_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ if not MissionService._table_exists("mission_project_blockers"):
+ return None
+
+ updates: list[str] = []
+ values: list[Any] = []
+
+ if "title" in payload:
+ title = str(payload.get("title") or "").strip()
+ if title:
+ updates.append("title = %s")
+ values.append(title)
+
+ if "description" in payload:
+ updates.append("description = %s")
+ values.append(str(payload.get("description") or "").strip() or None)
+
+ if "status" in payload:
+ status = str(payload.get("status") or "").strip().lower()
+ if status in {"open", "in_progress", "resolved", "cancelled"}:
+ updates.append("status = %s")
+ values.append(status)
+ if status == "resolved" and "resolved_at" not in payload:
+ updates.append("resolved_at = NOW()")
+
+ if "severity" in payload:
+ severity = str(payload.get("severity") or "").strip().lower()
+ if severity in {"low", "medium", "high", "critical"}:
+ updates.append("severity = %s")
+ values.append(severity)
+
+ if "resolved_at" in payload:
+ updates.append("resolved_at = %s")
+ values.append(payload.get("resolved_at"))
+
+ if not updates:
+ return execute_query_single(
+ """
+ SELECT id, project_id, title, description, status, severity, resolved_at, created_at, updated_at
+ FROM mission_project_blockers
+ WHERE id = %s AND project_id = %s
+ """,
+ (blocker_id, project_id),
+ )
+
+ updates.append("updated_at = NOW()")
+ values.extend([blocker_id, project_id])
+ rows = execute_query(
+ f"""
+ UPDATE mission_project_blockers
+ SET {', '.join(updates)}
+ WHERE id = %s AND project_id = %s
+ RETURNING id, project_id, title, description, status, severity, resolved_at, created_at, updated_at
+ """,
+ tuple(values),
+ ) or []
+
+ return dict(rows[0]) if rows else None
+
+ @staticmethod
+ def link_case_to_project(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ if not MissionService._table_exists("mission_projects") or not MissionService._table_exists("sag_sager"):
+ return None
+
+ sag_id = payload.get("sag_id")
+ if sag_id is None:
+ return None
+
+ project_exists = execute_query_single("SELECT id FROM mission_projects WHERE id = %s", (project_id,))
+ if not project_exists:
+ return None
+
+ milestone_id = payload.get("project_milestone_id")
+ is_project_blocker = bool(payload.get("is_project_blocker") or False)
+ project_task_type = str(payload.get("project_task_type") or "").strip() or None
+
+ rows = execute_query(
+ """
+ UPDATE sag_sager
+ SET
+ project_id = %s,
+ project_milestone_id = %s,
+ is_project_blocker = %s,
+ project_task_type = %s
+ WHERE id = %s
+ AND deleted_at IS NULL
+ RETURNING id, titel, status, project_id, project_milestone_id, is_project_blocker, project_task_type
+ """,
+ (project_id, milestone_id, is_project_blocker, project_task_type, sag_id),
+ ) or []
+
+ return dict(rows[0]) if rows else None
+
@staticmethod
def get_assignment_users(limit: int = 300) -> list[Dict[str, Any]]:
rows = execute_query(
@@ -495,7 +1183,16 @@ class MissionService:
created_at ASC
) AS case_list
FROM active_cases
- GROUP BY assignee_key, assignee_name
+ GROUP BY
+ COALESCE(
+ CASE
+ WHEN ansvarlig_bruger_id IS NOT NULL THEN CONCAT('user:', ansvarlig_bruger_id::text)
+ WHEN assigned_group_id IS NOT NULL THEN CONCAT('group:', assigned_group_id::text)
+ ELSE 'unassigned'
+ END,
+ 'unassigned'
+ ),
+ COALESCE(assignee_name, group_name, 'Ufordelt')
)
SELECT
assignee_key,
@@ -603,6 +1300,7 @@ class MissionService:
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
"important_cases": MissionService._safe("important_cases", lambda: MissionService.get_important_cases(80), []),
+ "projects": MissionService._safe("projects", lambda: MissionService.get_projects_state_payload(120), {"summary": {}, "projects": [], "workload": []}),
"day_unassigned_cases": MissionService._safe("day_unassigned_cases", lambda: MissionService.get_day_unassigned_cases(120), []),
"day_agent_workloads": MissionService._safe("day_agent_workloads", lambda: MissionService.get_day_agent_workloads(60, 20), []),
"assignment_users": MissionService._safe("assignment_users", lambda: MissionService.get_assignment_users(300), []),
diff --git a/app/dashboard/backend/router.py b/app/dashboard/backend/router.py
index e804a5d..67ed47e 100644
--- a/app/dashboard/backend/router.py
+++ b/app/dashboard/backend/router.py
@@ -198,11 +198,35 @@ async def search_sag(q: str):
CAST(s.id AS TEXT) ILIKE %s OR
s.titel ILIKE %s OR
s.beskrivelse ILIKE %s OR
- c.name ILIKE %s
+ c.name ILIKE %s OR
+ EXISTS (
+ SELECT 1
+ FROM entity_tags et
+ JOIN tags t ON t.id = et.tag_id
+ WHERE et.entity_type = 'case'
+ AND et.entity_id = s.id
+ AND t.name ILIKE %s
+ ) OR
+ EXISTS (
+ SELECT 1
+ FROM sag_tags st
+ WHERE st.sag_id = s.id
+ AND st.deleted_at IS NULL
+ AND st.tag_navn ILIKE %s
+ ) OR
+ EXISTS (
+ SELECT 1
+ FROM sag_buzzwords sb
+ JOIN buzzwords b ON b.id = sb.buzzword_id
+ WHERE sb.sag_id = s.id
+ AND sb.deleted_at IS NULL
+ AND b.deleted_at IS NULL
+ AND b.word ILIKE %s
+ )
)
ORDER BY s.created_at DESC
LIMIT 20
- """, (search_term, search_term, search_term, search_term))
+ """, (search_term, search_term, search_term, search_term, search_term, search_term, search_term))
return sager or []
except Exception as e:
diff --git a/app/dashboard/backend/views.py b/app/dashboard/backend/views.py
index 7924dd6..7e7365e 100644
--- a/app/dashboard/backend/views.py
+++ b/app/dashboard/backend/views.py
@@ -108,7 +108,14 @@ def _sanitize_mission_next(value: str) -> str:
if not value:
return "/dashboard/mission-control"
candidate = value.strip()
- if candidate == "/dashboard/mission-control":
+ if candidate in {
+ "/dashboard/mission-control",
+ "/dashboard/mission-control/",
+ "/dashboard/mission-control/projects",
+ "/dashboard/mission-control/projects/",
+ "/dashboard/mission-control.old",
+ "/dashboard/mission-control.old/",
+ }:
return candidate
if candidate.startswith("/api/v1/mission/"):
return candidate
@@ -479,9 +486,12 @@ async def clear_default_dashboard_get_fallback():
@router.get("/dashboard/mission-control", response_class=HTMLResponse)
async def mission_control_dashboard(request: Request):
return templates.TemplateResponse(
- "dashboard/frontend/mission_control.html",
+ "dashboard/frontend/mission_control_v2.html",
{
"request": request,
+ "hide_top_nav": True,
+ "mission_control_version": "v2",
+ "mission_initial_view": "project",
}
)
@@ -490,3 +500,38 @@ async def mission_control_dashboard(request: Request):
async def mission_control_dashboard_trailing_slash(request: Request):
return await mission_control_dashboard(request)
+
+@router.get("/dashboard/mission-control/projects", response_class=HTMLResponse)
+async def mission_control_projects_dashboard(request: Request):
+ return templates.TemplateResponse(
+ "dashboard/frontend/mission_control_v2.html",
+ {
+ "request": request,
+ "hide_top_nav": True,
+ "mission_control_version": "v2",
+ "mission_initial_view": "project",
+ "mission_project_only": True,
+ }
+ )
+
+
+@router.get("/dashboard/mission-control/projects/", response_class=HTMLResponse)
+async def mission_control_projects_dashboard_trailing_slash(request: Request):
+ return await mission_control_projects_dashboard(request)
+
+
+@router.get("/dashboard/mission-control.old", response_class=HTMLResponse)
+async def mission_control_dashboard_legacy(request: Request):
+ return templates.TemplateResponse(
+ "dashboard/frontend/mission_control_legacy.html",
+ {
+ "request": request,
+ "mission_control_version": "v1",
+ }
+ )
+
+
+@router.get("/dashboard/mission-control.old/", response_class=HTMLResponse)
+async def mission_control_dashboard_legacy_trailing_slash(request: Request):
+ return await mission_control_dashboard_legacy(request)
+
diff --git a/app/dashboard/frontend/mission_control_legacy.html b/app/dashboard/frontend/mission_control_legacy.html
new file mode 100644
index 0000000..866745e
--- /dev/null
+++ b/app/dashboard/frontend/mission_control_legacy.html
@@ -0,0 +1,1953 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Mission Control - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
Driftsstatus
+
Ingen aktive driftsalarmer
+
+
+
+
+
Ingen aktive opkald
+
Mission overvager opkald og opdaterer live.
+
+
+
+
Temperatur sensorer
+
+
+
+
+
+
+
Kamera (startvisning)
+ Ikke aktiv
+
+
+
Feed er ikke aktiveret endnu.
+
+
+
+
+
+
+
Vigtige sager
+
+
Sag
+
Type
+
Status
+
Deadline
+
+
+
+
+
+
+
+
+
+
Dagen
+
Morgenmøde-overblik: nye ikke-tildelte sager og arbejdsfordeling i dag.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Deadlines pr. medarbejder
+
+
Medarbejder
+
I dag
+
Overskredet
+
+
+
+
+
+
+
+
+
+
+
Kamera spotlight
+ Ikke aktiv
+
+
+ Spotlight varighed
+
+
+
+
+
+
Feed er ikke aktiveret endnu.
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/dashboard/frontend/mission_control_v2.html b/app/dashboard/frontend/mission_control_v2.html
new file mode 100644
index 0000000..8ec16a2
--- /dev/null
+++ b/app/dashboard/frontend/mission_control_v2.html
@@ -0,0 +1,2455 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Mission Control - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+{% set project_only = mission_project_only | default(false) %}
+
+
+
+
+
+
+
+
Driftsstatus
+
Ingen aktive driftsalarmer
+
+
+
+
+
Ingen aktive opkald
+
Mission overvager opkald og opdaterer live.
+
+
+
+
Temperatur sensorer
+
+
+
+
+
+
+
Kamera (startvisning)
+ Ikke aktiv
+
+
+
Feed er ikke aktiveret endnu.
+
+
+
+
+
+
+
Vigtige sager
+
+
+
Sag
+
Type
+
Status
+
Deadline
+
+
+
+
+
+
+
+
+
+
Projekt view
+
Viser projekter fra projects payload med risiko, status, workload og deadlines.
+
+
+
Projekt
+
Risiko
+
Status
+
Workload
+
Deadline
+
+
+
+
+
+
+
+
Projektdetalje
+
Tryk på et projekt for at se mere info.
+
+
+
+
+
+
+
+
+
+
+
+
Dagen
+
Morgenmøde-overblik: nye ikke-tildelte sager og arbejdsfordeling i dag.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Deadlines pr. medarbejder
+
+
Medarbejder
+
I dag
+
Overskredet
+
+
+
+
+
+
+
+
+
+
+
Kamera spotlight
+ Ikke aktiv
+
+
+ Spotlight varighed
+
+
+
+
+
+
Feed er ikke aktiveret endnu.
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py
index 1ff4cbf..d6aec6a 100644
--- a/app/emails/backend/router.py
+++ b/app/emails/backend/router.py
@@ -5,11 +5,15 @@ API endpoints for email viewing, classification, and rule management
import logging
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
-from typing import List, Optional, Dict
+from typing import List, Optional, Dict, Any
from pydantic import BaseModel
from datetime import datetime, date
import unicodedata
+import html
+import re
+from html.parser import HTMLParser
+from app.core.config import settings
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.services.email_processor_service import EmailProcessorService
from app.services.email_workflow_service import email_workflow_service
@@ -126,6 +130,86 @@ def _is_supplier_case_type(case_type: Optional[str]) -> bool:
return "indk" in value or "leverand" in value or "supplier" in value
+class _SafeDescriptionHtmlSanitizer(HTMLParser):
+ """Allow-list HTML sanitizer for case descriptions created from email content."""
+
+ _ALLOWED_TAGS = {
+ "b", "strong", "i", "em", "u",
+ "p", "br", "hr",
+ "ul", "ol", "li",
+ "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption",
+ "a",
+ }
+ _VOID_TAGS = {"br", "hr"}
+ _ALLOWED_ATTRS = {
+ "a": {"href", "title", "target", "rel"},
+ "th": {"colspan", "rowspan"},
+ "td": {"colspan", "rowspan"},
+ }
+
+ def __init__(self):
+ super().__init__(convert_charrefs=True)
+ self._parts: List[str] = []
+
+ def handle_starttag(self, tag, attrs):
+ tag = (tag or "").lower()
+ if tag not in self._ALLOWED_TAGS:
+ return
+
+ attr_allow = self._ALLOWED_ATTRS.get(tag, set())
+ safe_attrs = []
+ for key, value in attrs or []:
+ key_l = str(key or "").lower()
+ if key_l not in attr_allow:
+ continue
+ safe_value = str(value or "").strip()
+ if key_l == "href":
+ href_l = safe_value.lower()
+ if href_l.startswith(("javascript:", "data:", "vbscript:")):
+ continue
+ safe_attrs.append(f'{key_l}="{html.escape(safe_value, quote=True)}"')
+
+ attrs_html = (" " + " ".join(safe_attrs)) if safe_attrs else ""
+ self._parts.append(f"<{tag}{attrs_html}>")
+
+ def handle_endtag(self, tag):
+ tag = (tag or "").lower()
+ if tag not in self._ALLOWED_TAGS or tag in self._VOID_TAGS:
+ return
+ self._parts.append(f"{tag}>")
+
+ def handle_data(self, data):
+ self._parts.append(html.escape(data or ""))
+
+ def handle_entityref(self, name):
+ self._parts.append(f"&{name};")
+
+ def handle_charref(self, name):
+ self._parts.append(f"{name};")
+
+ def get_html(self) -> str:
+ return "".join(self._parts).strip()
+
+
+def _sanitize_case_description_html(value: Optional[str]) -> str:
+ raw = str(value or "").strip()
+ if not raw:
+ return ""
+
+ # Fast path: plain text stays plain text.
+ if "<" not in raw and ">" not in raw:
+ return raw
+
+ sanitizer = _SafeDescriptionHtmlSanitizer()
+ try:
+ sanitizer.feed(raw)
+ sanitizer.close()
+ return sanitizer.get_html()
+ except Exception:
+ # Fallback to escaped text if parsing fails.
+ return html.escape(raw)
+
+
def _extract_domain_from_email(email: Optional[str]) -> str:
sender = str(email or "").strip().lower()
if "@" not in sender:
@@ -435,6 +519,124 @@ class RewriteEmailTextResponse(BaseModel):
rewritten_text: str
model: Optional[str] = None
endpoint: Optional[str] = None
+
+
+def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
+ """Evaluate which workflows would run for an email without executing them."""
+ classification = str(email_data.get('classification') or '').strip().lower()
+ confidence = float(email_data.get('confidence_score') or 0.0)
+ sender = str(email_data.get('sender_email') or '')
+ subject = str(email_data.get('subject') or '')
+
+ workflows = execute_query(
+ """
+ SELECT id, name, description, classification_trigger, sender_pattern, subject_pattern,
+ confidence_threshold, workflow_steps, priority, enabled, stop_on_match
+ FROM email_workflows
+ WHERE enabled = true
+ ORDER BY priority ASC
+ """
+ )
+
+ candidates = []
+ matching = []
+
+ for wf in workflows:
+ trigger = str(wf.get('classification_trigger') or '').strip().lower()
+ min_conf = float(wf.get('confidence_threshold') or 0.0)
+ sender_pattern = wf.get('sender_pattern')
+ subject_pattern = wf.get('subject_pattern')
+
+ reasons = []
+ matches = True
+
+ if trigger != classification:
+ matches = False
+ reasons.append(f"classification_mismatch ({trigger} != {classification or 'none'})")
+
+ if min_conf > confidence:
+ matches = False
+ reasons.append(f"confidence_too_low ({confidence:.2f} < {min_conf:.2f})")
+
+ sender_ok = True
+ if sender_pattern:
+ try:
+ sender_ok = bool(re.search(str(sender_pattern), sender, re.IGNORECASE))
+ except re.error as e:
+ sender_ok = False
+ reasons.append(f"invalid_sender_pattern ({e})")
+ if not sender_ok and "invalid_sender_pattern" not in " ".join(reasons):
+ matches = False
+ reasons.append("sender_pattern_no_match")
+
+ subject_ok = True
+ if subject_pattern:
+ try:
+ subject_ok = bool(re.search(str(subject_pattern), subject, re.IGNORECASE))
+ except re.error as e:
+ subject_ok = False
+ reasons.append(f"invalid_subject_pattern ({e})")
+ if not subject_ok and "invalid_subject_pattern" not in " ".join(reasons):
+ matches = False
+ reasons.append("subject_pattern_no_match")
+
+ steps = wf.get('workflow_steps')
+ steps_total = len(steps) if isinstance(steps, list) else 0
+ row = {
+ 'id': wf.get('id'),
+ 'name': wf.get('name'),
+ 'classification_trigger': wf.get('classification_trigger'),
+ 'confidence_threshold': min_conf,
+ 'priority': wf.get('priority'),
+ 'stop_on_match': bool(wf.get('stop_on_match')),
+ 'sender_pattern': sender_pattern,
+ 'subject_pattern': subject_pattern,
+ 'steps_total': steps_total,
+ 'matches': bool(matches),
+ 'reasons': reasons,
+ }
+ candidates.append(row)
+ if matches:
+ matching.append(row)
+
+ system_matches = []
+ if classification == 'bankruptcy':
+ system_matches.append({
+ 'code': 'system_bankruptcy_analysis',
+ 'name': 'System: Bankruptcy Analysis',
+ 'matches': True,
+ 'reason': 'classification == bankruptcy',
+ })
+
+ has_hint = email_workflow_service.has_helpdesk_routing_hint(email_data)
+ hard_skip = {'newsletter', 'spam'}
+ should_try_helpdesk = (
+ classification not in hard_skip
+ and (
+ classification not in email_workflow_service.HELPDESK_SKIP_CLASSIFICATIONS
+ or has_hint
+ )
+ )
+ system_matches.append({
+ 'code': 'system_helpdesk_routing',
+ 'name': 'System: Helpdesk SAG routing',
+ 'matches': bool(should_try_helpdesk),
+ 'reason': 'hint_or_allowed_classification' if should_try_helpdesk else 'classification_in_skip_list',
+ })
+
+ return {
+ 'email': {
+ 'id': email_data.get('id'),
+ 'classification': classification,
+ 'confidence_score': confidence,
+ 'sender_email': sender,
+ 'subject': subject,
+ },
+ 'system_matches': system_matches,
+ 'matching_workflows': matching,
+ 'workflow_candidates': candidates,
+ 'auto_run_enabled': bool(getattr(settings, 'EMAIL_WORKFLOW_AUTORUN_ENABLED', False)),
+ }
context: Optional[str] = None
@@ -787,6 +989,7 @@ async def search_sager(q: str = Query(..., min_length=1), limit: int = Query(20,
async def list_emails(
status: Optional[str] = Query(None),
classification: Optional[str] = Query(None),
+ folder: Optional[str] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(50, le=500),
offset: int = Query(0, ge=0)
@@ -804,6 +1007,10 @@ async def list_emails(
where_clauses.append("em.classification = %s")
params.append(classification)
+ if folder:
+ where_clauses.append("LOWER(COALESCE(em.folder, '')) = LOWER(%s)")
+ params.append(folder)
+
if q:
where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s)")
search_term = f"%{q}%"
@@ -875,8 +1082,16 @@ async def get_email(email_id: int, request: Request):
linked_case_id = email_data.get("linked_case_id")
can_mark_read = _can_user_mark_case_email_read(user_id, linked_case_id)
- if not bool(email_data.get("is_read")) and can_mark_read:
- update_query = "UPDATE email_messages SET is_read = true WHERE id = %s"
+ folder_value = str(email_data.get("folder") or "").strip().lower()
+ status_value = str(email_data.get("status") or "").strip().lower()
+ is_outgoing = folder_value.startswith("sent") or status_value == "sent"
+
+ if is_outgoing and not bool(email_data.get("is_read")):
+ update_query = "UPDATE email_messages SET is_read = true, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
+ execute_update(update_query, (email_id,))
+ email_data["is_read"] = True
+ elif not bool(email_data.get("is_read")) and can_mark_read:
+ update_query = "UPDATE email_messages SET is_read = true, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
execute_update(update_query, (email_id,))
email_data["is_read"] = True
@@ -897,12 +1112,19 @@ async def update_email_read_state(email_id: int, payload: EmailReadStateUpdate,
"""
try:
row = execute_query_single(
- "SELECT id, linked_case_id, is_read FROM email_messages WHERE id = %s AND deleted_at IS NULL",
+ "SELECT id, linked_case_id, is_read, folder, status FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,),
)
if not row:
raise HTTPException(status_code=404, detail="Email not found")
+ folder_value = str(row.get("folder") or "").strip().lower()
+ status_value = str(row.get("status") or "").strip().lower()
+ is_outgoing = folder_value.startswith("sent") or status_value == "sent"
+
+ if is_outgoing and payload.is_read is False:
+ raise HTTPException(status_code=400, detail="Udgaaende emails kan ikke markeres som ulaest")
+
user_id = getattr(request.state, "user_id", None)
if payload.is_read:
can_mark_read = _can_user_mark_case_email_read(user_id, row.get("linked_case_id"))
@@ -1126,7 +1348,8 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
_upsert_domain_mapping(sender_domain, int(customer_id), "supplier_auto")
titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip()
- beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
+ beskrivelse_raw = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
+ beskrivelse = _sanitize_case_description_html(beskrivelse_raw)
template_key = requested_case_type[:50]
priority = (payload.priority or 'normal').strip().lower()
@@ -1942,17 +2165,33 @@ async def reprocess_email(email_id: int):
@router.post("/emails/process")
-async def process_emails():
- """Manually trigger email processing"""
+async def process_emails(
+ limit: Optional[int] = Query(default=None, ge=1, le=500),
+ folder: Optional[str] = Query(default=None, description="Optional mailbox folder override for this run"),
+):
+ """Manually trigger email processing. Supports one-off folder testing via query params."""
try:
+ clean_folder = str(folder or '').strip() or None
+ if clean_folder and not settings.EMAIL_PROCESS_ALLOW_FOLDER_OVERRIDE:
+ raise HTTPException(status_code=403, detail="Folder override is disabled by configuration")
+
processor = EmailProcessorService()
- stats = await processor.process_inbox()
+ stats = await processor.process_inbox(
+ limit_override=limit,
+ folder_override=clean_folder,
+ force_run=True,
+ )
return {
"success": True,
"message": "Email processing completed",
- "stats": stats
+ "stats": stats,
+ "folder": clean_folder or settings.IMAP_TEST_FOLDER or settings.IMAP_FOLDER,
+ "limit": limit or settings.EMAIL_MAX_FETCH_PER_RUN,
}
+
+ except HTTPException:
+ raise
except Exception as e:
logger.error(f"❌ Email processing failed: {e}")
@@ -2616,6 +2855,7 @@ async def execute_workflows_for_email(email_id: int):
# Get email data
query = """
SELECT id, message_id, subject, sender_email, sender_name, body_text,
+ body_html, in_reply_to, email_references, thread_key,
classification, confidence_score, status
FROM email_messages
WHERE id = %s AND deleted_at IS NULL
@@ -2639,6 +2879,44 @@ async def execute_workflows_for_email(email_id: int):
raise HTTPException(status_code=500, detail=str(e))
+@router.get("/emails/{email_id}/workflow-preview")
+async def preview_workflows_for_email(email_id: int):
+ """Preview which workflows would match an email without executing them."""
+ try:
+ query = """
+ SELECT id, message_id, subject, sender_email, sender_name, body_text,
+ body_html, in_reply_to, email_references, thread_key,
+ classification, confidence_score, status
+ FROM email_messages
+ WHERE id = %s AND deleted_at IS NULL
+ """
+ email_result = execute_query(query, (email_id,))
+
+ if not email_result:
+ raise HTTPException(status_code=404, detail="Email not found")
+
+ email_data = email_result[0]
+ return _compute_workflow_preview(email_data)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("❌ Error previewing workflows: %s", e)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/emails/{email_id}/auto-run-workflows")
+async def auto_run_workflows_for_email(email_id: int):
+ """Feature-flagged auto-run endpoint for production-ready rollout."""
+ if not bool(getattr(settings, 'EMAIL_WORKFLOW_AUTORUN_ENABLED', False)):
+ raise HTTPException(
+ status_code=403,
+ detail="Auto-run er ikke aktiveret endnu (EMAIL_WORKFLOW_AUTORUN_ENABLED=false)",
+ )
+
+ return await execute_workflows_for_email(email_id)
+
+
@router.get("/workflow-executions", response_model=List[WorkflowExecution])
async def list_workflow_executions(
workflow_id: Optional[int] = Query(None),
diff --git a/app/emails/frontend/emails_v2.html b/app/emails/frontend/emails_v2.html
new file mode 100644
index 0000000..2758ac3
--- /dev/null
+++ b/app/emails/frontend/emails_v2.html
@@ -0,0 +1,1287 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Email v2 - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Klar
+
+
+
+
+
+
+
+ Vælg en email fra listen
+
+ Ingen email valgt
+
+
+
+
+
+ Vælg en email for handlinger
+
+ Ingen email valgt
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/app/emails/frontend/views.py b/app/emails/frontend/views.py
index 818ed09..ed90868 100644
--- a/app/emails/frontend/views.py
+++ b/app/emails/frontend/views.py
@@ -20,5 +20,23 @@ async def emails_page(request: Request):
"""Email management UI - 3-column modern email interface"""
return templates.TemplateResponse(
"emails/frontend/emails.html",
- {"request": request}
+ {"request": request, "email_ui_version": "v1"}
+ )
+
+
+@router.get("/emails/v1", response_class=HTMLResponse)
+async def emails_page_v1(request: Request):
+ """Email management UI v1 (legacy/stable)."""
+ return templates.TemplateResponse(
+ "emails/frontend/emails.html",
+ {"request": request, "email_ui_version": "v1"}
+ )
+
+
+@router.get("/emails/v2", response_class=HTMLResponse)
+async def emails_page_v2(request: Request):
+ """Email management UI v2 (simplified workflow)."""
+ return templates.TemplateResponse(
+ "emails/frontend/emails_v2.html",
+ {"request": request, "email_ui_version": "v2"}
)
diff --git a/app/modules/orders/templates/list.html b/app/modules/orders/templates/list.html
index 24da15c..7fd3b2b 100644
--- a/app/modules/orders/templates/list.html
+++ b/app/modules/orders/templates/list.html
@@ -61,30 +61,91 @@
font-size: 0.85rem;
font-weight: 600;
}
+ .ordre-top-panel {
+ background: linear-gradient(145deg, rgba(var(--accent-rgb, 15, 76, 117), 0.08), rgba(var(--accent-rgb, 15, 76, 117), 0.02));
+ border: 1px solid rgba(var(--accent-rgb, 15, 76, 117), 0.16);
+ border-radius: 14px;
+ padding: 1rem 1.1rem;
+ margin-bottom: 1rem;
+ }
+ .ordre-top-title {
+ margin: 0;
+ color: var(--text-primary);
+ font-weight: 700;
+ font-size: 1.35rem;
+ }
+ .ordre-top-subtitle {
+ margin: 0.2rem 0 0;
+ color: var(--text-secondary);
+ font-size: 0.94rem;
+ }
+ .ordre-top-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ align-items: center;
+ }
+ .ordre-filter-wrap {
+ min-width: 190px;
+ }
+ .ordre-btn {
+ height: 38px;
+ border-radius: 10px;
+ font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ white-space: nowrap;
+ }
+ .table-icon-btn {
+ width: 31px;
+ height: 31px;
+ border-radius: 8px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ }
+ @media (max-width: 991.98px) {
+ .ordre-top-actions {
+ justify-content: flex-start;
+ }
+ .ordre-filter-wrap {
+ min-width: 100%;
+ }
+ }
{% endblock %}
{% block content %}
-
-
-
Ordre
-
Oversigt over alle ordre
-
-
-
Opret ny ordre
-
-
Valgte: 0
-
-
-
+
+
+
+
Ordre
+
Samlet overblik, konsolidering og sync-status for alle ordrekladder
+
+
+
+
Opret ny ordre
+
+
+
+
Valgte: 0
+
+
+
+
+
@@ -227,21 +288,21 @@
-
-
+
-
+
|
diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py
index 12240a6..1130065 100644
--- a/app/modules/sag/backend/router.py
+++ b/app/modules/sag/backend/router.py
@@ -324,6 +324,14 @@ class DirectPrintOverrideRequest(BaseModel):
hardware_id: Optional[int] = None
+class SagBuzzwordCreateRequest(BaseModel):
+ buzzword: str = Field(..., min_length=1, max_length=120)
+
+
+class SagBuzzwordSelectionRequest(BaseModel):
+ selected_text: str = Field(..., min_length=1, max_length=2000)
+
+
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
cleaned: List[str] = []
for value in values or []:
@@ -344,6 +352,101 @@ def _normalize_message_id_token(value: Optional[str]) -> Optional[str]:
return normalized or None
+def _assert_sag_exists(sag_id: int) -> None:
+ exists = execute_query_single(
+ "SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
+ (sag_id,),
+ )
+ if not exists:
+ raise HTTPException(status_code=404, detail="Case not found")
+
+
+def _normalize_buzzword_text(value: Optional[str]) -> str:
+ raw = str(value or "")
+ normalized = " ".join(raw.strip().lower().split())
+ if not normalized:
+ raise HTTPException(status_code=400, detail="buzzword is required")
+ if len(normalized) > 120:
+ raise HTTPException(status_code=400, detail="buzzword is too long")
+ return normalized
+
+
+def _ensure_buzzword(normalized_word: str) -> dict:
+ existing = execute_query_single(
+ "SELECT id, word FROM buzzwords WHERE word = %s AND deleted_at IS NULL",
+ (normalized_word,),
+ )
+ if existing:
+ return existing
+
+ revived = execute_query(
+ """
+ UPDATE buzzwords
+ SET deleted_at = NULL, created_at = NOW()
+ WHERE id = (
+ SELECT id FROM buzzwords
+ WHERE word = %s AND deleted_at IS NOT NULL
+ ORDER BY deleted_at DESC NULLS LAST, id DESC
+ LIMIT 1
+ )
+ RETURNING id, word
+ """,
+ (normalized_word,),
+ )
+ if revived:
+ return revived[0]
+
+ inserted = execute_query(
+ "INSERT INTO buzzwords (word) VALUES (%s) RETURNING id, word",
+ (normalized_word,),
+ )
+ if not inserted:
+ raise HTTPException(status_code=500, detail="Failed to create buzzword")
+ return inserted[0]
+
+
+def _ensure_sag_buzzword_link(sag_id: int, buzzword_id: int) -> tuple[dict, str]:
+ active = execute_query_single(
+ """
+ SELECT id, sag_id, buzzword_id, created_at
+ FROM sag_buzzwords
+ WHERE sag_id = %s AND buzzword_id = %s AND deleted_at IS NULL
+ """,
+ (sag_id, buzzword_id),
+ )
+ if active:
+ return active, "existing"
+
+ revived = execute_query(
+ """
+ UPDATE sag_buzzwords
+ SET deleted_at = NULL, created_at = NOW()
+ WHERE id = (
+ SELECT id FROM sag_buzzwords
+ WHERE sag_id = %s AND buzzword_id = %s AND deleted_at IS NOT NULL
+ ORDER BY deleted_at DESC NULLS LAST, id DESC
+ LIMIT 1
+ )
+ RETURNING id, sag_id, buzzword_id, created_at
+ """,
+ (sag_id, buzzword_id),
+ )
+ if revived:
+ return revived[0], "reactivated"
+
+ created = execute_query(
+ """
+ INSERT INTO sag_buzzwords (sag_id, buzzword_id)
+ VALUES (%s, %s)
+ RETURNING id, sag_id, buzzword_id, created_at
+ """,
+ (sag_id, buzzword_id),
+ )
+ if not created:
+ raise HTTPException(status_code=500, detail="Failed to link buzzword")
+ return created[0], "created"
+
+
def _derive_thread_key_for_outbound(
payload_thread_key: Optional[str],
in_reply_to_header: Optional[str],
@@ -1709,6 +1812,124 @@ async def delete_tag(sag_id: int, tag_id: int):
raise HTTPException(status_code=500, detail="Failed to delete tag")
+# ============================================================================
+# BUZZWORDS - Case Buzzwords (Free Text)
+# ============================================================================
+
+@router.get("/sag/buzzwords/all")
+async def get_all_buzzwords(limit: int = Query(200, ge=1, le=500)):
+ """Return active buzzwords for autocomplete."""
+ try:
+ rows = execute_query(
+ """
+ SELECT id, word, created_at
+ FROM buzzwords
+ WHERE deleted_at IS NULL
+ ORDER BY word ASC
+ LIMIT %s
+ """,
+ (limit,),
+ ) or []
+ return rows
+ except Exception as e:
+ logger.error("❌ Error loading buzzwords: %s", e)
+ raise HTTPException(status_code=500, detail="Failed to load buzzwords")
+
+
+@router.get("/sag/{sag_id}/buzzwords")
+async def get_case_buzzwords(sag_id: int):
+ """Get buzzwords linked to a case."""
+ try:
+ _assert_sag_exists(sag_id)
+ rows = execute_query(
+ """
+ SELECT
+ sb.id AS relation_id,
+ sb.sag_id,
+ b.id AS buzzword_id,
+ b.word,
+ sb.created_at
+ FROM sag_buzzwords sb
+ JOIN buzzwords b ON b.id = sb.buzzword_id
+ WHERE sb.sag_id = %s
+ AND sb.deleted_at IS NULL
+ AND b.deleted_at IS NULL
+ ORDER BY b.word ASC
+ """,
+ (sag_id,),
+ ) or []
+ return rows
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("❌ Error loading case buzzwords: %s", e)
+ raise HTTPException(status_code=500, detail="Failed to load case buzzwords")
+
+
+@router.post("/sag/{sag_id}/buzzwords")
+async def add_case_buzzword(sag_id: int, payload: SagBuzzwordCreateRequest):
+ """Create or link a buzzword to a case using normalized free text."""
+ try:
+ _assert_sag_exists(sag_id)
+ normalized_word = _normalize_buzzword_text(payload.buzzword)
+ buzzword = _ensure_buzzword(normalized_word)
+ relation, link_status = _ensure_sag_buzzword_link(sag_id, int(buzzword["id"]))
+
+ return {
+ "status": link_status,
+ "relation_id": relation.get("id"),
+ "sag_id": sag_id,
+ "buzzword_id": buzzword.get("id"),
+ "word": buzzword.get("word"),
+ "created_at": relation.get("created_at"),
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("❌ Error adding case buzzword: %s", e)
+ raise HTTPException(status_code=500, detail="Failed to add case buzzword")
+
+
+@router.post("/sag/{sag_id}/buzzwords/from-selection")
+async def add_case_buzzword_from_selection(sag_id: int, payload: SagBuzzwordSelectionRequest):
+ """Create a buzzword from selected text and link it to a case."""
+ return await add_case_buzzword(sag_id, SagBuzzwordCreateRequest(buzzword=payload.selected_text))
+
+
+@router.delete("/sag/{sag_id}/buzzwords/{buzzword_id}")
+async def delete_case_buzzword(sag_id: int, buzzword_id: int):
+ """Soft-delete buzzword relation from a case."""
+ try:
+ _assert_sag_exists(sag_id)
+ relation = execute_query_single(
+ """
+ SELECT id
+ FROM sag_buzzwords
+ WHERE sag_id = %s AND buzzword_id = %s AND deleted_at IS NULL
+ """,
+ (sag_id, buzzword_id),
+ )
+ if not relation:
+ raise HTTPException(status_code=404, detail="Buzzword relation not found")
+
+ execute_query(
+ "UPDATE sag_buzzwords SET deleted_at = NOW() WHERE id = %s",
+ (relation["id"],),
+ )
+
+ return {
+ "status": "deleted",
+ "sag_id": sag_id,
+ "buzzword_id": buzzword_id,
+ "relation_id": relation["id"],
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("❌ Error deleting case buzzword: %s", e)
+ raise HTTPException(status_code=500, detail="Failed to delete case buzzword")
+
+
# ============================================================================
# CUSTOMERS - Case Customers (Many-to-Many)
# ============================================================================
@@ -3105,6 +3326,8 @@ async def add_kommentar(sag_id: int, data: dict, request: Request):
raise HTTPException(status_code=404, detail="Case not found")
er_system_besked = bool(data.get("er_system_besked", False))
+ er_intern = bool(data.get("er_intern", False))
+ has_internal_flag = table_has_column("sag_kommentarer", "er_intern")
if er_system_besked:
forfatter = str(data.get("forfatter") or "System").strip() or "System"
@@ -3133,12 +3356,20 @@ async def add_kommentar(sag_id: int, data: dict, request: Request):
else:
forfatter = "Bruger"
- query = """
- INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
- VALUES (%s, %s, %s, %s)
- RETURNING *
- """
- result = execute_query(query, (sag_id, forfatter, data.get("indhold"), er_system_besked))
+ if has_internal_flag:
+ query = """
+ INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked, er_intern)
+ VALUES (%s, %s, %s, %s, %s)
+ RETURNING *
+ """
+ result = execute_query(query, (sag_id, forfatter, data.get("indhold"), er_system_besked, er_intern))
+ else:
+ query = """
+ INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
+ VALUES (%s, %s, %s, %s)
+ RETURNING *
+ """
+ result = execute_query(query, (sag_id, forfatter, data.get("indhold"), er_system_besked))
if result:
logger.info("✅ Comment added to case %s by %s", sag_id, forfatter)
diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py
index 0eba19d..45ad0a6 100644
--- a/app/modules/sag/frontend/views.py
+++ b/app/modules/sag/frontend/views.py
@@ -491,6 +491,52 @@ async def sag_detaljer(request: Request, sag_id: int):
if not tags:
tags_query_legacy = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
tags = execute_query(tags_query_legacy, (sag_id,))
+
+ buzzwords = []
+ try:
+ buzzwords = execute_query(
+ """
+ SELECT
+ sb.id AS relation_id,
+ sb.sag_id,
+ b.id AS buzzword_id,
+ b.word,
+ sb.created_at
+ FROM sag_buzzwords sb
+ JOIN buzzwords b ON b.id = sb.buzzword_id
+ WHERE sb.sag_id = %s
+ AND sb.deleted_at IS NULL
+ AND b.deleted_at IS NULL
+ ORDER BY b.word ASC
+ """,
+ (sag_id,),
+ ) or []
+ except Exception as e:
+ logger.warning("⚠️ Could not load buzzwords for case %s: %s", sag_id, e)
+ buzzwords = []
+
+ buzzwords = []
+ try:
+ buzzwords = execute_query(
+ """
+ SELECT
+ sb.id AS relation_id,
+ sb.sag_id,
+ b.id AS buzzword_id,
+ b.word,
+ sb.created_at
+ FROM sag_buzzwords sb
+ JOIN buzzwords b ON b.id = sb.buzzword_id
+ WHERE sb.sag_id = %s
+ AND sb.deleted_at IS NULL
+ AND b.deleted_at IS NULL
+ ORDER BY b.word ASC
+ """,
+ (sag_id,),
+ ) or []
+ except Exception as e:
+ logger.warning("⚠️ Could not load buzzwords for case %s: %s", sag_id, e)
+ buzzwords = []
# Fetch relations
relationer_query = """
@@ -759,6 +805,8 @@ async def sag_detaljer(request: Request, sag_id: int):
"prepaid_cards": prepaid_cards,
"fixed_price_agreements": fixed_price_agreements,
"tags": tags,
+ "buzzwords": buzzwords,
+ "buzzwords": buzzwords,
"relationer": relationer,
"relation_tree": relation_tree,
diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html
index 659542f..0679b1c 100644
--- a/app/modules/sag/templates/create.html
+++ b/app/modules/sag/templates/create.html
@@ -455,6 +455,9 @@
const timeoutVar = type === 'customer' ? customerSearchTimeout : contactSearchTimeout;
const resultsDiv = document.getElementById(resultsId);
+ const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
+ const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
+
clearTimeout(timeoutVar);
if (query.length < 2) {
@@ -469,7 +472,10 @@
resultsDiv.innerHTML = '
Søger...
';
const endpoint = type === 'customer' ? '/api/v1/search/customers' : '/api/v1/search/contacts';
- const response = await fetch(`${endpoint}?q=${encodeURIComponent(query)}`);
+ const response = await fetch(`${endpoint}?q=${encodeURIComponent(query)}`, {
+ headers: authHeaders,
+ credentials: 'include'
+ });
if (!response.ok) {
const errorText = await response.text();
resultsDiv.innerHTML = `
Fejl ved søgning: ${errorText}
`;
@@ -492,7 +498,9 @@
} else {
resultsDiv.innerHTML = data.map(item => {
const name = type === 'customer' ? item.name : `${item.first_name} ${item.last_name}`;
- const meta = item.email || (type === 'customer' ? 'CVR: ' + (item.cvr_nummer || '-') : '-');
+ const meta = type === 'customer'
+ ? (item.email || ('CVR: ' + (item.cvr_nummer || '-')))
+ : `${item.user_company || 'Ingen firma'} · Tlf: ${item.mobile || item.phone || item.user_company_phone || '-'}`;
// Handle escaping for JS function call
const safeName = name.replace(/'/g, "\\'");
@@ -937,9 +945,15 @@
btn.innerHTML = '
Opretter...';
try {
+ const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
+ const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
+
const customerQuery = document.getElementById('customerSearch').value.trim();
if (!selectedCustomer && customerQuery.length >= 2) {
- const res = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(customerQuery)}`);
+ const res = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(customerQuery)}`, {
+ headers: authHeaders,
+ credentials: 'include'
+ });
const matches = await res.json();
if (Array.isArray(matches) && matches.length > 0) {
throw new Error('Firma findes allerede. Vælg det fra listen.');
@@ -949,7 +963,10 @@
const contactQuery = document.getElementById('contactSearch').value.trim();
if (Object.keys(selectedContacts).length === 0 && contactQuery.length >= 2) {
- const res = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(contactQuery)}`);
+ const res = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(contactQuery)}`, {
+ headers: authHeaders,
+ credentials: 'include'
+ });
const matches = await res.json();
if (Array.isArray(matches) && matches.length > 0) {
throw new Error('Kontakt findes allerede. Vælg den fra listen.');
@@ -992,6 +1009,13 @@
fetch(`/api/v1/sag/${result.id}/contacts`, {
method: 'POST',
credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contact_id: parseInt(cid),
+ role: 'Kontakt',
+ is_primary: false
+ })
+ })
);
await Promise.all(contactPromises);
diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html
index 6296e1e..63d5e01 100644
--- a/app/modules/sag/templates/detail.html
+++ b/app/modules/sag/templates/detail.html
@@ -1241,6 +1241,10 @@
display: none;
}
+ .card[data-module="buzzwords"].module-empty-compact .card-body {
+ display: block;
+ }
+
.card[data-module].module-empty-compact .card-header,
.card[data-module].module-empty-compact .module-header {
margin-bottom: 0;
@@ -4045,6 +4049,7 @@
let customerSearchMode = 'link';
const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
const initialCaseTagsSnapshot = {{ (tags or [])|tojson }};
+ const initialCaseBuzzwordsSnapshot = {{ (buzzwords or [])|tojson }};
async function markCaseAsRecentlyOpened() {
try {
@@ -4141,6 +4146,7 @@
'contacts': 'Kontakter',
'customers': 'Kunder',
'tags': 'Tags',
+ 'buzzwords': 'Buzzwords',
'wiki': 'Wiki',
'todo-steps': 'Todo-opgaver',
'time': 'Tid',
@@ -4214,6 +4220,7 @@
loadTodoSteps();
loadCaseTagsModule();
loadCaseTagSuggestions();
+ loadCaseBuzzwordsModule();
// Keep suggestions fresh while user works on the case.
setInterval(loadCaseTagSuggestions, 30000);
@@ -4550,8 +4557,8 @@
}
async function callMainContactFromModal() {
- const number = {{ (hovedkontakt.mobile or hovedkontakt.phone or '')|tojson if hovedkontakt else "''" }};
- const name = {{ ((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim)|tojson if hovedkontakt else "''" }};
+ const number = {{ ((hovedkontakt.mobile or hovedkontakt.phone or '') if hovedkontakt else '')|tojson }};
+ const name = {{ (((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim) if hovedkontakt else '')|tojson }};
if (!number) {
alert('Ingen telefon eller mobil på hovedkontakt');
return;
@@ -5481,21 +5488,24 @@
return;
}
- suggestionsContainer.innerHTML = suggestions.slice(0, 8).map((item) => {
+ suggestionsContainer.innerHTML = `
${suggestions.slice(0, 8).map((item) => {
const tag = item.tag || {};
const matched = Array.isArray(item.matched_words) ? item.matched_words.join(', ') : '';
+ const hint = matched ? `Match: ${escapeHtml(matched)}` : 'Tilfoej tag';
return `
-
-
-
- ${tag.icon ? ` ` : ''}${escapeHtml(tag.name || 'Tag')}
-
- ${matched ? `
Match: ${escapeHtml(matched)}
` : ''}
-
-
Tilfoej
-
+
+ ${tag.icon ? ` ` : ''}${escapeHtml(tag.name || 'Tag')}
+
+
+
+
`;
- }).join('');
+ }).join('')}
`;
} catch (error) {
console.error('Error loading tag suggestions:', error);
suggestionsContainer.innerHTML = '
Fejl ved forslag
';
@@ -5570,7 +5580,149 @@
await loadCaseTagSuggestions();
}
+ async function createCaseBuzzwordFromText(rawText) {
+ const text = String(rawText || '').trim();
+ if (!text) {
+ throw new Error('Ingen tekst at oprette buzzword fra');
+ }
+
+ const response = await fetch(`/api/v1/sag/${caseId}/buzzwords`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ buzzword: text })
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.detail || 'Kunne ikke oprette buzzword');
+ }
+
+ await loadCaseBuzzwordsModule();
+ }
+
+ async function loadCaseBuzzwordsModule() {
+ const moduleContainer = document.getElementById('case-buzzwords-module');
+ if (!moduleContainer) return;
+
+ try {
+ let items = [];
+ const response = await fetch(`/api/v1/sag/${caseId}/buzzwords`, { credentials: 'include' });
+ if (response.ok) {
+ const payload = await response.json();
+ items = Array.isArray(payload) ? payload : [];
+ }
+
+ if ((!Array.isArray(items) || items.length === 0) && Array.isArray(initialCaseBuzzwordsSnapshot) && initialCaseBuzzwordsSnapshot.length > 0) {
+ items = initialCaseBuzzwordsSnapshot;
+ }
+
+ if (!Array.isArray(items) || items.length === 0) {
+ moduleContainer.innerHTML = '
Ingen buzzwords paa sagen endnu
';
+ setModuleContentState('buzzwords', false);
+ return;
+ }
+
+ moduleContainer.innerHTML = items.map((item) => `
+
+ ${escapeHtml(item.word || '')}
+
+
+ `).join('');
+
+ setModuleContentState('buzzwords', true);
+ } catch (error) {
+ console.error('Error loading case buzzwords:', error);
+ moduleContainer.innerHTML = '
Fejl ved hentning af buzzwords
';
+ setModuleContentState('buzzwords', true);
+ }
+ }
+
+ async function createCaseBuzzwordFromInput() {
+ const input = document.getElementById('case-buzzword-input');
+ if (!input) return;
+
+ try {
+ await createCaseBuzzwordFromText(input.value);
+ input.value = '';
+ if (typeof showNotification === 'function') {
+ showNotification('Buzzword tilfoejet', 'success');
+ }
+ } catch (error) {
+ if (typeof showNotification === 'function') {
+ showNotification(error.message || 'Kunne ikke oprette buzzword', 'error');
+ }
+ }
+ }
+
+ function getCurrentBuzzwordSelectionText() {
+ const liveSelection = String(window.getSelection ? window.getSelection().toString() : '').trim();
+ if (liveSelection) {
+ return liveSelection;
+ }
+
+ const descTextarea = document.getElementById('beskrivelse-textarea');
+ if (descTextarea) {
+ const start = Number(descTextarea.selectionStart);
+ const end = Number(descTextarea.selectionEnd);
+ if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
+ return String(descTextarea.value || '').slice(start, end).trim();
+ }
+ }
+
+ return '';
+ }
+
+ async function createCaseBuzzwordFromCurrentSelection() {
+ const selectedText = getCurrentBuzzwordSelectionText();
+ if (!selectedText) {
+ if (typeof showNotification === 'function') {
+ showNotification('Marker tekst foerst, og tryk derefter Opret markering', 'warning');
+ }
+ return;
+ }
+
+ try {
+ await createCaseBuzzwordFromText(selectedText);
+ if (typeof showNotification === 'function') {
+ showNotification('Buzzword oprettet fra markering', 'success');
+ }
+ } catch (error) {
+ if (typeof showNotification === 'function') {
+ showNotification(error.message || 'Kunne ikke oprette buzzword', 'error');
+ }
+ }
+ }
+
+ async function removeCaseBuzzwordAndSync(buzzwordId) {
+ const numericBuzzwordId = Number(buzzwordId || 0);
+ if (!numericBuzzwordId) return;
+
+ try {
+ const response = await fetch(`/api/v1/sag/${caseId}/buzzwords/${numericBuzzwordId}`, {
+ method: 'DELETE',
+ credentials: 'include'
+ });
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.detail || 'Kunne ikke fjerne buzzword');
+ }
+
+ await loadCaseBuzzwordsModule();
+ } catch (error) {
+ if (typeof showNotification === 'function') {
+ showNotification(error.message || 'Kunne ikke fjerne buzzword', 'error');
+ }
+ }
+ }
+
window.syncCaseTagsUi = syncCaseTagsUi;
+ window.createCaseBuzzwordFromInput = createCaseBuzzwordFromInput;
+ window.createCaseBuzzwordFromCurrentSelection = createCaseBuzzwordFromCurrentSelection;
+ window.removeCaseBuzzwordAndSync = removeCaseBuzzwordAndSync;
let todoUserId = null;
@@ -6221,9 +6373,9 @@
Ingen tags paa sagen endnu
{% endif %}
-
+
+
+
+
+ {% if buzzwords and buzzwords|length > 0 %}
+ {% for item in buzzwords %}
+
+ {{ item.word }}
+
+
+ {% endfor %}
+ {% else %}
+
Ingen buzzwords paa sagen endnu
+ {% endif %}
+
+
+
+
@@ -4035,7 +4082,7 @@
await logCaseSystemComment(`💬 SMS sendt til ${to}: ${preview}`);
} catch (err) {
console.warn('Kunne ikke logge sendt SMS i kommentarer', err);
- }
+ alert(error?.message || 'Kunne ikke gemme kommentar og handlinger. Prøv igen.');
});
}
@@ -4660,6 +4707,7 @@
let customerSearchMode = 'link';
const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
const initialCaseTagsSnapshot = {{ (tags or [])|tojson }};
+ const initialCaseBuzzwordsSnapshot = {{ (buzzwords or [])|tojson }};
async function markCaseAsRecentlyOpened() {
try {
@@ -4756,6 +4804,7 @@
'contacts': 'Kontakter',
'customers': 'Kunder',
'tags': 'Tags',
+ 'buzzwords': 'Buzzwords',
'wiki': 'Wiki',
'todo-steps': 'Todo-opgaver',
'time': 'Tid',
@@ -4818,6 +4867,7 @@
loadTodoSteps();
loadCaseTagsModule();
loadCaseTagSuggestions();
+ loadCaseBuzzwordsModule();
// Keep suggestions fresh while user works on the case.
setInterval(loadCaseTagSuggestions, 30000);
@@ -4837,6 +4887,16 @@
todoForm.addEventListener('submit', createTodoStep);
}
+ const buzzwordInput = document.getElementById('case-buzzword-input');
+ if (buzzwordInput) {
+ buzzwordInput.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ createCaseBuzzwordFromInput();
+ }
+ });
+ }
+
['topbarStatusSelect', 'tabsAssignmentUserSelect', 'tabsAssignmentGroupSelect', 'topbarTypeSelect', 'topbarPrioritySelect', 'topbarStartDateInput', 'topbarDeferredInput', 'topbarDeadlineInput'].forEach((id) => {
const el = document.getElementById(id);
if (el) {
@@ -5169,8 +5229,8 @@
}
async function callMainContactFromModal() {
- const number = {{ (hovedkontakt.mobile or hovedkontakt.phone or '')|tojson if hovedkontakt else "''" }};
- const name = {{ ((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim)|tojson if hovedkontakt else "''" }};
+ const number = {{ ((hovedkontakt.mobile or hovedkontakt.phone or '') if hovedkontakt else '')|tojson }};
+ const name = {{ (((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim) if hovedkontakt else '')|tojson }};
if (!number) {
alert('Ingen telefon eller mobil på hovedkontakt');
return;
@@ -6102,21 +6162,24 @@
return;
}
- suggestionsContainer.innerHTML = suggestions.slice(0, 8).map((item) => {
+ suggestionsContainer.innerHTML = `${suggestions.slice(0, 8).map((item) => {
const tag = item.tag || {};
const matched = Array.isArray(item.matched_words) ? item.matched_words.join(', ') : '';
+ const hint = matched ? `Match: ${escapeHtml(matched)}` : 'Tilfoej tag';
return `
-
-
-
- ${tag.icon ? ` ` : ''}${escapeHtml(tag.name || 'Tag')}
-
- ${matched ? `
Match: ${escapeHtml(matched)}
` : ''}
-
-
Tilfoej
-
+
+ ${tag.icon ? ` ` : ''}${escapeHtml(tag.name || 'Tag')}
+
+
+
+
`;
- }).join('');
+ }).join('')}
`;
} catch (error) {
console.error('Error loading tag suggestions:', error);
suggestionsContainer.innerHTML = 'Fejl ved forslag
';
@@ -6252,6 +6315,211 @@
});
}
+ function getSelectionInsideElement(element) {
+ if (!element || !window.getSelection) return '';
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) return '';
+ const selectedText = String(selection.toString() || '').trim();
+ if (!selectedText) return '';
+ const range = selection.getRangeAt(0);
+ const container = range.commonAncestorContainer;
+ if (!element.contains(container)) return '';
+ return selectedText;
+ }
+
+ async function createCaseBuzzwordFromText(rawText) {
+ const text = String(rawText || '').trim();
+ if (!text) {
+ throw new Error('Ingen tekst at oprette buzzword fra');
+ }
+
+ const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
+ const headers = { 'Content-Type': 'application/json' };
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+
+ const response = await fetch(`/api/v1/sag/${caseId}/buzzwords`, {
+ method: 'POST',
+ headers,
+ credentials: 'include',
+ body: JSON.stringify({ buzzword: text })
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.detail || 'Kunne ikke oprette buzzword');
+ }
+
+ const payload = await response.json().catch(() => ({}));
+ await loadCaseBuzzwordsModule();
+ return payload;
+ }
+
+ async function loadCaseBuzzwordsModule() {
+ const moduleContainer = document.getElementById('case-buzzwords-module');
+ if (!moduleContainer) return;
+
+ try {
+ let items = [];
+ const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
+ const response = await fetch(`/api/v1/sag/${caseId}/buzzwords`, { headers, credentials: 'include' });
+ if (response.ok) {
+ const payload = await response.json();
+ items = Array.isArray(payload) ? payload : [];
+ }
+
+ if ((!Array.isArray(items) || items.length === 0) && Array.isArray(initialCaseBuzzwordsSnapshot) && initialCaseBuzzwordsSnapshot.length > 0) {
+ items = initialCaseBuzzwordsSnapshot;
+ }
+
+ if (!Array.isArray(items) || items.length === 0) {
+ moduleContainer.innerHTML = 'Ingen buzzwords paa sagen endnu
';
+ setModuleContentState('buzzwords', false);
+ return;
+ }
+
+ moduleContainer.innerHTML = items.map((item) => `
+
+ ${escapeHtml(item.word || '')}
+
+
+ `).join('');
+
+ setModuleContentState('buzzwords', true);
+ } catch (error) {
+ console.error('Error loading case buzzwords:', error);
+ moduleContainer.innerHTML = 'Fejl ved hentning af buzzwords
';
+ setModuleContentState('buzzwords', true);
+ }
+ }
+
+ async function createCaseBuzzwordFromInput() {
+ const input = document.getElementById('case-buzzword-input');
+ if (!input) return;
+
+ try {
+ await createCaseBuzzwordFromText(input.value);
+ input.value = '';
+ if (typeof showNotification === 'function') {
+ showNotification('Buzzword tilfoejet', 'success');
+ }
+ } catch (error) {
+ if (typeof showNotification === 'function') {
+ showNotification(error.message || 'Kunne ikke oprette buzzword', 'error');
+ } else {
+ alert(`Fejl: ${error.message || 'Kunne ikke oprette buzzword'}`);
+ }
+ }
+ }
+
+ function getCurrentBuzzwordSelectionText() {
+ const liveSelection = String(window.getSelection ? window.getSelection().toString() : '').trim();
+ if (liveSelection) {
+ return liveSelection;
+ }
+
+ const descTextarea = document.getElementById('beskrivelse-textarea');
+ if (descTextarea) {
+ const start = Number(descTextarea.selectionStart);
+ const end = Number(descTextarea.selectionEnd);
+ if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
+ return String(descTextarea.value || '').slice(start, end).trim();
+ }
+ }
+
+ return '';
+ }
+
+ async function createCaseBuzzwordFromCurrentSelection() {
+ const selectedText = getCurrentBuzzwordSelectionText();
+ if (!selectedText) {
+ if (typeof showNotification === 'function') {
+ showNotification('Marker tekst foerst, og tryk derefter Opret markering', 'warning');
+ } else {
+ alert('Marker tekst foerst, og tryk derefter Opret markering');
+ }
+ return;
+ }
+
+ try {
+ await createCaseBuzzwordFromText(selectedText);
+ if (typeof showNotification === 'function') {
+ showNotification('Buzzword oprettet fra markering', 'success');
+ }
+ } catch (error) {
+ if (typeof showNotification === 'function') {
+ showNotification(error.message || 'Kunne ikke oprette buzzword', 'error');
+ } else {
+ alert(`Fejl: ${error.message || 'Kunne ikke oprette buzzword'}`);
+ }
+ }
+ }
+
+ async function removeCaseBuzzwordAndSync(buzzwordId) {
+ const numericBuzzwordId = Number(buzzwordId || 0);
+ if (!numericBuzzwordId) return;
+
+ try {
+ const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
+ const response = await fetch(`/api/v1/sag/${caseId}/buzzwords/${numericBuzzwordId}`, {
+ method: 'DELETE',
+ headers,
+ credentials: 'include'
+ });
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.detail || 'Kunne ikke fjerne buzzword');
+ }
+
+ await loadCaseBuzzwordsModule();
+ } catch (error) {
+ if (typeof showNotification === 'function') {
+ showNotification(error.message || 'Kunne ikke fjerne buzzword', 'error');
+ } else {
+ alert(`Fejl: ${error.message || 'Kunne ikke fjerne buzzword'}`);
+ }
+ }
+ }
+
+ async function createCaseBuzzwordFromCommentButton(triggerButton) {
+ const commentItem = triggerButton?.closest('.comment-item');
+ const commentBody = commentItem?.querySelector('.comment-body');
+ const selectedText = getSelectionInsideElement(commentBody);
+
+ if (!selectedText) {
+ if (typeof showNotification === 'function') {
+ showNotification('Marker tekst i kommentaren foerst', 'warning');
+ } else {
+ alert('Marker tekst i kommentaren foerst');
+ }
+ return;
+ }
+
+ try {
+ await createCaseBuzzwordFromText(selectedText);
+ if (typeof showNotification === 'function') {
+ showNotification('Buzzword oprettet fra kommentar-markering', 'success');
+ }
+ } catch (error) {
+ if (typeof showNotification === 'function') {
+ showNotification(error.message || 'Kunne ikke oprette buzzword', 'error');
+ }
+ }
+ }
+
+ window.createCaseBuzzwordFromInput = createCaseBuzzwordFromInput;
+ window.createCaseBuzzwordFromCurrentSelection = createCaseBuzzwordFromCurrentSelection;
+ window.createCaseBuzzwordFromText = createCaseBuzzwordFromText;
+ window.removeCaseBuzzwordAndSync = removeCaseBuzzwordAndSync;
+ window.createCaseBuzzwordFromCommentButton = createCaseBuzzwordFromCommentButton;
+ window.loadCaseBuzzwordsModule = loadCaseBuzzwordsModule;
+
window.openTemplateSelectorFromTagClick = openTemplateSelectorFromTagClick;
window.syncCaseTagsUi = syncCaseTagsUi;
@@ -6913,9 +7181,9 @@
Ingen tags paa sagen endnu
{% endif %}
-
-
Forslag (brand/type)
-
+
@@ -6955,6 +7223,138 @@
+
+
+
+
+ {% if buzzwords and buzzwords|length > 0 %}
+ {% for item in buzzwords %}
+
+ {{ item.word }}
+
+
+ {% endfor %}
+ {% else %}
+
Ingen buzzwords paa sagen endnu
+ {% endif %}
+
+
+
+
+
+