feat: Add Service Contract Report page with customer and contract selection

- Implemented a new HTML page for generating service contract reports.
- Added CSS styles for report layout and components.
- Developed JavaScript functionality for loading customers and contracts, fetching report data, and rendering metrics and cases.
- Included buttons for downloading reports in PDF and Excel formats.

docs: Create Route Auth Audit for route access control

- Generated an audit report detailing route access requirements.
- Classified routes based on authentication needs and documented them in a markdown file.

feat: Introduce buzzwords and mission projects tables in the database

- Created `buzzwords` and `sag_buzzwords` tables for managing keywords related to SAG cases.
- Established `mission_projects`, `mission_project_milestones`, and `mission_project_blockers` tables for project management.
- Updated `sag_sager` table to link with mission projects and milestones, including necessary foreign key constraints.
This commit is contained in:
Christian 2026-05-12 08:41:13 +02:00
parent 770f822fc6
commit a36e3e716f
37 changed files with 10961 additions and 208 deletions

View File

@ -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

View File

@ -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,
}
)

View File

@ -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

View File

@ -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()

View File

@ -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), []),

View File

@ -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:

View File

@ -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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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),

File diff suppressed because it is too large Load Diff

View File

@ -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"}
)

View File

@ -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%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-receipt me-2"></i>Ordre</h2>
<div class="text-muted">Oversigt over alle ordre</div>
</div>
<div class="d-flex gap-2">
<a href="/ordre/create/new" class="btn btn-success"><i class="bi bi-plus-circle me-1"></i>Opret ny ordre</a>
<select id="syncStatusFilter" class="form-select" style="min-width: 170px;" onchange="renderOrders()">
<option value="all">Alle sync-status</option>
<option value="pending">pending</option>
<option value="exported">exported</option>
<option value="failed">failed</option>
<option value="posted">posted</option>
<option value="paid">paid</option>
</select>
<span id="selectedCountBadge" class="selected-counter">Valgte: 0</span>
<button class="btn btn-outline-secondary" onclick="consolidateSelectedByCustomer()"><i class="bi bi-collection me-1"></i>Konsolider valgte</button>
<button class="btn btn-outline-success" onclick="markSelectedOrdersPaid()"><i class="bi bi-cash-stack me-1"></i>Markér valgte som betalt</button>
<button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
<div class="ordre-top-panel">
<div class="row g-3 align-items-center">
<div class="col-lg-5">
<h1 class="ordre-top-title"><i class="bi bi-receipt me-2"></i>Ordre</h1>
<p class="ordre-top-subtitle">Samlet overblik, konsolidering og sync-status for alle ordrekladder</p>
</div>
<div class="col-lg-7">
<div class="ordre-top-actions">
<a href="/ordre/create/new" class="btn btn-success ordre-btn"><i class="bi bi-plus-circle"></i>Opret ny ordre</a>
<div class="ordre-filter-wrap">
<select id="syncStatusFilter" class="form-select ordre-btn" onchange="renderOrders()">
<option value="all">Alle sync-status</option>
<option value="pending">pending</option>
<option value="exported">exported</option>
<option value="failed">failed</option>
<option value="posted">posted</option>
<option value="paid">paid</option>
</select>
</div>
<span id="selectedCountBadge" class="selected-counter">Valgte: 0</span>
<button class="btn btn-outline-secondary ordre-btn" onclick="consolidateSelectedByCustomer()"><i class="bi bi-collection"></i>Konsolider valgte</button>
<button class="btn btn-outline-success ordre-btn" onclick="markSelectedOrdersPaid()"><i class="bi bi-cash-stack"></i>Markér valgte som betalt</button>
<button class="btn btn-outline-primary ordre-btn" onclick="loadOrders()"><i class="bi bi-arrow-clockwise"></i>Opdater</button>
</div>
</div>
</div>
</div>
@ -227,21 +288,21 @@
<option value="posted" ${syncStatus === 'posted' ? 'selected' : ''}>posted</option>
<option value="paid" ${syncStatus === 'paid' ? 'selected' : ''}>paid</option>
</select>
<button class="btn btn-sm btn-outline-primary" title="Gem sync" onclick="saveQuickSyncStatus(${order.id})">
<button class="btn btn-sm btn-outline-primary table-icon-btn" title="Gem sync" onclick="saveQuickSyncStatus(${order.id})">
<i class="bi bi-check2"></i>
</button>
${syncStatus === 'posted' ? `
<button class="btn btn-sm btn-outline-success" title="Markér som betalt" onclick="markOrderPaid(${order.id})">
<button class="btn btn-sm btn-outline-success table-icon-btn" title="Markér som betalt" onclick="markOrderPaid(${order.id})">
<i class="bi bi-cash-coin"></i>
</button>
` : ''}
</div>
</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); window.location.href='/ordre/${order.id}'">
<button class="btn btn-sm btn-outline-primary table-icon-btn" onclick="event.stopPropagation(); window.location.href='/ordre/${order.id}'">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="event.stopPropagation(); deleteOrder(${order.id})">
<button class="btn btn-sm btn-outline-danger table-icon-btn" onclick="event.stopPropagation(); deleteOrder(${order.id})">
<i class="bi bi-trash"></i>
</button>
</td>

View File

@ -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)

View File

@ -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,

View File

@ -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 = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
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 = `<div class="p-3 text-danger small">Fejl ved søgning: ${errorText}</div>`;
@ -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 = '<span class="spinner-border spinner-border-sm me-2"></span>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);

View File

@ -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 = `<div class="d-flex flex-wrap gap-1">${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 `
<div class="d-flex justify-content-between align-items-start gap-2 mb-2 border rounded p-2">
<div class="small">
<span class="badge" style="background-color: ${tag.color || '#0f4c75'};">
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name || 'Tag')}
</span>
${matched ? `<div class="text-muted mt-1">Match: ${escapeHtml(matched)}</div>` : ''}
</div>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="applySuggestedCaseTag(${Number(tag.id)})">Tilfoej</button>
</div>
<button class="btn btn-sm btn-outline-primary d-inline-flex align-items-center gap-1 px-2 py-0"
type="button"
onclick="applySuggestedCaseTag(${Number(tag.id)})"
title="${hint}"
aria-label="${hint}"
style="height: 22px; line-height: 1; font-size: 0.72rem; border-radius: 999px; max-width: 100%;">
<span class="text-truncate" style="max-width: 150px;">${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name || 'Tag')}</span>
<span class="d-inline-flex align-items-center justify-content-center rounded-circle" style="width: 14px; height: 14px; background: ${tag.color || '#0f4c75'}; color: #fff; font-size: 0.6rem;">
<i class="bi bi-plus-lg"></i>
</span>
</button>
`;
}).join('');
}).join('')}</div>`;
} catch (error) {
console.error('Error loading tag suggestions:', error);
suggestionsContainer.innerHTML = '<div class="text-danger small">Fejl ved forslag</div>';
@ -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 = '<div class="p-2 text-muted small">Ingen buzzwords paa sagen endnu</div>';
setModuleContentState('buzzwords', false);
return;
}
moduleContainer.innerHTML = items.map((item) => `
<span class="badge bg-warning text-dark me-1 mb-1">
<i class="bi bi-lightbulb"></i> ${escapeHtml(item.word || '')}
<button type="button" class="btn-close btn-close-black btn-sm ms-1"
onclick="removeCaseBuzzwordAndSync(${Number(item.buzzword_id || 0)})"
style="font-size: 0.6rem; vertical-align: middle;"
title="Fjern buzzword"></button>
</span>
`).join('');
setModuleContentState('buzzwords', true);
} catch (error) {
console.error('Error loading case buzzwords:', error);
moduleContainer.innerHTML = '<div class="p-2 text-danger small">Fejl ved hentning af buzzwords</div>';
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 @@
<div class="p-2 text-muted small">Ingen tags paa sagen endnu</div>
{% endif %}
</div>
<div class="border-top pt-2">
<div class="small text-muted fw-semibold mb-2">Forslag (brand/type)</div>
<div id="case-tag-suggestions">
<div class="border-top pt-1">
<div class="small text-muted fw-semibold mb-1">Forslag</div>
<div id="case-tag-suggestions" style="max-height: 72px; overflow: auto;">
<div class="text-muted small">Indlaeser forslag...</div>
</div>
</div>
@ -6263,6 +6415,46 @@
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card module-priority-low" data-module="buzzwords" data-has-content="{{ 'true' if buzzwords and buzzwords|length > 0 else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-start gap-2 flex-wrap">
<h6 class="module-title mb-0"><i class="bi bi-lightbulb-fill module-icon"></i>Buzzwords</h6>
<div class="d-flex align-items-center gap-1 flex-nowrap" style="max-width: 100%;">
<input
type="text"
id="case-buzzword-input"
class="form-control form-control-sm"
maxlength="120"
placeholder="Buzzword"
aria-label="Tilfoej buzzword"
style="width: 140px; height: 26px; min-height: 26px; padding-top: 0.1rem; padding-bottom: 0.1rem;"
>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="createCaseBuzzwordFromInput()" title="Opret buzzword" aria-label="Opret buzzword" style="height: 26px; min-height: 26px; padding: 0.1rem 0.35rem; line-height: 1;">
<i class="bi bi-plus-lg"></i>
</button>
<button class="btn btn-sm btn-outline-warning" type="button" onclick="createCaseBuzzwordFromCurrentSelection()" title="Opret fra markering" aria-label="Opret fra markering" style="height: 26px; min-height: 26px; padding: 0.1rem 0.35rem; line-height: 1;">
<i class="bi bi-highlighter"></i>
</button>
</div>
</div>
<div class="card-body" style="max-height: 240px; overflow: auto;">
<div id="case-buzzwords-module">
{% if buzzwords and buzzwords|length > 0 %}
{% for item in buzzwords %}
<span class="badge bg-warning text-dark me-1 mb-1">
<i class="bi bi-lightbulb"></i> {{ item.word }}
<button type="button" class="btn-close btn-close-black btn-sm ms-1"
onclick="removeCaseBuzzwordAndSync({{ item.buzzword_id }})"
style="font-size: 0.6rem; vertical-align: middle;"
title="Fjern buzzword"></button>
</span>
{% endfor %}
{% else %}
<div class="p-2 text-muted small">Ingen buzzwords paa sagen endnu</div>
{% endif %}
</div>
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="contacts" data-has-content="{{ 'true' if contacts else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="module-title"><i class="bi bi-people-fill module-icon"></i>Kontakter</h6>
@ -12143,9 +12335,9 @@
document.body.setAttribute('data-case-view', viewName);
const viewDefaults = {
'Pipeline': ['pipeline', 'relations', 'sales', 'time'],
'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags'],
'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar']
'Pipeline': ['pipeline', 'relations', 'sales', 'time', 'buzzwords'],
'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags', 'buzzwords'],
'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'buzzwords', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar']
};
const currentCaseTypeKey = (typeof caseTypeKey !== 'undefined' && caseTypeKey)
@ -12158,12 +12350,14 @@
const standardModuleSet = new Set(standardModules);
standardModuleSet.add('tags');
standardModuleSet.add('time');
standardModuleSet.add('buzzwords');
document.querySelectorAll('[data-module]').forEach((el) => {
const moduleName = el.getAttribute('data-module');
const hasContent = moduleHasContent(el);
const isTimeModule = moduleName === 'time';
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule;
const isBuzzwordsModule = moduleName === 'buzzwords';
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && moduleName !== 'buzzwords' && !isTimeModule;
const pref = modulePrefs[moduleName];
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
@ -12199,6 +12393,12 @@
return;
}
if (isBuzzwordsModule) {
setVisibility(true);
el.classList.remove('module-empty-compact');
return;
}
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
if (pref === false) {
setVisibility(false);

File diff suppressed because it is too large Load Diff

View File

@ -33,15 +33,23 @@ class EmailProcessorService:
self.auto_process = settings.EMAIL_RULES_AUTO_PROCESS
self.ai_enabled = settings.EMAIL_AI_ENABLED
async def process_inbox(self) -> Dict:
async def process_inbox(
self,
limit_override: Optional[int] = None,
folder_override: Optional[str] = None,
force_run: bool = False,
) -> Dict:
"""
Main entry point: Process all new emails from inbox
Returns: Processing statistics
"""
if not self.enabled:
if not self.enabled and not force_run:
logger.info("⏭️ Email processing disabled (EMAIL_TO_TICKET_ENABLED=false)")
return {'status': 'disabled'}
if force_run and not self.enabled:
logger.info("🧪 Manual force run enabled (EMAIL_TO_TICKET_ENABLED=false)")
logger.info("🔄 Starting email processing cycle...")
@ -56,8 +64,18 @@ class EmailProcessorService:
try:
# Step 1: Fetch new emails
limit = settings.EMAIL_MAX_FETCH_PER_RUN
new_emails = await self.email_service.fetch_new_emails(limit=limit)
limit = int(limit_override) if limit_override else settings.EMAIL_MAX_FETCH_PER_RUN
if limit < 1:
limit = settings.EMAIL_MAX_FETCH_PER_RUN
active_folder = str(folder_override or '').strip() or None
if active_folder:
logger.info("🧪 Processing inbox with folder override: %s (limit=%s)", active_folder, limit)
new_emails = await self.email_service.fetch_new_emails_from_folder(
limit=limit,
folder_override=active_folder,
)
stats['fetched'] = len(new_emails)
if not new_emails:

View File

@ -20,6 +20,7 @@ import asyncio
import base64
import re
from uuid import uuid4
from urllib.parse import quote
# Try to import aiosmtplib, but don't fail if not available
try:
@ -33,7 +34,7 @@ from aiohttp import ClientSession, BasicAuth
import msal
from app.core.config import settings
from app.core.database import execute_query, execute_insert
from app.core.database import execute_query, execute_insert, execute_update
logger = logging.getLogger(__name__)
@ -244,17 +245,61 @@ class EmailService:
Fetch new emails from configured source (IMAP or Graph API)
Returns list of parsed email dictionaries
"""
return await self.fetch_new_emails_from_folder(limit=limit, folder_override=None)
async def fetch_new_emails_from_folder(self, limit: int = 50, folder_override: Optional[str] = None) -> List[Dict]:
"""
Fetch new emails from configured source with optional folder override.
Used by manual test runs to process one folder/mail type at a time.
"""
selected_folder = self._resolve_folder(folder_override)
if self.use_graph and self.graph_config['client_id']:
logger.info("📥 Fetching emails via Microsoft Graph API")
return await self._fetch_via_graph(limit)
logger.info("📥 Fetching emails via Microsoft Graph API (folder=%s)", selected_folder)
return await self._fetch_via_graph(limit, folder=selected_folder)
elif self.imap_config['username']:
logger.info("📥 Fetching emails via IMAP")
return await self._fetch_via_imap(limit)
logger.info("📥 Fetching emails via IMAP (folder=%s)", selected_folder)
return await self._fetch_via_imap(limit, folder=selected_folder)
else:
logger.warning("⚠️ No email source configured (IMAP or Graph API)")
return []
def _resolve_folder(self, folder_override: Optional[str]) -> str:
"""Resolve active folder with safe fallback order."""
override = str(folder_override or '').strip()
if override:
return override
test_folder = str(getattr(settings, 'IMAP_TEST_FOLDER', '') or '').strip()
if test_folder:
return test_folder
return self.imap_config['folder']
def _sync_existing_email_folder(self, message_id: Optional[str], folder: Optional[str]) -> None:
"""Ensure existing email rows follow the latest source folder label."""
normalized_message_id = str(message_id or '').strip()
normalized_folder = str(folder or '').strip()
if not normalized_message_id or not normalized_folder:
return
try:
updated = execute_update(
"""
UPDATE email_messages
SET folder = %s,
updated_at = CURRENT_TIMESTAMP
WHERE deleted_at IS NULL
AND message_id = %s
AND COALESCE(folder, '') <> %s
""",
(normalized_folder, normalized_message_id, normalized_folder),
)
if updated:
logger.info("📁 Updated folder for existing email %s -> %s", normalized_message_id, normalized_folder)
except Exception as e:
logger.warning("⚠️ Could not sync folder for message_id=%s: %s", normalized_message_id, e)
async def _fetch_via_imap(self, limit: int) -> List[Dict]:
async def _fetch_via_imap(self, limit: int, folder: Optional[str] = None) -> List[Dict]:
"""Fetch emails using IMAP protocol (READ-ONLY mode)"""
emails = []
@ -269,12 +314,12 @@ class EmailService:
mail.login(self.imap_config['username'], self.imap_config['password'])
# Select folder in READ-ONLY mode (critical for safety)
folder = self.imap_config['folder']
selected_folder = self._resolve_folder(folder)
readonly = self.imap_config['readonly']
mail.select(folder, readonly=readonly)
mail.select(selected_folder, readonly=readonly)
if readonly:
logger.info(f"🔒 Connected to {folder} in READ-ONLY mode (emails will NOT be marked as read)")
logger.info(f"🔒 Connected to {selected_folder} in READ-ONLY mode (emails will NOT be marked as read)")
# Search for all emails
status, messages = mail.search(None, 'ALL')
@ -286,7 +331,7 @@ class EmailService:
email_ids = messages[0].split()
total_emails = len(email_ids)
logger.info(f"📊 Found {total_emails} emails in {folder}")
logger.info(f"📊 Found {total_emails} emails in {selected_folder}")
# Get most recent emails (reverse order, limit)
email_ids_to_fetch = email_ids[-limit:] if len(email_ids) > limit else email_ids
@ -306,7 +351,7 @@ class EmailService:
msg = email.message_from_bytes(raw_email)
# Extract fields
parsed_email = self._parse_email(msg, email_id.decode())
parsed_email = self._parse_email(msg, email_id.decode(), folder=selected_folder)
# Check if already exists in database
if not self._email_exists(parsed_email['message_id']):
@ -314,6 +359,7 @@ class EmailService:
logger.info(f"✅ New email: {parsed_email['subject'][:50]}... from {parsed_email['sender_email']}")
else:
logger.debug(f"⏭️ Email already exists: {parsed_email['message_id']}")
self._sync_existing_email_folder(parsed_email.get('message_id'), selected_folder)
except Exception as e:
logger.error(f"❌ Error parsing email {email_id}: {e}")
@ -332,7 +378,7 @@ class EmailService:
logger.error(f"❌ Unexpected error fetching via IMAP: {e}")
return []
async def _fetch_via_graph(self, limit: int) -> List[Dict]:
async def _fetch_via_graph(self, limit: int, folder: Optional[str] = None) -> List[Dict]:
"""Fetch emails using Microsoft Graph API (OAuth2)"""
emails = []
@ -346,42 +392,56 @@ class EmailService:
# Build Graph API request
user_email = self.graph_config['user_email']
folder = self.imap_config['folder'] # Use same folder name
# Graph API endpoint for messages
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/{folder}/messages"
params = {
'$top': limit,
'$orderby': 'receivedDateTime desc',
'$select': 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,internetMessageId,conversationId,internetMessageHeaders'
}
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
selected_folder = self._resolve_folder(folder)
async with ClientSession() as session:
folder_id, folder_log_name = await self._resolve_graph_folder_id(
session=session,
user_email=user_email,
access_token=access_token,
requested_folder=selected_folder,
)
encoded_folder = quote(folder_id, safe='')
logger.info(
"🗂️ Graph folder resolved: requested='%s' -> id='%s' (%s)",
selected_folder,
folder_id,
folder_log_name,
)
# Graph API endpoint for messages
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/{encoded_folder}/messages"
params = {
'$top': limit,
'$orderby': 'receivedDateTime desc',
'$select': 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,internetMessageId,conversationId,internetMessageHeaders'
}
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
async with session.get(url, params=params, headers=headers) as response:
if response.status != 200:
error_text = await response.text()
logger.error(f"❌ Graph API error: {response.status} - {error_text}")
return []
data = await response.json()
messages = data.get('value', [])
logger.info(f"📊 Found {len(messages)} emails via Graph API")
logger.info(f"📊 Found {len(messages)} emails via Graph API in {selected_folder}")
for msg in messages:
try:
parsed_email = self._parse_graph_message(msg)
parsed_email = self._parse_graph_message(msg, folder=selected_folder)
# Fetch attachments if email has them
if msg.get('hasAttachments', False):
attachments = await self._fetch_graph_attachments(
user_email,
msg['id'],
user_email,
msg['id'],
access_token,
session
)
@ -389,20 +449,21 @@ class EmailService:
parsed_email['attachment_count'] = len(attachments)
else:
parsed_email['attachments'] = []
# Check if already exists
if not self._email_exists(parsed_email['message_id']):
emails.append(parsed_email)
logger.info(f"✅ New email: {parsed_email['subject'][:50]}... from {parsed_email['sender_email']}")
else:
logger.debug(f"⏭️ Email already exists: {parsed_email['message_id']}")
self._sync_existing_email_folder(parsed_email.get('message_id'), selected_folder)
# Re-save attachment bytes for existing emails (fills content_data for old emails)
if parsed_email.get('attachments'):
await self._resave_attachment_content(
parsed_email['message_id'],
parsed_email['attachments']
)
except Exception as e:
logger.error(f"❌ Error parsing Graph message: {e}")
continue
@ -413,6 +474,191 @@ class EmailService:
except Exception as e:
logger.error(f"❌ Unexpected error fetching via Graph API: {e}")
return []
async def _resolve_graph_folder_id(
self,
session: ClientSession,
user_email: str,
access_token: str,
requested_folder: str,
) -> Tuple[str, str]:
"""Resolve IMAP-like folder path (e.g. INBOX.BMC_TEST) to Graph folder id."""
normalized = str(requested_folder or '').strip()
if not normalized:
return "Inbox", "default"
well_known = {
'inbox': 'Inbox',
'drafts': 'Drafts',
'sent': 'SentItems',
'sentitems': 'SentItems',
'junk': 'JunkEmail',
'junkemail': 'JunkEmail',
'deleteditems': 'DeletedItems',
'archive': 'Archive',
}
direct = well_known.get(normalized.lower())
if direct:
return direct, "well-known"
path = re.split(r"[./\\]+", normalized)
path = [part.strip() for part in path if part and part.strip()]
starts_with_inbox = bool(path and path[0].lower() == 'inbox')
if starts_with_inbox:
path = path[1:]
# If no child path remains, Inbox is the target.
if not path:
return "Inbox", "well-known"
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
if starts_with_inbox:
current_folder_id = "Inbox"
for segment in path:
child_id = await self._find_graph_child_folder_id(
session=session,
user_email=user_email,
access_token=access_token,
parent_folder_id=current_folder_id,
segment_name=segment,
headers=headers,
)
if not child_id:
logger.warning(
"⚠️ Graph folder segment not found: '%s' under '%s' (requested '%s'). Trying global folder lookup.",
segment,
current_folder_id,
normalized,
)
break
current_folder_id = child_id
else:
return current_folder_id, "resolved-path"
# Fallback strategy: search globally by displayName candidates.
candidates: List[str] = [normalized]
if normalized.lower().startswith('inbox.'):
candidates.append(normalized[6:])
if path:
candidates.append(path[-1])
fallback_id = await self._find_graph_folder_id_by_name(
session=session,
user_email=user_email,
headers=headers,
candidate_names=candidates,
)
if fallback_id:
return fallback_id, "resolved-search"
logger.warning(
"⚠️ Could not resolve Graph folder '%s'; falling back to Inbox to avoid malformed-id errors.",
normalized,
)
return "Inbox", "fallback-inbox"
async def _find_graph_folder_id_by_name(
self,
session: ClientSession,
user_email: str,
headers: Dict[str, str],
candidate_names: List[str],
) -> Optional[str]:
"""Search all reachable mail folders and return first id matching a candidate displayName."""
normalized_candidates = {
str(name).strip().lower()
for name in candidate_names
if str(name).strip()
}
if not normalized_candidates:
return None
queue: List[str] = ["https://graph.microsoft.com/v1.0/users/{}/mailFolders?$top=200&$select=id,displayName,childFolderCount".format(user_email)]
while queue:
url = queue.pop(0)
async with session.get(url, headers=headers) as response:
if response.status != 200:
error_text = await response.text()
logger.warning(
"⚠️ Graph folder lookup failed: %s - %s",
response.status,
error_text,
)
return None
payload = await response.json()
folders = payload.get('value', [])
for folder in folders:
display_name = str(folder.get('displayName') or '').strip()
if display_name.lower() in normalized_candidates:
logger.info(
"🧭 Graph global folder lookup matched '%s' (id=%s)",
display_name,
folder.get('id'),
)
return folder.get('id')
if int(folder.get('childFolderCount') or 0) > 0 and folder.get('id'):
encoded_id = quote(str(folder['id']), safe='')
queue.append(
f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/{encoded_id}/childFolders?$top=200&$select=id,displayName,childFolderCount"
)
next_link = payload.get('@odata.nextLink')
if next_link:
queue.append(next_link)
return None
async def _find_graph_child_folder_id(
self,
session: ClientSession,
user_email: str,
access_token: str,
parent_folder_id: str,
segment_name: str,
headers: Dict[str, str],
) -> Optional[str]:
"""Find child folder id by displayName under a specific Graph parent folder."""
expected = segment_name.strip().lower()
if not expected:
return None
encoded_parent = quote(parent_folder_id, safe='')
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/{encoded_parent}/childFolders"
params = {
'$top': 200,
'$select': 'id,displayName'
}
while url:
async with session.get(url, params=params, headers=headers) as response:
if response.status != 200:
error_text = await response.text()
logger.warning(
"⚠️ Graph childFolders lookup failed for parent '%s': %s - %s",
parent_folder_id,
response.status,
error_text,
)
return None
payload = await response.json()
for folder in payload.get('value', []):
display_name = str(folder.get('displayName') or '').strip()
if display_name.lower() == expected:
return folder.get('id')
url = payload.get('@odata.nextLink')
params = None
return None
async def _get_graph_access_token(self) -> Optional[str]:
"""Get OAuth2 access token for Microsoft Graph API using MSAL"""
@ -441,7 +687,7 @@ class EmailService:
logger.error(f"❌ Error getting Graph access token: {e}")
return None
def _parse_email(self, msg: email.message.Message, email_id: str) -> Dict:
def _parse_email(self, msg: email.message.Message, email_id: str, folder: Optional[str] = None) -> Dict:
"""Parse IMAP email message into dictionary"""
# Decode subject
@ -545,13 +791,13 @@ class EmailService:
'body_text': body_text,
'body_html': body_html,
'received_date': received_date,
'folder': self.imap_config['folder'],
'folder': self._resolve_folder(folder),
'has_attachments': len(attachments) > 0,
'attachment_count': len(attachments),
'attachments': attachments
}
def _parse_graph_message(self, msg: Dict) -> Dict:
def _parse_graph_message(self, msg: Dict, folder: Optional[str] = None) -> Dict:
"""Parse Microsoft Graph API message into dictionary"""
# Extract sender
@ -607,7 +853,7 @@ class EmailService:
'body_text': body_text,
'body_html': body_html,
'received_date': received_date,
'folder': self.imap_config['folder'],
'folder': self._resolve_folder(folder),
'has_attachments': msg.get('hasAttachments', False),
'attachment_count': 0 # Will be updated after fetching attachments
}

View File

@ -235,10 +235,10 @@ class EmailWorkflowService:
def has_helpdesk_routing_hint(self, email_data: Dict) -> bool:
"""Return True when email has explicit routing hints (SAG tag, BMCid, or reply headers).
NOTE: A bare thread_key (Graph conversationId) is NOT a routing hint
because every Graph email has one, including newsletters and spam.
Only actual reply indicators (In-Reply-To, References), explicit
SAG tags, or BMCid markers count as hints."""
NOTE: A bare thread_key (Graph conversationId) is NOT automatically a
routing hint because every Graph email has one, including newsletters
and spam. However, if the thread_key already maps to an existing SAG
via prior linked emails, we treat it as a valid hint."""
if self._extract_bmc_id(email_data):
return True
@ -251,6 +251,10 @@ class EmailWorkflowService:
if self._extract_reference_message_ids(email_data.get('email_references')):
return True
provider_thread_key = self._normalize_message_id(email_data.get('thread_key'))
if provider_thread_key and self._find_sag_id_from_thread_key(provider_thread_key):
return True
return False
def _extract_bmc_id(self, email_data: Dict) -> Optional[Dict[str, Any]]:
@ -364,15 +368,16 @@ class EmailWorkflowService:
try:
rows = execute_query(
"""
SELECT se.sag_id
FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id
SELECT COALESCE(se.sag_id, em.linked_case_id) AS sag_id
FROM email_messages em
LEFT JOIN sag_emails se ON se.email_id = em.id
WHERE em.deleted_at IS NULL
AND COALESCE(se.sag_id, em.linked_case_id) IS NOT NULL
AND (
LOWER(REGEXP_REPLACE(COALESCE(em.thread_key, ''), '[<>\\s]', '', 'g')) = %s
OR LOWER(REGEXP_REPLACE(COALESCE(em.message_id, ''), '[<>\\s]', '', 'g')) = %s
)
ORDER BY se.created_at DESC
ORDER BY em.received_date DESC, em.id DESC
LIMIT 1
""",
(thread_key, thread_key)
@ -389,18 +394,42 @@ class EmailWorkflowService:
placeholders = ','.join(['%s'] * len(thread_message_ids))
rows = execute_query(
f"""
SELECT se.sag_id
FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id
SELECT COALESCE(se.sag_id, em.linked_case_id) AS sag_id
FROM email_messages em
LEFT JOIN sag_emails se ON se.email_id = em.id
WHERE em.deleted_at IS NULL
AND COALESCE(se.sag_id, em.linked_case_id) IS NOT NULL
AND LOWER(REGEXP_REPLACE(COALESCE(em.message_id, ''), '[<>\\s]', '', 'g')) IN ({placeholders})
ORDER BY se.created_at DESC
ORDER BY em.received_date DESC, em.id DESC
LIMIT 1
""",
tuple(thread_message_ids)
)
return rows[0]['sag_id'] if rows else None
def _find_sag_id_from_legacy_description_message_id(self, email_data: Dict) -> Optional[int]:
"""Fallback for old auto-created cases that only stored Message-ID inside description."""
thread_message_ids = self._extract_thread_message_ids(email_data)
if not thread_message_ids:
return None
for message_id in thread_message_ids:
rows = execute_query(
"""
SELECT id
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(REGEXP_REPLACE(COALESCE(beskrivelse, ''), '[<>\\s]', '', 'g')) LIKE %s
ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 1
""",
(f"%message-id:{message_id}%",),
)
if rows:
return rows[0]['id']
return None
# Sender domains that should never trigger customer-domain SAG creation.
# Includes own sending domain and common automated senders.
_IGNORED_SENDER_DOMAINS = {
@ -1061,6 +1090,7 @@ class EmailWorkflowService:
sag_id_from_thread_key = self._find_sag_id_from_thread_key(derived_thread_key)
sag_id_from_thread = self._find_sag_id_from_thread_headers(email_data)
sag_id_from_tag = self._extract_sag_id(email_data)
sag_id_from_legacy = self._find_sag_id_from_legacy_description_message_id(email_data)
scan_token_route = self._resolve_scan_token_route(email_id, email_data)
if scan_token_route and scan_token_route.get('sag_id'):
@ -1136,6 +1166,11 @@ class EmailWorkflowService:
routing_source = 'sag_tag'
logger.info("🏷️ Matched email %s to SAG-%s via SAG tag", email_id, sag_id)
if sag_id_from_legacy and not sag_id:
sag_id = sag_id_from_legacy
routing_source = 'legacy_message_id'
logger.info("🧷 Matched email %s to SAG-%s via legacy Message-ID marker", email_id, sag_id)
# 1) Existing SAG via subject/headers
if sag_id:
return await self._finalize_sag_routing(email_id, email_data, sag_id, routing_source)

View File

@ -4,8 +4,11 @@ Handles subscription and sales order data retrieval
"""
import logging
import json
import re
import html as html_lib
import aiohttp
from typing import List, Dict, Optional
from decimal import Decimal
from typing import List, Dict, Optional, Any
from app.core.config import settings
logger = logging.getLogger(__name__)
@ -36,6 +39,68 @@ class VTigerService:
if not self.api_key:
raise ValueError("VTIGER_API_KEY not configured")
return aiohttp.BasicAuth(self.username, self.api_key)
@staticmethod
def _sanitize_vtiger_id(raw_value: Optional[str]) -> Optional[str]:
"""Allow-list known vTiger id characters before embedding in query strings."""
if not raw_value:
return None
candidate = str(raw_value).strip()
if re.match(r"^[A-Za-z0-9_\-x]+$", candidate):
return candidate
return None
@staticmethod
def _extract_timelog_hours(timelog_data: Dict[str, Any]) -> Decimal:
"""Extract hours from common vTiger timelog fields and normalize to decimal hours."""
raw_value = None
field_used = None
for key in ("time_spent", "duration", "total_hours", "hours"):
value = timelog_data.get(key)
if value not in (None, ""):
raw_value = value
field_used = key
break
if raw_value in (None, ""):
return Decimal("0")
if field_used == "duration":
try:
seconds = Decimal(str(raw_value))
return (seconds / Decimal(3600)).quantize(Decimal("0.01"))
except Exception:
pass
raw_str = str(raw_value).strip().lower()
time_match = re.match(r"^(\d+):(\d+)(?::(\d+))?$", raw_str)
if time_match:
hours = Decimal(int(time_match.group(1)))
minutes = Decimal(int(time_match.group(2))) / Decimal(60)
seconds = Decimal(int(time_match.group(3) or 0)) / Decimal(3600)
return (hours + minutes + seconds).quantize(Decimal("0.01"))
if "h" in raw_str or "m" in raw_str or "s" in raw_str:
total_hours = Decimal("0")
hours_match = re.search(r"(\d+(?:[\.,]\d+)?)\s*h", raw_str)
mins_match = re.search(r"(\d+(?:[\.,]\d+)?)\s*m", raw_str)
secs_match = re.search(r"(\d+(?:[\.,]\d+)?)\s*s", raw_str)
if hours_match:
total_hours += Decimal(hours_match.group(1).replace(",", "."))
if mins_match:
total_hours += Decimal(mins_match.group(1).replace(",", ".")) / Decimal(60)
if secs_match:
total_hours += Decimal(secs_match.group(1).replace(",", ".")) / Decimal(3600)
return total_hours.quantize(Decimal("0.01"))
try:
return Decimal(raw_str.replace(",", ".")).quantize(Decimal("0.01"))
except Exception:
return Decimal("0")
async def query(self, query_string: str) -> List[Dict]:
"""
@ -506,7 +571,7 @@ class VTigerService:
logger.error(f"❌ Error updating subscription: {e}")
raise
async def get_service_contracts(self, account_id: Optional[str] = None) -> List[Dict]:
async def get_service_contracts(self, account_id: Optional[str] = None, active_only: bool = True) -> List[Dict]:
"""
Fetch service contracts from vTiger
@ -517,15 +582,26 @@ class VTigerService:
List of service contract records with account_id included
"""
try:
if account_id:
query = (
"SELECT * FROM ServiceContracts "
f"WHERE sc_related_to='{account_id}' AND contract_status='Active';"
)
logger.info(f"🔍 Fetching active service contracts for account {account_id}")
query_filters = []
safe_account_id = self._sanitize_vtiger_id(account_id)
if account_id and not safe_account_id:
logger.warning("⚠️ Rejected unsafe account_id for service contract query")
return []
if safe_account_id:
query_filters.append(f"sc_related_to='{safe_account_id}'")
if active_only:
query_filters.append("contract_status='Active'")
where_clause = f" WHERE {' AND '.join(query_filters)}" if query_filters else ""
query = f"SELECT * FROM ServiceContracts{where_clause};"
if safe_account_id:
logger.info(f"🔍 Fetching service contracts for account {safe_account_id} (active_only={active_only})")
else:
query = "SELECT * FROM ServiceContracts WHERE contract_status='Active';"
logger.info(f"🔍 Fetching all active service contracts")
logger.info(f"🔍 Fetching all service contracts (active_only={active_only})")
contracts = await self.query(query)
logger.info(f"✅ Found {len(contracts)} service contracts")
@ -634,6 +710,422 @@ class VTigerService:
logger.error(f"❌ Error fetching contract timelogs: {e}")
return []
async def get_service_contract_customers(self) -> List[Dict[str, str]]:
"""Fetch account list that has at least one service contract."""
contracts = await self.get_service_contracts(active_only=False)
account_ids = sorted({
c.get("account_id")
for c in contracts
if c.get("account_id")
})
if not account_ids:
return []
customers: Dict[str, Dict[str, str]] = {}
chunk_size = 20
for i in range(0, len(account_ids), chunk_size):
chunk = account_ids[i:i + chunk_size]
ids = "', '".join(chunk)
query = (
"SELECT id, accountname FROM Accounts "
f"WHERE id IN ('{ids}');"
)
rows = await self.query(query)
for row in rows:
account_id = row.get("id")
if account_id:
customers[account_id] = {
"account_id": account_id,
"account_name": row.get("accountname") or account_id,
}
# Fallback names for accounts not returned by Accounts query
for account_id in account_ids:
if account_id not in customers:
customers[account_id] = {
"account_id": account_id,
"account_name": account_id,
}
return sorted(customers.values(), key=lambda x: x.get("account_name", "").lower())
@staticmethod
def _is_closed_case(status: Optional[str]) -> bool:
if not status:
return False
normalized = status.strip().lower()
return normalized in {"closed", "resolved", "done", "completed", "lukket"}
@staticmethod
def _first_non_empty(record: Dict[str, Any], keys: tuple[str, ...]) -> str:
for key in keys:
value = record.get(key)
if value not in (None, ""):
text = str(value).strip()
if text:
return text
return ""
@staticmethod
def _html_to_text(value: Optional[str]) -> str:
"""Convert HTML snippets from CRM fields into readable plain text."""
if value in (None, ""):
return ""
text = str(value)
text = re.sub(r"(?i)<br\s*/?>", "\n", text)
text = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", text)
text = re.sub(r"(?is)<[^>]+>", " ", text)
text = html_lib.unescape(text)
text = text.replace("\xa0", " ")
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
@staticmethod
def _extract_vtiger_record_id(raw_id: Optional[str]) -> Optional[str]:
if not raw_id:
return None
text = str(raw_id).strip()
if not text:
return None
if re.match(r"^\d+x\d+$", text):
return text.split("x", 1)[1]
if text.isdigit():
return text
return None
def _build_vtiger_record_url(self, module: str, raw_id: Optional[str]) -> str:
if not self.base_url:
return ""
record_id = self._extract_vtiger_record_id(raw_id)
if not record_id:
return ""
base = str(self.base_url).rstrip("/")
if module == "Cases":
return f"{base}/view/detail?module=Cases&id={record_id}&viewtype=summary"
if module == "Timelog":
return f"{base}/view/detail?module=Timelog&id={record_id}&viewtype=summary"
return f"{base}/index.php?module={module}&view=Detail&record={record_id}"
@staticmethod
def _to_initials(value: Optional[str]) -> str:
if not value:
return "--"
text = str(value).strip()
if not text:
return "--"
# Raw vTiger entity IDs are not human initials.
if re.match(r"^\d+x\d+$", text):
return "--"
tokens = [token for token in re.split(r"[^A-Za-z0-9]+", text) if token]
if not tokens:
return "--"
if len(tokens) == 1:
return tokens[0][:2].upper()
return f"{tokens[0][0]}{tokens[1][0]}".upper()
async def _resolve_user_initials(self, user_refs: List[str]) -> Dict[str, str]:
initials_by_ref: Dict[str, str] = {}
safe_ids = []
for raw_ref in user_refs:
safe_ref = self._sanitize_vtiger_id(raw_ref)
if safe_ref:
safe_ids.append(safe_ref)
if not safe_ids:
return initials_by_ref
unique_ids = sorted(set(safe_ids))
chunk_size = 20
for index in range(0, len(unique_ids), chunk_size):
chunk = unique_ids[index:index + chunk_size]
id_list = "', '".join(chunk)
rows = await self.query(
"SELECT id, first_name, last_name, user_name FROM Users "
f"WHERE id IN ('{id_list}');"
)
for row in rows:
user_id = row.get("id")
if not user_id:
continue
full_name = " ".join(
part for part in [row.get("first_name"), row.get("last_name")] if part
).strip()
source = full_name or row.get("user_name") or user_id
initials_by_ref[user_id] = self._to_initials(source)
# Some timelog owners are groups, not user records.
group_rows = await self.query(
"SELECT id, groupname FROM Groups "
f"WHERE id IN ('{id_list}');"
)
for row in group_rows:
group_id = row.get("id")
if not group_id or group_id in initials_by_ref:
continue
initials_by_ref[group_id] = self._to_initials(row.get("groupname") or group_id)
return initials_by_ref
async def _resolve_contact_names(self, contact_refs: List[str]) -> Dict[str, str]:
names_by_ref: Dict[str, str] = {}
safe_ids = []
for raw_ref in contact_refs:
if not raw_ref:
continue
text = str(raw_ref).strip()
safe_ref = self._sanitize_vtiger_id(text)
if safe_ref and re.match(r"^\d+x\d+$", safe_ref):
safe_ids.append(safe_ref)
elif text:
names_by_ref[text] = text
if not safe_ids:
return names_by_ref
unique_ids = sorted(set(safe_ids))
chunk_size = 20
for index in range(0, len(unique_ids), chunk_size):
chunk = unique_ids[index:index + chunk_size]
id_list = "', '".join(chunk)
rows = await self.query(
"SELECT id, firstname, lastname, salutationtype FROM Contacts "
f"WHERE id IN ('{id_list}');"
)
for row in rows:
contact_id = row.get("id")
if not contact_id:
continue
parts = [
str(row.get("salutationtype") or "").strip(),
str(row.get("firstname") or "").strip(),
str(row.get("lastname") or "").strip(),
]
full_name = " ".join(part for part in parts if part)
names_by_ref[contact_id] = full_name or contact_id
return names_by_ref
async def get_service_contract_report_data(self, account_id: str, contract_id: str) -> Dict[str, Any]:
"""Build report payload for selected customer + service contract."""
safe_account_id = self._sanitize_vtiger_id(account_id)
safe_contract_id = self._sanitize_vtiger_id(contract_id)
if not safe_account_id or not safe_contract_id:
raise ValueError("Invalid account_id or contract_id")
contract_rows = await self.query(
f"SELECT * FROM ServiceContracts WHERE id='{safe_contract_id}' LIMIT 1;"
)
if not contract_rows:
raise ValueError("Service contract was not found")
contract = contract_rows[0]
contract_account_id = (
contract.get("account_id")
or contract.get("accountid")
or contract.get("cf_service_contracts_account")
or contract.get("sc_related_to")
or ""
)
# If contract is linked to a different customer, return clear validation error.
if contract_account_id and contract_account_id != safe_account_id:
raise ValueError("Service contract does not belong to selected customer")
account = await self.get_account_by_id(safe_account_id)
account_name = (account or {}).get("accountname") or safe_account_id
cases = await self.get_service_contract_cases(safe_contract_id)
timelogs = await self.get_service_contract_timelogs(safe_contract_id)
contact_ref_candidates = [
str(
case.get("contact_id")
or case.get("contactid")
or case.get("parent_contact_id")
or case.get("contactname")
or case.get("cf_contact_person")
or ""
)
for case in cases
if (
case.get("contact_id")
or case.get("contactid")
or case.get("parent_contact_id")
or case.get("contactname")
or case.get("cf_contact_person")
)
]
contact_name_map = await self._resolve_contact_names(contact_ref_candidates)
user_ref_candidates = [
str(
log.get("assigned_user_id")
or log.get("modifiedby")
or log.get("created_user_id")
or ""
)
for log in timelogs
if (
log.get("assigned_user_id")
or log.get("modifiedby")
or log.get("created_user_id")
)
]
user_initials_map = await self._resolve_user_initials(user_ref_candidates)
case_map: Dict[str, Dict[str, Any]] = {}
report_cases: List[Dict[str, Any]] = []
for case in cases:
case_id = case.get("id")
if not case_id:
continue
case_payload = {
"id": case_id,
"cc_number": self._first_non_empty(
case,
("ticket_no", "case_no", "caseno", "ticketid", "cf_case_number")
) or case_id,
"title": self._first_non_empty(
case,
("ticket_title", "tickettitle", "subject", "title")
) or f"Case {case_id}",
"description": self._html_to_text(
self._first_non_empty(
case,
("description", "ticketdescription", "solution", "comments", "comment")
)
) or "Ingen beskrivelse",
"contact_person": contact_name_map.get(
str(
case.get("contact_id")
or case.get("contactid")
or case.get("parent_contact_id")
or case.get("contactname")
or case.get("cf_contact_person")
or ""
),
self._first_non_empty(
case,
("contactname", "contact_id", "contactid", "cf_contact_person")
) or "-"
),
"vtiger_url": self._build_vtiger_record_url("Cases", case_id),
"status": case.get("ticketstatus") or case.get("status"),
"priority": case.get("ticketpriorities") or case.get("priority"),
"total_hours": Decimal("0"),
"timelog_count": 0,
"timelogs": [],
}
case_map[case_id] = case_payload
report_cases.append(case_payload)
unmatched_case_id = "UNMAPPED"
for log in timelogs:
related_case_id = (
log.get("relatedto")
or log.get("case_id")
or log.get("ticket_id")
or log.get("parent_id")
)
raw_user = (
log.get("assigned_user_id")
or log.get("modifiedby")
or log.get("created_user_id")
or ""
)
hours = self._extract_timelog_hours(log)
timelog_payload = {
"id": log.get("id") or "",
"related_case_id": related_case_id,
"worked_date": log.get("date_start") or log.get("createdtime") or log.get("modifiedtime"),
"user_name": raw_user,
"employee_initials": user_initials_map.get(str(raw_user), self._to_initials(str(raw_user))),
"description": self._html_to_text(
self._first_non_empty(
log,
(
"description",
"subject",
"commentcontent",
"comments",
"details",
"note",
"notes",
"cf_timelog_description",
),
)
) or "Ingen beskrivelse",
"status": log.get("status"),
"billable": log.get("billable"),
"hours": hours,
"vtiger_url": self._build_vtiger_record_url("Timelog", log.get("id")),
}
if related_case_id in case_map:
target_case = case_map[related_case_id]
else:
if unmatched_case_id not in case_map:
unmatched_case = {
"id": unmatched_case_id,
"cc_number": "-",
"title": "Timelogs uden relateret case",
"description": "Ingen relateret sag fra servicekontrakten.",
"contact_person": "-",
"vtiger_url": "",
"status": "Unknown",
"priority": None,
"total_hours": Decimal("0"),
"timelog_count": 0,
"timelogs": [],
}
case_map[unmatched_case_id] = unmatched_case
report_cases.append(unmatched_case)
target_case = case_map[unmatched_case_id]
target_case["timelogs"].append(timelog_payload)
target_case["timelog_count"] += 1
target_case["total_hours"] = (target_case["total_hours"] + hours).quantize(Decimal("0.01"))
total_timelogs = sum(int(case_item["timelog_count"]) for case_item in report_cases)
total_hours = sum((case_item["total_hours"] for case_item in report_cases), Decimal("0")).quantize(Decimal("0.01"))
closed_cases = sum(
1 for case_item in report_cases
if self._is_closed_case(case_item.get("status"))
)
open_cases = max(0, len(report_cases) - closed_cases)
return {
"customer": {
"account_id": safe_account_id,
"account_name": account_name,
},
"contract": {
"id": safe_contract_id,
"contract_number": contract.get("contract_number") or contract.get("contract_no") or "",
"subject": contract.get("subject") or "",
"contract_status": contract.get("contract_status"),
"vtiger_url": self._build_vtiger_record_url("ServiceContracts", safe_contract_id),
},
"cases": report_cases,
"summary": {
"total_cases": len(report_cases),
"open_cases": open_cases,
"closed_cases": closed_cases,
"total_timelogs": total_timelogs,
"total_hours": total_hours,
},
}
# Singleton instance
_vtiger_service = None

View File

@ -1492,7 +1492,7 @@ async def scan_document(file_path: str):
<option value="/ticket/dashboard/technician/v3" {% if default_dashboard_path == '/ticket/dashboard/technician/v3' %}selected{% endif %}>Tekniker Dashboard V3</option>
<option value="/dashboard/sales" {% if default_dashboard_path == '/dashboard/sales' %}selected{% endif %}>Salg Dashboard</option>
<option value="/dashboard/mission-control" {% if default_dashboard_path == '/dashboard/mission-control' %}selected{% endif %}>Mission Control</option>
{% if default_dashboard_path and default_dashboard_path not in ['/ticket/dashboard/technician/v1', '/ticket/dashboard/technician/v2', '/ticket/dashboard/technician/v3', '/dashboard/sales', '/dashboard/mission-control'] %}
{% if default_dashboard_path and default_dashboard_path not in ['/ticket/dashboard/technician/v1', '/ticket/dashboard/technician/v2', '/ticket/dashboard/technician/v3', '/dashboard/sales', '/dashboard/mission-control', '/dashboard/mission-control.old'] %}
<option value="{{ default_dashboard_path }}" selected>Nuværende (tilpasset): {{ default_dashboard_path }}</option>
{% endif %}
</select>

View File

@ -783,13 +783,18 @@
<i class="bi bi-people me-2"></i>CRM
</a>
<ul class="dropdown-menu mt-2">
<li><h6 class="dropdown-header">Kunderelationer</h6></li>
<li><a class="dropdown-item py-2" href="/customers">Kunder</a></li>
<li><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
<li><a class="dropdown-item py-2" href="/links">Links</a></li>
<li><a class="dropdown-item py-2" href="/vendors">Leverandører</a></li>
<li><a class="dropdown-item py-2" href="#">Leads</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
<li><h6 class="dropdown-header">Struktur</h6></li>
<li><a class="dropdown-item py-2" href="/links">Links</a></li>
<li><a class="dropdown-item py-2" href="/app/locations">Lokaliteter</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Pipeline</h6></li>
<li><a class="dropdown-item py-2" href="/opportunities"><i class="bi bi-briefcase me-2"></i>Muligheder</a></li>
<li><a class="dropdown-item py-2" href="/pipeline"><i class="bi bi-diagram-3 me-2"></i>Pipeline</a></li>
</ul>
</li>
<li class="nav-item">
@ -807,21 +812,20 @@
<i class="bi bi-headset me-2"></i>Support
</a>
<ul class="dropdown-menu mt-2">
<li><h6 class="dropdown-header">Support</h6></li>
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>BMC Assets</a></li>
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
<li><a class="dropdown-item py-2" href="/emails"><i class="bi bi-envelope me-2"></i>Email</a></li>
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
<li><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li><h6 class="dropdown-header">Assets & Licenser</h6></li>
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>BMC Assets</a></li>
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><h6 class="dropdown-header">Værktøjer</h6></li>
<li><a class="dropdown-item py-2" href="/manual"><i class="bi bi-journal-richtext me-2"></i>Manualer</a></li>
</ul>
</li>
@ -830,14 +834,10 @@
<i class="bi bi-cart3 me-2"></i>Salg
</a>
<ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="#">Tilbud</a></li>
<li><h6 class="dropdown-header">Salg</h6></li>
<li><a class="dropdown-item py-2" href="/ordre"><i class="bi bi-receipt me-2"></i>Ordre</a></li>
<li><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/opportunities"><i class="bi bi-briefcase me-2"></i>Muligheder</a></li>
<li><a class="dropdown-item py-2" href="/pipeline"><i class="bi bi-diagram-3 me-2"></i>Pipeline</a></li>
</ul>
</li>
<li class="nav-item dropdown">
@ -845,19 +845,16 @@
<i class="bi bi-currency-dollar me-2"></i>Økonomi
</a>
<ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
<li><h6 class="dropdown-header">Fakturering</h6></li>
<li><a class="dropdown-item py-2" href="/economy/time-queue"><i class="bi bi-clock-history me-2"></i>Time Queue</a></li>
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
<li><h6 class="dropdown-header">Aftaler</h6></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="/emails">
<i class="bi bi-envelope me-2"></i>Email
</a>
</li>
</ul>
<div class="d-flex align-items-center gap-3">
<div class="dropdown">
@ -868,7 +865,9 @@
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/employee-log"><i class="bi bi-bar-chart-steps me-2"></i>Medarbejder Log</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-report"><i class="bi bi-file-earmark-bar-graph me-2"></i>Servicekontrakt Rapport</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul>
@ -888,6 +887,9 @@
<button class="btn btn-light rounded-circle border-0" id="globalRemindersBtn" style="background: var(--accent-light); color: var(--accent);" title="Åbn reminders">
<i class="bi bi-bell"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="bugReportBtn" style="background: var(--accent-light); color: var(--accent);" title="Rapporter fejl (Ctrl+Shift+B)">
<i class="bi bi-bug"></i>
</button>
<div class="dropdown">
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
@ -1109,6 +1111,8 @@
</div>
</div>
{% include "shared/frontend/bug_report_modal.html" %}
{% block content_wrapper %}
<div class="container-fluid px-4 py-4">
{% block content %}{% endblock %}
@ -1265,6 +1269,7 @@ window.addEventListener('unhandledrejection', function(event) {
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.2"></script>
<script src="/static/js/sms.js?v=1.0"></script>
<script src="/static/js/bug-report.js?v=1.0"></script>
<script src="/static/js/bottom-bar.js?v=2.23"></script>
<script>
// Dark Mode Toggle Logic

View File

@ -523,6 +523,72 @@ class ServiceContractWizardSummary(BaseModel):
timestamp: datetime
class ServiceContractReportCustomer(BaseModel):
"""Customer selector payload for service contract report."""
account_id: str
account_name: str
class ServiceContractReportContract(BaseModel):
"""Service contract selector payload for report."""
id: str
contract_number: str = ""
subject: str = ""
contract_status: Optional[str] = None
vtiger_url: str = ""
class ServiceContractReportTimelog(BaseModel):
"""Single timelog row in report."""
id: str
related_case_id: Optional[str] = None
worked_date: Optional[str] = None
user_name: Optional[str] = None
employee_initials: str = ""
description: str = ""
status: Optional[str] = None
billable: Optional[bool] = None
hours: Decimal = Field(default=Decimal("0"), ge=0)
vtiger_url: str = ""
class ServiceContractReportCase(BaseModel):
"""Case with nested timelogs for report display."""
id: str
cc_number: str = ""
title: str
description: str = ""
contact_person: str = ""
vtiger_url: str = ""
status: Optional[str] = None
priority: Optional[str] = None
total_hours: Decimal = Field(default=Decimal("0"), ge=0)
timelog_count: int = Field(default=0, ge=0)
timelogs: List[ServiceContractReportTimelog] = Field(default_factory=list)
class ServiceContractReportSummary(BaseModel):
"""Computed totals for report footer/cards."""
total_cases: int = Field(default=0, ge=0)
open_cases: int = Field(default=0, ge=0)
closed_cases: int = Field(default=0, ge=0)
total_timelogs: int = Field(default=0, ge=0)
total_hours: Decimal = Field(default=Decimal("0"), ge=0)
class ServiceContractReportData(BaseModel):
"""Complete report payload shared by web and PDF output."""
customer: ServiceContractReportCustomer
contract: ServiceContractReportContract
cases: List[ServiceContractReportCase] = Field(default_factory=list)
summary: ServiceContractReportSummary
generated_at: datetime = Field(default_factory=datetime.utcnow)
# Backward compatibility: older workers mistakenly expected wizard summary fields here.
failed_items: int = 0
status: Optional[str] = None
timestamp: Optional[datetime] = None
class TimologTransferRequest(BaseModel):
"""Request to transfer single timelog to klippekort"""
timelog_id: str = Field(..., description="vTiger timelog ID")

View File

@ -7,12 +7,13 @@ Isoleret routing uden påvirkning af existing Hub endpoints.
"""
import logging
from io import BytesIO
from typing import Optional, List, Dict, Any
from datetime import datetime, date
from datetime import datetime, date, timedelta
from calendar import monthrange
from fastapi import APIRouter, HTTPException, Depends, Body, Query
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, Response
from app.core.database import execute_query, execute_update, execute_query_single
from app.timetracking.backend.models import (
@ -33,6 +34,9 @@ from app.timetracking.backend.models import (
ServiceContractWizardData,
ServiceContractWizardAction,
ServiceContractWizardSummary,
ServiceContractReportCustomer,
ServiceContractReportContract,
ServiceContractReportData,
TimologTransferRequest,
TimologTransferResult,
)
@ -1834,6 +1838,192 @@ async def list_time_entries(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/employee-log/overview", tags=["Times"])
async def get_employee_log_overview(
granularity: str = Query("week", pattern="^(day|week|month)$"),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
target_daily_hours: float = Query(7.5, ge=0, le=24),
):
"""Employee time overview with gap detection for day/week/month buckets."""
try:
today = date.today()
if not end_date:
end_date = today
if not start_date:
if granularity == "day":
start_date = end_date - timedelta(days=13)
elif granularity == "week":
start_date = end_date - timedelta(days=55)
else:
start_date = end_date.replace(day=1) - timedelta(days=150)
if start_date > end_date:
raise HTTPException(status_code=400, detail="start_date must be before end_date")
query = """
SELECT
COALESCE(t.medarbejder_id::text, NULLIF(t.user_name, ''), 'unknown') AS employee_key,
COALESCE(NULLIF(u.full_name, ''), NULLIF(u.username, ''), NULLIF(t.user_name, ''), 'Ukendt') AS employee_name,
DATE(COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at)) AS work_day,
SUM(COALESCE(t.original_hours, 0))::numeric AS total_hours,
COUNT(*)::int AS entry_count,
COUNT(DISTINCT t.case_id)::int AS case_count,
SUM(CASE WHEN t.case_id IS NULL THEN 1 ELSE 0 END)::int AS missing_case_entries
FROM tmodule_times t
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE DATE(COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at)) BETWEEN %s AND %s
GROUP BY employee_key, employee_name, work_day
ORDER BY employee_name, work_day
"""
rows = execute_query(query, (start_date, end_date)) or []
def bucket_start(day: date) -> date:
if granularity == "day":
return day
if granularity == "week":
return day - timedelta(days=day.weekday())
return day.replace(day=1)
def next_bucket_start(day: date) -> date:
if granularity == "day":
return day + timedelta(days=1)
if granularity == "week":
return day + timedelta(days=7)
year = day.year + (1 if day.month == 12 else 0)
month = 1 if day.month == 12 else day.month + 1
return date(year, month, 1)
def working_days_between(start: date, end: date) -> int:
count = 0
cursor = start
while cursor <= end:
if cursor.weekday() < 5:
count += 1
cursor += timedelta(days=1)
return count
first_bucket = bucket_start(start_date)
periods: List[Dict[str, Any]] = []
cursor = first_bucket
while cursor <= end_date:
bucket_end = next_bucket_start(cursor) - timedelta(days=1)
label = cursor.strftime("%Y-%m-%d")
if granularity == "week":
label = f"Uge {cursor.isocalendar().week} ({cursor.strftime('%d/%m')})"
elif granularity == "month":
label = cursor.strftime("%b %Y")
periods.append({
"key": cursor.isoformat(),
"label": label,
"start_date": cursor.isoformat(),
"end_date": min(bucket_end, end_date).isoformat(),
})
cursor = next_bucket_start(cursor)
employees: Dict[str, Dict[str, Any]] = {}
for row in rows:
work_day = row.get("work_day")
if not work_day:
continue
if isinstance(work_day, str):
work_day = date.fromisoformat(work_day)
period_key = bucket_start(work_day).isoformat()
employee_key = row.get("employee_key") or "unknown"
employee_name = row.get("employee_name") or "Ukendt"
if employee_key not in employees:
employees[employee_key] = {
"employee_key": employee_key,
"employee_name": employee_name,
"periods": {},
"total_hours": 0.0,
"total_entries": 0,
"total_cases": 0,
"missing_case_entries": 0,
}
employee_row = employees[employee_key]
if period_key not in employee_row["periods"]:
employee_row["periods"][period_key] = {
"hours": 0.0,
"entries": 0,
"cases": 0,
"missing_case_entries": 0,
}
period_obj = employee_row["periods"][period_key]
hours = float(row.get("total_hours") or 0)
entries = int(row.get("entry_count") or 0)
cases = int(row.get("case_count") or 0)
missing_cases = int(row.get("missing_case_entries") or 0)
period_obj["hours"] += hours
period_obj["entries"] += entries
period_obj["cases"] += cases
period_obj["missing_case_entries"] += missing_cases
employee_row["total_hours"] += hours
employee_row["total_entries"] += entries
employee_row["total_cases"] += cases
employee_row["missing_case_entries"] += missing_cases
for employee in employees.values():
missing_periods = 0
for period in periods:
p_start = date.fromisoformat(period["start_date"])
p_end = date.fromisoformat(period["end_date"])
workdays = working_days_between(p_start, p_end)
expected = round(workdays * target_daily_hours, 2)
obj = employee["periods"].get(period["key"], {
"hours": 0.0,
"entries": 0,
"cases": 0,
"missing_case_entries": 0,
})
gap_hours = round(max(expected - obj["hours"], 0.0), 2)
status = "ok"
if expected > 0 and obj["hours"] == 0:
status = "missing"
elif gap_hours >= 2:
status = "warning"
if status != "ok":
missing_periods += 1
obj.update({
"expected_hours": expected,
"gap_hours": gap_hours,
"status": status,
})
employee["periods"][period["key"]] = obj
employee["missing_periods"] = missing_periods
employee["completeness_percent"] = 0.0 if not periods else round(
(len(periods) - missing_periods) / len(periods) * 100,
1,
)
result = sorted(employees.values(), key=lambda e: (e["employee_name"] or "").lower())
return {
"granularity": granularity,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"target_daily_hours": target_daily_hours,
"periods": periods,
"employees": result,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error building employee log overview: {e}")
raise HTTPException(status_code=500, detail="Kunne ikke hente medarbejder log")
@router.get("/times/{time_id}", tags=["Times"])
async def get_time_entry(time_id: int):
"""
@ -3169,3 +3359,303 @@ async def get_customer_klippekort_cards(customer_id: int):
except Exception as e:
logger.error(f"❌ Error fetching customer cards: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# SERVICE CONTRACT REPORT ENDPOINTS
# ============================================================================
def _build_service_contract_report_pdf(report_data: ServiceContractReportData) -> bytes:
"""Generate PDF bytes from report payload for customer delivery."""
try:
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
except ModuleNotFoundError as exc:
raise RuntimeError("PDF library not installed: reportlab") from exc
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
page_width, page_height = A4
left = 18 * mm
right = page_width - (18 * mm)
y = page_height - (20 * mm)
def ensure_space(lines_needed: int = 1):
nonlocal y
min_y = 20 * mm
required = lines_needed * 6 * mm
if y - required < min_y:
pdf.showPage()
y = page_height - (20 * mm)
def draw_line(text: str, size: int = 10, bold: bool = False):
nonlocal y
ensure_space(1)
font_name = "Helvetica-Bold" if bold else "Helvetica"
pdf.setFont(font_name, size)
pdf.drawString(left, y, text[:140])
y -= 6 * mm
pdf.setTitle("Servicekontrakt Rapport")
draw_line("BMC Hub - Servicekontrakt Rapport", size=14, bold=True)
draw_line(f"Kunde: {report_data.customer.account_name} ({report_data.customer.account_id})", bold=True)
draw_line(
f"Kontrakt: {report_data.contract.contract_number or '-'} | {report_data.contract.subject or '-'}",
bold=False,
)
if report_data.contract.vtiger_url:
draw_line(f"vTiger: {report_data.contract.vtiger_url}", size=9)
draw_line(f"Genereret: {report_data.generated_at.strftime('%Y-%m-%d %H:%M')}", bold=False)
y -= 2 * mm
summary = report_data.summary
draw_line(
(
f"Total cases: {summary.total_cases} | Timelogs: {summary.total_timelogs} | Timer: {summary.total_hours}"
),
bold=True,
)
y -= 2 * mm
for case_item in report_data.cases:
draw_line(
f"Case {case_item.cc_number or case_item.id}: {case_item.title}",
size=11,
bold=True,
)
if case_item.vtiger_url:
draw_line(f"Case link: {case_item.vtiger_url}", size=8)
draw_line(f"Kontaktperson: {case_item.contact_person or '-'}", size=9)
draw_line(f"Beskrivelse: {case_item.description or '-'}", size=9)
draw_line(
(
f"Prioritet: {case_item.priority or '-'} | "
f"Timelogs: {case_item.timelog_count} | Timer: {case_item.total_hours}"
),
size=9,
)
if not case_item.timelogs:
draw_line("Ingen timelogs fundet", size=9)
y -= 1 * mm
continue
for timelog in case_item.timelogs:
description = (timelog.description or "").replace("\n", " ").strip()
if len(description) > 70:
description = f"{description[:67]}..."
draw_line(
(
f"- {timelog.worked_date or '-'} | {timelog.employee_initials or '-'} | "
f"{timelog.hours}h | {description}"
),
size=8,
)
if timelog.vtiger_url:
draw_line(f" Link: {timelog.vtiger_url}", size=7)
y -= 2 * mm
pdf.showPage()
pdf.save()
buffer.seek(0)
return buffer.getvalue()
def _build_service_contract_report_excel(report_data: ServiceContractReportData) -> bytes:
"""Generate Excel bytes from report payload for customer delivery."""
try:
from openpyxl import Workbook
from openpyxl.styles import Font
except ModuleNotFoundError as exc:
raise RuntimeError("Excel library not installed: openpyxl") from exc
workbook = Workbook()
sheet = workbook.active
sheet.title = "Servicekontrakt"
sheet.append(["Kunde", report_data.customer.account_name])
sheet.append(["Kunde ID", report_data.customer.account_id])
sheet.append(["Kontrakt", report_data.contract.contract_number or "-"])
sheet.append(["Kontrakt titel", report_data.contract.subject or "-"])
sheet.append(["Kontrakt link", report_data.contract.vtiger_url or ""])
sheet.append(["Genereret", report_data.generated_at.strftime("%Y-%m-%d %H:%M")])
sheet.append([])
headers = [
"CC nummer",
"Case ID",
"Case titel",
"Case link",
"Kontaktperson",
"Case beskrivelse",
"Prioritet",
"Timelog ID",
"Timelog link",
"Dato",
"Medarbejder initialer",
"Timer",
"Log beskrivelse",
]
sheet.append(headers)
header_row = sheet.max_row
for col_idx in range(1, len(headers) + 1):
sheet.cell(row=header_row, column=col_idx).font = Font(bold=True)
for case_item in report_data.cases:
timelogs = case_item.timelogs or []
if not timelogs:
sheet.append([
case_item.cc_number or case_item.id,
case_item.id,
case_item.title,
case_item.vtiger_url or "",
case_item.contact_person or "",
case_item.description or "",
case_item.priority or "",
"",
"",
"",
"",
float(case_item.total_hours),
"",
])
continue
for timelog in timelogs:
sheet.append([
case_item.cc_number or case_item.id,
case_item.id,
case_item.title,
case_item.vtiger_url or "",
case_item.contact_person or "",
case_item.description or "",
case_item.priority or "",
timelog.id,
timelog.vtiger_url or "",
timelog.worked_date or "",
timelog.employee_initials or "",
float(timelog.hours),
timelog.description or "",
])
for col in ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M"]:
sheet.column_dimensions[col].width = 22
output = BytesIO()
workbook.save(output)
output.seek(0)
return output.getvalue()
@router.get(
"/service-contract-report/customers",
response_model=List[ServiceContractReportCustomer],
tags=["Service Contracts"],
)
async def get_service_contract_report_customers():
"""List customers with service contracts for report filter."""
try:
customers = await get_vtiger_service().get_service_contract_customers()
return [ServiceContractReportCustomer(**item) for item in customers]
except Exception as e:
logger.error(f"❌ Error loading report customers: {e}")
raise HTTPException(status_code=500, detail="Kunne ikke hente kunder til rapport")
@router.get(
"/service-contract-report/contracts",
response_model=List[ServiceContractReportContract],
tags=["Service Contracts"],
)
async def get_service_contract_report_contracts(account_id: str = Query(..., min_length=2)):
"""List service contracts for a selected customer account."""
try:
vtiger = get_vtiger_service()
contracts = await vtiger.get_service_contracts(account_id=account_id, active_only=False)
return [
ServiceContractReportContract(
id=contract.get("id", ""),
contract_number=contract.get("contract_number") or contract.get("contract_no") or "",
subject=contract.get("subject") or "",
contract_status=contract.get("contract_status"),
)
for contract in contracts
if contract.get("id")
]
except Exception as e:
logger.error(f"❌ Error loading contracts for report: {e}")
raise HTTPException(status_code=500, detail="Kunne ikke hente servicekontrakter")
@router.get(
"/service-contract-report/data",
response_model=ServiceContractReportData,
tags=["Service Contracts"],
)
async def get_service_contract_report_data(
account_id: str = Query(..., min_length=2),
contract_id: str = Query(..., min_length=2),
):
"""Get full case + timelog report for selected customer + contract."""
try:
payload = await get_vtiger_service().get_service_contract_report_data(account_id, contract_id)
return ServiceContractReportData(**payload)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error loading service contract report data: {e}")
raise HTTPException(status_code=500, detail="Kunne ikke hente rapportdata fra vTiger")
@router.get("/service-contract-report/pdf", tags=["Service Contracts"])
async def download_service_contract_report_pdf(
account_id: str = Query(..., min_length=2),
contract_id: str = Query(..., min_length=2),
):
"""Generate and return PDF for selected customer + service contract."""
try:
payload = await get_vtiger_service().get_service_contract_report_data(account_id, contract_id)
report_data = ServiceContractReportData(**payload)
pdf_bytes = _build_service_contract_report_pdf(report_data)
file_name = f"servicekontrakt-rapport-{report_data.contract.id}.pdf"
headers = {
"Content-Disposition": f'attachment; filename="{file_name}"'
}
return Response(content=pdf_bytes, media_type="application/pdf", headers=headers)
except RuntimeError as e:
logger.error(f"❌ PDF dependency missing: {e}")
raise HTTPException(status_code=503, detail="PDF-funktion er ikke tilgaengelig endnu (mangler reportlab i container)")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error generating service contract report PDF: {e}")
raise HTTPException(status_code=500, detail="Kunne ikke generere PDF")
@router.get("/service-contract-report/excel", tags=["Service Contracts"])
async def download_service_contract_report_excel(
account_id: str = Query(..., min_length=2),
contract_id: str = Query(..., min_length=2),
):
"""Generate and return Excel for selected customer + service contract."""
try:
payload = await get_vtiger_service().get_service_contract_report_data(account_id, contract_id)
report_data = ServiceContractReportData(**payload)
excel_bytes = _build_service_contract_report_excel(report_data)
file_name = f"servicekontrakt-rapport-{report_data.contract.id}.xlsx"
headers = {
"Content-Disposition": f'attachment; filename="{file_name}"'
}
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
return Response(content=excel_bytes, media_type=media_type, headers=headers)
except RuntimeError as e:
logger.error(f"❌ Excel dependency missing: {e}")
raise HTTPException(status_code=503, detail="Excel-funktion er ikke tilgaengelig endnu (mangler openpyxl i container)")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error generating service contract report Excel: {e}")
raise HTTPException(status_code=500, detail="Kunne ikke generere Excel")

View File

@ -0,0 +1,320 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Medarbejder Log - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.log-hero {
background: linear-gradient(135deg, #0f4c75 0%, #1b262c 100%);
color: #fff;
padding: 1.6rem;
border-radius: 14px;
margin-bottom: 1.2rem;
}
.log-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
}
.filters {
display: grid;
grid-template-columns: repeat(5, minmax(150px, 1fr));
gap: 0.8rem;
align-items: end;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(140px, 1fr));
gap: 0.75rem;
}
.stat-box {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 0.8rem;
text-align: center;
background: color-mix(in srgb, var(--accent, #0f4c75) 8%, var(--bg-card));
}
.stat-box .value {
font-size: 1.3rem;
font-weight: 700;
}
.log-table-wrap {
overflow-x: auto;
}
.log-table {
width: 100%;
border-collapse: collapse;
min-width: 980px;
}
.log-table th,
.log-table td {
border-bottom: 1px solid var(--border-color);
padding: 0.55rem;
vertical-align: top;
font-size: 0.86rem;
}
.sticky-col {
position: sticky;
left: 0;
background: var(--bg-card);
z-index: 2;
min-width: 200px;
border-right: 1px solid var(--border-color);
}
.period-cell {
min-width: 130px;
border-radius: 8px;
padding: 0.45rem;
}
.status-ok {
background: #d1e7dd;
color: #0f5132;
}
.status-warning {
background: #fff3cd;
color: #664d03;
}
.status-missing {
background: #f8d7da;
color: #842029;
}
.small-muted {
color: var(--text-secondary);
font-size: 0.78rem;
}
@media (max-width: 992px) {
.filters {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="log-hero">
<h1 class="h4 mb-1">Medarbejder Log</h1>
<div>Visualiser registreret tid over dag, uge og maaned, og find manglende tider/sager.</div>
</div>
<div class="log-card">
<div class="filters">
<div>
<label class="form-label small">Visning</label>
<select id="granularity" class="form-select">
<option value="day">Dag</option>
<option value="week" selected>Uge</option>
<option value="month">Maaned</option>
</select>
</div>
<div>
<label class="form-label small">Fra dato</label>
<input id="startDate" type="date" class="form-control">
</div>
<div>
<label class="form-label small">Til dato</label>
<input id="endDate" type="date" class="form-control">
</div>
<div>
<label class="form-label small">Maal timer/dag</label>
<input id="targetHours" type="number" min="0" max="24" step="0.5" value="7.5" class="form-control">
</div>
<div>
<button id="loadBtn" class="btn btn-primary w-100"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button>
</div>
</div>
</div>
<div class="log-card">
<div class="stats-grid" id="statsGrid"></div>
</div>
<div id="errorBox" class="alert alert-danger d-none"></div>
<div class="log-card log-table-wrap">
<table class="log-table">
<thead>
<tr id="tableHeadRow">
<th class="sticky-col">Medarbejder</th>
<th>Total timer</th>
<th>Registreringer uden sag</th>
<th>Completeness</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="12" class="py-4 text-center small-muted">Henter data...</td></tr>
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const granularityEl = document.getElementById('granularity');
const startDateEl = document.getElementById('startDate');
const endDateEl = document.getElementById('endDate');
const targetHoursEl = document.getElementById('targetHours');
const loadBtnEl = document.getElementById('loadBtn');
const tableHeadRowEl = document.getElementById('tableHeadRow');
const tableBodyEl = document.getElementById('tableBody');
const statsGridEl = document.getElementById('statsGrid');
const errorBoxEl = document.getElementById('errorBox');
document.addEventListener('DOMContentLoaded', async () => {
setDefaultDates();
loadBtnEl.addEventListener('click', loadOverview);
granularityEl.addEventListener('change', setDefaultDates);
await loadOverview();
});
function setDefaultDates() {
const today = new Date();
const end = formatDate(today);
let start = new Date(today);
if (granularityEl.value === 'day') {
start.setDate(today.getDate() - 13);
} else if (granularityEl.value === 'week') {
start.setDate(today.getDate() - 55);
} else {
start.setMonth(today.getMonth() - 5);
start.setDate(1);
}
startDateEl.value = formatDate(start);
endDateEl.value = end;
}
async function loadOverview() {
errorBoxEl.classList.add('d-none');
const params = new URLSearchParams({
granularity: granularityEl.value,
start_date: startDateEl.value,
end_date: endDateEl.value,
target_daily_hours: targetHoursEl.value || '7.5'
});
try {
const response = await fetch(`/api/v1/timetracking/employee-log/overview?${params.toString()}`);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.detail || 'Kunne ikke hente medarbejder log');
}
renderOverview(payload);
} catch (error) {
errorBoxEl.textContent = error.message || 'Ukendt fejl';
errorBoxEl.classList.remove('d-none');
tableBodyEl.innerHTML = '<tr><td colspan="12" class="py-4 text-center text-danger">Fejl ved hentning af data</td></tr>';
}
}
function renderOverview(payload) {
const periods = payload.periods || [];
const employees = payload.employees || [];
tableHeadRowEl.innerHTML = `
<th class="sticky-col">Medarbejder</th>
<th>Total timer</th>
<th>Registreringer uden sag</th>
<th>Completeness</th>
${periods.map(p => `<th>${escapeHtml(p.label)}</th>`).join('')}
`;
if (!employees.length) {
tableBodyEl.innerHTML = `<tr><td colspan="${4 + periods.length}" class="py-4 text-center small-muted">Ingen data i valgt periode</td></tr>`;
renderStats([]);
return;
}
tableBodyEl.innerHTML = employees.map(employee => {
const periodCells = periods.map(period => {
const p = employee.periods[period.key] || {hours: 0, expected_hours: 0, gap_hours: 0, entries: 0, cases: 0, missing_case_entries: 0, status: 'missing'};
return `
<td>
<div class="period-cell status-${p.status}">
<div><strong>${formatHours(p.hours)}h</strong> / ${formatHours(p.expected_hours)}h</div>
<div class="small-muted">Gap: ${formatHours(p.gap_hours)}h</div>
<div class="small-muted">Cases: ${p.cases} | Uden sag: ${p.missing_case_entries}</div>
</div>
</td>
`;
}).join('');
return `
<tr>
<td class="sticky-col">
<div><strong>${escapeHtml(employee.employee_name || 'Ukendt')}</strong></div>
<div class="small-muted">${escapeHtml(employee.employee_key || '-')}</div>
</td>
<td>${formatHours(employee.total_hours)}h</td>
<td>${employee.missing_case_entries || 0}</td>
<td>${employee.completeness_percent || 0}%</td>
${periodCells}
</tr>
`;
}).join('');
renderStats(employees);
}
function renderStats(employees) {
const totals = employees.reduce((acc, e) => {
acc.hours += Number(e.total_hours || 0);
acc.missingCases += Number(e.missing_case_entries || 0);
acc.missingPeriods += Number(e.missing_periods || 0);
return acc;
}, {hours: 0, missingCases: 0, missingPeriods: 0});
const avgCompleteness = employees.length
? (employees.reduce((sum, e) => sum + Number(e.completeness_percent || 0), 0) / employees.length)
: 0;
statsGridEl.innerHTML = `
<div class="stat-box"><div class="value">${employees.length}</div><div class="small-muted">Medarbejdere</div></div>
<div class="stat-box"><div class="value">${formatHours(totals.hours)}h</div><div class="small-muted">Total registreret</div></div>
<div class="stat-box"><div class="value">${totals.missingPeriods}</div><div class="small-muted">Manglende perioder</div></div>
<div class="stat-box"><div class="value">${totals.missingCases}</div><div class="small-muted">Registreringer uden sag</div></div>
<div class="stat-box"><div class="value">${avgCompleteness.toFixed(1)}%</div><div class="small-muted">Gns. completeness</div></div>
`;
}
function formatDate(dateObj) {
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatHours(v) {
return Number(v || 0).toFixed(1);
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#039;');
}
</script>
{% endblock %}

View File

@ -0,0 +1,431 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Servicekontrakt Rapport - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.report-hero {
background: linear-gradient(135deg, #0f4c75 0%, #0a3a59 100%);
color: #ffffff;
border-radius: 14px;
padding: 1.75rem;
margin-bottom: 1.5rem;
box-shadow: 0 16px 34px rgba(15, 76, 117, 0.24);
}
.report-hero h1 {
margin: 0;
font-size: 1.55rem;
font-weight: 700;
}
.report-hero p {
margin: 0.4rem 0 0;
opacity: 0.9;
}
.report-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.25rem;
}
.filters-grid {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.9rem;
align-items: end;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 0.75rem;
margin: 1rem 0;
}
.metric {
background: color-mix(in srgb, var(--accent, #0f4c75) 10%, var(--bg-card));
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 0.8rem;
text-align: center;
}
.metric .value {
font-size: 1.3rem;
font-weight: 700;
line-height: 1.1;
}
.metric .label {
font-size: 0.82rem;
opacity: 0.75;
}
.case-card {
border: 1px solid var(--border-color);
border-radius: 10px;
margin-bottom: 0.8rem;
overflow: hidden;
}
.case-head {
background: color-mix(in srgb, var(--accent, #0f4c75) 8%, var(--bg-card));
padding: 0.8rem 1rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.case-title {
font-weight: 700;
}
.table-wrap {
padding: 0.5rem 1rem 1rem;
}
.table-sm th,
.table-sm td {
font-size: 0.86rem;
vertical-align: top;
}
.status-pill {
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
border: 1px solid var(--border-color);
background: var(--bg-card);
}
.empty-state {
border: 1px dashed var(--border-color);
border-radius: 12px;
padding: 2rem 1rem;
text-align: center;
opacity: 0.8;
}
@media (max-width: 992px) {
.filters-grid {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="report-hero">
<h1>Servicekontrakt Rapport</h1>
<p>Vaelg kunde og servicekontrakt for at se relaterede cases og timelogs fra vTiger.</p>
</div>
<div class="report-card mb-3">
<div class="filters-grid">
<div>
<label for="customerSelect" class="form-label fw-semibold">Kunde</label>
<select id="customerSelect" class="form-select">
<option value="">-- Vaelg kunde --</option>
</select>
</div>
<div>
<label for="contractSelect" class="form-label fw-semibold">Servicekontrakt</label>
<select id="contractSelect" class="form-select" disabled>
<option value="">-- Vaelg servicekontrakt --</option>
</select>
</div>
<div class="d-flex gap-2">
<button id="loadBtn" class="btn btn-primary" disabled>
<i class="bi bi-search me-1"></i>Hent rapport
</button>
<button id="pdfBtn" class="btn btn-outline-primary" disabled>
<i class="bi bi-file-earmark-pdf me-1"></i>PDF
</button>
<button id="excelBtn" class="btn btn-outline-success" disabled>
<i class="bi bi-file-earmark-excel me-1"></i>Excel
</button>
</div>
</div>
<div id="filterHint" class="small text-muted mt-2">Start med at vaelge en kunde.</div>
</div>
<div id="errorBox" class="alert alert-danger d-none" role="alert"></div>
<div id="reportSection" class="d-none">
<div class="report-card mb-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
<div>
<h4 class="mb-1" id="reportContractTitle">-</h4>
<div class="text-muted small" id="reportContractMeta">-</div>
<div class="small" id="reportContractLink"></div>
</div>
</div>
<div class="metrics-grid" id="metricsGrid"></div>
</div>
<div class="report-card" id="casesContainer"></div>
</div>
</div>
<script>
const state = {
customers: [],
contracts: [],
selectedAccountId: '',
selectedContractId: '',
report: null,
};
const customerSelect = document.getElementById('customerSelect');
const contractSelect = document.getElementById('contractSelect');
const loadBtn = document.getElementById('loadBtn');
const pdfBtn = document.getElementById('pdfBtn');
const excelBtn = document.getElementById('excelBtn');
const filterHint = document.getElementById('filterHint');
const errorBox = document.getElementById('errorBox');
const reportSection = document.getElementById('reportSection');
const casesContainer = document.getElementById('casesContainer');
const metricsGrid = document.getElementById('metricsGrid');
document.addEventListener('DOMContentLoaded', async () => {
await loadCustomers();
customerSelect.addEventListener('change', onCustomerChange);
contractSelect.addEventListener('change', onContractChange);
loadBtn.addEventListener('click', loadReport);
pdfBtn.addEventListener('click', downloadPdf);
excelBtn.addEventListener('click', downloadExcel);
});
async function loadCustomers() {
clearError();
try {
const response = await fetch('/api/v1/timetracking/service-contract-report/customers');
if (!response.ok) {
throw new Error('Kunne ikke hente kunder');
}
state.customers = await response.json();
for (const customer of state.customers) {
const option = document.createElement('option');
option.value = customer.account_id;
option.textContent = `${customer.account_name} (${customer.account_id})`;
customerSelect.appendChild(option);
}
} catch (error) {
showError(error.message || 'Fejl ved indlaesning af kunder');
}
}
async function onCustomerChange(event) {
state.selectedAccountId = event.target.value || '';
state.selectedContractId = '';
reportSection.classList.add('d-none');
contractSelect.innerHTML = '<option value="">-- Vaelg servicekontrakt --</option>';
contractSelect.disabled = !state.selectedAccountId;
loadBtn.disabled = true;
pdfBtn.disabled = true;
excelBtn.disabled = true;
if (!state.selectedAccountId) {
filterHint.textContent = 'Start med at vaelge en kunde.';
return;
}
clearError();
filterHint.textContent = 'Henter servicekontrakter...';
try {
const response = await fetch(`/api/v1/timetracking/service-contract-report/contracts?account_id=${encodeURIComponent(state.selectedAccountId)}`);
if (!response.ok) {
throw new Error('Kunne ikke hente servicekontrakter');
}
state.contracts = await response.json();
for (const contract of state.contracts) {
const option = document.createElement('option');
option.value = contract.id;
const number = contract.contract_number || '-';
const subject = contract.subject || 'Uden titel';
option.textContent = `${number} - ${subject}`;
contractSelect.appendChild(option);
}
filterHint.textContent = state.contracts.length
? 'Vaelg servicekontrakt og hent rapporten.'
: 'Ingen servicekontrakter fundet for kunden.';
} catch (error) {
showError(error.message || 'Fejl ved hentning af servicekontrakter');
filterHint.textContent = 'Kunne ikke hente servicekontrakter.';
}
}
function onContractChange(event) {
state.selectedContractId = event.target.value || '';
loadBtn.disabled = !state.selectedContractId;
pdfBtn.disabled = !state.selectedContractId;
excelBtn.disabled = !state.selectedContractId;
}
async function loadReport() {
if (!state.selectedAccountId || !state.selectedContractId) {
return;
}
clearError();
loadBtn.disabled = true;
loadBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Henter...';
try {
const url = `/api/v1/timetracking/service-contract-report/data?account_id=${encodeURIComponent(state.selectedAccountId)}&contract_id=${encodeURIComponent(state.selectedContractId)}`;
const response = await fetch(url);
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke hente rapportdata');
}
state.report = await response.json();
renderReport();
} catch (error) {
showError(error.message || 'Fejl ved hentning af rapport');
} finally {
loadBtn.disabled = false;
loadBtn.innerHTML = '<i class="bi bi-search me-1"></i>Hent rapport';
}
}
function renderReport() {
if (!state.report) {
reportSection.classList.add('d-none');
return;
}
const { customer, contract, cases, summary } = state.report;
document.getElementById('reportContractTitle').textContent = `${contract.contract_number || '-'} - ${contract.subject || 'Uden titel'}`;
document.getElementById('reportContractMeta').textContent = `${customer.account_name} (${customer.account_id})`;
const contractLinkEl = document.getElementById('reportContractLink');
if (contract.vtiger_url) {
contractLinkEl.innerHTML = `<a href="${escapeHtml(contract.vtiger_url)}" target="_blank" rel="noopener noreferrer">Aabn servicekontrakt i vTiger</a>`;
} else {
contractLinkEl.textContent = '';
}
metricsGrid.innerHTML = [
metricHtml('Cases', summary.total_cases),
metricHtml('Timelogs', summary.total_timelogs),
metricHtml('Timer', summary.total_hours)
].join('');
if (!Array.isArray(cases) || cases.length === 0) {
casesContainer.innerHTML = `
<div class="empty-state">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
<div>Ingen cases fundet for den valgte kontrakt.</div>
</div>
`;
reportSection.classList.remove('d-none');
return;
}
casesContainer.innerHTML = cases.map(caseItem => {
const rows = (caseItem.timelogs || []).map(log => `
<tr>
<td>${escapeHtml(log.worked_date || '-')}</td>
<td>${escapeHtml(log.employee_initials || '-')}</td>
<td>${escapeHtml(log.hours ?? 0)}</td>
<td>${escapeHtml(log.description || '')}</td>
<td>${log.vtiger_url ? `<a href="${escapeHtml(log.vtiger_url)}" target="_blank" rel="noopener noreferrer">vTiger</a>` : '-'}</td>
</tr>
`).join('');
const tableContent = rows || '<tr><td colspan="5" class="text-muted">Ingen timelogs</td></tr>';
return `
<div class="case-card">
<div class="case-head">
<div>
<div class="case-title">${escapeHtml(caseItem.title || '-')}</div>
<div class="small text-muted">CC-nummer: ${escapeHtml(caseItem.cc_number || caseItem.id || '-')}</div>
<div class="small text-muted">Kontaktperson: ${escapeHtml(caseItem.contact_person || '-')}</div>
<div class="small">${caseItem.vtiger_url ? `<a href="${escapeHtml(caseItem.vtiger_url)}" target="_blank" rel="noopener noreferrer">Aabn case i vTiger</a>` : ''}</div>
<div class="small mt-1">${escapeHtml(caseItem.description || 'Ingen beskrivelse')}</div>
</div>
<div class="small text-end">
<div>Prioritet: <strong>${escapeHtml(caseItem.priority || '-')}</strong></div>
<div>Timelogs: <strong>${escapeHtml(caseItem.timelog_count ?? 0)}</strong></div>
<div>Timer: <strong>${escapeHtml(caseItem.total_hours ?? 0)}</strong></div>
</div>
</div>
<div class="table-wrap table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Dato</th>
<th>Medarbejder</th>
<th>Timer</th>
<th>Beskrivelse</th>
<th>Link</th>
</tr>
</thead>
<tbody>${tableContent}</tbody>
</table>
</div>
</div>
`;
}).join('');
reportSection.classList.remove('d-none');
}
function metricHtml(label, value) {
return `
<div class="metric">
<div class="value">${escapeHtml(value ?? 0)}</div>
<div class="label">${escapeHtml(label)}</div>
</div>
`;
}
function downloadPdf() {
if (!state.selectedAccountId || !state.selectedContractId) {
return;
}
const url = `/api/v1/timetracking/service-contract-report/pdf?account_id=${encodeURIComponent(state.selectedAccountId)}&contract_id=${encodeURIComponent(state.selectedContractId)}`;
window.open(url, '_blank');
}
function downloadExcel() {
if (!state.selectedAccountId || !state.selectedContractId) {
return;
}
const url = `/api/v1/timetracking/service-contract-report/excel?account_id=${encodeURIComponent(state.selectedAccountId)}&contract_id=${encodeURIComponent(state.selectedContractId)}`;
window.open(url, '_blank');
}
function showError(message) {
errorBox.textContent = message;
errorBox.classList.remove('d-none');
}
function clearError() {
errorBox.classList.add('d-none');
errorBox.textContent = '';
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
</script>
{% endblock %}

View File

@ -56,3 +56,15 @@ async def timetracking_orders(request: Request):
async def service_contract_wizard(request: Request):
"""Service Contract Migration Wizard"""
return templates.TemplateResponse("timetracking/frontend/service_contract_wizard.html", {"request": request})
@router.get("/timetracking/service-contract-report", response_class=HTMLResponse, name="service_contract_report")
async def service_contract_report(request: Request):
"""Service contract case/timelog report"""
return templates.TemplateResponse("timetracking/frontend/service_contract_report.html", {"request": request})
@router.get("/timetracking/employee-log", response_class=HTMLResponse, name="timetracking_employee_log")
async def timetracking_employee_log(request: Request):
"""Employee time log with day/week/month gap overview"""
return templates.TemplateResponse("timetracking/frontend/employee_log.html", {"request": request})

View File

@ -64,6 +64,11 @@ services:
- FEDEX_BASE_URL=${FEDEX_BASE_URL}
- FEDEX_TIMEOUT_SECONDS=${FEDEX_TIMEOUT_SECONDS}
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
extra_hosts:
- "ollama-host:172.16.31.195"
healthcheck:

View File

@ -0,0 +1,125 @@
# Route Auth Audit (2026-05-09)
Generated from frontend/auth/dashboard view decorators and classified against global middleware rules in main.py.
## Summary
- Total routes found: 104
- Requires login: 93
- Mission PIN protected: 6
- Public: 5
## Rules used
- Public: /health, /login, /api/v1/auth/login, /mission/pin*
- Mission PIN protected: /dashboard/mission-control*, /api/v1/mission/*
- All other listed routes: Requires login
## Route Matrix
| File | Line | Method | Path | Expected auth rule | Note |
|---|---:|---|---|---|---|
| app/auth/backend/views.py | 12 | GET | /login | Public | Allowed in public_paths |
| app/auth/backend/views.py | 27 | GET | /2fa/setup | Requires login | Redirect to /login when unauthenticated |
| app/backups/frontend/views.py | 14 | GET | /backups | Requires login | Redirect to /login when unauthenticated |
| app/billing/frontend/views.py | 14 | GET | /billing/supplier-invoices | Requires login | Redirect to /login when unauthenticated |
| app/billing/frontend/views.py | 23 | GET | /billing/supplier-invoices2 | Requires login | Redirect to /login when unauthenticated |
| app/billing/frontend/views.py | 32 | GET | /billing/template-builder | Requires login | Redirect to /login when unauthenticated |
| app/billing/frontend/views.py | 41 | GET | /billing/templates | Requires login | Redirect to /login when unauthenticated |
| app/billing/frontend/views.py | 50 | GET | /billing/sync-dashboard | Requires login | Redirect to /login when unauthenticated |
| app/conversations/frontend/views.py | 11 | GET | /conversations/my | Requires login | Redirect to /login when unauthenticated |
| app/dashboard/backend/views.py | 161 | GET | /mission/pin | Public | Mission PIN entry/verify/logout are public |
| app/dashboard/backend/views.py | 168 | GET | /mission/pin/ | Public | Mission PIN entry/verify/logout are public |
| app/dashboard/backend/views.py | 173 | POST | /mission/pin/verify | Public | Mission PIN entry/verify/logout are public |
| app/dashboard/backend/views.py | 195 | POST | /mission/pin/logout | Public | Mission PIN entry/verify/logout are public |
| app/dashboard/backend/views.py | 201 | GET | / | Requires login | Redirect to /login when unauthenticated |
| app/dashboard/backend/views.py | 292 | GET | /dashboard/sales | Requires login | Redirect to /login when unauthenticated |
| app/dashboard/backend/views.py | 413 | POST | /dashboard/default | Requires login | Redirect to /login when unauthenticated |
| app/dashboard/backend/views.py | 452 | GET | /dashboard/default | Requires login | Redirect to /login when unauthenticated |
| app/dashboard/backend/views.py | 457 | POST | /dashboard/default/clear | Requires login | Redirect to /login when unauthenticated |
| app/dashboard/backend/views.py | 481 | GET | /dashboard/default/clear | Requires login | Redirect to /login when unauthenticated |
| app/dashboard/backend/views.py | 486 | GET | /dashboard/mission-control | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated |
| app/dashboard/backend/views.py | 499 | GET | /dashboard/mission-control/ | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated |
| app/dashboard/backend/views.py | 504 | GET | /dashboard/mission-control/projects | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated |
| app/dashboard/backend/views.py | 518 | GET | /dashboard/mission-control/projects/ | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated |
| app/dashboard/backend/views.py | 523 | GET | /dashboard/mission-control.old | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated |
| app/dashboard/backend/views.py | 534 | GET | /dashboard/mission-control.old/ | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated |
| app/economy/frontend/views.py | 9 | GET | /economy/time-queue | Requires login | Redirect to /login when unauthenticated |
| app/emails/frontend/views.py | 18 | GET | /emails | Requires login | Redirect to /login when unauthenticated |
| app/fixed_price/frontend/views.py | 16 | GET | /fixed-price-agreements | Requires login | Redirect to /login when unauthenticated |
| app/fixed_price/frontend/views.py | 48 | GET | /fixed-price-agreements/{agreement_id} | Requires login | Redirect to /login when unauthenticated |
| app/fixed_price/frontend/views.py | 117 | GET | /fixed-price-agreements/reports/dashboard | Requires login | Redirect to /login when unauthenticated |
| app/fixed_price/frontend/views.py | 196 | GET | /api/fixed-price-agreements/customers | Requires login | Redirect to /login when unauthenticated |
| app/modules/_template/frontend/views.py | 22 | GET | /template | Requires login | Redirect to /login when unauthenticated |
| app/modules/calendar/frontend/views.py | 15 | GET | /calendar | Requires login | Redirect to /login when unauthenticated |
| app/modules/fedex/frontend/views.py | 9 | GET | /support/fedex | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 219 | GET | /hardware | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 301 | GET | /hardware/customers | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 348 | GET | /hardware/new | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 360 | GET | /hardware/eset | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 423 | GET | /hardware/eset/test | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 431 | GET | /hardware/eset/import | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 439 | GET | /hardware/{hardware_id:int} | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 687 | GET | /hardware/{hardware_id:int}/edit | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 708 | POST | /hardware/{hardware_id:int}/location | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 754 | POST | /hardware/{hardware_id:int}/owner | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 829 | POST | /hardware/{hardware_id:int}/contacts/add | Requires login | Redirect to /login when unauthenticated |
| app/modules/hardware/frontend/views.py | 851 | POST | /hardware/{hardware_id:int}/contacts/{contact_id:int}/delete | Requires login | Redirect to /login when unauthenticated |
| app/modules/links/frontend/views.py | 12 | GET | /links | Requires login | Redirect to /login when unauthenticated |
| app/modules/locations/frontend/views.py | 117 | GET | /app/locations | Requires login | Redirect to /login when unauthenticated |
| app/modules/locations/frontend/views.py | 249 | GET | /app/locations/create | Requires login | Redirect to /login when unauthenticated |
| app/modules/locations/frontend/views.py | 316 | GET | /app/locations/wizard | Requires login | Redirect to /login when unauthenticated |
| app/modules/locations/frontend/views.py | 362 | GET | /app/locations/{id} | Requires login | Redirect to /login when unauthenticated |
| app/modules/locations/frontend/views.py | 533 | GET | /app/locations/{id}/edit | Requires login | Redirect to /login when unauthenticated |
| app/modules/locations/frontend/views.py | 605 | POST | /app/locations/{id}/edit | Requires login | Redirect to /login when unauthenticated |
| app/modules/locations/frontend/views.py | 661 | GET | /app/locations/map | Requires login | Redirect to /login when unauthenticated |
| app/modules/manual/frontend/views.py | 32 | GET | /manual | Requires login | Redirect to /login when unauthenticated |
| app/modules/manual/frontend/views.py | 106 | GET | /manual/admin | Requires login | Redirect to /login when unauthenticated |
| app/modules/manual/frontend/views.py | 123 | GET | /manual/{slug} | Requires login | Redirect to /login when unauthenticated |
| app/modules/orders/frontend/views.py | 12 | GET | /ordre/create/new | Requires login | Redirect to /login when unauthenticated |
| app/modules/orders/frontend/views.py | 18 | GET | /ordre/{draft_id} | Requires login | Redirect to /login when unauthenticated |
| app/modules/orders/frontend/views.py | 27 | GET | /ordre | Requires login | Redirect to /login when unauthenticated |
| app/modules/sag/frontend/views.py | 161 | GET | /sag | Requires login | Redirect to /login when unauthenticated |
| app/modules/sag/frontend/views.py | 414 | GET | /sag/new | Requires login | Redirect to /login when unauthenticated |
| app/modules/sag/frontend/views.py | 424 | GET | /sag/{sag_id}/work-orders/print | Requires login | Redirect to /login when unauthenticated |
| app/modules/sag/frontend/views.py | 437 | GET | /sag/{sag_id}/labels/hardware/print | Requires login | Redirect to /login when unauthenticated |
| app/modules/sag/frontend/views.py | 449 | GET | /sag/varekob-salg | Requires login | Redirect to /login when unauthenticated |
| app/modules/sag/frontend/views.py | 456 | GET | /sag/{sag_id} | Requires login | Redirect to /login when unauthenticated |
| app/modules/sag/frontend/views.py | 833 | GET | /sag/{sag_id}/v3 | Requires login | Redirect to /login when unauthenticated |
| app/modules/sag/frontend/views.py | 1159 | GET | /sag/{sag_id}/edit | Requires login | Redirect to /login when unauthenticated |
| app/modules/solution/frontend/views.py | 22 | GET | /solution | Requires login | Redirect to /login when unauthenticated |
| app/modules/telefoni/frontend/views.py | 13 | GET | /telefoni | Requires login | Redirect to /login when unauthenticated |
| app/modules/test_module/frontend/views.py | 22 | GET | /test_module | Requires login | Redirect to /login when unauthenticated |
| app/modules/webshop/frontend/views.py | 18 | GET | /webshop | Requires login | Redirect to /login when unauthenticated |
| app/opportunities/frontend/views.py | 9 | GET | /opportunities | Requires login | Redirect to /login when unauthenticated |
| app/products/frontend/views.py | 12 | GET | /products | Requires login | Redirect to /login when unauthenticated |
| app/products/frontend/views.py | 19 | GET | /products/{product_id} | Requires login | Redirect to /login when unauthenticated |
| app/subscriptions/frontend/views.py | 14 | GET | /subscriptions | Requires login | Redirect to /login when unauthenticated |
| app/subscriptions/frontend/views.py | 22 | GET | /subscriptions/simply-imports | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 35 | GET | / | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 101 | GET | /mockups/1 | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 106 | GET | /mockups/2 | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 111 | GET | /mockups/3 | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 117 | GET | /worklog/review | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 256 | POST | /worklog/{worklog_id}/approve | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 313 | POST | /worklog/{worklog_id}/reject | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 369 | GET | /tickets/new | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 719 | GET | /dashboard/technician | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 741 | GET | /dashboard/technician/v1 | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 763 | GET | /dashboard/technician/v2 | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 785 | GET | /dashboard/technician/v3 | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 807 | GET | /dashboard | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 878 | GET | /tickets | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 959 | GET | /archived | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 1052 | GET | /archived/{archived_ticket_id} | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 1113 | GET | /tickets/{ticket_id} | Requires login | Redirect to /login when unauthenticated |
| app/ticket/frontend/views.py | 1187 | GET | /{path:path} | Requires login | Redirect to /login when unauthenticated |
| app/timetracking/frontend/views.py | 19 | GET | /timetracking | Requires login | Redirect to /login when unauthenticated |
| app/timetracking/frontend/views.py | 25 | GET | /timetracking/wizard | Requires login | Redirect to /login when unauthenticated |
| app/timetracking/frontend/views.py | 31 | GET | /timetracking/wizard2 | Requires login | Redirect to /login when unauthenticated |
| app/timetracking/frontend/views.py | 37 | GET | /timetracking/registrations | Requires login | Redirect to /login when unauthenticated |
| app/timetracking/frontend/views.py | 43 | GET | /timetracking/customers | Requires login | Redirect to /login when unauthenticated |
| app/timetracking/frontend/views.py | 49 | GET | /timetracking/orders | Requires login | Redirect to /login when unauthenticated |
| app/timetracking/frontend/views.py | 55 | GET | /timetracking/service-contract-wizard | Requires login | Redirect to /login when unauthenticated |
| app/timetracking/frontend/views.py | 61 | GET | /timetracking/service-contract-report | Requires login | Redirect to /login when unauthenticated |
| app/timetracking/frontend/views.py | 67 | GET | /timetracking/employee-log | Requires login | Redirect to /login when unauthenticated |

View File

@ -68,6 +68,8 @@ from app.customers.backend import bmc_office_router
from app.alert_notes.backend import router as alert_notes_api
from app.billing.backend import router as billing_api
from app.billing.frontend import views as billing_views
from app.economy.backend import router as economy_api
from app.economy.frontend import views as economy_views
from app.system.backend import router as system_api
from app.system.backend import sync_router
from app.dashboard.backend import views as dashboard_views
@ -415,6 +417,7 @@ app.include_router(bmc_office_router.router, prefix="/api/v1", tags=["BMC Office
# app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"]) # Replaced by hardware module
app.include_router(alert_notes_api, prefix="/api/v1", tags=["Alert Notes"])
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
app.include_router(economy_api.router, prefix="/api/v1", tags=["Economy"])
app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"])
app.include_router(mission_api.router, prefix="/api/v1", tags=["Mission"])
@ -472,6 +475,7 @@ app.include_router(products_views.router, tags=["Frontend"])
app.include_router(vendors_views.router, tags=["Frontend"])
app.include_router(timetracking_views.router, tags=["Frontend"])
app.include_router(billing_views.router, tags=["Frontend"])
app.include_router(economy_views.router, tags=["Frontend"])
app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend"])
app.include_router(contacts_views.router, tags=["Frontend"])
app.include_router(tags_views.router, tags=["Frontend"])

View File

@ -0,0 +1,41 @@
-- Migration: 188_sag_buzzwords
-- Description: Separate buzzwords module for SAG with free-text keywords and case links
BEGIN;
CREATE TABLE IF NOT EXISTS buzzwords (
id SERIAL PRIMARY KEY,
word VARCHAR(120) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sag_buzzwords (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
buzzword_id INTEGER NOT NULL REFERENCES buzzwords(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_buzzwords_word_active
ON buzzwords (word)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_buzzwords_word_active
ON buzzwords (word)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_sag_buzzwords_active
ON sag_buzzwords (sag_id, buzzword_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_buzzwords_sag_id_active
ON sag_buzzwords (sag_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_buzzwords_buzzword_id_active
ON sag_buzzwords (buzzword_id)
WHERE deleted_at IS NULL;
COMMIT;

View File

@ -0,0 +1,93 @@
-- Mission Control Project View foundation
-- Adds project domain tables and links from sag_sager.
CREATE TABLE IF NOT EXISTS mission_projects (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned', 'active', 'paused', 'completed', 'cancelled')),
score INTEGER NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
created_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_mission_projects_status ON mission_projects (status);
CREATE INDEX IF NOT EXISTS idx_mission_projects_score ON mission_projects (score DESC);
CREATE INDEX IF NOT EXISTS idx_mission_projects_updated_at ON mission_projects (updated_at DESC);
CREATE TABLE IF NOT EXISTS mission_project_milestones (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES mission_projects(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'completed', 'cancelled')),
target_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_mission_project_milestones_project_id ON mission_project_milestones (project_id);
CREATE INDEX IF NOT EXISTS idx_mission_project_milestones_status ON mission_project_milestones (status);
CREATE INDEX IF NOT EXISTS idx_mission_project_milestones_target_date ON mission_project_milestones (target_date);
CREATE TABLE IF NOT EXISTS mission_project_blockers (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES mission_projects(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'resolved', 'cancelled')),
severity VARCHAR(16) NOT NULL DEFAULT 'medium'
CHECK (severity IN ('low', 'medium', 'high', 'critical')),
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_mission_project_blockers_project_id ON mission_project_blockers (project_id);
CREATE INDEX IF NOT EXISTS idx_mission_project_blockers_status ON mission_project_blockers (status);
CREATE INDEX IF NOT EXISTS idx_mission_project_blockers_severity ON mission_project_blockers (severity);
ALTER TABLE sag_sager
ADD COLUMN IF NOT EXISTS project_id BIGINT,
ADD COLUMN IF NOT EXISTS project_milestone_id BIGINT,
ADD COLUMN IF NOT EXISTS is_project_blocker BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS project_task_type VARCHAR(32);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_sag_sager_project_id'
) THEN
ALTER TABLE sag_sager
ADD CONSTRAINT fk_sag_sager_project_id
FOREIGN KEY (project_id)
REFERENCES mission_projects(id)
ON DELETE SET NULL;
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_sag_sager_project_milestone_id'
) THEN
ALTER TABLE sag_sager
ADD CONSTRAINT fk_sag_sager_project_milestone_id
FOREIGN KEY (project_milestone_id)
REFERENCES mission_project_milestones(id)
ON DELETE SET NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_sag_sager_project_id ON sag_sager (project_id);
CREATE INDEX IF NOT EXISTS idx_sag_sager_project_milestone_id ON sag_sager (project_milestone_id);
CREATE INDEX IF NOT EXISTS idx_sag_sager_project_task_type ON sag_sager (project_task_type);
CREATE INDEX IF NOT EXISTS idx_sag_sager_is_project_blocker ON sag_sager (is_project_blocker);

View File

@ -23,3 +23,5 @@ Pillow==11.0.0
brother_ql==0.9.4
pyzbar==0.1.9
pypdfium2==4.30.0
reportlab==4.2.5
openpyxl==3.1.5

View File

@ -97,12 +97,31 @@
});
if (!response.ok) {
throw new Error('Could not load bottom bar state');
const error = new Error('Could not load bottom bar state');
error.status = response.status;
throw error;
}
return response.json();
}
function disableBottomBarAuthRetry(reason) {
stopPolling();
if (wsReconnectTimer) {
window.clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
}
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.close(1000, 'auth-failed');
} catch (_) {
// Ignore close failures
}
}
setVisibility(false);
console.info('Bottom bar disabled due to auth state:', reason || 'unauthorized');
}
function applyState(data) {
if (data && data.enabled) {
latestSections = data.sections || {};
@ -697,12 +716,19 @@
}
function pollOnce() {
let shouldScheduleNextPoll = true;
fetchBottomBarState().then(function (data) {
applyState(data);
}).catch(function (err) {
console.warn('Bottom bar poll failed', err);
if (err && (err.status === 401 || err.status === 403)) {
shouldScheduleNextPoll = false;
disableBottomBarAuthRetry('poll-' + err.status);
}
}).finally(function () {
pollTimer = window.setTimeout(pollOnce, 15000);
if (shouldScheduleNextPoll) {
pollTimer = window.setTimeout(pollOnce, 15000);
}
});
}
@ -820,7 +846,12 @@
}
});
ws.addEventListener('close', function () {
ws.addEventListener('close', function (event) {
// 1008 = policy violation (used server-side for auth failure).
if (event && event.code === 1008) {
disableBottomBarAuthRetry('ws-1008');
return;
}
startPollingFallback();
scheduleWsReconnect();
});

View File

@ -311,7 +311,11 @@
bugModal.show();
}
function prepareScreenshotFromTrigger() {
function prepareScreenshotFromTrigger(forceNew) {
if (!forceNew && pendingScreenshotPromise) {
return pendingScreenshotPromise;
}
pendingScreenshotPromise = takeScreenshot();
return pendingScreenshotPromise;
}
@ -434,11 +438,14 @@
const modalEl = document.getElementById('bugReportModal');
if (btn) {
const primeCapture = () => {
prepareScreenshotFromTrigger(true);
};
btn.addEventListener('pointerdown', primeCapture);
btn.addEventListener('click', (e) => {
e.preventDefault();
if (!pendingScreenshotPromise) {
prepareScreenshotFromTrigger();
}
prepareScreenshotFromTrigger(false);
openBugReportModal();
});
}
@ -479,9 +486,7 @@
if (isTyping) return;
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
e.preventDefault();
if (!pendingScreenshotPromise) {
prepareScreenshotFromTrigger();
}
prepareScreenshotFromTrigger(true);
openBugReportModal();
}
});