From bc504b925763a4fa66202a9a3f7cf138bf68d27f Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 30 Mar 2026 07:50:15 +0200 Subject: [PATCH] feat: Add subscription management functionality and AnyDesk API integration - Implemented subscription creation, updating, and rendering in script_9.js. - Added functions for handling subscription line items, product selection, and total calculations. - Integrated AnyDesk API for session management in test_anydesk.py. - Created REST client test requests for API endpoints in api.http. - Developed a script to check ESET machine status and save details in tmp_check_eset_machine.py. --- .env.example | 13 + .env.prod.example | 14 + add_css.py | 142 ++ app/anydesk/backend/views.py | 14 + app/anydesk/frontend/sessions.html | 1165 +++++++++ app/auth/backend/router.py | 99 + app/core/config.py | 16 +- app/jobs/eset_sync.py | 87 + app/modules/hardware/backend/router.py | 208 +- .../hardware/templates/eset_import.html | 70 +- app/modules/links/README.md | 16 + app/modules/links/__init__.py | 8 + app/modules/links/backend/__init__.py | 0 app/modules/links/backend/router.py | 279 ++ app/modules/links/backend/service.py | 229 ++ app/modules/links/frontend/__init__.py | 0 app/modules/links/frontend/views.py | 17 + app/modules/links/jobs/__init__.py | 0 app/modules/links/jobs/dead_link_check.py | 18 + app/modules/links/models/__init__.py | 0 app/modules/links/models/schemas.py | 123 + app/modules/links/templates/index.html | 19 + app/modules/sag/backend/router.py | 389 ++- app/modules/sag/templates/create.html | 19 +- app/modules/sag/templates/detail.html | 1856 ++++++++++++-- app/modules/search/backend/router.py | 18 +- app/modules/telefoni/backend/router.py | 14 +- app/modules/telefoni/backend/service.py | 59 +- app/modules/telefoni/backend/utils.py | 15 + app/routers/anydesk.py | 467 +++- app/services/anydesk.py | 261 +- app/services/email_service.py | 11 +- app/services/email_workflow_service.py | 11 +- app/settings/backend/router.py | 23 +- app/settings/frontend/settings.html | 131 + app/shared/frontend/base.html | 122 +- app/timetracking/backend/models.py | 4 +- app/timetracking/backend/router.py | 116 +- apply_migration_150.py | 22 + final_wc.txt | 1 + fix_domcontent.py | 18 + fix_getTimeV1EmployeeId.py | 19 + fix_js2.py | 0 fix_tab.py | 13 + fix_time_modal.py | 22 + fix_timeline_clean.py | 223 ++ fix_timeline_colors.py | 195 ++ get_js.py | 9 + get_saveTime.py | 6 + main.py | 23 + migrations/152_users_profile_fields.sql | 7 + migrations/153_user_anydesk_ids.sql | 21 + migrations/154_links_endpoints_module.sql | 119 + migrations/155_links_permissions.sql | 42 + old_js.txt | 54 + parse_html.py | 33 +- parse_test.py | 82 +- patch_detail.py | 323 +++ patch_everything.py | 741 ++++++ patch_time_form.py | 207 ++ patch_time_modal.py | 171 ++ patcher.py | 1 + print_saveTime.py | 9 + result.txt | 1 + run_anydesk_import.py | 15 + script_0.js | 43 + script_1.js | 1433 +++++++++++ script_10.js | 918 +++++++ script_11.js | 186 ++ script_2.js | 578 +++++ script_3.js | 208 ++ script_4.js | 356 +++ script_5.js | 344 +++ script_6.js | 235 ++ script_7.js | 3 + script_8.js | 2261 +++++++++++++++++ script_9.js | 544 ++++ static/js/sms.js | 13 + test_anydesk.py | 45 + tests/api.http | 178 ++ tmp_check_eset_machine.py | 112 + 81 files changed, 15355 insertions(+), 532 deletions(-) create mode 100644 add_css.py create mode 100644 app/anydesk/backend/views.py create mode 100644 app/anydesk/frontend/sessions.html create mode 100644 app/modules/links/README.md create mode 100644 app/modules/links/__init__.py create mode 100644 app/modules/links/backend/__init__.py create mode 100644 app/modules/links/backend/router.py create mode 100644 app/modules/links/backend/service.py create mode 100644 app/modules/links/frontend/__init__.py create mode 100644 app/modules/links/frontend/views.py create mode 100644 app/modules/links/jobs/__init__.py create mode 100644 app/modules/links/jobs/dead_link_check.py create mode 100644 app/modules/links/models/__init__.py create mode 100644 app/modules/links/models/schemas.py create mode 100644 app/modules/links/templates/index.html create mode 100644 apply_migration_150.py create mode 100644 final_wc.txt create mode 100644 fix_domcontent.py create mode 100644 fix_getTimeV1EmployeeId.py create mode 100644 fix_js2.py create mode 100644 fix_tab.py create mode 100644 fix_time_modal.py create mode 100644 fix_timeline_clean.py create mode 100644 fix_timeline_colors.py create mode 100644 get_js.py create mode 100644 get_saveTime.py create mode 100644 migrations/152_users_profile_fields.sql create mode 100644 migrations/153_user_anydesk_ids.sql create mode 100644 migrations/154_links_endpoints_module.sql create mode 100644 migrations/155_links_permissions.sql create mode 100644 old_js.txt create mode 100644 patch_detail.py create mode 100644 patch_everything.py create mode 100644 patch_time_form.py create mode 100644 patch_time_modal.py create mode 100644 patcher.py create mode 100644 print_saveTime.py create mode 100644 result.txt create mode 100644 run_anydesk_import.py create mode 100644 script_0.js create mode 100644 script_1.js create mode 100644 script_10.js create mode 100644 script_11.js create mode 100644 script_2.js create mode 100644 script_3.js create mode 100644 script_4.js create mode 100644 script_5.js create mode 100644 script_6.js create mode 100644 script_7.js create mode 100644 script_8.js create mode 100644 script_9.js create mode 100644 test_anydesk.py create mode 100644 tests/api.http create mode 100644 tmp_check_eset_machine.py diff --git a/.env.example b/.env.example index a808ac3..75d12ef 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,19 @@ NEXTCLOUD_CACHE_TTL_SECONDS=300 # Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" NEXTCLOUD_ENCRYPTION_KEY= +# ===================================================== +# Links / Endpoints Module (Optional) +# ===================================================== +LINKS_MODULE_ENABLED=false +LINKS_READ_ONLY=true +LINKS_DRY_RUN=true +LINKS_DEAD_LINK_CHECK_ENABLED=true +LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60 + +# Vaultwarden (Bitwarden-compatible) +VAULTWARDEN_BASE_URL= +VAULTWARDEN_API_TOKEN= + # ===================================================== # vTiger Cloud Integration (Required for Subscriptions) # ===================================================== diff --git a/.env.prod.example b/.env.prod.example index 5f8a61f..608a28b 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -76,3 +76,17 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here # VIGTIGT: Brug kun 'true' eller 'false' uden kommentarer på samme linje ECONOMIC_READ_ONLY=true ECONOMIC_DRY_RUN=true + +# ===================================================== +# Links / Endpoints Module - Production (Optional) +# ===================================================== +# Start disabled; enable after migration + validation +LINKS_MODULE_ENABLED=false +LINKS_READ_ONLY=true +LINKS_DRY_RUN=true +LINKS_DEAD_LINK_CHECK_ENABLED=true +LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60 + +# Vaultwarden (Bitwarden-compatible) +VAULTWARDEN_BASE_URL= +VAULTWARDEN_API_TOKEN= diff --git a/add_css.py b/add_css.py new file mode 100644 index 0000000..650d2d8 --- /dev/null +++ b/add_css.py @@ -0,0 +1,142 @@ +with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f: + text = f.read() + +css_start = text.find(' +{% endblock %} + +{% block content %} +
+ + + + + +
+
Sessions i alt
+
Uden sag/kontakt
+
Total tid (min)
+
Unikke enheder
+
+ + +
+ + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + +
TidspunktVarighedRemote IDTeknikkerHardwareKundeKontaktSagStatus
Indlæser…
+
+ +
+ + +
+ + +
+ + +
+
+
+ Session + +
+
+ +
+ + +
+
+ Tidspunkt + +
+
+ Varighed + +
+
+ Maskine-ID + +
+
+ Teknikker + +
+
+ + +
+ + + + + + + + +
+ + + + +
+ + +
+ + + + +
+ + +
+ + + + +
+ + +
+ +
+ +
+
+ +
+ + +
+ + +
+
+ + + +
+ + +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/auth/backend/router.py b/app/auth/backend/router.py index 109cb63..5071f7a 100644 --- a/app/auth/backend/router.py +++ b/app/auth/backend/router.py @@ -7,6 +7,7 @@ from typing import Optional from app.core.auth_service import AuthService from app.core.config import settings from app.core.auth_dependencies import get_current_user +from app.core.database import execute_query import logging logger = logging.getLogger(__name__) @@ -207,3 +208,101 @@ async def disable_2fa( ) return {"message": "2FA disabled"} + + +# ─── User Profile ───────────────────────────────────────────────────────────── + +class UserProfileUpdate(BaseModel): + full_name: Optional[str] = None + phone: Optional[str] = None + title: Optional[str] = None + anydesk_id: Optional[str] = None + + +@router.get("/me/profile") +async def get_my_profile(current_user: dict = Depends(get_current_user)): + """Get current user's extended profile fields""" + rows = execute_query( + "SELECT full_name, phone, title, anydesk_id FROM users WHERE user_id = %s", + (current_user["id"],) + ) + if not rows: + raise HTTPException(status_code=404, detail="User not found") + return dict(rows[0]) + + +@router.patch("/me/profile") +async def update_my_profile( + payload: UserProfileUpdate, + current_user: dict = Depends(get_current_user) +): + """Update current user's profile fields""" + fields = [] + values = [] + + if payload.full_name is not None: + fields.append("full_name = %s") + values.append(payload.full_name.strip() or None) + if payload.phone is not None: + fields.append("phone = %s") + values.append(payload.phone.strip() or None) + if payload.title is not None: + fields.append("title = %s") + values.append(payload.title.strip() or None) + if payload.anydesk_id is not None: + fields.append("anydesk_id = %s") + values.append(payload.anydesk_id.strip() or None) + + if not fields: + raise HTTPException(status_code=400, detail="No fields to update") + + fields.append("updated_at = NOW()") + values.append(current_user["id"]) + + execute_query( + f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s", + tuple(values) + ) + return {"message": "Profil opdateret"} + + +# ─── User AnyDesk IDs (multiple per technician) ─────────────────────────────── + +class AnyDeskIdAdd(BaseModel): + anydesk_id: str + label: Optional[str] = None + + +@router.get("/me/anydesk-ids") +async def get_my_anydesk_ids(current_user: dict = Depends(get_current_user)): + rows = execute_query( + "SELECT id, anydesk_id, label, created_at FROM user_anydesk_ids WHERE user_id = %s ORDER BY created_at", + (current_user["id"],) + ) + return {"ids": [dict(r) for r in (rows or [])]} + + +@router.post("/me/anydesk-ids", status_code=201) +async def add_my_anydesk_id(payload: AnyDeskIdAdd, current_user: dict = Depends(get_current_user)): + ad_id = payload.anydesk_id.strip() + if not ad_id: + raise HTTPException(status_code=400, detail="anydesk_id cannot be empty") + try: + execute_query( + "INSERT INTO user_anydesk_ids (user_id, anydesk_id, label) VALUES (%s, %s, %s)", + (current_user["id"], ad_id, payload.label or None) + ) + except Exception: + raise HTTPException(status_code=409, detail="AnyDesk ID allerede tilføjet") + return {"message": "Tilføjet"} + + +@router.delete("/me/anydesk-ids/{entry_id}") +async def delete_my_anydesk_id(entry_id: int, current_user: dict = Depends(get_current_user)): + rows = execute_query( + "DELETE FROM user_anydesk_ids WHERE id = %s AND user_id = %s RETURNING id", + (entry_id, current_user["id"]) + ) + if not rows: + raise HTTPException(status_code=404, detail="Ikke fundet") + return {"message": "Slettet"} diff --git a/app/core/config.py b/app/core/config.py index c624aa4..4d83094 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -70,6 +70,17 @@ class Settings(BaseSettings): NEXTCLOUD_CACHE_TTL_SECONDS: int = 300 NEXTCLOUD_ENCRYPTION_KEY: str = "" + # Links / Endpoints Module + LINKS_MODULE_ENABLED: bool = False + LINKS_READ_ONLY: bool = True + LINKS_DRY_RUN: bool = True + LINKS_DEAD_LINK_CHECK_ENABLED: bool = True + LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES: int = 60 + + # Vaultwarden (Bitwarden-compatible) + VAULTWARDEN_BASE_URL: str = "" + VAULTWARDEN_API_TOKEN: str = "" + # Wiki.js Integration WIKI_BASE_URL: str = "https://wiki.bmcnetworks.dk" WIKI_API_TOKEN: str = "" @@ -227,9 +238,10 @@ class Settings(BaseSettings): REMINDERS_QUEUE_BATCH_SIZE: int = 10 # AnyDesk Remote Support Integration + ANYDESK_API_URL: str = "https://v1.api.anydesk.com:8081" # AnyDesk REST API base URL ANYDESK_LICENSE_ID: str = "" - ANYDESK_API_TOKEN: str = "" - ANYDESK_PASSWORD: str = "" + ANYDESK_API_TOKEN: str = "" # API Password (HMAC-SHA1, not Bearer) from my.anydesk.com + ANYDESK_PASSWORD: str = "" # Alias for ANYDESK_API_TOKEN ANYDESK_READ_ONLY: bool = True # SAFETY: Prevent API calls if true ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls ANYDESK_TIMEOUT_SECONDS: int = 30 diff --git a/app/jobs/eset_sync.py b/app/jobs/eset_sync.py index 3a86ead..a68bf9c 100644 --- a/app/jobs/eset_sync.py +++ b/app/jobs/eset_sync.py @@ -79,6 +79,35 @@ def _extract_full_name(payload: Any) -> Optional[str]: return None +def _extract_login_candidates(payload: Any) -> List[str]: + raw = _extract_first_str( + payload, + ["userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser"] + ) + if not raw: + return [] + + candidates: List[str] = [] + + def _add(value: str) -> None: + v = (value or "").strip().lower() + if v and v not in candidates: + candidates.append(v) + + _add(raw) + # DOMAIN\\user or provider/user -> user + if "\\" in raw: + _add(raw.split("\\")[-1]) + if "/" in raw: + _add(raw.split("/")[-1]) + + # email local-part fallback + if "@" in raw: + _add(raw.split("@", 1)[0]) + + return candidates + + def _detect_asset_type(payload: Any) -> str: device_type = _extract_first_str(payload, ["deviceType", "type"]) if device_type: @@ -104,6 +133,57 @@ def _match_contact(full_name: str, company: str) -> Optional[int]: return None +def _match_contact_by_login(login_candidate: str, company: Optional[str] = None) -> Optional[int]: + if not login_candidate: + return None + + # Try scoped match first when company is known to reduce false positives. + if company: + scoped_query = """ + SELECT id + FROM contacts + WHERE LOWER(COALESCE(email, '')) = LOWER(%s) + AND LOWER(COALESCE(user_company, '')) = LOWER(%s) + LIMIT 1 + """ + scoped = execute_query(scoped_query, (login_candidate, company)) + if scoped: + return scoped[0]["id"] + + scoped_local_part_query = """ + SELECT id + FROM contacts + WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s) + AND LOWER(COALESCE(user_company, '')) = LOWER(%s) + LIMIT 1 + """ + scoped_local_part = execute_query(scoped_local_part_query, (login_candidate, company)) + if scoped_local_part: + return scoped_local_part[0]["id"] + + email_query = """ + SELECT id + FROM contacts + WHERE LOWER(COALESCE(email, '')) = LOWER(%s) + LIMIT 1 + """ + by_email = execute_query(email_query, (login_candidate,)) + if by_email: + return by_email[0]["id"] + + local_part_query = """ + SELECT id + FROM contacts + WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s) + LIMIT 1 + """ + by_local_part = execute_query(local_part_query, (login_candidate,)) + if by_local_part: + return by_local_part[0]["id"] + + return None + + def _get_contact_customer(contact_id: int) -> Optional[int]: query = """ SELECT customer_id @@ -213,7 +293,14 @@ async def sync_eset_hardware() -> None: full_name = _extract_full_name(details) company = _extract_company(details) + login_candidates = _extract_login_candidates(details) + contact_id = _match_contact(full_name, company) if full_name and company else None + if not contact_id: + for login_candidate in login_candidates: + contact_id = _match_contact_by_login(login_candidate, company) + if contact_id: + break customer_id = _get_contact_customer(contact_id) if contact_id else None if not customer_id: customer_id = _match_customer_exact(group_name or company) if (group_name or company) else None diff --git a/app/modules/hardware/backend/router.py b/app/modules/hardware/backend/router.py index ecca455..238e9d1 100644 --- a/app/modules/hardware/backend/router.py +++ b/app/modules/hardware/backend/router.py @@ -55,6 +55,90 @@ def _eset_extract_company(payload: dict) -> Optional[str]: return None +def _eset_extract_login_candidates(payload: dict) -> List[str]: + raw = _eset_extract_first_str( + payload, + ["userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser"] + ) + if not raw: + return [] + + candidates: List[str] = [] + + def _add(value: str) -> None: + v = (value or "").strip().lower() + if v and v not in candidates: + candidates.append(v) + + _add(raw) + if "\\" in raw: + _add(raw.split("\\")[-1]) + if "/" in raw: + _add(raw.split("/")[-1]) + if "@" in raw: + _add(raw.split("@", 1)[0]) + + return candidates + + +def _match_contact_by_login(login_candidate: str, company: Optional[str] = None) -> Optional[int]: + if not login_candidate: + return None + + if company: + scoped = execute_query( + """ + SELECT id + FROM contacts + WHERE LOWER(COALESCE(email, '')) = LOWER(%s) + AND LOWER(COALESCE(user_company, '')) = LOWER(%s) + LIMIT 1 + """, + (login_candidate, company), + ) + if scoped: + return scoped[0]["id"] + + scoped_local_part = execute_query( + """ + SELECT id + FROM contacts + WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s) + AND LOWER(COALESCE(user_company, '')) = LOWER(%s) + LIMIT 1 + """, + (login_candidate, company), + ) + if scoped_local_part: + return scoped_local_part[0]["id"] + + by_email = execute_query( + """ + SELECT id + FROM contacts + WHERE LOWER(COALESCE(email, '')) = LOWER(%s) + LIMIT 1 + """, + (login_candidate,), + ) + if by_email: + return by_email[0]["id"] + + by_local_part = execute_query( + """ + SELECT id + FROM contacts + WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s) + LIMIT 1 + """, + (login_candidate,), + ) + if by_local_part: + return by_local_part[0]["id"] + + return None + + def _eset_detect_asset_type(payload: dict) -> str: device_type = _eset_extract_first_str(payload, ["deviceType", "type"]) if device_type: @@ -89,6 +173,23 @@ def _get_contact_customer(contact_id: int) -> Optional[int]: return None +def _match_contact_by_name_and_company(full_name: str, company: str) -> Optional[int]: + if not full_name or not company: + return None + + query = """ + SELECT id + FROM contacts + WHERE LOWER(TRIM(first_name || ' ' || last_name)) = LOWER(%s) + AND LOWER(COALESCE(user_company, '')) = LOWER(%s) + LIMIT 1 + """ + result = execute_query(query, (full_name, company)) + if result: + return result[0]["id"] + return None + + def _upsert_hardware_contact(hardware_id: int, contact_id: int) -> None: query = """ INSERT INTO hardware_contacts (hardware_id, contact_id, role, source) @@ -172,34 +273,39 @@ async def list_hardware_by_contact(contact_id: int): """ result_new = execute_query(query_new, (contact_id,)) - # Also check legacy hardware table via customer_id (if contact has companies) - query_legacy = """ - SELECT DISTINCT + # Also look up hardware_assets by the contact's company (customer link) + query_by_customer = """ + SELECT DISTINCT h.id, - NULL as asset_type, - NULL as brand, + h.asset_type, + h.brand, h.model, h.serial_number, - NULL as anydesk_id, - NULL as anydesk_link, - 'active' as status, - NULL as notes, + h.anydesk_id, + h.anydesk_link, + h.status, + h.notes, h.created_at, - 'hardware' as source_table - FROM hardware h - WHERE h.customer_id IN ( - SELECT customer_id - FROM contact_companies + 'hardware_assets' as source_table + FROM hardware_assets h + WHERE h.current_owner_customer_id IN ( + SELECT customer_id + FROM contact_companies WHERE contact_id = %s ) AND h.deleted_at IS NULL ORDER BY h.created_at DESC """ - result_legacy = execute_query(query_legacy, (contact_id,)) - - # Merge results, prioritizing new table - all_results = (result_new or []) + (result_legacy or []) - + result_customer = execute_query(query_by_customer, (contact_id,)) + + # Merge: hardware_contacts first (direct link), then customer-linked, dedup by id + seen = set() + all_results = [] + for item in (result_new or []) + (result_customer or []): + if item["id"] not in seen: + seen.add(item["id"]) + all_results.append(item) + return all_results @@ -828,6 +934,60 @@ async def test_eset_device(device_uuid: str = Query(..., min_length=1)): return details +@router.get("/hardware/eset/test-one-pc-full", response_model=dict) +async def test_eset_one_pc_full(include_raw: bool = Query(False)): + """Fetch one device from ESET and return full parsed test payload including software list.""" + payload = await eset_service.list_devices(page_size=1) + if not payload: + raise HTTPException(status_code=404, detail="No devices returned from ESET") + + devices = payload.get("devices") or payload.get("items") or payload.get("results") or payload.get("data") or [] + if not devices: + raise HTTPException(status_code=404, detail="No devices found in ESET list") + + first_device = devices[0] + device_uuid = ( + first_device.get("deviceUuid") + or first_device.get("uuid") + or first_device.get("id") + or "" + ) + if not device_uuid: + raise HTTPException(status_code=404, detail="No device UUID found on first ESET device") + + details = await eset_service.get_device_details(device_uuid) + if not details: + raise HTTPException(status_code=404, detail="Device details not found in ESET") + + software = eset_service.extract_installed_software(details) + identifier_fields = [ + "userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser", "owner", "ownerUuid" + ] + identifier_candidates = [] + for field_name in identifier_fields: + value = _eset_extract_first_str(details, [field_name]) + if value and value not in identifier_candidates: + identifier_candidates.append(value) + + user_identifier = identifier_candidates[0] if identifier_candidates else None + + response = { + "device_uuid": device_uuid, + "device_name": _eset_extract_first_str(details, ["displayName", "deviceName", "name"]), + "user_identifier": user_identifier, + "group": _eset_extract_group_path(details), + "serial": _eset_extract_first_str(details, ["serialNumber", "serial", "serial_number"]), + "identifier_candidates": identifier_candidates, + "installed_software_count": len(software), + "installed_software": software, + } + + if include_raw: + response["raw"] = details + + return response + + @router.get("/hardware/eset/devices", response_model=dict) async def list_eset_devices( page_size: Optional[int] = Query(None, ge=1, le=1000), @@ -859,12 +1019,22 @@ async def import_eset_device(data: dict): group_path = _eset_extract_group_path(details) group_name = _eset_extract_group_name(details) company = _eset_extract_company(details) + login_candidates = _eset_extract_login_candidates(details) + full_name = _eset_extract_first_str(details, ["realName", "displayName", "userName", "owner", "user", "lastLoggedInUser"]) if contact_id: contact_check = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,)) if not contact_check: raise HTTPException(status_code=404, detail="Contact not found") + if not contact_id: + contact_id = _match_contact_by_name_and_company(full_name, company) + if not contact_id: + for login_candidate in login_candidates: + contact_id = _match_contact_by_login(login_candidate, company) + if contact_id: + break + customer_id = _get_contact_customer(contact_id) if contact_id else None if not customer_id: customer_id = _match_customer_exact(group_name or company) diff --git a/app/modules/hardware/templates/eset_import.html b/app/modules/hardware/templates/eset_import.html index bc64b32..4873049 100644 --- a/app/modules/hardware/templates/eset_import.html +++ b/app/modules/hardware/templates/eset_import.html @@ -169,15 +169,18 @@
Ingen data indlaest
+
+
+ @@ -186,7 +189,7 @@ - +
NavnBruger/ID Serial Gruppe Device UUID
Klik "Hent devices" for at hente ESET-listen.Klik "Hent devices" for at hente ESET-listen.
@@ -279,9 +282,42 @@ return ''; } + function getNestedField(obj, keys) { + if (!obj || typeof obj !== 'object') return ''; + const keySet = new Set((keys || []).map(k => String(k).toLowerCase())); + const stack = [obj]; + + while (stack.length) { + const current = stack.pop(); + if (Array.isArray(current)) { + current.forEach(item => { + if (item && typeof item === 'object') stack.push(item); + }); + continue; + } + if (!current || typeof current !== 'object') continue; + + for (const [k, v] of Object.entries(current)) { + if (keySet.has(String(k).toLowerCase()) && (typeof v === 'string' || typeof v === 'number')) { + const value = String(v).trim(); + if (value) return value; + } + if (v && typeof v === 'object') stack.push(v); + } + } + + return ''; + } + + function getUserIdentifier(device) { + return getNestedField(device, [ + 'userPrincipalName', 'upn', 'email', 'mail', 'loginName', 'login', 'userName', 'lastLoggedInUser', 'owner', 'ownerUuid' + ]); + } + function renderDevices(devices) { if (!devices.length) { - devicesTable.innerHTML = 'Ingen devices fundet.'; + devicesTable.innerHTML = 'Ingen devices fundet.'; if (devicesCards) { devicesCards.innerHTML = '
Ingen devices fundet.
'; } @@ -291,12 +327,14 @@ devicesTable.innerHTML = devices.map(device => { const uuid = getField(device, ['deviceUuid', 'uuid', 'id']); const name = getField(device, ['displayName', 'deviceName', 'name']); + const login = getUserIdentifier(device); const serial = getField(device, ['serialNumber', 'serial', 'serial_number']); const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']); return ` ${name || '-'} + ${login || '-'} ${serial || '-'} ${group || '-'} ${uuid || '-'} @@ -311,15 +349,18 @@ devicesCards.innerHTML = devices.map((device, index) => { const uuid = getField(device, ['deviceUuid', 'uuid', 'id']); const name = getField(device, ['displayName', 'deviceName', 'name']); + const login = getUserIdentifier(device); const serial = getField(device, ['serialNumber', 'serial', 'serial_number']); const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']); const safeName = name || '-'; + const safeLogin = login || '-'; const safeSerial = serial || '-'; const safeGroup = group || '-'; const safeUuid = uuid || ''; return `
${safeName}
+
Bruger/ID: ${safeLogin}
Serial: ${safeSerial}
Gruppe: ${safeGroup}
UUID: ${safeUuid || '-'}
@@ -481,7 +522,30 @@ renderDevices(allDevices); } catch (err) { deviceStatus.textContent = 'Fejl ved hentning'; - devicesTable.innerHTML = `${err.message}`; + devicesTable.innerHTML = `${err.message}`; + } + } + + async function runOnePcFullTest() { + const statusEl = document.getElementById('onePcTestStatus'); + if (statusEl) statusEl.textContent = 'Korer test...'; + + try { + const response = await fetch('/api/v1/hardware/eset/test-one-pc-full?include_raw=true'); + if (!response.ok) { + const err = await response.text(); + throw new Error(err || 'Request failed'); + } + const data = await response.json(); + const identifier = data.user_identifier || '-'; + const softwareCount = Number(data.installed_software_count || 0); + const firstSoftware = (data.installed_software || []).slice(0, 5).join(', '); + const summary = `Test OK. UUID: ${data.device_uuid || '-'} | Login: ${identifier} | Software: ${softwareCount}${firstSoftware ? ` | Eksempel: ${firstSoftware}` : ''}`; + + if (statusEl) statusEl.textContent = summary; + console.log('ESET one-PC full test', data); + } catch (err) { + if (statusEl) statusEl.textContent = `Test fejlede: ${err.message}`; } } diff --git a/app/modules/links/README.md b/app/modules/links/README.md new file mode 100644 index 0000000..c682cad --- /dev/null +++ b/app/modules/links/README.md @@ -0,0 +1,16 @@ +# Links Module + +Removable operational access layer module. + +## Enable +- Set `LINKS_MODULE_ENABLED=true` in `.env` +- Run migrations `154_links_endpoints_module.sql` and `155_links_permissions.sql` + +## Disable (soft remove) +- Set `LINKS_MODULE_ENABLED=false` +- Restart API + +## Remove (hard) +1. Soft-remove first. +2. Export required data from links tables. +3. Drop module tables (`links`, `link_categories`, `link_category_map`, `link_runbooks`, `link_runbook_steps`, `link_status_checks`, `link_access_log`, `links_audit_log`). diff --git a/app/modules/links/__init__.py b/app/modules/links/__init__.py new file mode 100644 index 0000000..1a149b9 --- /dev/null +++ b/app/modules/links/__init__.py @@ -0,0 +1,8 @@ +""" +Links Module - Operational access layer +""" + +MODULE_NAME = "links" +MODULE_DISPLAY_NAME = "Links / Endpoints" +MODULE_ICON = "bi-link-45deg" +MODULE_DESCRIPTION = "Context-aware operational links and endpoint actions" diff --git a/app/modules/links/backend/__init__.py b/app/modules/links/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/links/backend/router.py b/app/modules/links/backend/router.py new file mode 100644 index 0000000..1c0c688 --- /dev/null +++ b/app/modules/links/backend/router.py @@ -0,0 +1,279 @@ +import json +import logging +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.core.auth_dependencies import get_current_user, require_permission +from app.core.database import execute_query +from app.modules.links.backend.service import ( + build_action_result, + get_link_category_ids, + get_relevant_links, + log_access, + update_link_categories, +) +from app.modules.links.models.schemas import ( + Link, + LinkActionLogCreate, + LinkActionResult, + LinkCategory, + LinkCategoryCreate, + LinkCreate, + LinkUpdate, + RelevantLink, +) + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def _with_categories(link_row: dict) -> dict: + out = dict(link_row) + out["vault_item_ids"] = out.get("vault_item_ids") or [] + out["category_ids"] = get_link_category_ids(int(out["id"])) + return out + + +@router.get("/links/health") +async def links_health(): + execute_query("SELECT 1", ()) + return {"status": "healthy", "service": "links-module"} + + +@router.get("/links/categories", response_model=List[LinkCategory]) +async def list_categories(current_user: dict = Depends(require_permission("links.read"))): + del current_user + rows = execute_query( + "SELECT * FROM link_categories ORDER BY sort_order ASC, name ASC", + (), + ) or [] + return rows + + +@router.post("/links/categories", response_model=LinkCategory) +async def create_category( + payload: LinkCategoryCreate, + current_user: dict = Depends(require_permission("links.create")), +): + del current_user + rows = execute_query( + """ + INSERT INTO link_categories (name, icon, sort_order) + VALUES (%s, %s, %s) + RETURNING * + """, + (payload.name, payload.icon, payload.sort_order), + ) + return rows[0] + + +@router.get("/links", response_model=List[Link]) +async def list_links( + q: Optional[str] = Query(None), + customer_id: Optional[int] = Query(None), + case_id: Optional[int] = Query(None), + hardware_id: Optional[int] = Query(None), + category_id: Optional[int] = Query(None), + is_favorite: Optional[bool] = Query(None), + current_user: dict = Depends(require_permission("links.read")), +): + del current_user + + query = """ + SELECT l.* + FROM links l + WHERE l.deleted_at IS NULL + """ + params: List[object] = [] + + if q: + query += " AND (l.name ILIKE %s OR l.url ILIKE %s OR l.host ILIKE %s)" + term = f"%{q}%" + params.extend([term, term, term]) + if customer_id is not None: + query += " AND l.customer_id = %s" + params.append(customer_id) + if case_id is not None: + query += " AND l.case_id = %s" + params.append(case_id) + if hardware_id is not None: + query += " AND l.hardware_id = %s" + params.append(hardware_id) + if is_favorite is not None: + query += " AND l.is_favorite = %s" + params.append(is_favorite) + if category_id is not None: + query += " AND EXISTS (SELECT 1 FROM link_category_map lcm WHERE lcm.link_id = l.id AND lcm.category_id = %s)" + params.append(category_id) + + query += " ORDER BY l.is_critical DESC, l.updated_at DESC" + rows = execute_query(query, tuple(params) if params else ()) or [] + + return [_with_categories(row) for row in rows] + + +@router.get("/links/{link_id}", response_model=Link) +async def get_link(link_id: int, current_user: dict = Depends(require_permission("links.read"))): + del current_user + rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,)) + if not rows: + raise HTTPException(status_code=404, detail="Link not found") + return _with_categories(rows[0]) + + +@router.post("/links", response_model=Link) +async def create_link(payload: LinkCreate, current_user: dict = Depends(require_permission("links.create"))): + rows = execute_query( + """ + INSERT INTO links ( + name, description, type, url, host, port, username, icon, color, + customer_id, case_id, hardware_id, + vault_item_id, vault_item_ids, + is_critical, is_favorite, environment + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s) + RETURNING * + """, + ( + payload.name, + payload.description, + payload.type.value, + payload.url, + payload.host, + payload.port, + payload.username, + payload.icon, + payload.color, + payload.customer_id, + payload.case_id, + payload.hardware_id, + payload.vault_item_id, + json.dumps(payload.vault_item_ids), + payload.is_critical, + payload.is_favorite, + payload.environment.value, + ), + ) + created = rows[0] + + update_link_categories(int(created["id"]), payload.category_ids) + + execute_query( + """ + INSERT INTO links_audit_log (link_id, event_type, actor_user_id, changes) + VALUES (%s, %s, %s, %s::jsonb) + """, + (created["id"], "created", current_user["id"], json.dumps({"name": payload.name})), + ) + + return _with_categories(created) + + +@router.patch("/links/{link_id}", response_model=Link) +async def update_link( + link_id: int, + payload: LinkUpdate, + current_user: dict = Depends(require_permission("links.update")), +): + fields = payload.model_dump(exclude_unset=True) + category_ids = fields.pop("category_ids", None) + + updates = [] + params: List[object] = [] + + for field_name, value in fields.items(): + if field_name == "type" and value is not None: + updates.append("type = %s") + params.append(value.value) + elif field_name == "environment" and value is not None: + updates.append("environment = %s") + params.append(value.value) + elif field_name == "vault_item_ids" and value is not None: + updates.append("vault_item_ids = %s::jsonb") + params.append(json.dumps(value)) + else: + updates.append(f"{field_name} = %s") + params.append(value) + + if updates: + updates.append("updated_at = NOW()") + params.append(link_id) + query = f"UPDATE links SET {', '.join(updates)} WHERE id = %s AND deleted_at IS NULL RETURNING *" + rows = execute_query(query, tuple(params)) or [] + if not rows: + raise HTTPException(status_code=404, detail="Link not found") + updated = rows[0] + else: + rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,)) + if not rows: + raise HTTPException(status_code=404, detail="Link not found") + updated = rows[0] + + if category_ids is not None: + update_link_categories(link_id, category_ids) + + execute_query( + """ + INSERT INTO links_audit_log (link_id, event_type, actor_user_id, changes) + VALUES (%s, %s, %s, %s::jsonb) + """, + (link_id, "updated", current_user["id"], json.dumps(fields or {"category_ids": category_ids})), + ) + + return _with_categories(updated) + + +@router.delete("/links/{link_id}") +async def delete_link(link_id: int, current_user: dict = Depends(require_permission("links.delete"))): + rows = execute_query( + "UPDATE links SET deleted_at = NOW(), updated_at = NOW() WHERE id = %s AND deleted_at IS NULL RETURNING id", + (link_id,), + ) or [] + if not rows: + raise HTTPException(status_code=404, detail="Link not found") + + execute_query( + """ + INSERT INTO links_audit_log (link_id, event_type, actor_user_id, changes) + VALUES (%s, %s, %s, %s::jsonb) + """, + (link_id, "deleted", current_user["id"], json.dumps({"deleted": True})), + ) + + return {"status": "deleted", "id": link_id} + + +@router.get("/links/cases/{case_id}/relevant", response_model=List[RelevantLink]) +async def case_relevant_links( + case_id: int, + limit: int = Query(50, ge=1, le=200), + current_user: dict = Depends(require_permission("links.read")), +): + del current_user + return get_relevant_links(case_id, limit=limit) + + +@router.post("/links/{link_id}/access", response_model=LinkActionResult) +async def access_link( + link_id: int, + payload: LinkActionLogCreate, + current_user: dict = Depends(require_permission("links.use")), +): + rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,)) or [] + if not rows: + raise HTTPException(status_code=404, detail="Link not found") + + link_row = rows[0] + action_result = build_action_result(link_row, payload.action_type) + + log_access( + link_id=link_id, + user_id=current_user["id"], + action_type=payload.action_type, + case_id=payload.case_id, + customer_id=payload.customer_id, + metadata=payload.metadata, + ) + + return action_result diff --git a/app/modules/links/backend/service.py b/app/modules/links/backend/service.py new file mode 100644 index 0000000..9f710c3 --- /dev/null +++ b/app/modules/links/backend/service.py @@ -0,0 +1,229 @@ +import json +import logging +from typing import Dict, List, Optional + +from app.core.database import execute_query, execute_query_single +from app.modules.links.models.schemas import LinkActionResult, LinkScope, LinkType + +logger = logging.getLogger(__name__) + + +def _get_case(case_id: int) -> Optional[dict]: + return execute_query_single( + "SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", + (case_id,), + ) + + +def _get_case_hardware_ids(case_id: int) -> List[int]: + rows = execute_query( + "SELECT hardware_id FROM sag_hardware WHERE sag_id = %s", + (case_id,), + ) or [] + return [int(row["hardware_id"]) for row in rows if row.get("hardware_id") is not None] + + +def _get_tag_ids_for_entity(entity_type: str, entity_id: int) -> List[int]: + rows = execute_query( + "SELECT tag_id FROM entity_tags WHERE entity_type = %s AND entity_id = %s", + (entity_type, entity_id), + ) or [] + return [int(row["tag_id"]) for row in rows if row.get("tag_id") is not None] + + +def _get_link_tag_map(link_ids: List[int]) -> Dict[int, List[int]]: + if not link_ids: + return {} + + rows = execute_query( + """ + SELECT entity_id AS link_id, tag_id + FROM entity_tags + WHERE entity_type = 'link' + AND entity_id = ANY(%s) + """, + (link_ids,), + ) or [] + + out: Dict[int, List[int]] = {link_id: [] for link_id in link_ids} + for row in rows: + link_id = int(row.get("link_id")) + tag_id = int(row.get("tag_id")) + out.setdefault(link_id, []).append(tag_id) + return out + + +def _get_link_category_map(link_ids: List[int]) -> Dict[int, List[int]]: + if not link_ids: + return {} + + rows = execute_query( + """ + SELECT link_id, category_id + FROM link_category_map + WHERE link_id = ANY(%s) + """, + (link_ids,), + ) or [] + + out: Dict[int, List[int]] = {link_id: [] for link_id in link_ids} + for row in rows: + link_id = int(row.get("link_id")) + category_id = int(row.get("category_id")) + out.setdefault(link_id, []).append(category_id) + return out + + +def _resolve_scope(link_row: dict, case_id: int, case_customer_id: Optional[int], case_hardware_ids: List[int]) -> tuple[LinkScope, int]: + if link_row.get("case_id") == case_id: + return (LinkScope.case, 1) + if case_customer_id and link_row.get("customer_id") == case_customer_id: + return (LinkScope.customer, 2) + if link_row.get("hardware_id") in case_hardware_ids: + return (LinkScope.hardware, 3) + return (LinkScope.global_scope, 4) + + +def get_relevant_links(case_id: int, limit: int = 50) -> List[dict]: + case_row = _get_case(case_id) + if not case_row: + return [] + + case_customer_id = case_row.get("customer_id") + case_hardware_ids = _get_case_hardware_ids(case_id) + case_tag_ids = set(_get_tag_ids_for_entity("case", case_id)) + + candidate_query = """ + SELECT * + FROM links + WHERE deleted_at IS NULL + AND ( + case_id = %s + OR (%s IS NOT NULL AND customer_id = %s) + OR (hardware_id IS NOT NULL AND hardware_id = ANY(%s)) + OR (case_id IS NULL AND customer_id IS NULL AND hardware_id IS NULL) + ) + """ + candidate_rows = execute_query( + candidate_query, + (case_id, case_customer_id, case_customer_id, case_hardware_ids or [0]), + ) or [] + + link_ids = [int(row["id"]) for row in candidate_rows] + link_tag_map = _get_link_tag_map(link_ids) + link_category_map = _get_link_category_map(link_ids) + + scored: List[dict] = [] + for row in candidate_rows: + link_id = int(row["id"]) + link_tags = set(link_tag_map.get(link_id, [])) + matched_tags = sorted(case_tag_ids.intersection(link_tags)) + + scope, scope_priority = _resolve_scope(row, case_id, case_customer_id, case_hardware_ids) + + if not matched_tags and scope != LinkScope.case and not row.get("is_critical"): + continue + + score = 0 + if case_customer_id and row.get("customer_id") == case_customer_id: + score += 3 + if row.get("is_critical"): + score += 2 + score += len(matched_tags) + + row["scope"] = scope.value + row["scope_priority"] = scope_priority + row["score"] = score + row["match_count"] = len(matched_tags) + row["matched_tag_ids"] = matched_tags + row["category_ids"] = link_category_map.get(link_id, []) + scored.append(row) + + scored.sort( + key=lambda item: ( + item["scope_priority"], + -int(item.get("is_critical") is True), + -item["score"], + item.get("name") or "", + ) + ) + return scored[:limit] + + +def update_link_categories(link_id: int, category_ids: List[int]) -> None: + execute_query("DELETE FROM link_category_map WHERE link_id = %s", (link_id,)) + if not category_ids: + return + + values = [] + params: List[int] = [] + for category_id in category_ids: + values.append("(%s, %s)") + params.extend([link_id, category_id]) + + query = f"INSERT INTO link_category_map (link_id, category_id) VALUES {', '.join(values)} ON CONFLICT DO NOTHING" + execute_query(query, tuple(params)) + + +def get_link_category_ids(link_id: int) -> List[int]: + rows = execute_query( + "SELECT category_id FROM link_category_map WHERE link_id = %s ORDER BY category_id", + (link_id,), + ) or [] + return [int(row["category_id"]) for row in rows] + + +def log_access(link_id: int, user_id: Optional[int], action_type: str, case_id: Optional[int], customer_id: Optional[int], metadata: Optional[dict]) -> None: + execute_query( + """ + INSERT INTO link_access_log (link_id, user_id, action_type, case_id, customer_id, metadata) + VALUES (%s, %s, %s, %s, %s, %s::jsonb) + """, + (link_id, user_id, action_type, case_id, customer_id, json.dumps(metadata or {})), + ) + + +def build_action_result(link_row: dict, action_type: str) -> LinkActionResult: + link_type = LinkType(link_row["type"]) + host = link_row.get("host") + port = link_row.get("port") + username = link_row.get("username") + + ssh_command = None + rdp_content = None + command_text = None + open_url = link_row.get("url") + + if link_type == LinkType.ssh: + if host: + base = "ssh" + if username: + base += f" {username}@{host}" + else: + base += f" {host}" + if port: + base += f" -p {port}" + ssh_command = base + + if link_type == LinkType.rdp and host: + rdp_port = port or 3389 + rdp_content = f"full address:s:{host}:{rdp_port}\nusername:s:{username or ''}\nprompt for credentials:i:1\n" + + if link_type == LinkType.command: + command_text = link_row.get("url") or link_row.get("description") or "" + + if link_type in (LinkType.ssh, LinkType.rdp) and not open_url and host: + open_url = host + + return LinkActionResult( + link_id=int(link_row["id"]), + action_type=action_type, + type=link_type, + open_url=open_url, + ssh_command=ssh_command, + rdp_content=rdp_content, + command_text=command_text, + username=username, + vault_item_id=link_row.get("vault_item_id"), + vault_search_hint=host or link_row.get("url") or None, + ) diff --git a/app/modules/links/frontend/__init__.py b/app/modules/links/frontend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/links/frontend/views.py b/app/modules/links/frontend/views.py new file mode 100644 index 0000000..6de37eb --- /dev/null +++ b/app/modules/links/frontend/views.py @@ -0,0 +1,17 @@ +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +logger = logging.getLogger(__name__) +router = APIRouter() +templates = Jinja2Templates(directory="app") + + +@router.get("/links", response_class=HTMLResponse) +async def links_index(request: Request): + return templates.TemplateResponse( + "modules/links/templates/index.html", + {"request": request}, + ) diff --git a/app/modules/links/jobs/__init__.py b/app/modules/links/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/links/jobs/dead_link_check.py b/app/modules/links/jobs/dead_link_check.py new file mode 100644 index 0000000..1621954 --- /dev/null +++ b/app/modules/links/jobs/dead_link_check.py @@ -0,0 +1,18 @@ +import logging + +from app.core.database import execute_query + +logger = logging.getLogger(__name__) + + +async def check_links_health(): + rows = execute_query("SELECT id, type, url, host FROM links WHERE deleted_at IS NULL", ()) or [] + for row in rows: + execute_query( + """ + INSERT INTO link_status_checks (link_id, status, details) + VALUES (%s, %s, %s::jsonb) + """, + (row["id"], "unknown", '{"reason":"initial implementation placeholder"}'), + ) + logger.info("✅ Links health placeholder executed for %s links", len(rows)) diff --git a/app/modules/links/models/__init__.py b/app/modules/links/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/links/models/schemas.py b/app/modules/links/models/schemas.py new file mode 100644 index 0000000..e2eaae9 --- /dev/null +++ b/app/modules/links/models/schemas.py @@ -0,0 +1,123 @@ +from datetime import datetime +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class LinkType(str, Enum): + http = "http" + ssh = "ssh" + rdp = "rdp" + command = "command" + + +class LinkEnvironment(str, Enum): + prod = "prod" + test = "test" + dev = "dev" + + +class LinkScope(str, Enum): + case = "case" + customer = "customer" + hardware = "hardware" + global_scope = "global" + + +class LinkCategoryBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + icon: Optional[str] = Field(default=None, max_length=100) + sort_order: int = 100 + + +class LinkCategoryCreate(LinkCategoryBase): + pass + + +class LinkCategory(LinkCategoryBase): + id: int + created_at: datetime + updated_at: datetime + + +class LinkBase(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + type: LinkType + url: Optional[str] = None + host: Optional[str] = None + port: Optional[int] = Field(default=None, ge=1, le=65535) + username: Optional[str] = None + icon: Optional[str] = None + color: Optional[str] = None + customer_id: Optional[int] = None + case_id: Optional[int] = None + hardware_id: Optional[int] = None + vault_item_id: Optional[str] = None + vault_item_ids: List[str] = Field(default_factory=list) + is_critical: bool = False + is_favorite: bool = False + environment: LinkEnvironment = LinkEnvironment.prod + + +class LinkCreate(LinkBase): + category_ids: List[int] = Field(default_factory=list) + + +class LinkUpdate(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + description: Optional[str] = None + type: Optional[LinkType] = None + url: Optional[str] = None + host: Optional[str] = None + port: Optional[int] = Field(default=None, ge=1, le=65535) + username: Optional[str] = None + icon: Optional[str] = None + color: Optional[str] = None + customer_id: Optional[int] = None + case_id: Optional[int] = None + hardware_id: Optional[int] = None + vault_item_id: Optional[str] = None + vault_item_ids: Optional[List[str]] = None + is_critical: Optional[bool] = None + is_favorite: Optional[bool] = None + environment: Optional[LinkEnvironment] = None + category_ids: Optional[List[int]] = None + + +class Link(LinkBase): + id: int + category_ids: List[int] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + deleted_at: Optional[datetime] = None + + +class RelevantLink(Link): + scope: LinkScope + scope_priority: int + score: int + match_count: int + matched_tag_ids: List[int] = Field(default_factory=list) + category_ids: List[int] = Field(default_factory=list) + + +class LinkActionLogCreate(BaseModel): + action_type: str = Field(..., min_length=1, max_length=50) + case_id: Optional[int] = None + customer_id: Optional[int] = None + metadata: Optional[dict] = None + + +class LinkActionResult(BaseModel): + link_id: int + action_type: str + type: LinkType + open_url: Optional[str] = None + ssh_command: Optional[str] = None + rdp_content: Optional[str] = None + command_text: Optional[str] = None + username: Optional[str] = None + vault_item_id: Optional[str] = None + vault_search_hint: Optional[str] = None diff --git a/app/modules/links/templates/index.html b/app/modules/links/templates/index.html new file mode 100644 index 0000000..4d10ea5 --- /dev/null +++ b/app/modules/links/templates/index.html @@ -0,0 +1,19 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Links{% endblock %} + +{% block content %} +
+
+
+

Links / Endpoints

+

Operational access layer module (phase 1 foundation)

+
+
+
+
+

Module page scaffold is active. Use API endpoints under /api/v1/links.

+
+
+
+{% endblock %} diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 5186405..b548ec4 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -12,7 +12,7 @@ from uuid import uuid4 from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request from fastapi.responses import FileResponse from pydantic import BaseModel, Field -from app.core.database import execute_query, execute_query_single +from app.core.database import execute_query, execute_query_single, table_has_column from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis from app.core.config import settings from app.services.email_service import EmailService @@ -2326,18 +2326,26 @@ async def get_sag_emails(sag_id: int): SELECT e.*, COALESCE( - NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.thread_key, '')), '[<>\\s]', '', 'g'), ''), - NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.in_reply_to, '')), '[<>\\s]', '', 'g'), ''), NULLIF(REGEXP_REPLACE((REGEXP_SPLIT_TO_ARRAY(COALESCE(e.email_references, ''), E'[\\s,]+'))[1], '[<>\\s]', '', 'g'), ''), NULLIF( REGEXP_REPLACE( - LOWER(TRIM(COALESCE(e.subject, ''))), - '^(?:re|fw|fwd)\\s*:\\s*', + (REGEXP_SPLIT_TO_ARRAY(COALESCE(e.in_reply_to, ''), E'[\\s,]+'))[1], + '[<>\\s]', '', 'g' ), '' ), + NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.thread_key, '')), '[<>\\s]', '', 'g'), ''), + NULLIF( + REGEXP_REPLACE( + LOWER(TRIM(COALESCE(e.subject, ''))), + '^(?:(?:re|fw|fwd|sv|aw)\\s*:\\s*)+', + '', + 'i' + ), + '' + ), NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.message_id, '')), '[<>\\s]', '', 'g'), ''), CONCAT('email-', e.id::text) ) AS resolved_thread_key @@ -2515,12 +2523,13 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req in_reply_to_header = None references_header = None + selected_thread_key = None if payload.thread_email_id: thread_row = None try: thread_row = execute_query_single( """ - SELECT id, message_id, in_reply_to, email_references + SELECT id, message_id, in_reply_to, email_references, thread_key FROM email_messages WHERE id = %s """, @@ -2528,14 +2537,24 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req ) except Exception: # Backward compatibility for DBs without in_reply_to/email_references columns. - thread_row = execute_query_single( - """ - SELECT id, message_id - FROM email_messages - WHERE id = %s - """, - (payload.thread_email_id,), - ) + try: + thread_row = execute_query_single( + """ + SELECT id, message_id, thread_key + FROM email_messages + WHERE id = %s + """, + (payload.thread_email_id,), + ) + except Exception: + thread_row = execute_query_single( + """ + SELECT id, message_id + FROM email_messages + WHERE id = %s + """, + (payload.thread_email_id,), + ) if thread_row: base_message_id = str(thread_row.get("message_id") or "").strip() if base_message_id and not base_message_id.startswith("<"): @@ -2549,8 +2568,17 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req else: references_header = base_message_id + selected_thread_key = _derive_thread_key_for_outbound( + thread_row.get("thread_key"), + thread_row.get("in_reply_to"), + thread_row.get("email_references"), + thread_row.get("message_id"), + ) + + effective_payload_thread_key = payload.thread_key or selected_thread_key + provisional_thread_key = _derive_thread_key_for_outbound( - payload.thread_key, + effective_payload_thread_key, in_reply_to_header, references_header, None, @@ -2584,16 +2612,28 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub" sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or "" - thread_key = _normalize_message_id_token(provider_thread_key) + 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. + # Some providers may return a new conversation id even for replies. + derived_thread_key = _derive_thread_key_for_outbound( + effective_payload_thread_key, + in_reply_to_header, + references_header, + None, + ) + + thread_key = derived_thread_key or provider_thread_key_normalized if not thread_key: thread_key = _derive_thread_key_for_outbound( - payload.thread_key, + effective_payload_thread_key, in_reply_to_header, references_header, generated_message_id, ) insert_result = None + insert_error = None try: insert_email_query = """ INSERT INTO email_messages ( @@ -2648,89 +2688,183 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req sag_id, ), ) - except Exception: - insert_email_query = """ - INSERT INTO email_messages ( - message_id, subject, sender_email, sender_name, - recipient_email, cc, body_text, body_html, - received_date, folder, has_attachments, attachment_count, - status, import_method, linked_case_id - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (message_id) DO UPDATE - SET - subject = EXCLUDED.subject, - sender_email = EXCLUDED.sender_email, - sender_name = EXCLUDED.sender_name, - recipient_email = EXCLUDED.recipient_email, - cc = EXCLUDED.cc, - body_text = EXCLUDED.body_text, - body_html = EXCLUDED.body_html, - folder = 'Sent', - has_attachments = EXCLUDED.has_attachments, - attachment_count = EXCLUDED.attachment_count, - status = 'sent', - import_method = COALESCE(email_messages.import_method, EXCLUDED.import_method), - linked_case_id = COALESCE(email_messages.linked_case_id, EXCLUDED.linked_case_id), - updated_at = CURRENT_TIMESTAMP - RETURNING id - """ - insert_result = execute_query( - insert_email_query, - ( - generated_message_id, - subject, - sender_email, - sender_name, - ", ".join(to_addresses), - ", ".join(cc_addresses), - body_text, - body_html, - datetime.now(), - "Sent", - bool(smtp_attachments), - len(smtp_attachments), - "sent", - "manual_upload", - sag_id, - ), - ) + except Exception as e: + insert_error = e + logger.warning("⚠️ Outbound email full insert fallback for case %s: %s", sag_id, e) if not insert_result: - logger.error("❌ Email sent but outbound log insert failed for case %s", sag_id) - raise HTTPException(status_code=500, detail="Email sent but logging failed") + try: + insert_email_query = """ + INSERT INTO email_messages ( + message_id, subject, sender_email, sender_name, + recipient_email, cc, body_text, body_html, + received_date, folder, has_attachments, attachment_count, + status, import_method, linked_case_id + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (message_id) DO UPDATE + SET + subject = EXCLUDED.subject, + sender_email = EXCLUDED.sender_email, + sender_name = EXCLUDED.sender_name, + recipient_email = EXCLUDED.recipient_email, + cc = EXCLUDED.cc, + body_text = EXCLUDED.body_text, + body_html = EXCLUDED.body_html, + folder = 'Sent', + has_attachments = EXCLUDED.has_attachments, + attachment_count = EXCLUDED.attachment_count, + status = 'sent', + import_method = COALESCE(email_messages.import_method, EXCLUDED.import_method), + linked_case_id = COALESCE(email_messages.linked_case_id, EXCLUDED.linked_case_id), + updated_at = CURRENT_TIMESTAMP + RETURNING id + """ + insert_result = execute_query( + insert_email_query, + ( + generated_message_id, + subject, + sender_email, + sender_name, + ", ".join(to_addresses), + ", ".join(cc_addresses), + body_text, + body_html, + datetime.now(), + "Sent", + bool(smtp_attachments), + len(smtp_attachments), + "sent", + "manual_upload", + sag_id, + ), + ) + except Exception as e: + insert_error = e + logger.warning("⚠️ Outbound email medium insert fallback for case %s: %s", sag_id, e) - email_id = insert_result[0]["id"] + if not insert_result: + # Legacy-safe fallback: persist with minimal guaranteed columns. + try: + insert_email_query = """ + INSERT INTO email_messages ( + message_id, subject, sender_email, sender_name, + recipient_email, cc, body_text, + received_date, folder, has_attachments, attachment_count + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (message_id) DO UPDATE + SET + subject = EXCLUDED.subject, + sender_email = EXCLUDED.sender_email, + sender_name = EXCLUDED.sender_name, + recipient_email = EXCLUDED.recipient_email, + cc = EXCLUDED.cc, + body_text = EXCLUDED.body_text, + folder = 'Sent', + has_attachments = EXCLUDED.has_attachments, + attachment_count = EXCLUDED.attachment_count, + updated_at = CURRENT_TIMESTAMP + RETURNING id + """ + insert_result = execute_query( + insert_email_query, + ( + generated_message_id, + subject, + sender_email, + sender_name, + ", ".join(to_addresses), + ", ".join(cc_addresses), + body_text, + datetime.now(), + "Sent", + bool(smtp_attachments), + len(smtp_attachments), + ), + ) + except Exception as e: + insert_error = e + logger.error("❌ Email sent but outbound log insert failed for case %s: %s", sag_id, e) - if smtp_attachments: + email_id = None + if insert_result: + email_id = insert_result[0]["id"] + else: + # Last chance recovery: if row exists already, continue with that id. + existing_email = execute_query_single( + "SELECT id FROM email_messages WHERE message_id = %s", + (generated_message_id,), + ) if generated_message_id else None + if existing_email: + email_id = existing_email["id"] + else: + warning_detail = str(insert_error or "email logging failed") + logger.error("❌ Email sent but no local email_id could be resolved for case %s", sag_id) + return { + "status": "sent", + "email_id": None, + "message": send_message, + "warning": f"Email sent but could not be logged locally: {warning_detail}", + } + + if smtp_attachments and email_id: from psycopg2 import Binary for attachment in smtp_attachments: - execute_query( - """ - INSERT INTO email_attachments ( - email_id, filename, content_type, size_bytes, file_path, content_data - ) - VALUES (%s, %s, %s, %s, %s, %s) - """, - ( + try: + execute_query( + """ + INSERT INTO email_attachments ( + email_id, filename, content_type, size_bytes, file_path, content_data + ) + VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + email_id, + attachment["filename"], + attachment["content_type"], + attachment.get("size") or len(attachment["content"]), + attachment.get("file_path"), + Binary(attachment["content"]), + ), + ) + except Exception as e: + logger.warning( + "⚠️ Could not persist outbound email attachment '%s' for email_id=%s: %s", + attachment.get("filename"), email_id, - attachment["filename"], - attachment["content_type"], - attachment.get("size") or len(attachment["content"]), - attachment.get("file_path"), - Binary(attachment["content"]), - ), - ) + e, + ) - execute_query( - """ - INSERT INTO sag_emails (sag_id, email_id) - VALUES (%s, %s) - ON CONFLICT DO NOTHING - """, - (sag_id, email_id), - ) + linked_ok = False + try: + execute_query( + """ + INSERT INTO sag_emails (sag_id, email_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (sag_id, email_id), + ) + linked_ok = True + except Exception as e: + logger.warning("⚠️ Could not insert sag_emails link for case=%s email_id=%s: %s", sag_id, email_id, e) + if table_has_column("email_messages", "linked_case_id"): + try: + execute_query( + "UPDATE email_messages SET linked_case_id = %s WHERE id = %s", + (sag_id, email_id), + ) + linked_ok = True + except Exception as nested_e: + logger.warning( + "⚠️ Fallback linked_case_id update also failed for case=%s email_id=%s: %s", + sag_id, + email_id, + nested_e, + ) sent_ts = datetime.now().isoformat() outgoing_comment = ( @@ -2743,14 +2877,63 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req f"{body_text}" ) - comment_row = execute_query_single( - """ - INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) - VALUES (%s, %s, %s, %s) - RETURNING kommentar_id, created_at - """, - (sag_id, 'Email Bot', outgoing_comment, True), - ) or {} + comment_row = {} + try: + has_system_flag = table_has_column("sag_kommentarer", "er_system_besked") + attempted_errors = [] + + has_comment_id_col = table_has_column("sag_kommentarer", "kommentar_id") + has_id_col = table_has_column("sag_kommentarer", "id") + + # Prefer the variant that matches the live schema to avoid noisy SQL errors in logs. + if has_comment_id_col: + returning_variants = ["kommentar_id", "id AS kommentar_id"] + elif has_id_col: + returning_variants = ["id AS kommentar_id", "kommentar_id"] + else: + returning_variants = ["kommentar_id", "id AS kommentar_id"] + + if has_system_flag: + comment_variants = [ + ( + f""" + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) + VALUES (%s, %s, %s, %s) + RETURNING {returning_expr}, created_at + """, + (sag_id, 'Email Bot', outgoing_comment, True), + ) + for returning_expr in returning_variants + ] + else: + comment_variants = [ + ( + f""" + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold) + VALUES (%s, %s, %s) + RETURNING {returning_expr}, created_at + """, + (sag_id, 'Email Bot', outgoing_comment), + ) + for returning_expr in returning_variants + ] + + for variant_query, variant_params in comment_variants: + try: + comment_row = execute_query_single(variant_query, variant_params) or {} + if comment_row: + break + except Exception as variant_error: + attempted_errors.append(str(variant_error)) + + if not comment_row and attempted_errors: + logger.warning( + "⚠️ Outbound email sent but comment logging variants failed for case %s: %s", + sag_id, + " | ".join(attempted_errors), + ) + except Exception as e: + logger.warning("⚠️ Outbound email sent but comment logging failed for case %s: %s", sag_id, e) comment_created_at = comment_row.get("created_at") if isinstance(comment_created_at, datetime): @@ -2759,17 +2942,21 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req comment_created_at = sent_ts logger.info( - "✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, thread_key=%s, recipients=%s)", + "✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, payload_thread_key=%s, stored_thread_key=%s, provider_thread_key=%s, recipients=%s)", sag_id, email_id, payload.thread_email_id, - payload.thread_key, + effective_payload_thread_key, + thread_key, + provider_thread_key_normalized, ", ".join(to_addresses), ) return { "status": "sent", "email_id": email_id, "message": send_message, + "linked_to_case": linked_ok, + "warning": None if linked_ok else "Email sent, but automatic case-thread link fallback was required", "comment": { "kommentar_id": comment_row.get("kommentar_id"), "forfatter": "Email Bot", diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index 616457b..f0f217b 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -460,7 +460,7 @@ // Check for associated company (auto-select if single match) try { - const response = await fetch(`/api/v1/contacts/${id}`); + const response = await fetch(`/api/v1/contacts/${id}`, { credentials: 'include' }); if (response.ok) { const data = await response.json(); selectedContactsCompanies[id] = data.companies || []; @@ -530,7 +530,7 @@ if (telefoniPrefill.customerId && !selectedCustomer) { try { - const customerRes = await fetch(`/api/v1/customers/${telefoniPrefill.customerId}`); + const customerRes = await fetch(`/api/v1/customers/${telefoniPrefill.customerId}`, { credentials: 'include' }); if (customerRes.ok) { const customer = await customerRes.json(); const customerName = customer.name || `Kunde #${telefoniPrefill.customerId}`; @@ -543,7 +543,7 @@ if (telefoniPrefill.contactId) { try { - const res = await fetch(`/api/v1/contacts/${telefoniPrefill.contactId}`); + const res = await fetch(`/api/v1/contacts/${telefoniPrefill.contactId}`, { credentials: 'include' }); if (!res.ok) return; const c = await res.json(); const name = `${c.first_name || ''} ${c.last_name || ''}`.trim() || `Kontakt #${telefoniPrefill.contactId}`; @@ -598,7 +598,7 @@ try { const responses = await Promise.all( - contactIds.map(contactId => fetch(`/api/v1/hardware/by-contact/${contactId}`)) + contactIds.map(contactId => fetch(`/api/v1/hardware/by-contact/${contactId}`, { credentials: 'include' })) ); const datasets = await Promise.all(responses.map(r => r.ok ? r.json() : [])); const merged = new Map(); @@ -686,6 +686,7 @@ try { const response = await fetch('/api/v1/hardware/quick', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, @@ -723,6 +724,7 @@ try { const response = await fetch('/api/v1/customers', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.trim() }) }); @@ -738,6 +740,7 @@ const linkResponses = await Promise.all(contactIds.map(contactId => fetch(`/api/v1/contacts/${contactId}/companies`, { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_id: created.id, is_primary: false }) }) @@ -772,6 +775,7 @@ }; const response = await fetch('/api/v1/contacts', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); @@ -886,6 +890,7 @@ try { const response = await fetch('/api/v1/sag', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); @@ -897,9 +902,7 @@ const contactPromises = Object.keys(selectedContacts).map(cid => fetch(`/api/v1/sag/${result.id}/contacts`, { method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({contact_id: parseInt(cid), role: 'Kontakt'}) - }) + credentials: 'include', ); await Promise.all(contactPromises); @@ -909,6 +912,7 @@ try { await fetch(`/api/v1/telefoni/calls/${encodeURIComponent(telefoniPrefill.callId)}`, { method: 'PATCH', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sag_id: result.id, @@ -925,6 +929,7 @@ const linkPromises = Object.keys(selectedContacts).map(cid => fetch(`/api/v1/contacts/${parseInt(cid)}/companies`, { method: 'POST', + credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ customer_id: selectedCustomer.id, is_primary: false }) }) diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index 78560a7..fb01c20 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -4,6 +4,280 @@ {% block extra_css %} ', css_start) + + css_new = """ + .time-v1-calendar-container { + background: var(--bg-surface, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 12px; + margin-bottom: 2rem; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0,0,0,0.03); + } + .time-v1-calendar-header { + background: var(--bg-element, #f8f9fa); + border-bottom: 1px solid var(--border-color, #e0e0e0); + padding: 12px 20px; + font-weight: 600; + font-size: 1rem; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-color); + } + .time-v1-calendar-grid { + display: flex; + position: relative; + overflow-x: auto; + } + .time-v1-time-axis { + width: 60px; + flex-shrink: 0; + border-right: 1px solid var(--border-color, #f0f0f0); + position: relative; + background: var(--bg-element, #fafafa); + padding-top: 40px; + } + .time-v1-hour-marker { + position: absolute; + width: 100%; + text-align: center; + font-size: 0.75rem; + color: var(--text-secondary); + transform: translateY(-50%); + } + .time-v1-tech-col { + flex: 1; + min-width: 250px; + border-right: 1px solid var(--border-color, #f0f0f0); + position: relative; + } + .time-v1-tech-col:last-child { + border-right: none; + } + .time-v1-tech-header { + text-align: center; + padding: 8px; + height: 40px; + font-weight: 600; + font-size: 0.85rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + background: var(--bg-element, #f8f9fa); + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + position: sticky; + top: 0; + z-index: 50; + color: var(--text-color); + } + .time-v1-tech-body { + position: relative; + height: 600px; /* 10h * 60Px = 600px */ + background-image: linear-gradient(to bottom, transparent 59px, var(--border-color, #f0f0f0) 60px); + background-size: 100% 60px; + } + .time-v1-entry-block { + position: absolute; + left: 4px; + right: 4px; + border-radius: 6px; + padding: 6px 8px; + font-size: 0.8rem; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + transition: transform 0.2s, box-shadow 0.2s, z-index 0.2s; + border-left: 4px solid var(--bs-secondary); + background: var(--bg-surface, #fff); + cursor: grab; + z-index: 10; + } + .time-v1-entry-block:active { cursor: grabbing; opacity: 0.9; } + .time-v1-entry-block:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + z-index: 20; + } + .time-v1-entry-pending { border-left-color: #f59e0b; background: rgba(245, 158, 11, 0.05) !important; } + .time-v1-entry-godkendt { border-left-color: #2fb344; background: rgba(47, 179, 68, 0.05) !important; } + .time-v1-entry-kladde { border-left-color: #6c757d; background: rgba(108, 117, 125, 0.05) !important; } + + .time-v1-entry-time { + font-weight: 600; + font-size: 0.75rem; + margin-bottom: 2px; + color: var(--text-color); + } + .time-v1-entry-desc { + color: var(--text-secondary); + font-size: 0.75rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .time-v1-unplaced-container { + padding: 12px 20px; + border-top: 1px solid var(--border-color); + background: var(--bg-element); + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + .time-v1-unplaced-item { + background: var(--bg-surface); + border: 1px solid var(--border-color); + padding: 4px 10px; + border-radius: 20px; + font-size: 0.8rem; + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-color); + } +""" + if css_start != -1 and css_end != -1: + text = text[:css_start] + css_new + text[css_end:] + print("Replaced CSS.") + + js_start = text.find('function renderTimeV1Timeline(entries) {') + js_end = text.find('async function loadTimeTrackingTab() {', js_start) + + js_new = """function renderTimeV1Timeline(entries) { + const timeline = document.getElementById('timeTimelineColumns'); + if (!timeline) return; + + if (!entries || entries.length === 0) { + timeline.innerHTML = '
Ingen tidsregistreringer endnu
'; + return; + } + + const START_HOUR = 7; + const TOTAL_HOURS = 10; // 07:00 to 17:00 + const HOUR_HEIGHT = 60; // px + + const groupedByDate = {}; + entries.forEach((entry) => { + let dateKey = 'Ukendt dato'; + if (entry.start_tid) { + dateKey = entry.start_tid.split('T')[0]; + } else if (entry.worked_date) { + dateKey = entry.worked_date; + } else if (entry.created_at) { + dateKey = entry.created_at.split('T')[0]; + } + + // Keep only first 10 chars for proper grouping if it's an ISO timestamp + if (dateKey.length > 10) dateKey = dateKey.substring(0, 10); + + if (!groupedByDate[dateKey]) groupedByDate[dateKey] = []; + groupedByDate[dateKey].push(entry); + }); + + const sortedDates = Object.keys(groupedByDate).sort((a, b) => new Date(b) - new Date(a)); + let html = ''; + + sortedDates.forEach(dateStr => { + const dayEntries = groupedByDate[dateStr]; + + let formattedDateLab = dateStr; + try { + const d = new Date(dateStr); + if (!isNaN(d.getTime())) { + formattedDateLab = d.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); + formattedDateLab = formattedDateLab.charAt(0).toUpperCase() + formattedDateLab.slice(1); + } + } catch(e){} + + const techs = {}; + const unplaced = []; + + dayEntries.forEach(entry => { + const tech = entry.bruger_navn || entry.user_name || 'Ukendt'; + if (!techs[tech]) techs[tech] = []; + + if (!entry.start_tid || entry.start_tid === null) { + unplaced.push(entry); + } else { + techs[tech].push(entry); + } + }); + + const techNames = Object.keys(techs).sort(); + + html += ` +
+
+ ${formattedDateLab} +
+
+
+ `; + + for (let i = 0; i <= TOTAL_HOURS; i++) { + const h = START_HOUR + i; + const top = i * HOUR_HEIGHT; + html += `
${h.toString().padStart(2, '0')}:00
`; + } + + html += `
`; + + techNames.forEach(tech => { + html += ` +
+
+ ${escapeHtml(tech)} +
+
+ `; + + techs[tech].forEach(entry => { + const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse'); + const status = entry.entry_status || entry.status || 'kladde'; + let cssClass = 'time-v1-entry-kladde'; + if (status === 'afventer' || status === 'pending') cssClass = 'time-v1-entry-pending'; + if (status === 'godkendt' || status === 'billed' || status === 'approved' || entry.fakturerbar_tid_min > 0) cssClass = 'time-v1-entry-godkendt'; + + const startObj = new Date(entry.start_tid); + let durationMin = 30; // default length + if (entry.faktisk_tid_min) { + durationMin = parseInt(entry.faktisk_tid_min); + } else if (entry.original_hours || entry.timer) { + durationMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60); + } + + let startH = startObj.getHours(); + let startM = startObj.getMinutes(); + + if (startH < START_HOUR) { + durationMin -= ((START_HOUR * 60) - (startH * 60 + startM)); + startH = START_HOUR; + startM = 0; + } + + let topPx = ((startH - START_HOUR) + (startM / 60)) * HOUR_HEIGHT; + let heightPx = (durationMin / 60) * HOUR_HEIGHT; + + if (topPx < 0) topPx = 0; + if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) { + heightPx = (TOTAL_HOURS * HOUR_HEIGHT) - topPx; + } + + if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) { + const endObj = new Date(startObj.getTime() + durationMin * 60000); + const timeStr = `${startObj.getHours().toString().padStart(2,'0')}:${startObj.getMinutes().toString().padStart(2,'0')} - ${endObj.getHours().toString().padStart(2,'0')}:${endObj.getMinutes().toString().padStart(2,'0')}`; + + html += ` +
+
${timeStr}
+
${desc}
+
+ `; + } + }); + + html += ` +
+
+ `; + }); + + html += `
`; + + if (unplaced.length > 0) { + html += `
+ Uden tidsrum: + `; + unplaced.forEach(u => { + const userName = escapeHtml(u.bruger_navn || u.user_name || 'Ukendt'); + const hrs = u.original_hours || u.timer || 0; + html += `
+ ${userName} • ${hrs}t +
`; + }); + html += `
`; + } + + html += `
`; + }); + + timeline.innerHTML = html; + } + + """ + + if js_start != -1 and js_end != -1: + text = text[:js_start] + js_new + text[js_end:] + with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f: + f.write(text) + print("Replaced JS and saved detail.html.") + else: + print("JS function not found or end not found.") + +patch() diff --git a/patch_everything.py b/patch_everything.py new file mode 100644 index 0000000..bef6f92 --- /dev/null +++ b/patch_everything.py @@ -0,0 +1,741 @@ +with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f: + text = f.read() + +# 1. Timeline Layout & CSS +css_start = text.find('.time-v1-global-timeline {') +if css_start == -1: + css_start = text.find('.time-v1-calendar-container {') + +if css_start != -1: + css_end = text.find('', css_start) + css_new = """ + .time-v1-calendar-container { + background: var(--bg-surface, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 12px; + margin-bottom: 2rem; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0,0,0,0.03); + } + .time-v1-calendar-header { + background: var(--bg-element, #f8f9fa); + border-bottom: 1px solid var(--border-color, #e0e0e0); + padding: 12px 20px; + font-weight: 600; + font-size: 1rem; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-color); + } + .time-v1-calendar-grid { + display: flex; + position: relative; + overflow-x: auto; + } + .time-v1-time-axis { + width: 60px; + flex-shrink: 0; + border-right: 1px solid var(--border-color, #f0f0f0); + position: relative; + background: var(--bg-element, #fafafa); + padding-top: 40px; + } + .time-v1-hour-marker { + position: absolute; + width: 100%; + text-align: center; + font-size: 0.75rem; + color: var(--text-secondary); + transform: translateY(-50%); + } + .time-v1-tech-col { + flex: 1; + min-width: 250px; + border-right: 1px solid var(--border-color, #f0f0f0); + position: relative; + } + .time-v1-tech-col:last-child { + border-right: none; + } + .time-v1-tech-header { + text-align: center; + padding: 8px; + height: 40px; + font-weight: 600; + font-size: 0.85rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + background: var(--bg-element, #f8f9fa); + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + position: sticky; + top: 0; + z-index: 50; + color: var(--text-color); + } + .time-v1-tech-body { + position: relative; + height: 600px; /* 10h * 60Px = 600px */ + background-image: linear-gradient(to bottom, transparent 59px, var(--border-color, #f0f0f0) 60px); + background-size: 100% 60px; + } + .time-v1-entry-block { + position: absolute; + left: 4px; + right: 4px; + border-radius: 6px; + padding: 6px 8px; + font-size: 0.8rem; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + transition: transform 0.2s, box-shadow 0.2s, z-index 0.2s; + border-left: 4px solid var(--bs-secondary); + background: var(--bg-surface, #fff); + cursor: grab; + z-index: 10; + } + .time-v1-entry-block:active { cursor: grabbing; opacity: 0.9; } + .time-v1-entry-block:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + z-index: 20; + } + .time-v1-entry-pending { border-left-color: #f59e0b; background: rgba(245, 158, 11, 0.05) !important; } + .time-v1-entry-godkendt { border-left-color: #2fb344; background: rgba(47, 179, 68, 0.05) !important; } + .time-v1-entry-kladde { border-left-color: #6c757d; background: rgba(108, 117, 125, 0.05) !important; } + + .time-v1-entry-time { + font-weight: 600; + font-size: 0.75rem; + margin-bottom: 2px; + color: var(--text-color); + } + .time-v1-entry-desc { + color: var(--text-secondary); + font-size: 0.75rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .time-v1-unplaced-container { + padding: 12px 20px; + border-top: 1px solid var(--border-color); + background: var(--bg-element); + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + .time-v1-unplaced-item { + background: var(--bg-surface); + border: 1px solid var(--border-color); + padding: 4px 10px; + border-radius: 20px; + font-size: 0.8rem; + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-color); + } +""" + if css_end != -1: + text = text[:css_start] + css_new + text[css_end:] + print("CSS applied.") + +js_start = text.find('function renderTimeV1Timeline(entries) {') +js_end = text.find('async function loadTimeTrackingTab() {', js_start) +js_new = """function renderTimeV1Timeline(entries) { + const timeline = document.getElementById('timeTimelineColumns'); + if (!timeline) return; + + if (!entries || entries.length === 0) { + timeline.innerHTML = '
Ingen tidsregistreringer endnu
'; + return; + } + + const START_HOUR = 7; + const TOTAL_HOURS = 10; // 07:00 to 17:00 + const HOUR_HEIGHT = 60; // px + + const groupedByDate = {}; + entries.forEach((entry) => { + let dateKey = 'Ukendt dato'; + if (entry.start_tid) { + dateKey = entry.start_tid.split('T')[0]; + } else if (entry.worked_date) { + dateKey = entry.worked_date; + } else if (entry.created_at) { + dateKey = entry.created_at.split('T')[0]; + } + + // Keep only first 10 chars for proper grouping if it's an ISO timestamp + if (dateKey.length > 10) dateKey = dateKey.substring(0, 10); + + if (!groupedByDate[dateKey]) groupedByDate[dateKey] = []; + groupedByDate[dateKey].push(entry); + }); + + const sortedDates = Object.keys(groupedByDate).sort((a, b) => new Date(b) - new Date(a)); + let html = ''; + + sortedDates.forEach(dateStr => { + const dayEntries = groupedByDate[dateStr]; + + let formattedDateLab = dateStr; + try { + const d = new Date(dateStr); + if (!isNaN(d.getTime())) { + formattedDateLab = d.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); + formattedDateLab = formattedDateLab.charAt(0).toUpperCase() + formattedDateLab.slice(1); + } + } catch(e){} + + const techs = {}; + const unplaced = []; + + dayEntries.forEach(entry => { + const tech = entry.bruger_navn || entry.user_name || 'Ukendt'; + if (!techs[tech]) techs[tech] = []; + + if (!entry.start_tid || entry.start_tid === null) { + unplaced.push(entry); + } else { + techs[tech].push(entry); + } + }); + + const techNames = Object.keys(techs).sort(); + + html += ` +
+
+ ${formattedDateLab} +
+
+
+ `; + + for (let i = 0; i <= TOTAL_HOURS; i++) { + const h = START_HOUR + i; + const top = i * HOUR_HEIGHT; + html += `
${h.toString().padStart(2, '0')}:00
`; + } + + html += `
`; + + techNames.forEach(tech => { + html += ` +
+
+ ${escapeHtml(tech)} +
+
+ `; + + techs[tech].forEach(entry => { + const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse'); + const status = entry.entry_status || entry.status || 'kladde'; + let cssClass = 'time-v1-entry-kladde'; + if (status === 'afventer' || status === 'pending') cssClass = 'time-v1-entry-pending'; + if (status === 'godkendt' || status === 'billed' || status === 'approved' || entry.fakturerbar_tid_min > 0) cssClass = 'time-v1-entry-godkendt'; + + const startObj = new Date(entry.start_tid); + let durationMin = 30; // default length + if (entry.faktisk_tid_min) { + durationMin = parseInt(entry.faktisk_tid_min); + } else if (entry.original_hours || entry.timer) { + durationMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60); + } + + let startH = startObj.getHours(); + let startM = startObj.getMinutes(); + + if (startH < START_HOUR) { + durationMin -= ((START_HOUR * 60) - (startH * 60 + startM)); + startH = START_HOUR; + startM = 0; + } + + let topPx = ((startH - START_HOUR) + (startM / 60)) * HOUR_HEIGHT; + let heightPx = (durationMin / 60) * HOUR_HEIGHT; + + if (topPx < 0) topPx = 0; + if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) { + heightPx = (TOTAL_HOURS * HOUR_HEIGHT) - topPx; + } + + if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) { + const endObj = new Date(startObj.getTime() + durationMin * 60000); + const timeStr = `${startObj.getHours().toString().padStart(2,'0')}:${startObj.getMinutes().toString().padStart(2,'0')} - ${endObj.getHours().toString().padStart(2,'0')}:${endObj.getMinutes().toString().padStart(2,'0')}`; + + html += ` +
+
${timeStr}
+
${desc}
+
+ `; + } + }); + + html += ` +
+
+ `; + }); + + html += `
`; + + if (unplaced.length > 0) { + html += `
+ Uden tidsrum: + `; + unplaced.forEach(u => { + const userName = escapeHtml(u.bruger_navn || u.user_name || 'Ukendt'); + const hrs = u.original_hours || u.timer || 0; + html += `
+ ${userName} • ${hrs}t +
`; + }); + html += `
`; + } + + html += `
`; + }); + + timeline.innerHTML = html; + } + + """ +if js_start != -1 and js_end != -1: + text = text[:js_start] + js_new + text[js_end:] + print("Timeline JS applied.") + + +# 2. timeManualFormV1 update +tf1_start = text.find('
', tf1_start) + 7 +new_tf1 = """ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
""" +if tf1_start != -1 and tf1_end != -1: + text = text[:tf1_start] + new_tf1 + text[tf1_end:] + print("timeManualFormV1 applied") + +tf1_js_s = text.find('async function createManualTimeV1(event) {') +tf1_js_e = text.find(' document.addEventListener(\'DOMContentLoaded\'', tf1_js_s) +new_tf1_js = """function bindTimeV1Calculations() { + const startIn = document.getElementById('timeV1Start'); + const endIn = document.getElementById('timeV1End'); + const minIn = document.getElementById('timeV1Minutes'); + + if (!startIn || !endIn || !minIn) return; + + const parseTime = (val) => { + if (!val) return null; + const [h,m] = val.split(':').map(Number); + return (h * 60) + m; + }; + + const toTimeStr = (totalMins) => { + const h = Math.floor(totalMins / 60) % 24; + const m = totalMins % 60; + return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`; + }; + + const recalculate = (trigger) => { + const s = parseTime(startIn.value); + const e = parseTime(endIn.value); + const dur = parseInt(minIn.value); + + if (trigger === 'start' || trigger === 'end') { + if (s !== null && e !== null) { + let diff = e - s; + if (diff < 0) diff += 24*60; + minIn.value = diff; + } else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while (base < 0) base += 24*60; + startIn.value = toTimeStr(base); + } + } else if (trigger === 'min') { + if (s !== null && !isNaN(dur) && dur > 0) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while(base < 0) base+=24*60; + startIn.value = toTimeStr(base); + } + } + }; + + startIn.addEventListener('change', () => recalculate('start')); + endIn.addEventListener('change', () => recalculate('end')); + minIn.addEventListener('input', () => recalculate('min')); + } + + async function createManualTimeV1(event) { + event.preventDefault(); + const minutes = Number(document.getElementById('timeV1Minutes')?.value || 0); + + if (minutes <= 0) { + alert('Indtast minutter over 0'); + return; + } + + const dateVal = document.getElementById('timeV1Date')?.value || null; + const tStart = document.getElementById('timeV1Start')?.value; + const tEnd = document.getElementById('timeV1End')?.value; + + let startObj = null; + let endObj = null; + + if (dateVal && tStart) { + try { + const l = new Date(`${dateVal}T${tStart}:00`); + startObj = l.toISOString(); + } catch(e){} + } + + if (dateVal && tEnd) { + try { + const l = new Date(`${dateVal}T${tEnd}:00`); + if (startObj && new Date(startObj) > l) { + l.setDate(l.getDate() + 1); + } + endObj = l.toISOString(); + } catch(e){} + } + + const payload = { + sag_id: timeCaseId, + medarbejder_id: getTimeV1EmployeeId(), + faktisk_tid_min: minutes, + worked_date: dateVal, + entry_type: document.getElementById('timeV1Type')?.value || 'manuel', + entry_status: document.getElementById('timeV1Status')?.value || 'afventer', + beskrivelse: document.getElementById('timeV1Description')?.value || null, + kilde: 'manuel', + start_tid: startObj, + slut_tid: endObj + }; + + try { + const res = await fetch('/api/v1/timetracking/time/manual', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error(await res.text()); + + const minutesInput = document.getElementById('timeV1Minutes'); + const descInput = document.getElementById('timeV1Description'); + const startIn = document.getElementById('timeV1Start'); + const endIn = document.getElementById('timeV1End'); + + if (minutesInput) minutesInput.value = ''; + if (descInput) descInput.value = ''; + if (startIn) startIn.value = ''; + if (endIn) endIn.value = ''; + + await loadTimeTrackingTab(); + } catch (error) { + alert('Kunne ikke oprette tidsregistrering: ' + (error.message || 'ukendt fejl')); + } + } +\n""" +if tf1_js_s != -1 and tf1_js_e != -1: + text = text[:tf1_js_s] + new_tf1_js + text[tf1_js_e:] + print("createManualTimeV1 js applied.") + +# Inject bindTimeV1Calculations in DOMContentLoaded (lines 6830ish) +# We find: document.addEventListener('DOMContentLoaded', () => { +# const dateInput = document.getElementById('timeV1Date'); +dom_inject = """document.addEventListener('DOMContentLoaded', () => { + bindTimeV1Calculations(); + const dateInput = document.getElementById('timeV1Date');""" +text = text.replace("document.addEventListener('DOMContentLoaded', () => {\n const dateInput = document.getElementById('timeV1Date');", dom_inject) + +# 3. Modal timeForm Update +mhtml_start = text.find('
') +mhtml_end = text.find('
', mhtml_start) + 7 +new_mhtml = """
+ +
+
+ + +
+
+ +
+ Min. + +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+
""" +if mhtml_start != -1 and mhtml_end != -1: + text = text[:mhtml_start] + new_mhtml + text[mhtml_end:] + print("timeForm modal html applied.") + +# Replace saveTime to send start_tid / slut_tid using the new fields +old_save_time_start = text.find('async function saveTime() {') +if old_save_time_start != -1: + # Safely find the end of saveTime function body + bracket_count = 0 + in_function = False + old_save_time_end = -1 + for i in range(old_save_time_start, len(text)): + if text[i] == '{': + bracket_count += 1 + in_function = True + elif text[i] == '}': + bracket_count -= 1 + if in_function and bracket_count == 0: + old_save_time_end = i + 1 + break + + if old_save_time_end != -1: + new_save_time_js = """ function bindTimeModalCalculations() { + const startIn = document.getElementById('time_start_input'); + const endIn = document.getElementById('time_end_input'); + const minIn = document.getElementById('time_total_minutes'); + + if (!startIn || !endIn || !minIn) return; + + const parseTime = (val) => { + if (!val) return null; + const [h,m] = val.split(':').map(Number); + return (h * 60) + m; + }; + + const toTimeStr = (totalMins) => { + const h = Math.floor(totalMins / 60) % 24; + const m = totalMins % 60; + return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`; + }; + + const recalculate = (trigger) => { + const s = parseTime(startIn.value); + const e = parseTime(endIn.value); + const dur = parseInt(minIn.value); + + if (trigger === 'start' || trigger === 'end') { + if (s !== null && e !== null) { + let diff = e - s; + if (diff < 0) diff += 24*60; + minIn.value = diff; + } else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while (base < 0) base += 24*60; + startIn.value = toTimeStr(base); + } + } else if (trigger === 'min') { + if (s !== null && !isNaN(dur) && dur > 0) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while(base < 0) base+=24*60; + startIn.value = toTimeStr(base); + } + } + }; + + startIn.addEventListener('change', () => recalculate('start')); + endIn.addEventListener('change', () => recalculate('end')); + minIn.addEventListener('input', () => recalculate('min')); + } + + document.addEventListener('DOMContentLoaded', bindTimeModalCalculations); + + async function saveTime() { + const mInput = document.getElementById('time_total_minutes'); + const minVal = parseInt(mInput ? mInput.value : 0); + if (!minVal || minVal <= 0) { + alert('Indtast en gyldig varighed (minutter).'); + return; + } + const totalHours = minVal / 60; + const dateVal = document.getElementById('time_date').value; + // extract optional start/end limits + const tStart = document.getElementById('time_start_input')?.value; + const tEnd = document.getElementById('time_end_input')?.value; + + let startObj = null; + let endObj = null; + if (dateVal && tStart) { + try { + const l = new Date(`${dateVal}T${tStart}:00`); + startObj = l.toISOString(); + } catch(e){} + } + if (dateVal && tEnd) { + try { + const l = new Date(`${dateVal}T${tEnd}:00`); + if (startObj && new Date(startObj) > l) { + l.setDate(l.getDate() + 1); + } + endObj = l.toISOString(); + } catch(e){} + } + + const sagId = document.getElementById('time_sag_id').value; + const payload = { + sag_id: parseInt(sagId), + // Note: saveTime modal expects 'timer' as totalHours currently, let's keep compatibility: + timer: totalHours, + faktisk_tid_min: minVal, + worked_date: dateVal, + start_tid: startObj, + slut_tid: endObj, + description: document.getElementById('time_desc').value, + work_type: document.getElementById('time_work_type').value, + billing_method: document.getElementById('time_billing_method').value + }; + + try { + const res = await fetch(`/api/v1/cases/${sagId}/time`, { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify(payload) + }); + if(res.ok) { + window.location.reload(); + } else { + alert("Fejl ved registrering af tid"); + } + } catch(err) { + console.error(err); + alert("Forbindelsesfejl"); + } + }""" + text = text[:old_save_time_start] + new_save_time_js + text[old_save_time_end:] + print("saveTime js logic replaced.") + +# We also need to fix `showAddTimeModal()` reset fields: +show_add_modal = text.find('if(document.getElementById(\'time_hours_input\')) {') +show_add_modal_end = text.find('}', show_add_modal) + 1 +if show_add_modal != -1: + new_reset = """if(document.getElementById('time_total_minutes')) { + document.getElementById('time_total_minutes').value = ''; + document.getElementById('time_start_input').value = ''; + document.getElementById('time_end_input').value = ''; + }""" + text = text[:show_add_modal] + new_reset + text[show_add_modal_end:] + +# And delete old 'updateTimeTotal()' function +old_update_tot_s = text.find('function updateTimeTotal() {') +if old_update_tot_s != -1: + old_update_tot_e = text.find('}', text.find('}', old_update_tot_s) + 1) + 1 + # We'll just comment it out to avoid bracket mess tracking + if text[old_update_tot_e-1] == '}': + text = text[:old_update_tot_s] + "/* removed updateTimeTotal */\n" + text[old_update_tot_e:] + +with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f: + f.write(text) + print("Done writing to file safely.") diff --git a/patch_time_form.py b/patch_time_form.py new file mode 100644 index 0000000..01b4b11 --- /dev/null +++ b/patch_time_form.py @@ -0,0 +1,207 @@ +with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f: + text = f.read() + +html_start = text.find('
', html_start) + 7 + +new_html = """ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
""" + +if html_start != -1 and html_end != -1: + text = text[:html_start] + new_html + text[html_end:] + print("HTML updated.") + +js_start = text.find('async function createManualTimeV1(event) {') +js_end = text.find(' document.addEventListener(\'DOMContentLoaded\'', js_start) + +# Notice here the JS checks for start_tid / slut_tid to populate them. +new_js = """function bindTimeV1Calculations() { + const startIn = document.getElementById('timeV1Start'); + const endIn = document.getElementById('timeV1End'); + const minIn = document.getElementById('timeV1Minutes'); + + if (!startIn || !endIn || !minIn) return; + + const parseTime = (val) => { + if (!val) return null; + const [h,m] = val.split(':').map(Number); + return (h * 60) + m; + }; + + const toTimeStr = (totalMins) => { + const h = Math.floor(totalMins / 60) % 24; + const m = totalMins % 60; + return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`; + }; + + const recalculate = (trigger) => { + const s = parseTime(startIn.value); + const e = parseTime(endIn.value); + const dur = parseInt(minIn.value); + + if (trigger === 'start' || trigger === 'end') { + if (s !== null && e !== null) { + let diff = e - s; + if (diff < 0) diff += 24*60; + minIn.value = diff; + } else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while (base < 0) base += 24*60; + startIn.value = toTimeStr(base); + } + } else if (trigger === 'min') { + if (s !== null && !isNaN(dur) && dur > 0) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while(base < 0) base+=24*60; + startIn.value = toTimeStr(base); + } + } + }; + + startIn.addEventListener('change', () => recalculate('start')); + endIn.addEventListener('change', () => recalculate('end')); + minIn.addEventListener('input', () => recalculate('min')); + } + + async function createManualTimeV1(event) { + event.preventDefault(); + const minutes = Number(document.getElementById('timeV1Minutes')?.value || 0); + + if (minutes <= 0) { + alert('Indtast minutter over 0'); + return; + } + + const dateVal = document.getElementById('timeV1Date')?.value || null; + const tStart = document.getElementById('timeV1Start')?.value; + const tEnd = document.getElementById('timeV1End')?.value; + + let startObj = null; + let endObj = null; + + if (dateVal && tStart) { + try { + const l = new Date(`${dateVal}T${tStart}:00`); + startObj = l.toISOString(); + } catch(e){} + } + + if (dateVal && tEnd) { + try { + const l = new Date(`${dateVal}T${tEnd}:00`); + if (startObj && new Date(startObj) > l) { + l.setDate(l.getDate() + 1); + } + endObj = l.toISOString(); + } catch(e){} + } + + const payload = { + sag_id: timeCaseId, + medarbejder_id: getTimeV1EmployeeId(), + faktisk_tid_min: minutes, + worked_date: dateVal, + entry_type: document.getElementById('timeV1Type')?.value || 'manuel', + entry_status: document.getElementById('timeV1Status')?.value || 'afventer', + beskrivelse: document.getElementById('timeV1Description')?.value || null, + kilde: 'manuel', + start_tid: startObj, + slut_tid: endObj + }; + + try { + const res = await fetch('/api/v1/timetracking/time/manual', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error(await res.text()); + + const minutesInput = document.getElementById('timeV1Minutes'); + const descInput = document.getElementById('timeV1Description'); + const startIn = document.getElementById('timeV1Start'); + const endIn = document.getElementById('timeV1End'); + + if (minutesInput) minutesInput.value = ''; + if (descInput) descInput.value = ''; + if (startIn) startIn.value = ''; + if (endIn) endIn.value = ''; + + await loadTimeTrackingTab(); + } catch (error) { + alert('Kunne ikke oprette tidsregistrering: ' + (error.message || 'ukendt fejl')); + } + } +\n""" + +if js_start != -1 and js_end != -1: + text = text[:js_start] + new_js + text[js_end:] + print("JS updated.") + +dom_start = text.find('document.addEventListener(\'DOMContentLoaded\'') +if dom_start != -1: + dom_body_start = text.find('{', dom_start) + 1 + # Check if we already injected it + if 'bindTimeV1Calculations();' not in text[dom_start:dom_start+200]: + text = text[:dom_body_start] + "\n bindTimeV1Calculations();" + text[dom_body_start:] + print("DOMContentLoaded updated.") + +with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f: + f.write(text) + print("File saved successfully.") diff --git a/patch_time_modal.py b/patch_time_modal.py new file mode 100644 index 0000000..3cc1821 --- /dev/null +++ b/patch_time_modal.py @@ -0,0 +1,171 @@ +with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f: + text = f.read() + +# Replace HTML for timeForm +html_start = text.find('
') +html_end = text.find('
', html_start) + 7 + +new_html = """
+ +
+
+ + +
+
+ +
+ Min. + +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+
""" + +if html_start != -1 and html_end != -1: + text = text[:html_start] + new_html + text[html_end:] + print("Replaced timeForm HTML.") + +# Modify reset logic in showAddTimeModal +reset_start = text.find('if(document.getElementById(\'time_hours_input\')) {') +reset_end = text.find('}', reset_start) + 1 +if reset_start != -1: + new_reset = """if(document.getElementById('time_total_minutes')) { + document.getElementById('time_total_minutes').value = ''; + document.getElementById('time_start_input').value = ''; + document.getElementById('time_end_input').value = ''; + }""" + text = text[:reset_start] + new_reset + text[reset_end:] + print("Replaced modal form reset.") + +# Delete old updateTimeTotal function, add bindTimeModalCalculations +updateTotalStart = text.find('function updateTimeTotal() {') +updateTotalEnd = text.find('}', updateTotalStart) + 1 +if updateTotalStart != -1: + new_updateTotal = """function bindTimeModalCalculations() { + const startIn = document.getElementById('time_start_input'); + const endIn = document.getElementById('time_end_input'); + const minIn = document.getElementById('time_total_minutes'); + + if (!startIn || !endIn || !minIn) return; + + const parseTime = (val) => { + if (!val) return null; + const [h,m] = val.split(':').map(Number); + return (h * 60) + m; + }; + + const toTimeStr = (totalMins) => { + const h = Math.floor(totalMins / 60) % 24; + const m = totalMins % 60; + return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`; + }; + + const recalculate = (trigger) => { + const s = parseTime(startIn.value); + const e = parseTime(endIn.value); + const dur = parseInt(minIn.value); + + if (trigger === 'start' || trigger === 'end') { + if (s !== null && e !== null) { + let diff = e - s; + if (diff < 0) diff += 24*60; + minIn.value = diff; + } else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while (base < 0) base += 24*60; + startIn.value = toTimeStr(base); + } + } else if (trigger === 'min') { + if (s !== null && !isNaN(dur) && dur > 0) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while(base < 0) base+=24*60; + startIn.value = toTimeStr(base); + } + } + }; + + startIn.addEventListener('change', () => recalculate('start')); + endIn.addEventListener('change', () => recalculate('end')); + minIn.addEventListener('input', () => recalculate('min')); + }""" + text = text[:updateTotalStart] + new_updateTotal + text[updateTotalEnd:] + print("Replaced updateTimeTotal with bindTimeModalCalculations") + +# Fix listeners initialization +dom_start = text.find('const hInput = document.getElementById(\'time_hours_input\');') +dom_end = text.find('if(mInput) mInput.addEventListener(\'input\', updateTimeTotal);', dom_start) + 63 +if dom_start != -1: + text = text[:dom_start] + "bindTimeModalCalculations();" + text[dom_end:] + print("Fixed DOM listeners") + +# Replace saveTime body part logic: calculate minutes explicitly from `time_total_minutes` +save_start = text.find('async function saveTime() {') +save_end = text.find('const isInternal = document.getElementById(\'time_internal\')?.checked || false;', save_start) +if save_start != -1: + new_save = """async function saveTime() { + const mInput = document.getElementById('time_total_minutes'); + const minVal = parseInt(mInput ? mInput.value : 0); + if (!minVal || minVal <= 0) { + alert('Indtast en gyldig varighed (minutter).'); + return; + } + const totalHours = minVal / 60; + """ + text = text[:save_start] + new_save + text[save_end:] + print("Updated saveTime first half.") + +# Note: saveTime uses `POST /api/v1/cases/${sagId}/time` or similar, wait let me check the actual fetch path. +# Let's check `saveTime` first before committing blindly. I will just do the above first, then verify `saveTime`. +with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f: + f.write(text) diff --git a/patcher.py b/patcher.py new file mode 100644 index 0000000..21b405d --- /dev/null +++ b/patcher.py @@ -0,0 +1 @@ +import os diff --git a/print_saveTime.py b/print_saveTime.py new file mode 100644 index 0000000..2a6aeab --- /dev/null +++ b/print_saveTime.py @@ -0,0 +1,9 @@ +import re +with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f: + text = f.read() + +s = text.find('async function saveTime()') +if s != -1: + e = text.find('async function createTodoStep', s) + if e == -1: e = s + 2000 + print(text[s:e]) diff --git a/result.txt b/result.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/result.txt @@ -0,0 +1 @@ +2 diff --git a/run_anydesk_import.py b/run_anydesk_import.py new file mode 100644 index 0000000..226940d --- /dev/null +++ b/run_anydesk_import.py @@ -0,0 +1,15 @@ +"""Run AnyDesk session import directly (bypasses HTTP auth)""" +import asyncio, sys, os +sys.path.insert(0, os.path.dirname(__file__)) +os.environ.setdefault("DATABASE_URL", "postgresql://bmc_hub:bmc_hub@localhost:5433/bmc_hub") + +from app.services.anydesk import AnyDeskService + +async def main(): + svc = AnyDeskService() + print("Credentials:", svc._get_credentials()) + print("\nFetching sessions (last 30 days, up to 1000)...") + result = await svc.fetch_sessions_from_api(days=30, limit=1000) + print(f"\nResult: {result}") + +asyncio.run(main()) diff --git a/script_0.js b/script_0.js new file mode 100644 index 0000000..448e306 --- /dev/null +++ b/script_0.js @@ -0,0 +1,43 @@ + + let caseCurrentUserId = null; + + async function ensureCaseCurrentUserId() { + if (caseCurrentUserId !== null) return caseCurrentUserId; + try { + const res = await fetch('/api/v1/auth/me', { credentials: 'include' }); + if (!res.ok) return null; + const me = await res.json(); + caseCurrentUserId = Number(me?.id) || null; + return caseCurrentUserId; + } catch (e) { + return null; + } + } + + async function ringOutFromCase(number) { + const clean = String(number || '').trim(); + if (!clean || clean === '-') { + alert('Intet gyldigt nummer at ringe til'); + return; + } + + const userId = await ensureCaseCurrentUserId(); + try { + const res = await fetch('/api/v1/telefoni/click-to-call', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ number: clean, user_id: userId }) + }); + + if (!res.ok) { + const t = await res.text(); + alert('Ring ud fejlede: ' + t); + return; + } + alert('Ringer ud via Yealink...'); + } catch (e) { + alert('Kunne ikke starte opkald'); + } + } + \ No newline at end of file diff --git a/script_1.js b/script_1.js new file mode 100644 index 0000000..f7df730 --- /dev/null +++ b/script_1.js @@ -0,0 +1,1433 @@ + + const caseId = {{ case.id }}; + const wikiCustomerId = {{ customer.id if customer else 'null' }}; + const wikiDefaultTag = "guide"; + let contactSearchTimeout; + let customerSearchTimeout; + let relationSearchTimeout; + let wikiSearchTimeout; + let selectedRelationCaseId = null; + const caseTypeKey = "{{ (case.template_key or case.type or 'ticket')|lower }}"; + + function forceCaseTabActivation(tabId) { + if (!tabId) return; + + const tabContent = document.getElementById('caseTabsContent'); + const targetPane = document.getElementById(tabId); + if (!tabContent || !targetPane) return; + + tabContent.querySelectorAll(':scope > .tab-pane').forEach((pane) => { + pane.classList.remove('show', 'active'); + pane.style.display = 'none'; + }); + + targetPane.classList.add('show', 'active'); + targetPane.style.display = 'block'; + + const tabButtons = document.querySelectorAll('#caseTabs [data-bs-target]'); + tabButtons.forEach((btn) => { + btn.classList.toggle('active', btn.getAttribute('data-bs-target') === `#${tabId}`); + }); + } + + window.moduleDisplayNames = { + 'relations': 'Relationer', + 'call-history': 'Opkaldshistorik', + 'files': 'Filer', + 'emails': 'E-mails', + 'pipeline': 'Salgspipeline', + 'hardware': 'Hardware', + 'locations': 'Lokationer', + 'contacts': 'Kontakter', + 'customers': 'Kunder', + 'tags': 'Tags', + 'wiki': 'Wiki', + 'todo-steps': 'Todo-opgaver', + 'time': 'Tid', + 'timetracking': 'Tidsforbrug', + 'solution': 'Løsning', + 'sales': 'Varekøb & salg', + 'subscription': 'Abonnement', + 'reminders': 'Påmindelser', + 'calendar': 'Kalender' + }; + let caseTypeModuleDefaults = {}; + + // Modal instances + let contactSearchModal, customerSearchModal, relationModal, contactInfoModal, createRelatedCaseModalInstance; + let currentContactInfo = null; + + // Initialize everything when DOM is ready + document.addEventListener('DOMContentLoaded', () => { + hydrateTopbarStatusOptions(); + // Initialize modals + contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal')); + customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal')); + relationModal = new bootstrap.Modal(document.getElementById('relationModal')); + contactInfoModal = new bootstrap.Modal(document.getElementById('contactInfoModal')); + createRelatedCaseModalInstance = new bootstrap.Modal(document.getElementById('createRelatedCaseModal')); + + // Setup search handlers + setupContactSearch(); + setupCustomerSearch(); + setupRelationSearch(); + updateRelationTypeHint(); + updateNewCaseRelationTypeHint(); + + // Initialize all tooltips on the page + document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { + bootstrap.Tooltip.getOrCreateInstance(el, { html: true, container: 'body' }); + }); + + Promise.all([loadModulePrefs(), loadCaseTypeModuleDefaultsSetting()]).then(() => applyViewFromTags()); + + // Set default context for keyboard shortcuts (Option+Shift+T) + if (window.setTagPickerContext) { + window.setTagPickerContext('case', {{ case.id }}, () => syncCaseTagsUi()); + } + + // Load Hardware & Locations + loadCaseHardware(); + loadCaseLocations(); + loadCaseWiki(); + loadTodoSteps(); + loadCaseTagsModule(); + loadCaseTagSuggestions(); + + // Keep suggestions fresh while user works on the case. + setInterval(loadCaseTagSuggestions, 30000); + + const wikiSearchInput = document.getElementById('wikiSearchInput'); + if (wikiSearchInput) { + wikiSearchInput.addEventListener('input', () => { + clearTimeout(wikiSearchTimeout); + wikiSearchTimeout = setTimeout(() => { + loadCaseWiki(wikiSearchInput.value || ''); + }, 300); + }); + } + + const todoForm = document.getElementById('todoStepForm'); + if (todoForm) { + todoForm.addEventListener('submit', createTodoStep); + } + + const caseTabs = document.getElementById('caseTabs'); + if (caseTabs) { + caseTabs.addEventListener('shown.bs.tab', async (event) => { + const targetSelector = event?.target?.getAttribute('data-bs-target') || ''; + const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector; + + forceCaseTabActivation(tabId); + + try { + if (tabId === 'sales' && typeof loadVarekobSalg === 'function') { + await loadVarekobSalg(); + } else if (tabId === 'timetracking' && typeof loadTimeTrackingTab === 'function') { + await loadTimeTrackingTab(); + } else if (tabId === 'subscription' && typeof loadSubscriptionForCase === 'function') { + await loadSubscriptionForCase(); + } else if (tabId === 'reminders') { + if (typeof loadReminders === 'function') await loadReminders(); + if (typeof loadCaseCalendar === 'function') await loadCaseCalendar(); + } + } catch (tabLoadError) { + console.error('Tab data reload failed:', tabLoadError); + } + }); + + caseTabs.addEventListener('click', (event) => { + const btn = event.target.closest('[data-bs-target]'); + if (!btn) return; + const targetSelector = btn.getAttribute('data-bs-target') || ''; + const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector; + if (tabId) { + setTimeout(() => forceCaseTabActivation(tabId), 0); + } + }); + } + + forceCaseTabActivation('details'); + + // Focus on title when create modal opens + const createModalEl = document.getElementById('createRelatedCaseModal'); + if (createModalEl) { + createModalEl.addEventListener('shown.bs.modal', function () { + document.getElementById('newCaseTitle').focus(); + }); + } + }); + + // Show modal functions + function showContactSearch() { + contactSearchModal.show(); + setTimeout(() => document.getElementById('contactSearch').focus(), 300); + } + + function showCustomerSearch() { + customerSearchModal.show(); + setTimeout(() => document.getElementById('customerSearch').focus(), 300); + } + + function showRelationModal() { + relationModal.show(); + updateRelationTypeHint(); + setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300); + } + + function showContactInfoModal(el) { + currentContactInfo = { + id: el.dataset.contactId, + name: el.dataset.name || '-', + title: el.dataset.title || '-', + company: el.dataset.company || '-', + email: el.dataset.email || '-', + phone: el.dataset.phone || '-', + mobile: el.dataset.mobile || '-', + role: el.dataset.role || '-', + isPrimary: el.dataset.isPrimary === 'true' + }; + + document.getElementById('contactInfoName').textContent = currentContactInfo.name; + document.getElementById('contactInfoTitle').textContent = currentContactInfo.title || '-'; + document.getElementById('contactInfoCompany').textContent = currentContactInfo.company || '-'; + document.getElementById('contactInfoEmail').textContent = currentContactInfo.email || '-'; + document.getElementById('contactInfoPhone').innerHTML = renderCasePhone(currentContactInfo.phone); + document.getElementById('contactInfoMobile').innerHTML = renderCaseMobile(currentContactInfo.mobile, currentContactInfo.name); + document.getElementById('contactInfoRole').textContent = currentContactInfo.role || '-'; + + const primaryBadge = document.getElementById('contactInfoPrimary'); + if (currentContactInfo.isPrimary) { + primaryBadge.classList.remove('d-none'); + } else { + primaryBadge.classList.add('d-none'); + } + + contactInfoModal.show(); + } + + function renderCasePhone(number) { + const clean = String(number || '').trim(); + if (!clean || clean === '-') return '-'; + return `${escapeHtml(clean)}`; + } + + function renderCaseMobile(number, name) { + const clean = String(number || '').trim(); + if (!clean || clean === '-') return '-'; + return ` + + `; + } + + function openContactRoleFromInfo() { + if (!currentContactInfo) return; + contactInfoModal.hide(); + openContactRoleModal( + currentContactInfo.id, + currentContactInfo.name, + currentContactInfo.role || 'Kontakt', + currentContactInfo.isPrimary + ); + } + + function showCreateRelatedModal() { + createRelatedCaseModalInstance.show(); + updateNewCaseRelationTypeHint(); + } + + function relationTypeMeaning(type) { + const map = { + 'Relateret til': { + icon: '🔗', + text: 'Sagerne hænger fagligt sammen, men ingen af dem er direkte afhængig af den anden.' + }, + 'Afledt af': { + icon: '↪', + text: 'Denne sag er opstået på baggrund af den anden sag (den anden er ophav/forløber).' + }, + 'Årsag til': { + icon: '➡', + text: 'Denne sag er årsag til den anden sag (du peger frem mod en konsekvens/opfølgning).' + }, + 'Blokkerer': { + icon: '⛔', + text: 'Arbejdet i denne sag stopper fremdrift i den anden sag, indtil blokeringen er løst.' + } + }; + return map[type] || null; + } + + function updateRelationTypeHint() { + const select = document.getElementById('relationTypeSelect'); + const hint = document.getElementById('relationTypeHint'); + if (!select || !hint) return; + + const meaning = relationTypeMeaning(select.value); + if (!meaning) { + hint.style.display = 'none'; + hint.innerHTML = ''; + return; + } + + hint.style.display = 'block'; + hint.innerHTML = `${meaning.icon} Betydning: ${meaning.text}`; + } + + function updateNewCaseRelationTypeHint() { + const select = document.getElementById('newCaseRelationType'); + const hint = document.getElementById('newCaseRelationTypeHint'); + if (!select || !hint) return; + + const selected = select.value; + if (selected === 'Afledt af') { + hint.innerHTML = '↪ Effekt: Nuværende sag markeres som afledt af den nye sag.'; + return; + } + if (selected === 'Årsag til') { + hint.innerHTML = '➡ Effekt: Nuværende sag markeres som årsag til den nye sag.'; + return; + } + if (selected === 'Blokkerer') { + hint.innerHTML = '⛔ Effekt: Nuværende sag markeres som blokering for den nye sag.'; + return; + } + + hint.innerHTML = '🔗 Effekt: Sagerne kobles fagligt uden direkte afhængighed.'; + } + + async function createRelatedCase() { + const title = document.getElementById('newCaseTitle').value; + const relationType = document.getElementById('newCaseRelationType').value; + const description = document.getElementById('newCaseDescription').value; + + if (!title) { + alert('Titel er påkrævet'); + return; + } + + // 1. Create the new case + try { + const caseResponse = await fetch('/api/v1/sag', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + titel: title, + beskrivelse: description, + customer_id: {{ case.customer_id }}, + status: 'åben' + }) + }); + + if (!caseResponse.ok) throw new Error('Kunne ikke oprette sag'); + const newCase = await caseResponse.json(); + + // 2. Create the relation + const relationResponse = await fetch(`/api/v1/sag/${caseId}/relationer`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + målsag_id: newCase.id, + relationstype: relationType + }) + }); + + if (!relationResponse.ok) throw new Error('Kunne ikke oprette relation'); + + // 3. Reload to show new relation + window.location.reload(); + + } catch (err) { + console.error('Error creating related case:', err); + alert('Der opstod en fejl: ' + err.message); + } + } + + function confirmDeleteCase() { + if(confirm('Slet denne sag?')) { + fetch('/api/v1/sag/{{ case.id }}', {method: 'DELETE'}) + .then(() => window.location='/sag'); + } + } + + // Contact Search + function setupContactSearch() { + const contactSearchInput = document.getElementById('contactSearch'); + contactSearchInput.addEventListener('input', function(e) { + clearTimeout(contactSearchTimeout); + const query = e.target.value.trim(); + + if (query.length < 2) { + document.getElementById('contactSearchResults').innerHTML = ''; + return; + } + + contactSearchTimeout = setTimeout(async () => { + try { + const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(query)}`); + const contacts = await response.json(); + + const resultsDiv = document.getElementById('contactSearchResults'); + if (contacts.length === 0) { + resultsDiv.innerHTML = '
Ingen kontakter fundet
'; + } else { + resultsDiv.innerHTML = contacts.map(c => ` +
+ ${c.first_name} ${c.last_name} +
${c.email || ''} ${c.user_company ? '(' + c.user_company + ')' : ''}
+
+ `).join(''); + } + } catch (err) { + console.error('Error searching contacts:', err); + } + }, 300); + }); + } + + async function addContact(caseId, contactId, contactName) { + try { + const response = await fetch(`/api/v1/sag/${caseId}/contacts`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({contact_id: contactId, role: 'Kontakt'}) + }); + + if (response.ok) { + contactSearchModal.hide(); + window.location.reload(); + } else { + const error = await response.json(); + alert(`Fejl: ${error.detail}`); + } + } catch (err) { + alert('Fejl ved tilføjelse af kontakt: ' + err.message); + } + } + + async function removeContact(caseId, contactId) { + if (confirm('Fjern denne kontakt fra sagen?')) { + const response = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, {method: 'DELETE'}); + if (response.ok) { + window.location.reload(); + } else { + alert('Fejl ved fjernelse af kontakt'); + } + } + } + + // Customer Search + function setupCustomerSearch() { + const customerSearchInput = document.getElementById('customerSearch'); + customerSearchInput.addEventListener('input', function(e) { + clearTimeout(customerSearchTimeout); + const query = e.target.value.trim(); + + if (query.length < 2) { + document.getElementById('customerSearchResults').innerHTML = ''; + return; + } + + customerSearchTimeout = setTimeout(async () => { + try { + const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`); + const customers = await response.json(); + + const resultsDiv = document.getElementById('customerSearchResults'); + if (customers.length === 0) { + resultsDiv.innerHTML = '
Ingen kunder fundet
'; + } else { + resultsDiv.innerHTML = customers.map(c => ` +
+ ${c.name} +
${c.email || ''} ${c.cvr_number ? '(CVR: ' + c.cvr_number + ')' : ''}
+
+ `).join(''); + } + } catch (err) { + console.error('Error searching customers:', err); + } + }, 300); + }); + } + + async function addCustomer(caseId, customerId, customerName) { + try { + const response = await fetch(`/api/v1/sag/${caseId}/customers`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({customer_id: customerId, role: 'Kunde'}) + }); + + if (response.ok) { + customerSearchModal.hide(); + window.location.reload(); + } else { + const error = await response.json(); + alert(`Fejl: ${error.detail}`); + } + } catch (err) { + alert('Fejl ved tilføjelse af kunde: ' + err.message); + } + } + + async function removeCustomer(caseId, customerId) { + if (confirm('Fjern denne kunde fra sagen?')) { + const response = await fetch(`/api/v1/sag/${caseId}/customers/${customerId}`, {method: 'DELETE'}); + if (response.ok) { + window.location.reload(); + } else { + alert('Fejl ved fjernelse af kunde'); + } + } + } + + // Relation Search - Enhanced version + let currentFocusIndex = -1; + let searchResults = []; + + function setupRelationSearch() { + const relationSearchInput = document.getElementById('relationCaseSearch'); + + // Input handler + relationSearchInput.addEventListener('input', function(e) { + clearTimeout(relationSearchTimeout); + const query = e.target.value.trim(); + currentFocusIndex = -1; + + if (query.length < 2) { + document.getElementById('relationSearchResults').innerHTML = ''; + document.getElementById('relationSearchResults').style.display = 'none'; + return; + } + + relationSearchTimeout = setTimeout(async () => { + try { + const response = await fetch(`/api/v1/search/sag?q=${encodeURIComponent(query)}`); + const cases = await response.json(); + searchResults = cases.filter(c => c.id !== caseId); + + renderRelationSearchResults(searchResults); + } catch (err) { + console.error('Error searching cases:', err); + } + }, 200); + }); + + // Keyboard navigation + relationSearchInput.addEventListener('keydown', function(e) { + const resultsDiv = document.getElementById('relationSearchResults'); + const items = resultsDiv.querySelectorAll('.relation-search-item'); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + currentFocusIndex = (currentFocusIndex + 1) % items.length; + updateFocusedItem(items); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + currentFocusIndex = currentFocusIndex <= 0 ? items.length - 1 : currentFocusIndex - 1; + updateFocusedItem(items); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (currentFocusIndex >= 0 && currentFocusIndex < items.length) { + items[currentFocusIndex].click(); + } + } + }); + } + + function updateFocusedItem(items) { + items.forEach((item, index) => { + if (index === currentFocusIndex) { + item.classList.add('active'); + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } else { + item.classList.remove('active'); + } + }); + } + + function renderRelationSearchResults(cases) { + const resultsDiv = document.getElementById('relationSearchResults'); + + if (cases.length === 0) { + resultsDiv.innerHTML = '
Ingen sager fundet
'; + resultsDiv.style.display = 'block'; + return; + } + + // Group by status + const grouped = {}; + cases.forEach(c => { + const status = c.status || 'ukendt'; + if (!grouped[status]) grouped[status] = []; + grouped[status].push(c); + }); + + let html = '
'; + + // Sort status groups: åben first, then others + const statusOrder = ['åben', 'under behandling', 'afventer', 'løst', 'lukket']; + const sortedStatuses = Object.keys(grouped).sort((a, b) => { + const aIndex = statusOrder.indexOf(a); + const bIndex = statusOrder.indexOf(b); + if (aIndex === -1 && bIndex === -1) return a.localeCompare(b); + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + return aIndex - bIndex; + }); + + sortedStatuses.forEach(status => { + const statusCases = grouped[status]; + + // Status group header + html += ` +
+ ${status} + ${statusCases.length} +
+ `; + + statusCases.forEach(c => { + const createdDate = c.created_at ? new Date(c.created_at).toLocaleDateString('da-DK') : 'N/A'; + const beskrivelse = c.beskrivelse ? c.beskrivelse.substring(0, 80) + '...' : ''; + const customerName = c.customer_name || ''; + const safeTitle = (c.titel || '').replace(/"/g, '"').replace(/'/g, '''); + const safeCustomer = customerName.replace(/"/g, '"').replace(/'/g, '''); + + html += ` +
+
+
+
+ #${c.id} + ${escapeHtml(c.titel)} +
+ ${c.customer_name ? ` +
+ ${escapeHtml(c.customer_name)} +
+ ` : ''} + ${beskrivelse ? ` +
${escapeHtml(beskrivelse)}
+ ` : ''} +
+
+
${createdDate}
+
+
+
+ `; + }); + }); + + html += '
'; + resultsDiv.innerHTML = html; + resultsDiv.style.display = 'block'; + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function selectRelationCase(caseIdValue, caseTitel, customerName, status) { + selectedRelationCaseId = caseIdValue; + + // Update preview + const previewDiv = document.getElementById('selectedCasePreview'); + const titleDiv = document.getElementById('selectedCaseTitle'); + + titleDiv.innerHTML = ` +
+ #${caseIdValue} + ${escapeHtml(caseTitel)} + ${status} +
+ ${customerName ? `
${escapeHtml(customerName)}
` : ''} + `; + + previewDiv.style.display = 'block'; + document.getElementById('relationSearchResults').innerHTML = ''; + document.getElementById('relationSearchResults').style.display = 'none'; + document.getElementById('relationCaseSearch').value = ''; + + // Enable add button + updateAddRelationButton(); + } + + function clearSelectedRelationCase() { + selectedRelationCaseId = null; + document.getElementById('selectedCasePreview').style.display = 'none'; + document.getElementById('relationCaseSearch').value = ''; + document.getElementById('relationCaseSearch').focus(); + updateAddRelationButton(); + } + + function updateAddRelationButton() { + const btn = document.getElementById('addRelationBtn'); + const relationType = document.getElementById('relationTypeSelect').value; + btn.disabled = !selectedRelationCaseId || !relationType; + } + + async function addRelation() { + const relationType = document.getElementById('relationTypeSelect').value; + const btn = document.getElementById('addRelationBtn'); + + if (!selectedRelationCaseId) { + alert('Vælg en sag først'); + return; + } + + if (!relationType) { + alert('Vælg en relationstype'); + return; + } + + // Disable button during request + btn.disabled = true; + btn.innerHTML = 'Tilføjer...'; + + try { + const response = await fetch(`/api/v1/sag/${caseId}/relationer`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + målsag_id: selectedRelationCaseId, + relationstype: relationType + }) + }); + + if (response.ok) { + selectedRelationCaseId = null; + relationModal.hide(); + window.location.reload(); + } else { + const error = await response.json(); + alert(`Fejl: ${error.detail}`); + btn.disabled = false; + btn.innerHTML = ' Tilføj relation'; + } + } catch (err) { + alert('Fejl ved tilføjelse af relation: ' + err.message); + btn.disabled = false; + btn.innerHTML = ' Tilføj relation'; + } + } + + async function deleteRelation(relationId) { + if (confirm('Fjern denne relation?')) { + const response = await fetch(`/api/v1/sag/${caseId}/relationer/${relationId}`, {method: 'DELETE'}); + if (response.ok) { + window.location.reload(); + } else { + alert('Fejl ved fjernelse af relation'); + } + } + } + + // ============ Hardware Handling ============ + async function loadCaseHardware() { + try { + const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`); + if (!res.ok) { + let message = 'Kunne ikke hente hardware.'; + try { + const err = await res.json(); + if (err?.detail) { + message = err.detail; + } + } catch (_) { + } + throw new Error(message); + } + const hardware = await res.json(); + if (!Array.isArray(hardware)) { + throw new Error('Uventet svar fra serveren ved hardware-hentning.'); + } + const container = document.getElementById('hardware-list'); + + if (hardware.length === 0) { + container.innerHTML = '
Ingen hardware tilknyttet
'; + setModuleContentState('hardware', false); + return; + } + + container.innerHTML = ` +
+ Enhed + SN + Slet +
+ ${hardware.map(h => ` +
+
+ + ${h.brand} ${h.model} + +
+ ${h.serial_number || '-'} + +
+ `).join('')} + `; + setModuleContentState('hardware', true); + } catch (e) { + console.error("Error loading hardware:", e); + const message = (e?.message || '').trim() || 'Fejl ved hentning'; + document.getElementById('hardware-list').innerHTML = `
${escapeHtml(message)}
`; + setModuleContentState('hardware', true); + } + } + + async function promptLinkHardware() { + const id = prompt("Indtast Hardware ID:"); + if (!id) return; + + try { + const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ hardware_id: parseInt(id) }) + }); + + if (!res.ok) throw await res.json(); + loadCaseHardware(); + } catch (e) { + alert("Fejl: " + (e.detail || e.message)); + } + } + + async function unlinkHardware(hwId) { + if(!confirm("Fjern link til dette hardware?")) return; + try { + await fetch(`/api/v1/sag/{{ case.id }}/hardware/${hwId}`, { method: 'DELETE' }); + loadCaseHardware(); + } catch (e) { + alert("Fejl ved sletning"); + } + } + + // ============ Location Handling ============ + async function loadCaseLocations() { + try { + const res = await fetch(`/api/v1/sag/{{ case.id }}/locations`); + if (!res.ok) { + let message = 'Kunne ikke hente lokationer.'; + try { + const err = await res.json(); + if (err?.detail) { + message = err.detail; + } + } catch (_) { + } + throw new Error(message); + } + const locations = await res.json(); + if (!Array.isArray(locations)) { + throw new Error('Uventet svar fra serveren ved lokations-hentning.'); + } + const container = document.getElementById('locations-list'); + + if (locations.length === 0) { + container.innerHTML = '
Ingen lokationer tilknyttet
'; + setModuleContentState('locations', false); + return; + } + + container.innerHTML = ` +
+ Navn + Type + Slet +
+ ${locations.map(l => ` +
+
+ + ${l.name} +
+ ${l.location_type || '-'} + +
+ `).join('')} + `; + setModuleContentState('locations', true); + } catch (e) { + console.error("Error loading locations:", e); + const message = (e?.message || '').trim() || 'Fejl ved hentning'; + document.getElementById('locations-list').innerHTML = `
${escapeHtml(message)}
`; + setModuleContentState('locations', true); + } + } + + // ============ Wiki Handling ============ + async function loadCaseWiki(searchValue = '') { + const container = document.getElementById('wiki-list'); + if (!container) return; + + if (!wikiCustomerId) { + container.innerHTML = '
Ingen kunde tilknyttet
'; + setModuleContentState('wiki', false); + return; + } + + container.innerHTML = '
Henter wiki...
'; + + const params = new URLSearchParams(); + const trimmed = (searchValue || '').trim(); + if (trimmed) { + params.set('query', trimmed); + } else { + params.set('tag', wikiDefaultTag); + } + + try { + const res = await fetch(`/api/v1/wiki/customers/${wikiCustomerId}/pages?${params.toString()}`); + if (!res.ok) { + throw new Error('Kunne ikke hente Wiki'); + } + const payload = await res.json(); + if (payload.errors && payload.errors.length) { + container.innerHTML = '
Wiki API fejlede
'; + setModuleContentState('wiki', true); + return; + } + + const pages = Array.isArray(payload.pages) ? payload.pages : []; + + if (!pages.length) { + container.innerHTML = '
Ingen sider fundet
'; + setModuleContentState('wiki', false); + return; + } + + container.innerHTML = pages.map(page => { + const title = page.title || page.path || 'Wiki side'; + const url = page.url || page.path || '#'; + const safeUrl = url ? encodeURI(url) : '#'; + return ` + +
${escapeHtml(title)}
+ ${escapeHtml(page.path || '')} +
+ `; + }).join(''); + setModuleContentState('wiki', true); + } catch (e) { + console.error('Error loading Wiki:', e); + container.innerHTML = '
Fejl ved hentning
'; + setModuleContentState('wiki', true); + } + } + + async function loadCaseTagsModule() { + const moduleContainer = document.getElementById('case-tags-module'); + if (!moduleContainer) return; + + try { + const response = await fetch(`/api/v1/tags/entity/case/${caseId}`); + if (!response.ok) throw new Error('Kunne ikke hente tags'); + + const tags = await response.json(); + if (!Array.isArray(tags) || tags.length === 0) { + moduleContainer.innerHTML = '
Ingen tags paaa sagen endnu
'; + setModuleContentState('tags', false); + return; + } + + moduleContainer.innerHTML = tags.map((tag) => ` + + ${tag.icon ? ` ` : ''}${escapeHtml(tag.name)} + + + `).join(''); + + setModuleContentState('tags', true); + } catch (error) { + console.error('Error loading case tags module:', error); + moduleContainer.innerHTML = '
Fejl ved hentning af tags
'; + setModuleContentState('tags', true); + } + } + + async function loadCaseTagSuggestions() { + const suggestionsContainer = document.getElementById('case-tag-suggestions'); + if (!suggestionsContainer) return; + + try { + const response = await fetch(`/api/v1/tags/entity/case/${caseId}/suggestions`); + if (!response.ok) throw new Error('Kunne ikke hente forslag'); + + const suggestions = await response.json(); + if (!Array.isArray(suggestions) || suggestions.length === 0) { + suggestionsContainer.innerHTML = '
Ingen nye forslag lige nu
'; + return; + } + + suggestionsContainer.innerHTML = suggestions.slice(0, 8).map((item) => { + const tag = item.tag || {}; + const matched = Array.isArray(item.matched_words) ? item.matched_words.join(', ') : ''; + return ` +
+
+ + ${tag.icon ? ` ` : ''}${escapeHtml(tag.name || 'Tag')} + + ${matched ? `
Match: ${escapeHtml(matched)}
` : ''} +
+ +
+ `; + }).join(''); + } catch (error) { + console.error('Error loading tag suggestions:', error); + suggestionsContainer.innerHTML = '
Fejl ved forslag
'; + } + } + + async function applySuggestedCaseTag(tagId) { + if (!tagId) return; + try { + const response = await fetch('/api/v1/tags/entity', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entity_type: 'case', entity_id: caseId, tag_id: tagId }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Kunne ikke tilfoeje tag'); + } + + await syncCaseTagsUi(); + if (typeof showNotification === 'function') { + showNotification('Tag tilfoejet', 'success'); + } + } catch (error) { + alert('Fejl: ' + error.message); + } + } + + async function removeCaseTagAndSync(tagId) { + await window.removeEntityTag('case', caseId, tagId, 'case-tags-module'); + await syncCaseTagsUi(); + } + + async function syncCaseTagsUi() { + if (window.renderEntityTags) { + await window.renderEntityTags('case', caseId, 'case-tags'); + } + await loadCaseTagsModule(); + await loadCaseTagSuggestions(); + } + + let todoUserId = null; + + function getTodoUserId() { + if (todoUserId) return todoUserId; + const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); + if (token) { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + todoUserId = payload.sub || payload.user_id; + return todoUserId; + } catch (e) { + console.warn('Could not decode token for todo user_id'); + } + } + const metaTag = document.querySelector('meta[name="user-id"]'); + if (metaTag) { + todoUserId = metaTag.getAttribute('content'); + } + return todoUserId; + } + + function formatTodoDate(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return date.toLocaleDateString('da-DK'); + } + + function formatTodoDateTime(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return date.toLocaleString('da-DK', { hour: '2-digit', minute: '2-digit', hour12: false }); + } + + function getNextTodoOverrideStorageKey() { + return `case:${caseId}:nextTodoStepId`; + } + + function getNextTodoOverrideId() { + const raw = localStorage.getItem(getNextTodoOverrideStorageKey()); + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; + } + + function setNextTodoOverrideId(stepIdOrNull) { + const key = getNextTodoOverrideStorageKey(); + if (stepIdOrNull === null || stepIdOrNull === undefined) { + localStorage.removeItem(key); + return; + } + localStorage.setItem(key, String(stepIdOrNull)); + } + + function renderTodoSteps(steps) { + const list = document.getElementById('todo-steps-list'); + if (!list) return; + + updateTopbarNextTodo(steps || []); + + const escapeAttr = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + + if (!steps || steps.length === 0) { + list.innerHTML = '
Ingen opgaver endnu
'; + setModuleContentState('todo-steps', false); + return; + } + + const openSteps = steps.filter(step => !step.is_done); + const doneSteps = steps.filter(step => step.is_done); + const nextOverrideId = getNextTodoOverrideId(); + + const renderStep = (step) => { + const createdBy = step.created_by_name || 'Ukendt'; + const completedBy = step.completed_by_name || 'Ukendt'; + const dueLabel = step.due_date ? formatTodoDate(step.due_date) : '-'; + const createdLabel = formatTodoDateTime(step.created_at); + const completedLabel = step.completed_at ? formatTodoDateTime(step.completed_at) : null; + const isNextEffective = !step.is_done && (!!step.is_next || (nextOverrideId !== null && step.id === nextOverrideId)); + const statusBadge = step.is_done + ? 'Færdig' + : `${isNextEffective ? 'Næste' : 'Åben'}`; + const toggleLabel = step.is_done ? 'Genåbn' : 'Færdig'; + const toggleClass = step.is_done ? 'btn-outline-secondary' : 'btn-outline-success'; + const nextLabel = isNextEffective ? 'Fjern som næste' : 'Sæt som næste'; + const nextClass = isNextEffective ? 'btn-primary' : 'btn-outline-primary'; + const tooltipText = [ + `Oprettet af: ${createdBy}`, + `Oprettet: ${createdLabel}`, + `Forfald: ${dueLabel}`, + isNextEffective ? 'Markeret som næste opgave' : null, + step.is_done && completedLabel ? `Færdiggjort af: ${completedBy}` : null, + step.is_done && completedLabel ? `Færdiggjort: ${completedLabel}` : null + ].filter(Boolean).join('
'); + + return ` +
+
+
+ ${step.title} + +
+
+ ${statusBadge} +
+ ${!step.is_done ? ` + + ` : ''} + + +
+
+
+ ${step.description ? `
${step.description}
` : ''} +
+ Forfald: ${dueLabel} +
+
+ `; + }; + + const sections = []; + if (openSteps.length) { + sections.push(` +
Åbne (${openSteps.length})
+ ${openSteps.map(renderStep).join('')} + `); + } + if (doneSteps.length) { + sections.push(` +
Færdige (${doneSteps.length})
+ ${doneSteps.map(renderStep).join('')} + `); + } + + list.innerHTML = sections.join(''); + if (window.bootstrap) { + list.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => { + bootstrap.Tooltip.getOrCreateInstance(el, { + trigger: 'hover focus', + placement: 'left', + container: 'body', + html: true + }); + }); + } + setModuleContentState('todo-steps', true); + } + + function updateTopbarNextTodo(steps) { + const valueEl = document.getElementById('topbarNextTodoValue'); + const metaEl = document.getElementById('topbarNextTodoMeta'); + if (!valueEl || !metaEl) return; + + const openSteps = Array.isArray(steps) ? steps.filter((step) => !step.is_done) : []; + if (!openSteps.length) { + valueEl.textContent = 'Ingen åbne todo-opgaver'; + metaEl.textContent = 'Alt er færdigt'; + setNextTodoOverrideId(null); + return; + } + + const nextOverrideId = getNextTodoOverrideId(); + const overrideStep = nextOverrideId ? openSteps.find((step) => step.id === nextOverrideId) : null; + const nextStep = overrideStep || openSteps.find((step) => !!step.is_next) || openSteps[0]; + + if (!overrideStep && nextOverrideId) { + setNextTodoOverrideId(null); + } + + valueEl.textContent = nextStep.title || 'Untitled todo'; + metaEl.textContent = nextStep.due_date + ? `Forfald: ${formatTodoDate(nextStep.due_date)}` + : 'Ingen forfaldsdato'; + } + + async function setNextTodoStep(stepId, isNext) { + try { + const res = await fetch(`/api/v1/sag/todo-steps/${stepId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_next: isNext, is_done: false }) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({})); + throw new Error(error.detail || 'Kunne ikke opdatere næste-opgave'); + } + + setNextTodoOverrideId(isNext ? stepId : null); + await loadTodoSteps(); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + async function loadTodoSteps() { + const list = document.getElementById('todo-steps-list'); + if (!list) return; + list.innerHTML = '
Henter opgaver...
'; + + try { + const res = await fetch(`/api/v1/sag/${caseId}/todo-steps`); + if (!res.ok) throw new Error('Kunne ikke hente steps'); + const steps = await res.json(); + renderTodoSteps(steps || []); + } catch (e) { + console.error('Error loading todo steps:', e); + list.innerHTML = '
Fejl ved hentning
'; + setModuleContentState('todo-steps', true); + } + } + + function toggleTodoStepForm(forceOpen = null) { + const form = document.getElementById('todoStepForm'); + const moduleCard = document.querySelector('[data-module="todo-steps"]'); + if (!form) return; + + const shouldOpen = forceOpen === null ? form.classList.contains('d-none') : Boolean(forceOpen); + + if (shouldOpen) { + form.classList.remove('d-none'); + if (moduleCard) { + moduleCard.classList.remove('module-empty-compact'); + } + const titleInput = document.getElementById('todoStepTitle'); + if (titleInput) { + titleInput.focus(); + } + } else { + form.classList.add('d-none'); + applyViewLayout(currentCaseView); + } + } + + async function createTodoStep(event) { + event.preventDefault(); + const titleInput = document.getElementById('todoStepTitle'); + const descInput = document.getElementById('todoStepDescription'); + const dueInput = document.getElementById('todoStepDueDate'); + if (!titleInput) return; + + const title = titleInput.value.trim(); + if (!title) { + alert('Titel er paakraevet'); + return; + } + + const userId = getTodoUserId(); + if (!userId) { + alert('Mangler bruger-id. Log ind igen.'); + return; + } + + try { + const res = await fetch(`/api/v1/sag/${caseId}/todo-steps?user_id=${userId}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + description: descInput.value.trim() || null, + due_date: dueInput.value || null + }) + } + ); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Kunne ikke oprette step'); + } + titleInput.value = ''; + descInput.value = ''; + dueInput.value = ''; + await loadTodoSteps(); + toggleTodoStepForm(false); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + async function toggleTodoStep(stepId, isDone) { + const userId = getTodoUserId(); + if (!userId) { + alert('Mangler bruger-id. Log ind igen.'); + return; + } + try { + const res = await fetch(`/api/v1/sag/todo-steps/${stepId}?user_id=${userId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_done: isDone }) + } + ); + if (!res.ok) throw new Error('Kunne ikke opdatere step'); + await loadTodoSteps(); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + async function deleteTodoStep(stepId) { + if (!confirm('Slet dette step?')) return; + try { + const res = await fetch(`/api/v1/sag/todo-steps/${stepId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Kunne ikke slette step'); + await loadTodoSteps(); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + async function promptLinkLocation() { + const id = prompt("Indtast Lokations ID:"); + if (!id) return; + + try { + const res = await fetch(`/api/v1/sag/{{ case.id }}/locations`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ location_id: parseInt(id) }) + }); + + if (!res.ok) throw await res.json(); + loadCaseLocations(); + } catch (e) { + alert("Fejl: " + (e.detail || e.message)); + } + } + + async function unlinkLocation(locId) { + if(!confirm("Fjern link til denne lokation?")) return; + try { + const res = await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || 'Kunne ikke fjerne lokation'); + } + loadCaseLocations(); + } catch (e) { + alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl')); + } + } + + + // Initialize relation search when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setupRelationSearch); + } else { + setupRelationSearch(); + } + + // Kontakt Modal functions + function showKontaktModal() { + const modal = new bootstrap.Modal(document.getElementById('kontaktModal')); + modal.show(); + } + + // Afdeling Modal functions + function showAfdelingModal() { + const modal = new bootstrap.Modal(document.getElementById('afdelingModal')); + modal.show(); + } + + async function updateAfdeling() { + const newAfdeling = document.getElementById('afdelingInput').value.trim(); + + try { + const response = await fetch('/api/v1/customers/{{ customer.id if customer else 0 }}', { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ department: newAfdeling }) + }); + + if (!response.ok) throw await response.json(); + + // Reload page to show updated data + window.location.reload(); + } catch (e) { + alert("Fejl ved opdatering: " + (e.detail || e.message)); + } + } + \ No newline at end of file diff --git a/script_10.js b/script_10.js new file mode 100644 index 0000000..389d836 --- /dev/null +++ b/script_10.js @@ -0,0 +1,918 @@ + + (function () { + 'use strict'; + + let _openPopover = null; + + // ── helpers ─────────────────────────────────────────────────────── + function closeAllPopovers() { + document.querySelectorAll('.rel-qa-menu').forEach(el => el.remove()); + _openPopover = null; + } + document.addEventListener('click', function(e) { + if (!e.target.closest('.rel-qa-menu') && !e.target.closest('.btn-rel-action')) closeAllPopovers(); + }); + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') closeAllPopovers(); + }); + + function popoverPos(btn) { + const r = btn.getBoundingClientRect(); + return { top: r.bottom + window.scrollY + 4, left: r.left + window.scrollX }; + } + + function esc(s) { return String(s||'').replace(/&/g,'&').replace(//g,'>'); } + + // ── load global entity tags into rel-tag-row divs (using global tag system) ── + async function loadAllRelationTags() { + const rows = Array.from(document.querySelectorAll('.rel-tag-row')); + if (!rows.length) return; + // Wait briefly for tag-picker.js to initialize + const renderFn = () => window.renderEntityTags; + await new Promise(res => { const t = setInterval(() => { if (renderFn()) { clearInterval(t); res(); } }, 50); setTimeout(() => { clearInterval(t); res(); }, 2000); }); + await Promise.all(rows.map(async el => { + const caseId = parseInt(el.id.replace('rel-tags-', '')); + if (isNaN(caseId) || !window.renderEntityTags) return; + await window.renderEntityTags('case', caseId, el.id); + })); + } + + // ── tag button → opens global tag picker ────────────────────────── + window.openRelTagPopover = function(caseId) { + if (!window.showTagPicker) return; + window.showTagPicker('case', caseId, () => { + if (window.renderEntityTags) window.renderEntityTags('case', caseId, 'rel-tags-' + caseId); + }); + }; + + // ── quick action menu ───────────────────────────────────────────── + const QA_ITEMS = [ + { icon: 'bi-person-check', label: 'Tildel sag', action: 'assign' }, + { icon: 'bi-clock', label: 'Tidregistrering', action: 'time' }, + { icon: 'bi-chat-left-text', label: 'Kommentar', action: 'note' }, + { icon: 'bi-bell', label: 'Påmindelse', action: 'reminder' }, + { icon: 'bi-graph-up-arrow', label: 'Salgspipeline', action: 'pipeline' }, + { icon: 'bi-paperclip', label: 'Filer', action: 'files' }, + { icon: 'bi-cpu', label: 'Hardware', action: 'hardware' }, + { icon: 'bi-check2-square', label: 'Opgave', action: 'todo' }, + { icon: 'bi-lightbulb', label: 'Løsning', action: 'solution' }, + { icon: 'bi-bag', label: 'Varekøb & salg', action: 'sales' }, + { icon: 'bi-arrow-repeat', label: 'Abonnement', action: 'subscription' }, + { icon: 'bi-envelope', label: 'Send email', action: 'email' }, + ]; + + // cache pipeline presence per caseId so we only fetch once per page load + const _pipelineCache = {}; + + window.openRelQaMenu = async function(caseId, caseTitle, btn) { + closeAllPopovers(); + btn.classList.add('active'); + const pos = popoverPos(btn); + const menu = document.createElement('div'); + menu.className = 'rel-qa-menu'; + menu.style.cssText = `position:absolute;top:${pos.top}px;left:${Math.max(0, pos.left - 120)}px;`; + menu.innerHTML = `
SAG-${caseId}
` + + `
`; + document.body.appendChild(menu); + _openPopover = menu; + + // Fetch case data to check pipeline presence (cached) + if (!(_pipelineCache[caseId] !== undefined)) { + try { + const r = await fetch(`/api/v1/sag/${caseId}`, { credentials: 'include' }); + if (r.ok) { + const d = await r.json(); + _pipelineCache[caseId] = !!(d.pipeline_stage_id || d.pipeline_amount || d.pipeline_description); + } else { + _pipelineCache[caseId] = false; + } + } catch { _pipelineCache[caseId] = false; } + } + + const hasPipeline = _pipelineCache[caseId]; + + // Filter: hide pipeline item if case already has one; show "Se pipeline" link instead + const items = QA_ITEMS.filter(i => i.action !== 'pipeline' || !hasPipeline); + const extra = hasPipeline + ? `
Pipeline (se sagen)
` + : ''; + + if (!_openPopover || _openPopover !== menu) return; // closed before fetch returned + menu.innerHTML = `
SAG-${caseId}
` + + items.map(item => + `
${esc(item.label)}
` + ).join('') + + extra; + }; + + function getRelQaPrimaryButton() { + const sidePanel = document.getElementById('caseAddSidePanel'); + if (sidePanel && sidePanel.classList.contains('open')) { + return sidePanel.querySelector('#relQaModalFooter .btn-primary'); + } + return document.querySelector('#relQaModalEl .btn-primary'); + } + + function closeRelQaSurfaceAfterSave() { + const sidePanel = document.getElementById('caseAddSidePanel'); + const panelOpen = !!(sidePanel && sidePanel.classList.contains('open')); + + const relModalEl = document.getElementById('relQaModalEl'); + const relModalInstance = relModalEl ? bootstrap.Modal.getInstance(relModalEl) : null; + if (relModalInstance) { + relModalInstance.hide(); + } + + // In sidepanel mode, refresh to reflect new persisted data across modules. + if (panelOpen) { + setTimeout(() => window.location.reload(), 120); + } + } + + window.relQaAction = function(action, caseId, caseTitle) { + closeAllPopovers(); + if (action === 'time') openRelTimeModal(caseId, caseTitle); + else if (action === 'email') openRelEmailModal(caseId, caseTitle); + else if (action === 'note') openRelNoteModal(caseId, caseTitle); + else if (action === 'reminder') openRelReminderModal(caseId, caseTitle); + else if (action === 'todo') openRelTodoModal(caseId, caseTitle); + else if (action === 'assign') openRelAssignModal(caseId, caseTitle); + else if (action === 'pipeline') openRelPipelineModal(caseId, caseTitle); + else if (action === 'files') openRelFilesModal(caseId, caseTitle); + else if (action === 'hardware') openRelHardwareModal(caseId, caseTitle); + else if (action === 'solution') openRelSolutionModal(caseId, caseTitle); + else if (action === 'sales') openRelSalesModal(caseId, caseTitle); + else if (action === 'subscription') openRelSubscriptionModal(caseId, caseTitle); + else window.open(`/sag/${caseId}`, '_blank'); + }; + + // ── Quick Pipeline modal ────────────────────────────────────────── + window.openRelPipelineModal = function(caseId, caseTitle) { + _showRelModal( + `Salgspipeline`, + `
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
`, + `` + ); + }; + + window._submitRelPipeline = async function(caseId) { + const stage = document.getElementById('rqp_stage').value; + const amount = document.getElementById('rqp_amount').value; + const prob = document.getElementById('rqp_prob').value; + const desc = document.getElementById('rqp_desc').value; + const payload = {}; + if (stage) payload.stage_id = parseInt(stage); + if (amount) payload.amount = parseFloat(amount); + if (prob) payload.probability = parseInt(prob); + if (desc) payload.description = desc; + if (!Object.keys(payload).length) { if (typeof showNotification === 'function') showNotification('Udfyld mindst ét felt', 'warning'); return; } + const saveBtn = getRelQaPrimaryButton(); + if (saveBtn) { saveBtn.disabled = true; } + try { + const r = await fetch(`/api/v1/sag/${caseId}/pipeline`, { method: 'PATCH', credentials: 'include', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) }); + if (r.ok) { + closeRelQaSurfaceAfterSave(); + if (typeof showNotification === 'function') showNotification('Pipeline opdateret ✓', 'success'); + } else { + const d = await r.json().catch(()=>({})); + if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error'); + if (saveBtn) saveBtn.disabled = false; + } + } catch { if (saveBtn) saveBtn.disabled = false; } + }; + + // ── Quick Files modal ───────────────────────────────────────────── + window.openRelFilesModal = function(caseId, caseTitle) { + _showRelModal( + `Upload fil`, + `
+
+ + +
+
+ + +
`, + `` + ); + }; + + window._submitRelFiles = async function(caseId) { + const fileInput = document.getElementById('rqf_file'); + if (!fileInput.files.length) { if (typeof showNotification === 'function') showNotification('Vælg mindst én fil', 'warning'); return; } + const saveBtn = getRelQaPrimaryButton(); + if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = ' Uploader…'; } + let success = 0; let failed = 0; + for (const file of fileInput.files) { + try { + const fd = new FormData(); + fd.append('file', file); + const desc = document.getElementById('rqf_desc').value; + if (desc) fd.append('description', desc); + const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd }); + if (r.ok) success++; else failed++; + } catch { failed++; } + } + closeRelQaSurfaceAfterSave(); + if (typeof showNotification === 'function') { + if (failed === 0) showNotification(`${success} fil(er) uploadet ✓`, 'success'); + else showNotification(`${success} ok, ${failed} fejlede`, 'warning'); + } + }; + + // ── Quick Hardware modal ────────────────────────────────────────── + window.openRelHardwareModal = async function(caseId, caseTitle) { + _showRelModal( + `Hardware`, + `
+
+ + + +
+
+
+ + +
+ `, + `` + ); + // Wire up search + const inp = document.getElementById('rqhw_search'); + const res = document.getElementById('rqhw_results'); + let _hwTimer; + inp.addEventListener('input', () => { + clearTimeout(_hwTimer); + _hwTimer = setTimeout(async () => { + const q = inp.value.trim(); + if (q.length < 2) { res.style.display='none'; return; } + try { + const r = await fetch(`/api/v1/search/hardware?q=${encodeURIComponent(q)}`, { credentials: 'include' }); + if (!r.ok) return; + const items = await r.json(); + if (!items.length) { res.innerHTML = '
Ingen resultater
'; res.style.display='block'; return; } + res.innerHTML = items.slice(0,10).map(h => + `
${esc(h.name||'')} ${esc(h.serial_number||'')}
` + ).join(''); + res.style.display = 'block'; + res.querySelectorAll('.hw-opt').forEach(el => el.addEventListener('click', () => { + document.getElementById('rqhw_id').value = el.dataset.id; + document.getElementById('rqhw_selected').textContent = '✓ Valgt: ' + el.dataset.label; + inp.value = el.dataset.label; + res.style.display = 'none'; + })); + } catch {} + }, 300); + }); + }; + + window._submitRelHardware = async function(caseId) { + const hwId = document.getElementById('rqhw_id').value; + if (!hwId) { if (typeof showNotification === 'function') showNotification('Vælg hardware fra listen', 'warning'); return; } + const saveBtn = getRelQaPrimaryButton(); + if (saveBtn) saveBtn.disabled = true; + try { + const r = await fetch(`/api/v1/sag/${caseId}/hardware`, { + method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ hardware_id: parseInt(hwId), note: document.getElementById('rqhw_note').value }) + }); + if (r.ok) { + closeRelQaSurfaceAfterSave(); + if (typeof showNotification === 'function') showNotification('Hardware tilknyttet ✓', 'success'); + } else { + const d = await r.json().catch(()=>({})); + if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error'); + if (saveBtn) saveBtn.disabled = false; + } + } catch { if (saveBtn) saveBtn.disabled = false; } + }; + + // ── Quick Løsning modal ─────────────────────────────────────────── + window.openRelSolutionModal = function(caseId, caseTitle) { + const today = new Date().toISOString().split('T')[0]; + _showRelModal( + `Løsning`, + `
+
+ + +
+
+ + +
+
+ + +
+
+ + +
`, + `` + ); + }; + + window._submitRelSolution = async function(caseId) { + const title = document.getElementById('rqs_title').value.trim(); + if (!title) { if (typeof showNotification === 'function') showNotification('Angiv en titel', 'warning'); return; } + const saveBtn = getRelQaPrimaryButton(); + if (saveBtn) saveBtn.disabled = true; + try { + const r = await fetch(`/api/v1/sag/${caseId}/solution`, { + method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ + sag_id: caseId, + title, + solution_type: document.getElementById('rqs_type').value, + result: document.getElementById('rqs_result').value, + description: document.getElementById('rqs_desc').value, + }) + }); + if (r.ok) { + closeRelQaSurfaceAfterSave(); + if (typeof showNotification === 'function') showNotification('Løsning gemt ✓', 'success'); + } else { + const d = await r.json().catch(()=>({})); + if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error'); + if (saveBtn) saveBtn.disabled = false; + } + } catch { if (saveBtn) saveBtn.disabled = false; } + }; + + // ── Quick Varekøb & Salg modal ──────────────────────────────────── + window.openRelSalesModal = function(caseId, caseTitle) { + const today = new Date().toISOString().split('T')[0]; + _showRelModal( + `Varekøb & salg`, + `
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
`, + `` + ); + // Auto-calculate total when qty/uprice changes + setTimeout(() => { + const qtyEl = document.getElementById('rqsl_qty'); + const uprEl = document.getElementById('rqsl_uprice'); + const totEl = document.getElementById('rqsl_total'); + function calcTotal() { + const q = parseFloat(qtyEl.value) || 0; + const u = parseFloat(uprEl.value) || 0; + if (q && u) totEl.value = (q * u).toFixed(2); + } + qtyEl.addEventListener('input', calcTotal); + uprEl.addEventListener('input', calcTotal); + }, 50); + }; + + window._submitRelSales = async function(caseId) { + const desc = document.getElementById('rqsl_desc').value.trim(); + const total = parseFloat(document.getElementById('rqsl_total').value); + if (!desc) { if (typeof showNotification === 'function') showNotification('Angiv beskrivelse', 'warning'); return; } + if (!total) { if (typeof showNotification === 'function') showNotification('Angiv beløb', 'warning'); return; } + const saveBtn = getRelQaPrimaryButton(); + if (saveBtn) saveBtn.disabled = true; + try { + const r = await fetch(`/api/v1/sag/${caseId}/sale-items`, { + method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ + type: document.getElementById('rqsl_type').value, + description: desc, + quantity: parseFloat(document.getElementById('rqsl_qty').value) || 1, + unit_price: parseFloat(document.getElementById('rqsl_uprice').value) || null, + amount: total, + line_date: document.getElementById('rqsl_date').value || null, + status: 'draft', + }) + }); + if (r.ok) { + closeRelQaSurfaceAfterSave(); + if (typeof showNotification === 'function') showNotification('Varelinje oprettet ✓', 'success'); + } else { + const d = await r.json().catch(()=>({})); + if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error'); + if (saveBtn) saveBtn.disabled = false; + } + } catch { if (saveBtn) saveBtn.disabled = false; } + }; + + // ── Quick Abonnement modal ──────────────────────────────────────── + window.openRelSubscriptionModal = function(caseId, caseTitle) { + const today = new Date().toISOString().split('T')[0]; + _showRelModal( + `Abonnement`, + `
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
Varelinje
+
+
+
+
+
+
+
+ + +
`, + `` + ); + }; + + window._submitRelSubscription = async function(caseId) { + const interval = document.getElementById('rqsub_interval').value; + const day = parseInt(document.getElementById('rqsub_day').value); + const startDate = document.getElementById('rqsub_start').value; + const liDesc = document.getElementById('rqsub_li_desc').value.trim(); + const liQty = parseFloat(document.getElementById('rqsub_li_qty').value) || 1; + const liPrice = parseFloat(document.getElementById('rqsub_li_price').value) || 0; + if (!startDate) { if (typeof showNotification === 'function') showNotification('Angiv startdato', 'warning'); return; } + if (!liDesc || !liPrice) { if (typeof showNotification === 'function') showNotification('Udfyld varelinje (beskrivelse + pris)', 'warning'); return; } + const saveBtn = getRelQaPrimaryButton(); + if (saveBtn) saveBtn.disabled = true; + try { + const r = await fetch('/api/v1/sag-subscriptions', { + method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ + sag_id: caseId, + billing_interval: interval, + billing_day: day, + start_date: startDate, + notes: document.getElementById('rqsub_notes').value || null, + line_items: [{ description: liDesc, quantity: liQty, unit_price: liPrice }] + }) + }); + if (r.ok) { + closeRelQaSurfaceAfterSave(); + if (typeof showNotification === 'function') showNotification('Abonnement oprettet ✓', 'success'); + } else { + const d = await r.json().catch(()=>({})); + if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error'); + if (saveBtn) saveBtn.disabled = false; + } + } catch { if (saveBtn) saveBtn.disabled = false; } + }; + + // ── Quick Time modal ────────────────────────────────────────────── + window.openRelTimeModal = function(caseId, caseTitle) { + const today = new Date().toISOString().split('T')[0]; + _showRelModal( + `Tidregistrering`, + `
+
+
+
+
+
+
+
+
+
+
+
+
+
`, + `` + ); + }; + + window._submitRelTime = async function(caseId) { + const h = parseInt(document.getElementById('rqt_h').value) || 0; + const m = parseInt(document.getElementById('rqt_m').value) || 0; + const totalHours = parseFloat((h + m / 60).toFixed(4)); + if (totalHours <= 0) { + if (typeof showNotification === 'function') showNotification('Angiv tid (timer/minutter)', 'warning'); + return; + } + const billing = document.getElementById('rqt_billing')?.value || 'invoice'; + const payload = { + sag_id: caseId, + worked_date: document.getElementById('rqt_date').value, + original_hours: totalHours, + description: document.getElementById('rqt_desc').value, + billing_method: billing, + is_internal: billing === 'internal', + }; + const saveBtn = getRelQaPrimaryButton(); + if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = ''; } + try { + const r = await fetch('/api/v1/timetracking/entries/internal', { method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) }); + if (r.ok) { + closeRelQaSurfaceAfterSave(); + if (typeof showNotification === 'function') showNotification('Tid registreret ✓', 'success'); + } else { + const d = await r.json().catch(()=>({})); + if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl ved registrering', 'error'); + if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = 'Gem'; } + } + } catch { if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = 'Gem'; } } + }; + + // ── Quick Email modal ───────────────────────────────────────────── + window.openRelEmailModal = function(caseId, caseTitle) { + const defaultRecipient = typeof getDefaultCaseRecipient === 'function' ? getDefaultCaseRecipient() : ''; + const defaultSubject = `Sag #${caseId}: `; + const attachmentOptions = Array.isArray(sagFilesCache) && sagFilesCache.length + ? sagFilesCache + .map((file) => { + const fileId = Number(file.id); + const filename = esc(file.filename || `Fil ${fileId}`); + return ``; + }) + .join('') + : ''; + + _showRelModal( + `Email`, + `
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
`, + `` + ); + }; + + window._submitRelEmail = async function(caseId) { + const toInput = document.getElementById('rqe_to'); + const ccInput = document.getElementById('rqe_cc'); + const bccInput = document.getElementById('rqe_bcc'); + const subjectInput = document.getElementById('rqe_subject'); + const bodyInput = document.getElementById('rqe_body'); + const attachmentSelect = document.getElementById('rqe_attachment_ids'); + const statusEl = document.getElementById('rqe_status'); + const saveBtn = getRelQaPrimaryButton(); + + if (!toInput || !subjectInput || !bodyInput || !statusEl) return; + + const to = parseEmailField(toInput.value); + const cc = parseEmailField(ccInput?.value || ''); + const bcc = parseEmailField(bccInput?.value || ''); + const subject = (subjectInput.value || '').trim(); + const bodyText = (bodyInput.value || '').trim(); + const attachmentFileIds = Array.from(attachmentSelect?.selectedOptions || []) + .map((opt) => Number(opt.value)) + .filter((id) => Number.isInteger(id) && id > 0); + + if (!to.length) { + if (typeof showNotification === 'function') showNotification('Udfyld mindst en modtager.', 'warning'); + return; + } + if (!subject) { + if (typeof showNotification === 'function') showNotification('Udfyld emne.', 'warning'); + return; + } + if (!bodyText) { + if (typeof showNotification === 'function') showNotification('Udfyld besked.', 'warning'); + return; + } + + if (saveBtn) { + saveBtn.disabled = true; + saveBtn.innerHTML = 'Sender...'; + } + statusEl.className = 'small text-muted'; + statusEl.textContent = 'Sender e-mail...'; + + try { + const res = await fetch(`/api/v1/sag/${caseId}/emails/send`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + to, + cc, + bcc, + subject, + body_text: bodyText, + attachment_file_ids: attachmentFileIds, + thread_email_id: selectedLinkedEmailId || null, + thread_key: linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key || null + }) + }); + + if (!res.ok) { + let message = `HTTP ${res.status} ${res.statusText || 'Send failed'}`; + try { + const responseText = await res.text(); + if (responseText) { + try { + const err = JSON.parse(responseText); + if (err?.detail) { + message = err.detail; + } else if (err?.message) { + message = err.message; + } + } catch (_) { + message = responseText.slice(0, 500); + } + } + } catch (_) { + } + throw new Error(message); + } + + statusEl.className = 'small text-success'; + statusEl.textContent = 'E-mail sendt.'; + if (typeof loadLinkedEmails === 'function') { + loadLinkedEmails(); + } + if (typeof showNotification === 'function') showNotification('E-mail sendt.', 'success'); + + const relModalEl = document.getElementById('relQaModalEl'); + const relModal = relModalEl ? bootstrap.Modal.getInstance(relModalEl) : null; + if (relModal) relModal.hide(); + } catch (error) { + statusEl.className = 'small text-danger'; + statusEl.textContent = error?.message || 'Email send failed (ukendt fejl)'; + if (typeof showNotification === 'function') showNotification(statusEl.textContent, 'error'); + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.innerHTML = 'Send email'; + } + return; + } + + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.innerHTML = 'Send email'; + } + }; + + // ── Quick Kommentar modal ───────────────────────────────────────── + window.openRelNoteModal = function(caseId, caseTitle) { + _showRelModal( + `Kommentar`, + `
+ `, + `` + ); + }; + + window._submitRelNote = async function(caseId) { + const text = document.getElementById('rqn_text').value.trim(); + if (!text) return; + const saveBtn = document.querySelector('#relQaModalEl .btn-primary'); + if (saveBtn) { saveBtn.disabled = true; } + try { + const r = await fetch(`/api/v1/sag/${caseId}/kommentarer`, { + method: 'POST', credentials: 'include', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ forfatter: 'Hurtig kommentar', indhold: text }) + }); + if (r.ok) { + closeRelQaSurfaceAfterSave(); + if (typeof showNotification === 'function') showNotification('Kommentar tilføjet ✓', 'success'); + } else { + const d = await r.json().catch(()=>({})); + if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl ved gemning', 'error'); + if (saveBtn) saveBtn.disabled = false; + } + } catch { if (saveBtn) saveBtn.disabled = false; } + }; + + // ── Quick Opgave modal ──────────────────────────────────────────── + window.openRelTodoModal = function(caseId, caseTitle) { + const today = new Date().toISOString().split('T')[0]; + _showRelModal( + `Opgave`, + `
+
+
+
+
`, + `` + ); + }; + + window._submitRelTodo = async function(caseId) { + const title = document.getElementById('rqtd_title').value.trim(); + if (!title) { if (typeof showNotification === 'function') showNotification('Angiv opgavetitel', 'warning'); return; } + const due = document.getElementById('rqtd_due').value || null; + const saveBtn = getRelQaPrimaryButton(); + if (saveBtn) { saveBtn.disabled = true; } + try { + const r = await fetch(`/api/v1/sag/${caseId}/todos`, { + method: 'POST', credentials: 'include', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ titel: title, frist: due, sag_id: caseId }) + }); + if (r.ok) { + closeRelQaSurfaceAfterSave(); + if (typeof showNotification === 'function') showNotification('Opgave oprettet ✓', 'success'); + } else { + const d = await r.json().catch(()=>({})); + if (typeof showNotification === 'function') showNotification(d.detail || 'Opgave-endpoint ikke tilgængeligt endnu', 'warning'); + if (saveBtn) saveBtn.disabled = false; + } + } catch { if (saveBtn) saveBtn.disabled = false; } + }; + + // ── Quick Tildel sag modal ──────────────────────────────────────── + window.openRelAssignModal = async function(caseId, caseTitle) { + _showRelModal( + `Tildel sag`, + `
+ + `, + `` + ); + try { + const r = await fetch('/api/v1/users', { credentials: 'include' }); + if (r.ok) { + const users = await r.json(); + const sel = document.getElementById('rqa_user'); + if (sel) sel.innerHTML = '' + + users.map(u => ``).join(''); + } + } catch {} + }; + + window._submitRelAssign = async function(caseId) { + const userId = document.getElementById('rqa_user')?.value; + const saveBtn = getRelQaPrimaryButton(); + if (saveBtn) { saveBtn.disabled = true; } + try { + const r = await fetch(`/api/v1/sag/${caseId}`, { + method: 'PATCH', credentials: 'include', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ ansvarlig_bruger_id: userId ? parseInt(userId) : null }) + }); + if (r.ok) { + closeRelQaSurfaceAfterSave(); + if (typeof showNotification === 'function') showNotification('Sag tildelt ✓', 'success'); + } else { + const d = await r.json().catch(()=>({})); + if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl ved tildeling', 'error'); + if (saveBtn) saveBtn.disabled = false; + } + } catch { if (saveBtn) saveBtn.disabled = false; } + }; + + // ── Quick Reminder modal ────────────────────────────────────────── + window.openRelReminderModal = function(caseId, caseTitle) { + const tmr = new Date(); tmr.setDate(tmr.getDate()+1); + const tmrStr = tmr.toISOString().slice(0,16); + _showRelModal( + `Påmindelse`, + `
+
+
+
+
`, + `` + ); + }; + + window._submitRelReminder = async function(caseId) { + const payload = { sag_id: caseId, remind_at: document.getElementById('rqr_at').value, message: document.getElementById('rqr_msg').value }; + const saveBtn = getRelQaPrimaryButton(); + if (saveBtn) { saveBtn.disabled = true; } + try { + const r = await fetch('/api/v1/reminders', { + method: 'POST', credentials: 'include', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify(payload) + }); + if (r.ok) { + closeRelQaSurfaceAfterSave(); + if (typeof showNotification === 'function') showNotification('Påmindelse oprettet', 'success'); + } else { if (saveBtn) saveBtn.disabled = false; } + } catch { if (saveBtn) saveBtn.disabled = false; } + }; + + // ── shared modal helper ─────────────────────────────────────────── + window._showRelModal = function(title, bodyHtml, footerBtns) { + let el = document.getElementById('relQaModalEl'); + if (!el) { + el = document.createElement('div'); + el.id = 'relQaModalEl'; + el.className = 'modal fade'; + el.tabIndex = -1; + el.innerHTML = ``; + document.body.appendChild(el); + } + document.getElementById('relQaModalTitle').innerHTML = title; + document.getElementById('relQaModalBody').innerHTML = bodyHtml; + const footer = document.getElementById('relQaModalFooter'); + // Remove old action buttons (keep Annuller) + footer.querySelectorAll('.btn-primary').forEach(b => b.remove()); + if (footerBtns) footer.insertAdjacentHTML('afterbegin', footerBtns); + new bootstrap.Modal(el).show(); + }; + + // ── init on page load ───────────────────────────────────────────── + document.addEventListener('DOMContentLoaded', loadAllRelationTags); + + })(); + \ No newline at end of file diff --git a/script_11.js b/script_11.js new file mode 100644 index 0000000..8cabbb2 --- /dev/null +++ b/script_11.js @@ -0,0 +1,186 @@ + + (function () { + const SAG_ID = {{ case.id }}; + let _historyLoaded = false; + + window.rewriteCaseDescriptionWithApproval = async function () { + const ta = document.getElementById('beskrivelse-textarea'); + const rewriteBtn = document.getElementById('beskrivelse-rewrite-btn'); + if (!ta) return; + + const source = (ta.value || '').trim(); + if (!source) { + if (typeof showNotification === 'function') showNotification('Skriv en beskrivelse først', 'warning'); + else alert('Skriv en beskrivelse først'); + return; + } + + const originalHtml = rewriteBtn?.innerHTML || ''; + if (rewriteBtn) { + rewriteBtn.disabled = true; + rewriteBtn.innerHTML = 'Renskriver...'; + } + + try { + const rewriteEndpoints = ['/api/v1/rewrite-text', '/api/v1/sag/rewrite-text', '/api/v1/emails/rewrite-text']; + let payload = null; + let lastError = null; + + for (const endpoint of rewriteEndpoints) { + const response = await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: source, context: 'case' }) + }); + + if (response.ok) { + payload = await response.json(); + lastError = null; + break; + } + + let detail = `HTTP ${response.status}`; + try { + const err = await response.json(); + if (err?.detail) detail = err.detail; + } catch (_) {} + + lastError = new Error(detail); + + // Retry next endpoint for common route mismatch cases. + if (![404, 405].includes(response.status)) { + break; + } + } + + if (!payload) { + throw lastError || new Error('Kunne ikke hente renskrivningsforslag'); + } + + const rewrittenRaw = String(payload?.rewritten_text || '').trim(); + const descMatch = rewrittenRaw.match(/(?:^|\n)Beskrivelse:\s*\n([\s\S]*)$/i); + const rewritten = descMatch?.[1] ? descMatch[1].trim() : rewrittenRaw; + + openRewriteReviewModal({ + title: 'Sagsbeskrivelse', + originalText: source, + rewrittenText: rewritten, + applyToTarget: (nextText) => { + ta.value = nextText; + bootstrap.Modal.getOrCreateInstance(document.getElementById('rewritePreviewModal')).hide(); + } + }); + } catch (e) { + console.error(e); + if (typeof showNotification === 'function') showNotification('Kunne ikke renskrive beskrivelse', 'error'); + else alert(`Kunne ikke renskrive beskrivelse: ${e.message || 'Ukendt fejl'}`); + } finally { + if (rewriteBtn) { + rewriteBtn.disabled = false; + rewriteBtn.innerHTML = originalHtml; + } + } + }; + + window.startBeskrivelsEdit = function () { + const current = document.getElementById('beskrivelse-text').innerText.trim(); + document.getElementById('beskrivelse-textarea').value = current; + document.getElementById('beskrivelse-view').classList.add('d-none'); + document.getElementById('beskrivelse-edit-btn')?.classList.add('d-none'); + document.getElementById('beskrivelse-editor').classList.remove('d-none'); + document.getElementById('beskrivelse-textarea').focus(); + }; + + window.cancelBeskrivelsEdit = function () { + document.getElementById('beskrivelse-editor').classList.add('d-none'); + document.getElementById('beskrivelse-view').classList.remove('d-none'); + document.getElementById('beskrivelse-edit-btn')?.classList.remove('d-none'); + }; + + window.saveBeskrivelsEdit = async function () { + const ta = document.getElementById('beskrivelse-textarea'); + const saveBtn = document.getElementById('beskrivelse-save-btn'); + const newVal = ta.value; + if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = 'Gemmer...'; } + try { + const res = await fetch(`/api/v1/sag/${SAG_ID}/beskrivelse`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ beskrivelse: newVal }) + }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + // Update view + const textEl = document.getElementById('beskrivelse-text'); + textEl.innerText = data.beskrivelse || ''; + const emptyEl = document.getElementById('beskrivelse-empty'); + if (emptyEl) emptyEl.style.display = data.beskrivelse ? 'none' : ''; + cancelBeskrivelsEdit(); + // Show history and mark stale + document.getElementById('beskrivelse-history-wrap').classList.remove('d-none'); + _historyLoaded = false; + if (typeof showNotification === 'function') showNotification('Beskrivelse gemt', 'success'); + } catch (e) { + console.error(e); + if (typeof showNotification === 'function') showNotification('Kunne ikke gemme beskrivelse', 'error'); + } finally { + if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = 'Gem'; } + } + }; + + window.loadBeskrivelsHistory = async function () { + if (_historyLoaded) return; + const list = document.getElementById('beskrivelse-history-list'); + try { + const res = await fetch(`/api/v1/sag/${SAG_ID}/beskrivelse/history`, { credentials: 'include' }); + if (!res.ok) throw new Error('failed'); + const rows = await res.json(); + _historyLoaded = true; + const label = document.getElementById('beskrivelse-history-label'); + if (!rows.length) { + label.textContent = 'Historik (0)'; + list.innerHTML = '
Ingen historik endnu.
'; + return; + } + label.textContent = `Historik (${rows.length})`; + const esc = s => String(s || '').replace(/&/g,'&').replace(//g,'>'); + const trunc = (s, n) => s && s.length > n ? s.substring(0, n) + '…' : (s || ''); + list.innerHTML = rows.map(h => { + const d = new Date(h.changed_at); + const when = d.toLocaleDateString('da-DK', {day:'2-digit',month:'2-digit',year:'numeric'}) + + ' ' + d.toLocaleTimeString('da-DK', {hour:'2-digit',minute:'2-digit'}); + const who = esc(h.changed_by_name || 'Ukendt'); + const before = h.beskrivelse_before ? esc(trunc(h.beskrivelse_before, 150)) : 'tom'; + const after = h.beskrivelse_after ? esc(trunc(h.beskrivelse_after, 150)) : 'tom'; + return `
+
+ ${who} + ${when} +
+
+
Før${before}
+
Efter${after}
+
+
`; + }).join(''); + } catch (e) { + list.innerHTML = '
Kunne ikke indlæse historik.
'; + } + }; + + // Keyboard shortcuts + document.addEventListener('keydown', function (e) { + const editor = document.getElementById('beskrivelse-editor'); + if (!editor || editor.classList.contains('d-none')) return; + if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); saveBeskrivelsEdit(); } + if (e.key === 'Escape') { e.preventDefault(); cancelBeskrivelsEdit(); } + }); + + // Show history toggle if description already exists on page load + if ((document.getElementById('beskrivelse-text').innerText || '').trim()) { + document.getElementById('beskrivelse-history-wrap').classList.remove('d-none'); + } + })(); + \ No newline at end of file diff --git a/script_2.js b/script_2.js new file mode 100644 index 0000000..0329df6 --- /dev/null +++ b/script_2.js @@ -0,0 +1,578 @@ + + function _escapeCommentHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function _removeQuotedMailLines(text) { + const source = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = source.split('\n'); + const kept = []; + + const headerRe = /^(fra|from|sent|date|dato|to|til|emne|subject|cc):\s*/i; + const originalMessageRe = /^(original message|oprindelig besked|videresendt besked)/i; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const trimmed = line.trim(); + + if (trimmed.startsWith('>')) break; + if (originalMessageRe.test(trimmed)) break; + + if (/^[-_]{3,}$/.test(trimmed)) { + const lookahead = lines.slice(i + 1, i + 4); + if (lookahead.some((candidate) => headerRe.test(String(candidate || '').trim()))) { + break; + } + } + + if (i > 0 && headerRe.test(trimmed) && String(lines[i - 1] || '').trim() === '') { + break; + } + + kept.push(line); + } + + while (kept.length > 0 && String(kept[kept.length - 1] || '').trim() === '') { + kept.pop(); + } + + return kept.join('\n').trim(); + } + + function _parseEmailComment(rawText) { + const normalized = String(rawText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const emailIdMatch = normalized.match(/^Email-ID:\s*(\d+)\s*$/m); + const emailId = emailIdMatch ? Number(emailIdMatch[1]) : null; + const withoutMeta = normalized.replace(/^Email-ID:\s*\d+\s*\n?/m, '').trim(); + return { + emailId, + visibleText: _removeQuotedMailLines(withoutMeta) + }; + } + + function _formatEmailHeaderTimestamp(value) { + if (!value) return ''; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return String(value); + return parsed.toLocaleString('da-DK'); + } + + function _buildEmailHeaderAndBody(visibleText) { + const text = String(visibleText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); + const lines = text.split('\n'); + + let idx = 0; + let typeLabel = 'Indgaaende email'; + const firstLine = String(lines[0] || '').trim(); + if (/^📧\s*Indgående email/i.test(firstLine)) { + typeLabel = 'Indgaaende email'; + idx = 1; + } else if (/^📧\s*Udgående email/i.test(firstLine)) { + typeLabel = 'Udgaaende email'; + idx = 1; + } + + let fra = ''; + let til = ''; + let cc = ''; + let emne = ''; + let modtaget = ''; + + while (idx < lines.length) { + const line = String(lines[idx] || '').trim(); + if (!line) { + idx += 1; + break; + } + if (/^Fra:\s*/i.test(line)) fra = line.replace(/^Fra:\s*/i, '').trim(); + else if (/^Til:\s*/i.test(line)) til = line.replace(/^Til:\s*/i, '').trim(); + else if (/^Cc:\s*/i.test(line)) cc = line.replace(/^Cc:\s*/i, '').trim(); + else if (/^Emne:\s*/i.test(line)) emne = line.replace(/^Emne:\s*/i, '').trim(); + else if (/^Modtaget:\s*/i.test(line)) modtaget = line.replace(/^Modtaget:\s*/i, '').trim(); + else break; + idx += 1; + } + + const bodyText = lines.slice(idx).join('\n').trim(); + const summaryParts = [typeLabel]; + if (fra) summaryParts.push(`Fra: ${fra}`); + if (til) summaryParts.push(`Til: ${til}`); + if (cc) summaryParts.push(`Cc: ${cc}`); + if (emne) summaryParts.push(`Emne: ${emne}`); + if (modtaget) summaryParts.push(`Modtaget: ${_formatEmailHeaderTimestamp(modtaget)}`); + + return { + summary: summaryParts.join(' • '), + bodyText + }; + } + + function _extractEmailHeaderFields(visibleText) { + const text = String(visibleText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); + const lines = text.split('\n'); + let idx = 0; + + const firstLine = String(lines[0] || '').trim(); + const isOutgoing = /^📧\s*Udgående email/i.test(firstLine); + if (/^📧\s*(Indgående|Udgående)\s+email/i.test(firstLine)) { + idx = 1; + } + + let fra = ''; + let til = ''; + let emne = ''; + let modtaget = ''; + + while (idx < lines.length) { + const line = String(lines[idx] || '').trim(); + if (!line) break; + if (/^Fra:\s*/i.test(line)) fra = line.replace(/^Fra:\s*/i, '').trim(); + else if (/^Til:\s*/i.test(line)) til = line.replace(/^Til:\s*/i, '').trim(); + else if (/^Emne:\s*/i.test(line)) emne = line.replace(/^Emne:\s*/i, '').trim(); + else if (/^Modtaget:\s*/i.test(line)) modtaget = line.replace(/^Modtaget:\s*/i, '').trim(); + else break; + idx += 1; + } + + return { fra, til, emne, modtaget, isOutgoing }; + } + + function _normalizeReplySubject(value) { + const subject = String(value || '').trim(); + return subject.replace(/^(re|fw|fwd)\s*:\s*/ig, '').toLowerCase(); + } + + function _findBestLinkedEmailByHeader(header) { + const targetSubject = _normalizeReplySubject(header?.emne || ''); + const targetFrom = String(header?.fra || '').trim().toLowerCase(); + const targetTo = String(header?.til || '').trim().toLowerCase(); + + const candidates = (linkedEmailsCache || []).filter((email) => { + const emailSubject = _normalizeReplySubject(email?.subject || ''); + if (targetSubject && emailSubject !== targetSubject) { + return false; + } + + const sender = String(email?.sender_email || email?.sender_name || '').toLowerCase(); + const recipient = String(email?.recipient_email || '').toLowerCase(); + + if (targetFrom && sender && sender.includes(targetFrom)) { + return true; + } + if (targetTo && recipient && recipient.includes(targetTo)) { + return true; + } + + return !targetFrom && !targetTo; + }); + + if (!candidates.length) { + return null; + } + + candidates.sort((a, b) => { + const aTs = a?.received_date ? new Date(a.received_date).getTime() : 0; + const bTs = b?.received_date ? new Date(b.received_date).getTime() : 0; + return bTs - aTs; + }); + + return Number(candidates[0]?.id) || null; + } + + function _extractEmailAddress(value) { + const raw = String(value || '').trim(); + const match = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); + return match ? match[0] : raw; + } + + function _commentInitials(name) { + const clean = String(name || '').trim(); + if (!clean) return 'EM'; + const parts = clean.split(/\s+/).filter(Boolean); + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase(); + } + + function _formatCommentTime(value) { + const parsed = new Date(value || Date.now()); + if (Number.isNaN(parsed.getTime())) return ''; + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(parsed.getDate())}/${pad(parsed.getMonth() + 1)}-${parsed.getFullYear()} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`; + } + + function _refreshCommentCountBadge() { + const container = document.getElementById('comments-container'); + const badge = document.querySelector('#beskrivelse-comments-wrap .badge.bg-secondary'); + if (!container || !badge) return; + badge.textContent = String(container.querySelectorAll('.comment-item').length); + } + + function prependCommentToThread(comment) { + const container = document.getElementById('comments-container'); + if (!container || !comment || !comment.indhold) return; + + const emptyState = container.querySelector('p.text-center.text-muted.my-3'); + if (emptyState) emptyState.remove(); + + const author = String(comment.forfatter || 'Email Bot'); + const createdAtIso = String(comment.created_at || new Date().toISOString()); + const createdAtMs = new Date(createdAtIso).getTime(); + const createdAtUnix = Number.isFinite(createdAtMs) ? Math.floor(createdAtMs / 1000) : Math.floor(Date.now() / 1000); + + const item = document.createElement('div'); + item.className = 'comment-item comment-system'; + item.dataset.createdAt = String(createdAtUnix); + + const meta = document.createElement('div'); + meta.className = 'comment-meta'; + meta.innerHTML = ` + ${_escapeCommentHtml(_commentInitials(author))} + ${_escapeCommentHtml(author)} + ${_escapeCommentHtml(_formatCommentTime(createdAtIso))} + `; + + const body = document.createElement('div'); + body.className = 'comment-body'; + body.setAttribute('data-comment-raw', String(comment.indhold)); + body.textContent = String(comment.indhold); + + item.appendChild(meta); + item.appendChild(body); + container.insertBefore(item, container.firstChild); + + processCommentBodies(); + sortCommentsNewestFirst(); + _refreshCommentCountBadge(); + } + + let activeCommentQuickReply = null; + + window.closeInlineCommentQuickReply = function() { + const host = document.getElementById('comment-quick-reply-host'); + if (host) host.innerHTML = ''; + activeCommentQuickReply = null; + } + + window.sendInlineCommentQuickReply = async function() { + const host = document.getElementById('comment-quick-reply-host'); + const textarea = document.getElementById('commentQuickReplyText'); + const sendBtn = document.getElementById('commentQuickReplySendBtn'); + const statusEl = document.getElementById('commentQuickReplyStatus'); + if (!host || !textarea || !sendBtn || !statusEl || !activeCommentQuickReply) return; + + const bodyText = String(textarea.value || '').trim(); + if (!bodyText) { + statusEl.className = 'comment-quick-reply-status text-danger'; + statusEl.textContent = 'Skriv et svar'; + return; + } + + const recipient = _extractEmailAddress(activeCommentQuickReply.recipient); + if (!recipient || recipient.indexOf('@') === -1) { + statusEl.className = 'comment-quick-reply-status text-danger'; + statusEl.textContent = 'Ingen gyldig modtager fundet i kommentaren'; + return; + } + + sendBtn.disabled = true; + statusEl.className = 'comment-quick-reply-status'; + statusEl.textContent = 'Sender...'; + + try { + await loadLinkedEmails(); + + let threadEmailId = Number(activeCommentQuickReply.emailId) || null; + if (!threadEmailId) { + threadEmailId = _findBestLinkedEmailByHeader(activeCommentQuickReply.header); + } + + let threadKey = null; + if (threadEmailId) { + const linked = linkedEmailsCache.find((entry) => Number(entry.id) === Number(threadEmailId)); + threadKey = linked?.thread_key || linked?.resolved_thread_key || null; + } + + const response = await fetch(`/api/v1/sag/${caseIds}/emails/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to: [recipient], + subject: activeCommentQuickReply.subject, + body_text: bodyText, + thread_email_id: threadEmailId, + thread_key: threadKey + }) + }); + + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const payload = await response.json(); + message = payload?.detail || payload?.message || message; + } catch (_) { + } + throw new Error(message); + } + + const result = await response.json(); + if (result?.comment) { + prependCommentToThread(result.comment); + } + + statusEl.className = 'comment-quick-reply-status text-success'; + statusEl.textContent = 'Svar sendt'; + textarea.value = ''; + await loadLinkedEmails(); + setTimeout(() => { + window.closeInlineCommentQuickReply(); + }, 500); + } catch (error) { + statusEl.className = 'comment-quick-reply-status text-danger'; + statusEl.textContent = error?.message || 'Kunne ikke sende svar'; + } finally { + sendBtn.disabled = false; + } + } + + function openInlineCommentQuickReply(rawText, emailId) { + const host = document.getElementById('comment-quick-reply-host'); + if (!host) return; + + const parsed = _parseEmailComment(rawText || ''); + const header = _extractEmailHeaderFields(parsed.visibleText || ''); + const fallbackRecipient = header.isOutgoing ? (header.til || header.fra) : (header.fra || header.til); + const subject = /^re:\s*/i.test(header.emne || '') + ? (header.emne || `Sag #${caseIds}`) + : `Re: ${header.emne || `Sag #${caseIds}`}`; + + activeCommentQuickReply = { + rawText, + header, + emailId: Number(emailId) || parsed.emailId || null, + recipient: fallbackRecipient, + subject + }; + + host.innerHTML = ` +
+
Quick svar til ${_escapeCommentHtml(String(fallbackRecipient || 'ukendt modtager'))}
+ +
+
+
+ + +
+
+
+ `; + + const textarea = document.getElementById('commentQuickReplyText'); + if (textarea) { + textarea.focus(); + } + } + + async function quickReplyToEmailFromCommentText(rawText) { + openCaseEmailTab(); + + const parsed = _parseEmailComment(rawText || ''); + const header = _extractEmailHeaderFields(parsed.visibleText || ''); + + try { + await loadLinkedEmails(); + + const matchedEmailId = _findBestLinkedEmailByHeader(header); + if (matchedEmailId) { + await loadLinkedEmailDetail(matchedEmailId); + openReplyToLinkedEmail(); + return; + } + } catch (error) { + console.error('Kunne ikke finde trådmail fra kommentar:', error); + } + + const composeModalEl = document.getElementById('caseEmailComposeModal'); + if (!composeModalEl) return; + + const toInput = document.getElementById('caseEmailTo'); + const subjectInput = document.getElementById('caseEmailSubject'); + const bodyInput = document.getElementById('caseEmailBody'); + + const fallbackRecipient = (header.isOutgoing ? header.til : header.fra) || header.fra || header.til || ''; + if (toInput && !toInput.value.trim() && fallbackRecipient) { + toInput.value = fallbackRecipient; + } + + if (subjectInput && !subjectInput.value.trim()) { + subjectInput.value = escapeHtmlForInput( + /^re:\s*/i.test(header.emne || '') + ? (header.emne || `Sag #${caseIds}`) + : `Re: ${header.emne || `Sag #${caseIds}`}` + ); + } + + if (bodyInput && !bodyInput.value.trim()) { + bodyInput.value = `\n\n---\nFra: ${header.fra || '-'}\nDato: ${header.modtaget || '-'}\nEmne: ${header.emne || '(Ingen emne)'}\n`; + } + + bootstrap.Modal.getOrCreateInstance(composeModalEl).show(); + } + + async function openEmailFromComment(emailId) { + const parsedId = Number(emailId); + if (!Number.isFinite(parsedId)) return; + + if (typeof openCaseEmailTab === 'function') { + openCaseEmailTab(); + } + + try { + if (typeof loadLinkedEmails === 'function') { + await loadLinkedEmails(); + } + if (typeof loadLinkedEmailDetail === 'function') { + await loadLinkedEmailDetail(parsedId); + } + const emailTabPane = document.getElementById('emails'); + if (emailTabPane) { + emailTabPane.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } catch (error) { + console.error('Kunne ikke åbne email fra kommentar:', error); + } + } + + function processCommentBodies() { + const commentItems = Array.from(document.querySelectorAll('#comments-container .comment-item')); + commentItems.forEach((item) => { + const body = item.querySelector('.comment-body'); + if (!body) return; + + const rawText = body.dataset.commentRaw || body.textContent || ''; + if (!item.classList.contains('comment-system')) { + body.innerHTML = _escapeCommentHtml(String(rawText)).replace(/\n/g, '
'); + return; + } + + const hasEmailHeader = /(^|\n)\s*📧\s*(Indgående|Udgående)\s+email/i.test(String(rawText)); + if (!hasEmailHeader) { + body.innerHTML = _escapeCommentHtml(String(rawText)).replace(/\n/g, '
'); + return; + } + + const parsed = _parseEmailComment(rawText); + const display = _buildEmailHeaderAndBody(parsed.visibleText || ''); + const safeHeader = _escapeCommentHtml(display.summary || 'Indgaaende email'); + const safeBody = _escapeCommentHtml(display.bodyText || '').replace(/\n/g, '
'); + body.innerHTML = ` +
${safeHeader}
+ ${display.bodyText ? `
${safeBody}
` : ''} + `; + + const existingActions = item.querySelector('.comment-actions'); + if (existingActions) { + existingActions.remove(); + } + + if (parsed.emailId) { + const actions = document.createElement('div'); + actions.className = 'comment-actions'; + actions.innerHTML = ` + + + + `; + item.appendChild(actions); + const quickInlineBtn = actions.querySelector('.js-quick-inline-reply'); + if (quickInlineBtn) { + quickInlineBtn.addEventListener('click', () => { + openInlineCommentQuickReply(rawText, parsed.emailId); + }); + } + } else { + const actions = document.createElement('div'); + actions.className = 'comment-actions'; + actions.innerHTML = ` + + + + `; + item.appendChild(actions); + const replyBtn = actions.querySelector('.js-reply-fallback'); + if (replyBtn) { + replyBtn.addEventListener('click', () => { + quickReplyToEmailFromCommentText(rawText); + }); + } + const quickReplyBtn = actions.querySelector('.js-quick-reply-fallback'); + if (quickReplyBtn) { + quickReplyBtn.addEventListener('click', () => { + openInlineCommentQuickReply(rawText, null); + }); + } + } + }); + } + + function sortCommentsNewestFirst() { + const container = document.getElementById('comments-container'); + if (!container) return; + + const items = Array.from(container.querySelectorAll('.comment-item')); + if (items.length < 2) return; + + items + .sort((a, b) => Number(b.dataset.createdAt || 0) - Number(a.dataset.createdAt || 0)) + .forEach((item) => container.appendChild(item)); + } + + async function submitComment(event) { + event.preventDefault(); + const form = event.target; + const content = form.indhold.value; + const btn = form.querySelector('button'); + const originalText = btn.innerHTML; + + btn.innerHTML = ' Sender...'; + btn.disabled = true; + + try { + const response = await fetch('/api/v1/sag/{{ case.id }}/kommentarer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + indhold: content + }) + }); + + if (response.ok) { + location.reload(); + } else { + alert('Fejl ved oprettelse af kommentar'); + btn.innerHTML = originalText; + btn.disabled = false; + } + } catch (error) { + console.error('Error:', error); + alert('Der skete en fejl. Prøv igen.'); + btn.innerHTML = originalText; + btn.disabled = false; + } + } + + // Keep newest comments visible at top + document.addEventListener('DOMContentLoaded', function() { + sortCommentsNewestFirst(); + processCommentBodies(); + const container = document.getElementById('comments-container'); + if(container) { + container.scrollTop = 0; + } + }); + \ No newline at end of file diff --git a/script_3.js b/script_3.js new file mode 100644 index 0000000..fdf7d9d --- /dev/null +++ b/script_3.js @@ -0,0 +1,208 @@ + + const salesCaseId = {{ case.id }}; + + function formatCurrency(value) { + const num = Number(value || 0); + return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(num); + } + + function formatNumber(value) { + const num = Number(value || 0); + return new Intl.NumberFormat('da-DK', { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(num); + } + + let saleItemsCache = []; + + async function loadVarekobSalg() { + try { + const res = await fetch(`/api/v1/sag/${salesCaseId}/varekob-salg?include_subcases=true`); + if (!res.ok) throw new Error('Failed to load aggregated data'); + const data = await res.json(); + + document.getElementById('salesTotalPurchase').textContent = formatCurrency(data?.totals?.purchase_total); + document.getElementById('salesTotalSale').textContent = formatCurrency(data?.totals?.sale_total); + document.getElementById('salesTotalNet').textContent = formatCurrency(data?.totals?.net_total); + document.getElementById('salesTotalHours').textContent = formatNumber(data?.totals?.total_hours) + ' t'; + document.getElementById('salesBillableHours').textContent = formatNumber(data?.totals?.billable_hours) + ' t'; + + saleItemsCache = data.sale_items || []; + renderSaleItems(saleItemsCache); + renderTimeEntries(data.time_entries || []); + const hasSalesData = (data.sale_items || []).length > 0 || (data.time_entries || []).length > 0; + setModuleContentState('sales', hasSalesData); + } catch (error) { + console.error(error); + const saleBody = document.getElementById('saleItemsBody'); + if (saleBody) { + saleBody.innerHTML = 'Kunne ikke hente data'; + } + const timeBody = document.getElementById('salesTimeBody'); + if (timeBody) { + timeBody.innerHTML = 'Kunne ikke hente data'; + } + setModuleContentState('sales', true); + } + } + + function renderSaleItems(items) { + const salesBody = document.getElementById('saleItemsSalesBody'); + const purchaseBody = document.getElementById('saleItemsPurchaseBody'); + const salesSubtotal = document.getElementById('salesLinesSubtotal'); + const purchaseSubtotal = document.getElementById('purchaseLinesSubtotal'); + if (!salesBody || !purchaseBody) return; + + const salesItems = items.filter(item => (item.type || '').toLowerCase() !== 'purchase'); + const purchaseItems = items.filter(item => (item.type || '').toLowerCase() === 'purchase'); + + const renderRows = (list) => { + if (!list.length) { + return 'Ingen linjer'; + } + + return list.map(item => { + const statusLabel = item.status || 'draft'; + const isSubcase = item.sag_id && item.sag_id !== salesCaseId; + const sourceBadge = isSubcase + ? `Under-sag` + : `Denne sag`; + return ` + + ${item.line_date || '-'} + ${item.description || '-'} + ${item.quantity ?? '-'} + ${item.unit || '-'} + ${item.unit_price != null ? formatCurrency(item.unit_price) : '-'} + ${formatCurrency(item.amount)} + ${item.source_sag_titel || '-'}${sourceBadge} + ${statusLabel} + +
+ + +
+ + + `; + }).join(''); + }; + + salesBody.innerHTML = renderRows(salesItems); + purchaseBody.innerHTML = renderRows(purchaseItems); + + const salesSum = salesItems.reduce((sum, item) => sum + Number(item.amount || 0), 0); + const purchaseSum = purchaseItems.reduce((sum, item) => sum + Number(item.amount || 0), 0); + if (salesSubtotal) salesSubtotal.textContent = formatCurrency(salesSum); + if (purchaseSubtotal) purchaseSubtotal.textContent = formatCurrency(purchaseSum); + } + + function renderTimeEntries(entries) { + const tbody = document.getElementById('salesTimeBody'); + if (!tbody) return; + if (!entries.length) { + tbody.innerHTML = 'Ingen tid registreret'; + return; + } + + tbody.innerHTML = entries.map(entry => { + const hours = entry.approved_hours || entry.original_hours || 0; + const isSubcase = entry.sag_id && entry.sag_id !== salesCaseId; + const sourceBadge = isSubcase + ? `Under-sag` + : `Denne sag`; + return ` + + ${entry.worked_date || '-'} + ${formatNumber(hours)} t + ${entry.source_sag_titel || '-'}${sourceBadge} + + `; + }).join(''); + } + + function openSaleItemModal(item = null) { + document.getElementById('sale_item_id').value = item?.id || ''; + document.getElementById('sale_type').value = item?.type || 'sale'; + document.getElementById('sale_status').value = item?.status || 'draft'; + document.getElementById('sale_date').value = item?.line_date || ''; + document.getElementById('sale_description').value = item?.description || ''; + document.getElementById('sale_quantity').value = item?.quantity ?? ''; + document.getElementById('sale_unit').value = item?.unit || ''; + document.getElementById('sale_unit_price').value = item?.unit_price ?? ''; + document.getElementById('sale_amount').value = item?.amount ?? ''; + document.getElementById('sale_currency').value = item?.currency || 'DKK'; + document.getElementById('sale_external_ref').value = item?.external_ref || ''; + + new bootstrap.Modal(document.getElementById('saleItemModal')).show(); + } + + function openSaleItemModalById(itemId) { + const item = saleItemsCache.find((entry) => entry.id === itemId); + openSaleItemModal(item || null); + } + + function updateSaleAmount() { + const qty = parseFloat(document.getElementById('sale_quantity').value || 0); + const price = parseFloat(document.getElementById('sale_unit_price').value || 0); + if (qty && price) { + document.getElementById('sale_amount').value = (qty * price).toFixed(2); + } + } + + async function saveSaleItem() { + const itemId = document.getElementById('sale_item_id').value; + const payload = { + type: document.getElementById('sale_type').value, + status: document.getElementById('sale_status').value, + line_date: document.getElementById('sale_date').value || null, + description: document.getElementById('sale_description').value, + quantity: document.getElementById('sale_quantity').value || null, + unit: document.getElementById('sale_unit').value || null, + unit_price: document.getElementById('sale_unit_price').value || null, + amount: document.getElementById('sale_amount').value, + currency: document.getElementById('sale_currency').value || 'DKK', + external_ref: document.getElementById('sale_external_ref').value || null + }; + + if (!payload.description || !payload.amount) { + alert('Beskrivelse og linjesum er påkrævet.'); + return; + } + + const method = itemId ? 'PATCH' : 'POST'; + const url = itemId + ? `/api/v1/sag/${salesCaseId}/sale-items/${itemId}` + : `/api/v1/sag/${salesCaseId}/sale-items`; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + alert('Kunne ikke gemme varelinje'); + return; + } + + bootstrap.Modal.getInstance(document.getElementById('saleItemModal')).hide(); + await loadVarekobSalg(); + } + + async function deleteSaleItem(itemId) { + if (!confirm('Vil du slette denne varelinje?')) return; + const res = await fetch(`/api/v1/sag/${salesCaseId}/sale-items/${itemId}`, { method: 'DELETE' }); + if (!res.ok) { + alert('Kunne ikke slette varelinje'); + return; + } + await loadVarekobSalg(); + } + + document.addEventListener('DOMContentLoaded', function() { + const qtyInput = document.getElementById('sale_quantity'); + const priceInput = document.getElementById('sale_unit_price'); + if (qtyInput) qtyInput.addEventListener('input', updateSaleAmount); + if (priceInput) priceInput.addEventListener('input', updateSaleAmount); + loadVarekobSalg(); + }); + \ No newline at end of file diff --git a/script_4.js b/script_4.js new file mode 100644 index 0000000..44976fe --- /dev/null +++ b/script_4.js @@ -0,0 +1,356 @@ + + const timeCaseId = {{ case.id }}; + + function minutesToLabel(minutes) { + const value = Number(minutes || 0); + const h = Math.floor(value / 60); + const m = value % 60; + return `${h}t ${m}m`; + } + + function timeStatusBadge(status) { + if (status === 'godkendt') return 'Godkendt'; + if (status === 'kladde') return 'Kladde'; + return 'Afventer'; + } + + function renderTimeV1Timeline(entries) { + const timeline = document.getElementById('timeTimelineColumns'); + if (!timeline) return; + + if (!entries || entries.length === 0) { + timeline.innerHTML = '
Ingen tidsregistreringer endnu
'; + return; + } + + const START_HOUR = 7; + const TOTAL_HOURS = 10; // 07:00 to 17:00 + const HOUR_HEIGHT = 60; // px + + const groupedByDate = {}; + entries.forEach((entry) => { + let dateKey = 'Ukendt dato'; + if (entry.start_tid) { + dateKey = entry.start_tid.split('T')[0]; + } else if (entry.worked_date) { + dateKey = entry.worked_date; + } else if (entry.created_at) { + dateKey = entry.created_at.split('T')[0]; + } + + // Keep only first 10 chars for proper grouping if it's an ISO timestamp + if (dateKey.length > 10) dateKey = dateKey.substring(0, 10); + + if (!groupedByDate[dateKey]) groupedByDate[dateKey] = []; + groupedByDate[dateKey].push(entry); + }); + + const sortedDates = Object.keys(groupedByDate).sort((a, b) => new Date(b) - new Date(a)); + let html = ''; + + sortedDates.forEach(dateStr => { + const dayEntries = groupedByDate[dateStr]; + + let formattedDateLab = dateStr; + try { + const d = new Date(dateStr); + if (!isNaN(d.getTime())) { + formattedDateLab = d.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); + formattedDateLab = formattedDateLab.charAt(0).toUpperCase() + formattedDateLab.slice(1); + } + } catch(e){} + + const techs = {}; + const unplaced = []; + + dayEntries.forEach(entry => { + const tech = entry.bruger_navn || entry.user_name || 'Ukendt'; + if (!techs[tech]) techs[tech] = []; + + if (!entry.start_tid || entry.start_tid === null) { + unplaced.push(entry); + } else { + techs[tech].push(entry); + } + }); + + const techNames = Object.keys(techs).sort(); + + html += ` +
+
+ ${formattedDateLab} +
+
+
+ `; + + for (let i = 0; i <= TOTAL_HOURS; i++) { + const h = START_HOUR + i; + const top = i * HOUR_HEIGHT; + html += `
${h.toString().padStart(2, '0')}:00
`; + } + + html += `
`; + + techNames.forEach(tech => { + html += ` +
+
+ ${escapeHtml(tech)} +
+
+ `; + + techs[tech].forEach(entry => { + const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse'); + const status = entry.entry_status || entry.status || 'kladde'; + let cssClass = 'time-v1-entry-kladde'; + if (status === 'afventer' || status === 'pending') cssClass = 'time-v1-entry-pending'; + if (status === 'godkendt' || status === 'billed' || status === 'approved' || entry.fakturerbar_tid_min > 0) cssClass = 'time-v1-entry-godkendt'; + + const startObj = new Date(entry.start_tid); + let durationMin = 30; // default length + if (entry.faktisk_tid_min) { + durationMin = parseInt(entry.faktisk_tid_min); + } else if (entry.original_hours || entry.timer) { + durationMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60); + } + + let startH = startObj.getHours(); + let startM = startObj.getMinutes(); + + if (startH < START_HOUR) { + durationMin -= ((START_HOUR * 60) - (startH * 60 + startM)); + startH = START_HOUR; + startM = 0; + } + + let topPx = ((startH - START_HOUR) + (startM / 60)) * HOUR_HEIGHT; + let heightPx = (durationMin / 60) * HOUR_HEIGHT; + + if (topPx < 0) topPx = 0; + if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) { + heightPx = (TOTAL_HOURS * HOUR_HEIGHT) - topPx; + } + + if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) { + const endObj = new Date(startObj.getTime() + durationMin * 60000); + const timeStr = `${startObj.getHours().toString().padStart(2,'0')}:${startObj.getMinutes().toString().padStart(2,'0')} - ${endObj.getHours().toString().padStart(2,'0')}:${endObj.getMinutes().toString().padStart(2,'0')}`; + + html += ` +
+
${timeStr}
+
${desc}
+
+ `; + } + }); + + html += ` +
+
+ `; + }); + + html += `
`; + + if (unplaced.length > 0) { + html += `
+ Uden tidsrum: + `; + unplaced.forEach(u => { + const userName = escapeHtml(u.bruger_navn || u.user_name || 'Ukendt'); + const hrs = u.original_hours || u.timer || 0; + html += `
+ ${userName} • ${hrs}t +
`; + }); + html += `
`; + } + + html += `
`; + }); + + timeline.innerHTML = html; + } + + async function loadTimeTrackingTab() { + try { + const res = await fetch(`/api/v1/timetracking/time?sag_id=${timeCaseId}`); + if (!res.ok) throw new Error('Kunne ikke hente tidsforbrug'); + const entries = await res.json(); + renderTimeV1Timeline(entries || []); + setModuleContentState('timetracking', (entries || []).length > 0); + } catch (error) { + console.error(error); + const timeline = document.getElementById('timeTimelineColumns'); + if (timeline) { + timeline.innerHTML = '
Kunne ikke hente tidsforbrug.
'; + } + setModuleContentState('timetracking', true); + } + } + + async function startLiveTimerV1() { + try { + const res = await fetch('/api/v1/timetracking/time/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sag_id: timeCaseId, + entry_type: document.getElementById('timeV1Type')?.value || 'manuel', + beskrivelse: document.getElementById('timeV1Description')?.value || null + }) + }); + if (!res.ok) throw new Error(await res.text()); + await loadTimeTrackingTab(); + } catch (error) { + alert('Kunne ikke starte timer: ' + (error.message || 'ukendt fejl')); + } + } + + async function stopLiveTimerV1(extra = {}) { + try { + const res = await fetch('/api/v1/timetracking/time/stop', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(extra || {}) + }); + if (!res.ok) throw new Error(await res.text()); + await loadTimeTrackingTab(); + } catch (error) { + alert('Kunne ikke stoppe timer: ' + (error.message || 'ukendt fejl')); + } + } + + function bindTimeV1Calculations() { + const startIn = document.getElementById('timeV1Start'); + const endIn = document.getElementById('timeV1End'); + const minIn = document.getElementById('timeV1Minutes'); + + if (!startIn || !endIn || !minIn) return; + + const parseTime = (val) => { + if (!val) return null; + const [h,m] = val.split(':').map(Number); + return (h * 60) + m; + }; + + const toTimeStr = (totalMins) => { + const h = Math.floor(totalMins / 60) % 24; + const m = totalMins % 60; + return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`; + }; + + const recalculate = (trigger) => { + const s = parseTime(startIn.value); + const e = parseTime(endIn.value); + const dur = parseInt(minIn.value); + + if (trigger === 'start' || trigger === 'end') { + if (s !== null && e !== null) { + let diff = e - s; + if (diff < 0) diff += 24*60; + minIn.value = diff; + } else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while (base < 0) base += 24*60; + startIn.value = toTimeStr(base); + } + } else if (trigger === 'min') { + if (s !== null && !isNaN(dur) && dur > 0) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while(base < 0) base+=24*60; + startIn.value = toTimeStr(base); + } + } + }; + + startIn.addEventListener('change', () => recalculate('start')); + endIn.addEventListener('change', () => recalculate('end')); + minIn.addEventListener('input', () => recalculate('min')); + } + + async function createManualTimeV1(event) { + event.preventDefault(); + const minutes = Number(document.getElementById('timeV1Minutes')?.value || 0); + + if (minutes <= 0) { + alert('Indtast minutter over 0'); + return; + } + + const dateVal = document.getElementById('timeV1Date')?.value || null; + const tStart = document.getElementById('timeV1Start')?.value; + const tEnd = document.getElementById('timeV1End')?.value; + + let startObj = null; + let endObj = null; + + if (dateVal && tStart) { + try { + const l = new Date(`${dateVal}T${tStart}:00`); + startObj = l.toISOString(); + } catch(e){} + } + + if (dateVal && tEnd) { + try { + const l = new Date(`${dateVal}T${tEnd}:00`); + if (startObj && new Date(startObj) > l) { + l.setDate(l.getDate() + 1); + } + endObj = l.toISOString(); + } catch(e){} + } + + const payload = { + sag_id: timeCaseId, + medarbejder_id: getTimeV1EmployeeId(), + faktisk_tid_min: minutes, + worked_date: dateVal, + entry_type: document.getElementById('timeV1Type')?.value || 'manuel', + entry_status: document.getElementById('timeV1Status')?.value || 'afventer', + beskrivelse: document.getElementById('timeV1Description')?.value || null, + kilde: 'manuel', + start_tid: startObj, + slut_tid: endObj + }; + + try { + const res = await fetch('/api/v1/timetracking/time/manual', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error(await res.text()); + + const minutesInput = document.getElementById('timeV1Minutes'); + const descInput = document.getElementById('timeV1Description'); + const startIn = document.getElementById('timeV1Start'); + const endIn = document.getElementById('timeV1End'); + + if (minutesInput) minutesInput.value = ''; + if (descInput) descInput.value = ''; + if (startIn) startIn.value = ''; + if (endIn) endIn.value = ''; + + await loadTimeTrackingTab(); + } catch (error) { + alert('Kunne ikke oprette tidsregistrering: ' + (error.message || 'ukendt fejl')); + } + } + + document.addEventListener('DOMContentLoaded', () => { + bindTimeV1Calculations(); + const dateInput = document.getElementById('timeV1Date'); + if (dateInput && !dateInput.value) { + dateInput.valueAsDate = new Date(); + } + }); + \ No newline at end of file diff --git a/script_5.js b/script_5.js new file mode 100644 index 0000000..96b30ab --- /dev/null +++ b/script_5.js @@ -0,0 +1,344 @@ + + let reminderUserId = null; + const remindersCaseId = {{ case.id }}; + + function getReminderUserId() { + const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); + if (token) { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.sub || payload.user_id; + } catch (e) { + console.warn('Could not decode token for reminder user_id'); + } + } + const metaTag = document.querySelector('meta[name="user-id"]'); + if (metaTag) return metaTag.getAttribute('content'); + return null; + } + + async function ensureReminderUserId() { + const localId = getReminderUserId(); + if (localId) return localId; + + try { + const res = await fetch('/api/v1/auth/me', { credentials: 'include' }); + if (!res.ok) return null; + const me = await res.json(); + return me?.id || me?.user_id || null; + } catch (err) { + return null; + } + } + + function formatReminderDate(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return date.toLocaleString('da-DK', { hour12: false }); + } + + function updateReminderTriggerFields() { + const triggerType = document.getElementById('rem_trigger_type')?.value; + const timeWrap = document.getElementById('rem_trigger_time_wrap'); + const statusWrap = document.getElementById('rem_trigger_status_wrap'); + if (timeWrap && statusWrap) { + if (triggerType === 'status_change') { + timeWrap.classList.add('d-none'); + statusWrap.classList.remove('d-none'); + } else { + timeWrap.classList.remove('d-none'); + statusWrap.classList.add('d-none'); + } + } + } + + function updateReminderRecurrenceFields() { + const recurrenceType = document.getElementById('rem_recurrence_type')?.value; + const dowWrap = document.getElementById('rem_recurrence_dow_wrap'); + const domWrap = document.getElementById('rem_recurrence_dom_wrap'); + if (!dowWrap || !domWrap) return; + dowWrap.classList.toggle('d-none', recurrenceType !== 'weekly'); + domWrap.classList.toggle('d-none', recurrenceType !== 'monthly'); + } + + function openCreateReminderModal(defaultEventType) { + reminderUserId = getReminderUserId(); + const warning = document.getElementById('rem_user_warning'); + if (warning) warning.classList.toggle('d-none', !!reminderUserId); + + const form = document.getElementById('createReminderForm'); + if (form) form.reset(); + document.getElementById('rem_notify_frontend').checked = true; + document.getElementById('rem_priority').value = 'normal'; + document.getElementById('rem_event_type').value = defaultEventType || 'reminder'; + document.getElementById('rem_trigger_type').value = 'time_based'; + document.getElementById('rem_recurrence_type').value = 'once'; + updateReminderTriggerFields(); + updateReminderRecurrenceFields(); + new bootstrap.Modal(document.getElementById('createReminderModal')).show(); + } + + async function loadReminders() { + const list = document.getElementById('remindersList'); + if (!list) return; + reminderUserId = await ensureReminderUserId(); + + if (!reminderUserId) { + list.innerHTML = '
Kunne ikke finde bruger-id.
'; + setModuleContentState('reminders', true); + return; + } + + list.innerHTML = '
Henter reminders...
'; + + try { + const res = await fetch(`/api/v1/sag/${remindersCaseId}/reminders?user_id=${reminderUserId}`); + if (!res.ok) throw new Error('Kunne ikke hente reminders'); + const reminders = await res.json(); + renderReminders(reminders); + } catch (e) { + console.error(e); + list.innerHTML = '
Fejl ved hentning af reminders
'; + setModuleContentState('reminders', true); + } + } + + function renderReminders(reminders) { + const list = document.getElementById('remindersList'); + if (!list) return; + if (!reminders || reminders.length === 0) { + list.innerHTML = '
Ingen reminders endnu.
'; + setModuleContentState('reminders', false); + return; + } + + const triggerLabels = { + time_based: 'Tidspunkt', + status_change: 'Status ændring', + deadline_approaching: 'Deadline' + }; + + const eventTypeLabels = { + reminder: 'Reminder', + meeting: 'Moede', + technician_visit: 'Teknikerbesoeg', + obs: 'OBS', + deadline: 'Deadline' + }; + + const recurrenceLabels = { + once: 'Én gang', + daily: 'Dagligt', + weekly: 'Ugentligt', + monthly: 'Månedligt' + }; + + list.innerHTML = reminders.map(reminder => { + const nextCheck = formatReminderDate(reminder.next_check_at); + const createdAt = formatReminderDate(reminder.created_at); + const isActive = reminder.is_active; + const statusBadge = isActive + ? 'Aktiv' + : 'Inaktiv'; + + return ` +
+
+
+
${reminder.title}
+
${reminder.message || '-'}
+
+ Type: ${eventTypeLabels[reminder.event_type] || reminder.event_type || 'Reminder'} · Trigger: ${triggerLabels[reminder.trigger_type] || reminder.trigger_type} · Gentagelse: ${recurrenceLabels[reminder.recurrence_type] || reminder.recurrence_type} +
+
Næste: ${nextCheck} · Oprettet: ${createdAt}
+
+
+ ${statusBadge} + +
+
+
+ `; + }).join(''); + setModuleContentState('reminders', true); + } + + async function saveReminder() { + reminderUserId = await ensureReminderUserId(); + if (!reminderUserId) { + alert('Mangler bruger-id. Log ind igen.'); + return; + } + + const title = document.getElementById('rem_title').value.trim(); + const message = document.getElementById('rem_message').value.trim(); + const priority = document.getElementById('rem_priority').value; + const eventType = document.getElementById('rem_event_type').value; + const triggerType = document.getElementById('rem_trigger_type').value; + const scheduledAtValue = document.getElementById('rem_scheduled_at').value; + const targetStatus = document.getElementById('rem_target_status').value; + const recurrenceType = document.getElementById('rem_recurrence_type').value; + const recurrenceDow = document.getElementById('rem_recurrence_dow').value; + const recurrenceDom = document.getElementById('rem_recurrence_dom').value; + const notifyFrontend = document.getElementById('rem_notify_frontend').checked; + const notifyEmail = document.getElementById('rem_notify_email').checked; + const notifyMattermost = document.getElementById('rem_notify_mattermost').checked; + const overridePrefs = document.getElementById('rem_override_prefs').checked; + + if (!title) { + alert('Titel er påkrævet'); + return; + } + + let triggerConfig = {}; + let scheduledAt = null; + + if (triggerType === 'status_change') { + if (!targetStatus) { + alert('Vælg en status for statusændring'); + return; + } + triggerConfig = { target_status: targetStatus }; + } else { + if (!scheduledAtValue) { + alert('Vælg et tidspunkt'); + return; + } + scheduledAt = new Date(scheduledAtValue).toISOString(); + } + + const payload = { + title, + message: message || null, + priority, + event_type: eventType, + trigger_type: triggerType, + trigger_config: triggerConfig, + recipient_user_ids: [Number(reminderUserId)], + recipient_emails: [], + notify_mattermost: notifyMattermost, + notify_email: notifyEmail, + notify_frontend: notifyFrontend, + override_user_preferences: overridePrefs, + recurrence_type: recurrenceType, + recurrence_day_of_week: recurrenceType === 'weekly' ? Number(recurrenceDow) : null, + recurrence_day_of_month: recurrenceType === 'monthly' ? Number(recurrenceDom) : null, + scheduled_at: scheduledAt + }; + + try { + const res = await fetch(`/api/v1/sag/${remindersCaseId}/reminders?user_id=${reminderUserId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Kunne ikke oprette reminder'); + } + bootstrap.Modal.getInstance(document.getElementById('createReminderModal')).hide(); + await loadReminders(); + await loadCaseCalendar(); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + async function deleteReminder(reminderId) { + if (!confirm('Vil du slette denne reminder?')) return; + try { + const res = await fetch(`/api/v1/sag/reminders/${reminderId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Kunne ikke slette reminder'); + await loadReminders(); + await loadCaseCalendar(); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + function formatCalendarEvent(event) { + const dateLabel = formatReminderDate(event.start); + const typeLabelMap = { + reminder: 'Reminder', + meeting: 'Moede', + technician_visit: 'Teknikerbesoeg', + obs: 'OBS', + deadline: 'Deadline', + deferred: 'Deferred' + }; + const typeLabel = typeLabelMap[event.event_kind] || event.event_kind || 'Reminder'; + return ` + +
+
+
${event.title || 'Aftale'}
+
${typeLabel} · ${dateLabel}
+
+
+
+ `; + } + + async function loadCaseCalendar() { + const currentList = document.getElementById('caseCalendarCurrent'); + const childrenList = document.getElementById('caseCalendarChildren'); + if (!currentList || !childrenList) return; + + currentList.innerHTML = '
Indlæser aftaler...
'; + childrenList.innerHTML = '
Indlæser børnesager...
'; + + try { + const res = await fetch(`/api/v1/sag/${remindersCaseId}/calendar-events?include_children=true`); + if (!res.ok) throw new Error('Kunne ikke hente kalenderaftaler'); + const data = await res.json(); + + const currentEvents = data.current || []; + const childGroups = data.children || []; + const childCount = childGroups.reduce((sum, child) => sum + (child.events || []).length, 0); + const hasAnyEvents = currentEvents.length > 0 || childCount > 0; + + if (!currentEvents.length) { + currentList.innerHTML = '
Ingen aftaler for denne sag.
'; + } else { + currentList.innerHTML = currentEvents + .map(formatCalendarEvent) + .join(''); + } + + if (!childGroups.length) { + childrenList.innerHTML = '
Ingen børnesager.
'; + } else { + childrenList.innerHTML = childGroups.map(child => { + const eventsHtml = (child.events || []).length + ? child.events.map(formatCalendarEvent).join('') + : '
Ingen aftaler.
'; + return ` +
+
${child.case_title}
+
+ ${eventsHtml} +
+
+ `; + }).join(''); + } + + setModuleContentState('calendar', hasAnyEvents); + } catch (e) { + console.error(e); + currentList.innerHTML = '
Fejl ved hentning af aftaler.
'; + childrenList.innerHTML = ''; + setModuleContentState('calendar', true); + } + } + + document.addEventListener('DOMContentLoaded', function() { + updateReminderTriggerFields(); + updateReminderRecurrenceFields(); + loadReminders(); + loadCaseCalendar(); + }); + \ No newline at end of file diff --git a/script_6.js b/script_6.js new file mode 100644 index 0000000..1879de0 --- /dev/null +++ b/script_6.js @@ -0,0 +1,235 @@ + + function showCreateSolutionModal() { + const addTimeCheckbox = document.getElementById('sol_add_time'); + const timeFields = document.getElementById('sol_time_fields'); + if (addTimeCheckbox && timeFields) { + addTimeCheckbox.checked = false; + timeFields.classList.add('d-none'); + } + const timeDate = document.getElementById('sol_time_date'); + if (timeDate) timeDate.valueAsDate = new Date(); + const timeHours = document.getElementById('sol_time_hours'); + const timeMinutes = document.getElementById('sol_time_minutes'); + const timeTotal = document.getElementById('sol_time_total'); + if (timeHours) timeHours.value = ''; + if (timeMinutes) timeMinutes.value = ''; + if (timeTotal) timeTotal.textContent = 'Total: 0.00 timer'; + const timeDesc = document.getElementById('sol_time_desc'); + if (timeDesc) timeDesc.value = ''; + const timeInternal = document.getElementById('sol_time_internal'); + if (timeInternal) timeInternal.checked = false; + new bootstrap.Modal(document.getElementById('createSolutionModal')).show(); + } + + function updateSolutionTimeTotal() { + const h = parseInt(document.getElementById('sol_time_hours').value) || 0; + const m = parseInt(document.getElementById('sol_time_minutes').value) || 0; + const total = h + (m / 60); + const output = document.getElementById('sol_time_total'); + if (output) output.textContent = `Total: ${total.toFixed(2)} timer`; + } + + async function saveSolution() { + const data = { + sag_id: document.getElementById('sol_sag_id').value, + title: document.getElementById('sol_title').value, + solution_type: document.getElementById('sol_type').value, + result: document.getElementById('sol_result').value, + description: document.getElementById('sol_desc').value, + created_by_user_id: 1 // TODO: Get from auth + }; + const addTime = document.getElementById('sol_add_time')?.checked; + const timeHours = parseInt(document.getElementById('sol_time_hours').value) || 0; + const timeMinutes = parseInt(document.getElementById('sol_time_minutes').value) || 0; + const timeTotal = timeHours + (timeMinutes / 60); + + try { + const res = await fetch(`/api/v1/sag/${data.sag_id}/solution`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data) + }); + if (res.ok) { + if (addTime && timeTotal > 0) { + const solution = await res.json(); + const timePayload = { + sag_id: data.sag_id, + solution_id: solution.id, + description: document.getElementById('sol_time_desc').value || data.title, + original_hours: timeTotal, + worked_date: document.getElementById('sol_time_date').value || null, + is_internal: document.getElementById('sol_time_internal').checked, + work_type: 'support' + }; + const timeRes = await fetch('/api/v1/timetracking/entries/internal', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(timePayload) + }); + if (!timeRes.ok) { + alert('Løsning oprettet, men tid kunne ikke registreres'); + } + } + window.location.reload(); + } else { + alert('Fejl ved oprettelse af løsning'); + } + } catch(e) { console.error(e); alert('Fejl'); } + } + + function showAddTimeModal() { + // Set date to today + document.getElementById('time_date').valueAsDate = new Date(); + + // Reset fields + if(document.getElementById('time_total_minutes')) { + document.getElementById('time_total_minutes').value = ''; + document.getElementById('time_start_input').value = ''; + document.getElementById('time_end_input').value = ''; + } + document.getElementById('time_desc').value = ''; + if(document.getElementById('time_internal')) document.getElementById('time_internal').checked = false; + if(document.getElementById('time_billing_method')) document.getElementById('time_billing_method').value = 'invoice'; + if(document.getElementById('time_work_type')) document.getElementById('time_work_type').value = 'support'; + + new bootstrap.Modal(document.getElementById('createTimeModal')).show(); + } + + // Auto-calculate total hours + /* removed updateTimeTotal */ + + // Add listeners safely + document.addEventListener('DOMContentLoaded', () => { + const hInput = document.getElementById('time_hours_input'); + const mInput = document.getElementById('time_minutes_input'); + if(hInput) hInput.addEventListener('input', updateTimeTotal); + if(mInput) mInput.addEventListener('input', updateTimeTotal); + const solAddTime = document.getElementById('sol_add_time'); + const solFields = document.getElementById('sol_time_fields'); + if (solAddTime && solFields) { + solAddTime.addEventListener('change', () => { + solFields.classList.toggle('d-none', !solAddTime.checked); + }); + } + const solHours = document.getElementById('sol_time_hours'); + const solMinutes = document.getElementById('sol_time_minutes'); + if (solHours) solHours.addEventListener('input', updateSolutionTimeTotal); + if (solMinutes) solMinutes.addEventListener('input', updateSolutionTimeTotal); + }); + + function bindTimeModalCalculations() { + const startIn = document.getElementById('time_start_input'); + const endIn = document.getElementById('time_end_input'); + const minIn = document.getElementById('time_total_minutes'); + + if (!startIn || !endIn || !minIn) return; + + const parseTime = (val) => { + if (!val) return null; + const [h,m] = val.split(':').map(Number); + return (h * 60) + m; + }; + + const toTimeStr = (totalMins) => { + const h = Math.floor(totalMins / 60) % 24; + const m = totalMins % 60; + return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`; + }; + + const recalculate = (trigger) => { + const s = parseTime(startIn.value); + const e = parseTime(endIn.value); + const dur = parseInt(minIn.value); + + if (trigger === 'start' || trigger === 'end') { + if (s !== null && e !== null) { + let diff = e - s; + if (diff < 0) diff += 24*60; + minIn.value = diff; + } else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while (base < 0) base += 24*60; + startIn.value = toTimeStr(base); + } + } else if (trigger === 'min') { + if (s !== null && !isNaN(dur) && dur > 0) { + endIn.value = toTimeStr(s + dur); + } else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) { + let base = e - dur; + while(base < 0) base+=24*60; + startIn.value = toTimeStr(base); + } + } + }; + + startIn.addEventListener('change', () => recalculate('start')); + endIn.addEventListener('change', () => recalculate('end')); + minIn.addEventListener('input', () => recalculate('min')); + } + + document.addEventListener('DOMContentLoaded', bindTimeModalCalculations); + + async function saveTime() { + const mInput = document.getElementById('time_total_minutes'); + const minVal = parseInt(mInput ? mInput.value : 0); + if (!minVal || minVal <= 0) { + alert('Indtast en gyldig varighed (minutter).'); + return; + } + const totalHours = minVal / 60; + const dateVal = document.getElementById('time_date').value; + // extract optional start/end limits + const tStart = document.getElementById('time_start_input')?.value; + const tEnd = document.getElementById('time_end_input')?.value; + + let startObj = null; + let endObj = null; + if (dateVal && tStart) { + try { + const l = new Date(`${dateVal}T${tStart}:00`); + startObj = l.toISOString(); + } catch(e){} + } + if (dateVal && tEnd) { + try { + const l = new Date(`${dateVal}T${tEnd}:00`); + if (startObj && new Date(startObj) > l) { + l.setDate(l.getDate() + 1); + } + endObj = l.toISOString(); + } catch(e){} + } + + const sagId = document.getElementById('time_sag_id').value; + const payload = { + sag_id: parseInt(sagId), + // Note: saveTime modal expects 'timer' as totalHours currently, let's keep compatibility: + timer: totalHours, + faktisk_tid_min: minVal, + worked_date: dateVal, + start_tid: startObj, + slut_tid: endObj, + description: document.getElementById('time_desc').value, + work_type: document.getElementById('time_work_type').value, + billing_method: document.getElementById('time_billing_method').value + }; + + try { + const res = await fetch(`/api/v1/cases/${sagId}/time`, { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify(payload) + }); + if(res.ok) { + window.location.reload(); + } else { + alert("Fejl ved registrering af tid"); + } + } catch(err) { + console.error(err); + alert("Forbindelsesfejl"); + } + } + \ No newline at end of file diff --git a/script_7.js b/script_7.js new file mode 100644 index 0000000..489b2dd --- /dev/null +++ b/script_7.js @@ -0,0 +1,3 @@ + + {% endif %} + \ No newline at end of file diff --git a/script_8.js b/script_8.js new file mode 100644 index 0000000..ea1f40d --- /dev/null +++ b/script_8.js @@ -0,0 +1,2261 @@ + + let currentSearchType = null; + let searchDebounceIds = null; + const caseIds = {{ case.id }}; + const currentCaseTitle = {{ (case.titel or '') | tojson }}; + let caseAddPanelInitialized = false; + let caseAddActiveAction = null; + let caseAddOriginalShowRelModal = null; + const CASE_ADD_ACTIONS = [ + { action: 'assign', label: 'Tildel sag', icon: 'bi-person-check', moduleKey: null, relFn: 'openRelAssignModal' }, + { action: 'time', label: 'Tidregistrering', icon: 'bi-clock', moduleKey: 'time', relFn: 'openRelTimeModal' }, + { action: 'note', label: 'Kommentar', icon: 'bi-chat-left-text', moduleKey: 'solution', relFn: 'openRelNoteModal' }, + { action: 'reminder', label: 'Pamindelse', icon: 'bi-bell', moduleKey: 'reminders', relFn: 'openRelReminderModal' }, + { action: 'pipeline', label: 'Salgspipeline', icon: 'bi-graph-up-arrow', moduleKey: 'pipeline', relFn: 'openRelPipelineModal' }, + { action: 'files', label: 'Filer', icon: 'bi-paperclip', moduleKey: 'files', relFn: 'openRelFilesModal' }, + { action: 'hardware', label: 'Hardware', icon: 'bi-cpu', moduleKey: 'hardware', relFn: 'openRelHardwareModal' }, + { action: 'todo', label: 'Opgave', icon: 'bi-check2-square', moduleKey: 'todo-steps', relFn: 'openRelTodoModal' }, + { action: 'solution', label: 'Losning', icon: 'bi-lightbulb', moduleKey: 'solution', relFn: 'openRelSolutionModal' }, + { action: 'sales', label: 'Varekob og salg', icon: 'bi-bag', moduleKey: 'sales', relFn: 'openRelSalesModal' }, + { action: 'subscription', label: 'Abonnement', icon: 'bi-arrow-repeat', moduleKey: 'subscription', relFn: 'openRelSubscriptionModal' }, + { action: 'email', label: 'Send email', icon: 'bi-envelope', moduleKey: 'emails', relFn: 'openRelEmailModal' } + ]; + + async function openCaseModuleAddPanel() { + if (typeof loadModulePrefs === 'function') { + await loadModulePrefs(); + } + + const panel = document.getElementById('caseAddSidePanel'); + const backdrop = document.getElementById('caseAddSideBackdrop'); + if (!panel || !backdrop) return; + + backdrop.classList.add('open'); + panel.classList.add('open'); + panel.setAttribute('aria-hidden', 'false'); + + if (!caseAddOriginalShowRelModal && typeof window._showRelModal === 'function') { + caseAddOriginalShowRelModal = window._showRelModal; + } + if (typeof caseAddOriginalShowRelModal === 'function') { + window._showRelModal = renderCaseAddWorkspaceModal; + } + + renderCaseAddActionList(caseAddActiveAction); + caseAddPanelInitialized = true; + } + + function closeCaseModuleAddPanel() { + const panel = document.getElementById('caseAddSidePanel'); + const backdrop = document.getElementById('caseAddSideBackdrop'); + if (!panel || !backdrop) return; + + panel.classList.remove('open'); + panel.setAttribute('aria-hidden', 'true'); + backdrop.classList.remove('open'); + + if (typeof caseAddOriginalShowRelModal === 'function') { + window._showRelModal = caseAddOriginalShowRelModal; + } + } + + function renderCaseAddWorkspaceModal(title, bodyHtml, footerBtns) { + const workspace = document.getElementById('caseAddSideWorkspace'); + if (!workspace) return; + + workspace.innerHTML = ` +
+
${title}
+
${bodyHtml}
+
+ + ${footerBtns || ''} +
+
+ `; + + workspace.querySelectorAll('form').forEach((formEl) => { + formEl.addEventListener('submit', (evt) => evt.preventDefault()); + }); + + workspace.querySelectorAll('#relQaModalFooter button').forEach((btnEl) => { + if (!btnEl.getAttribute('type')) { + btnEl.setAttribute('type', 'button'); + } + }); + } + + function _isCaseAddModuleEnabled(actionConfig) { + if (!actionConfig?.moduleKey) return true; + if (actionConfig.moduleKey === 'time') return true; + return modulePrefs[actionConfig.moduleKey] !== false; + } + + function _renderCaseAddModuleToggle(actionConfig) { + if (!actionConfig?.moduleKey) { + return ''; + } + + const isTimeModule = actionConfig.moduleKey === 'time'; + const isChecked = _isCaseAddModuleEnabled(actionConfig); + return ``; + } + + function renderCaseAddActionList(preferredAction = null) { + const listEl = document.getElementById('caseAddModuleList'); + if (!listEl) return; + + const actions = CASE_ADD_ACTIONS; + if (!actions.length) { + listEl.innerHTML = '
Ingen aktive moduler fundet.
'; + return; + } + + listEl.innerHTML = actions.map((cfg) => ` +
+ + ${_renderCaseAddModuleToggle(cfg)} +
+ `).join(''); + + const fallbackAction = actions[0]?.action || null; + const nextAction = actions.some((cfg) => cfg.action === preferredAction) ? preferredAction : fallbackAction; + if (nextAction) { + openCaseAddAction(nextAction); + } + } + + async function openCaseAddAction(actionName) { + document.querySelectorAll('.case-add-module-btn').forEach((btn) => btn.classList.remove('active')); + document.getElementById(`caseAddAction_${actionName}`)?.classList.add('active'); + caseAddActiveAction = actionName; + + const action = CASE_ADD_ACTIONS.find((cfg) => cfg.action === actionName); + const workspace = document.getElementById('caseAddSideWorkspace'); + if (!action || !workspace) return; + + workspace.innerHTML = '
Indlaeser formular...
'; + + const relFn = window[action.relFn]; + if (typeof relFn !== 'function') { + workspace.innerHTML = '
Modulformular er ikke tilgaengelig endnu.
'; + return; + } + + const existingRelQaEl = document.getElementById('relQaModalEl'); + if (existingRelQaEl && !workspace.contains(existingRelQaEl)) { + const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl); + if (existingModalInstance) { + existingModalInstance.hide(); + } + existingRelQaEl.remove(); + } + + try { + await Promise.resolve(relFn(caseIds, currentCaseTitle)); + } catch (error) { + console.error('Could not load module add form', error); + workspace.innerHTML = '
Kunne ikke indlaese formularen.
'; + } + } + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + const panel = document.getElementById('caseAddSidePanel'); + if (panel && panel.classList.contains('open')) { + closeCaseModuleAddPanel(); + } + } + }); + + function openSearchModal(type) { + currentSearchType = type; + const titles = { + 'hardware': 'Tilføj Hardware', + 'location': 'Tilføj Lokation', + 'contact': 'Tilføj Kontakt', + 'customer': 'Tilføj Kunde' + }; + document.getElementById('entitySearchTitle').textContent = titles[type] || 'Søg'; + document.getElementById('entitySearchInput').value = ''; + document.getElementById('entitySearchResults').innerHTML = ''; + + const modal = new bootstrap.Modal(document.getElementById('entitySearchModal')); + modal.show(); + + setTimeout(() => document.getElementById('entitySearchInput').focus(), 500); + } + + document.getElementById('entitySearchInput').addEventListener('input', function(e) { + clearTimeout(searchDebounceIds); + const query = e.target.value.trim(); + if (query.length < 2) { + document.getElementById('entitySearchResults').innerHTML = ''; + return; + } + + searchDebounceIds = setTimeout(() => performSearch(query), 300); + }); + + async function performSearch(query) { + document.getElementById('entitySearchSpinner').classList.remove('d-none'); + document.getElementById('entitySearchResults').classList.add('d-none'); + + try { + let url = ''; + if (currentSearchType === 'hardware') url = `/api/v1/search/hardware?q=${encodeURIComponent(query)}`; + else if (currentSearchType === 'location') url = `/api/v1/search/locations?q=${encodeURIComponent(query)}`; + else if (currentSearchType === 'contact') url = `/api/v1/search/contacts?q=${encodeURIComponent(query)}`; + else if (currentSearchType === 'customer') url = `/api/v1/search/customers?q=${encodeURIComponent(query)}`; + + const res = await fetch(url); + if (!res.ok) throw new Error('Search failed'); + const results = await res.json(); + renderResults(results); + } catch (e) { + console.error(e); + document.getElementById('entitySearchResults').innerHTML = '
Fejl ved søgning
'; + } finally { + document.getElementById('entitySearchSpinner').classList.add('d-none'); + document.getElementById('entitySearchResults').classList.remove('d-none'); + } + } + + function renderResults(results) { + const container = document.getElementById('entitySearchResults'); + if (results.length === 0) { + container.innerHTML = '
Ingen resultater fundet
'; + return; + } + + container.innerHTML = results.map(item => { + let title = '', subtitle = '', icon = '', id = item.id; + + if (currentSearchType === 'hardware') { + title = `${item.brand} ${item.model}`; + subtitle = `SN: ${item.serial_number}`; + icon = 'bi-laptop'; + } else if (currentSearchType === 'location') { + title = item.name; + subtitle = `${item.address_street || ''} ${item.address_city || ''}`; + icon = 'bi-geo-alt'; + } else if (currentSearchType === 'contact') { + title = `${item.first_name} ${item.last_name}`; + subtitle = item.email; + icon = 'bi-person'; + } else if (currentSearchType === 'customer') { + title = item.name; + subtitle = `CVR: ${item.cvr_nummer || 'N/A'}`; + icon = 'bi-building'; + } + + return ` + + `; + }).join(''); + } + + async function addEntity(id) { + let url = '', body = {}; + + if (currentSearchType === 'hardware') { + url = `/api/v1/sag/${caseIds}/hardware`; + body = { hardware_id: id }; + } else if (currentSearchType === 'location') { + url = `/api/v1/sag/${caseIds}/locations`; + body = { location_id: id }; + } else if (currentSearchType === 'contact') { + url = `/api/v1/sag/${caseIds}/contacts`; + body = { contact_id: id }; + } else if (currentSearchType === 'customer') { + url = `/api/v1/sag/${caseIds}/customers`; + body = { customer_id: id }; + } + + try { + const res = await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const err = await res.json(); + alert("Fejl: " + (err.detail || 'Kunne ikke tilføje')); + return; + } + + bootstrap.Modal.getInstance(document.getElementById('entitySearchModal')).hide(); + window.location.reload(); + } catch (e) { + alert("Fejl: " + e.message); + } + } + + async function removeContact(caseId, contactId) { + if(!confirm("Fjern denne kontakt fra sagen?")) return; + try { + const res = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, { method: 'DELETE' }); + if (res.ok) window.location.reload(); + else alert("Fejl ved sletning"); + } catch(e) { alert("Fejl: " + e.message); } + } + + function openContactRoleModal(contactId, contactName, role, isPrimary) { + document.getElementById('contactRoleContactId').value = contactId; + document.getElementById('contactRoleName').textContent = contactName || '-'; + document.getElementById('contactRoleInput').value = role || ''; + document.getElementById('contactRolePrimary').checked = !!isPrimary; + + const modal = new bootstrap.Modal(document.getElementById('contactRoleModal')); + modal.show(); + } + + async function saveContactRole() { + const contactId = document.getElementById('contactRoleContactId').value; + const role = document.getElementById('contactRoleInput').value.trim(); + const isPrimary = document.getElementById('contactRolePrimary').checked; + + try { + const res = await fetch(`/api/v1/sag/${caseIds}/contacts/${contactId}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ role, is_primary: isPrimary }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Kunne ikke opdatere kontakt'); + } + + bootstrap.Modal.getInstance(document.getElementById('contactRoleModal')).hide(); + window.location.reload(); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + async function removeCustomer(caseId, customerId) { + if(!confirm("Fjern denne kunde fra sagen?")) return; + try { + const res = await fetch(`/api/v1/sag/${caseId}/customers/${customerId}`, { method: 'DELETE' }); + if (res.ok) window.location.reload(); + else alert("Fejl ved sletning"); + } catch(e) { alert("Fejl: " + e.message); } + } + + async function updateDeferredUntil(value) { + try { + const res = await fetch(`/api/v1/sag/${caseIds}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ deferred_until: value || null }) + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Kunne ikke opdatere'); + } + window.location.reload(); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + async function updateDeadline(value) { + try { + const res = await fetch(`/api/v1/sag/${caseIds}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ deadline: value || null }) + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Kunne ikke opdatere deadline'); + } + window.location.reload(); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + function shiftDeadlineDays(days) { + const input = document.getElementById('deadlineInput'); + const base = input.value ? new Date(input.value) : new Date(); + base.setDate(base.getDate() + days); + input.value = base.toISOString().slice(0, 10); + updateDeadline(input.value); + } + + function shiftDeadlineMonths(months) { + const input = document.getElementById('deadlineInput'); + const base = input.value ? new Date(input.value) : new Date(); + base.setMonth(base.getMonth() + months); + input.value = base.toISOString().slice(0, 10); + updateDeadline(input.value); + } + + function openDeadlineModal() { + const modal = new bootstrap.Modal(document.getElementById('deadlineModal')); + modal.show(); + } + + function saveDeadlineAll() { + const input = document.getElementById('deadlineInput'); + updateDeadline(input.value || null); + } + + function clearDeadlineAll() { + const input = document.getElementById('deadlineInput'); + input.value = ''; + updateDeadline(null); + } + + function setDeferredFromInput() { + const input = document.getElementById('deferredUntilInput'); + updateDeferredUntil(input.value || null); + } + + function shiftDeferredDays(days) { + const input = document.getElementById('deferredUntilInput'); + const base = input.value ? new Date(input.value) : new Date(); + base.setDate(base.getDate() + days); + input.value = base.toISOString().slice(0, 10); + updateDeferredUntil(input.value); + } + + function shiftDeferredMonths(months) { + const input = document.getElementById('deferredUntilInput'); + const base = input.value ? new Date(input.value) : new Date(); + base.setMonth(base.getMonth() + months); + input.value = base.toISOString().slice(0, 10); + updateDeferredUntil(input.value); + } + + function clearDeferredUntil() { + const input = document.getElementById('deferredUntilInput'); + input.value = ''; + updateDeferredUntil(null); + } + + function openDeferredModal() { + const modal = new bootstrap.Modal(document.getElementById('deferredModal')); + modal.show(); + } + + async function updateDeferredCaseAndStatus(caseId, status) { + try { + const res = await fetch(`/api/v1/sag/${caseIds}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + deferred_until_case_id: caseId ? parseInt(caseId, 10) : null, + deferred_until_status: status || null + }) + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Kunne ikke opdatere'); + } + window.location.reload(); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + function setDeferredCaseFromInputs() { + const caseSelect = document.getElementById('deferredCaseSelect'); + const statusSelect = document.getElementById('deferredStatusSelect'); + updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null); + } + + function clearDeferredCase() { + const caseSelect = document.getElementById('deferredCaseSelect'); + const statusSelect = document.getElementById('deferredStatusSelect'); + caseSelect.value = ''; + statusSelect.value = ''; + updateDeferredCaseAndStatus(null, null); + } + + function saveDeferredAll() { + const input = document.getElementById('deferredUntilInput'); + const caseSelect = document.getElementById('deferredCaseSelect'); + const statusSelect = document.getElementById('deferredStatusSelect'); + updateDeferredUntil(input.value || null); + updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null); + } + + function clearDeferredAll() { + const input = document.getElementById('deferredUntilInput'); + const caseSelect = document.getElementById('deferredCaseSelect'); + const statusSelect = document.getElementById('deferredStatusSelect'); + input.value = ''; + caseSelect.value = ''; + statusSelect.value = ''; + updateDeferredUntil(null); + updateDeferredCaseAndStatus(null, null); + } + + function togglePipelineEdit(forceEdit = null) { + const view = document.getElementById('pipelineViewMode'); + const edit = document.getElementById('pipelineEditMode'); + const shouldEdit = forceEdit === null ? edit.classList.contains('d-none') : forceEdit; + + if (shouldEdit) { + view.classList.add('d-none'); + edit.classList.remove('d-none'); + } else { + view.classList.remove('d-none'); + edit.classList.add('d-none'); + } + + if (shouldEdit) { + ensurePipelineStagesLoaded(); + } + } + + async function ensurePipelineStagesLoaded() { + const select = document.getElementById('pipelineStageSelect'); + if (!select) return; + + if (select.options.length > 1) return; + + try { + const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' }); + if (!response.ok) return; + + const stages = await response.json(); + if (!Array.isArray(stages) || stages.length === 0) return; + + const existingValue = select.value || ''; + select.innerHTML = '' + + stages.map((stage) => ``).join(''); + if (existingValue) { + select.value = existingValue; + } + } catch (error) { + console.error('Could not load pipeline stages', error); + } + } + + async function saveCaseType(newType, newLabel, newIcon, newColor) { + // Update UI immediately for snappy feel + const btn = document.getElementById('caseTypeDropdownBtn'); + const lbl = document.getElementById('caseTypeLabel'); + const ico = document.getElementById('caseTypeIcon'); + if (btn) btn.style.setProperty('--tcolor', newColor); + if (lbl) lbl.textContent = newLabel; + if (ico) { ico.className = 'bi ' + newIcon; } + + try { + const resp = await fetch(`/api/v1/sag/${caseId}`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: newType }) + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + // Reload to re-render template vars (color accent on ID chip etc.) + location.reload(); + } catch (e) { + console.error('saveCaseType error', e); + showToast('Kunne ikke gemme sagstype', 'danger'); + } + } + + async function saveCaseStatusFromTopbar() { + const select = document.getElementById('topbarStatusSelect'); + if (!select) return; + + try { + const response = await fetch(`/api/v1/sag/${caseId}`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: select.value || 'åben' }) + }); + + if (!response.ok) throw new Error('HTTP ' + response.status); + location.reload(); + } catch (e) { + console.error('saveCaseStatusFromTopbar error', e); + showToast('Kunne ikke gemme status', 'danger'); + } + } + + async function hydrateTopbarStatusOptions() { + const select = document.getElementById('topbarStatusSelect'); + if (!select) return; + + const initialValue = String(select.value || '').trim(); + const known = new Map(); + + const addStatus = (raw) => { + const value = String(raw || '').trim(); + if (!value) return; + const key = value.toLowerCase(); + if (!known.has(key)) { + known.set(key, value); + } + }; + + Array.from(select.options || []).forEach((opt) => addStatus(opt.value)); + + try { + const response = await fetch('/api/v1/sag?include_deferred=true', { credentials: 'include' }); + if (response.ok) { + const cases = await response.json(); + (Array.isArray(cases) ? cases : []).forEach((c) => addStatus(c?.status)); + } + } catch (error) { + console.warn('Could not hydrate status options from cases API', error); + } + + ['åben', 'under behandling', 'afventer', 'løst', 'lukket'].forEach(addStatus); + addStatus(initialValue); + + const sortedValues = Array.from(known.values()).sort((a, b) => + a.localeCompare(b, 'da', { sensitivity: 'base' }) + ); + + select.innerHTML = sortedValues.map((value) => { + const selected = initialValue && value.toLowerCase() === initialValue.toLowerCase(); + return ``; + }).join(''); + + if (initialValue) { + select.value = Array.from(known.values()).find((v) => v.toLowerCase() === initialValue.toLowerCase()) || initialValue; + } + } + + function saveCaseTypeFromTopbar() { + const select = document.getElementById('topbarTypeSelect'); + if (!select) return; + + const typeMeta = { + ticket: { label: 'Ticket', icon: 'bi-ticket-perforated', color: '#6366f1' }, + pipeline: { label: 'Pipeline', icon: 'bi-graph-up-arrow', color: '#0ea5e9' }, + opgave: { label: 'Opgave', icon: 'bi-puzzle', color: '#f59e0b' }, + ordre: { label: 'Ordre', icon: 'bi-receipt', color: '#10b981' }, + projekt: { label: 'Projekt', icon: 'bi-folder2-open', color: '#8b5cf6' }, + service: { label: 'Service', icon: 'bi-tools', color: '#ef4444' } + }; + + const nextType = (select.value || 'ticket').toLowerCase(); + const meta = typeMeta[nextType] || typeMeta.ticket; + saveCaseType(nextType, meta.label, meta.icon, meta.color); + } + + async function saveCasePriorityFromTopbar() { + const select = document.getElementById('topbarPrioritySelect'); + if (!select) return; + + try { + const resp = await fetch(`/api/v1/sag/${caseId}`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ priority: (select.value || 'normal').toLowerCase() }) + }); + + if (!resp.ok) throw new Error('HTTP ' + resp.status); + location.reload(); + } catch (e) { + console.error('saveCasePriorityFromTopbar error', e); + showToast('Kunne ikke gemme prioritet', 'danger'); + } + } + + async function saveCaseStartDateFromTopbar() { + const input = document.getElementById('topbarStartDateInput'); + if (!input) return; + + try { + const resp = await fetch(`/api/v1/sag/${caseId}`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ start_date: input.value || null }) + }); + + if (!resp.ok) throw new Error('HTTP ' + resp.status); + location.reload(); + } catch (e) { + console.error('saveCaseStartDateFromTopbar error', e); + showToast('Kunne ikke gemme startdato', 'danger'); + } + } + + function clearCaseStartDateFromTopbar() { + const input = document.getElementById('topbarStartDateInput'); + if (!input) return; + input.value = ''; + saveCaseStartDateFromTopbar(); + } + + async function saveAssignmentFromTabsBar() { + const topUser = document.getElementById('tabsAssignmentUserSelect'); + const topGroup = document.getElementById('tabsAssignmentGroupSelect'); + const legacyUser = document.getElementById('assignmentUserSelect'); + const legacyGroup = document.getElementById('assignmentGroupSelect'); + + if (legacyUser && topUser) { + legacyUser.value = topUser.value; + } + if (legacyGroup && topGroup) { + legacyGroup.value = topGroup.value; + } + + await saveAssignment(); + } + + async function saveAssignment() { + const statusEl = document.getElementById('assignmentStatus'); + const userValue = document.getElementById('assignmentUserSelect')?.value || ''; + const groupValue = document.getElementById('assignmentGroupSelect')?.value || ''; + + const payload = { + ansvarlig_bruger_id: userValue ? parseInt(userValue, 10) : null, + assigned_group_id: groupValue ? parseInt(groupValue, 10) : null + }; + + if (statusEl) { + statusEl.textContent = 'Gemmer...'; + } + + try { + const response = await fetch(`/api/v1/sag/${caseId}`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + let message = 'Kunne ikke gemme tildeling'; + try { + const data = await response.json(); + message = data.detail || message; + } catch (err) { + // Keep default message + } + if (statusEl) { + statusEl.textContent = `❌ ${message}`; + } + return; + } + + if (statusEl) { + statusEl.textContent = '✅ Tildeling gemt'; + } + + const topUser = document.getElementById('tabsAssignmentUserSelect'); + const topGroup = document.getElementById('tabsAssignmentGroupSelect'); + if (topUser) { + topUser.value = userValue; + } + if (topGroup) { + topGroup.value = groupValue; + } + } catch (err) { + if (statusEl) { + statusEl.textContent = `❌ ${err.message}`; + } + } + } + + async function savePipeline() { + const stageValue = document.getElementById('pipelineStageSelect').value; + const probabilityValue = document.getElementById('pipelineProbabilityInput').value; + const amountValue = document.getElementById('pipelineAmountInput').value; + const descriptionValue = document.getElementById('pipelineDescriptionInput').value; + + const payload = { + stage_id: stageValue ? parseInt(stageValue, 10) : null, + probability: probabilityValue === '' ? null : parseInt(probabilityValue, 10), + amount: amountValue === '' ? null : parseFloat(amountValue), + description: descriptionValue === '' ? null : descriptionValue + }; + + try { + const response = await fetch(`/api/v1/sag/${caseId}/pipeline`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + let message = 'Kunne ikke opdatere pipeline'; + try { + const err = await response.json(); + message = err.detail || err.message || message; + } catch (_e) { + const text = await response.text(); + if (text) message = text; + } + throw new Error(`${message} (HTTP ${response.status})`); + } + + window.location.reload(); + } catch (error) { + alert(`Fejl: ${error.message}`); + } + } + + // ========================================== + // VIEW CONTROL (Tag-based) + // ========================================== + + let modulePrefs = {}; + let currentCaseView = 'Sag-detalje'; + + function moduleHasContent(el) { + const attr = el.getAttribute('data-has-content'); + if (attr === 'true') return true; + if (attr === 'false') return false; + if (attr === 'unknown') return false; + + if (el.querySelector('.person-card')) return true; + if (el.querySelector('.list-group-item')) return true; + return true; + } + + function setModuleContentState(moduleKey, hasContent) { + const el = document.querySelector(`[data-module="${moduleKey}"]`); + if (!el) return; + el.setAttribute('data-has-content', hasContent ? 'true' : 'false'); + applyViewLayout(currentCaseView); + } + + function applyViewLayout(viewName) { + if (!viewName) return; + currentCaseView = viewName; + document.body.setAttribute('data-case-view', viewName); + + const viewDefaults = { + 'Pipeline': ['pipeline', 'relations', 'sales', 'time'], + 'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags'], + 'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar'] + }; + + const defaultsByCaseType = caseTypeModuleDefaults[caseTypeKey]; + const standardModules = Array.isArray(defaultsByCaseType) && defaultsByCaseType.length > 0 + ? defaultsByCaseType + : (viewDefaults[viewName] || []); + const standardModuleSet = new Set(standardModules); + standardModuleSet.add('tags'); + standardModuleSet.add('time'); + + document.querySelectorAll('[data-module]').forEach((el) => { + const moduleName = el.getAttribute('data-module'); + const hasContent = moduleHasContent(el); + const isTimeModule = moduleName === 'time'; + const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule; + const pref = modulePrefs[moduleName]; + const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`); + + // Helper til at skjule eller vise modulet og dets mb-3 indpakning + const setVisibility = (visible) => { + let wrapper = null; + if (el.parentElement) { + const isMB3 = el.parentElement.classList.contains('mb-3'); + const isRowCol12 = el.parentElement.classList.contains('col-12') && el.parentElement.parentElement && el.parentElement.parentElement.classList.contains('row'); + if (isMB3) wrapper = el.parentElement; + else if (isRowCol12) wrapper = el.parentElement.parentElement; + } + + if (visible) { + el.classList.remove('d-none'); + if (wrapper && wrapper.classList.contains('d-none')) { + wrapper.classList.remove('d-none'); + } + if (tabButton && tabButton.classList.contains('d-none')) { + tabButton.classList.remove('d-none'); + } + } else { + el.classList.add('d-none'); + if (wrapper && !wrapper.classList.contains('d-none')) wrapper.classList.add('d-none'); + if (tabButton && !tabButton.classList.contains('d-none')) tabButton.classList.add('d-none'); + } + }; + + // Altid vis time (tid) + if (isTimeModule) { + setVisibility(true); + el.classList.remove('module-empty-compact'); + return; + } + + // HVIS specifik præference deaktiverer den - Skjul den! Uanset content. + if (pref === false) { + setVisibility(false); + el.classList.remove('module-empty-compact'); + return; + } + + // HVIS specifik præference aktiverer den (brugervalg) + if (pref === true) { + setVisibility(true); + el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty && !hasContent); + return; + } + + // Default logic (ingen brugervalg) - har den content, så vis den + if (hasContent) { + setVisibility(true); + el.classList.remove('module-empty-compact'); + return; + } + + // Default logic - ingen content: se på layout defaults + if (standardModuleSet.has(moduleName)) { + setVisibility(true); + el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty); + } else { + setVisibility(false); + el.classList.remove('module-empty-compact'); + } + }); + + updateRightColumnVisibility(); + updateInnerColumnVisibility(); + } + + function updateRightColumnVisibility() { + const rightColumn = document.getElementById('case-right-column'); + const leftColumn = document.getElementById('case-left-column'); + if (!rightColumn || !leftColumn) return; + + const visibleRightModules = rightColumn.querySelectorAll('.right-module-card:not(.d-none)'); + if (visibleRightModules.length === 0) { + rightColumn.classList.add('d-none'); + rightColumn.classList.remove('col-xl-4'); + rightColumn.classList.remove('col-lg-4'); + leftColumn.classList.remove('col-xl-8'); + leftColumn.classList.remove('col-lg-8'); + leftColumn.classList.add('col-12'); + } else { + rightColumn.classList.remove('d-none'); + rightColumn.classList.add('col-xl-4'); + rightColumn.classList.add('col-lg-4'); + leftColumn.classList.add('col-xl-8'); + leftColumn.classList.add('col-lg-8'); + leftColumn.classList.remove('col-12'); + } + } + + function updateInnerColumnVisibility() { + const leftCol = document.getElementById('inner-left-col'); + const centerCol = document.getElementById('inner-center-col'); + if (!leftCol || !centerCol) return; + + // Tæl synlige moduler i venstre kolonnen (mb-3 wrappers der ikke er skjulte) + const visibleLeftModules = leftCol.querySelectorAll('.mb-3:not(.d-none) [data-module]'); + const hasVisibleLeft = visibleLeftModules.length > 0; + + if (!hasVisibleLeft) { + // Ingen synlige moduler i venstre - center forbliver fuld bredde + leftCol.classList.add('d-none'); + centerCol.classList.add('col-12'); + } else { + // Begge interne sektioner vises stadig i én kolonne hver + leftCol.classList.remove('d-none'); + centerCol.classList.add('col-12'); + } + } + + async function applyViewFromTags() { + try { + const res = await fetch(`/api/v1/tags/entity/case/${caseIds}`); + if (!res.ok) return; + const tags = await res.json(); + const viewTag = tags.find(t => ['Pipeline', 'Kundevisning', 'Sag-detalje'].includes(t.name)); + applyViewLayout(viewTag ? viewTag.name : 'Sag-detalje'); + } catch (e) { + console.error('View tag lookup failed', e); + } + } + + async function loadModulePrefs() { + try { + const res = await fetch(`/api/v1/sag/${caseIds}/modules`); + if (!res.ok) return; + const prefs = await res.json(); + modulePrefs = (prefs || []).reduce((acc, p) => { + acc[p.module_key] = p.is_enabled; + return acc; + }, {}); + modulePrefs.time = true; + } catch (e) { + console.error('Module prefs load failed', e); + } + } + + async function loadCaseTypeModuleDefaultsSetting() { + try { + const res = await fetch('/api/v1/settings/case_type_module_defaults'); + if (!res.ok) return; + const setting = await res.json(); + const parsed = JSON.parse(setting.value || '{}'); + if (parsed && typeof parsed === 'object') { + caseTypeModuleDefaults = Object.entries(parsed).reduce((acc, [key, value]) => { + acc[String(key || '').toLowerCase()] = Array.isArray(value) ? value : []; + return acc; + }, {}); + } else { + caseTypeModuleDefaults = {}; + } + } catch (e) { + console.error('Case type module defaults load failed', e); + caseTypeModuleDefaults = {}; + } + } + + async function openModuleControlModal() { + const list = document.getElementById('moduleControlList'); + list.innerHTML = '
Indlæser...
'; + + const modules = Array.from(document.querySelectorAll('[data-module]')).map(el => { + const key = el.getAttribute('data-module'); + return { key, label: window.moduleDisplayNames[key] || key }; + }); + + list.innerHTML = modules.map(m => { + const isTimeModule = m.key === 'time'; + const checked = isTimeModule ? true : modulePrefs[m.key] !== false; + return ` +
+ + +
+ `; + }).join(''); + + const modal = new bootstrap.Modal(document.getElementById('moduleControlModal')); + modal.show(); + } + + async function toggleModulePref(moduleKey, isEnabled) { + if (moduleKey === 'time') { + modulePrefs.time = true; + applyViewFromTags(); + return; + } + try { + const res = await fetch(`/api/v1/sag/${caseIds}/modules`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ module_key: moduleKey, is_enabled: isEnabled }) + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Kunne ikke opdatere modul'); + } + modulePrefs[moduleKey] = isEnabled; + applyViewFromTags(); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + // ========================================== + // FILES & EMAILS LOGIC + // ========================================== + + let sagFilesCache = []; + + // ---------------- FILES ---------------- + + function updateCaseEmailAttachmentOptions(files) { + const select = document.getElementById('caseEmailAttachmentIds'); + if (!select) return; + + const safeFiles = Array.isArray(files) ? files : []; + if (!safeFiles.length) { + select.innerHTML = ''; + return; + } + + select.innerHTML = safeFiles.map((file) => { + const fileId = Number(file.id); + const filename = escapeHtml(file.filename || `Fil ${fileId}`); + const date = file.created_at ? new Date(file.created_at).toLocaleDateString('da-DK') : '-'; + return ``; + }).join(''); + } + + async function loadSagFiles() { + const container = document.getElementById('files-list'); + if (container) { + container.innerHTML = '
Henter filer...
'; + } + + try { + const res = await fetch(`/api/v1/sag/${caseIds}/files`); + if(res.ok) { + const files = await res.json(); + sagFilesCache = Array.isArray(files) ? files : []; + updateCaseEmailAttachmentOptions(sagFilesCache); + renderFiles(files); + } else { + sagFilesCache = []; + updateCaseEmailAttachmentOptions(sagFilesCache); + if (container) { + container.innerHTML = '
Fejl ved hentning af filer
'; + } + setModuleContentState('files', true); + } + } catch(e) { + console.error(e); + sagFilesCache = []; + updateCaseEmailAttachmentOptions(sagFilesCache); + if (container) { + container.innerHTML = '
Fejl ved hentning af filer
'; + } + setModuleContentState('files', true); + } + } + + function renderFiles(files) { + const container = document.getElementById('files-list'); + sagFilesCache = Array.isArray(files) ? files : []; + updateCaseEmailAttachmentOptions(sagFilesCache); + + if (!container) { + return; + } + + if(!files || files.length === 0) { + container.innerHTML = '
Ingen filer fundet...
'; + setModuleContentState('files', false); + return; + } + setModuleContentState('files', true); + container.innerHTML = files.map(f => { + const size = (f.size_bytes / 1024 / 1024).toFixed(2) + ' MB'; + return ` +
+
+ + ${size} • ${new Date(f.created_at).toLocaleDateString()} +
+
+ + + + +
+
+ `; + }).join(''); + } + + async function handleFileUpload(fileList) { + if(!fileList || fileList.length === 0) return; + const formData = new FormData(); + for (let i = 0; i < fileList.length; i++) { + formData.append("files", fileList[i]); + } + + // Show loading + document.getElementById('files-list').innerHTML += '
Uploader...
'; + + try { + const res = await fetch(`/api/v1/sag/${caseIds}/files`, { + method: 'POST', + body: formData + }); + if(res.ok) { + loadSagFiles(); + } else { + alert('Upload fejlede'); + loadSagFiles(); // Reload to clear loading state + } + } catch(e) { + alert('Upload fejl: ' + e); + loadSagFiles(); + } + } + + async function deleteFile(fileId) { + if(!confirm("Slet denne fil?")) return; + try { + const res = await fetch(`/api/v1/sag/${caseIds}/files/${fileId}`, { method: 'DELETE' }); + if(res.ok) loadSagFiles(); + else alert("Kunne ikke slette fil"); + } catch(e) { alert("Fejl: " + e); } + } + + // File Preview + function previewFile(fileId, filename, contentType) { + const modal = new bootstrap.Modal(document.getElementById('filePreviewModal')); + const previewContent = document.getElementById('previewContent'); + const fileNameEl = document.getElementById('previewFileName'); + const downloadBtn = document.getElementById('previewDownloadBtn'); + + // Set filename and download link + fileNameEl.textContent = filename; + const fileUrl = `/api/v1/sag/${caseIds}/files/${fileId}`; + downloadBtn.href = `${fileUrl}?download=true`; + downloadBtn.download = filename; + + // Show loading spinner + previewContent.innerHTML = ` +
+ Indlæser... +
+ `; + + modal.show(); + + // Determine file type and render preview + const ext = filename.split('.').pop().toLowerCase(); + + if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp'].includes(ext)) { + // Image preview + previewContent.innerHTML = `${filename}`; + } else if (ext === 'pdf') { + // PDF preview using iframe + previewContent.innerHTML = ``; + } else if (['txt', 'log', 'md', 'json', 'xml', 'csv', 'html', 'css', 'js', 'py', 'sql'].includes(ext)) { + // Text file preview + fetch(fileUrl) + .then(res => res.text()) + .then(text => { + previewContent.innerHTML = `
${escapeHtml(text)}
`; + }) + .catch(err => { + previewContent.innerHTML = `
Kunne ikke indlæse fil: ${err}
`; + }); + } else if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) { + // Office documents - use Google Docs Viewer + const encodedUrl = encodeURIComponent(window.location.origin + fileUrl); + previewContent.innerHTML = ``; + } else if (['mp4', 'webm', 'ogg'].includes(ext)) { + // Video preview + previewContent.innerHTML = ` + + `; + } else if (['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) { + // Audio preview + previewContent.innerHTML = ` +
+ +
${filename}
+ +
+ `; + } else { + // Unsupported file type + previewContent.innerHTML = ` +
+ +
Kan ikke vise forhåndsvisning for denne filtype
+

${filename}

+ + Download fil + +
+ `; + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // File Drag & Drop + const fileDropZone = document.getElementById('fileDropZone'); + if(fileDropZone) { + fileDropZone.addEventListener('dragover', e => { e.preventDefault(); fileDropZone.classList.add('bg-light-subtle'); }); + fileDropZone.addEventListener('dragleave', e => { e.preventDefault(); fileDropZone.classList.remove('bg-light-subtle'); }); + fileDropZone.addEventListener('drop', e => { + e.preventDefault(); + fileDropZone.classList.remove('bg-light-subtle'); + if(e.dataTransfer.files.length) handleFileUpload(e.dataTransfer.files); + }); + } + + // ---------------- EMAILS ---------------- + + let linkedEmailsCache = []; + let filteredLinkedEmailsCache = []; + let selectedLinkedEmailId = null; + let selectedLinkedEmailDetail = null; + let selectedEmailThreadKey = null; + + function parseEmailField(value) { + return String(value || '') + .split(/[\n,;]+/) + .map((email) => email.trim()) + .filter(Boolean); + } + + function escapeHtmlForInput(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + let rewriteReviewState = null; + + function extractRewriteBody(rawText, context) { + const text = String(rawText || '').trim(); + if (!text) return ''; + + if (context === 'email') { + const bodyMatch = text.match(/(?:^|\n)Besked:\s*\n([\s\S]*)$/i); + if (bodyMatch?.[1]) return bodyMatch[1].trim(); + return text; + } + + if (context === 'case') { + const descMatch = text.match(/(?:^|\n)Beskrivelse:\s*\n([\s\S]*)$/i); + if (descMatch?.[1]) return descMatch[1].trim(); + return text; + } + + return text; + } + + function buildLineDiff(originalText, rewrittenText) { + const originalLines = String(originalText || '').split('\n'); + const rewrittenLines = String(rewrittenText || '').split('\n'); + const maxLen = Math.max(originalLines.length, rewrittenLines.length); + const changes = []; + + for (let idx = 0; idx < maxLen; idx += 1) { + const before = originalLines[idx] ?? ''; + const after = rewrittenLines[idx] ?? ''; + if (before !== after) { + changes.push({ index: idx, before, after }); + } + } + + return { changes, originalLines, rewrittenLines }; + } + + function updateRewriteSelectionInfo() { + const infoEl = document.getElementById('rewritePreviewSelectionInfo'); + const selectedCount = document.querySelectorAll('.rewrite-change-check:checked').length; + const totalCount = rewriteReviewState?.changes?.length || 0; + if (!infoEl) return; + infoEl.textContent = `${selectedCount} af ${totalCount} ændringer valgt`; + } + + function renderRewritePreview(changes) { + const listEl = document.getElementById('rewritePreviewList'); + const noChangesEl = document.getElementById('rewritePreviewNoChanges'); + if (!listEl || !noChangesEl) return; + + if (!changes.length) { + listEl.innerHTML = ''; + noChangesEl.classList.remove('d-none'); + return; + } + + noChangesEl.classList.add('d-none'); + listEl.innerHTML = changes.map((change, i) => ` +
+
+
+
+ + +
+
+
+
+
Før
+
${escapeHtml(change.before) || '(tom)'}
+
+
+
Efter
+
${escapeHtml(change.after) || '(tom)'}
+
+
+
+
+ `).join(''); + + listEl.querySelectorAll('.rewrite-change-check').forEach((input) => { + input.addEventListener('change', updateRewriteSelectionInfo); + }); + updateRewriteSelectionInfo(); + } + + function applyRewriteChanges(mode) { + if (!rewriteReviewState) return; + + const { originalLines, rewrittenLines, applyToTarget } = rewriteReviewState; + if (mode === 'all') { + applyToTarget(rewrittenLines.join('\n')); + return; + } + + const selectedIndexes = new Set( + Array.from(document.querySelectorAll('.rewrite-change-check:checked')) + .map((el) => Number(el.value)) + .filter((val) => Number.isInteger(val) && val >= 0) + ); + + const merged = [...originalLines]; + for (let idx = 0; idx < rewrittenLines.length; idx += 1) { + if (selectedIndexes.has(idx)) { + merged[idx] = rewrittenLines[idx] ?? ''; + } + } + applyToTarget(merged.join('\n')); + } + + function openRewriteReviewModal({ title, originalText, rewrittenText, applyToTarget }) { + const summaryEl = document.getElementById('rewritePreviewSummary'); + const applyAllBtn = document.getElementById('rewriteApplyAllBtn'); + const applySelectedBtn = document.getElementById('rewriteApplySelectedBtn'); + const modalEl = document.getElementById('rewritePreviewModal'); + if (!summaryEl || !applyAllBtn || !applySelectedBtn || !modalEl) return; + + const diff = buildLineDiff(originalText, rewrittenText); + rewriteReviewState = { + ...diff, + applyToTarget, + }; + + summaryEl.textContent = `${title}: ${diff.changes.length} foreslaaede ændringer.`; + renderRewritePreview(diff.changes); + + applyAllBtn.disabled = !diff.changes.length; + applySelectedBtn.disabled = !diff.changes.length; + + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + modal.show(); + } + + async function requestRewriteSuggestion(endpoint, text, context) { + const response = await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, context }) + }); + if (!response.ok) { + let detail = `HTTP ${response.status}`; + try { + const err = await response.json(); + if (err?.detail) detail = err.detail; + } catch (_) {} + throw new Error(detail); + } + return response.json(); + } + + window.rewriteCaseEmailWithApproval = async function () { + const bodyInput = document.getElementById('caseEmailBody'); + const btn = document.getElementById('caseEmailRewriteBtn'); + if (!bodyInput) return; + + const source = (bodyInput.value || '').trim(); + if (!source) { + alert('Skriv en besked først.'); + return; + } + + const originalHtml = btn?.innerHTML || ''; + if (btn) { + btn.disabled = true; + btn.innerHTML = 'Renskriver...'; + } + + try { + const payload = await requestRewriteSuggestion('/api/v1/emails/rewrite-text', source, 'email'); + const rewritten = extractRewriteBody(payload?.rewritten_text || '', 'email'); + openRewriteReviewModal({ + title: 'Email-tekst', + originalText: source, + rewrittenText: rewritten, + applyToTarget: (nextText) => { + bodyInput.value = nextText; + bootstrap.Modal.getOrCreateInstance(document.getElementById('rewritePreviewModal')).hide(); + } + }); + } catch (error) { + console.error(error); + alert(`Kunne ikke renskrive email: ${error.message || 'Ukendt fejl'}`); + } finally { + if (btn) { + btn.disabled = false; + btn.innerHTML = originalHtml; + } + } + }; + + function getDefaultCaseRecipient() { + const primaryContact = document.querySelector('.contact-row[data-is-primary="true"][data-email]'); + if (primaryContact?.dataset?.email) { + return primaryContact.dataset.email.trim(); + } + + const anyContact = document.querySelector('.contact-row[data-email]'); + if (anyContact?.dataset?.email) { + return anyContact.dataset.email.trim(); + } + + const customerSmall = document.querySelector('.customer-row small'); + if (customerSmall) { + const text = customerSmall.textContent || ''; + const match = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); + if (match) { + return match[0].trim(); + } + } + + return ''; + } + + function prefillCaseEmailCompose() { + const toInput = document.getElementById('caseEmailTo'); + const subjectInput = document.getElementById('caseEmailSubject'); + + if (toInput && !toInput.value.trim()) { + const recipient = getDefaultCaseRecipient(); + if (recipient) { + toInput.value = recipient; + } + } + + if (subjectInput && !subjectInput.value.trim()) { + subjectInput.value = escapeHtmlForInput(`Sag #${caseIds}: `); + } + } + + function openReplyToLinkedEmail() { + const composeModalEl = document.getElementById('caseEmailComposeModal'); + if (!composeModalEl || !selectedLinkedEmailId || !selectedLinkedEmailDetail) { + return; + } + + const toInput = document.getElementById('caseEmailTo'); + const subjectInput = document.getElementById('caseEmailSubject'); + const bodyInput = document.getElementById('caseEmailBody'); + + const senderEmail = (selectedLinkedEmailDetail.sender_email || '').trim(); + const originalSubject = (selectedLinkedEmailDetail.subject || '').trim(); + + if (toInput && !toInput.value.trim() && senderEmail) { + toInput.value = senderEmail; + } + + if (subjectInput && !subjectInput.value.trim()) { + const replySubject = /^re:\s*/i.test(originalSubject) + ? originalSubject + : `Re: ${originalSubject || `Sag #${caseIds}`}`; + subjectInput.value = escapeHtmlForInput(replySubject); + } + + if (bodyInput && !bodyInput.value.trim()) { + const received = selectedLinkedEmailDetail.received_date + ? new Date(selectedLinkedEmailDetail.received_date).toLocaleString('da-DK') + : '-'; + const senderName = selectedLinkedEmailDetail.sender_name || senderEmail || 'Ukendt'; + bodyInput.value = `\n\n---\nFra: ${senderName}\nDato: ${received}\nEmne: ${originalSubject || '(Ingen emne)'}\n`; + } + + bootstrap.Modal.getOrCreateInstance(composeModalEl).show(); + } + + async function sendCaseEmail() { + const toInput = document.getElementById('caseEmailTo'); + const ccInput = document.getElementById('caseEmailCc'); + const bccInput = document.getElementById('caseEmailBcc'); + const subjectInput = document.getElementById('caseEmailSubject'); + const bodyInput = document.getElementById('caseEmailBody'); + const attachmentSelect = document.getElementById('caseEmailAttachmentIds'); + const sendBtn = document.getElementById('caseEmailSendBtn'); + const statusEl = document.getElementById('caseEmailSendStatus'); + + if (!toInput || !subjectInput || !bodyInput || !sendBtn || !statusEl) { + return; + } + + const to = parseEmailField(toInput.value); + const cc = parseEmailField(ccInput?.value || ''); + const bcc = parseEmailField(bccInput?.value || ''); + const subject = (subjectInput.value || '').trim(); + const bodyText = (bodyInput.value || '').trim(); + const attachmentFileIds = Array.from(attachmentSelect?.selectedOptions || []) + .map((opt) => Number(opt.value)) + .filter((id) => Number.isInteger(id) && id > 0); + + if (!to.length) { + alert('Udfyld mindst én modtager i Til-feltet.'); + return; + } + + if (!subject) { + alert('Udfyld emne før afsendelse.'); + return; + } + + if (!bodyText) { + alert('Udfyld besked før afsendelse.'); + return; + } + + sendBtn.disabled = true; + statusEl.className = 'text-muted'; + statusEl.textContent = 'Sender e-mail...'; + + try { + const res = await fetch(`/api/v1/sag/${caseIds}/emails/send`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + to, + cc, + bcc, + subject, + body_text: bodyText, + attachment_file_ids: attachmentFileIds, + thread_email_id: selectedLinkedEmailId || null, + thread_key: ( + linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key + || linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.resolved_thread_key + || null + ) + }) + }); + + if (!res.ok) { + let message = `HTTP ${res.status} ${res.statusText || 'Send failed'}`; + try { + const responseText = await res.text(); + if (responseText) { + try { + const err = JSON.parse(responseText); + if (err?.detail) { + message = err.detail; + } else if (err?.message) { + message = err.message; + } + } catch (_) { + message = responseText.slice(0, 500); + } + } + } catch (_) { + } + throw new Error(message); + } + + if (subjectInput) subjectInput.value = ''; + if (bodyInput) bodyInput.value = ''; + if (ccInput) ccInput.value = ''; + if (bccInput) bccInput.value = ''; + if (attachmentSelect) { + Array.from(attachmentSelect.options).forEach((option) => { + option.selected = false; + }); + } + + statusEl.className = 'text-success'; + statusEl.textContent = 'E-mail sendt.'; + loadLinkedEmails(); + + const composeModalEl = document.getElementById('caseEmailComposeModal'); + const composeModal = composeModalEl ? bootstrap.Modal.getInstance(composeModalEl) : null; + if (composeModal) { + composeModal.hide(); + } + } catch (error) { + statusEl.className = 'text-danger'; + statusEl.textContent = error?.message || 'Email send failed (ukendt fejl)'; + } finally { + sendBtn.disabled = false; + } + } + + function openCaseEmailTab() { + const trigger = document.getElementById('emails-tab'); + if (!trigger) return; + const instance = bootstrap.Tab.getOrCreateInstance(trigger); + instance.show(); + } + + window.quickReplyToEmailFromComment = async function(emailId) { + const parsedId = Number(emailId); + if (!Number.isFinite(parsedId)) return; + + openCaseEmailTab(); + + try { + await loadLinkedEmails(); + await loadLinkedEmailDetail(parsedId); + openReplyToLinkedEmail(); + } catch (error) { + console.error('Kunne ikke starte quick svar fra kommentar:', error); + } + } + + async function loadLinkedEmails() { + const container = document.getElementById('linked-emails-list'); + const threadContainer = document.getElementById('email-threads-list'); + if (!container || !threadContainer) return; + + try { + const res = await fetch(`/api/v1/sag/${caseIds}/email-links`); + if(res.ok) { + linkedEmailsCache = await res.json(); + await applyLinkedEmailFilters(true); + } else { + container.innerHTML = '
Fejl ved hentning af emails
'; + threadContainer.innerHTML = '
Fejl ved hentning af tråde
'; + setModuleContentState('emails', true); + } + } catch(e) { + console.error(e); + container.innerHTML = '
Fejl ved hentning af emails
'; + threadContainer.innerHTML = '
Fejl ved hentning af tråde
'; + setModuleContentState('emails', true); + } + } + + function getFilteredLinkedEmails() { + const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase(); + const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all'; + const readFilter = document.getElementById('emailReadFilter')?.value || 'all'; + + return linkedEmailsCache.filter((email) => { + if (textFilter) { + const haystack = [ + email.subject, + email.sender_email, + email.sender_name, + email.body_text, + email.body_html + ].join(' ').toLowerCase(); + if (!haystack.includes(textFilter)) return false; + } + + const hasAttachments = Boolean(email.has_attachments) || Number(email.attachment_count || 0) > 0; + if (attachmentFilter === 'with' && !hasAttachments) return false; + if (attachmentFilter === 'without' && hasAttachments) return false; + + const isRead = Boolean(email.is_read); + if (readFilter === 'read' && !isRead) return false; + if (readFilter === 'unread' && isRead) return false; + + return true; + }); + } + + function getThreadKey(email) { + return (email?.resolved_thread_key || email?.thread_key || `email-${email?.id || 'unknown'}`).toString(); + } + + function isOutgoingEmail(email) { + if (typeof email?.is_outgoing === 'boolean') { + return email.is_outgoing; + } + const folder = (email?.folder || '').toString().toLowerCase(); + const status = (email?.status || '').toString().toLowerCase(); + return folder.startsWith('sent') || status === 'sent'; + } + + function buildThreadGroups(emails) { + const map = new Map(); + + emails.forEach((email) => { + const threadKey = getThreadKey(email); + const existing = map.get(threadKey); + const receivedDateMs = email.received_date ? new Date(email.received_date).getTime() : 0; + + if (!existing) { + map.set(threadKey, { + threadKey, + lastDateMs: receivedDateMs, + latestEmail: email, + emails: [email] + }); + return; + } + + existing.emails.push(email); + if (receivedDateMs > existing.lastDateMs) { + existing.lastDateMs = receivedDateMs; + existing.latestEmail = email; + } + }); + + return Array.from(map.values()) + .map((group) => { + group.emails.sort((a, b) => { + const aDate = a.received_date ? new Date(a.received_date).getTime() : 0; + const bDate = b.received_date ? new Date(b.received_date).getTime() : 0; + return bDate - aDate; + }); + return group; + }) + .sort((a, b) => b.lastDateMs - a.lastDateMs); + } + + function getCurrentThreadEmails() { + if (!selectedEmailThreadKey) return []; + return filteredLinkedEmailsCache + .filter((email) => getThreadKey(email) === selectedEmailThreadKey) + .sort((a, b) => { + const aDate = a.received_date ? new Date(a.received_date).getTime() : 0; + const bDate = b.received_date ? new Date(b.received_date).getTime() : 0; + return bDate - aDate; + }); + } + + function renderEmailThreads(threadGroups) { + const container = document.getElementById('email-threads-list'); + if (!container) return; + + if (!threadGroups.length) { + container.innerHTML = '
Ingen tråde fundet...
'; + const counter = document.getElementById('linkedEmailThreadsCount'); + if (counter) counter.textContent = '0'; + return; + } + + const counter = document.getElementById('linkedEmailThreadsCount'); + if (counter) counter.textContent = String(threadGroups.length); + + container.innerHTML = threadGroups.map((group) => { + const latest = group.latestEmail || {}; + const isSelected = selectedEmailThreadKey === group.threadKey; + const receivedDate = latest.received_date ? new Date(latest.received_date).toLocaleString('da-DK') : '-'; + const sender = latest.sender_name || latest.sender_email || '-'; + const subject = latest.subject || '(Ingen emne)'; + const unreadCount = group.emails.filter((item) => !item.is_read).length; + + return ` + + `; + }).join(''); + } + + function selectEmailThread(threadKey) { + selectedEmailThreadKey = String(threadKey || ''); + + const threadGroups = buildThreadGroups(filteredLinkedEmailsCache); + renderEmailThreads(threadGroups); + + const threadEmails = getCurrentThreadEmails(); + renderLinkedEmails(threadEmails); + + if (!threadEmails.length) { + selectedLinkedEmailId = null; + renderEmailPreviewEmpty(); + return; + } + + const hasCurrentSelected = threadEmails.some((item) => Number(item.id) === Number(selectedLinkedEmailId)); + if (!hasCurrentSelected) { + selectedLinkedEmailId = Number(threadEmails[0].id); + } + + loadLinkedEmailDetail(selectedLinkedEmailId, true); + } + + async function applyLinkedEmailFilters(loadDetail = false) { + filteredLinkedEmailsCache = getFilteredLinkedEmails(); + const threadGroups = buildThreadGroups(filteredLinkedEmailsCache); + + renderEmailThreads(threadGroups); + + if (!threadGroups.length) { + selectedEmailThreadKey = null; + selectedLinkedEmailId = null; + renderLinkedEmails([]); + const threadCounter = document.getElementById('threadEmailsCount'); + if (threadCounter) threadCounter.textContent = '0'; + renderEmailPreviewEmpty(); + setModuleContentState('emails', false); + return; + } + + const selectedThreadExists = threadGroups.some((group) => group.threadKey === selectedEmailThreadKey); + if (!selectedThreadExists) { + selectedEmailThreadKey = threadGroups[0].threadKey; + } + + const threadEmails = getCurrentThreadEmails(); + renderLinkedEmails(threadEmails); + + const threadCounter = document.getElementById('threadEmailsCount'); + if (threadCounter) threadCounter.textContent = String(threadEmails.length); + + if (!threadEmails.length) { + selectedLinkedEmailId = null; + renderEmailPreviewEmpty(); + return; + } + + const selectedEmailExists = threadEmails.some((item) => Number(item.id) === Number(selectedLinkedEmailId)); + if (!selectedEmailExists) { + selectedLinkedEmailId = Number(threadEmails[0].id); + } + + if (loadDetail && selectedLinkedEmailId) { + await loadLinkedEmailDetail(selectedLinkedEmailId, true); + } + + setModuleContentState('emails', true); + } + + function renderLinkedEmails(emails) { + const container = document.getElementById('linked-emails-list'); + if (!container) return; + if(!emails || emails.length === 0) { + container.innerHTML = '
Ingen linkede e-mails...
'; + return; + } + + container.innerHTML = emails.map(e => { + const isSelected = Number(selectedLinkedEmailId) === Number(e.id); + const receivedDate = e.received_date ? new Date(e.received_date).toLocaleString('da-DK') : '-'; + const sender = e.sender_name || e.sender_email || '-'; + const subject = e.subject || '(Ingen emne)'; + const isOutgoing = isOutgoingEmail(e); + const snippetSource = e.body_text || e.body_html || ''; + const snippet = snippetSource.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 130); + const hasAttachments = Boolean(e.has_attachments) || Number(e.attachment_count || 0) > 0; + + return ` + + `; + }).join(''); + } + + function renderEmailPreviewEmpty() { + const panel = document.getElementById('email-preview-panel'); + if (!panel) return; + selectedLinkedEmailDetail = null; + panel.innerHTML = ` +
+ Vælg en e-mail i listen for at se indhold og vedhæftninger +
+ `; + } + + async function loadLinkedEmailDetail(emailId, skipRefresh = false) { + selectedLinkedEmailId = Number(emailId); + const panel = document.getElementById('email-preview-panel'); + if (!panel) return; + + panel.innerHTML = ` +
+
+ Henter e-mail... +
+ `; + + if (!skipRefresh) { + const threadEmails = getCurrentThreadEmails(); + renderLinkedEmails(threadEmails); + } + + try { + const res = await fetch(`/api/v1/emails/${emailId}`); + if (!res.ok) { + panel.innerHTML = '
Kunne ikke hente e-mail detaljer.
'; + return; + } + + const email = await res.json(); + const subject = email.subject || '(Ingen emne)'; + const sender = email.sender_name || email.sender_email || '-'; + const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-'; + const attachments = Array.isArray(email.attachments) ? email.attachments : []; + const bodyText = email.body_text || ''; + const bodyHtml = email.body_html || ''; + selectedLinkedEmailDetail = email; + + panel.innerHTML = ` +
+
${escapeHtml(subject)}
+
Fra: ${escapeHtml(sender)}
+
Dato: ${escapeHtml(received)}
+
+ +
+
+
+
Vedhæftninger (${attachments.length})
+
+
+
+ ${bodyText ? `
${escapeHtml(bodyText)}
` : (bodyHtml ? bodyHtml : '
Ingen indhold
')} +
+ `; + + const attachmentContainer = document.getElementById('email-attachments-list'); + if (attachmentContainer) { + if (!attachments.length) { + attachmentContainer.innerHTML = 'Ingen vedhæftninger'; + } else { + attachmentContainer.innerHTML = attachments.map(att => { + const attachmentName = att.filename || `Vedhæftning ${att.id}`; + const url = `/api/v1/emails/${email.id}/attachments/${att.id}`; + return `${escapeHtml(attachmentName)}`; + }).join(''); + } + } + + const cacheIdx = linkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id)); + if (cacheIdx >= 0) { + linkedEmailsCache[cacheIdx].is_read = true; + } + + const filteredIdx = filteredLinkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id)); + if (filteredIdx >= 0) { + filteredLinkedEmailsCache[filteredIdx].is_read = true; + } + + if (!skipRefresh) { + const threadEmails = getCurrentThreadEmails(); + renderLinkedEmails(threadEmails); + renderEmailThreads(buildThreadGroups(filteredLinkedEmailsCache)); + } + } catch (e) { + console.error(e); + selectedLinkedEmailDetail = null; + panel.innerHTML = '
Fejl ved hentning af e-mail detaljer.
'; + } + } + + async function unlinkEmail(emailId) { + if(!confirm("Fjern link til denne email?")) return; + try { + const res = await fetch(`/api/v1/sag/${caseIds}/email-links/${emailId}`, { method: 'DELETE' }); + if(res.ok) { + if (Number(selectedLinkedEmailId) === Number(emailId)) { + selectedLinkedEmailId = null; + renderEmailPreviewEmpty(); + } + loadLinkedEmails(); + } + } catch(e) { alert(e); } + } + + // Email Search + const emailSearchInput = document.getElementById('emailSearchInput'); + const emailSearchResults = document.getElementById('emailSearchResults'); + let emailDebounce = null; + + if(emailSearchInput) { + emailSearchInput.addEventListener('input', e => { + clearTimeout(emailDebounce); + const q = e.target.value.trim(); + if(q.length < 2) { + emailSearchResults.style.display = 'none'; + return; + } + emailDebounce = setTimeout(() => searchEmails(q), 300); + }); + + // Hide on outside click + document.addEventListener('click', e => { + if(!emailSearchInput.contains(e.target) && !emailSearchResults.contains(e.target)) { + emailSearchResults.style.display = 'none'; + } + }); + } + + ['emailFilterInput', 'emailAttachmentFilter', 'emailReadFilter'].forEach((id) => { + const el = document.getElementById(id); + if (!el) return; + const eventName = id === 'emailFilterInput' ? 'input' : 'change'; + el.addEventListener(eventName, () => { + applyLinkedEmailFilters(true); + }); + }); + + async function searchEmails(query) { + try { + const res = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`); + if(res.ok) { + const emails = await res.json(); + renderEmailSuggestions(emails); + emailSearchResults.style.display = 'block'; + } + } catch(e) { console.error(e); } + } + + function renderEmailSuggestions(emails) { + if(!emails.length) { + emailSearchResults.innerHTML = '
Ingen fundet
'; + return; + } + emailSearchResults.innerHTML = emails.map(e => ` + + `).join(''); + } + + async function linkEmail(emailId) { + emailSearchInput.value = ''; + emailSearchResults.style.display = 'none'; + try { + const res = await fetch(`/api/v1/sag/${caseIds}/email-links`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({email_id: emailId}) + }); + if(res.ok) loadLinkedEmails(); + else alert("Kunne ikke linke email"); + } catch(e) { alert(e); } + } + + // Email Import Drag & Drop (.msg / .eml) + const emailDropZone = document.getElementById('emailDropZone'); + if(emailDropZone) { + emailDropZone.addEventListener('dragover', e => { e.preventDefault(); emailDropZone.classList.add('bg-warning-subtle'); }); + emailDropZone.addEventListener('dragleave', e => { e.preventDefault(); emailDropZone.classList.remove('bg-warning-subtle'); }); + emailDropZone.addEventListener('drop', e => { + e.preventDefault(); + emailDropZone.classList.remove('bg-warning-subtle'); + const files = e.dataTransfer.files; + if(files.length) uploadEmailFile(files[0]); + }); + } + + async function uploadEmailFile(file) { + if (!file) return; + const lowerName = String(file.name || '').toLowerCase(); + if (!(lowerName.endsWith('.eml') || lowerName.endsWith('.msg'))) { + alert('Kun .eml og .msg filer understøttes'); + return; + } + + const formData = new FormData(); + formData.append('file', file); + + // Show busy indicator + emailDropZone.style.opacity = '0.5'; + + try { + const res = await fetch(`/api/v1/sag/${caseIds}/upload-email`, { + method: 'POST', + body: formData + }); + if(res.ok) { + loadLinkedEmails(); + } else { + alert('Import fejlede'); + } + } catch(e) { alert(e); } + finally { + emailDropZone.style.opacity = '1'; + } + } + + // Load content on start + document.addEventListener('DOMContentLoaded', () => { + const caseEmailSendBtn = document.getElementById('caseEmailSendBtn'); + if (caseEmailSendBtn) { + caseEmailSendBtn.addEventListener('click', sendCaseEmail); + } + + const caseEmailRewriteBtn = document.getElementById('caseEmailRewriteBtn'); + if (caseEmailRewriteBtn) { + caseEmailRewriteBtn.addEventListener('click', rewriteCaseEmailWithApproval); + } + + const rewriteApplyAllBtn = document.getElementById('rewriteApplyAllBtn'); + if (rewriteApplyAllBtn) { + rewriteApplyAllBtn.addEventListener('click', () => applyRewriteChanges('all')); + } + + const rewriteApplySelectedBtn = document.getElementById('rewriteApplySelectedBtn'); + if (rewriteApplySelectedBtn) { + rewriteApplySelectedBtn.addEventListener('click', () => applyRewriteChanges('selected')); + } + + const caseEmailComposeModal = document.getElementById('caseEmailComposeModal'); + if (caseEmailComposeModal) { + caseEmailComposeModal.addEventListener('show.bs.modal', () => { + const statusEl = document.getElementById('caseEmailSendStatus'); + if (statusEl) { + statusEl.className = 'text-muted'; + statusEl.textContent = ''; + } + prefillCaseEmailCompose(); + updateCaseEmailAttachmentOptions(sagFilesCache); + }); + } + + prefillCaseEmailCompose(); + updateCaseEmailAttachmentOptions(sagFilesCache); + loadSagFiles(); + loadLinkedEmails(); + }); + + \ No newline at end of file diff --git a/script_9.js b/script_9.js new file mode 100644 index 0000000..a828c9a --- /dev/null +++ b/script_9.js @@ -0,0 +1,544 @@ + + const subscriptionCaseId = {{ case.id }}; + let currentSubscription = null; + let subscriptionProducts = []; + let lastCreatedSubscriptionProductId = null; + + function formatSubscriptionInterval(interval) { + const map = { + 'daily': 'Daglig', + 'biweekly': '14-dage', + 'monthly': 'Maaned', + 'quarterly': 'Kvartal', + 'yearly': 'Aar' + }; + return map[interval] || interval || '-'; + } + + function formatSubscriptionCurrency(amount) { + return new Intl.NumberFormat('da-DK', { + style: 'currency', + currency: 'DKK', + minimumFractionDigits: 0, + maximumFractionDigits: 0 + }).format(amount || 0); + } + + function formatSubscriptionDate(dateStr) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return date.toLocaleDateString('da-DK'); + } + + function setSubscriptionBadge(status) { + const badge = document.getElementById('subscriptionStatusBadge'); + if (!badge) return; + const classes = { + 'draft': 'bg-light text-dark', + 'active': 'bg-success', + 'paused': 'bg-warning', + 'cancelled': 'bg-secondary' + }; + const label = { + 'draft': 'Kladde', + 'active': 'Aktiv', + 'paused': 'Pauset', + 'cancelled': 'Opsagt' + }; + badge.className = `badge ${classes[status] || 'bg-light text-dark'}`; + badge.textContent = label[status] || status || 'Ingen'; + } + + function showSubscriptionCreateForm() { + const empty = document.getElementById('subscriptionEmpty'); + const form = document.getElementById('subscriptionCreateForm'); + const details = document.getElementById('subscriptionDetails'); + if (empty) empty.classList.remove('d-none'); + if (form) form.classList.remove('d-none'); + if (details) details.classList.add('d-none'); + setSubscriptionBadge(null); + + const startDateInput = document.getElementById('subscriptionStartDateInput'); + if (startDateInput && !startDateInput.value) { + startDateInput.value = new Date().toISOString().split('T')[0]; + } + + const body = document.getElementById('subscriptionLineItemsBody'); + if (body) { + body.innerHTML = ` + + + + + + + + 0,00 kr + + + + + `; + } + populateSubscriptionProductSelects(); + updateSubscriptionLineTotals(); + } + + function populateSubscriptionProductSelects() { + const selects = document.querySelectorAll('.subscriptionProductSelect'); + selects.forEach(select => { + const currentValue = select.value; + select.innerHTML = ''; + subscriptionProducts.forEach(product => { + const option = document.createElement('option'); + option.value = product.id; + option.textContent = product.name; + option.dataset.salesPrice = product.sales_price ?? ''; + option.dataset.description = product.short_description ?? ''; + select.appendChild(option); + }); + if (currentValue) { + select.value = currentValue; + } else if (lastCreatedSubscriptionProductId) { + select.value = String(lastCreatedSubscriptionProductId); + } + }); + lastCreatedSubscriptionProductId = null; + } + + function applySubscriptionProduct(select) { + const row = select.closest('tr'); + if (!row) return; + const descriptionInput = row.querySelector('input[type="text"]'); + const unitPriceInput = row.querySelectorAll('input[type="number"]')[1]; + const selected = select.options[select.selectedIndex]; + if (!selected) return; + + const description = selected.dataset.description || selected.textContent || ''; + const salesPrice = selected.dataset.salesPrice; + + if (descriptionInput && !descriptionInput.value.trim()) { + descriptionInput.value = description; + } + if (unitPriceInput && salesPrice !== '') { + unitPriceInput.value = salesPrice; + } + updateSubscriptionLineTotals(); + } + + function addSubscriptionLine() { + const body = document.getElementById('subscriptionLineItemsBody'); + if (!body) return; + const row = document.createElement('tr'); + row.innerHTML = ` + + + + + + + 0,00 kr + + + + `; + body.appendChild(row); + populateSubscriptionProductSelects(); + updateSubscriptionLineTotals(); + } + + function removeSubscriptionLine(button) { + const row = button.closest('tr'); + const body = document.getElementById('subscriptionLineItemsBody'); + if (!row || !body) return; + if (body.children.length <= 1) { + row.querySelectorAll('input').forEach(input => { + input.value = input.type === 'number' ? 0 : ''; + }); + } else { + row.remove(); + } + updateSubscriptionLineTotals(); + } + + function updateSubscriptionLineTotals() { + const body = document.getElementById('subscriptionLineItemsBody'); + const totalEl = document.getElementById('subscriptionLinesTotal'); + if (!body || !totalEl) return; + + let total = 0; + Array.from(body.querySelectorAll('tr')).forEach(row => { + const inputs = row.querySelectorAll('input'); + const description = inputs[0]?.value || ''; + const qty = parseFloat(inputs[1]?.value || 0); + const unit = parseFloat(inputs[2]?.value || 0); + const lineTotal = (qty > 0 ? qty : 0) * (unit > 0 ? unit : 0); + total += lineTotal; + const lineTotalEl = row.querySelector('.subscriptionLineTotal'); + if (lineTotalEl) { + lineTotalEl.textContent = formatSubscriptionCurrency(lineTotal); + } + if (!description && qty === 0 && unit === 0) { + if (lineTotalEl) { + lineTotalEl.textContent = formatSubscriptionCurrency(0); + } + } + }); + + totalEl.textContent = formatSubscriptionCurrency(total); + } + + function collectSubscriptionLineItems() { + const body = document.getElementById('subscriptionLineItemsBody'); + if (!body) return []; + const items = []; + Array.from(body.querySelectorAll('tr')).forEach(row => { + const productSelect = row.querySelector('.subscriptionProductSelect'); + const inputs = row.querySelectorAll('input'); + const description = (inputs[0]?.value || '').trim(); + const quantity = parseFloat(inputs[1]?.value || 0); + const unitPrice = parseFloat(inputs[2]?.value || 0); + if (!description && quantity === 0 && unitPrice === 0) { + return; + } + items.push({ + product_id: productSelect && productSelect.value ? parseInt(productSelect.value, 10) : null, + description, + quantity, + unit_price: unitPrice + }); + }); + return items; + } + + async function loadSubscriptionProducts() { + try { + const res = await fetch('/api/v1/products'); + if (!res.ok) { + throw new Error('Kunne ikke hente produkter'); + } + subscriptionProducts = await res.json(); + } catch (e) { + console.error('Error loading products:', e); + subscriptionProducts = []; + } + populateSubscriptionProductSelects(); + } + + function openSubscriptionProductModal() { + const form = document.getElementById('subscriptionProductForm'); + if (form) form.reset(); + new bootstrap.Modal(document.getElementById('subscriptionProductModal')).show(); + } + + async function createSubscriptionProduct() { + const payload = { + name: document.getElementById('subscriptionProductName').value.trim(), + type: document.getElementById('subscriptionProductType').value.trim() || null, + status: document.getElementById('subscriptionProductStatus').value, + sales_price: document.getElementById('subscriptionProductSalesPrice').value || null, + billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null, + short_description: document.getElementById('subscriptionProductDescription').value.trim() || null + }; + + if (!payload.name) { + alert('Navn er paakraevet'); + return; + } + + const res = await fetch('/api/v1/products', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const error = await res.json(); + alert(error.detail || 'Kunne ikke oprette produkt'); + return; + } + + const product = await res.json(); + lastCreatedSubscriptionProductId = product.id; + bootstrap.Modal.getInstance(document.getElementById('subscriptionProductModal')).hide(); + await loadSubscriptionProducts(); + updateSubscriptionLineTotals(); + } + + function renderSubscription(subscription) { + currentSubscription = subscription; + const empty = document.getElementById('subscriptionEmpty'); + const form = document.getElementById('subscriptionCreateForm'); + const details = document.getElementById('subscriptionDetails'); + if (empty) empty.classList.add('d-none'); + if (form) form.classList.add('d-none'); + if (details) details.classList.remove('d-none'); + + document.getElementById('subscriptionNumber').textContent = subscription.subscription_number || `#${subscription.id}`; + document.getElementById('subscriptionProduct').textContent = subscription.product_name || '-'; + document.getElementById('subscriptionInterval').textContent = formatSubscriptionInterval(subscription.billing_interval); + document.getElementById('subscriptionPrice').textContent = formatSubscriptionCurrency(subscription.price); + document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date); + document.getElementById('subscriptionStatusText').textContent = subscription.status || '-'; + + // New fields + const periodStartEl = document.getElementById('subscriptionPeriodStart'); + const nextInvoiceEl = document.getElementById('subscriptionNextInvoice'); + if (periodStartEl) { + periodStartEl.textContent = subscription.period_start ? formatSubscriptionDate(subscription.period_start) : '-'; + } + if (nextInvoiceEl) { + const nextDate = subscription.next_invoice_date ? formatSubscriptionDate(subscription.next_invoice_date) : '-'; + nextInvoiceEl.textContent = nextDate; + // Highlight if invoice is due soon + if (subscription.next_invoice_date) { + const daysUntil = Math.ceil((new Date(subscription.next_invoice_date) - new Date()) / (1000 * 60 * 60 * 24)); + if (daysUntil <= 7 && daysUntil >= 0) { + nextInvoiceEl.innerHTML = `${nextDate} Om ${daysUntil} dage`; + } + } + } + + setSubscriptionBadge(subscription.status); + + const itemsBody = document.getElementById('subscriptionItemsBody'); + const itemsTotal = document.getElementById('subscriptionItemsTotal'); + if (itemsBody) { + const items = subscription.line_items || []; + if (!items.length) { + itemsBody.innerHTML = 'Ingen linjer'; + } else { + itemsBody.innerHTML = items.map(item => ` + + ${item.product_name || '-'} + ${item.description} + ${parseFloat(item.quantity).toFixed(2)} + ${formatSubscriptionCurrency(item.unit_price)} + ${formatSubscriptionCurrency(item.line_total)} + + `).join(''); + } + } + if (itemsTotal) { + itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0); + } + + const actions = document.getElementById('subscriptionActions'); + if (!actions) return; + + const buttons = []; + if (subscription.status === 'draft' || subscription.status === 'paused') { + buttons.push(``); + } + if (subscription.status === 'active') { + buttons.push(``); + } + if (subscription.status !== 'cancelled') { + buttons.push(``); + } + actions.innerHTML = buttons.join(' '); + } + + async function loadSubscriptionForCase() { + try { + const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}`); + if (res.status === 404) { + showSubscriptionCreateForm(); + setModuleContentState('subscription', false); + return; + } + if (!res.ok) { + throw new Error('Kunne ikke hente abonnement'); + } + const subscription = await res.json(); + renderSubscription(subscription); + setModuleContentState('subscription', true); + } catch (e) { + console.error('Error loading subscription:', e); + showSubscriptionCreateForm(); + setModuleContentState('subscription', true); + } + } + + async function createSubscription() { + const billingInterval = document.getElementById('subscriptionIntervalInput').value; + const billingDay = parseInt(document.getElementById('subscriptionBillingDayInput').value, 10); + const startDate = document.getElementById('subscriptionStartDateInput').value; + const notes = document.getElementById('subscriptionNotesInput').value.trim(); + + const lineItems = collectSubscriptionLineItems(); + + if (!billingInterval || !billingDay || !startDate) { + alert('Udfyld venligst alle paakraevet felter'); + return; + } + if (!lineItems.length) { + alert('Du skal angive mindst en varelinje'); + return; + } + + try { + const res = await fetch('/api/v1/sag-subscriptions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sag_id: subscriptionCaseId, + billing_interval: billingInterval, + billing_day: billingDay, + start_date: startDate, + notes: notes || null, + line_items: lineItems + }) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || 'Fejl ved oprettelse'); + } + + const subscription = await res.json(); + renderSubscription(subscription); + } catch (e) { + alert(e.message || e); + } + } + + async function updateSubscriptionStatus(status) { + if (!currentSubscription) return; + if (status === 'cancelled' && !confirm('Er du sikker paa, at abonnementet skal opsiges?')) { + return; + } + + try { + const res = await fetch(`/api/v1/sag-subscriptions/${currentSubscription.id}/status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || 'Kunne ikke opdatere status'); + } + + const updated = await res.json(); + renderSubscription(updated); + } catch (e) { + alert(e.message || e); + } + } + + document.addEventListener('DOMContentLoaded', () => { + loadSubscriptionProducts(); + loadSubscriptionForCase(); + }); + + // === Quick Time Entry Functions (for inline time tracking) === + function toggleQuickTimeForm() { + const container = document.getElementById('quickTimeFormContainer'); + if (container) { + container.classList.remove('d-none'); + } + } + + // Make function globally available for onclick handler + window.toggleQuickTimeForm = toggleQuickTimeForm; + + async function quickAddTime(event) { + event.preventDefault(); + + const form = document.getElementById('quickAddTimeForm'); + const formData = new FormData(form); + + // Parse hours and minutes + const hours = parseInt(formData.get('hours')) || 0; + const minutes = parseInt(formData.get('minutes')) || 0; + const totalHours = hours + (minutes / 60); + + if (totalHours === 0) { + alert('Angiv venligst timer eller minutter'); + return; + } + + const billingSelect = document.getElementById('quickTimeBillingMethod'); + let billingMethod = billingSelect ? billingSelect.value : 'invoice'; + let prepaidCardId = null; + let fixedPriceAgreementId = null; + + if (billingMethod.startsWith('card_')) { + prepaidCardId = parseInt(billingMethod.split('_')[1]); + billingMethod = 'prepaid'; + } + + if (billingMethod.startsWith('fpa_')) { + fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]); + billingMethod = 'fixed_price'; + } + + const isInternal = billingMethod === 'internal'; + + // Build payload + const payload = { + sag_id: {{ case.id }}, + worked_date: formData.get('date'), + original_hours: totalHours, + description: formData.get('description'), + billing_method: billingMethod, + is_internal: isInternal + }; + + if (prepaidCardId) { + payload.prepaid_card_id = prepaidCardId; + } + + if (fixedPriceAgreementId) { + payload.fixed_price_agreement_id = fixedPriceAgreementId; + } + + try { + const response = await fetch('/api/v1/timetracking/entries/internal', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Kunne ikke gemme tidsregistrering'); + } + + // Success - reload page to show new entry + window.location.reload(); + } catch (error) { + alert('Fejl: ' + error.message); + console.error('Quick add time error:', error); + } + } + + // Set today's date as default for quick time form + document.addEventListener('DOMContentLoaded', function() { + const dateInput = document.getElementById('quickTimeDate'); + if (dateInput && !dateInput.value) { + const today = new Date().toISOString().split('T')[0]; + dateInput.value = today; + } + // Activate tab from ?tab= URL parameter (used when navigating from relation tree QA menu) + const tabParam = new URLSearchParams(window.location.search).get('tab'); + if (tabParam) { + const tabBtn = document.getElementById(tabParam + '-tab') + || document.querySelector(`[data-module-tab="${tabParam}"]`); + if (tabBtn) { + setTimeout(() => { + bootstrap.Tab.getOrCreateInstance(tabBtn).show(); + forceCaseTabActivation(tabParam); + }, 300); + } + } + }); + \ No newline at end of file diff --git a/static/js/sms.js b/static/js/sms.js index 7e0fb35..8b0d2a2 100644 --- a/static/js/sms.js +++ b/static/js/sms.js @@ -31,6 +31,19 @@ async function sendSms(number, message, sender = null, contactId = null) { } const data = await response.json(); + try { + window.dispatchEvent(new CustomEvent('bmc:sms-sent', { + detail: { + to, + message: String(message || ''), + sender: sender || null, + contact_id: contactId || null, + provider_result: data || null, + } + })); + } catch (e) { + console.warn('Kunne ikke udsende sms-sent event', e); + } alert('SMS sendt ✅'); return { ok: true, data }; } diff --git a/test_anydesk.py b/test_anydesk.py new file mode 100644 index 0000000..d724020 --- /dev/null +++ b/test_anydesk.py @@ -0,0 +1,45 @@ +"""Quick test of AnyDesk HMAC-SHA1 auth + live API call""" +import hashlib, hmac, base64, time, asyncio, aiohttp, json + +LICENSE_ID = "1543834064287906" +API_TOKEN = "KQI35S594KAHJS5" +BASE_URL = "https://v1.api.anydesk.com:8081" + +def make_auth(resource, method="GET", content=""): + sha1 = hashlib.sha1() + sha1.update(content.encode()) + ch = base64.b64encode(sha1.digest()).decode() + ts = str(int(time.time())) + req = f"{method}\n{resource}\n{ts}\n{ch}" + sig = hmac.new(API_TOKEN.encode(), req.encode(), hashlib.sha1).digest() + tok = base64.b64encode(sig).decode() + return f"AD {LICENSE_ID}:{ts}:{tok}" + +async def test(): + end = int(time.time()) + start = end - 30 * 86400 # last 30 days + resource = f"/sessions?from={start}&to={end}&limit=10" + headers = {"Authorization": make_auth(resource)} + print(f"URL: GET {BASE_URL}{resource}") + print(f"Auth: {headers['Authorization'][:70]}...") + async with aiohttp.ClientSession() as s: + async with s.get( + f"{BASE_URL}{resource}", + headers=headers, + timeout=aiohttp.ClientTimeout(total=15), + ssl=True, + ) as r: + txt = await r.text() + print(f"\nStatus: {r.status}") + print(f"Response:\n{txt[:800]}") + if r.status == 200: + try: + data = json.loads(txt) + sessions = data.get("list", []) + print(f"\n✅ OK - {len(sessions)} sessions returned") + for s in sessions[:3]: + print(f" sid={s.get('sid')} | from={s.get('from',{}).get('alias','?')} | duration={s.get('duration')}s") + except Exception as e: + print(f"Parse error: {e}") + +asyncio.run(test()) diff --git a/tests/api.http b/tests/api.http new file mode 100644 index 0000000..4897ab2 --- /dev/null +++ b/tests/api.http @@ -0,0 +1,178 @@ +# BMC Hub — REST Client Test Requests +# Requires VS Code extension: "REST Client" (humao.rest-client) +# Usage: Click "Send Request" above any ### block +# +# Set your base URL and auth cookie below +# @baseUrl = http://localhost:8001 +@baseUrl = http://172.16.31.183:8001 + +# Paste a valid session cookie from browser DevTools (Network tab → any API call → Cookie header) +@authCookie = session=YOUR_SESSION_COOKIE_HERE + +### +# 1. Health check +GET {{baseUrl}}/health + +### +# 2. System info +GET {{baseUrl}}/api/v1/system/health +Cookie: {{authCookie}} + +### +# 3. List AnyDesk sessions (all or filtered) +GET {{baseUrl}}/api/v1/anydesk/sessions +Cookie: {{authCookie}} + +### +# 4. List AnyDesk sessions for specific sag +GET {{baseUrl}}/api/v1/anydesk/sessions?sag_id=53 +Cookie: {{authCookie}} + +### +# 5. Start AnyDesk session (dry-run by default) +POST {{baseUrl}}/api/v1/anydesk/start-session +Cookie: {{authCookie}} +Content-Type: application/json + +{ + "sag_id": 53, + "contact_id": null, + "customer_id": null +} + +### +# 5b. Assign AnyDesk session to sag +PATCH {{baseUrl}}/api/v1/anydesk/sessions/1 +Cookie: {{authCookie}} +Content-Type: application/json + +{ + "sag_id": 53 +} + +### +# 5c. End AnyDesk session +POST {{baseUrl}}/api/v1/anydesk/sessions/1/end +Cookie: {{authCookie}} + +### +# 5d. Get session details +GET {{baseUrl}}/api/v1/anydesk/sessions/1 +Cookie: {{authCookie}} + +### +# 5e. Pull live session log from AnyDesk API (requires dry_run=false in settings) +# Uses HMAC-SHA1 auth against https://v1.api.anydesk.com:8081/sessions +POST {{baseUrl}}/api/v1/anydesk/fetch-from-api?days=30&limit=1000 +Cookie: {{authCookie}} + +### +# 5f. Pull log for last 90 days +POST {{baseUrl}}/api/v1/anydesk/fetch-from-api?days=90&limit=1000 +Cookie: {{authCookie}} + +### +# 5g. AnyDesk health / config status +GET {{baseUrl}}/api/v1/anydesk/health +Cookie: {{authCookie}} + +### +# 6. Create sag (case) +POST {{baseUrl}}/api/v1/sag +Cookie: {{authCookie}} +Content-Type: application/json + +{ + "titel": "Test sag fra REST client", + "customer_id": 1, + "status": "åben" +} + +### +# 7. Update sag title (PATCH) +PATCH {{baseUrl}}/api/v1/sag/53 +Cookie: {{authCookie}} +Content-Type: application/json + +{ + "titel": "Opdateret sagsoverskrift" +} + +### +# 8. Set deadline on sag +PATCH {{baseUrl}}/api/v1/sag/53 +Cookie: {{authCookie}} +Content-Type: application/json + +{ + "deadline": "2026-04-30" +} + +### +# 9. List time entries for a sag +GET {{baseUrl}}/api/v1/timetracking/time?sag_id=53 +Cookie: {{authCookie}} + +### +# 10. Start live timer +POST {{baseUrl}}/api/v1/timetracking/time/start +Cookie: {{authCookie}} +Content-Type: application/json + +{ + "sag_id": 53, + "description": "Test timer fra REST client" +} + +### +# 11. Stop live timer +POST {{baseUrl}}/api/v1/timetracking/time/stop +Cookie: {{authCookie}} +Content-Type: application/json + +{ + "sag_id": 53 +} + +### +# 12. Create manual time entry +POST {{baseUrl}}/api/v1/timetracking/time/manual +Cookie: {{authCookie}} +Content-Type: application/json + +{ + "sag_id": 53, + "faktisk_tid_min": 30, + "description": "Manuel registrering fra REST client", + "worked_date": "2026-03-27" +} + +### +# 13. List call history +GET {{baseUrl}}/api/v1/telefoni/calls?limit=20 +Cookie: {{authCookie}} + +### +# 14. Link call to sag +PATCH {{baseUrl}}/api/v1/telefoni/calls/1 +Cookie: {{authCookie}} +Content-Type: application/json + +{ + "sag_id": 53 +} + +### +# 15. Search contacts (partial name) +GET {{baseUrl}}/api/v1/search/contacts?q=Martin +Cookie: {{authCookie}} + +### +# 16. Hardware by contact +GET {{baseUrl}}/api/v1/hardware/by-contact/1 +Cookie: {{authCookie}} + +### +# 17. Timetracking wizard - stats +GET {{baseUrl}}/api/v1/timetracking/wizard/stats +Cookie: {{authCookie}} diff --git a/tmp_check_eset_machine.py b/tmp_check_eset_machine.py new file mode 100644 index 0000000..9ac2a1c --- /dev/null +++ b/tmp_check_eset_machine.py @@ -0,0 +1,112 @@ +import asyncio +import json +from app.services.eset_service import eset_service + +TARGET = "ati-w11-yoga.norva24.lcl".lower() + + +def parse_devices(payload): + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return [] + return payload.get("devices") or payload.get("items") or payload.get("results") or payload.get("data") or [] + + +def get_next(payload): + if not isinstance(payload, dict): + return None + return payload.get("nextPageToken") or payload.get("next_page_token") or payload.get("nextPage") + + +def pick(dev, *keys): + for key in keys: + val = dev.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + return "" + + +def extract_first_str(payload, keys): + if payload is None: + return None + + key_set = {k.lower() for k in keys} + stack = [payload] + while stack: + cur = stack.pop() + if isinstance(cur, dict): + for k, v in cur.items(): + if k.lower() in key_set and isinstance(v, str) and v.strip(): + return v.strip() + if isinstance(v, (dict, list)): + stack.append(v) + elif isinstance(cur, list): + for item in cur: + if isinstance(item, (dict, list)): + stack.append(item) + return None + + +async def main(): + page_token = None + page_size = 200 + max_pages = 50 + found = None + + for _ in range(max_pages): + payload = await eset_service.list_devices(page_size=page_size, page_token=page_token) + if not payload: + print("ERROR: No payload from ESET list_devices") + return + + devices = parse_devices(payload) + for device in devices: + name = pick(device, "displayName", "deviceName", "name").lower() + fqdn = pick(device, "fqdn", "dnsName", "hostName", "hostname").lower() + if TARGET in {name, fqdn} or TARGET in name or TARGET in fqdn: + found = device + break + + if found: + break + + page_token = get_next(payload) + if not page_token or not devices: + break + + if not found: + print("NOT_FOUND") + return + + uuid = pick(found, "deviceUuid", "uuid", "id") + details = await eset_service.get_device_details(uuid) + if not details: + print("FOUND_BUT_NO_DETAILS") + print("uuid=", uuid) + return + + software = eset_service.extract_installed_software(details) + summary = { + "device_uuid": uuid, + "device_name": extract_first_str(details, ["displayName", "deviceName", "name"]), + "user_identifier": extract_first_str(details, [ + "userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser", "owner", "ownerUuid" + ]), + "serial": extract_first_str(details, ["serialNumber", "serial", "serial_number"]), + "group": extract_first_str(details, ["parentGroup", "groupPath", "group", "path"]), + "installed_software_count": len(software), + "installed_software_first_20": software[:20], + } + + out_path = "/tmp/eset_ati_w11_yoga.json" + with open(out_path, "w", encoding="utf-8") as f: + json.dump({"summary": summary, "raw": details}, f, ensure_ascii=False, indent=2) + + print("FOUND") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + print("SAVED=", out_path) + + +if __name__ == "__main__": + asyncio.run(main())