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:
parent
770f822fc6
commit
a36e3e716f
@ -133,6 +133,7 @@ IMAP_USERNAME=your_email@gmail.com
|
|||||||
IMAP_PASSWORD=your_app_password
|
IMAP_PASSWORD=your_app_password
|
||||||
IMAP_USE_SSL=true
|
IMAP_USE_SSL=true
|
||||||
IMAP_FOLDER=INBOX
|
IMAP_FOLDER=INBOX
|
||||||
|
IMAP_TEST_FOLDER=BMC_TEST # Shared test inbox for all mail scenarios
|
||||||
IMAP_READ_ONLY=true # Safety: READ-ONLY mode
|
IMAP_READ_ONLY=true # Safety: READ-ONLY mode
|
||||||
|
|
||||||
# Microsoft Graph API (Alternative to IMAP - for Office365/Outlook)
|
# 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_REQUIRE_MANUAL_APPROVAL=true
|
||||||
EMAIL_AUTO_CREATE_CASES_FROM_EMAIL=false
|
EMAIL_AUTO_CREATE_CASES_FROM_EMAIL=false
|
||||||
EMAIL_MAX_FETCH_PER_RUN=50
|
EMAIL_MAX_FETCH_PER_RUN=50
|
||||||
|
EMAIL_PROCESS_ALLOW_FOLDER_OVERRIDE=true
|
||||||
EMAIL_PROCESS_INTERVAL_MINUTES=5
|
EMAIL_PROCESS_INTERVAL_MINUTES=5
|
||||||
EMAIL_WORKFLOWS_ENABLED=true
|
EMAIL_WORKFLOWS_ENABLED=true
|
||||||
|
EMAIL_WORKFLOW_AUTORUN_ENABLED=false
|
||||||
EMAIL_MAX_UPLOAD_SIZE_MB=50
|
EMAIL_MAX_UPLOAD_SIZE_MB=50
|
||||||
ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip
|
ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip
|
||||||
@ -16,7 +16,11 @@ async def login_page(request: Request):
|
|||||||
"""
|
"""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"auth/frontend/login.html",
|
"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(
|
return templates.TemplateResponse(
|
||||||
"auth/frontend/2fa_setup.html",
|
"auth/frontend/2fa_setup.html",
|
||||||
{"request": request}
|
{
|
||||||
|
"request": request,
|
||||||
|
"hide_top_nav": True,
|
||||||
|
"hide_bottom_bar": True,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -106,6 +106,7 @@ class Settings(BaseSettings):
|
|||||||
IMAP_PASSWORD: str = ""
|
IMAP_PASSWORD: str = ""
|
||||||
IMAP_USE_SSL: bool = True
|
IMAP_USE_SSL: bool = True
|
||||||
IMAP_FOLDER: str = "INBOX"
|
IMAP_FOLDER: str = "INBOX"
|
||||||
|
IMAP_TEST_FOLDER: str = ""
|
||||||
IMAP_READ_ONLY: bool = True
|
IMAP_READ_ONLY: bool = True
|
||||||
|
|
||||||
# Microsoft Graph API (alternative to IMAP)
|
# 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_REQUIRE_MANUAL_APPROVAL: bool = True # Phase 1: human approval before case creation/routing
|
||||||
EMAIL_AUTO_CREATE_CASES_FROM_EMAIL: bool = False
|
EMAIL_AUTO_CREATE_CASES_FROM_EMAIL: bool = False
|
||||||
EMAIL_MAX_FETCH_PER_RUN: int = 50
|
EMAIL_MAX_FETCH_PER_RUN: int = 50
|
||||||
|
EMAIL_PROCESS_ALLOW_FOLDER_OVERRIDE: bool = True
|
||||||
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
|
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
|
||||||
EMAIL_WORKFLOWS_ENABLED: bool = True
|
EMAIL_WORKFLOWS_ENABLED: bool = True
|
||||||
|
EMAIL_WORKFLOW_AUTORUN_ENABLED: bool = False
|
||||||
EMAIL_MAX_UPLOAD_SIZE_MB: int = 50 # Max file size for email uploads
|
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
|
ALLOWED_EXTENSIONS: List[str] = ["pdf", "jpg", "jpeg", "png", "gif", "doc", "docx", "xls", "xlsx", "zip"] # Allowed file extensions for uploads
|
||||||
|
|
||||||
|
|||||||
@ -65,6 +65,62 @@ class MissionTemperatureWebhook(BaseModel):
|
|||||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
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]:
|
def _first_query_param(request: Request, *names: str) -> Optional[str]:
|
||||||
for name in names:
|
for name in names:
|
||||||
value = request.query_params.get(name)
|
value = request.query_params.get(name)
|
||||||
@ -295,6 +351,138 @@ async def get_mission_state():
|
|||||||
return MissionService.get_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")
|
@router.get("/mission/camera/mjpeg")
|
||||||
async def mission_camera_mjpeg_stream(fps: float = Query(5.0, ge=1.0, le=15.0)):
|
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()
|
feed_url = (MissionService.get_setting_value("mission_camera_feed_url", "") or "").strip()
|
||||||
|
|||||||
@ -326,6 +326,694 @@ class MissionService:
|
|||||||
result.append(item)
|
result.append(item)
|
||||||
return result
|
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
|
@staticmethod
|
||||||
def get_assignment_users(limit: int = 300) -> list[Dict[str, Any]]:
|
def get_assignment_users(limit: int = 300) -> list[Dict[str, Any]]:
|
||||||
rows = execute_query(
|
rows = execute_query(
|
||||||
@ -495,7 +1183,16 @@ class MissionService:
|
|||||||
created_at ASC
|
created_at ASC
|
||||||
) AS case_list
|
) AS case_list
|
||||||
FROM active_cases
|
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
|
SELECT
|
||||||
assignee_key,
|
assignee_key,
|
||||||
@ -603,6 +1300,7 @@ class MissionService:
|
|||||||
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
|
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
|
||||||
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
|
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
|
||||||
"important_cases": MissionService._safe("important_cases", lambda: MissionService.get_important_cases(80), []),
|
"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_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), []),
|
"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), []),
|
"assignment_users": MissionService._safe("assignment_users", lambda: MissionService.get_assignment_users(300), []),
|
||||||
|
|||||||
@ -198,11 +198,35 @@ async def search_sag(q: str):
|
|||||||
CAST(s.id AS TEXT) ILIKE %s OR
|
CAST(s.id AS TEXT) ILIKE %s OR
|
||||||
s.titel ILIKE %s OR
|
s.titel ILIKE %s OR
|
||||||
s.beskrivelse 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
|
ORDER BY s.created_at DESC
|
||||||
LIMIT 20
|
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 []
|
return sager or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -108,7 +108,14 @@ def _sanitize_mission_next(value: str) -> str:
|
|||||||
if not value:
|
if not value:
|
||||||
return "/dashboard/mission-control"
|
return "/dashboard/mission-control"
|
||||||
candidate = value.strip()
|
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
|
return candidate
|
||||||
if candidate.startswith("/api/v1/mission/"):
|
if candidate.startswith("/api/v1/mission/"):
|
||||||
return candidate
|
return candidate
|
||||||
@ -479,9 +486,12 @@ async def clear_default_dashboard_get_fallback():
|
|||||||
@router.get("/dashboard/mission-control", response_class=HTMLResponse)
|
@router.get("/dashboard/mission-control", response_class=HTMLResponse)
|
||||||
async def mission_control_dashboard(request: Request):
|
async def mission_control_dashboard(request: Request):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"dashboard/frontend/mission_control.html",
|
"dashboard/frontend/mission_control_v2.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"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):
|
async def mission_control_dashboard_trailing_slash(request: Request):
|
||||||
return await mission_control_dashboard(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)
|
||||||
|
|
||||||
|
|||||||
1953
app/dashboard/frontend/mission_control_legacy.html
Normal file
1953
app/dashboard/frontend/mission_control_legacy.html
Normal file
File diff suppressed because it is too large
Load Diff
2455
app/dashboard/frontend/mission_control_v2.html
Normal file
2455
app/dashboard/frontend/mission_control_v2.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -5,11 +5,15 @@ API endpoints for email viewing, classification, and rule management
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
|
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 pydantic import BaseModel
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
import unicodedata
|
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.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||||
from app.services.email_processor_service import EmailProcessorService
|
from app.services.email_processor_service import EmailProcessorService
|
||||||
from app.services.email_workflow_service import email_workflow_service
|
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
|
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:
|
def _extract_domain_from_email(email: Optional[str]) -> str:
|
||||||
sender = str(email or "").strip().lower()
|
sender = str(email or "").strip().lower()
|
||||||
if "@" not in sender:
|
if "@" not in sender:
|
||||||
@ -435,6 +519,124 @@ class RewriteEmailTextResponse(BaseModel):
|
|||||||
rewritten_text: str
|
rewritten_text: str
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
endpoint: 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
|
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(
|
async def list_emails(
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
classification: Optional[str] = Query(None),
|
classification: Optional[str] = Query(None),
|
||||||
|
folder: Optional[str] = Query(None),
|
||||||
q: Optional[str] = Query(None),
|
q: Optional[str] = Query(None),
|
||||||
limit: int = Query(50, le=500),
|
limit: int = Query(50, le=500),
|
||||||
offset: int = Query(0, ge=0)
|
offset: int = Query(0, ge=0)
|
||||||
@ -804,6 +1007,10 @@ async def list_emails(
|
|||||||
where_clauses.append("em.classification = %s")
|
where_clauses.append("em.classification = %s")
|
||||||
params.append(classification)
|
params.append(classification)
|
||||||
|
|
||||||
|
if folder:
|
||||||
|
where_clauses.append("LOWER(COALESCE(em.folder, '')) = LOWER(%s)")
|
||||||
|
params.append(folder)
|
||||||
|
|
||||||
if q:
|
if q:
|
||||||
where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s)")
|
where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s)")
|
||||||
search_term = f"%{q}%"
|
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")
|
linked_case_id = email_data.get("linked_case_id")
|
||||||
can_mark_read = _can_user_mark_case_email_read(user_id, 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:
|
folder_value = str(email_data.get("folder") or "").strip().lower()
|
||||||
update_query = "UPDATE email_messages SET is_read = true WHERE id = %s"
|
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,))
|
execute_update(update_query, (email_id,))
|
||||||
email_data["is_read"] = True
|
email_data["is_read"] = True
|
||||||
|
|
||||||
@ -897,12 +1112,19 @@ async def update_email_read_state(email_id: int, payload: EmailReadStateUpdate,
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
row = execute_query_single(
|
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,),
|
(email_id,),
|
||||||
)
|
)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Email not found")
|
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)
|
user_id = getattr(request.state, "user_id", None)
|
||||||
if payload.is_read:
|
if payload.is_read:
|
||||||
can_mark_read = _can_user_mark_case_email_read(user_id, row.get("linked_case_id"))
|
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")
|
_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()
|
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]
|
template_key = requested_case_type[:50]
|
||||||
priority = (payload.priority or 'normal').strip().lower()
|
priority = (payload.priority or 'normal').strip().lower()
|
||||||
|
|
||||||
@ -1942,18 +2165,34 @@ async def reprocess_email(email_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/emails/process")
|
@router.post("/emails/process")
|
||||||
async def process_emails():
|
async def process_emails(
|
||||||
"""Manually trigger email processing"""
|
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:
|
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()
|
processor = EmailProcessorService()
|
||||||
stats = await processor.process_inbox()
|
stats = await processor.process_inbox(
|
||||||
|
limit_override=limit,
|
||||||
|
folder_override=clean_folder,
|
||||||
|
force_run=True,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Email processing completed",
|
"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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Email processing failed: {e}")
|
logger.error(f"❌ Email processing failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@ -2616,6 +2855,7 @@ async def execute_workflows_for_email(email_id: int):
|
|||||||
# Get email data
|
# Get email data
|
||||||
query = """
|
query = """
|
||||||
SELECT id, message_id, subject, sender_email, sender_name, body_text,
|
SELECT id, message_id, subject, sender_email, sender_name, body_text,
|
||||||
|
body_html, in_reply_to, email_references, thread_key,
|
||||||
classification, confidence_score, status
|
classification, confidence_score, status
|
||||||
FROM email_messages
|
FROM email_messages
|
||||||
WHERE id = %s AND deleted_at IS NULL
|
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))
|
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])
|
@router.get("/workflow-executions", response_model=List[WorkflowExecution])
|
||||||
async def list_workflow_executions(
|
async def list_workflow_executions(
|
||||||
workflow_id: Optional[int] = Query(None),
|
workflow_id: Optional[int] = Query(None),
|
||||||
|
|||||||
1287
app/emails/frontend/emails_v2.html
Normal file
1287
app/emails/frontend/emails_v2.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -20,5 +20,23 @@ async def emails_page(request: Request):
|
|||||||
"""Email management UI - 3-column modern email interface"""
|
"""Email management UI - 3-column modern email interface"""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"emails/frontend/emails.html",
|
"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"}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -61,30 +61,91 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
|
<div class="ordre-top-panel">
|
||||||
<div>
|
<div class="row g-3 align-items-center">
|
||||||
<h2 class="mb-1"><i class="bi bi-receipt me-2"></i>Ordre</h2>
|
<div class="col-lg-5">
|
||||||
<div class="text-muted">Oversigt over alle ordre</div>
|
<h1 class="ordre-top-title"><i class="bi bi-receipt me-2"></i>Ordre</h1>
|
||||||
</div>
|
<p class="ordre-top-subtitle">Samlet overblik, konsolidering og sync-status for alle ordrekladder</p>
|
||||||
<div class="d-flex gap-2">
|
</div>
|
||||||
<a href="/ordre/create/new" class="btn btn-success"><i class="bi bi-plus-circle me-1"></i>Opret ny ordre</a>
|
<div class="col-lg-7">
|
||||||
<select id="syncStatusFilter" class="form-select" style="min-width: 170px;" onchange="renderOrders()">
|
<div class="ordre-top-actions">
|
||||||
<option value="all">Alle sync-status</option>
|
<a href="/ordre/create/new" class="btn btn-success ordre-btn"><i class="bi bi-plus-circle"></i>Opret ny ordre</a>
|
||||||
<option value="pending">pending</option>
|
<div class="ordre-filter-wrap">
|
||||||
<option value="exported">exported</option>
|
<select id="syncStatusFilter" class="form-select ordre-btn" onchange="renderOrders()">
|
||||||
<option value="failed">failed</option>
|
<option value="all">Alle sync-status</option>
|
||||||
<option value="posted">posted</option>
|
<option value="pending">pending</option>
|
||||||
<option value="paid">paid</option>
|
<option value="exported">exported</option>
|
||||||
</select>
|
<option value="failed">failed</option>
|
||||||
<span id="selectedCountBadge" class="selected-counter">Valgte: 0</span>
|
<option value="posted">posted</option>
|
||||||
<button class="btn btn-outline-secondary" onclick="consolidateSelectedByCustomer()"><i class="bi bi-collection me-1"></i>Konsolider valgte</button>
|
<option value="paid">paid</option>
|
||||||
<button class="btn btn-outline-success" onclick="markSelectedOrdersPaid()"><i class="bi bi-cash-stack me-1"></i>Markér valgte som betalt</button>
|
</select>
|
||||||
<button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -227,21 +288,21 @@
|
|||||||
<option value="posted" ${syncStatus === 'posted' ? 'selected' : ''}>posted</option>
|
<option value="posted" ${syncStatus === 'posted' ? 'selected' : ''}>posted</option>
|
||||||
<option value="paid" ${syncStatus === 'paid' ? 'selected' : ''}>paid</option>
|
<option value="paid" ${syncStatus === 'paid' ? 'selected' : ''}>paid</option>
|
||||||
</select>
|
</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>
|
<i class="bi bi-check2"></i>
|
||||||
</button>
|
</button>
|
||||||
${syncStatus === 'posted' ? `
|
${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>
|
<i class="bi bi-cash-coin"></i>
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<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>
|
<i class="bi bi-eye"></i>
|
||||||
</button>
|
</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>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -324,6 +324,14 @@ class DirectPrintOverrideRequest(BaseModel):
|
|||||||
hardware_id: Optional[int] = None
|
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]:
|
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
|
||||||
cleaned: List[str] = []
|
cleaned: List[str] = []
|
||||||
for value in values or []:
|
for value in values or []:
|
||||||
@ -344,6 +352,101 @@ def _normalize_message_id_token(value: Optional[str]) -> Optional[str]:
|
|||||||
return normalized or None
|
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(
|
def _derive_thread_key_for_outbound(
|
||||||
payload_thread_key: Optional[str],
|
payload_thread_key: Optional[str],
|
||||||
in_reply_to_header: 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")
|
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)
|
# 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")
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
er_system_besked = bool(data.get("er_system_besked", False))
|
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:
|
if er_system_besked:
|
||||||
forfatter = str(data.get("forfatter") or "System").strip() or "System"
|
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:
|
else:
|
||||||
forfatter = "Bruger"
|
forfatter = "Bruger"
|
||||||
|
|
||||||
query = """
|
if has_internal_flag:
|
||||||
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
query = """
|
||||||
VALUES (%s, %s, %s, %s)
|
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked, er_intern)
|
||||||
RETURNING *
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
"""
|
RETURNING *
|
||||||
result = execute_query(query, (sag_id, forfatter, data.get("indhold"), er_system_besked))
|
"""
|
||||||
|
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:
|
if result:
|
||||||
logger.info("✅ Comment added to case %s by %s", sag_id, forfatter)
|
logger.info("✅ Comment added to case %s by %s", sag_id, forfatter)
|
||||||
|
|||||||
@ -492,6 +492,52 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
tags_query_legacy = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
|
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,))
|
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
|
# Fetch relations
|
||||||
relationer_query = """
|
relationer_query = """
|
||||||
SELECT sr.*,
|
SELECT sr.*,
|
||||||
@ -759,6 +805,8 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
"prepaid_cards": prepaid_cards,
|
"prepaid_cards": prepaid_cards,
|
||||||
"fixed_price_agreements": fixed_price_agreements,
|
"fixed_price_agreements": fixed_price_agreements,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
|
"buzzwords": buzzwords,
|
||||||
|
"buzzwords": buzzwords,
|
||||||
|
|
||||||
"relationer": relationer,
|
"relationer": relationer,
|
||||||
"relation_tree": relation_tree,
|
"relation_tree": relation_tree,
|
||||||
|
|||||||
@ -455,6 +455,9 @@
|
|||||||
const timeoutVar = type === 'customer' ? customerSearchTimeout : contactSearchTimeout;
|
const timeoutVar = type === 'customer' ? customerSearchTimeout : contactSearchTimeout;
|
||||||
const resultsDiv = document.getElementById(resultsId);
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
||||||
|
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
|
||||||
clearTimeout(timeoutVar);
|
clearTimeout(timeoutVar);
|
||||||
|
|
||||||
if (query.length < 2) {
|
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>';
|
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 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) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
resultsDiv.innerHTML = `<div class="p-3 text-danger small">Fejl ved søgning: ${errorText}</div>`;
|
resultsDiv.innerHTML = `<div class="p-3 text-danger small">Fejl ved søgning: ${errorText}</div>`;
|
||||||
@ -492,7 +498,9 @@
|
|||||||
} else {
|
} else {
|
||||||
resultsDiv.innerHTML = data.map(item => {
|
resultsDiv.innerHTML = data.map(item => {
|
||||||
const name = type === 'customer' ? item.name : `${item.first_name} ${item.last_name}`;
|
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
|
// Handle escaping for JS function call
|
||||||
const safeName = name.replace(/'/g, "\\'");
|
const safeName = name.replace(/'/g, "\\'");
|
||||||
@ -937,9 +945,15 @@
|
|||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
||||||
|
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
|
||||||
const customerQuery = document.getElementById('customerSearch').value.trim();
|
const customerQuery = document.getElementById('customerSearch').value.trim();
|
||||||
if (!selectedCustomer && customerQuery.length >= 2) {
|
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();
|
const matches = await res.json();
|
||||||
if (Array.isArray(matches) && matches.length > 0) {
|
if (Array.isArray(matches) && matches.length > 0) {
|
||||||
throw new Error('Firma findes allerede. Vælg det fra listen.');
|
throw new Error('Firma findes allerede. Vælg det fra listen.');
|
||||||
@ -949,7 +963,10 @@
|
|||||||
|
|
||||||
const contactQuery = document.getElementById('contactSearch').value.trim();
|
const contactQuery = document.getElementById('contactSearch').value.trim();
|
||||||
if (Object.keys(selectedContacts).length === 0 && contactQuery.length >= 2) {
|
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();
|
const matches = await res.json();
|
||||||
if (Array.isArray(matches) && matches.length > 0) {
|
if (Array.isArray(matches) && matches.length > 0) {
|
||||||
throw new Error('Kontakt findes allerede. Vælg den fra listen.');
|
throw new Error('Kontakt findes allerede. Vælg den fra listen.');
|
||||||
@ -992,6 +1009,13 @@
|
|||||||
fetch(`/api/v1/sag/${result.id}/contacts`, {
|
fetch(`/api/v1/sag/${result.id}/contacts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contact_id: parseInt(cid),
|
||||||
|
role: 'Kontakt',
|
||||||
|
is_primary: false
|
||||||
|
})
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(contactPromises);
|
await Promise.all(contactPromises);
|
||||||
|
|||||||
@ -1241,6 +1241,10 @@
|
|||||||
display: none;
|
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 .card-header,
|
||||||
.card[data-module].module-empty-compact .module-header {
|
.card[data-module].module-empty-compact .module-header {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@ -4045,6 +4049,7 @@
|
|||||||
let customerSearchMode = 'link';
|
let customerSearchMode = 'link';
|
||||||
const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
|
const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
|
||||||
const initialCaseTagsSnapshot = {{ (tags or [])|tojson }};
|
const initialCaseTagsSnapshot = {{ (tags or [])|tojson }};
|
||||||
|
const initialCaseBuzzwordsSnapshot = {{ (buzzwords or [])|tojson }};
|
||||||
|
|
||||||
async function markCaseAsRecentlyOpened() {
|
async function markCaseAsRecentlyOpened() {
|
||||||
try {
|
try {
|
||||||
@ -4141,6 +4146,7 @@
|
|||||||
'contacts': 'Kontakter',
|
'contacts': 'Kontakter',
|
||||||
'customers': 'Kunder',
|
'customers': 'Kunder',
|
||||||
'tags': 'Tags',
|
'tags': 'Tags',
|
||||||
|
'buzzwords': 'Buzzwords',
|
||||||
'wiki': 'Wiki',
|
'wiki': 'Wiki',
|
||||||
'todo-steps': 'Todo-opgaver',
|
'todo-steps': 'Todo-opgaver',
|
||||||
'time': 'Tid',
|
'time': 'Tid',
|
||||||
@ -4214,6 +4220,7 @@
|
|||||||
loadTodoSteps();
|
loadTodoSteps();
|
||||||
loadCaseTagsModule();
|
loadCaseTagsModule();
|
||||||
loadCaseTagSuggestions();
|
loadCaseTagSuggestions();
|
||||||
|
loadCaseBuzzwordsModule();
|
||||||
|
|
||||||
// Keep suggestions fresh while user works on the case.
|
// Keep suggestions fresh while user works on the case.
|
||||||
setInterval(loadCaseTagSuggestions, 30000);
|
setInterval(loadCaseTagSuggestions, 30000);
|
||||||
@ -4550,8 +4557,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function callMainContactFromModal() {
|
async function callMainContactFromModal() {
|
||||||
const number = {{ (hovedkontakt.mobile or hovedkontakt.phone or '')|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)|tojson if hovedkontakt else "''" }};
|
const name = {{ (((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim) if hovedkontakt else '')|tojson }};
|
||||||
if (!number) {
|
if (!number) {
|
||||||
alert('Ingen telefon eller mobil på hovedkontakt');
|
alert('Ingen telefon eller mobil på hovedkontakt');
|
||||||
return;
|
return;
|
||||||
@ -5481,21 +5488,24 @@
|
|||||||
return;
|
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 tag = item.tag || {};
|
||||||
const matched = Array.isArray(item.matched_words) ? item.matched_words.join(', ') : '';
|
const matched = Array.isArray(item.matched_words) ? item.matched_words.join(', ') : '';
|
||||||
|
const hint = matched ? `Match: ${escapeHtml(matched)}` : 'Tilfoej tag';
|
||||||
return `
|
return `
|
||||||
<div class="d-flex justify-content-between align-items-start gap-2 mb-2 border rounded p-2">
|
<button class="btn btn-sm btn-outline-primary d-inline-flex align-items-center gap-1 px-2 py-0"
|
||||||
<div class="small">
|
type="button"
|
||||||
<span class="badge" style="background-color: ${tag.color || '#0f4c75'};">
|
onclick="applySuggestedCaseTag(${Number(tag.id)})"
|
||||||
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name || 'Tag')}
|
title="${hint}"
|
||||||
</span>
|
aria-label="${hint}"
|
||||||
${matched ? `<div class="text-muted mt-1">Match: ${escapeHtml(matched)}</div>` : ''}
|
style="height: 22px; line-height: 1; font-size: 0.72rem; border-radius: 999px; max-width: 100%;">
|
||||||
</div>
|
<span class="text-truncate" style="max-width: 150px;">${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name || 'Tag')}</span>
|
||||||
<button class="btn btn-sm btn-outline-primary" type="button" onclick="applySuggestedCaseTag(${Number(tag.id)})">Tilfoej</button>
|
<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;">
|
||||||
</div>
|
<i class="bi bi-plus-lg"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('')}</div>`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading tag suggestions:', error);
|
console.error('Error loading tag suggestions:', error);
|
||||||
suggestionsContainer.innerHTML = '<div class="text-danger small">Fejl ved forslag</div>';
|
suggestionsContainer.innerHTML = '<div class="text-danger small">Fejl ved forslag</div>';
|
||||||
@ -5570,7 +5580,149 @@
|
|||||||
await loadCaseTagSuggestions();
|
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.syncCaseTagsUi = syncCaseTagsUi;
|
||||||
|
window.createCaseBuzzwordFromInput = createCaseBuzzwordFromInput;
|
||||||
|
window.createCaseBuzzwordFromCurrentSelection = createCaseBuzzwordFromCurrentSelection;
|
||||||
|
window.removeCaseBuzzwordAndSync = removeCaseBuzzwordAndSync;
|
||||||
|
|
||||||
let todoUserId = null;
|
let todoUserId = null;
|
||||||
|
|
||||||
@ -6221,9 +6373,9 @@
|
|||||||
<div class="p-2 text-muted small">Ingen tags paa sagen endnu</div>
|
<div class="p-2 text-muted small">Ingen tags paa sagen endnu</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="border-top pt-2">
|
<div class="border-top pt-1">
|
||||||
<div class="small text-muted fw-semibold mb-2">Forslag (brand/type)</div>
|
<div class="small text-muted fw-semibold mb-1">Forslag</div>
|
||||||
<div id="case-tag-suggestions">
|
<div id="case-tag-suggestions" style="max-height: 72px; overflow: auto;">
|
||||||
<div class="text-muted small">Indlaeser forslag...</div>
|
<div class="text-muted small">Indlaeser forslag...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -6263,6 +6415,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 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">
|
<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>
|
<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);
|
document.body.setAttribute('data-case-view', viewName);
|
||||||
|
|
||||||
const viewDefaults = {
|
const viewDefaults = {
|
||||||
'Pipeline': ['pipeline', 'relations', 'sales', 'time'],
|
'Pipeline': ['pipeline', 'relations', 'sales', 'time', 'buzzwords'],
|
||||||
'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags'],
|
'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags', 'buzzwords'],
|
||||||
'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar']
|
'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)
|
const currentCaseTypeKey = (typeof caseTypeKey !== 'undefined' && caseTypeKey)
|
||||||
@ -12158,12 +12350,14 @@
|
|||||||
const standardModuleSet = new Set(standardModules);
|
const standardModuleSet = new Set(standardModules);
|
||||||
standardModuleSet.add('tags');
|
standardModuleSet.add('tags');
|
||||||
standardModuleSet.add('time');
|
standardModuleSet.add('time');
|
||||||
|
standardModuleSet.add('buzzwords');
|
||||||
|
|
||||||
document.querySelectorAll('[data-module]').forEach((el) => {
|
document.querySelectorAll('[data-module]').forEach((el) => {
|
||||||
const moduleName = el.getAttribute('data-module');
|
const moduleName = el.getAttribute('data-module');
|
||||||
const hasContent = moduleHasContent(el);
|
const hasContent = moduleHasContent(el);
|
||||||
const isTimeModule = moduleName === 'time';
|
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 pref = modulePrefs[moduleName];
|
||||||
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
|
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
|
||||||
|
|
||||||
@ -12199,6 +12393,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isBuzzwordsModule) {
|
||||||
|
setVisibility(true);
|
||||||
|
el.classList.remove('module-empty-compact');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
|
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
|
||||||
if (pref === false) {
|
if (pref === false) {
|
||||||
setVisibility(false);
|
setVisibility(false);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -33,16 +33,24 @@ class EmailProcessorService:
|
|||||||
self.auto_process = settings.EMAIL_RULES_AUTO_PROCESS
|
self.auto_process = settings.EMAIL_RULES_AUTO_PROCESS
|
||||||
self.ai_enabled = settings.EMAIL_AI_ENABLED
|
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
|
Main entry point: Process all new emails from inbox
|
||||||
Returns: Processing statistics
|
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)")
|
logger.info("⏭️ Email processing disabled (EMAIL_TO_TICKET_ENABLED=false)")
|
||||||
return {'status': 'disabled'}
|
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...")
|
logger.info("🔄 Starting email processing cycle...")
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
@ -56,8 +64,18 @@ class EmailProcessorService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Fetch new emails
|
# Step 1: Fetch new emails
|
||||||
limit = settings.EMAIL_MAX_FETCH_PER_RUN
|
limit = int(limit_override) if limit_override else settings.EMAIL_MAX_FETCH_PER_RUN
|
||||||
new_emails = await self.email_service.fetch_new_emails(limit=limit)
|
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)
|
stats['fetched'] = len(new_emails)
|
||||||
|
|
||||||
if not new_emails:
|
if not new_emails:
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import asyncio
|
|||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
# Try to import aiosmtplib, but don't fail if not available
|
# Try to import aiosmtplib, but don't fail if not available
|
||||||
try:
|
try:
|
||||||
@ -33,7 +34,7 @@ from aiohttp import ClientSession, BasicAuth
|
|||||||
import msal
|
import msal
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import execute_query, execute_insert
|
from app.core.database import execute_query, execute_insert, execute_update
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -244,17 +245,61 @@ class EmailService:
|
|||||||
Fetch new emails from configured source (IMAP or Graph API)
|
Fetch new emails from configured source (IMAP or Graph API)
|
||||||
Returns list of parsed email dictionaries
|
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']:
|
if self.use_graph and self.graph_config['client_id']:
|
||||||
logger.info("📥 Fetching emails via Microsoft Graph API")
|
logger.info("📥 Fetching emails via Microsoft Graph API (folder=%s)", selected_folder)
|
||||||
return await self._fetch_via_graph(limit)
|
return await self._fetch_via_graph(limit, folder=selected_folder)
|
||||||
elif self.imap_config['username']:
|
elif self.imap_config['username']:
|
||||||
logger.info("📥 Fetching emails via IMAP")
|
logger.info("📥 Fetching emails via IMAP (folder=%s)", selected_folder)
|
||||||
return await self._fetch_via_imap(limit)
|
return await self._fetch_via_imap(limit, folder=selected_folder)
|
||||||
else:
|
else:
|
||||||
logger.warning("⚠️ No email source configured (IMAP or Graph API)")
|
logger.warning("⚠️ No email source configured (IMAP or Graph API)")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def _fetch_via_imap(self, limit: int) -> List[Dict]:
|
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, folder: Optional[str] = None) -> List[Dict]:
|
||||||
"""Fetch emails using IMAP protocol (READ-ONLY mode)"""
|
"""Fetch emails using IMAP protocol (READ-ONLY mode)"""
|
||||||
emails = []
|
emails = []
|
||||||
|
|
||||||
@ -269,12 +314,12 @@ class EmailService:
|
|||||||
mail.login(self.imap_config['username'], self.imap_config['password'])
|
mail.login(self.imap_config['username'], self.imap_config['password'])
|
||||||
|
|
||||||
# Select folder in READ-ONLY mode (critical for safety)
|
# 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']
|
readonly = self.imap_config['readonly']
|
||||||
mail.select(folder, readonly=readonly)
|
mail.select(selected_folder, readonly=readonly)
|
||||||
|
|
||||||
if 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
|
# Search for all emails
|
||||||
status, messages = mail.search(None, 'ALL')
|
status, messages = mail.search(None, 'ALL')
|
||||||
@ -286,7 +331,7 @@ class EmailService:
|
|||||||
email_ids = messages[0].split()
|
email_ids = messages[0].split()
|
||||||
total_emails = len(email_ids)
|
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)
|
# Get most recent emails (reverse order, limit)
|
||||||
email_ids_to_fetch = email_ids[-limit:] if len(email_ids) > limit else email_ids
|
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)
|
msg = email.message_from_bytes(raw_email)
|
||||||
|
|
||||||
# Extract fields
|
# 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
|
# Check if already exists in database
|
||||||
if not self._email_exists(parsed_email['message_id']):
|
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']}")
|
logger.info(f"✅ New email: {parsed_email['subject'][:50]}... from {parsed_email['sender_email']}")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"⏭️ Email already exists: {parsed_email['message_id']}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error parsing email {email_id}: {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}")
|
logger.error(f"❌ Unexpected error fetching via IMAP: {e}")
|
||||||
return []
|
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)"""
|
"""Fetch emails using Microsoft Graph API (OAuth2)"""
|
||||||
emails = []
|
emails = []
|
||||||
|
|
||||||
@ -346,22 +392,36 @@ class EmailService:
|
|||||||
|
|
||||||
# Build Graph API request
|
# Build Graph API request
|
||||||
user_email = self.graph_config['user_email']
|
user_email = self.graph_config['user_email']
|
||||||
folder = self.imap_config['folder'] # Use same folder name
|
selected_folder = self._resolve_folder(folder)
|
||||||
|
|
||||||
# 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'
|
|
||||||
}
|
|
||||||
|
|
||||||
async with ClientSession() as session:
|
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:
|
async with session.get(url, params=params, headers=headers) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
error_text = await response.text()
|
error_text = await response.text()
|
||||||
@ -371,11 +431,11 @@ class EmailService:
|
|||||||
data = await response.json()
|
data = await response.json()
|
||||||
messages = data.get('value', [])
|
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:
|
for msg in messages:
|
||||||
try:
|
try:
|
||||||
parsed_email = self._parse_graph_message(msg)
|
parsed_email = self._parse_graph_message(msg, folder=selected_folder)
|
||||||
|
|
||||||
# Fetch attachments if email has them
|
# Fetch attachments if email has them
|
||||||
if msg.get('hasAttachments', False):
|
if msg.get('hasAttachments', False):
|
||||||
@ -396,6 +456,7 @@ class EmailService:
|
|||||||
logger.info(f"✅ New email: {parsed_email['subject'][:50]}... from {parsed_email['sender_email']}")
|
logger.info(f"✅ New email: {parsed_email['subject'][:50]}... from {parsed_email['sender_email']}")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"⏭️ Email already exists: {parsed_email['message_id']}")
|
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)
|
# Re-save attachment bytes for existing emails (fills content_data for old emails)
|
||||||
if parsed_email.get('attachments'):
|
if parsed_email.get('attachments'):
|
||||||
await self._resave_attachment_content(
|
await self._resave_attachment_content(
|
||||||
@ -414,6 +475,191 @@ class EmailService:
|
|||||||
logger.error(f"❌ Unexpected error fetching via Graph API: {e}")
|
logger.error(f"❌ Unexpected error fetching via Graph API: {e}")
|
||||||
return []
|
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]:
|
async def _get_graph_access_token(self) -> Optional[str]:
|
||||||
"""Get OAuth2 access token for Microsoft Graph API using MSAL"""
|
"""Get OAuth2 access token for Microsoft Graph API using MSAL"""
|
||||||
try:
|
try:
|
||||||
@ -441,7 +687,7 @@ class EmailService:
|
|||||||
logger.error(f"❌ Error getting Graph access token: {e}")
|
logger.error(f"❌ Error getting Graph access token: {e}")
|
||||||
return None
|
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"""
|
"""Parse IMAP email message into dictionary"""
|
||||||
|
|
||||||
# Decode subject
|
# Decode subject
|
||||||
@ -545,13 +791,13 @@ class EmailService:
|
|||||||
'body_text': body_text,
|
'body_text': body_text,
|
||||||
'body_html': body_html,
|
'body_html': body_html,
|
||||||
'received_date': received_date,
|
'received_date': received_date,
|
||||||
'folder': self.imap_config['folder'],
|
'folder': self._resolve_folder(folder),
|
||||||
'has_attachments': len(attachments) > 0,
|
'has_attachments': len(attachments) > 0,
|
||||||
'attachment_count': len(attachments),
|
'attachment_count': len(attachments),
|
||||||
'attachments': 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"""
|
"""Parse Microsoft Graph API message into dictionary"""
|
||||||
|
|
||||||
# Extract sender
|
# Extract sender
|
||||||
@ -607,7 +853,7 @@ class EmailService:
|
|||||||
'body_text': body_text,
|
'body_text': body_text,
|
||||||
'body_html': body_html,
|
'body_html': body_html,
|
||||||
'received_date': received_date,
|
'received_date': received_date,
|
||||||
'folder': self.imap_config['folder'],
|
'folder': self._resolve_folder(folder),
|
||||||
'has_attachments': msg.get('hasAttachments', False),
|
'has_attachments': msg.get('hasAttachments', False),
|
||||||
'attachment_count': 0 # Will be updated after fetching attachments
|
'attachment_count': 0 # Will be updated after fetching attachments
|
||||||
}
|
}
|
||||||
|
|||||||
@ -235,10 +235,10 @@ class EmailWorkflowService:
|
|||||||
def has_helpdesk_routing_hint(self, email_data: Dict) -> bool:
|
def has_helpdesk_routing_hint(self, email_data: Dict) -> bool:
|
||||||
"""Return True when email has explicit routing hints (SAG tag, BMCid, or reply headers).
|
"""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
|
NOTE: A bare thread_key (Graph conversationId) is NOT automatically a
|
||||||
because every Graph email has one, including newsletters and spam.
|
routing hint because every Graph email has one, including newsletters
|
||||||
Only actual reply indicators (In-Reply-To, References), explicit
|
and spam. However, if the thread_key already maps to an existing SAG
|
||||||
SAG tags, or BMCid markers count as hints."""
|
via prior linked emails, we treat it as a valid hint."""
|
||||||
if self._extract_bmc_id(email_data):
|
if self._extract_bmc_id(email_data):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -251,6 +251,10 @@ class EmailWorkflowService:
|
|||||||
if self._extract_reference_message_ids(email_data.get('email_references')):
|
if self._extract_reference_message_ids(email_data.get('email_references')):
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
def _extract_bmc_id(self, email_data: Dict) -> Optional[Dict[str, Any]]:
|
def _extract_bmc_id(self, email_data: Dict) -> Optional[Dict[str, Any]]:
|
||||||
@ -364,15 +368,16 @@ class EmailWorkflowService:
|
|||||||
try:
|
try:
|
||||||
rows = execute_query(
|
rows = execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT se.sag_id
|
SELECT COALESCE(se.sag_id, em.linked_case_id) AS sag_id
|
||||||
FROM sag_emails se
|
FROM email_messages em
|
||||||
JOIN email_messages em ON em.id = se.email_id
|
LEFT JOIN sag_emails se ON se.email_id = em.id
|
||||||
WHERE em.deleted_at IS NULL
|
WHERE em.deleted_at IS NULL
|
||||||
|
AND COALESCE(se.sag_id, em.linked_case_id) IS NOT NULL
|
||||||
AND (
|
AND (
|
||||||
LOWER(REGEXP_REPLACE(COALESCE(em.thread_key, ''), '[<>\\s]', '', 'g')) = %s
|
LOWER(REGEXP_REPLACE(COALESCE(em.thread_key, ''), '[<>\\s]', '', 'g')) = %s
|
||||||
OR LOWER(REGEXP_REPLACE(COALESCE(em.message_id, ''), '[<>\\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
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(thread_key, thread_key)
|
(thread_key, thread_key)
|
||||||
@ -389,18 +394,42 @@ class EmailWorkflowService:
|
|||||||
placeholders = ','.join(['%s'] * len(thread_message_ids))
|
placeholders = ','.join(['%s'] * len(thread_message_ids))
|
||||||
rows = execute_query(
|
rows = execute_query(
|
||||||
f"""
|
f"""
|
||||||
SELECT se.sag_id
|
SELECT COALESCE(se.sag_id, em.linked_case_id) AS sag_id
|
||||||
FROM sag_emails se
|
FROM email_messages em
|
||||||
JOIN email_messages em ON em.id = se.email_id
|
LEFT JOIN sag_emails se ON se.email_id = em.id
|
||||||
WHERE em.deleted_at IS NULL
|
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})
|
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
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
tuple(thread_message_ids)
|
tuple(thread_message_ids)
|
||||||
)
|
)
|
||||||
return rows[0]['sag_id'] if rows else None
|
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.
|
# Sender domains that should never trigger customer-domain SAG creation.
|
||||||
# Includes own sending domain and common automated senders.
|
# Includes own sending domain and common automated senders.
|
||||||
_IGNORED_SENDER_DOMAINS = {
|
_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_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_thread = self._find_sag_id_from_thread_headers(email_data)
|
||||||
sag_id_from_tag = self._extract_sag_id(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)
|
scan_token_route = self._resolve_scan_token_route(email_id, email_data)
|
||||||
|
|
||||||
if scan_token_route and scan_token_route.get('sag_id'):
|
if scan_token_route and scan_token_route.get('sag_id'):
|
||||||
@ -1136,6 +1166,11 @@ class EmailWorkflowService:
|
|||||||
routing_source = 'sag_tag'
|
routing_source = 'sag_tag'
|
||||||
logger.info("🏷️ Matched email %s to SAG-%s via SAG tag", email_id, sag_id)
|
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
|
# 1) Existing SAG via subject/headers
|
||||||
if sag_id:
|
if sag_id:
|
||||||
return await self._finalize_sag_routing(email_id, email_data, sag_id, routing_source)
|
return await self._finalize_sag_routing(email_id, email_data, sag_id, routing_source)
|
||||||
|
|||||||
@ -4,8 +4,11 @@ Handles subscription and sales order data retrieval
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
import html as html_lib
|
||||||
import aiohttp
|
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
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -37,6 +40,68 @@ class VTigerService:
|
|||||||
raise ValueError("VTIGER_API_KEY not configured")
|
raise ValueError("VTIGER_API_KEY not configured")
|
||||||
return aiohttp.BasicAuth(self.username, self.api_key)
|
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]:
|
async def query(self, query_string: str) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Execute a query on vTiger REST API
|
Execute a query on vTiger REST API
|
||||||
@ -506,7 +571,7 @@ class VTigerService:
|
|||||||
logger.error(f"❌ Error updating subscription: {e}")
|
logger.error(f"❌ Error updating subscription: {e}")
|
||||||
raise
|
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
|
Fetch service contracts from vTiger
|
||||||
|
|
||||||
@ -517,15 +582,26 @@ class VTigerService:
|
|||||||
List of service contract records with account_id included
|
List of service contract records with account_id included
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if account_id:
|
query_filters = []
|
||||||
query = (
|
safe_account_id = self._sanitize_vtiger_id(account_id)
|
||||||
"SELECT * FROM ServiceContracts "
|
|
||||||
f"WHERE sc_related_to='{account_id}' AND contract_status='Active';"
|
if account_id and not safe_account_id:
|
||||||
)
|
logger.warning("⚠️ Rejected unsafe account_id for service contract query")
|
||||||
logger.info(f"🔍 Fetching active service contracts for account {account_id}")
|
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:
|
else:
|
||||||
query = "SELECT * FROM ServiceContracts WHERE contract_status='Active';"
|
logger.info(f"🔍 Fetching all service contracts (active_only={active_only})")
|
||||||
logger.info(f"🔍 Fetching all active service contracts")
|
|
||||||
|
|
||||||
contracts = await self.query(query)
|
contracts = await self.query(query)
|
||||||
logger.info(f"✅ Found {len(contracts)} service contracts")
|
logger.info(f"✅ Found {len(contracts)} service contracts")
|
||||||
@ -634,6 +710,422 @@ class VTigerService:
|
|||||||
logger.error(f"❌ Error fetching contract timelogs: {e}")
|
logger.error(f"❌ Error fetching contract timelogs: {e}")
|
||||||
return []
|
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
|
# Singleton instance
|
||||||
_vtiger_service = None
|
_vtiger_service = None
|
||||||
|
|||||||
@ -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="/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/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>
|
<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>
|
<option value="{{ default_dashboard_path }}" selected>Nuværende (tilpasset): {{ default_dashboard_path }}</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -783,13 +783,18 @@
|
|||||||
<i class="bi bi-people me-2"></i>CRM
|
<i class="bi bi-people me-2"></i>CRM
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu mt-2">
|
<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="/customers">Kunder</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/contacts">Kontakter</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="/vendors">Leverandører</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Leads</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@ -807,21 +812,20 @@
|
|||||||
<i class="bi bi-headset me-2"></i>Support
|
<i class="bi bi-headset me-2"></i>Support
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu mt-2">
|
<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="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
|
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>BMC Assets</a></li>
|
<li><a class="dropdown-item py-2" href="/emails"><i class="bi bi-envelope me-2"></i>Email</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="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
|
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
|
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
|
||||||
<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="/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><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><h6 class="dropdown-header">Assets & Licenser</h6></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="/hardware"><i class="bi bi-laptop me-2"></i>BMC Assets</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</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><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>
|
<li><a class="dropdown-item py-2" href="/manual"><i class="bi bi-journal-richtext me-2"></i>Manualer</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@ -830,14 +834,10 @@
|
|||||||
<i class="bi bi-cart3 me-2"></i>Salg
|
<i class="bi bi-cart3 me-2"></i>Salg
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu mt-2">
|
<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="/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><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><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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
@ -845,19 +845,16 @@
|
|||||||
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu mt-2">
|
<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="/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><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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/emails">
|
|
||||||
<i class="bi bi-envelope me-2"></i>Email
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="dropdown">
|
<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"><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/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/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-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/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>
|
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
|
||||||
</ul>
|
</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">
|
<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>
|
<i class="bi bi-bell"></i>
|
||||||
</button>
|
</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">
|
<div class="dropdown">
|
||||||
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include "shared/frontend/bug_report_modal.html" %}
|
||||||
|
|
||||||
{% block content_wrapper %}
|
{% block content_wrapper %}
|
||||||
<div class="container-fluid px-4 py-4">
|
<div class="container-fluid px-4 py-4">
|
||||||
{% block content %}{% endblock %}
|
{% 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/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=2.2"></script>
|
<script src="/static/js/telefoni.js?v=2.2"></script>
|
||||||
<script src="/static/js/sms.js?v=1.0"></script>
|
<script src="/static/js/sms.js?v=1.0"></script>
|
||||||
|
<script src="/static/js/bug-report.js?v=1.0"></script>
|
||||||
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
|
|||||||
@ -523,6 +523,72 @@ class ServiceContractWizardSummary(BaseModel):
|
|||||||
timestamp: datetime
|
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):
|
class TimologTransferRequest(BaseModel):
|
||||||
"""Request to transfer single timelog to klippekort"""
|
"""Request to transfer single timelog to klippekort"""
|
||||||
timelog_id: str = Field(..., description="vTiger timelog ID")
|
timelog_id: str = Field(..., description="vTiger timelog ID")
|
||||||
|
|||||||
@ -7,12 +7,13 @@ Isoleret routing uden påvirkning af existing Hub endpoints.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from io import BytesIO
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date, timedelta
|
||||||
from calendar import monthrange
|
from calendar import monthrange
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Body, Query
|
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.core.database import execute_query, execute_update, execute_query_single
|
||||||
from app.timetracking.backend.models import (
|
from app.timetracking.backend.models import (
|
||||||
@ -33,6 +34,9 @@ from app.timetracking.backend.models import (
|
|||||||
ServiceContractWizardData,
|
ServiceContractWizardData,
|
||||||
ServiceContractWizardAction,
|
ServiceContractWizardAction,
|
||||||
ServiceContractWizardSummary,
|
ServiceContractWizardSummary,
|
||||||
|
ServiceContractReportCustomer,
|
||||||
|
ServiceContractReportContract,
|
||||||
|
ServiceContractReportData,
|
||||||
TimologTransferRequest,
|
TimologTransferRequest,
|
||||||
TimologTransferResult,
|
TimologTransferResult,
|
||||||
)
|
)
|
||||||
@ -1834,6 +1838,192 @@ async def list_time_entries(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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"])
|
@router.get("/times/{time_id}", tags=["Times"])
|
||||||
async def get_time_entry(time_id: int):
|
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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error fetching customer cards: {e}")
|
logger.error(f"❌ Error fetching customer cards: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(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")
|
||||||
|
|||||||
320
app/timetracking/frontend/employee_log.html
Normal file
320
app/timetracking/frontend/employee_log.html
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
431
app/timetracking/frontend/service_contract_report.html
Normal file
431
app/timetracking/frontend/service_contract_report.html
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -56,3 +56,15 @@ async def timetracking_orders(request: Request):
|
|||||||
async def service_contract_wizard(request: Request):
|
async def service_contract_wizard(request: Request):
|
||||||
"""Service Contract Migration Wizard"""
|
"""Service Contract Migration Wizard"""
|
||||||
return templates.TemplateResponse("timetracking/frontend/service_contract_wizard.html", {"request": request})
|
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})
|
||||||
|
|||||||
@ -64,6 +64,11 @@ services:
|
|||||||
- FEDEX_BASE_URL=${FEDEX_BASE_URL}
|
- FEDEX_BASE_URL=${FEDEX_BASE_URL}
|
||||||
- FEDEX_TIMEOUT_SECONDS=${FEDEX_TIMEOUT_SECONDS}
|
- FEDEX_TIMEOUT_SECONDS=${FEDEX_TIMEOUT_SECONDS}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "ollama-host:172.16.31.195"
|
- "ollama-host:172.16.31.195"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
125
docs/audits/ROUTE_AUTH_AUDIT_2026-05-09.md
Normal file
125
docs/audits/ROUTE_AUTH_AUDIT_2026-05-09.md
Normal 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 |
|
||||||
4
main.py
4
main.py
@ -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.alert_notes.backend import router as alert_notes_api
|
||||||
from app.billing.backend import router as billing_api
|
from app.billing.backend import router as billing_api
|
||||||
from app.billing.frontend import views as billing_views
|
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 router as system_api
|
||||||
from app.system.backend import sync_router
|
from app.system.backend import sync_router
|
||||||
from app.dashboard.backend import views as dashboard_views
|
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(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(alert_notes_api, prefix="/api/v1", tags=["Alert Notes"])
|
||||||
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
|
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(system_api.router, prefix="/api/v1", tags=["System"])
|
||||||
app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"])
|
app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"])
|
||||||
app.include_router(mission_api.router, prefix="/api/v1", tags=["Mission"])
|
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(vendors_views.router, tags=["Frontend"])
|
||||||
app.include_router(timetracking_views.router, tags=["Frontend"])
|
app.include_router(timetracking_views.router, tags=["Frontend"])
|
||||||
app.include_router(billing_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(ticket_views.router, prefix="/ticket", tags=["Frontend"])
|
||||||
app.include_router(contacts_views.router, tags=["Frontend"])
|
app.include_router(contacts_views.router, tags=["Frontend"])
|
||||||
app.include_router(tags_views.router, tags=["Frontend"])
|
app.include_router(tags_views.router, tags=["Frontend"])
|
||||||
|
|||||||
41
migrations/188_sag_buzzwords.sql
Normal file
41
migrations/188_sag_buzzwords.sql
Normal 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;
|
||||||
93
migrations/189_mission_projects.sql
Normal file
93
migrations/189_mission_projects.sql
Normal 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);
|
||||||
@ -23,3 +23,5 @@ Pillow==11.0.0
|
|||||||
brother_ql==0.9.4
|
brother_ql==0.9.4
|
||||||
pyzbar==0.1.9
|
pyzbar==0.1.9
|
||||||
pypdfium2==4.30.0
|
pypdfium2==4.30.0
|
||||||
|
reportlab==4.2.5
|
||||||
|
openpyxl==3.1.5
|
||||||
|
|||||||
@ -97,12 +97,31 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
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) {
|
function applyState(data) {
|
||||||
if (data && data.enabled) {
|
if (data && data.enabled) {
|
||||||
latestSections = data.sections || {};
|
latestSections = data.sections || {};
|
||||||
@ -697,12 +716,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pollOnce() {
|
function pollOnce() {
|
||||||
|
let shouldScheduleNextPoll = true;
|
||||||
fetchBottomBarState().then(function (data) {
|
fetchBottomBarState().then(function (data) {
|
||||||
applyState(data);
|
applyState(data);
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
console.warn('Bottom bar poll failed', err);
|
console.warn('Bottom bar poll failed', err);
|
||||||
|
if (err && (err.status === 401 || err.status === 403)) {
|
||||||
|
shouldScheduleNextPoll = false;
|
||||||
|
disableBottomBarAuthRetry('poll-' + err.status);
|
||||||
|
}
|
||||||
}).finally(function () {
|
}).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();
|
startPollingFallback();
|
||||||
scheduleWsReconnect();
|
scheduleWsReconnect();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -311,7 +311,11 @@
|
|||||||
bugModal.show();
|
bugModal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareScreenshotFromTrigger() {
|
function prepareScreenshotFromTrigger(forceNew) {
|
||||||
|
if (!forceNew && pendingScreenshotPromise) {
|
||||||
|
return pendingScreenshotPromise;
|
||||||
|
}
|
||||||
|
|
||||||
pendingScreenshotPromise = takeScreenshot();
|
pendingScreenshotPromise = takeScreenshot();
|
||||||
return pendingScreenshotPromise;
|
return pendingScreenshotPromise;
|
||||||
}
|
}
|
||||||
@ -434,11 +438,14 @@
|
|||||||
const modalEl = document.getElementById('bugReportModal');
|
const modalEl = document.getElementById('bugReportModal');
|
||||||
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
|
const primeCapture = () => {
|
||||||
|
prepareScreenshotFromTrigger(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
btn.addEventListener('pointerdown', primeCapture);
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!pendingScreenshotPromise) {
|
prepareScreenshotFromTrigger(false);
|
||||||
prepareScreenshotFromTrigger();
|
|
||||||
}
|
|
||||||
openBugReportModal();
|
openBugReportModal();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -479,9 +486,7 @@
|
|||||||
if (isTyping) return;
|
if (isTyping) return;
|
||||||
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
|
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!pendingScreenshotPromise) {
|
prepareScreenshotFromTrigger(true);
|
||||||
prepareScreenshotFromTrigger();
|
|
||||||
}
|
|
||||||
openBugReportModal();
|
openBugReportModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user