${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 += `
+
+
+
+
+ `;
+
+ 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 += `
+
+
+
+ `;
+
+ 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 += `
+
+ `;
+ }
+ });
+
+ 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 += `
+
+
+
+
+ `;
+
+ 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 += `
+
+
+
+ `;
+
+ 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 += `
+
+ `;
+ }
+ });
+
+ 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('
"""
+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_start) + 7
+new_mhtml = """
"""
+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('
"""
+
+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_start) + 7
+
+new_html = """
"""
+
+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)}
+ ` : ''}
+
+
+
+
+ `;
+ });
+ });
+
+ 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 = `
+
+ ${hardware.map(h => `
+
+
+
${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 = `
+
+ ${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.description ? `
${step.description}
` : ''}
+
+ Forfald: ${dueLabel}
+
+
+ `;
+ };
+
+ const sections = [];
+ if (openSteps.length) {
+ sections.push(`
+
+ ${openSteps.map(renderStep).join('')}
+ `);
+ }
+ if (doneSteps.length) {
+ sections.push(`
+
+ ${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`,
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`,
+ `
`
+ );
+ };
+
+ 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(author)}
+
+ `;
+
+ 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 = `
+
+ `;
+
+ 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 = `
+
+ ${display.bodyText ? `` : ''}
+ `;
+
+ 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 += `
+
+
+
+
+ `;
+
+ 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 += `
+
+
+
+ `;
+
+ 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 += `
+
+ `;
+ }
+ });
+
+ 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}
+
+
+ `;
+
+ 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 = `

`;
+ } 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 = `
+
+ `;
+
+ 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())