From abfb945679e58983fb7ca04f52ffb5fb1e5b3f5e Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 20 Sep 2026 03:33:10 +0200 Subject: [PATCH] feat(planner): add overdue cases handling and improve case rendering - Introduced `overdueCases` state to track overdue cases in the planner. - Enhanced `loadCases` function to fetch and store overdue cases. - Updated `renderCases` to display overdue cases with appropriate labels. - Modified case card rendering to highlight overdue cases. fix(telefoni): ensure WebSocket connection on DOMContentLoaded - Adjusted WebSocket connection logic to handle cases where the script loads after DOMContentLoaded. test(tests): add tests for case creation and internal ticket handling - Added tests to ensure ticket creation commits all optional data. - Implemented tests for internal ticket creation, ensuring correct customer and user assignments. feat(migrations): add new tables for email configuration and case assignments - Created `email_connection_configurations` table for managing email settings. - Introduced `sag_case_assignees` table to handle individual case assignments. - Added `sag_case_details` table for structured case type details. - Updated existing case types to align with new templates and added support for internal tickets. fix(timetracking): allow manual time creation without case customer - Modified manual time creation to support entries without a customer. - Updated settlement options to recommend internal settlement for customerless time. feat(v4): enhance case detail template with new features - Added new features to the case detail template, including compact icons and inline relation creation. - Improved comment actions and time tracking functionalities. --- app/auth/backend/router.py | 4 +- app/modules/planner/backend/router.py | 66 +- app/modules/planner/templates/index.html | 5 +- app/modules/sag/backend/router.py | 385 +- app/modules/sag/frontend/views.py | 55 +- app/modules/sag/services/relation_service.py | 6 + app/modules/sag/templates/create.html | 61 +- app/modules/sag/templates/detail_v4.html | 23886 ++++++++++++++++ app/services/email_service.py | 103 +- app/settings/backend/router.py | 224 +- app/settings/frontend/settings.html | 182 + app/shared/frontend/base.html | 2 +- app/timetracking/backend/router.py | 26 +- .../sikker-fakturaudtraek-hybrid-ai.md | 2 +- .../1035_email_connection_configuration.sql | 30 + .../1036_sag_individual_assignments.sql | 12 + migrations/1037_case_type_details.sql | 13 + .../1038_sync_case_type_with_template.sql | 7 + migrations/1039_set_supported_case_types.sql | 17 + .../1040_tmodule_times_optional_customer.sql | 6 + .../1041_seed_internal_case_customer.sql | 22 + static/js/case-create.js | 122 +- static/js/planner.js | 7 +- static/js/telefoni.js | 9 +- tests/test_case_create_support.py | 51 + tests/test_planner.py | 11 + tests/test_sag_module.py | 177 + tests/test_timetracking_manual_time.py | 37 + 28 files changed, 25317 insertions(+), 211 deletions(-) create mode 100644 app/modules/sag/templates/detail_v4.html create mode 100644 migrations/1035_email_connection_configuration.sql create mode 100644 migrations/1036_sag_individual_assignments.sql create mode 100644 migrations/1037_case_type_details.sql create mode 100644 migrations/1038_sync_case_type_with_template.sql create mode 100644 migrations/1039_set_supported_case_types.sql create mode 100644 migrations/1040_tmodule_times_optional_customer.sql create mode 100644 migrations/1041_seed_internal_case_customer.sql diff --git a/app/auth/backend/router.py b/app/auth/backend/router.py index cba9c85..7282906 100644 --- a/app/auth/backend/router.py +++ b/app/auth/backend/router.py @@ -221,7 +221,7 @@ class UserProfileUpdate(BaseModel): def _allowed_case_types() -> list[str]: - fallback = ["ticket", "pipeline", "opgave", "ordre", "projekt", "service"] + fallback = ["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"] try: rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or [] if rows: @@ -229,7 +229,7 @@ def _allowed_case_types() -> list[str]: configured = json.loads(rows[0].get("value") or "[]") values = [str(value).strip().lower() for value in configured if str(value).strip()] if values: - return list(dict.fromkeys(values + (["pipeline"] if "pipeline" not in values else []))) + return list(dict.fromkeys(values)) except Exception: logger.warning("Could not load configured case types for profile preference", exc_info=True) return fallback diff --git a/app/modules/planner/backend/router.py b/app/modules/planner/backend/router.py index 56bf0e0..1894408 100644 --- a/app/modules/planner/backend/router.py +++ b/app/modules/planner/backend/router.py @@ -229,7 +229,7 @@ def update_settings(payload: PlannerSettingsInput, current_user: dict = Depends( def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str] = Query(default=[]), case_type: list[str] = Query(default=[]), responsible_id: int | None = None, department_id: int | None = None, - planning: Literal["all", "unplanned", "partial", "planned"] = "all", mine: bool = False, + planning: Literal["all", "unplanned", "partial", "planned", "overdue"] = "all", mine: bool = False, urgent: bool = False, sort: Literal["priority", "remaining", "updated"] = "priority", limit: int = Query(100, ge=1, le=300), current_user: dict = Depends(get_current_user)): _require_view(current_user) @@ -239,7 +239,9 @@ def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str COALESCE(s.estimated_minutes, 0) AS estimated_minutes, COALESCE(used.used_minutes, 0) AS used_minutes, used.latest_registration_at, - COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes + COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes, + BOOL_OR(a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE) AS overdue_planned, + MAX(a.ends_at) FILTER (WHERE a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE) AS overdue_planned_until FROM sag_sager s JOIN customers c ON c.id=s.customer_id LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id LEFT JOIN planner_allocations a ON a.sag_id=s.id LEFT JOIN groups department ON department.id=s.assigned_group_id @@ -266,6 +268,7 @@ def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str if planning == "unplanned": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)=0" elif planning == "partial": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0):not(.panel-heading),.case-panel.collapsed .case-list{display:none}.case-panel.collapsed{max-height:58px}.collapse-cases{display:inline-block!important}}.collapse-cases{display:none;border:0;background:transparent} +.quick-filters button[data-quick=overdue]{border-color:#f5b4ae;color:#b42318;font-weight:700}.quick-filters button[data-quick=overdue].active{background:#fef3f2;color:#b42318;border-color:#f04438}.overdue-case-section{border:1px solid #f5b4ae;border-radius:10px;background:#fff7f6;padding:.65rem}.overdue-case-heading{display:flex;align-items:center;gap:.4rem;color:#b42318;font-size:.8rem;font-weight:800;margin-bottom:.5rem}.case-card.overdue-planned{border-color:#f5b4ae;border-left-color:#d92d20;background:#fffafa}.case-overdue-label{display:inline-flex;align-items:center;gap:.25rem;color:#b42318;font-size:.73rem;font-weight:750;margin-top:.4rem} {% endblock %} {% block content %} @@ -15,7 +16,7 @@
- +
LedigNæsten fuldFuld/overbooketFri/fravær
@@ -23,4 +24,4 @@
Faktisk registreret tid
{% endblock %} -{% block extra_js %}{% endblock %} +{% block extra_js %}{% endblock %} diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 9664db3..652d9af 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -108,74 +108,33 @@ def _ensure_case_internet_connection_tag(sag_id: int) -> None: def _normalize_case_status(status_value: Optional[str]) -> str: - allowed_statuses = [] - seen = set() - - def _add_status(value: Optional[str]) -> None: - candidate = str(value or "").strip() - if not candidate: - return - key = candidate.lower() - if key in seen: - return - seen.add(key) - allowed_statuses.append(candidate) - - try: - setting_row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("case_statuses",)) - if setting_row and setting_row.get("value"): - parsed = json.loads(setting_row.get("value") or "[]") - for item in parsed if isinstance(parsed, list) else []: - if isinstance(item, str): - value = item.strip() - elif isinstance(item, dict): - value = str(item.get("value") or "").strip() - else: - value = "" - _add_status(value) - except Exception: - pass - - # Include historical/current DB statuses so legacy values remain valid - try: - rows = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or [] - for row in rows: - _add_status(row.get("status")) - except Exception: - pass - - if not allowed_statuses: - allowed_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"] - - allowed_map = {s.lower(): s for s in allowed_statuses} - open_aliases = {"åben", "open", "under behandling", "afventer", "i_gang", "on_hold"} - closed_aliases = {"lukket", "closed", "løst", "afsluttet", "resolved", "done"} - open_default = allowed_map.get("åben", allowed_statuses[0]) - closed_default = allowed_map.get("lukket", allowed_map.get("løst", open_default)) - - if not status_value: - return open_default - - normalized = str(status_value).strip().lower() - if normalized in allowed_map: - return allowed_map[normalized] - - if normalized in open_aliases: - return open_default - - if normalized in closed_aliases: - return closed_default - - # Backward compatibility for legacy mapping - if normalized == "afventer" and "åben" in allowed_map: - return open_default - - # Do not force unknown values back to default; preserve user-entered/custom DB values - raw_value = str(status_value).strip() - if raw_value: - return raw_value - - return open_default + canonical = { + "ny": "Ny", + "tildelt": "Tildelt", + "igangværende": "Igangværende", + "afventer": "Afventer", + "løst": "Løst", + "kunde svaret": "Kunde svaret", + "lukket": "Lukket", + } + legacy_aliases = { + "åben": "Ny", + "open": "Ny", + "under behandling": "Igangværende", + "i_gang": "Igangværende", + "in progress": "Igangværende", + "on_hold": "Afventer", + "resolved": "Løst", + "closed": "Lukket", + "afsluttet": "Lukket", + "done": "Lukket", + } + normalized = str(status_value or "Ny").strip().lower() + if normalized in canonical: + return canonical[normalized] + if normalized in legacy_aliases: + return legacy_aliases[normalized] + raise HTTPException(status_code=400, detail="Ugyldig status. Vælg en af de faste sagsstatuser.") def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]: @@ -442,6 +401,12 @@ class SagListPreferencesUpdate(BaseModel): hidden_columns: Optional[List[str]] = None +class CaseAssignmentsUpdate(BaseModel): + group_id: Optional[int] = None + user_ids: List[int] = Field(default_factory=list) + primary_user_id: Optional[int] = None + + SAG_LIST_COLUMN_KEYS = ( "id", "company", "contact", "description", "type", "priority", "status", "owner", "group", "next_todo", "created", "start", "deferred", "deadline", @@ -1022,23 +987,49 @@ async def create_sag(request: Request, data: dict): try: if not data.get("titel"): raise HTTPException(status_code=400, detail="titel is required") - if not data.get("customer_id"): + case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket" + customer_required_types = {"ticket", "salg", "bogholderi", "projekt", "bug_report", "service", "pipeline", "ordre", "opgave", "abonnement"} + if case_type == "intern_ticket": + # Internal work always belongs to BMC Networks and is owned by its creator. + data["customer_id"] = 8 + data["customer_id"] = _coerce_optional_int(data.get("customer_id"), "customer_id") + if case_type in customer_required_types and not data.get("customer_id"): raise HTTPException(status_code=400, detail="customer_id is required") + _validate_customer_id(data.get("customer_id")) status = _normalize_case_status(data.get("status")) deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline") deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until") current_user_id = _get_user_id_from_request(request) raw_responsible = data.get("ansvarlig_bruger_id") if "ansvarlig_bruger_id" in data else current_user_id + if case_type == "intern_ticket": + raw_responsible = current_user_id ansvarlig_bruger_id = _coerce_optional_int(raw_responsible, "ansvarlig_bruger_id") assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id") _validate_user_id(ansvarlig_bruger_id) _validate_group_id(assigned_group_id) - case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket" pipeline = data.get("pipeline") order_items = data.get("order_items", []) + type_details = data.get("type_details") or {} + if not isinstance(type_details, dict): + raise HTTPException(status_code=400, detail="type_details skal være et objekt") + if len(type_details) > 20: + raise HTTPException(status_code=400, detail="For mange typefelter") + normalized_type_details = {} + for key, value in type_details.items(): + if not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", str(key)): + raise HTTPException(status_code=400, detail="Ugyldigt typefeltnavn") + if value is None: + continue + if not isinstance(value, (str, int, float, bool)): + raise HTTPException(status_code=400, detail="Ugyldig værdi i typefelt") + normalized = str(value).strip() + if len(normalized) > 10000: + raise HTTPException(status_code=400, detail="Et typefelt er for langt") + if normalized: + normalized_type_details[str(key)] = normalized raw_contact_ids = data.get("contact_ids") or [] if not isinstance(raw_contact_ids, list): raise HTTPException(status_code=400, detail="contact_ids skal være en liste") @@ -1132,12 +1123,12 @@ async def create_sag(request: Request, data: dict): cursor.execute( """ INSERT INTO sag_sager - (titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status, + (titel, beskrivelse, template_key, type, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status, pipeline_amount, pipeline_probability, pipeline_stage_id, pipeline_description) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """, - (data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id, + (data.get("titel"), data.get("beskrivelse", ""), case_type, case_type, status, data.get("customer_id"), ansvarlig_bruger_id, assigned_group_id, current_user_id, deadline, deferred_until, data.get("deferred_until_case_id"), data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]), ) @@ -1145,6 +1136,34 @@ async def create_sag(request: Request, data: dict): if not result: raise HTTPException(status_code=500, detail="Failed to create case") + if normalized_type_details: + cursor.execute( + """INSERT INTO sag_case_details (sag_id, details, created_by_user_id, updated_by_user_id) + VALUES (%s, %s::jsonb, %s, %s)""", + (result["id"], json.dumps(normalized_type_details), current_user_id, current_user_id), + ) + + anydesk_id = normalized_type_details.get("anydesk_id") + if case_type == "ticket" and anydesk_id: + if len(anydesk_id) > 64 or not re.fullmatch(r"[0-9][0-9 .-]*", anydesk_id): + raise HTTPException(status_code=400, detail="Ugyldigt AnyDesk-ID") + cursor.execute( + """INSERT INTO sag_anydesk_ids (sag_id, anydesk_id, is_primary, created_by_user_id) + VALUES (%s, %s, TRUE, %s) + ON CONFLICT (sag_id, anydesk_id) DO UPDATE SET deleted_at=NULL, is_primary=TRUE, updated_at=NOW()""", + (result["id"], anydesk_id, current_user_id), + ) + cursor.execute("UPDATE sag_anydesk_ids SET is_primary=FALSE WHERE sag_id=%s AND anydesk_id<>%s AND deleted_at IS NULL", (result["id"], anydesk_id)) + + solution = normalized_type_details.get("solution") + if case_type == "ticket" and solution: + cursor.execute( + """INSERT INTO sag_solutions (sag_id, title, description, solution_type, result, created_by_user_id) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (sag_id) DO NOTHING""", + (result["id"], data.get("titel"), solution, "case_solution", "draft", current_user_id), + ) + if contact_ids: cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,)) existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()} @@ -1555,6 +1574,83 @@ async def set_case_module_pref(sag_id: int, data: dict): # SAG TODO STEPS # ========================================================================= +@router.get("/sag/{sag_id}/assignments", dependencies=[Depends(case_edit_access)]) +async def get_case_assignments(sag_id: int, group_id: Optional[int] = Query(None)): + """Return selected employees and, when a group is chosen, its available members.""" + case_row = execute_query_single( + "SELECT id, assigned_group_id, ansvarlig_bruger_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", + (sag_id,), + ) + if not case_row: + raise HTTPException(status_code=404, detail="Case not found") + effective_group_id = group_id if group_id is not None else case_row.get("assigned_group_id") + selected = execute_query( + """ + SELECT u.user_id, COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS display_name + FROM sag_case_assignees a JOIN users u ON u.user_id=a.user_id + WHERE a.sag_id=%s ORDER BY display_name + """, + (sag_id,), + ) or [] + members = [] + if effective_group_id: + _validate_group_id(effective_group_id) + members = execute_query( + """ + SELECT u.user_id, COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS display_name + FROM user_groups ug JOIN users u ON u.user_id=ug.user_id + WHERE ug.group_id=%s AND u.is_active=true ORDER BY display_name + """, + (effective_group_id,), + ) or [] + return { + "group_id": effective_group_id, + "primary_user_id": case_row.get("ansvarlig_bruger_id"), + "selected_user_ids": [row["user_id"] for row in selected], + "members": members, + } + + +@router.put("/sag/{sag_id}/assignments", dependencies=[Depends(case_edit_access)]) +async def save_case_assignments(sag_id: int, payload: CaseAssignmentsUpdate, request: Request): + """Persist every selected employee as its own assignment row.""" + case_row = execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)) + if not case_row: + raise HTTPException(status_code=404, detail="Case not found") + + user_ids = list(dict.fromkeys(payload.user_ids)) + if payload.group_id is None: + if user_ids: + raise HTTPException(status_code=400, detail="Vælg en gruppe før medarbejdere tildeles") + else: + _validate_group_id(payload.group_id) + group_members = execute_query( + "SELECT user_id FROM user_groups WHERE group_id=%s", (payload.group_id,) + ) or [] + permitted_user_ids = {row["user_id"] for row in group_members} + invalid_user_ids = [user_id for user_id in user_ids if user_id not in permitted_user_ids] + if invalid_user_ids: + raise HTTPException(status_code=400, detail="Alle valgte medarbejdere skal være medlem af den valgte gruppe") + + primary_user_id = payload.primary_user_id + if primary_user_id is not None and primary_user_id not in user_ids: + raise HTTPException(status_code=400, detail="Primær ansvarlig skal være blandt de valgte medarbejdere") + if primary_user_id is None and user_ids: + primary_user_id = user_ids[0] + + user_id = _get_user_id_from_request(request) + execute_query("DELETE FROM sag_case_assignees WHERE sag_id=%s", (sag_id,)) + for assignee_id in user_ids: + execute_query( + "INSERT INTO sag_case_assignees (sag_id, user_id, assigned_by_user_id) VALUES (%s, %s, %s)", + (sag_id, assignee_id, user_id), + ) + execute_query( + "UPDATE sag_sager SET assigned_group_id=%s, ansvarlig_bruger_id=%s WHERE id=%s", + (payload.group_id, primary_user_id, sag_id), + ) + return await get_case_assignments(sag_id, payload.group_id) + @router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep]) async def list_todo_steps(sag_id: int): try: @@ -1725,6 +1821,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)): """Update a case.""" try: confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True + awaiting_todo = updates.pop("awaiting_todo", None) # Check if case exists check = execute_query( """ @@ -1743,13 +1840,45 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)): # Backwards compatibility: frontend sends "type", DB stores "template_key" if "type" in updates and "template_key" not in updates: updates["template_key"] = updates.get("type") + if "template_key" in updates: + updates["type"] = updates["template_key"] + if str(updates.get("template_key") or "").strip().lower() == "intern_ticket": + # Keep the same invariant when an existing case is changed to Internal ticket. + updates["customer_id"] = 8 + updates["ansvarlig_bruger_id"] = _get_user_id_from_request(request) if "status" in updates: updates["status"] = _normalize_case_status(updates.get("status")) - closing_statuses = {"lukket", "løst", "afsluttet", "closed", "resolved", "done"} + closing_statuses = {"lukket"} new_status = str(updates.get("status") or "").strip().lower() case_type = str(previous_row.get("template_key") or "").strip().lower() is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"} + if new_status in closing_statuses and previous_status not in closing_statuses: + unfinished_time = execute_query_single( + """ + SELECT COUNT(*)::int AS count + FROM tmodule_times + WHERE sag_id = %s + AND ( + slut_tid IS NULL + OR COALESCE( + entry_status, + CASE WHEN status IN ('approved', 'billed') THEN 'godkendt' ELSE 'afventer' END + ) <> 'godkendt' + ) + """, + (sag_id,), + ) or {} + unfinished_count = int(unfinished_time.get("count") or 0) + if unfinished_count: + raise HTTPException( + status_code=409, + detail={ + "code": "case_close_blocked_unfinished_time", + "message": f"Sagen kan ikke lukkes, fordi {unfinished_count} tidsregistrering(er) stadig er aktive, i kladde eller afventer godkendelse.", + }, + ) + if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case: has_registered_time = execute_query_single( "SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time", @@ -1763,6 +1892,26 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)): "message": "Der er ikke registreret tid på supportsagen. Bekræft at den skal lukkes uden tid.", }, ) + is_awaiting_transition = new_status.startswith("afventer") and not previous_status.startswith("afventer") + if is_awaiting_transition: + if not isinstance(awaiting_todo, dict): + raise HTTPException( + status_code=400, + detail="En TODO med hvad der afventes er påkrævet, før sagen kan sættes til Afventer", + ) + todo_title = str(awaiting_todo.get("title") or "").strip() + if len(todo_title) < 3: + raise HTTPException( + status_code=400, + detail="TODO-titlen skal beskrive, hvad der afventes", + ) + awaiting_todo = { + "title": todo_title, + "description": str(awaiting_todo.get("description") or "").strip() or None, + "due_date": awaiting_todo.get("due_date") or None, + } + else: + is_awaiting_transition = False if "deadline" in updates: updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline") if "start_date" in updates: @@ -1805,6 +1954,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)): "titel", "beskrivelse", "template_key", + "type", "status", "customer_id", "ansvarlig_bruger_id", @@ -1832,6 +1982,28 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)): params.append(sag_id) set_sql = ", ".join(set_clauses) query = f"UPDATE sag_sager SET {set_sql} WHERE id = %s RETURNING *" + + # Create the follow-up before changing status: an awaiting case must never + # be persisted without its required next action. + if is_awaiting_transition and awaiting_todo: + try: + todo_user_id = _get_user_id_from_request(request) + execute_query( + """ + INSERT INTO sag_todo_steps (sag_id, title, description, due_date, created_by_user_id, is_next) + VALUES (%s, %s, %s, %s, %s, true) + """, + ( + sag_id, + awaiting_todo["title"], + awaiting_todo["description"], + awaiting_todo["due_date"], + todo_user_id, + ), + ) + except Exception as todo_error: + logger.error("Could not create mandatory awaiting TODO for case %s: %s", sag_id, todo_error) + raise HTTPException(status_code=500, detail="TODO kunne ikke oprettes; status er ikke ændret") result = execute_query(query, tuple(params)) if result: @@ -1869,6 +2041,12 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)): type_labels = { "ticket": "Ticket", + "salg": "Salg", + "bogholderi": "Bogholderi", + "admin": "Admin", + "postit": "Post-it", + "intern_ticket": "Intern ticket", + "bug_report": "Bug_report", "pipeline": "Pipeline", "opgave": "Opgave", "ordre": "Ordre", @@ -1973,6 +2151,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)): class BeskrivelsePatch(BaseModel): beskrivelse: str + append: bool = False @router.patch("/sag/{sag_id}/beskrivelse", dependencies=[Depends(case_edit_access)]) @@ -1987,8 +2166,6 @@ async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: R raise HTTPException(status_code=404, detail="Case not found") old_beskrivelse = row.get("beskrivelse") - new_beskrivelse = body.beskrivelse - # Resolve acting user (may be None for anonymous) user_id = _get_user_id_from_request(request) changed_by_name = None @@ -2000,6 +2177,18 @@ async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: R if u: changed_by_name = u["name"] + if body.append: + existing_beskrivelse = str(old_beskrivelse or "").rstrip() + addition = body.beskrivelse.strip() + name_parts = re.findall(r"[A-Za-zÆØÅæøåÀ-ÖØ-öø-ÿ0-9]+", str(changed_by_name or "Bruger")) + initials = "".join(part[0].upper() for part in name_parts[:2]) or "BR" + append_date = datetime.now().strftime("%d/%m") + safe_addition = html.escape(addition).replace("\n", "
") + append_fragment = f"
{initials}/{append_date}

{safe_addition}

" + new_beskrivelse = f"{existing_beskrivelse}\n{append_fragment}" if existing_beskrivelse else append_fragment + else: + new_beskrivelse = body.beskrivelse + # Write history entry execute_query( """INSERT INTO sag_beskrivelse_history @@ -2458,12 +2647,12 @@ async def list_case_customers(sag_id: int): raise HTTPException(status_code=500, detail="Failed to list case customers") -@router.post("/sag/{sag_id}/customer/replace") -async def replace_case_customer(sag_id: int, payload: dict): +@router.post("/sag/{sag_id}/customer/replace", dependencies=[Depends(case_edit_access)]) +async def replace_case_customer(sag_id: int, payload: dict, request: Request): """Replace the primary customer on a case and keep relations synchronized.""" try: existing_case = execute_query_single( - "SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", + "SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,), ) if not existing_case: @@ -2475,6 +2664,10 @@ async def replace_case_customer(sag_id: int, payload: dict): _validate_customer_id(customer_id) + old_customer_id = existing_case.get("customer_id") + old_customer = execute_query_single("SELECT name FROM customers WHERE id = %s", (old_customer_id,)) if old_customer_id else None + new_customer = execute_query_single("SELECT name FROM customers WHERE id = %s", (customer_id,)) + if table_has_column("sag_sager", "customer_id"): execute_query( "UPDATE sag_sager SET customer_id = %s WHERE id = %s", @@ -2517,6 +2710,32 @@ async def replace_case_customer(sag_id: int, payload: dict): (sag_id, customer_id, "Kunde"), ) + if old_customer_id != customer_id: + try: + changed_by = "System" + try: + user_id = _get_user_id_from_request(request) + user = execute_query_single( + "SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), ''), CONCAT('Bruger #', user_id::text)) AS name FROM users WHERE user_id = %s", + (user_id,), + ) + changed_by = str((user or {}).get("name") or changed_by) + except Exception: + pass + execute_query( + """ + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) + VALUES (%s, %s, %s, true) + """, + ( + sag_id, + changed_by, + f"🏢 Firma ændret: {(old_customer or {}).get('name') or 'Ingen kunde'} → {(new_customer or {}).get('name') or f'Kunde #{customer_id}'}", + ), + ) + except Exception as exc: + logger.warning("Could not write customer change history for case %s: %s", sag_id, exc) + logger.info("✅ Primary customer replaced for case %s -> customer %s", sag_id, customer_id) return {"status": "ok", "sag_id": sag_id, "customer_id": customer_id} except HTTPException: @@ -5708,8 +5927,12 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req failure_detail = "Email send failed (provider returned generic error; check Graph app permissions and API logs)" raise HTTPException(status_code=500, detail=f"{failure_detail} [diag:sag-send-v3]") - sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub" - sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or "" + sender_name = email_service.smtp_config.get("from_name") or "BMC Hub" + sender_email = ( + email_service.graph_config.get("user_email") + if email_service._graph_send_available() + else email_service.smtp_config.get("from_address") + ) or "" provider_thread_key_normalized = _normalize_message_id_token(provider_thread_key) # Keep replies in the existing case thread when we already know the target thread. diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index c82294c..a8912cc 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -159,47 +159,7 @@ def _coerce_optional_int(value: Optional[str]) -> Optional[int]: def _fetch_case_status_options() -> list[str]: - default_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"] - values = [] - seen = set() - - def _add(value: Optional[str]) -> None: - candidate = str(value or "").strip() - if not candidate: - return - key = candidate.lower() - if key in seen: - return - seen.add(key) - values.append(candidate) - - setting_row = execute_query( - "SELECT value FROM settings WHERE key = %s", - ("case_statuses",) - ) - - if setting_row and setting_row[0].get("value"): - try: - parsed = json.loads(setting_row[0].get("value") or "[]") - for item in parsed if isinstance(parsed, list) else []: - value = "" - if isinstance(item, str): - value = item.strip() - elif isinstance(item, dict): - value = str(item.get("value") or "").strip() - _add(value) - except Exception: - pass - - statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or [] - - for row in statuses: - _add(row.get("status")) - - for default in default_statuses: - _add(default) - - return values + return ["Ny", "Tildelt", "Igangværende", "Afventer", "Løst", "Kunde svaret", "Lukket"] def _fetch_closed_case_statuses() -> list[str]: @@ -571,8 +531,8 @@ async def sag_varekob_salg(request: Request): @router.get("/sag/{sag_id}", response_class=HTMLResponse) async def sag_detaljer(request: Request, sag_id: int): - """Redirect legacy case details URL to v3.""" - return RedirectResponse(url=f"/sag/{sag_id}/v3", status_code=307) + """Redirect the unversioned case URL to the active V4 page.""" + return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307) """Display case details.""" try: @@ -1000,8 +960,9 @@ async def sag_detaljer(request: Request, sag_id: int): @router.get("/sag/{sag_id}/v3", response_class=HTMLResponse) +@router.get("/sag/{sag_id}/v4", response_class=HTMLResponse) async def sag_detaljer_v3(request: Request, sag_id: int): - """Display case details.""" + """Display the active V4 case page; /v3 remains as a compatible alias.""" try: # Fetch main case sag_query = """ @@ -1339,7 +1300,11 @@ async def sag_detaljer_v3(request: Request, sag_id: int): status_options.append(current_status) is_deadline_overdue = _is_deadline_overdue(sag.get("deadline")) - response = templates.TemplateResponse("modules/sag/templates/detail_v3.html", { + # V4 is the only active case design. Keep /v3 routable because many + # existing links and bookmarks still use it, but render the same V4 + # template so users cannot accidentally fall back to the old design. + template_name = "modules/sag/templates/detail_v4.html" + response = templates.TemplateResponse(template_name, { "request": request, "case": sag, "customer": customer, diff --git a/app/modules/sag/services/relation_service.py b/app/modules/sag/services/relation_service.py index 6fac859..1da669f 100644 --- a/app/modules/sag/services/relation_service.py +++ b/app/modules/sag/services/relation_service.py @@ -45,9 +45,15 @@ class RelationService: s.status, s.template_key, COALESCE(s.template_key, 'ticket') AS type, + s.ansvarlig_bruger_id, + COALESCE(u.full_name, u.username) AS ansvarlig_navn, + s.assigned_group_id, + g.name AS gruppe_navn, next_todo.title AS next_todo_title, next_todo.due_date AS next_todo_due_date FROM sag_sager s + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id + LEFT JOIN groups g ON g.id = s.assigned_group_id LEFT JOIN LATERAL ( SELECT t.title, t.due_date FROM sag_todo_steps t diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index f79fc02..50f8729 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -245,7 +245,10 @@ .cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; } .cc-page .cc-type-section { padding:0 0 16px; margin-bottom:16px !important; border-bottom:1px solid var(--border-color,#e3eaf0); } .cc-page .cc-type-section > label { display:block; margin-bottom:8px !important; text-transform:none; letter-spacing:0; color:var(--text-primary,#213547); font-weight:600; font-size:.82rem; } - .cc-page #caseTypeHelp { display:none; } + .cc-page #caseTypeHelp { display:block; } + .cc-page .card-custom.cc-postit-mode { background:#fff5a8; border:1px solid #e6c74d; box-shadow:8px 10px 22px rgba(112,87,8,.2); } + .cc-page .cc-postit-panel { background:#fffbe0; border-color:#dfbd37; } + .cc-page .cc-postit-panel summary { background:#fff1a8; } .cc-page #cc-types { gap:7px; } .cc-page #cc-types > button { flex:0 1 auto; min-height:0; padding:8px 11px; border-radius:9px; font-size:.78rem; } .cc-page .cc-type-name { display:inline; font-size:.78rem; } @@ -286,11 +289,6 @@
-
-
ET GODT STED AT STARTE

Opret en ny sag

Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.

- -
-
@@ -375,6 +373,9 @@
+ +
+
Hardware (AnyDesk)
@@ -437,9 +438,9 @@
-
Type, Status & Ansvar
+
Ansvarlig
-
+
-
+
+
+
+

+ {{ customer.name if customer else 'Vælg virksomhed' }} + {% if customer and customer.is_vendor %} + + Leverandør + + {% endif %} + • + {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Vælg kontakt' }} + • + {{ customer.department if customer and customer.department else 'Ingen afdeling' }} + • + Oprettet {{ case.created_at.strftime('%d. %b %Y') if case.created_at else '-' }} +

+
+ +
+
+
+ + +
+
+ + +
+
+
+
Klassifikation
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + +
+
+
+
Tildeling
+ +
+ + +
+ +
+ + +
+
+ Indlæser medarbejdere… + +
+ +
+ + +
+ +
+
+
+ + +
+
+
+
Tidslinje
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + min +
+
+ +
+ {% if case.deferred_until_case_id and case.deferred_until_status %} + Trigger #{{ case.deferred_until_case_id }} → {{ case.deferred_until_status|replace('__any_change__', 'Skift') }} + {% endif %} +
+
+
+
+ + +
+
+
+
+
Tidsregistrering
+
+ + +
+
+ + +
+
+
+ +
+
+ + + + + + + + + + +
+ +
+
+
+
+ +
+
+ +
+ + + + + +
+ +
+
+
+
+
+
+
{{ case.titel }}
+ +
+
+ +
+
{{ (case.beskrivelse or '')|safe_case_html|safe }}
+ {% if not case.beskrivelse %} +
+

Ingen opgavebeskrivelse tilføjet endnu.

+ Dobbeltklik for at tilføje +
+ {% endif %} +
+ + +
+ +
+ Ctrl+Enter for at gemme · Esc for at annullere +
+ + + + +
+
+
+
+
+ +
+
+
Kommentarer
+ {{ comments|length if comments else 0 }} +
+
+
+
+
+ 0/0 vises +
+ + Kilde + + + + +
+ +
+
+
+ + +
+
+ + + + + + +
+
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+
+
+ +
+ {% if comments %} + {% for comment in comments %} + {% if comment.er_system_besked or comment.forfatter == 'System' %} +
+ {% elif comment.er_intern %} +
+ {% else %} +
+ {% endif %} +
+ {{ (comment.forfatter or 'Bruger')[:2]|upper }} + {{ comment.forfatter }} + {% if comment.er_system_besked or comment.forfatter == 'System' %} + System + {% elif comment.er_intern %} + Intern + {% else %} + Ekstern + {% endif %} + {{ comment.created_at.strftime('%d/%m-%Y %H:%M') }} +
+
{{ comment.indhold|replace('\n', '
')|safe }}
+
+ {% endfor %} + {% else %} +

Ingen kommentarer endnu.

+ {% endif %} +
+
Ingen kommentarer matcher de valgte filtre.
+ +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+
+
+
+
Relationer til #{{ case.id }}
+ +
+
+ +
+
+
+
+ + + + + +
+ + +
+
+ {% macro render_relation_rows(nodes, level=0) %} + {% for node in nodes %} + + {{ level }} + + {% if not node.is_current %}#{{ node.case.id }}{% endif %} + + + {% if node.is_current %} + {{ node.case.titel }} + Aktuel + {% else %} + {{ node.case.titel }} +
+ {{ node.case.ansvarlig_navn or 'Ingen ansvarlig' }} + · + {{ node.case.gruppe_navn or 'Ingen gruppe' }} +
+ {% endif %} + + + {% set row_status = (node.case.status or '')|lower %} + + {{ node.case.status or '-' }} + + + + {{ node.case.template_key or node.case.type or 'ticket' }} + + + {% set rel_raw = (node.relation_type or '')|trim %} + {% set rel_key = rel_raw|lower %} + {% if node.is_current %} + Rodsag + {% elif rel_key == 'afledt af' %} + Kommer fra +
(Afledt af)
+ {% elif rel_key == 'årsag til' %} + Skaber følge-sag +
(Årsag til)
+ {% elif rel_key == 'blokkerer' %} + Blokerer + {% elif rel_key == 'undersag' %} + Undersag + {% elif rel_key == 'afhænger af' %} + Afhænger af + {% elif rel_key == 'duplikat' %} + Duplikat + {% else %} + Koblet til +
(Relateret til)
+ {% endif %} + {% if node.is_repeated %} + + {% endif %} + + + {% if node.case.next_todo_title %} +
+ + {{ node.case.next_todo_title }} +
+ {% if node.case.next_todo_due_date %} +
+ Frist {{ node.case.next_todo_due_date.strftime('%d.%m.%Y') }} +
+ {% endif %} + {% elif not node.is_current %} + Ingen åben todo + {% else %} + – + {% endif %} + + +
+ {% if node.relation_id %} + + {% endif %} + + +
+ + + {% if node.children %} + {{ render_relation_rows(node.children, level + 1) }} + {% endif %} + {% endfor %} + {% endmacro %} + + {% set has_relations = relation_tree and (relation_tree|length > 1 or (relation_tree|length == 1 and relation_tree[0].children)) %} + {% if has_relations %} +
+ + + + + + + + + + + + + + + {{ render_relation_rows(relation_tree) }} + +
Niv.SagTitelStatusTypeSammenhængNæste todoHandling
+
+ {% else %} +

Ingen relaterede sager

+ {% endif %} +
+
+
+
+ + +
+ +
+
+
+
Filer & Dokumenter
+ + +
+ +
+
+ +

Træk filer hertil for at uploade

+
+
+
Ingen filer fundet...
+
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
Lokationer
+ +
+
+
+
Henter lokationer...
+
+
+
+ +
+
+
Internetforbindelser
+ +
+
+
+
+ + +
+
+
+
Henter forbindelser …
+
+
+ +
+
+
TAGS
+ +
+
+
+ {% if tags and tags|length > 0 %} + {% for tag in tags %} + + {{ tag.tag_navn or tag.name or 'Tag' }} + {% if tag.id or tag.tag_id %} + + {% endif %} + + {% endfor %} + {% else %} +
Ingen tags på sagen endnu
+ {% endif %} +
+
+
Forslag
+
+
Indlæser forslag...
+
+
+
+
+ +
+
+
Kunder
+ +
+
+ {% if customers %} +
+ Navn + Rolle + E-mail + Slet +
+ {% for customer in customers %} +
+ + {{ customer.role or '-' }} + {{ customer.customer_email or '-' }} + +
+ {% endfor %} + {% else %} +

Ingen kunder

+ {% endif %} +
+
+ +
+
+
Buzzwords
+
+ + + +
+
+
+
+ {% if buzzwords and buzzwords|length > 0 %} + {% for item in buzzwords %} + + {{ item.word }} + + + {% endfor %} + {% else %} +
Ingen buzzwords på sagen endnu
+ {% endif %} +
+
+
+ + + +
+
+
Kontakter
+ +
+
+ {% if contacts %} +
+ Navn + Titel + Kunde + Handlinger +
+ {% for contact in contacts %} + {% set external_contact = contact.is_external or (contact.customer_name and customer and contact.customer_name|lower != customer.name|lower) or (contact.customer_name and not customer) %} +
+
+ {{ contact.contact_name }} + {% if external_contact %} + Ekstern kontakt + {% endif %} +
+ {{ contact.title or '-' }} + + {{ contact.customer_name or 'Firma ikke registreret' }} + {% if external_contact %}
Andet firma end sagens kunde{% endif %} +
+
+ {% if contact.mobile %} + + + {% endif %} + + +
+
+ {% endfor %} + {% else %} +

Ingen kontakter

+ {% endif %} +
+
+ +
+
+
Hardware
+
+ + +
+
+
+
+
Henter hardware...
+
+
+
+ +
+
+
Salgspipeline
+
+ + +
+
+
+
+
+
+
+
Stage
+
+ {% set ns = namespace(selected_stage=None) %} + {% for stage in pipeline_stages or [] %} + {% if case.pipeline_stage_id == stage.id %} + {% set ns.selected_stage = stage %} + {% endif %} + {% endfor %} + {% if ns.selected_stage %} + {{ ns.selected_stage.name }} + {% else %} + Ikke sat + {% endif %} +
+
+
+
+
+
Sandsynlighed
+
{{ case.pipeline_probability if case.pipeline_probability is not none else 0 }}%
+
+
+
+
+
Beløb
+
+ {% if case.pipeline_amount is not none %} + {{ "{:,.2f}".format(case.pipeline_amount|float).replace(',', 'X').replace('.', ',').replace('X', '.') }} kr. + {% else %} + Ikke sat + {% endif %} +
+
+
+
+
+
+
Beskrivelse
+
{{ case.pipeline_description or 'Ingen beskrivelse' }}
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ +
+
+
Opkaldshistorik
+ + + +
+
+ {% if call_history and call_history|length > 0 %} +
+ + + + + + + + + + + + {% for call in call_history %} + + + + + + + + {% endfor %} + +
DatoRetningNummerBrugerVarighed
{{ call.started_at.strftime('%d/%m/%Y %H:%M') if call.started_at else '-' }}{{ 'Udgående' if call.direction == 'outbound' else 'Indgående' }} + {% if call.ekstern_nummer %} +
+ {{ call.ekstern_nummer }} + +
+ {% else %} + - + {% endif %} +
{{ call.full_name or call.username or '-' }} + {% if call.duration_sec is not none %} + {{ (call.duration_sec // 60)|int }}:{{ '%02d'|format((call.duration_sec % 60)|int) }} + {% elif call.ended_at %} + - + {% else %} + I gang + {% endif %} +
+
+ {% else %} +
Ingen opkald linket til denne sag
+ {% endif %} +
+
+ +
+
+
Todo-opgaver
+ +
+
+
+ + +
+ + +
+
+
+
Ingen opgaver endnu
+
+
+
+ +
+
+
Kunde-wiki
+
+
+
+ +
+
+
Henter wiki...
+
+
+
+ +
+
+
+
+ + + + +
+
+
+
E-mail på sagen
+
+ + + +
+
+
+
+
+
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
Tip: Træk .msg/.eml fil hertil for at importere direkte på sagen.
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+ + + + + + +
+
+
+
AI og relateret viden
Forslag bygger kun på godkendte vidensartikler og gemmes aldrig automatisk.
+ +
+
Finder relateret viden…
+
+ + {% if is_nextcloud %} +
+
+
☁️ Nextcloud Integration
+ {% if nextcloud_instance %} + Aktiv + {% else %} + Ingen instans + {% endif %} +
+
+ {% if nextcloud_instance %} + +
+ +
+ Admin Konto + {{ nextcloud_instance.username }} +
+
+ + +
+ Handlinger +
+ + + + +
+
+ {% else %} +
+ + Kunden mangler Nextcloud konfiguration +
+ {% endif %} +
+
+ {% endif %} +
+
+
Løsning
+
+ Alle løsninger + Vidensdatabase + {% if solution %}{% endif %} +
+
+
+ {% if solution %} + {% set approval_labels = {'draft':'Kladde','pending':'Afventer godkendelse','approved':'Godkendt','outdated':'Forældet','rejected':'Afvist'} %} + {% set visibility_labels = {'internal':'Kun internt','general':'Generel viden','customer':'Kun denne kunde'} %} +
+ {{ approval_labels.get(solution.approval_status or 'draft', solution.approval_status or 'Kladde') }} + {{ visibility_labels.get(solution.visibility or 'internal', solution.visibility or 'Kun internt') }} +
+
+ +
{{ solution.title }}
+
+
+
+ +
{{ solution.solution_type }}
+
+
+ +
{{ solution.result }}
+
+
+
+ {% if solution.problem %}
{{ solution.problem }}
{% endif %} + {% if solution.root_cause %}
{{ solution.root_cause }}
{% endif %} + {% if solution.investigation %}
{{ solution.investigation }}
{% endif %} + +
{{ solution.description }}
+ {% if solution.workaround %}
{{ solution.workaround }}
{% endif %} +
+
{% for tag in solution.tags or [] %}{{ tag }}{% endfor %}{% for product in solution.products or [] %}{{ product }}{% endfor %}
+
+ {% if solution.approval_status in [None, '', 'draft', 'rejected', 'outdated'] %}{% endif %} + {% if solution.approval_status == 'pending' %}{% endif %} + {% if solution.approval_status == 'approved' %}{% endif %} + +
+
+ {% else %} +
+ +

Ingen løsning registreret endnu.

+ +
+ {% endif %} +
+
+
+ + +
+
+
+
+
+
Salgslinjer
+ - +
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + + +
DatoBeskrivelseAntalEnhedEnhedsprisLinjesumKilde-sagStatusHandlinger
Indlæser salgslinjer...
+
+
+
+
+
+
Indkøbslinjer
+ - + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
DatoBeskrivelseAntalEnhedEnhedsprisLinjesumFormålKilde-sagStatusHandlinger
Indlæser indkøbslinjer...
+
+
+
+
+
+
+
+
Salg (samlet)
+
+
+
+ Total salg + - +
+
+ Netto + - +
+
+
+
+
+
Indkøb (samlet)
+
+
+
+ Total køb + - +
+
+
+
+
+
Tid (samlet)
+
+
+
+ Timer (total) + - +
+
+ Timer (fakturerbar) + - +
+
+ Inkluderer alle under-sager +
+
+
+
+
+
Tid (samlet)
+
+
+
+ + + + + + + + + + + + + +
DatoTimerKilde-sag
Indlæser tid...
+
+
+
+
+
+
+ + +
+
+
+
+
+
Leverandørflow Type
+ +
+
+
+
+ + +
+
+ + +
+
+ +
Ingen forslag endnu
+
+
+
+
+ + + + +
+ +
+
+
+
Leverandørfakturaer
+ +
+
+
+ + + + + + + + + + + + +
FakturaBeløbStatusActions
Indlæser...
+
+
+
+
+
+
+ + +
+
+
+
Live tracking
+
+ + + +
+
+
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ Faktisk tid gemmes internt. Fakturerbar tid afrundes per blok (standard 30 min). +
+
+
+ +
+
+
Timere på denne sag
+ 0 +
+
+
Henter timere på sagen...
+
+
+ +
+
+
Linkede opkald og AnyDesk-sessioner
+ 0 +
+
+
Henter linkede aktiviteter...
+
+
+ +
+
+
+
+
Timeline per medarbejder
+ +
+
+
Indlæser tidslinje...
+
+
+
+
+
+
+
Tids sammendrag
+
+
+
Indlæser sammendrag...
+
+
+
+
+
+ + + + + +
+
+
+
+
+
Abonnement
+ Ingen +
+
+
+
+ +

Ingen abonnement oprettet endnu.

+
+ +
+
+
+ +
-
+
+
+ +
-
+
+
+ +
-
+
+
+ +
-
+
+
+ +
-
+
+
+ +
-
+
+
+ +
-
+
+
+ +
-
+
+
+
+ + + + + + + + + + + + + + + +
ProduktBeskrivelseAntalEnhedsprisLinjesum
Ingen linjer
+
+
+
Total: 0,00 kr
+
+
+
+
+
+
+
Netværksprovisionering
+
Vælg BMC-hovedforbindelse og reserver IP-range på kladden, før aktivering.
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ +
-
+
+
+
+
+ +
+
+
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
Periode og aftalevilkår
Grundlag for fakturering, binding og opsigelse
+
+
Start på første faktureringsperiode
+
Samme dato ved start den 1. — ellers næste måneds 1.
+
+
dage
+
+
ad gangen
+
mdr. før
+
+
mdr.
+
Hvis feltet ikke overstyres, anvendes periodestart automatisk.
+ +
Vælg periode for at se faktureringsplanen.
+
+
Avanceret gruppering — normalt ikke nødvendig
Kun hvis flere abonnementer skal dele samme bindingsaftale.
Tomt felt samler automatisk abonnementer pr. kunde og fakturadato.
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ProduktBeskrivelseAntalEnhedsprisPeriode fraPeriode tilLinjesum
+ + 0,00 kr + +
+
+
+ + +
Total: 0,00 kr
+
+
+
+
+
Kun på første faktura
Engangsydelser som oprettelse, installation og konfiguration. De faktureres præcis én gang.
+
ProduktBeskrivelseAntalEnhedsprisLinjesum
Ingen engangsydelser tilføjet
Engangsbeløb:0 kr.
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
Live fakturavisning
Sådan vil fakturaen se ud
Live
+
+
+
Visningen er vejledende. Den endelige faktura dannes af fakturajobbet.
+
+
+
+
+
+ + + + + +
+
+
+
+
+
Reminders
+ +
+
+
+
Indlæser reminders...
+
+
+
+
+
+
Kalenderaftaler
+ +
+
+
+
Denne sag
+
+
Indlæser aftaler...
+
+
+
+
Børnesager
+
+
Indlæser børnesager...
+
+
+
+
+
+
+
+
+
Indstillinger
+
+
+

Reminders følger brugerens standardindstillinger (email, Mattermost og popup), medmindre du vælger at overskrive dem på reminderen.

+
+ Tip: Brug "Status ændring" hvis reminderen skal trigges af status. +
+
+
+
+
+
+ + +
+
+
+
+
+
Book fragt
+ Draft → Bekræft → Book +
+
+
+
+ + +
Vælg den udbyder, der skal booke og levere labelen.
+
+
+ + +
Vælg en eksisterende adresse for automatisk udfyldning.
+
+
+ + +
+
+ + +
+
+ + +
Produkter hentes direkte fra jeres Shipmondo-konto.
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+
+ + +
+
+ + +
+
+
+
Shipmondo-services
+
Vælg først et fragtprodukt.
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+
Forsendelser på sag
+ +
+
+
+
Indlæser...
+
+
+
+
+
+
+ +
+
+
+
+
Plan for sag #{{ case.id }}
Planlæg sagen uden at forlade sagsbilledet.
+ +
+
+
+
Forventet–
+
Planlagt–
+
Resterende–
+
Blokke–
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Klik på en dag for at allokere tid +
+
+
+
Åbn fanen for at hente planen.
+
+
+ +
+
Undersager
Opret og planlæg næste aktivitet i projektet.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+ + + + + + + + + 0 +
+
+
Åbn fanen for at indlæse historik...
+
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if nextcloud_instance %} + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+{% endblock %} diff --git a/app/services/email_service.py b/app/services/email_service.py index 1133c46..0d3e3a4 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -34,8 +34,9 @@ from aiohttp import ClientSession, BasicAuth import msal from app.core.config import settings +from app.core.crypto import decrypt_secret from app.services.system_event_logger import log_system_event -from app.core.database import execute_query, execute_insert, execute_update +from app.core.database import execute_query, execute_query_single, execute_insert, execute_update logger = logging.getLogger(__name__) @@ -60,6 +61,78 @@ class EmailService: 'client_secret': settings.GRAPH_CLIENT_SECRET, 'user_email': settings.GRAPH_USER_EMAIL } + self.smtp_config = { + 'host': settings.EMAIL_SMTP_HOST, + 'port': settings.EMAIL_SMTP_PORT, + 'username': settings.EMAIL_SMTP_USER, + 'password': settings.EMAIL_SMTP_PASSWORD, + 'use_tls': settings.EMAIL_SMTP_USE_TLS, + 'from_address': settings.EMAIL_SMTP_FROM_ADDRESS, + 'from_name': settings.EMAIL_SMTP_FROM_NAME, + } + self._apply_saved_connection() + + def _apply_saved_connection(self) -> None: + """Use the one Settings mailbox when enabled, otherwise keep environment compatibility.""" + try: + row = execute_query_single( + "SELECT * FROM email_connection_configurations WHERE is_enabled = true ORDER BY id DESC LIMIT 1" + ) + except Exception as exc: + # Older installations can run before migration 1035. Their .env setup remains authoritative. + logger.debug("Email connection settings unavailable; using environment configuration: %s", exc) + return + if not row: + return + + raw_secrets = decrypt_secret(str(row.get('secrets_encrypted') or '')) + if not raw_secrets: + logger.error("Enabled email connection has no readable credentials; using environment configuration") + return + try: + saved_secrets = json.loads(raw_secrets) + except (TypeError, json.JSONDecodeError): + logger.error("Enabled email connection has invalid encrypted credentials; using environment configuration") + return + + mailbox = str(row.get('mailbox_address') or '').strip() + if row.get('provider') == 'microsoft365_graph': + required = ('graph_tenant_id', 'graph_client_id', 'graph_client_secret') + if not all((row.get('graph_tenant_id'), row.get('graph_client_id'), saved_secrets.get('graph_client_secret'))): + logger.error("Enabled Microsoft 365 email connection is incomplete; using environment configuration") + return + self.use_graph = True + self.graph_config = { + 'tenant_id': row.get('graph_tenant_id'), + 'client_id': row.get('graph_client_id'), + 'client_secret': saved_secrets['graph_client_secret'], + 'user_email': mailbox, + } + return + + if not all((row.get('imap_host'), row.get('smtp_host'), saved_secrets.get('imap_password'), saved_secrets.get('smtp_password'))): + logger.error("Enabled IMAP/SMTP email connection is incomplete; using environment configuration") + return + self.use_graph = False + self.imap_config = { + 'server': row.get('imap_host'), + 'port': row.get('imap_port') or 993, + 'username': mailbox, + 'password': saved_secrets['imap_password'], + 'use_ssl': bool(row.get('imap_use_ssl')), + 'folder': row.get('imap_folder') or 'INBOX', + 'readonly': True, + } + # Outbound SMTP is read by the sender further down via settings. Keep config local as well. + self.smtp_config = { + 'host': row.get('smtp_host'), + 'port': row.get('smtp_port') or 587, + 'username': mailbox, + 'password': saved_secrets['smtp_password'], + 'use_tls': bool(row.get('smtp_use_tls')), + 'from_address': mailbox, + 'from_name': row.get('sender_name') or 'BMC Hub', + } def _graph_send_available(self) -> bool: return bool( @@ -1789,7 +1862,7 @@ class EmailService: return False, "aiosmtplib not installed" # Validate SMTP configuration - if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]): + if not all([self.smtp_config['host'], self.smtp_config['username'], self.smtp_config['password']]): logger.error("❌ SMTP not configured - cannot send email") if graph_failure_message: return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured" @@ -1799,7 +1872,7 @@ class EmailService: # Build message msg = MIMEMultipart('alternative') msg['Subject'] = subject - msg['From'] = f"{settings.EMAIL_SMTP_FROM_NAME} <{settings.EMAIL_SMTP_FROM_ADDRESS}>" + msg['From'] = f"{self.smtp_config['from_name']} <{self.smtp_config['from_address']}>" msg['To'] = ', '.join(to_addresses) if cc: @@ -1817,11 +1890,11 @@ class EmailService: # Send via SMTP async with aiosmtplib.SMTP( - hostname=settings.EMAIL_SMTP_HOST, - port=settings.EMAIL_SMTP_PORT, - use_tls=settings.EMAIL_SMTP_USE_TLS + hostname=self.smtp_config['host'], + port=self.smtp_config['port'], + use_tls=self.smtp_config['use_tls'] ) as smtp: - await smtp.login(settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD) + await smtp.login(self.smtp_config['username'], self.smtp_config['password']) # Combine all recipients all_recipients = to_addresses.copy() @@ -1831,7 +1904,7 @@ class EmailService: all_recipients.extend(bcc) await smtp.sendmail( - settings.EMAIL_SMTP_FROM_ADDRESS, + self.smtp_config['from_address'], all_recipients, msg.as_string() ) @@ -1928,7 +2001,7 @@ class EmailService: return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: aiosmtplib not installed", generated_message_id, provider_thread_key return False, "aiosmtplib not installed", generated_message_id, provider_thread_key - if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]): + if not all([self.smtp_config['host'], self.smtp_config['username'], self.smtp_config['password']]): logger.error("❌ SMTP not configured - cannot send email") if graph_failure_message: return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured", generated_message_id, provider_thread_key @@ -1937,7 +2010,7 @@ class EmailService: try: msg = MIMEMultipart('mixed') msg['Subject'] = subject - msg['From'] = f"{settings.EMAIL_SMTP_FROM_NAME} <{settings.EMAIL_SMTP_FROM_ADDRESS}>" + msg['From'] = f"{self.smtp_config['from_name']} <{self.smtp_config['from_address']}>" msg['To'] = ', '.join(to_addresses) msg['Message-ID'] = generated_message_id @@ -1974,11 +2047,11 @@ class EmailService: msg.attach(mime_attachment) async with aiosmtplib.SMTP( - hostname=settings.EMAIL_SMTP_HOST, - port=settings.EMAIL_SMTP_PORT, - use_tls=settings.EMAIL_SMTP_USE_TLS + hostname=self.smtp_config['host'], + port=self.smtp_config['port'], + use_tls=self.smtp_config['use_tls'] ) as smtp: - await smtp.login(settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD) + await smtp.login(self.smtp_config['username'], self.smtp_config['password']) all_recipients = to_addresses.copy() if cc: @@ -1987,7 +2060,7 @@ class EmailService: all_recipients.extend(bcc) await smtp.sendmail( - settings.EMAIL_SMTP_FROM_ADDRESS, + self.smtp_config['from_address'], all_recipients, msg.as_string() ) diff --git a/app/settings/backend/router.py b/app/settings/backend/router.py index a55403d..5843d5e 100644 --- a/app/settings/backend/router.py +++ b/app/settings/backend/router.py @@ -3,16 +3,18 @@ Settings and User Management API Router """ from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Literal from pydantic import BaseModel from datetime import datetime, timezone from app.core.database import execute_query, execute_query_single from app.core.config import settings from app.core.auth_dependencies import require_any_permission, require_superadmin from app.core.auth_service import AuthService +from app.core.crypto import decrypt_secret, encrypt_secret import argparse import asyncio import httpx +import imaplib import time import logging import json @@ -20,6 +22,7 @@ import os import re import threading from pathlib import Path +from urllib.parse import quote logger = logging.getLogger(__name__) router = APIRouter() @@ -75,6 +78,52 @@ class AIBenchmarkRunRequest(BaseModel): test_keys: Optional[List[str]] = None +class EmailConnectionUpdate(BaseModel): + provider: Literal["microsoft365_graph", "imap_smtp"] + is_enabled: bool = False + mailbox_address: str + sender_name: str = "BMC Hub" + imap_host: Optional[str] = "" + imap_port: int = 993 + imap_folder: str = "INBOX" + imap_use_ssl: bool = True + smtp_host: Optional[str] = "" + smtp_port: int = 587 + smtp_use_tls: bool = True + graph_tenant_id: Optional[str] = "" + graph_client_id: Optional[str] = "" + imap_password: Optional[str] = None + smtp_password: Optional[str] = None + graph_client_secret: Optional[str] = None + + +def _email_connection_public(row: Optional[Dict]) -> Dict: + """Return connection metadata only; encrypted credentials never leave the server.""" + if not row: + return {"configured": False, "source": "environment"} + encrypted = str(row.get("secrets_encrypted") or "").strip() + return { + "configured": True, + "source": "settings", + "provider": row["provider"], + "is_enabled": bool(row["is_enabled"]), + "mailbox_address": row["mailbox_address"], + "sender_name": row.get("sender_name") or "BMC Hub", + "imap_host": row.get("imap_host") or "", + "imap_port": row.get("imap_port") or 993, + "imap_folder": row.get("imap_folder") or "INBOX", + "imap_use_ssl": bool(row.get("imap_use_ssl")), + "smtp_host": row.get("smtp_host") or "", + "smtp_port": row.get("smtp_port") or 587, + "smtp_use_tls": bool(row.get("smtp_use_tls")), + "graph_tenant_id": row.get("graph_tenant_id") or "", + "graph_client_id": row.get("graph_client_id") or "", + "has_saved_credentials": bool(encrypted), + "inbound_processing_enabled": bool(settings.EMAIL_TO_TICKET_ENABLED), + "updated_at": row.get("updated_at"), + } + + MATTERMOST_SETTING_DEFAULTS = ( ("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"), ("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"), @@ -273,6 +322,177 @@ async def create_ai_benchmark_run( # Settings Endpoints +@router.get("/settings/email-connection", tags=["Settings"]) +async def get_email_connection(current_user: dict = Depends(require_superadmin)): + """Read mailbox setup metadata without exposing any password or client secret.""" + row = execute_query_single( + "SELECT * FROM email_connection_configurations ORDER BY id DESC LIMIT 1" + ) + if row: + return _email_connection_public(row) + + return { + "configured": False, + "source": "environment", + "provider": "microsoft365_graph" if settings.USE_GRAPH_API else "imap_smtp", + "is_enabled": bool( + settings.GRAPH_USER_EMAIL if settings.USE_GRAPH_API else settings.IMAP_USERNAME + ), + "mailbox_address": settings.GRAPH_USER_EMAIL if settings.USE_GRAPH_API else settings.IMAP_USERNAME, + "sender_name": settings.EMAIL_SMTP_FROM_NAME or "BMC Hub", + "has_saved_credentials": bool( + settings.GRAPH_CLIENT_SECRET if settings.USE_GRAPH_API else settings.IMAP_PASSWORD + ), + "inbound_processing_enabled": bool(settings.EMAIL_TO_TICKET_ENABLED), + } + + +@router.put("/settings/email-connection", tags=["Settings"]) +async def save_email_connection( + payload: EmailConnectionUpdate, + current_user: dict = Depends(require_superadmin), +): + """Save one encrypted mailbox configuration used for both inbound and outbound mail.""" + mailbox = payload.mailbox_address.strip().lower() + if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", mailbox): + raise HTTPException(status_code=400, detail="Indtast en gyldig postkasseadresse") + if not (1 <= payload.imap_port <= 65535 and 1 <= payload.smtp_port <= 65535): + raise HTTPException(status_code=400, detail="Port skal være mellem 1 og 65535") + + existing = execute_query_single( + "SELECT * FROM email_connection_configurations ORDER BY id DESC LIMIT 1" + ) + prior_secrets = (existing or {}).get("secrets_encrypted") + saved_secrets = {} + if prior_secrets: + decoded = decrypt_secret(str(prior_secrets)) + if decoded: + try: + saved_secrets = json.loads(decoded) + except (TypeError, json.JSONDecodeError): + raise HTTPException(status_code=500, detail="De gemte e-mailadgangsoplysninger kan ikke læses. Indtast dem igen.") + supplied_secrets = { + key: value.strip() + for key, value in { + "imap_password": payload.imap_password, + "smtp_password": payload.smtp_password, + "graph_client_secret": payload.graph_client_secret, + }.items() + if value is not None and value.strip() + } + saved_secrets.update(supplied_secrets) + secrets_encrypted = prior_secrets + if supplied_secrets or (prior_secrets and not saved_secrets): + try: + secrets_encrypted = encrypt_secret(json.dumps(saved_secrets)) + except ValueError: + raise HTTPException( + status_code=503, + detail="Sikker lagring er ikke klar. Sæt NEXTCLOUD_ENCRYPTION_KEY på serveren før adgangsoplysninger gemmes.", + ) + + if payload.provider == "microsoft365_graph": + if payload.is_enabled and not (payload.graph_tenant_id.strip() and payload.graph_client_id.strip() and saved_secrets.get("graph_client_secret")): + raise HTTPException(status_code=400, detail="Microsoft 365 kræver Tenant ID, Application ID og client secret før aktivering") + elif payload.is_enabled and not ( + payload.imap_host.strip() and payload.smtp_host.strip() + and saved_secrets.get("imap_password") and saved_secrets.get("smtp_password") + ): + raise HTTPException(status_code=400, detail="IMAP/SMTP kræver begge servere og adgangskoder før aktivering") + + user_id = current_user.get("id") or current_user.get("user_id") + values = ( + payload.provider, payload.is_enabled, mailbox, payload.sender_name.strip() or "BMC Hub", + payload.imap_host.strip() or None, payload.imap_port, payload.imap_folder.strip() or "INBOX", payload.imap_use_ssl, + payload.smtp_host.strip() or None, payload.smtp_port, payload.smtp_use_tls, + payload.graph_tenant_id.strip() or None, payload.graph_client_id.strip() or None, + secrets_encrypted, user_id, + ) + if existing: + query = """ + UPDATE email_connection_configurations SET + provider=%s, is_enabled=%s, mailbox_address=%s, sender_name=%s, + imap_host=%s, imap_port=%s, imap_folder=%s, imap_use_ssl=%s, + smtp_host=%s, smtp_port=%s, smtp_use_tls=%s, + graph_tenant_id=%s, graph_client_id=%s, secrets_encrypted=%s, + updated_by_user_id=%s, updated_at=CURRENT_TIMESTAMP + WHERE id=%s RETURNING * + """ + result = execute_query(query, (*values, existing["id"])) + else: + query = """ + INSERT INTO email_connection_configurations + (provider, is_enabled, mailbox_address, sender_name, imap_host, imap_port, imap_folder, imap_use_ssl, + smtp_host, smtp_port, smtp_use_tls, graph_tenant_id, graph_client_id, secrets_encrypted, updated_by_user_id) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * + """ + result = execute_query(query, values) + if not result: + raise HTTPException(status_code=500, detail="Kunne ikke gemme e-mailopsætningen") + return _email_connection_public(result[0]) + + +@router.post("/settings/email-connection/test", tags=["Settings"]) +async def test_email_connection(current_user: dict = Depends(require_superadmin)): + """Verify authentication and inbox access without sending or changing any email.""" + from app.services.email_service import EmailService, HAS_AIOSMTPLIB, aiosmtplib + + configured = execute_query_single( + "SELECT provider, is_enabled FROM email_connection_configurations ORDER BY id DESC LIMIT 1" + ) + if not configured or not configured.get("is_enabled"): + raise HTTPException(status_code=400, detail="Gem og aktivér en e-mailopsætning før du tester") + + service = EmailService() + if configured["provider"] == "microsoft365_graph": + token = await service._get_graph_access_token() + if not token: + raise HTTPException(status_code=502, detail="Microsoft 365 kunne ikke udstede et adgangstoken. Tjek Tenant ID, Application ID og client secret.") + mailbox = service.graph_config.get("user_email") + try: + async with httpx.AsyncClient(timeout=15) as client: + response = await client.get( + f"https://graph.microsoft.com/v1.0/users/{quote(mailbox)}/mailFolders/inbox?$top=1", + headers={"Authorization": f"Bearer {token}"}, + ) + if response.status_code >= 400: + raise HTTPException(status_code=502, detail="Microsoft 365-token virker, men postkassen kan ikke læses. Tjek Mail.ReadWrite og admin consent.") + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Kunne ikke kontakte Microsoft Graph: {exc}") + return {"ok": True, "provider": "Microsoft 365 / Graph", "message": "Token og læseadgang til Indbakke virker. Der er ikke sendt en e-mail."} + + def test_imap() -> None: + config = service.imap_config + connection = imaplib.IMAP4_SSL(config["server"], config["port"], timeout=15) if config["use_ssl"] else imaplib.IMAP4(config["server"], config["port"], timeout=15) + try: + connection.login(config["username"], config["password"]) + status, _ = connection.select(config["folder"], readonly=True) + if status != "OK": + raise RuntimeError(f"kunne ikke åbne mappen {config['folder']}") + finally: + try: + connection.logout() + except Exception: + pass + + try: + await asyncio.to_thread(test_imap) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"IMAP-test fejlede: {exc}") + + if not HAS_AIOSMTPLIB: + raise HTTPException(status_code=503, detail="IMAP virker, men SMTP-test er ikke mulig fordi aiosmtplib mangler på serveren") + try: + config = service.smtp_config + async with aiosmtplib.SMTP( + hostname=config["host"], port=config["port"], use_tls=config["use_tls"], timeout=15 + ) as smtp: + await smtp.login(config["username"], config["password"]) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"SMTP-test fejlede: {exc}") + return {"ok": True, "provider": "IMAP + SMTP", "message": "IMAP-indbakke og SMTP-login virker. Der er ikke sendt en e-mail."} + + @router.get("/settings", response_model=List[Setting], tags=["Settings"]) async def get_settings(category: Optional[str] = None): """Get all settings or filter by category""" @@ -375,7 +595,7 @@ async def get_setting(key: str): seed_query, ( "case_types", - '["ticket", "pipeline", "opgave", "ordre", "projekt", "service", "abonnement"]', + '["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]', "system", "Sags-typer", "json", diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index faf2fdc..8aa8e82 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -171,6 +171,9 @@ Notifikationer + + E-mail + Brugere @@ -604,6 +607,84 @@
+ +
+
+
+
+
Postkasse til sager
+

Den valgte postkasse bruges både til at hente indgående mails og sende svar fra BMC Hub.

+
+ Indlæser… +
+
+ Adgangskoder og client secrets krypteres på serveren og vises aldrig igen. Lad et hemmeligt felt stå tomt for at beholde den gemte værdi. +
+
+ Automatisk hentning af indgående mails er slået fra på serveren. Opsætningen kan gemmes, men EMAIL_TO_TICKET_ENABLED=true skal aktiveres ved deployment, før nye mails hentes automatisk. +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
Microsoft 365 via Microsoft Graph
+

Robust løsning til en delt postkasse eller en rigtig support-postkasse. BMC Hub bruger den samme adresse ind og ud.

+
+
+
+
Vises kun ved indtastning; en gemt secret kan ikke læses ud igen.
+
+
+ +
+
IMAP til modtagelse og SMTP til afsendelse
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
Opsætningsguide
+
+

  1. Opret eller vælg postkassen, fx support@firma.dk. Ved delt postkasse skal den kunne sende/modtage som adressen.
  2. Gå til Microsoft Entra ID → App registrations → New registration og opret appen.
  3. Kopiér Directory (tenant) ID og Application (client) ID ind her.
  4. Under Certificates & secrets opret en client secret og indsæt dens Value med det samme.
  5. Under API permissions → Microsoft Graph → Application permissions: tilføj Mail.ReadWrite og Mail.Send, og vælg Grant admin consent.
  6. Gem og aktivér først derefter postkassen her. Begræns gerne appen til den valgte postkasse med Exchange application access policy.
+

  1. Vælg IMAP + SMTP ovenfor.
  2. Aktivér IMAP på postkassen i Gmail/Workspace, hvis administratoren tillader det.
  3. Slå 2-trinsbekræftelse til på postkassen og opret en app-adgangskode til Mail. Brug aldrig den normale Google-adgangskode.
  4. Indtast imap.gmail.com, port 993, SSL/TLS og smtp.gmail.com, port 587, STARTTLS.
  5. Brug postkasseadressen som afsender og samme app-adgangskode i begge hemmelige felter. Gem og aktivér.
+

  1. Find IMAP- og SMTP-server, port og sikkerhedskrav hos udbyderen.
  2. Typiske værdier er IMAP 993 med SSL/TLS og SMTP 587 med STARTTLS.
  3. Opret helst en særskilt app-adgangskode eller servicepostkasse med kun nødvendig adgang.
  4. Indtast oplysningerne, gem og aktivér. BMC Hub ændrer ikke eller sletter mails via denne opsætning.
+
+
+
+
@@ -5501,6 +5582,105 @@ async function openAIBenchmarkRun(runId) { if (!['completed','failed'].includes(run.status)) pollAIBenchmarkRun(run.id); } +function emailField(id) { return document.getElementById(id); } + +function toggleEmailProviderFields() { + const graph = emailField('emailProvider').value === 'microsoft365_graph'; + emailField('emailGraphFields').classList.toggle('d-none', !graph); + emailField('emailImapFields').classList.toggle('d-none', graph); +} + +function setEmailConnectionFeedback(message, type = 'muted') { + const root = emailField('emailConnectionFeedback'); + root.className = `small text-${type}`; + root.textContent = message; +} + +async function loadEmailConnection() { + try { + const response = await fetch('/api/v1/settings/email-connection', {credentials: 'include'}); + if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke hente e-mailopsætningen')); + const data = await response.json(); + emailField('emailProvider').value = data.provider || 'microsoft365_graph'; + emailField('emailConnectionEnabled').checked = Boolean(data.is_enabled); + emailField('emailMailboxAddress').value = data.mailbox_address || ''; + emailField('emailSenderName').value = data.sender_name || 'BMC Hub'; + emailField('emailGraphTenantId').value = data.graph_tenant_id || ''; + emailField('emailGraphClientId').value = data.graph_client_id || ''; + emailField('emailImapHost').value = data.imap_host || ''; + emailField('emailImapPort').value = data.imap_port || 993; + emailField('emailImapFolder').value = data.imap_folder || 'INBOX'; + emailField('emailImapSsl').checked = data.imap_use_ssl !== false; + emailField('emailSmtpHost').value = data.smtp_host || ''; + emailField('emailSmtpPort').value = data.smtp_port || 587; + emailField('emailSmtpTls').checked = data.smtp_use_tls !== false; + emailField('emailConnectionState').className = `badge text-bg-${data.is_enabled ? 'success' : 'secondary'}`; + emailField('emailConnectionState').textContent = data.is_enabled ? `Aktiv: ${data.mailbox_address}` : (data.configured ? 'Gemt, ikke aktiv' : 'Miljøopsætning / ikke konfigureret'); + setEmailConnectionFeedback(data.has_saved_credentials ? 'Adgangsoplysninger er gemt sikkert.' : 'Ingen adgangsoplysninger gemt i Indstillinger.', 'muted'); + emailField('emailInboundDisabled').classList.toggle('d-none', Boolean(data.inbound_processing_enabled)); + toggleEmailProviderFields(); + } catch (error) { + setEmailConnectionFeedback(error.message || 'Kunne ikke hente e-mailopsætningen', 'danger'); + emailField('emailConnectionState').textContent = 'Kunne ikke indlæse'; + } +} + +async function saveEmailConnection(button) { + const provider = emailField('emailProvider').value; + const payload = { + provider, + is_enabled: emailField('emailConnectionEnabled').checked, + mailbox_address: emailField('emailMailboxAddress').value.trim(), + sender_name: emailField('emailSenderName').value.trim(), + graph_tenant_id: emailField('emailGraphTenantId').value.trim(), + graph_client_id: emailField('emailGraphClientId').value.trim(), + graph_client_secret: emailField('emailGraphClientSecret').value, + imap_host: emailField('emailImapHost').value.trim(), + imap_port: Number(emailField('emailImapPort').value), + imap_folder: emailField('emailImapFolder').value.trim(), + imap_use_ssl: emailField('emailImapSsl').checked, + imap_password: emailField('emailImapPassword').value, + smtp_host: emailField('emailSmtpHost').value.trim(), + smtp_port: Number(emailField('emailSmtpPort').value), + smtp_use_tls: emailField('emailSmtpTls').checked, + smtp_password: emailField('emailSmtpPassword').value, + }; + button.disabled = true; + setEmailConnectionFeedback('Gemmer …', 'muted'); + try { + const response = await fetch('/api/v1/settings/email-connection', { + method: 'PUT', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke gemme e-mailopsætningen')); + emailField('emailGraphClientSecret').value = ''; + emailField('emailImapPassword').value = ''; + emailField('emailSmtpPassword').value = ''; + setEmailConnectionFeedback('E-mailopsætningen er gemt. Nye mails bruger den aktive postkasse.', 'success'); + await loadEmailConnection(); + } catch (error) { + setEmailConnectionFeedback(error.message || 'Kunne ikke gemme e-mailopsætningen', 'danger'); + } finally { + button.disabled = false; + } +} + +async function testEmailConnection(button) { + button.disabled = true; + setEmailConnectionFeedback('Tester login og læseadgang — der sendes ingen e-mail …', 'muted'); + try { + const response = await fetch('/api/v1/settings/email-connection/test', { + method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: '{}', + }); + if (!response.ok) throw new Error(await getErrorMessage(response, 'Forbindelsestesten fejlede')); + const result = await response.json(); + setEmailConnectionFeedback(result.message || 'Forbindelsen virker.', 'success'); + } catch (error) { + setEmailConnectionFeedback(error.message || 'Forbindelsestesten fejlede', 'danger'); + } finally { + button.disabled = false; + } +} + // Tab navigation document.querySelectorAll('.settings-nav .nav-link').forEach(link => { link.addEventListener('click', (e) => { @@ -5534,6 +5714,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => { loadTaskTemplates(); } else if (tab === 'telefoni') { renderTelefoniSettings(); + } else if (tab === 'email') { + loadEmailConnection(); } else if (tab === 'mission') { renderMissionSettings(); } else if (tab === 'ai-prompts') { diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html index 4b05fac..9498373 100644 --- a/app/shared/frontend/base.html +++ b/app/shared/frontend/base.html @@ -2004,7 +2004,7 @@ if (bmcOriginalFetch) { - + {% include "shared/frontend/internal_message.html" %} diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py index 3109f95..b8839fa 100644 --- a/app/timetracking/backend/router.py +++ b/app/timetracking/backend/router.py @@ -194,6 +194,16 @@ def _assert_prepaid_entry_editable(entry: Dict[str, Any]) -> None: def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]: """Return valid settlement choices and a deterministic recommendation.""" + if customer_id is None: + return { + "recommended": { + "method": "internal", + "reason": "Sagen har ingen kunde og registreres derfor som intern tid", + }, + "prepaid_cards": [], + "agreements": [], + } + customer = execute_query_single( "SELECT id, hub_customer_id FROM tmodule_customers WHERE id = %s", (customer_id,), @@ -2410,11 +2420,6 @@ async def start_live_timer_v1( raise HTTPException(status_code=400, detail="sag_id is required") customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id")) - if customer_id is None: - raise HTTPException( - status_code=400, - detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.", - ) bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id")) if not bruger_id: @@ -2453,7 +2458,7 @@ async def start_live_timer_v1( user_name = payload.get("user_name") or default_user_name entry_type = payload.get("entry_type") or "manuel" kilde = payload.get("kilde") or "manuel" - billable = bool(payload.get("fakturerbar", True)) + billable = customer_id is not None and bool(payload.get("fakturerbar", True)) round_block_min = int(payload.get("round_block_min") or 30) created = execute_query( @@ -2880,11 +2885,6 @@ async def create_manual_time_v1( raise HTTPException(status_code=400, detail="sag_id is required") customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id")) - if customer_id is None: - raise HTTPException( - status_code=400, - detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.", - ) requested_user_ids = payload.get("medarbejder_ids") is_bulk_request = requested_user_ids is not None @@ -2921,11 +2921,13 @@ async def create_manual_time_v1( raise HTTPException(status_code=400, detail="faktisk_tid_min or original_hours must be > 0") round_block_min = int(payload.get("round_block_min") or 30) - billable = bool(payload.get("fakturerbar", True)) + billable = customer_id is not None and bool(payload.get("fakturerbar", True)) billable_minutes = payload.get("fakturerbar_tid_min") if billable_minutes is None: billable_minutes = _round_up_minutes(actual_minutes, round_block_min) billable_minutes = int(billable_minutes) + if not billable: + billable_minutes = 0 worked_date = payload.get("worked_date") if not worked_date: diff --git a/fremtidige planer/sikker-fakturaudtraek-hybrid-ai.md b/fremtidige planer/sikker-fakturaudtraek-hybrid-ai.md index 22c8fd8..7971382 100644 --- a/fremtidige planer/sikker-fakturaudtraek-hybrid-ai.md +++ b/fremtidige planer/sikker-fakturaudtraek-hybrid-ai.md @@ -1,4 +1,4 @@ -# Sikkert fakturaudtræk med layoutanalyse og AI +$# Sikkert fakturaudtræk med layoutanalyse og AI ## Status diff --git a/migrations/1035_email_connection_configuration.sql b/migrations/1035_email_connection_configuration.sql new file mode 100644 index 0000000..6c57a6b --- /dev/null +++ b/migrations/1035_email_connection_configuration.sql @@ -0,0 +1,30 @@ +-- Central, encrypted configuration for the mailbox BMC Hub reads and sends from. +-- Secrets are stored as a Fernet-encrypted JSON blob and are never returned by the API. +CREATE TABLE IF NOT EXISTS email_connection_configurations ( + id SERIAL PRIMARY KEY, + provider VARCHAR(30) NOT NULL CHECK (provider IN ('microsoft365_graph', 'imap_smtp')), + is_enabled BOOLEAN NOT NULL DEFAULT false, + mailbox_address VARCHAR(320) NOT NULL, + sender_name VARCHAR(255) NOT NULL DEFAULT 'BMC Hub', + imap_host VARCHAR(255), + imap_port INTEGER NOT NULL DEFAULT 993, + imap_folder VARCHAR(255) NOT NULL DEFAULT 'INBOX', + imap_use_ssl BOOLEAN NOT NULL DEFAULT true, + smtp_host VARCHAR(255), + smtp_port INTEGER NOT NULL DEFAULT 587, + smtp_use_tls BOOLEAN NOT NULL DEFAULT true, + graph_tenant_id VARCHAR(255), + graph_client_id VARCHAR(255), + secrets_encrypted TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by_user_id INTEGER +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_email_connection_singleton + ON email_connection_configurations ((1)); + +DROP TRIGGER IF EXISTS email_connection_configurations_updated_at_trigger ON email_connection_configurations; +CREATE TRIGGER email_connection_configurations_updated_at_trigger + BEFORE UPDATE ON email_connection_configurations + FOR EACH ROW EXECUTE FUNCTION update_settings_updated_at(); diff --git a/migrations/1036_sag_individual_assignments.sql b/migrations/1036_sag_individual_assignments.sql new file mode 100644 index 0000000..92acc17 --- /dev/null +++ b/migrations/1036_sag_individual_assignments.sql @@ -0,0 +1,12 @@ +-- Explicit employee assignments for a case. A group is only a picker/context; +-- each selected employee has a separate row. +CREATE TABLE IF NOT EXISTS sag_case_assignees ( + sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + assigned_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + PRIMARY KEY (sag_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_sag_case_assignees_user + ON sag_case_assignees(user_id, sag_id); diff --git a/migrations/1037_case_type_details.sql b/migrations/1037_case_type_details.sql new file mode 100644 index 0000000..17c0221 --- /dev/null +++ b/migrations/1037_case_type_details.sql @@ -0,0 +1,13 @@ +-- Stores the structured fields shown by the dynamic new-case templates. +-- Kept separately so legacy case payloads and sag_sager remain unchanged. +CREATE TABLE IF NOT EXISTS sag_case_details ( + sag_id INTEGER PRIMARY KEY REFERENCES sag_sager(id) ON DELETE CASCADE, + details JSONB NOT NULL DEFAULT '{}'::jsonb, + created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + updated_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_sag_case_details_details + ON sag_case_details USING GIN (details); diff --git a/migrations/1038_sync_case_type_with_template.sql b/migrations/1038_sync_case_type_with_template.sql new file mode 100644 index 0000000..afe637f --- /dev/null +++ b/migrations/1038_sync_case_type_with_template.sql @@ -0,0 +1,7 @@ +-- Keep the legacy type column aligned with template_key. +-- Some V4 components still consume type while newer flows use template_key. +UPDATE sag_sager +SET type = template_key +WHERE template_key IS NOT NULL + AND BTRIM(template_key) <> '' + AND type IS DISTINCT FROM template_key; diff --git a/migrations/1039_set_supported_case_types.sql b/migrations/1039_set_supported_case_types.sql new file mode 100644 index 0000000..53a9cf4 --- /dev/null +++ b/migrations/1039_set_supported_case_types.sql @@ -0,0 +1,17 @@ +-- Canonical case types shown when creating and editing V4 cases. +-- Existing cases keep their stored type values for backwards compatibility. +INSERT INTO settings (key, value, category, description, value_type, is_public) +VALUES ( + 'case_types', + '["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]', + 'system', + 'Sags-typer', + 'json', + true +) +ON CONFLICT (key) DO UPDATE +SET value = EXCLUDED.value, + description = EXCLUDED.description, + value_type = EXCLUDED.value_type, + is_public = EXCLUDED.is_public, + updated_at = CURRENT_TIMESTAMP; diff --git a/migrations/1040_tmodule_times_optional_customer.sql b/migrations/1040_tmodule_times_optional_customer.sql new file mode 100644 index 0000000..6ca18a6 --- /dev/null +++ b/migrations/1040_tmodule_times_optional_customer.sql @@ -0,0 +1,6 @@ +-- Internal work, including Post-it cases, may be registered before a customer +-- is attached. Customer-specific settlement is selected only after a customer +-- exists, so the time row itself must allow a null customer reference. +ALTER TABLE tmodule_times + ALTER COLUMN customer_id DROP NOT NULL; + diff --git a/migrations/1041_seed_internal_case_customer.sql b/migrations/1041_seed_internal_case_customer.sql new file mode 100644 index 0000000..be70794 --- /dev/null +++ b/migrations/1041_seed_internal_case_customer.sql @@ -0,0 +1,22 @@ +-- Internal tickets have a fixed, canonical customer identity. +-- Never overwrite another customer if an installation already uses ID 8. +DO $$ +DECLARE + existing_name TEXT; +BEGIN + SELECT name INTO existing_name FROM customers WHERE id = 8; + + IF existing_name IS NULL THEN + INSERT INTO customers (id, name, email, email_domain, country, is_active) + VALUES (8, 'BMC Networks', 'info@bmcnetworks.dk', 'bmcnetworks.dk', 'DK', TRUE); + ELSIF LOWER(TRIM(existing_name)) <> LOWER('BMC Networks') THEN + RAISE EXCEPTION 'Customer ID 8 is already used by %, expected BMC Networks', existing_name; + END IF; +END $$; + +SELECT setval( + pg_get_serial_sequence('customers', 'id'), + GREATEST((SELECT COALESCE(MAX(id), 1) FROM customers), 1), + TRUE +); + diff --git a/static/js/case-create.js b/static/js/case-create.js index 73d5f83..bb079a7 100644 --- a/static/js/case-create.js +++ b/static/js/case-create.js @@ -3,7 +3,7 @@ 'use strict'; const $ = id => document.getElementById(id); const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - const state = {tags: [], hardware: [], tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false}; + const state = {tags: [], hardware: [], typeDetails: {}, tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false}; let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = []; async function api(url, options) { const response = await fetch(url, {credentials: 'include', ...options}); @@ -38,7 +38,7 @@ } function snapshot() { return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies, - tags: state.tags, hardware: state.hardware, orders: collectOrderItems(), prefill: telefoniPrefill, + tags: state.tags, hardware: state.hardware, typeDetails: state.typeDetails, orders: collectOrderItems(), prefill: telefoniPrefill, customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value, panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))}; } @@ -128,17 +128,105 @@ $('cc-workload').innerHTML = `
${data.total} åbne sager · ${esc($('ansvarlig_bruger_id').selectedOptions[0].text)}${data.items.map(r => `
SAG-${r.id} · ${esc(r.titel)}${esc(r.customer_name || '')} · ${esc(r.status)} ${r.deadline ? esc(new Date(r.deadline).toLocaleString('da-DK')) : ''}
`).join('') || '

Ingen åbne sager.

'}${data.total > 10 ? `Se alle ${data.total} sager` : ''}
`; } catch { if (token === workloadToken) $('cc-workload').textContent = 'Kunne ikke hente åbne sager.'; } } - function typeChanged(type) { + function typeChanged(type, showTypeFields = false) { + if (type === 'intern_ticket') { + selectedCustomer = {id: 8, name: 'BMC Networks'}; + $('customer_id').value = '8'; + $('customerSearch').value = 'BMC Networks'; + const creatorId = Number(window.caseCreateCurrentUserId || 0); + if (creatorId && Array.from($('ansvarlig_bruger_id').options).some(option => Number(option.value) === creatorId)) { + $('ansvarlig_bruger_id').value = String(creatorId); + } + window.renderSelections?.(); + } + captureTypeDetails(); + renderTypeDetails(type); + $('createForm').closest('.card-custom')?.classList.toggle('cc-postit-mode', type === 'postit'); + state.panels['type-details']?.classList.toggle('cc-postit-panel', type === 'postit'); document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); }); - const key = {ticket:'hardware', pipeline:'pipeline', ordre:'order'}[type]; - if (key && state.panels[key] && !state.restoring) state.panels[key].open = true; + const visiblePanels = { + ticket: ['relations', 'type-details', 'hardware'], + salg: ['relations', 'type-details', 'pipeline', 'order'], + bogholderi: ['relations', 'type-details', 'order'], + admin: ['type-details'], + projekt: ['relations', 'type-details'], + postit: ['type-details'], + intern_ticket: ['type-details'], + bug_report: ['relations', 'type-details', 'hardware'], + service: ['relations', 'hardware'], + pipeline: ['relations', 'pipeline'], + ordre: ['relations', 'order'], + opgave: ['relations'], + abonnement: ['relations'], + }[type] || ['relations']; + ['relations', 'type-details', 'hardware', 'pipeline', 'order'].forEach(key => state.panels[key]?.classList.toggle('d-none', !showTypeFields || !visiblePanels.includes(key))); + const sectionIds = {hardware: 'hardwareSection', pipeline: 'pipelineSection', order: 'orderSection'}; + Object.entries(sectionIds).forEach(([key, id]) => $(id)?.classList.toggle('d-none', !showTypeFields || !visiblePanels.includes(key))); + document.querySelectorAll('.case-type-extra').forEach(el => el.classList.toggle('d-none', !showTypeFields)); + $('cc-template')?.closest('.cc-template-toolbar')?.classList.toggle('d-none', !showTypeFields); + if (showTypeFields && !state.restoring) visiblePanels.forEach(panelKey => { if (state.panels[panelKey]) state.panels[panelKey].open = true; }); if ($('cc-more')) $('cc-more').open = Boolean($('cc-more').querySelector(`[data-case-type="${CSS.escape(type)}"]`)); scheduleSave(); } + const typeFieldSets = { + ticket: [ + ['anydesk_id', 'AnyDesk-ID', 'text', '123-456-789'], + ['hardware_information', 'Hardwareinformation', 'textarea', 'Enhed, model, serienummer eller fejl'], + ['solution', 'Løsning', 'textarea', 'Den aftalte eller udførte løsning'], + ], + salg: [ + ['customer_need', 'Kundebehov', 'textarea', 'Hvad ønsker kunden?'], + ['expected_close_date', 'Forventet lukkedato', 'date', ''], + ], + bogholderi: [ + ['voucher_reference', 'Bilags- eller fakturanummer', 'text', 'F.eks. F-12345'], + ['supplier', 'Leverandør', 'text', 'Leverandørnavn'], + ['due_date', 'Forfaldsdato', 'date', ''], + ], + admin: [ + ['area', 'Område eller system', 'text', 'F.eks. brugeradministration'], + ['requested_action', 'Ønsket handling', 'textarea', 'Hvad skal ændres eller undersøges?'], + ], + projekt: [ + ['project_goal', 'Projektmål', 'textarea', 'Hvad skal projektet levere?'], + ['start_date', 'Startdato', 'date', ''], + ['end_date', 'Slutdato', 'date', ''], + ], + postit: [ + ['note', 'Notat', 'textarea', 'Kort huskeseddel'], + ['remind_date', 'Påmindelsesdato', 'date', ''], + ], + intern_ticket: [ + ['system', 'System', 'text', 'Berørt system eller modul'], + ['purpose', 'Formål', 'textarea', 'Hvad skal den interne opgave føre til?'], + ['next_step', 'Næste skridt', 'textarea', 'Hvad skal der ske nu?'], + ], + bug_report: [ + ['affected_area', 'Berørt område', 'text', 'System, side eller modul'], + ['steps_to_reproduce', 'Trin til at genskabe', 'textarea', 'Beskriv trin for trin hvordan fejlen opstår'], + ['expected_result', 'Forventet resultat', 'textarea', 'Hvad skulle der være sket?'], + ['actual_result', 'Faktisk resultat', 'textarea', 'Hvad skete der i stedet?'], + ], + }; + function captureTypeDetails() { + const currentType = $('type')?.value; + if (!currentType) return; + const values = {}; + document.querySelectorAll('[data-case-detail]').forEach(field => { values[field.dataset.caseDetail] = field.value; }); + if (Object.keys(values).length) state.typeDetails[currentType] = values; + } + function renderTypeDetails(type) { + const section = $('typeDetailsSection'), box = $('typeDetailsFields'); + if (!section || !box) return; + const fields = typeFieldSets[type] || []; + const values = state.typeDetails[type] || {}; + box.innerHTML = fields.map(([key, label, kind, placeholder]) => `
${kind === 'textarea' ? `` : ``}
`).join(''); + section.classList.toggle('d-none', !fields.length); + } function buildTypes() { const options = Array.from($('type').options), current = $('type').value; - const keys = [...new Set([current, 'ticket','pipeline','ordre','opgave', ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k)); - const hints = {ticket:'Support og hjælp',pipeline:'Muligheder og tilbud',ordre:'Indkøb og salg',opgave:'Noget der skal løses',projekt:'Større forløb',service:'Service og vedligehold',abonnement:'Løbende aftaler'}; + const keys = [...new Set([current, ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k)); + const hints = {ticket:'Support og hjælp',salg:'Tilbud og salg',bogholderi:'Bilag og økonomi',admin:'Intern administration',projekt:'Større forløb',postit:'Huskeseddel',intern_ticket:'Intern opgave',bug_report:'Rapportér en fejl'}; const button = key => ``; const messageButton = $('caseMessageTypeButton'); $('type').classList.add('d-none'); $('type').removeAttribute('required'); @@ -156,12 +244,12 @@ Object.entries(fields).forEach(([k,v]) => { const el = $(k); if (el && (el.tagName !== 'SELECT' || Array.from(el.options).some(o => o.value === String(v)))) el.value = v; }); const tags = await api('/api/v1/tags?is_active=true'); for (const id of values.tag_ids || []) { const tag = tags.find(t => t.id === id); if (tag) await addTag(tag); } - updateCaseTypeSections(); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates(); + window.updateCaseTypeSections?.(true); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates(); } async function restore(draft) { state.restoring = true; try { - selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {}; + selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {}; state.typeDetails = draft.typeDetails || {}; telefoniPrefill = draft.prefill || telefoniPrefill; Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } }); state.tags = draft.tags || []; state.hardware = draft.hardware || []; @@ -171,7 +259,7 @@ await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null); if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id); $('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || ''; - updateCaseTypeSections(); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; }); + window.updateCaseTypeSections?.(true); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; }); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; } finally { state.restoring = false; } state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload(); @@ -182,7 +270,7 @@ const errors = []; const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `
${esc(text)}
`); const detail = el.closest('details'); if (detail) { detail.open = true; if (!detail.querySelector('summary .cc-error')) detail.querySelector('summary').insertAdjacentHTML('beforeend',' · Kontrollér felter'); } }; if (!$('titel').value.trim()) add($('titel'), 'Titel er påkrævet'); - if (!selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma'); + if (['ticket', 'salg', 'bogholderi', 'projekt', 'service', 'pipeline', 'ordre', 'opgave', 'abonnement'].includes($('type').value) && !selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma'); $('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); }); document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } }); if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; } @@ -196,10 +284,10 @@ if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); }); } catch { /* Keep the built-in statuses available. */ } const headings = Array.from(form.querySelectorAll(':scope > h5')); - panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], true); - for (const [key,id,title] of [['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]); - const metadata = headings[1]; panel('metadata', 'Status, ansvar og deadline', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling]); - const tags = document.createElement('section'); tags.innerHTML = '
'; state.panels.hardware.before(tags); panel('tags', 'Tags', [tags]); + panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], false); + for (const [key,id,title] of [['type-details','typeDetailsSection','Oplysninger til sagstypen'],['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]); + const metadata = headings[1]; panel('metadata', 'Ansvarlig', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling], true); + const tags = document.createElement('section'); tags.innerHTML = '
'; state.panels.hardware.before(tags); panel('tags', 'Tags', [tags], true); $('cc-tag-add').onclick = () => window.tagPicker.showSelection(addTag); window.setTagPickerContext?.('case', null, addTag); $('cc-tag-chips').onclick = e => { const chip = e.target.closest('[data-tag-id]'); if (chip && e.target.closest('button')) { state.tags = state.tags.filter(t => t.id !== Number(chip.dataset.tagId)); renderTags(); scheduleSave(); } }; @@ -225,7 +313,7 @@ $('cc-template').onchange = e => applyTemplate(e.target.value).catch(err => showCreateError(err.message)); const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','Klargør kladde…'); toolbar.classList.add('cc-template-toolbar'); - buildTypes(); updateCaseTypeSections(); + buildTypes(); updateCaseTypeSections(false); form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0)); $('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); }); $('beskrivelse').addEventListener('input', scheduleTagSuggestions); $('ansvarlig_bruger_id').addEventListener('change', workload); @@ -249,7 +337,7 @@ state.tagCatalog = await api('/api/v1/tags?is_active=true').catch(() => []); state.ready = true; summaries(); renderTagSuggestions(); workload(); scheduleDuplicates(); contactOpenCases(); await loadHardwareForContacts(); } - window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => ({tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id)}),created: result => { + window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => { captureTypeDetails(); return {tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id),type_details:state.typeDetails[$('type').value] || {}}; },created: result => { state.completed = true; clearTimeout(saveTimer); try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ } }}; diff --git a/static/js/planner.js b/static/js/planner.js index b03c54a..d760f32 100644 --- a/static/js/planner.js +++ b/static/js/planner.js @@ -1,6 +1,6 @@ (() => { const root=document.querySelector('.planner-page'); if(!root)return; - const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],quick:'unplanned',selected:null,caseRefresh:0}; + const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],overdueCases:[],quick:'unplanned',selected:null,caseRefresh:0}; const $=s=>document.querySelector(s), pad=n=>String(n).padStart(2,'0'); const localISO=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; const dateKey=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; @@ -12,9 +12,10 @@ async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',``)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',``)}bind();await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)} function bind(){let timer;$('#caseSearch').addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(loadCases,250)});['statusFilter','priorityFilter','typeFilter','responsibleFilter','departmentFilter','sortFilter'].forEach(id=>$('#'+id).addEventListener('change',loadCases));document.querySelectorAll('[data-quick]').forEach(b=>b.onclick=()=>{state.quick=state.quick===b.dataset.quick?'':b.dataset.quick;document.querySelectorAll('[data-quick]').forEach(x=>x.classList.toggle('active',x.dataset.quick===state.quick));loadCases()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{state.view=b.dataset.view;document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});document.querySelectorAll('[data-scope]').forEach(b=>b.onclick=()=>{state.scope=b.dataset.scope;document.querySelectorAll('[data-scope]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});$('#prevPeriod').onclick=()=>shift(-1);$('#nextPeriod').onclick=()=>shift(1);$('#todayBtn').onclick=()=>{state.anchor=new Date();loadPeriod()};$('.collapse-cases').onclick=()=>$('#casePanel').classList.toggle('collapsed');$('#saveAllocation').onclick=saveSelected;$('#deleteAllocation').onclick=deleteSelected;$('#copyAllocation').onclick=copySelected} function shift(n){if(state.view==='month')state.anchor.setMonth(state.anchor.getMonth()+n);else state.anchor.setDate(state.anchor.getDate()+n*(state.view==='week'?7:1));loadPeriod()} - async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{state.cases=(await api('/api/v1/planner/cases?'+p)).items;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const current=$('#typeFilter').value;$('#typeFilter').innerHTML=''+types.map(x=>``).join('');renderCases()}catch(e){$('#caseList').innerHTML=`
${esc(e.message)}
`}} + async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial','overdue'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{const current=await api('/api/v1/planner/cases?'+p);let overdueItems=[];try{overdueItems=(await api('/api/v1/planner/cases?planning=overdue&sort=priority')).items||[]}catch(error){console.warn('Kunne ikke hente overskredne planer',error)}state.cases=current.items;state.overdueCases=overdueItems;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const currentType=$('#typeFilter').value;$('#typeFilter').innerHTML=''+types.map(x=>``).join('');renderCases()}catch(e){$('#caseList').innerHTML=`
${esc(e.message)}
`}} function setCompactDragImage(e,el){const ghost=document.createElement('div');ghost.textContent=el.querySelector('strong')?.textContent||'Planlæg sag';ghost.style.cssText='position:fixed;left:-9999px;top:-9999px;width:210px;padding:7px 10px;border-radius:7px;border-left:4px solid #2563eb;background:#dbeafe;color:#101828;font-size:12px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 5px 14px #0003';document.body.appendChild(ghost);e.dataTransfer.setDragImage(ghost,18,16);setTimeout(()=>ghost.remove(),0)} - function renderCases(){$('#caseList').innerHTML=state.cases.length?state.cases.map(c=>`
#${c.id} · ${esc(c.title)}
${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}
${esc(c.priority)}
${fmtMin(c.planned_minutes)} planlagt${fmtMin(c.remaining_minutes)} tilbage
`).join(''):'
Ingen sager matcher filtrene
';document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})} + function caseCardHtml(c){const overdue=Boolean(c.overdue_planned),overdueDate=overdue&&c.overdue_planned_until?new Date(c.overdue_planned_until).toLocaleDateString('da-DK'):'';return `
#${c.id} · ${esc(c.title)}
${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}
${overdue?`
Planlagt afsluttet ${esc(overdueDate)} · stadig åben
`:''}
${esc(c.priority)}
${fmtMin(c.planned_minutes)} planlagt${fmtMin(c.remaining_minutes)} tilbage
`} + function renderCases(){const overdueHtml=state.quick!=='overdue'&&state.overdueCases.length?`
${state.overdueCases.length} åben${state.overdueCases.length===1?' sag er':' sager er'} planlagt før i dag
${state.overdueCases.map(caseCardHtml).join('')}
`:'';const casesHtml=state.cases.length?state.cases.map(caseCardHtml).join(''):'
Ingen sager matcher filtrene
';$('#caseList').innerHTML=overdueHtml+casesHtml;document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})} async function setEstimate(caseId){const c=state.cases.find(x=>x.id===caseId),answer=prompt('Forventet tidsforbrug i timer (fx 2,5):',c?.estimated_minutes?String(c.estimated_minutes/60).replace('.',','):'');if(answer===null)return;const hours=Number(answer.replace(',','.'));if(!Number.isFinite(hours)||hours<0){toast('Angiv et gyldigt antal timer');return}try{await api(`/api/v1/planner/cases/${caseId}/estimate`,{method:'PATCH',body:JSON.stringify({estimated_minutes:Math.round(hours*60)})});toast('Forventet tid er gemt');await loadCases()}catch(e){toast(e.message)}} async function showTimeEntries(caseId){const c=state.cases.find(x=>x.id===caseId);$('#timeDialogTitle').textContent=`#${caseId} · ${c?.title||'Sag'}`;$('#timeDialogTotal').textContent='Henter tidsregistreringer…';$('#timeEntryList').innerHTML='';$('#timeDialogCaseLink').href=`/sag/${caseId}/v3`;$('#timeDialog').showModal();try{const data=await api(`/api/v1/planner/cases/${caseId}/time-entries`);$('#timeDialogTotal').textContent=`I alt ${fmtMin(data.total_minutes)} brugt`;$('#timeEntryList').innerHTML=data.items.length?data.items.map(x=>{const when=x.start_tid?new Date(x.start_tid).toLocaleString('da-DK'):(x.worked_date?new Date(x.worked_date+'T12:00').toLocaleDateString('da-DK'):'Ukendt tidspunkt');return `
${esc(x.employee_name)}${fmtMin(x.minutes)}
${esc(when)}${x.slut_tid?' – '+new Date(x.slut_tid).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}):''}${x.description?`
${esc(x.description)}
`:''}
`}).join(''):'
Ingen tid registreret endnu.
'}catch(e){$('#timeDialogTotal').textContent=e.message}} async function loadPeriod(){const [a,b]=range(),p=new URLSearchParams({starts_at:a.toISOString(),ends_at:b.toISOString()});selectedUsers().forEach(id=>p.append('user_id',id));$('#timeline').innerHTML='
Henter plan…
';try{state.data=await api('/api/v1/planner/period?'+p);render(a,b)}catch(e){$('#timeline').innerHTML=`
${esc(e.message)}
`}} diff --git a/static/js/telefoni.js b/static/js/telefoni.js index 7bce589..e7ebeef 100644 --- a/static/js/telefoni.js +++ b/static/js/telefoni.js @@ -968,7 +968,14 @@ }; } - document.addEventListener('DOMContentLoaded', connect); + // The script is included globally and may be loaded after DOMContentLoaded + // during internal navigation. In that case the old listener never ran and + // incoming-call popups silently stopped because no WebSocket was opened. + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', connect, { once: true }); + } else { + connect(); + } window.addEventListener('focus', async () => { if (reconnectDisabledForAuth) { const sessionOk = await hasActiveSession(); diff --git a/tests/test_case_create_support.py b/tests/test_case_create_support.py index bb1b7bb..08e12bd 100644 --- a/tests/test_case_create_support.py +++ b/tests/test_case_create_support.py @@ -111,6 +111,7 @@ def test_ticket_creation_commits_all_optional_data_or_rolls_back(monkeypatch, mi monkeypatch.setattr(cases, '_get_user_id_from_request', lambda r: 7) monkeypatch.setattr(cases, '_validate_user_id', lambda v: None) monkeypatch.setattr(cases, '_validate_group_id', lambda v: None) + monkeypatch.setattr(cases, '_validate_customer_id', lambda v: None) monkeypatch.setattr(cases, 'table_has_column', lambda *a: True) data = {'titel': 'Test', 'customer_id': 3, 'type': 'ticket', 'pipeline': {'amount': 123}, 'hardware_ids': [4], 'tag_ids': [9], 'order_items': [{'description': 'Router', 'amount': 50}]} if missing_hardware: @@ -123,3 +124,53 @@ def test_ticket_creation_commits_all_optional_data_or_rolls_back(monkeypatch, mi assert all(table in sql for table in ['sag_sager','sag_salgsvarer','sag_hardware','entity_tags']) case_args = next(args for query, args in conn.cur.calls if 'INSERT INTO sag_sager' in query) assert 123.0 in case_args + + +def test_internal_ticket_forces_bmc_customer_and_creator_as_responsible(monkeypatch): + import asyncio + from app.modules.sag.backend import router as cases + + captured = {} + + class Cursor: + def __enter__(self): return self + def __exit__(self, *_): pass + def execute(self, sql, args): + if 'INSERT INTO sag_sager' in sql: + captured['case_args'] = args + self._row = {'id': 21, 'customer_id': args[5], 'ansvarlig_bruger_id': args[6]} + else: + self._row = None + def fetchone(self): return self._row + def fetchall(self): return [] + + class Connection: + def __init__(self): self.cur = Cursor(); self.committed = False + def cursor(self, **_kwargs): return self.cur + def commit(self): self.committed = True + def rollback(self): pass + + conn = Connection() + monkeypatch.setattr(cases, 'get_db_connection', lambda: conn) + monkeypatch.setattr(cases, 'release_db_connection', lambda _conn: None) + monkeypatch.setattr(cases, '_normalize_case_status', lambda _value: 'åben') + monkeypatch.setattr(cases, '_get_user_id_from_request', lambda _request: 7) + monkeypatch.setattr(cases, '_validate_user_id', lambda value: captured.setdefault('validated_user', value)) + monkeypatch.setattr(cases, '_validate_group_id', lambda _value: None) + monkeypatch.setattr(cases, '_validate_customer_id', lambda value: captured.setdefault('validated_customer', value)) + monkeypatch.setattr(cases, 'table_has_column', lambda *_args: True) + + result = asyncio.run(cases.create_sag(None, { + 'titel': 'Intern opgave', + 'type': 'intern_ticket', + 'customer_id': 1662, + 'ansvarlig_bruger_id': 99, + })) + + assert result['id'] == 21 + assert captured['validated_customer'] == 8 + assert captured['validated_user'] == 7 + assert captured['case_args'][5] == 8 + assert captured['case_args'][6] == 7 + assert captured['case_args'][8] == 7 + assert conn.committed is True diff --git a/tests/test_planner.py b/tests/test_planner.py index 133bad3..c349a9e 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -95,6 +95,17 @@ def test_planner_ui_exposes_views_filters_capacity_used_time_and_undo(): assert "green" in html and "yellow" in html and "red" in html and "gray" in html +def test_case_planner_exposes_case_specific_allocations_endpoint(): + source = Path("app/modules/planner/backend/router.py").read_text(encoding="utf-8") + + assert '@router.get("/cases/{sag_id}/allocations")' in source + assert "def case_allocations(" in source + assert "WHERE a.sag_id=%s AND a.deleted_at IS NULL" in source + assert '"remaining_minutes"' in source + assert '"deferred"' in source + assert '"subcases"' in source + + def test_closed_case_is_checked_before_allocation(monkeypatch): monkeypatch.setattr(planner, "_can_edit", lambda *_: True) monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None}) diff --git a/tests/test_sag_module.py b/tests/test_sag_module.py index 544343c..0a78b7d 100644 --- a/tests/test_sag_module.py +++ b/tests/test_sag_module.py @@ -159,9 +159,18 @@ def test_case_create_lists_contacts_for_selected_customer(): def test_case_create_defaults_responsible_to_current_user(): template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8") router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8") + create_script = Path("static/js/case-create.js").read_text(encoding="utf-8") assert "selectCurrentUserAsResponsible()" in template + assert "if (!select || select.value) return" not in template + assert "updateCaseTypeSections(false);" in template assert "raw_responsible = data.get" in router assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router + assert 'if case_type == "intern_ticket":' in router + assert 'data["customer_id"] = 8' in router + assert "raw_responsible = current_user_id" in router + assert '_validate_customer_id(data.get("customer_id"))' in router + assert "selectedCustomer = {id: 8, name: 'BMC Networks'}" in create_script + assert "window.caseCreateCurrentUserId" in create_script def test_case_create_sends_relations_in_atomic_create_payload(): @@ -457,6 +466,158 @@ def test_sag_list_status_dropdown_receives_options_and_links_are_styled(): assert "function applyStatusSelectTone(control)" in template +def test_v4_compact_time_module_is_moved_below_tags_and_creates_drafts(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + + assert 'id="caseQuickTimeMinutes"' in template + assert 'id="caseQuickTimeType"' in template + assert 'id="caseQuickTimeDescription"' in template + assert 'onclick="startQuickTimerV4()"' in template + assert "tagsModule.insertAdjacentElement('afterend', timeModule);" in template + assert "entry_status: 'kladde'" in template + assert "medarbejder_ids: [null]" in template + + +def test_postit_keeps_tags_time_and_full_time_tab_available(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + + assert ':not([data-module="tags"]):not([data-module="time"])' in template + assert "target !== '#details' && target !== '#timetracking' && target !== '#planner'" in template + assert "moduleName !== 'tags' && moduleName !== 'time'" in template + + +def test_v4_timeline_date_menus_escape_hero_stacking_context(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + + assert template.count("data-v4-date-dropdown") >= 2 + assert "case-v4-floating-date-menu" in template + assert "document.body.appendChild(menu);" in template + assert "toggle.insertAdjacentElement('afterend', menu);" in template + + +def test_v4_group_assignment_loader_is_available_to_page_initialization(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + + assert "window.loadCaseAssignments = loadCaseAssignments;" in template + assert "window.persistCaseAssignees = persistCaseAssignees;" in template + assert "window.loadCaseAssignments?.().catch" in template + assert "await persistCaseAssignees([], null, null);" in template + assert "await saveCaseAssignees([], null, null);" not in template + + +def test_v4_deferred_modal_is_compact_and_saves_atomically(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + + assert 'class="modal fade deferred-v4-modal" id="deferredModal"' in template + assert "bootstrap.Modal.getOrCreateInstance(modalElement).show();" in template + assert "async function persistDeferredSettings" in template + assert "await persistDeferredSettings(input.value || null, caseSelect.value || null, statuses);" in template + assert "deferred_until_status: relatedCaseId && normalizedStatuses.length" in template + assert "updateDeferredUntil(input.value);" not in template + + +def test_v4_comment_actions_use_compact_icons_and_can_append_to_case(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + router = Path("app/modules/sag/backend/router.py").read_text() + + assert 'action-time is-icon-only' in template + assert 'action-close is-icon-only' in template + assert 'action-wait is-icon-only' in template + assert 'Log tid' not in template + assert 'Luk sag' not in template + assert 'Afventer kunde' not in template + assert 'id="commentActionAppendCase"' in template + assert "actions.push('append_case')" in template + assert "body: JSON.stringify({ beskrivelse: content, append: true })" in template + assert "await appendCommentToCaseDescription(content);" in template + assert "append: bool = False" in router + assert 'append_date = datetime.now().strftime("%d/%m")' in router + assert 'append_fragment = f"
{initials}/{append_date}

{safe_addition}

"' in router + + +def test_v4_comment_composer_prioritizes_writing_space(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + + assert 'min-height: 160px;' in template + assert 'rows="6"' in template + assert '.comment-composer textarea:focus' in template + assert 'align-items: flex-end;' in template + + +def test_v4_relations_are_compact_and_created_inline(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + + assert 'Relationer til #{{ case.id }}' in template + assert 'id="relationInlineCreate"' in template + assert 'onclick="showInlineRelationRow()"' in template + assert 'id="inlineRelatedCaseType"' in template + assert 'id="inlineRelatedCaseTitle"' in template + assert 'id="inlineRelationType"' in template + assert 'id="inlineRelatedCaseUser"' in template + assert 'id="inlineRelatedCaseGroup"' in template + assert 'async function saveInlineRelation()' in template + assert "const caseResponse = await fetch('/api/v1/sag'" in template + assert 'målsag_id: newCase.id' in template + assert 'placeholder="Sag #"' not in template + assert 'Skriv et gyldigt sagsnummer.' not in template + assert 'ansvarlig_bruger_id: responsibleUserId' in template + assert 'assigned_group_id: assignedGroupId' in template + assert "node.case.ansvarlig_navn or 'Ingen ansvarlig'" in template + assert "node.case.gruppe_navn or 'Ingen gruppe'" in template + assert 'class="btn btn-outline-primary btn-rel-action rel-related-add-btn"' in template + assert 'onclick=\'openRelQaMenu({{ node.case.id }}' in template + assert '.compact-relations-table .btn-rel-action.rel-related-add-btn' in template + assert 'href="/sag/{{ node.case.id }}/v4" class="compact-relation-id"' in template + assert '' in template + + +def test_v4_embeds_case_planner_with_direct_subcase_creation(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + + assert 'id="planner-tab" data-bs-target="#planner"' in template + assert 'id="casePlannerAllocationForm"' in template + assert 'id="casePlannerSubcaseForm"' in template + assert "async function createEmbeddedCaseAllocation" in template + assert "async function createPlannerSubcase" in template + assert "relationstype:'undersag'" in template + assert "`/api/v1/planner/cases/${caseIds}/allocations`" in template + assert 'id="casePlannerDeferredNotice"' in template + assert "Venter på sag #${deferred.case_id}" in template + assert "subcase.deferred_until_case_id" in template + assert "window.loadEmbeddedCasePlanner?.();" in template + assert 'id="casePlannerWeek"' in template + assert "function renderEmbeddedPlannerCalendar()" in template + assert "function shiftEmbeddedPlannerWeek(direction)" in template + assert "Klik på en dag for at allokere tid" in template + assert 'id="casePlannerSlotModal"' in template + assert "function openEmbeddedPlannerSlot(dateKey)" in template + assert "async function saveEmbeddedPlannerSlot(event)" in template + assert "Dette er planlagt tid" in template + assert "Der oprettes ingen tidsregistrering" in template + assert "konfliktadvarsel før overbooking" in template + + +def test_v4_comment_source_filters_start_off_and_use_compact_icons(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + + for filter_id in ("Email", "Sms", "Call", "Module"): + marker = f'id="commentSourceFilter{filter_id}"' + line = next(line for line in template.splitlines() if marker in line) + assert "checked" not in line + for icon in ("bi-envelope", "bi-chat-dots", "bi-telephone", "bi-grid"): + assert icon in template + assert ".comment-category-check:has(input:checked)" in template + assert "const sourceFilterIsActive = selectedSources.size > 0;" in template + assert "const matchesSource = !sourceFilterIsActive || selectedSources.has(source);" in template + + +def test_legacy_v3_route_renders_the_active_v4_template(): + views = Path("app/modules/sag/frontend/views.py").read_text() + + assert 'template_name = "modules/sag/templates/detail_v4.html"' in views + assert 'else "modules/sag/templates/detail_v3.html"' not in views + + def test_support_case_close_without_time_requires_explicit_confirmation(): template = Path("app/modules/sag/templates/index.html").read_text() source = Path("app/modules/sag/backend/router.py").read_text() @@ -470,6 +631,22 @@ def test_support_case_close_without_time_requires_explicit_confirmation(): assert "await confirmCloseCaseWithoutTime(caseId, detail.message)" in template +def test_v4_uses_fixed_case_statuses_and_blocks_unfinished_time_on_close(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + views = Path("app/modules/sag/frontend/views.py").read_text() + source = Path("app/modules/sag/backend/router.py").read_text() + + expected = '["Ny", "Tildelt", "Igangværende", "Afventer", "Løst", "Kunde svaret", "Lukket"]' + assert f"return {expected}" in views + assert "case_close_blocked_unfinished_time" in source + assert "entry_status" in source + assert "<> 'godkendt'" in source + assert "slut_tid IS NULL" in source + assert "const closesCase = nextStatus.toLowerCase() === 'lukket';" in template + assert "Sagen kan kun lukkes, når alle tidsregistreringer er færdigbehandlede" in template + assert "showToast(e.message || 'Kunne ikke gemme status', 'danger');" in template + + def test_sag_list_has_smart_toolbar_search_and_employee_quick_filters(): template = Path("app/modules/sag/templates/index.html").read_text() source = Path("app/modules/sag/backend/router.py").read_text() diff --git a/tests/test_timetracking_manual_time.py b/tests/test_timetracking_manual_time.py index 343750f..465ecc8 100644 --- a/tests/test_timetracking_manual_time.py +++ b/tests/test_timetracking_manual_time.py @@ -76,6 +76,43 @@ def test_manual_time_bulk_resolves_current_user_and_removes_duplicates(monkeypat assert result["count"] == 2 +def test_manual_internal_time_can_be_created_without_case_customer(monkeypatch): + captured = {} + monkeypatch.setattr(timetracking_router, "_resolve_case_customer_id", lambda *_args: None) + + def fake_execute_query(_query, params): + captured["params"] = params + return [{"id": 1, "customer_id": None, "billable": False}] + + monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query) + + result = asyncio.run( + timetracking_router.create_manual_time_v1( + { + "sag_id": 177, + "medarbejder_ids": [None], + "faktisk_tid_min": 15, + "entry_status": "kladde", + }, + current_user={"id": 9, "username": "tester"}, + ) + ) + + assert result["count"] == 1 + assert captured["params"][2] is None + assert captured["params"][8] is False + assert captured["params"][9] == "internal" + assert captured["params"][13] == 0 + + +def test_customerless_time_recommends_internal_settlement(): + result = timetracking_router._settlement_options_for_customer(None) + + assert result["recommended"]["method"] == "internal" + assert result["prepaid_cards"] == [] + assert result["agreements"] == [] + + def test_case_time_form_sends_local_time_and_multiple_employees(): template = Path("app/modules/sag/templates/detail_v3.html").read_text() manual_form_script = template.split("async function createManualTimeV1(event)", 1)[1].split(