diff --git a/.env.example b/.env.example index a808ac3..8d73e6c 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,11 @@ API_HOST=0.0.0.0 API_PORT=8001 # Changed from 8000 to avoid conflicts with other services ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker) +# FirmaAPI (CVR company lookup) +FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1 +FIRMAAPI_API_KEY= +FIRMAAPI_TIMEOUT_SECONDS=12 + # ===================================================== # SECURITY # ===================================================== @@ -69,6 +74,20 @@ NEXTCLOUD_CACHE_TTL_SECONDS=300 # Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" NEXTCLOUD_ENCRYPTION_KEY= +# ===================================================== +# Links / Endpoints Module (Optional) +# ===================================================== +LINKS_MODULE_ENABLED=false +LINKS_READ_ONLY=true +LINKS_DRY_RUN=true +LINKS_DEAD_LINK_CHECK_ENABLED=true +LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60 +LINKS_CHECK_TIMEOUT_SECONDS=5 + +# Vaultwarden (Bitwarden-compatible) +VAULTWARDEN_BASE_URL= +VAULTWARDEN_API_TOKEN= + # ===================================================== # vTiger Cloud Integration (Required for Subscriptions) # ===================================================== diff --git a/.env.prod.example b/.env.prod.example index 5f8a61f..ebbad72 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -44,6 +44,11 @@ API_HOST=0.0.0.0 API_PORT=8000 API_RELOAD=false +# FirmaAPI (CVR company lookup) +FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1 +FIRMAAPI_API_KEY= +FIRMAAPI_TIMEOUT_SECONDS=12 + # ===================================================== # SECURITY - Production # ===================================================== @@ -76,3 +81,18 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here # VIGTIGT: Brug kun 'true' eller 'false' uden kommentarer pΓ₯ samme linje ECONOMIC_READ_ONLY=true ECONOMIC_DRY_RUN=true + +# ===================================================== +# Links / Endpoints Module - Production (Optional) +# ===================================================== +# Start disabled; enable after migration + validation +LINKS_MODULE_ENABLED=false +LINKS_READ_ONLY=true +LINKS_DRY_RUN=true +LINKS_DEAD_LINK_CHECK_ENABLED=true +LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60 +LINKS_CHECK_TIMEOUT_SECONDS=5 + +# Vaultwarden (Bitwarden-compatible) +VAULTWARDEN_BASE_URL= +VAULTWARDEN_API_TOKEN= diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7f060f8..73899a4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -107,6 +107,23 @@ if settings.ECONOMIC_READ_ONLY: logger.warning("Read-only mode") ``` +### Migration Validation +```bash +# Validate root migrations against current PostgreSQL schema +python scripts/validate_migrations.py + +# Include module-specific migration directory in validation +python scripts/validate_migrations.py --module app/modules/sag/migrations + +# Machine-readable report and strict index validation +python scripts/validate_migrations.py --json --strict-indexes +``` + +Exit codes: +- `0`: Schema is aligned, or only index differences were found without strict mode. +- `1`: Schema mismatches were found (missing tables/columns, or missing indexes with strict mode). +- `2`: Runtime error (for example connection/configuration issues). + ## π³ Docker Commands ```bash diff --git a/Dockerfile b/Dockerfile index 42d78ca..907e764 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y \ curl \ git \ libpq-dev \ + libzbar0 \ gcc \ g++ \ python3-dev \ diff --git a/add_css.py b/add_css.py new file mode 100644 index 0000000..650d2d8 --- /dev/null +++ b/add_css.py @@ -0,0 +1,142 @@ +with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f: + text = f.read() + +css_start = text.find(' +{% endblock %} + +{% block content %} +
| Tidspunkt | +Varighed | +Remote ID | +Teknikker | +Hardware | +Kunde | +Kontakt | +Sag | +Status | ++ |
|---|---|---|---|---|---|---|---|---|---|
| Indlæser⦠| |||||||||
Administrer kontaktpersoner
| Navn | @@ -123,7 +419,7 @@ -|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Kunne ikke indlæse kontakter | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
${initials}
-
${escapeHtml(contact.first_name + ' ' + contact.last_name)}
- ${contact.department || '-'}
+ ${safeName}
+ ${safeDepartment}
|
- ${contact.email || '-'}
- ${smsLine}
+ ${safeEmail}
+ ${smsLine}
|
- ${contact.title || '-'} | +${safeTitle} |
-
- ${companyCount}
+
+ ${companyCount}
- ${companyDisplay !== '-' ? ' ' + companyDisplay + ' ' : ''}
+ ${companyDisplay !== '-' ? '' + escapeHtml(companyDisplay) + ' ' : ''}
|
${statusBadge} |
-
@@ -580,20 +960,88 @@ async function loadCompaniesForSelect() {
try {
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
-
- const select = document.getElementById('companySelect');
- select.innerHTML = data.customers.map(c =>
- ``
- ).join('');
+
+ availableCompanies = Array.isArray(data.customers)
+ ? data.customers.map((c) => ({ id: Number(c.id), name: String(c.name || '').trim() }))
+ : [];
+ renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
+ renderSelectedCompanies();
} catch (error) {
console.error('Failed to load companies:', error);
}
}
+function renderCompanyResults(query) {
+ const host = document.getElementById('companyResults');
+ if (!host) return;
+
+ const needle = String(query || '').trim().toLowerCase();
+ let list = availableCompanies;
+ if (needle) {
+ list = availableCompanies.filter((c) => c.name.toLowerCase().includes(needle));
+ }
+
+ list = list.slice(0, 80);
+
+ if (!list.length) {
+ host.innerHTML = 'Ingen firmaer fundet ';
+ return;
+ }
+
+ host.innerHTML = list.map((c) => {
+ const selected = selectedCompanyIds.has(c.id);
+ return `
+
+
+ Links
+
+
+
+
+
+
+
+
+
+ Kundens sager+ Alle sager knyttet til denne kunde +
+
+
+ Ingen sager fundet for denne kunde
+
+
@@ -748,6 +803,42 @@
+
+
+
+
+
+
+ Γ
bn fuld visning
+
+ Links / Endpoints+ Driftslinks knyttet til denne kunde +
+
+
+ Ingen links fundet for denne kunde
+
+
{% include "modules/nextcloud/templates/tab.html" %}
@@ -1210,6 +1301,11 @@ let customerKontaktFilter = 'all';
let eventListenersAdded = false;
+function getAuthHeaders() {
+ const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
+ return token ? { Authorization: `Bearer ${token}` } : {};
+}
+
document.addEventListener('DOMContentLoaded', () => {
if (eventListenersAdded) {
console.log('Event listeners already added, skipping...');
@@ -1226,6 +1322,13 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false });
}
+ const casesTab = document.querySelector('a[href="#cases"]');
+ if (casesTab) {
+ casesTab.addEventListener('shown.bs.tab', () => {
+ loadCustomerCases();
+ }, { once: false });
+ }
+
const kontaktTab = document.querySelector('a[href="#kontakt"]');
if (kontaktTab) {
kontaktTab.addEventListener('shown.bs.tab', () => {
@@ -1265,6 +1368,13 @@ document.addEventListener('DOMContentLoaded', () => {
loadCustomerHardware();
}, { once: false });
}
+
+ const linksTab = document.querySelector('a[href="#links"]');
+ if (linksTab) {
+ linksTab.addEventListener('shown.bs.tab', () => {
+ loadCustomerLinks();
+ }, { once: false });
+ }
// Load activity when tab is shown
const activityTab = document.querySelector('a[href="#activity"]');
@@ -2315,6 +2425,107 @@ async function loadContacts() {
}
}
+async function loadCustomerCases() {
+ const container = document.getElementById('customerCasesContainer');
+ const empty = document.getElementById('customerCasesEmpty');
+
+ if (!container || !empty) {
+ return;
+ }
+
+ container.classList.remove('d-none');
+ empty.classList.add('d-none');
+
+ container.innerHTML = `
+
#${id} |
+ ${title} |
+ ${statusLabel} |
+ ${priority} |
+ ${created} |
+
+
+
+
+ |
+
${escapeHtml(error.message || 'Fejl ved hentning af sager')} `;
+ }
+}
+
let subscriptionsLoaded = false;
async function loadSubscriptions() {
@@ -2376,6 +2587,7 @@ async function loadCustomerPipeline() {
let customerHardware = [];
let hardwareLocationsById = {};
+let customerLinks = [];
function getHardwareGroupLabel(item, groupBy) {
if (groupBy === 'location') {
@@ -2548,6 +2760,109 @@ document.addEventListener('change', (event) => {
}
});
+function renderCustomerLinksTable() {
+ const container = document.getElementById('customerLinksContainer');
+ const empty = document.getElementById('customerLinksEmpty');
+ if (!container || !empty) return;
+
+ if (!customerLinks.length) {
+ container.classList.add('d-none');
+ empty.classList.remove('d-none');
+ return;
+ }
+
+ container.classList.remove('d-none');
+ empty.classList.add('d-none');
+
+ container.innerHTML = `
+
+
KunderAdministrer dine kunder
-
-
+
+
+
+
-
@@ -73,55 +150,391 @@
+
+
+{% endblock %}
diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py
index 5186405..1ac3997 100644
--- a/app/modules/sag/backend/router.py
+++ b/app/modules/sag/backend/router.py
@@ -4,20 +4,23 @@ import shutil
import json
import re
import hashlib
+import base64
+import html
from pathlib import Path
-from datetime import datetime
-from typing import List, Optional
+from datetime import datetime, timedelta
+from typing import List, Optional, Dict
from uuid import uuid4
-from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
-from fastapi.responses import FileResponse
+from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response
+from fastapi.responses import FileResponse, HTMLResponse
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
from app.services.case_analysis_service import CaseAnalysisService
from app.services.ollama_service import ollama_service
+from app.services.brother_label_print_service import BrotherLabelPrintService, LabelJob
try:
import extract_msg
@@ -191,6 +194,18 @@ class SagSendEmailRequest(BaseModel):
thread_key: Optional[str] = None
+class SignatureCanvasRequest(BaseModel):
+ data_url: str = Field(..., min_length=32)
+
+
+class DirectPrintOverrideRequest(BaseModel):
+ printer_host: Optional[str] = None
+ printer_port: Optional[int] = None
+ printer_model: Optional[str] = None
+ label_size: Optional[str] = None
+ hardware_id: Optional[int] = None
+
+
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
cleaned: List[str] = []
for value in values or []:
@@ -234,6 +249,130 @@ def _derive_thread_key_for_outbound(
return _normalize_message_id_token(generated_message_id)
+def _generate_local_thread_key_for_new_outbound(sag_id: int) -> str:
+ """Generate a stable local thread key for brand-new case emails.
+
+ This prevents fallback BMCid tags like sXXt001 that cannot be mapped back
+ to a concrete thread later.
+ """
+ nonce = uuid4().hex[:10]
+ return f"sag{sag_id}-{int(datetime.now().timestamp())}-{nonce}@bmchub.local"
+
+
+def _build_scan_token(sag_id: int, token_type: str) -> str:
+ if token_type == "work_order":
+ return f"BMCSCAN-WO-S{sag_id}-{uuid4().hex[:10].upper()}"
+
+ # Keep hardware label tokens shorter so Code39 labels stay physically compact.
+ return f"BMCSCAN-HW-{sag_id}-{uuid4().hex[:6].upper()}"
+
+
+def _create_document_token(
+ sag_id: int,
+ token_type: str,
+ user_id: Optional[int] = None,
+ hardware_id: Optional[int] = None,
+) -> str:
+ token = _build_scan_token(sag_id, token_type)
+ expires_at = datetime.now() + timedelta(days=30)
+
+ execute_query(
+ """
+ INSERT INTO sag_document_tokens (
+ sag_id,
+ token,
+ token_type,
+ hardware_id,
+ created_by_user_id,
+ expires_at
+ )
+ VALUES (%s, %s, %s, %s, %s, %s)
+ """,
+ (sag_id, token, token_type, hardware_id, user_id, expires_at),
+ )
+ return token
+
+
+def _get_setting_value(key: str, fallback: Optional[str] = None) -> Optional[str]:
+ row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,))
+ if not row:
+ return fallback
+ value = row.get("value")
+ if value is None:
+ return fallback
+ return str(value)
+
+
+_CODE39_PATTERNS: Dict[str, str] = {
+ "0": "nnnwwnwnn", "1": "wnnwnnnnw", "2": "nnwwnnnnw", "3": "wnwwnnnnn",
+ "4": "nnnwwnnnw", "5": "wnnwwnnnn", "6": "nnwwwnnnn", "7": "nnnwnnwnw",
+ "8": "wnnwnnwnn", "9": "nnwwnnwnn", "A": "wnnnnwnnw", "B": "nnwnnwnnw",
+ "C": "wnwnnwnnn", "D": "nnnnwwnnw", "E": "wnnnwwnnn", "F": "nnwnwwnnn",
+ "G": "nnnnnwwnw", "H": "wnnnnwwnn", "I": "nnwnnwwnn", "J": "nnnnwwwnn",
+ "K": "wnnnnnnww", "L": "nnwnnnnww", "M": "wnwnnnnwn", "N": "nnnnwnnww",
+ "O": "wnnnwnnwn", "P": "nnwnwnnwn", "Q": "nnnnnnwww", "R": "wnnnnnwwn",
+ "S": "nnwnnnwwn", "T": "nnnnwnwwn", "U": "wwnnnnnnw", "V": "nwwnnnnnw",
+ "W": "wwwnnnnnn", "X": "nwnnwnnnw", "Y": "wwnnwnnnn", "Z": "nwwnwnnnn",
+ "-": "nwnnnnwnw", ".": "wwnnnnwnn", " ": "nwwnnnwnn", "$": "nwnwnwnnn",
+ "/": "nwnwnnnwn", "+": "nwnnnwnwn", "%": "nnnwnwnwn", "*": "nwnnwnwnn",
+}
+
+
+def _render_code39_svg(
+ value: str,
+ height: int = 48,
+ narrow: int = 2,
+ wide: int = 5,
+ gap: int = 2,
+ font_size: int = 11,
+ include_text: bool = True,
+) -> str:
+ safe_value = "".join(ch for ch in (value or "").upper() if ch in _CODE39_PATTERNS and ch != "*")
+ if not safe_value:
+ safe_value = "EMPTY"
+
+ sequence = f"*{safe_value}*"
+ total_width = 12
+ for ch in sequence:
+ pattern = _CODE39_PATTERNS[ch]
+ for idx, code in enumerate(pattern):
+ stroke = wide if code == "w" else narrow
+ total_width += stroke
+ if idx < len(pattern) - 1:
+ total_width += gap
+ total_width += gap
+
+ x = 6
+ bars = []
+ for ch in sequence:
+ pattern = _CODE39_PATTERNS[ch]
+ for idx, code in enumerate(pattern):
+ stroke = wide if code == "w" else narrow
+ if idx % 2 == 0:
+ bars.append(f'
+
+
+
+
+
+
+ Opret ny kunde+") - return f"{body_html} -- {signature_html}" + signature_block = ( + " "
+ f"{signature_html}"
+ " "
+ )
+ return f"{body_html}{signature_block}"
@router.post("/sag/analyze-quick-create", response_model=QuickCreateAnalysis)
@@ -2152,6 +2298,816 @@ async def add_kommentar(sag_id: int, data: dict, request: Request):
raise HTTPException(status_code=500, detail="Failed to add comment")
+# ============================================================================
+# WORK ORDERS / LABELS
+# ============================================================================
+
+def _resolve_case_row_for_documents(sag_id: int):
+ row = execute_query_single(
+ """
+ SELECT s.id, s.titel, s.status, s.created_at, s.beskrivelse, s.customer_id, c.name AS customer_name
+ FROM sag_sager s
+ LEFT JOIN customers c ON c.id = s.customer_id
+ WHERE s.id = %s AND s.deleted_at IS NULL
+ """,
+ (sag_id,),
+ )
+ if not row:
+ raise HTTPException(status_code=404, detail="Case not found")
+ return row
+
+
+def _store_generated_case_file(
+ sag_id: int,
+ filename: str,
+ content_bytes: bytes,
+ content_type: str,
+ source_type: str,
+ source_token: Optional[str] = None,
+) -> Dict:
+ stored_name = _generate_stored_name(filename, SAG_FILE_SUBDIR)
+ destination = _resolve_attachment_path(stored_name)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_bytes(content_bytes)
+
+ query = """
+ INSERT INTO sag_files (
+ sag_id,
+ filename,
+ content_type,
+ size_bytes,
+ stored_name,
+ source_type,
+ source_token
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
+ RETURNING id, filename, created_at
+ """
+ rows = execute_query(
+ query,
+ (sag_id, filename, content_type, len(content_bytes), stored_name, source_type, source_token),
+ )
+ return rows[0]
+
+
+@router.get("/sag/{sag_id}/work-orders/print", response_class=HTMLResponse)
+async def print_case_work_order(sag_id: int, request: Request):
+ """Render a printable work order with scan token + barcode."""
+ case = _resolve_case_row_for_documents(sag_id)
+
+ user_id = None
+ try:
+ user_id = _get_user_id_from_request(request)
+ except HTTPException:
+ user_id = None
+
+ token = _create_document_token(sag_id, "work_order", user_id=user_id)
+ barcode_svg = _render_code39_svg(token)
+
+ todo_steps = execute_query(
+ """
+ SELECT title, description, due_date, is_done
+ FROM sag_todo_steps
+ WHERE sag_id = %s
+ AND deleted_at IS NULL
+ ORDER BY is_done ASC, due_date ASC NULLS LAST, id ASC
+ """,
+ (sag_id,),
+ ) or []
+
+ todo_items_html = "".join(
+ (
+ "β‘ | "
+ f"{html.escape(step.get('title') or '')}"
+ f" | "
+ "{html.escape(step.get('description') or '')} β‘ | "
+ "Ingen todo-opgaver registreret | ".join(html.escape(part) for part in text.splitlines()) + + def _clip(value: Optional[str], limit: int = 600) -> str: + text = str(value or "").strip() + if not text: + return "" + if len(text) <= limit: + return text + return f"{text[:limit].rstrip()}..." + + def _strip_quoted_email_text(value: Optional[str]) -> str: + text = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip() + if not text: + return "" + + lines = text.split("\n") + kept = [] + header_re = re.compile(r"^(fra|from|til|to|sendt|sent|dato|date|emne|subject)\s*:\s*", re.IGNORECASE) + original_msg_re = re.compile(r"^(-----\s*original message\s*-----|begin forwarded message)", re.IGNORECASE) + wrote_re = re.compile(r"\b(wrote|skrev)\s*:\s*$", re.IGNORECASE) + + for idx, line in enumerate(lines): + trimmed = line.strip() + + if trimmed.startswith(">"): + break + if original_msg_re.match(trimmed): + break + if wrote_re.search(trimmed): + break + + if re.match(r"^[-_]{3,}$", trimmed): + lookahead = lines[idx + 1: idx + 5] + if any(header_re.match(str(candidate or "").strip()) for candidate in lookahead): + break + + if idx > 0 and header_re.match(trimmed) and not str(lines[idx - 1] or "").strip(): + break + + kept.append(line) + + while kept and not str(kept[-1]).strip(): + kept.pop() + + return "\n".join(kept).strip() + + email_from_expr = "NULL" + if table_has_column("email_messages", "sender_email"): + email_from_expr = "e.sender_email" + elif table_has_column("email_messages", "from_email"): + email_from_expr = "e.from_email" + + email_to_expr = "NULL" + if table_has_column("email_messages", "recipient_email"): + email_to_expr = "e.recipient_email" + elif table_has_column("email_messages", "to_email"): + email_to_expr = "e.to_email" + + linked_emails = [] + email_comment_rows = [] + if _table_exists("sag_emails") and _table_exists("email_messages"): + try: + linked_emails = execute_query( + f""" + SELECT + e.received_date, + e.subject, + {email_from_expr} AS from_email, + {email_to_expr} AS to_email, + e.body_text + FROM sag_emails se + JOIN email_messages e ON e.id = se.email_id + WHERE se.sag_id = %s + ORDER BY e.received_date DESC NULLS LAST, e.id DESC + LIMIT 30 + """, + (sag_id,), + ) or [] + except Exception as exc: + logger.warning("β οΈ Work-order linked email query failed for SAG-%s: %s", sag_id, exc) + linked_emails = [] + + if _table_exists("sag_kommentarer"): + try: + email_comment_rows = execute_query( + """ + SELECT created_at, forfatter, indhold + FROM sag_kommentarer + WHERE sag_id = %s + AND deleted_at IS NULL + AND ( + COALESCE(indhold, '') ILIKE '%%Email-ID:%%' + OR COALESCE(indhold, '') ILIKE '%%π§%%' + OR COALESCE(indhold, '') ILIKE '%%IndgΓ₯ende email%%' + OR COALESCE(indhold, '') ILIKE '%%UdgΓ₯ende email%%' + ) + ORDER BY created_at DESC + LIMIT 30 + """, + (sag_id,), + ) or [] + except Exception as exc: + logger.warning("β οΈ Work-order email comment query failed for SAG-%s: %s", sag_id, exc) + email_comment_rows = [] + + has_name = table_has_column("hardware_assets", "name") + has_brand = table_has_column("hardware_assets", "brand") + has_model = table_has_column("hardware_assets", "model") + has_serial = table_has_column("hardware_assets", "serial_number") + has_asset_tag = table_has_column("hardware_assets", "asset_tag") + has_customer_asset_id = table_has_column("hardware_assets", "customer_asset_id") + has_internal_asset_id = table_has_column("hardware_assets", "internal_asset_id") + has_type = table_has_column("hardware_assets", "type") + has_asset_type = table_has_column("hardware_assets", "asset_type") + + name_expr_parts = [] + if has_name: + name_expr_parts.append("NULLIF(TRIM(h.name), '')") + if has_brand and has_model: + name_expr_parts.append("NULLIF(TRIM(CONCAT_WS(' ', h.brand, h.model)), '')") + if has_brand: + name_expr_parts.append("NULLIF(TRIM(h.brand), '')") + if has_model: + name_expr_parts.append("NULLIF(TRIM(h.model), '')") + if has_serial: + name_expr_parts.append("NULLIF(TRIM(h.serial_number), '')") + name_expr_parts.append("CONCAT('Hardware #', h.id::text)") + name_expr = "COALESCE(" + ", ".join(name_expr_parts) + ")" + + serial_expr = "h.serial_number" if has_serial else "NULL" + + tag_expr_parts = [] + if has_asset_tag: + tag_expr_parts.append("NULLIF(TRIM(h.asset_tag), '')") + if has_customer_asset_id: + tag_expr_parts.append("NULLIF(TRIM(h.customer_asset_id), '')") + if has_internal_asset_id: + tag_expr_parts.append("NULLIF(TRIM(h.internal_asset_id), '')") + tag_expr_parts.append("'-'") + tag_expr = "COALESCE(" + ", ".join(tag_expr_parts) + ")" + + type_expr_parts = [] + if has_type: + type_expr_parts.append("NULLIF(TRIM(h.type), '')") + if has_asset_type: + type_expr_parts.append("NULLIF(TRIM(h.asset_type), '')") + type_expr_parts.append("'ukendt'") + type_expr = "COALESCE(" + ", ".join(type_expr_parts) + ")" + + hardware_rows = [] + if _table_exists("sag_hardware") and _table_exists("hardware_assets"): + try: + hardware_rows = execute_query( + f""" + SELECT + h.id, + {name_expr} AS label_name, + {serial_expr} AS serial_number, + {tag_expr} AS label_tag, + {type_expr} AS label_type + FROM sag_hardware sh + JOIN hardware_assets h ON h.id = sh.hardware_id + WHERE sh.sag_id = %s + AND sh.deleted_at IS NULL + ORDER BY label_name ASC + """, + (sag_id,), + ) or [] + except Exception as exc: + logger.warning("β οΈ Work-order hardware query failed for SAG-%s: %s", sag_id, exc) + hardware_rows = [] + + hardware_html = "".join( + ( + " {html.escape(str(hw.get('label_name') or '-'))} | "
+ f"{html.escape(str(hw.get('serial_number') or '-'))} | "
+ f"{html.escape(str(hw.get('label_tag') or '-'))} | "
+ f"{html.escape(str(hw.get('label_type') or '-'))} | "
+ "Ingen hardware er knyttet til sagen. | "
+ f" "
+ )
+ for row in linked_emails
+ )
+ if not linked_emails and email_comment_rows:
+ emails_html = "".join(
+ (
+ "{html.escape((row.get('subject') or '(Ingen emne)'))} "
+ f"Fra: {html.escape(row.get('from_email') or '-')} Β· Til: {html.escape(row.get('to_email') or '-')} "
+ f"Dato: {html.escape(str(row.get('received_date') or '-'))} "
+ f"{_nl2br(_clip(_strip_quoted_email_text(row.get('body_text')), 800))} "
+ ""
+ f" "
+ )
+ for row in email_comment_rows
+ )
+ elif not emails_html:
+ emails_html = "{html.escape(row.get('forfatter') or 'Email')} Β· {html.escape(str(row.get('created_at') or '-'))} "
+ f"{_nl2br(_clip(_strip_quoted_email_text(row.get('indhold')), 1200))} "
+ "Ingen linkede emails. "
+
+ internal_messages_html = "".join(
+ (
+ ""
+ f" "
+ )
+ for row in internal_messages
+ )
+ if not internal_messages_html:
+ internal_messages_html = "{html.escape(row.get('forfatter') or 'Ukendt')} Β· {html.escape(str(row.get('created_at') or '-'))} "
+ f"{_nl2br(_clip(row.get('indhold'), 900))} "
+ "Ingen interne beskeder. "
+
+ html_doc = f"""
+
+
+
+
+
+
+
+
+
+ BMC Work Order
+ SAG-{case['id']} Β· {html.escape(case.get('status') or '-')}
+ Kunde: {html.escape(case.get('customer_name') or '-')} {barcode_svg}
+
+
+
+ Sags titel
+ {html.escape(case.get('titel') or '-')}
+
+
+
+ Sagsbeskrivelse
+ {_nl2br(case.get('beskrivelse'))}
+
+
+
+ Opgaver (afkryds ved udfΓΈrelse)
+
+
+
+ Hardware
+
+
+
+ Linkede emails
+ {emails_html}
+
+
+
+ Interne beskeder
+ {internal_messages_html}
+
+
+
+ Underskrift
+
+ Dato: _____________ Navn: __________________________
+ Scan-token: {html.escape(token)}
+
+
+"""
+ return HTMLResponse(content=html_doc)
+
+
+@router.post("/sag/{sag_id}/work-orders/{token}/signature-canvas")
+async def upload_work_order_signature_canvas(sag_id: int, token: str, payload: SignatureCanvasRequest):
+ """Save canvas signature as case file and consume token."""
+ _resolve_case_row_for_documents(sag_id)
+
+ token_row = execute_query_single(
+ """
+ SELECT token, sag_id
+ FROM sag_document_tokens
+ WHERE token = %s AND token_type = 'work_order' AND sag_id = %s
+ """,
+ (token, sag_id),
+ )
+ if not token_row:
+ raise HTTPException(status_code=404, detail="Work-order token not found")
+
+ if "," not in payload.data_url:
+ raise HTTPException(status_code=400, detail="Invalid signature payload")
+
+ _, encoded = payload.data_url.split(",", 1)
+ try:
+ signature_bytes = base64.b64decode(encoded)
+ except Exception as exc:
+ raise HTTPException(status_code=400, detail=f"Invalid base64 signature: {exc}")
+
+ saved = _store_generated_case_file(
+ sag_id=sag_id,
+ filename=f"SAG-{sag_id}-signature-{datetime.now().strftime('%Y%m%d-%H%M%S')}.png",
+ content_bytes=signature_bytes,
+ content_type="image/png",
+ source_type="signature_canvas",
+ source_token=token,
+ )
+
+ execute_query(
+ """
+ UPDATE sag_document_tokens
+ SET consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP)
+ WHERE token = %s
+ """,
+ (token,),
+ )
+
+ return {"status": "saved", "file": saved}
+
+
+@router.post("/sag/{sag_id}/work-orders/{token}/signature-file")
+async def upload_work_order_signature_file(
+ sag_id: int,
+ token: str,
+ file: UploadFile = File(...),
+ source: str = Form("signature_upload"),
+):
+ """Save uploaded signature file and consume token."""
+ _resolve_case_row_for_documents(sag_id)
+
+ token_row = execute_query_single(
+ """
+ SELECT token, sag_id
+ FROM sag_document_tokens
+ WHERE token = %s AND token_type = 'work_order' AND sag_id = %s
+ """,
+ (token, sag_id),
+ )
+ if not token_row:
+ raise HTTPException(status_code=404, detail="Work-order token not found")
+
+ content = await file.read()
+ if not content:
+ raise HTTPException(status_code=400, detail="Empty file")
+
+ safe_name = Path(file.filename or "signature-upload.bin").name
+ saved = _store_generated_case_file(
+ sag_id=sag_id,
+ filename=f"SAG-{sag_id}-{safe_name}",
+ content_bytes=content,
+ content_type=file.content_type or "application/octet-stream",
+ source_type=source or "signature_upload",
+ source_token=token,
+ )
+
+ execute_query(
+ """
+ UPDATE sag_document_tokens
+ SET consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP)
+ WHERE token = %s
+ """,
+ (token,),
+ )
+
+ return {"status": "saved", "file": saved}
+
+
+@router.get("/sag/{sag_id}/labels/hardware/print", response_class=HTMLResponse)
+async def print_case_hardware_labels(
+ sag_id: int,
+ request: Request,
+ auto_print: bool = Query(False),
+):
+ """Render printable hardware labels with IDs and barcodes."""
+ _resolve_case_row_for_documents(sag_id)
+
+ has_name = table_has_column("hardware_assets", "name")
+ has_brand = table_has_column("hardware_assets", "brand")
+ has_model = table_has_column("hardware_assets", "model")
+ has_serial = table_has_column("hardware_assets", "serial_number")
+ has_asset_tag = table_has_column("hardware_assets", "asset_tag")
+ has_customer_asset_id = table_has_column("hardware_assets", "customer_asset_id")
+ has_internal_asset_id = table_has_column("hardware_assets", "internal_asset_id")
+ has_type = table_has_column("hardware_assets", "type")
+ has_asset_type = table_has_column("hardware_assets", "asset_type")
+
+ name_expr_parts = []
+ if has_name:
+ name_expr_parts.append("NULLIF(TRIM(h.name), '')")
+ if has_brand and has_model:
+ name_expr_parts.append("NULLIF(TRIM(CONCAT_WS(' ', h.brand, h.model)), '')")
+ if has_brand:
+ name_expr_parts.append("NULLIF(TRIM(h.brand), '')")
+ if has_model:
+ name_expr_parts.append("NULLIF(TRIM(h.model), '')")
+ if has_serial:
+ name_expr_parts.append("NULLIF(TRIM(h.serial_number), '')")
+ name_expr_parts.append("CONCAT('Hardware #', h.id::text)")
+ name_expr = "COALESCE(" + ", ".join(name_expr_parts) + ")"
+
+ tag_expr_parts = []
+ if has_asset_tag:
+ tag_expr_parts.append("NULLIF(TRIM(h.asset_tag), '')")
+ if has_customer_asset_id:
+ tag_expr_parts.append("NULLIF(TRIM(h.customer_asset_id), '')")
+ if has_internal_asset_id:
+ tag_expr_parts.append("NULLIF(TRIM(h.internal_asset_id), '')")
+ tag_expr_parts.append("'-'")
+ tag_expr = "COALESCE(" + ", ".join(tag_expr_parts) + ")"
+
+ type_expr_parts = []
+ if has_type:
+ type_expr_parts.append("NULLIF(TRIM(h.type), '')")
+ if has_asset_type:
+ type_expr_parts.append("NULLIF(TRIM(h.asset_type), '')")
+ type_expr_parts.append("'ukendt'")
+ type_expr = "COALESCE(" + ", ".join(type_expr_parts) + ")"
+
+ serial_expr = "h.serial_number" if has_serial else "NULL"
+
+ hardware_id_filter = None
+ if payload and payload.hardware_id is not None:
+ try:
+ hardware_id_filter = int(payload.hardware_id)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail="Ugyldigt hardware_id")
+
+ hardware_query = f"""
+ SELECT
+ h.id,
+ {name_expr} AS label_name,
+ {serial_expr} AS serial_number,
+ {tag_expr} AS label_tag,
+ {type_expr} AS label_type
+ FROM sag_hardware sh
+ JOIN hardware_assets h ON h.id = sh.hardware_id
+ WHERE sh.sag_id = %s
+ AND sh.deleted_at IS NULL
+ """
+ params = [sag_id]
+ if hardware_id_filter is not None:
+ hardware_query += " AND sh.hardware_id = %s"
+ params.append(hardware_id_filter)
+ hardware_query += " ORDER BY label_name ASC"
+
+ hardware_rows = execute_query(hardware_query, tuple(params)) or []
+
+ user_id = None
+ try:
+ user_id = _get_user_id_from_request(request)
+ except HTTPException:
+ user_id = None
+
+ label_cards = []
+ for hw in hardware_rows:
+ token = _create_document_token(
+ sag_id=sag_id,
+ token_type="hardware_label",
+ user_id=user_id,
+ hardware_id=hw.get("id"),
+ )
+ barcode_svg = _render_code39_svg(
+ token,
+ height=26,
+ narrow=1,
+ wide=2,
+ gap=0,
+ font_size=10,
+ include_text=False,
+ )
+ label_cards.append(
+ f"""
+
+
+ """
+ )
+
+ if not label_cards:
+ label_cards.append("{html.escape(hw.get('label_name') or 'Ukendt enhed')}
+ ID: HW-{hw.get('id')} Β· SAG-{sag_id}
+ SN: {html.escape(hw.get('serial_number') or '-')} Β· Tag: {html.escape(hw.get('label_tag') or '-')} Β· Type: {html.escape(hw.get('label_type') or '-')}
+ {barcode_svg}
+ {html.escape(token)}
+ Ingen hardware er knyttet til sagen endnu. ")
+
+ auto_print_script = ""
+ if auto_print:
+ auto_print_script = (
+ ""
+ )
+
+ html_doc = f"""
+
+
+
+
+
+ {''.join(label_cards)}
+
+ {auto_print_script}
+
+
+"""
+ return HTMLResponse(content=html_doc)
+
+
+@router.post("/sag/{sag_id}/labels/hardware/print-direct")
+async def print_case_hardware_labels_direct(
+ sag_id: int,
+ request: Request,
+ payload: Optional[DirectPrintOverrideRequest] = None,
+):
+ """Print hardware labels directly to configured Brother network printer."""
+ _resolve_case_row_for_documents(sag_id)
+
+ enabled = (_get_setting_value("label_printer_enabled", "false") or "false").strip().lower() == "true"
+ if not enabled:
+ raise HTTPException(status_code=400, detail="Direkte label-print er ikke aktiveret i indstillinger")
+
+ host = (payload.printer_host if payload and payload.printer_host else _get_setting_value("label_printer_host", "")).strip()
+ port_raw = payload.printer_port if payload and payload.printer_port is not None else _get_setting_value("label_printer_port", "9100")
+ model = (payload.printer_model if payload and payload.printer_model else _get_setting_value("label_printer_model", "QL-710W")).strip()
+ label_size = (payload.label_size if payload and payload.label_size else _get_setting_value("label_printer_label_size", "62")).strip()
+
+ try:
+ port = int(port_raw or 9100)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Ugyldig printer-port")
+
+ if not host:
+ raise HTTPException(status_code=400, detail="Printer host/IP mangler i indstillinger")
+
+ has_name = table_has_column("hardware_assets", "name")
+ has_brand = table_has_column("hardware_assets", "brand")
+ has_model = table_has_column("hardware_assets", "model")
+ has_serial = table_has_column("hardware_assets", "serial_number")
+ has_asset_tag = table_has_column("hardware_assets", "asset_tag")
+ has_customer_asset_id = table_has_column("hardware_assets", "customer_asset_id")
+ has_internal_asset_id = table_has_column("hardware_assets", "internal_asset_id")
+ has_type = table_has_column("hardware_assets", "type")
+ has_asset_type = table_has_column("hardware_assets", "asset_type")
+
+ name_expr_parts = []
+ if has_name:
+ name_expr_parts.append("NULLIF(TRIM(h.name), '')")
+ if has_brand and has_model:
+ name_expr_parts.append("NULLIF(TRIM(CONCAT_WS(' ', h.brand, h.model)), '')")
+ if has_brand:
+ name_expr_parts.append("NULLIF(TRIM(h.brand), '')")
+ if has_model:
+ name_expr_parts.append("NULLIF(TRIM(h.model), '')")
+ if has_serial:
+ name_expr_parts.append("NULLIF(TRIM(h.serial_number), '')")
+ name_expr_parts.append("CONCAT('Hardware #', h.id::text)")
+ name_expr = "COALESCE(" + ", ".join(name_expr_parts) + ")"
+
+ tag_expr_parts = []
+ if has_asset_tag:
+ tag_expr_parts.append("NULLIF(TRIM(h.asset_tag), '')")
+ if has_customer_asset_id:
+ tag_expr_parts.append("NULLIF(TRIM(h.customer_asset_id), '')")
+ if has_internal_asset_id:
+ tag_expr_parts.append("NULLIF(TRIM(h.internal_asset_id), '')")
+ tag_expr_parts.append("'-'")
+ tag_expr = "COALESCE(" + ", ".join(tag_expr_parts) + ")"
+
+ type_expr_parts = []
+ if has_type:
+ type_expr_parts.append("NULLIF(TRIM(h.type), '')")
+ if has_asset_type:
+ type_expr_parts.append("NULLIF(TRIM(h.asset_type), '')")
+ type_expr_parts.append("'ukendt'")
+ type_expr = "COALESCE(" + ", ".join(type_expr_parts) + ")"
+
+ serial_expr = "h.serial_number" if has_serial else "NULL"
+
+ hardware_rows = execute_query(
+ f"""
+ SELECT
+ h.id,
+ {name_expr} AS label_name,
+ {serial_expr} AS serial_number,
+ {tag_expr} AS label_tag,
+ {type_expr} AS label_type
+ FROM sag_hardware sh
+ JOIN hardware_assets h ON h.id = sh.hardware_id
+ WHERE sh.sag_id = %s
+ AND sh.deleted_at IS NULL
+ ORDER BY label_name ASC
+ """,
+ (sag_id,),
+ ) or []
+
+ if not hardware_rows:
+ if hardware_id_filter is not None:
+ raise HTTPException(status_code=404, detail="Valgt hardware er ikke knyttet til sagen")
+ raise HTTPException(status_code=400, detail="Ingen hardware er knyttet til sagen")
+
+ user_id = None
+ try:
+ user_id = _get_user_id_from_request(request)
+ except HTTPException:
+ user_id = None
+
+ jobs: List[LabelJob] = []
+ for hw in hardware_rows:
+ token = _create_document_token(
+ sag_id=sag_id,
+ token_type="hardware_label",
+ user_id=user_id,
+ hardware_id=hw.get("id"),
+ )
+ meta = (
+ f"ID: HW-{hw.get('id')} SAG-{sag_id} "
+ f"SN: {hw.get('serial_number') or '-'} Tag: {hw.get('label_tag') or '-'} Type: {hw.get('label_type') or '-'}"
+ )
+ jobs.append(
+ LabelJob(
+ name=str(hw.get("label_name") or "Ukendt enhed"),
+ meta_line=meta,
+ token=token,
+ )
+ )
+
+ service = BrotherLabelPrintService(
+ model=model,
+ host=host,
+ port=port,
+ label_size=label_size,
+ )
+
+ try:
+ printed = service.print_jobs(jobs)
+ except Exception as exc:
+ logger.error("β Direct label print failed for SAG-%s: %s", sag_id, exc)
+ raise HTTPException(status_code=500, detail=f"Direkte print fejlede: {exc}")
+
+ return {
+ "status": "ok",
+ "printed": printed,
+ "hardware_ids": [int(hw.get("id")) for hw in hardware_rows if hw.get("id") is not None],
+ "printer": {
+ "model": model,
+ "host": host,
+ "port": port,
+ "label_size": label_size,
+ },
+ }
+
+
# ============================================================================
# FILES - Case Files
# ============================================================================
@@ -2281,6 +3237,53 @@ async def download_sag_file(sag_id: int, file_id: int, download: bool = False):
headers=headers
)
+
+@router.get("/sag/{sag_id}/files/{file_id}/preview-image")
+async def preview_sag_pdf_as_image(sag_id: int, file_id: int, page: int = Query(1, ge=1), scale: float = Query(2.8, ge=1.0, le=5.0)):
+ """Render a PDF page as PNG for consistent in-app preview sizing."""
+ query = "SELECT * FROM sag_files WHERE id = %s AND sag_id = %s"
+ result = execute_query(query, (file_id, sag_id))
+
+ if not result:
+ raise HTTPException(status_code=404, detail="File not found")
+
+ file_data = result[0]
+ path = _resolve_attachment_path(file_data["stored_name"])
+
+ if not path.exists():
+ raise HTTPException(status_code=404, detail="File lost on server")
+
+ content_type = (file_data.get("content_type") or "").lower()
+ filename = str(file_data.get("filename") or "").lower()
+ if "pdf" not in content_type and not filename.endswith(".pdf"):
+ raise HTTPException(status_code=400, detail="Preview image is only supported for PDF files")
+
+ try:
+ import pypdfium2 as pdfium
+
+ document = pdfium.PdfDocument(str(path))
+ page_index = min(max(page - 1, 0), max(len(document) - 1, 0))
+ pdf_page = document.get_page(page_index)
+ bitmap = pdf_page.render(scale=scale)
+ png_bytes = bitmap.to_pil().convert("RGB")
+
+ import io
+ buffer = io.BytesIO()
+ png_bytes.save(buffer, format="PNG", optimize=True)
+ data = buffer.getvalue()
+
+ try:
+ pdf_page.close()
+ except Exception:
+ pass
+
+ return Response(content=data, media_type="image/png")
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("β PDF preview render failed for SAG-%s file %s: %s", sag_id, file_id, e)
+ raise HTTPException(status_code=500, detail="Could not render PDF preview")
+
@router.delete("/sag/{sag_id}/files/{file_id}")
async def delete_sag_file(sag_id: int, file_id: int):
"""Delete a file."""
@@ -2327,17 +3330,25 @@ async def get_sag_emails(sag_id: int):
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(
+ 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 +3526,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 +3540,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 +3571,21 @@ 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
+ if not effective_payload_thread_key:
+ # Brand-new thread: assign a local key immediately so signature/BMCid
+ # can carry a resolvable thread identity from the first outbound email.
+ effective_payload_thread_key = _generate_local_thread_key_for_new_outbound(sag_id)
+
provisional_thread_key = _derive_thread_key_for_outbound(
- payload.thread_key,
+ effective_payload_thread_key,
in_reply_to_header,
references_header,
None,
@@ -2559,6 +3594,21 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
body_text = _append_signature_to_body(body_text, signature)
body_html = _append_signature_to_html(payload.body_html, signature)
+ # Inject hidden BMCid tracker into HTML body so replies can be routed back
+ bmc_id_tag = _build_case_bmc_id_tag(sag_id, provisional_thread_key)
+ hidden_tracker = f'BMCid: {bmc_id_tag} '
+ if body_html:
+ body_html = f"{hidden_tracker}{body_html}"
+ elif body_text:
+ # Synthesize minimal HTML wrapper with tracker when only plain text exists
+ import html as _html
+ body_html = f"{hidden_tracker}{_html.escape(body_text)}"
+
+ # Ensure subject carries [SAG-XX] prefix for reliable subject-line matching
+ sag_prefix = f"[SAG-{sag_id}]"
+ if sag_prefix not in subject:
+ subject = f"{sag_prefix} {subject}"
+
email_service = EmailService()
success, send_message, generated_message_id, provider_thread_key = await email_service.send_email_with_attachments(
to_addresses=to_addresses,
@@ -2584,16 +3634,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 +3710,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 +3899,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 +3964,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/frontend/views.py b/app/modules/sag/frontend/views.py
index f35fc8b..a2f53fc 100644
--- a/app/modules/sag/frontend/views.py
+++ b/app/modules/sag/frontend/views.py
@@ -12,6 +12,59 @@ logger = logging.getLogger(__name__)
router = APIRouter()
+def _render_api_print_bridge(api_path: str, page_title: str) -> str:
+ safe_api_path = json.dumps(api_path)
+ safe_title = json.dumps(page_title)
+ return f"""
+
+
+
+
+ Henter printvisning...
+
+
+
+"""
+
+
def _is_deadline_overdue(deadline_value) -> bool:
if not deadline_value:
return False
@@ -128,7 +181,15 @@ async def sager_liste(
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
nt.title AS next_todo_title,
- nt.due_date AS next_todo_due_date
+ nt.due_date AS next_todo_due_date,
+ COALESCE(ec.unread_email_count, 0) AS unread_email_count,
+ ec.oldest_unread_received_date,
+ CASE
+ WHEN COALESCE(ec.unread_email_count, 0) = 0 THEN 'none'
+ WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '72 hours' THEN 'hot'
+ WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '24 hours' THEN 'warm'
+ ELSE 'fresh'
+ END AS unread_email_level
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
@@ -157,6 +218,14 @@ async def sager_liste(
t.created_at ASC
LIMIT 1
) nt ON true
+ LEFT JOIN LATERAL (
+ SELECT
+ COUNT(*) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS unread_email_count,
+ MIN(em.received_date) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS oldest_unread_received_date
+ FROM sag_emails se
+ JOIN email_messages em ON em.id = se.email_id
+ WHERE se.sag_id = s.id
+ ) ec ON true
LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id
WHERE s.deleted_at IS NULL
"""
@@ -196,10 +265,26 @@ async def sager_liste(
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
NULL::text AS next_todo_title,
- NULL::timestamp AS next_todo_due_date
+ NULL::timestamp AS next_todo_due_date,
+ COALESCE(ec.unread_email_count, 0) AS unread_email_count,
+ ec.oldest_unread_received_date,
+ CASE
+ WHEN COALESCE(ec.unread_email_count, 0) = 0 THEN 'none'
+ WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '72 hours' THEN 'hot'
+ WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '24 hours' THEN 'warm'
+ ELSE 'fresh'
+ END AS unread_email_level
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
+ LEFT JOIN LATERAL (
+ SELECT
+ COUNT(*) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS unread_email_count,
+ MIN(em.received_date) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS oldest_unread_received_date
+ FROM sag_emails se
+ JOIN email_messages em ON em.id = se.email_id
+ WHERE se.sag_id = s.id
+ ) ec ON true
WHERE s.deleted_at IS NULL
"""
fallback_params = []
@@ -289,6 +374,7 @@ async def sager_liste(
"toggle_include_deferred_url": toggle_include_deferred_url,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
+ "current_customer_id": customer_id_int,
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
})
@@ -307,6 +393,7 @@ async def sager_liste(
"toggle_include_deferred_url": str(request.url),
"assignment_users": [],
"assignment_groups": [],
+ "current_customer_id": customer_id_int,
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
})
@@ -320,6 +407,32 @@ async def opret_sag_side(request: Request):
"assignment_groups": _fetch_assignment_groups(),
})
+
+@router.get("/sag/{sag_id}/work-orders/print", response_class=HTMLResponse)
+async def sag_work_order_print_page(request: Request, sag_id: int):
+ auto_print = str(request.query_params.get("auto_print", "0")).lower() in {"1", "true", "yes", "on"}
+ api_path = f"/api/v1/sag/{sag_id}/work-orders/print"
+ if auto_print:
+ api_path = f"{api_path}?auto_print=1"
+ html = _render_api_print_bridge(
+ api_path=api_path,
+ page_title=f"Arbejdsseddel SAG-{sag_id}",
+ )
+ return HTMLResponse(content=html)
+
+
+@router.get("/sag/{sag_id}/labels/hardware/print", response_class=HTMLResponse)
+async def sag_hardware_labels_print_page(request: Request, sag_id: int):
+ auto_print = str(request.query_params.get("auto_print", "0")).lower() in {"1", "true", "yes", "on"}
+ api_path = f"/api/v1/sag/{sag_id}/labels/hardware/print"
+ if auto_print:
+ api_path = f"{api_path}?auto_print=1"
+ html = _render_api_print_bridge(
+ api_path=api_path,
+ page_title=f"Hardware labels SAG-{sag_id}",
+ )
+ return HTMLResponse(content=html)
+
@router.get("/sag/varekob-salg", response_class=HTMLResponse)
async def sag_varekob_salg(request: Request):
"""Display orders overview for all purchases and sales."""
diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html
index 616457b..ba5dfa9 100644
--- a/app/modules/sag/templates/create.html
+++ b/app/modules/sag/templates/create.html
@@ -124,6 +124,18 @@
[data-bs-theme="dark"] .selected-item button {
color: #a6d5fa;
}
+
+ .case-top-alerts .alert {
+ border-left: 6px solid;
+ }
+
+ .case-top-alerts .alert-warning {
+ border-left-color: #f59f00;
+ }
+
+ .case-top-alerts .alert-danger {
+ border-left-color: #e03131;
+ }
{% endblock %}
@@ -139,6 +151,8 @@
+
+
@@ -311,6 +325,79 @@
let contactSearchTimeout;
let successAlertTimeout;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
+ let topAlertLoadToken = 0;
+
+ function escapeTopAlertHtml(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ async function loadCreateTopAlertsForCustomer(customerId) {
+ const container = document.getElementById('caseTopAlerts');
+ if (!container) {
+ return;
+ }
+
+ if (!customerId) {
+ container.classList.add('d-none');
+ container.innerHTML = '';
+ return;
+ }
+
+ const loadToken = ++topAlertLoadToken;
+ container.classList.remove('d-none');
+ container.innerHTML = ' Henter kunde-alerts... ';
+
+ try {
+ const response = await fetch(`/api/v1/alert-notes/check?entity_type=customer&entity_id=${customerId}`, {
+ credentials: 'include'
+ });
+
+ if (loadToken !== topAlertLoadToken) {
+ return;
+ }
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ const alerts = (data?.alerts || []).filter((alert) => ['critical', 'warning'].includes(String(alert?.severity || '').toLowerCase()));
+
+ if (!alerts.length) {
+ container.classList.add('d-none');
+ container.innerHTML = '';
+ return;
+ }
+
+ container.innerHTML = alerts.map((alert) => {
+ const isCritical = String(alert.severity || '').toLowerCase() === 'critical';
+ const klass = isCritical ? 'alert-danger' : 'alert-warning';
+ const label = isCritical ? 'KRITISK' : 'ADVARSEL';
+ const title = escapeTopAlertHtml(alert.title || 'Vigtig kundeinformation');
+ const message = escapeTopAlertHtml(alert.message || '');
+
+ return `
+
+ ${label}: ${title}
+ ${message ? `
+ `;
+ }).join('');
+ container.classList.remove('d-none');
+ } catch (error) {
+ if (loadToken !== topAlertLoadToken) {
+ return;
+ }
+ console.error('Failed to load customer alerts on sag create:', error);
+ container.innerHTML = '${message} ` : ''}
+ Advarsel: Kunde-alerts kunne ikke hentes. ';
+ container.classList.remove('d-none');
+ }
+ }
// Helper function to show success alert
function showSuccessAlert(message, duration = 3000) {
@@ -436,6 +523,7 @@
document.getElementById('customerSearch').value = '';
document.getElementById('customerResults').classList.add('d-none');
renderSelections();
+ loadCreateTopAlertsForCustomer(id);
// Show notification
if (!skipAlert) {
@@ -447,6 +535,7 @@
selectedCustomer = null;
document.getElementById('customer_id').value = '';
renderSelections();
+ loadCreateTopAlertsForCustomer(null);
}
async function selectContact(id, name) {
@@ -460,7 +549,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 +619,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 +632,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 +687,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 +775,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 +813,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 +829,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 +864,7 @@
};
const response = await fetch('/api/v1/contacts', {
method: 'POST',
+ credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
@@ -886,6 +979,7 @@
try {
const response = await fetch('/api/v1/sag', {
method: 'POST',
+ credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
@@ -897,9 +991,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 +1001,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 +1018,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 39af3fd..18fdc9c 100644
--- a/app/modules/sag/templates/detail.html
+++ b/app/modules/sag/templates/detail.html
@@ -4,6 +4,280 @@
{% block extra_css %}
{% endblock %}
{% block content %}
+
+
@@ -382,6 +436,12 @@ + {% endif %} #{{ sag.id }} + {% if (sag.unread_email_count or 0) > 0 %} + {% set unread_level = sag.unread_email_level or 'fresh' %} + + {{ sag.unread_email_count if sag.unread_email_count <= 99 else '99+' }} + + {% endif %} |
{{ sag.customer_name if sag.customer_name else '-' }} @@ -440,6 +500,12 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #{{ related_sag.id }} + {% if (related_sag.unread_email_count or 0) > 0 %} + {% set child_unread_level = related_sag.unread_email_level or 'fresh' %} + + {{ related_sag.unread_email_count if related_sag.unread_email_count <= 99 else '99+' }} + + {% endif %} |
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
@@ -508,6 +574,67 @@
{% endblock %}
diff --git a/app/modules/search/backend/router.py b/app/modules/search/backend/router.py
index 4afddbf..0caa92e 100644
--- a/app/modules/search/backend/router.py
+++ b/app/modules/search/backend/router.py
@@ -27,19 +27,23 @@ async def search_contacts(q: str = Query(..., min_length=2)):
"""
Autocomplete search for contacts.
Returns list of {id, first_name, last_name, email}
+ Supports: first name, last name, email, combined "Fornavn Efternavn", phone, mobile.
"""
sql = """
- SELECT id, first_name, last_name, email
- FROM contacts
- WHERE
- (first_name ILIKE %s OR
- last_name ILIKE %s OR
- email ILIKE %s)
+ SELECT id, first_name, last_name, email
+ FROM contacts
+ WHERE
+ first_name ILIKE %s
+ OR last_name ILIKE %s
+ OR email ILIKE %s
+ OR CONCAT(first_name, ' ', last_name) ILIKE %s
+ OR phone ILIKE %s
+ OR mobile ILIKE %s
ORDER BY first_name ASC, last_name ASC
LIMIT 20
"""
term = f"%{q}%"
- results = execute_query(sql, (term, term, term))
+ results = execute_query(sql, (term, term, term, term, term, term))
return results
@router.get("/search/hardware")
diff --git a/app/modules/telefoni/backend/router.py b/app/modules/telefoni/backend/router.py
index d8b1fa9..8ec9101 100644
--- a/app/modules/telefoni/backend/router.py
+++ b/app/modules/telefoni/backend/router.py
@@ -39,8 +39,7 @@ async def send_sms(payload: SmsSendRequest, request: Request):
contact_id = payload.contact_id
if not contact_id:
- suffix8 = phone_suffix_8(payload.to)
- contact = TelefoniService.find_contact_by_phone_suffix(suffix8)
+ contact = TelefoniService.find_contact_by_phone(payload.to)
contact_id = int(contact["id"]) if contact and contact.get("id") else None
if not contact_id:
@@ -246,11 +245,10 @@ async def yealink_established(
break
ekstern_e164 = normalize_e164(ekstern_raw)
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
- suffix8 = phone_suffix_8(ekstern_raw)
user_ids = TelefoniService.find_user_by_extension(local_extension)
- kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
+ kontakt = TelefoniService.find_contact_by_phone(ekstern_raw)
kontakt_id = kontakt.get("id") if kontakt else None
# Get extended contact details if we found a contact
@@ -665,7 +663,13 @@ async def list_calls(
t.sag_id,
t.started_at,
t.ended_at,
- t.duration_sec,
+ COALESCE(
+ t.duration_sec,
+ CASE
+ WHEN t.started_at IS NOT NULL AND t.ended_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int, 0)
+ ELSE NULL
+ END
+ ) AS duration_sec,
t.created_at,
u.username,
u.full_name,
diff --git a/app/modules/telefoni/backend/service.py b/app/modules/telefoni/backend/service.py
index 295f644..ca18dda 100644
--- a/app/modules/telefoni/backend/service.py
+++ b/app/modules/telefoni/backend/service.py
@@ -20,15 +20,29 @@ class TelefoniService:
return [int(row["user_id"]) for row in rows if row.get("user_id") is not None]
@staticmethod
- def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
- if not suffix8:
+ def find_contact_by_phone(number: Optional[str]) -> Optional[dict]:
+ """Two-step lookup: full normalised number first, 8-digit suffix as fallback."""
+ from app.modules.telefoni.backend.utils import phone_digits_full, phone_suffix_8
+
+ full = phone_digits_full(number)
+ suffix = phone_suffix_8(number)
+
+ if not full and not suffix:
return None
- query = """
+ _contact_cte = """
SELECT
c.id,
c.first_name,
c.last_name,
+ (
+ SELECT cu.id
+ FROM contact_companies cc
+ JOIN customers cu ON cu.id = cc.customer_id
+ WHERE cc.contact_id = c.id
+ ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
+ LIMIT 1
+ ) AS company_id,
(
SELECT cu.name
FROM contact_companies cc
@@ -36,22 +50,66 @@ class TelefoniService:
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
- ) AS company
+ ) AS company,
+ (
+ SELECT COUNT(*)
+ FROM sag_kontakter sk
+ JOIN sag_sager s ON s.id = sk.sag_id
+ WHERE sk.contact_id = c.id
+ AND sk.deleted_at IS NULL
+ AND s.deleted_at IS NULL
+ AND LOWER(COALESCE(s.status, '')) <> 'lukket'
+ ) AS open_case_count,
+ (
+ SELECT MAX(t.started_at)
+ FROM telefoni_opkald t
+ WHERE t.kontakt_id = c.id
+ ) AS last_call_at
FROM contacts c
- WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = %s
- OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = %s
- ORDER BY c.id ASC
- LIMIT 1
"""
- row = execute_query_single(query, (suffix8, suffix8))
+
+ row = None
+
+ # Step 1: exact full-digit match (strips country code first)
+ if full:
+ query_full = _contact_cte + """
+ WHERE regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g') LIKE %s
+ OR regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g') LIKE %s
+ ORDER BY open_case_count DESC, last_call_at DESC NULLS LAST, c.id ASC
+ LIMIT 1
+ """
+ # Match ending with full digits (covers both with and without country code stored)
+ pattern = f"%{full}"
+ row = execute_query_single(query_full, (pattern, pattern))
+ if row:
+ logger.debug("π Phone lookup: full-digit match for %s β contact %s", number, row["id"])
+
+ # Step 2: 8-digit suffix fallback
+ if not row and suffix:
+ query_suffix = _contact_cte + """
+ WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = %s
+ OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = %s
+ ORDER BY open_case_count DESC, last_call_at DESC NULLS LAST, c.id ASC
+ LIMIT 1
+ """
+ row = execute_query_single(query_suffix, (suffix, suffix))
+ if row:
+ logger.debug("π Phone lookup: suffix-8 fallback for %s β contact %s", number, row["id"])
+
if not row:
return None
return {
"id": row["id"],
"name": f"{(row.get('first_name') or '').strip()} {(row.get('last_name') or '').strip()}".strip(),
+ "company_id": row.get("company_id"),
"company": row.get("company"),
}
+ @staticmethod
+ def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
+ """Deprecated: use find_contact_by_phone(). Kept for backward compatibility."""
+ return TelefoniService.find_contact_by_phone(suffix8)
+
@staticmethod
def upsert_call(
*,
@@ -103,7 +161,13 @@ class TelefoniService:
"""
UPDATE telefoni_opkald
SET ended_at = NOW(),
- duration_sec = %s
+ duration_sec = COALESCE(
+ %s,
+ CASE
+ WHEN started_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - started_at))::int, 0)
+ ELSE NULL
+ END
+ )
WHERE callid = %s
RETURNING id
""",
diff --git a/app/modules/telefoni/backend/utils.py b/app/modules/telefoni/backend/utils.py
index 499f443..63b4e1e 100644
--- a/app/modules/telefoni/backend/utils.py
+++ b/app/modules/telefoni/backend/utils.py
@@ -46,6 +46,21 @@ def phone_suffix_8(number: Optional[str]) -> Optional[str]:
return d[-8:]
+def phone_digits_full(number: Optional[str]) -> Optional[str]:
+ """Return full digit string strip of any +45/0045 prefix for Danish numbers."""
+ if not number:
+ return None
+ d = digits_only(number)
+ if not d:
+ return None
+ # Strip leading 0045 or 45 prefix from 10-digit Danish numbers
+ if d.startswith("0045") and len(d) == 12:
+ return d[4:]
+ if d.startswith("45") and len(d) == 10:
+ return d[2:]
+ return d
+
+
def is_outbound_call(caller: Optional[str], local_extension: Optional[str]) -> bool:
caller_d = digits_only(caller)
local_d = digits_only(local_extension)
diff --git a/app/routers/anydesk.py b/app/routers/anydesk.py
index 923c76e..be76cde 100644
--- a/app/routers/anydesk.py
+++ b/app/routers/anydesk.py
@@ -4,6 +4,8 @@ REST API endpoints for managing remote support sessions
"""
import logging
+import json
+from uuid import uuid4
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
@@ -100,14 +102,14 @@ async def get_session_details(session_id: int):
s.created_by_user_id, s.created_at, s.updated_at,
c.first_name || ' ' || c.last_name as contact_name,
cust.name as customer_name,
- sag.title as sag_title,
+ sag.titel as sag_title,
u.full_name as created_by_user_name,
s.device_info, s.metadata
FROM anydesk_sessions s
LEFT JOIN contacts c ON s.contact_id = c.id
LEFT JOIN customers cust ON s.customer_id = cust.id
LEFT JOIN sag_sager sag ON s.sag_id = sag.id
- LEFT JOIN users u ON s.created_by_user_id = u.id
+ LEFT JOIN users u ON s.created_by_user_id = u.user_id
WHERE s.id = %s
"""
@@ -149,6 +151,197 @@ async def end_remote_session(session_id: int):
raise HTTPException(status_code=500, detail=str(e))
+@router.patch("/anydesk/sessions/{session_id}", tags=["Remote Support"])
+async def update_session(session_id: int, data: dict):
+ """
+ Update a session β assign/re-assign to a sag, contact, or add notes.
+
+ Accepted fields: sag_id, contact_id, customer_id, notes, status
+ """
+ try:
+ allowed = {"sag_id", "contact_id", "customer_id", "notes", "status"}
+ updates = {k: v for k, v in data.items() if k in allowed}
+ if not updates:
+ raise HTTPException(status_code=400, detail="No valid fields to update")
+
+ # Verify session exists
+ existing = execute_query("SELECT id FROM anydesk_sessions WHERE id = %s", (session_id,))
+ if not existing:
+ raise HTTPException(status_code=404, detail="Session not found")
+
+ # Verify sag exists if provided
+ if "sag_id" in updates and updates["sag_id"] is not None:
+ sag = execute_query("SELECT id FROM sag_sager WHERE id = %s", (updates["sag_id"],))
+ if not sag:
+ raise HTTPException(status_code=404, detail="Case not found")
+
+ set_clauses = ", ".join([f"{k} = %s" for k in updates])
+ params = list(updates.values()) + [session_id]
+
+ query = f"""
+ UPDATE anydesk_sessions
+ SET {set_clauses}, updated_at = NOW()
+ WHERE id = %s
+ RETURNING id, anydesk_session_id, customer_id, contact_id, sag_id,
+ session_link, status, started_at, ended_at, duration_minutes,
+ created_by_user_id, created_at, updated_at
+ """
+ result = execute_query(query, tuple(params))
+ if not result:
+ raise HTTPException(status_code=500, detail="Update failed")
+
+ logger.info(f"β
Updated AnyDesk session {session_id}: {list(updates.keys())}")
+ return result[0]
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error updating session: {str(e)}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/anydesk/register-manual-session", tags=["Remote Support"])
+async def register_manual_session(data: dict):
+ """
+ Register a manual AnyDesk support session directly on a case.
+
+ Expected payload:
+ - customer_id (required)
+ - sag_id (required)
+ - anydesk_id (required)
+ - assisted_device (required)
+ - device_type (required, e.g. placebo/desktop/server)
+ - contact_id (optional)
+ - notes (optional)
+ - created_by_user_id (optional)
+ """
+ try:
+ customer_id = data.get("customer_id")
+ sag_id = data.get("sag_id")
+ contact_id = data.get("contact_id")
+ created_by_user_id = data.get("created_by_user_id")
+
+ anydesk_id = str(data.get("anydesk_id") or "").strip()
+ assisted_device = str(data.get("assisted_device") or "").strip()
+ device_type = str(data.get("device_type") or "placebo").strip().lower()
+ notes = str(data.get("notes") or "").strip()
+
+ if not customer_id:
+ raise HTTPException(status_code=400, detail="customer_id is required")
+ if not sag_id:
+ raise HTTPException(status_code=400, detail="sag_id is required")
+ if not anydesk_id:
+ raise HTTPException(status_code=400, detail="anydesk_id is required")
+ if not assisted_device:
+ raise HTTPException(status_code=400, detail="assisted_device is required")
+
+ customer = execute_query("SELECT id FROM customers WHERE id = %s", (customer_id,))
+ if not customer:
+ raise HTTPException(status_code=404, detail="Customer not found")
+
+ sag = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
+ if not sag:
+ raise HTTPException(status_code=404, detail="Case not found")
+
+ if contact_id:
+ contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
+ if not contact:
+ raise HTTPException(status_code=404, detail="Contact not found")
+
+ manual_external_id = f"manual-{uuid4().hex[:12]}"
+ is_placeholder = device_type in {"placebo", "placeholder", "ukendt", "unknown"}
+
+ device_info = {
+ "to_id": anydesk_id,
+ "customer_machine_id": anydesk_id,
+ "assisted_device_name": assisted_device,
+ "assisted_device_type": device_type,
+ "is_placeholder_device": is_placeholder,
+ "source": "manual_case_registration"
+ }
+ metadata = {
+ "notes": notes,
+ "source": "manual_case_registration"
+ }
+
+ insert_q = """
+ INSERT INTO anydesk_sessions (
+ anydesk_session_id,
+ contact_id,
+ customer_id,
+ sag_id,
+ session_link,
+ device_info,
+ created_by_user_id,
+ started_at,
+ ended_at,
+ duration_minutes,
+ status,
+ metadata,
+ created_at,
+ updated_at
+ )
+ VALUES (
+ %s,
+ %s,
+ %s,
+ %s,
+ NULL,
+ %s::jsonb,
+ %s,
+ NOW(),
+ NOW(),
+ 0,
+ 'completed',
+ %s::jsonb,
+ NOW(),
+ NOW()
+ )
+ RETURNING id, anydesk_session_id, customer_id, contact_id, sag_id, status, started_at
+ """
+ created = execute_query(
+ insert_q,
+ (
+ manual_external_id,
+ contact_id,
+ customer_id,
+ sag_id,
+ json.dumps(device_info),
+ created_by_user_id,
+ json.dumps(metadata),
+ ),
+ )
+ if not created:
+ raise HTTPException(status_code=500, detail="Failed to register session")
+
+ comment_lines = [
+ f"π₯οΈ AnyDesk session registreret manuelt (AnyDesk ID: {anydesk_id})",
+ f"Enhed: {assisted_device}",
+ f"Type: {device_type}",
+ ]
+ if notes:
+ comment_lines.append(f"Notat: {notes}")
+ if is_placeholder:
+ comment_lines.append("Info: Enhedstype er placeholder og kan linkes til hardware senere.")
+
+ execute_query(
+ """
+ INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
+ VALUES (%s, %s, %s, %s)
+ """,
+ (sag_id, "System", "\n".join(comment_lines), True),
+ )
+
+ logger.info("β
Manual AnyDesk session registered for case %s", sag_id)
+ return {"ok": True, "session": created[0]}
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Error registering manual AnyDesk session: %s", str(e))
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.get("/anydesk/sessions", response_model=AnyDeskSessionHistory, tags=["Remote Support"])
async def get_session_history(
contact_id: Optional[int] = None,
@@ -170,13 +363,6 @@ async def get_session_history(
- **offset**: Pagination offset
"""
try:
- if not any([contact_id, customer_id, sag_id]):
- raise HTTPException(
- status_code=400,
- detail="At least one filter (contact_id, customer_id, or sag_id) is required"
- )
-
- # Validate limit
if limit > 100:
limit = 100
@@ -369,11 +555,268 @@ async def anydesk_health_check():
Returns configuration status, API connectivity, and last sync time
"""
+ creds = anydesk_service._get_credentials()
return JSONResponse(content={
"service": "AnyDesk Remote Support",
"status": "operational",
- "configured": bool(anydesk_service.api_token and anydesk_service.license_id),
- "dry_run_mode": anydesk_service.dry_run,
- "read_only_mode": anydesk_service.read_only,
+ "configured": bool(creds["api_token"] and creds["license_id"]),
+ "dry_run_mode": creds["dry_run"],
+ "read_only_mode": creds["read_only"],
"auto_start_enabled": anydesk_service.auto_start
})
+
+
+@router.post("/anydesk/fetch-from-api", tags=["Remote Support"])
+async def fetch_sessions_from_anydesk(
+ days: int = 30,
+ limit: int = 1000,
+ after: Optional[str] = None,
+ before: Optional[str] = None,
+):
+ """
+ Pull session log from the live AnyDesk REST API and import into local DB.
+
+ - **days**: How many days back to fetch (default 30)
+ - **limit**: Max entries to fetch (default 1000)
+ - **after**: Override start as ISO-8601 timestamp (e.g. 2024-01-01T00:00:00Z)
+ - **before**: Override end as ISO-8601 timestamp
+
+ Requires `dry_run=false` in AnyDesk settings.
+ Auth uses HMAC-SHA1 (AnyDesk native format), not Bearer token.
+ Returns count of newly imported and updated sessions.
+ """
+ try:
+ result = await anydesk_service.fetch_sessions_from_api(
+ days=days,
+ limit=limit,
+ after=after,
+ before=before,
+ )
+ if "error" in result:
+ raise HTTPException(status_code=400, detail=result["error"])
+ return JSONResponse(content=result)
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error fetching from AnyDesk API: {str(e)}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# =====================================================
+# Sessions Dashboard Endpoints
+# =====================================================
+
+@router.get("/anydesk/sessions-overview", tags=["Remote Support"])
+async def sessions_overview(
+ days: int = 90,
+ unregistered_only: bool = False,
+ limit: int = 200,
+ offset: int = 0,
+):
+ """
+ Enriched session list for the dashboard page.
+ Joins hardware_assets (via remote_id/anydesk_id), contacts, customers, sag.
+ """
+ try:
+ where = "WHERE s.started_at >= NOW() - INTERVAL '%s days'" % int(days)
+ if unregistered_only:
+ where += " AND s.sag_id IS NULL AND s.contact_id IS NULL AND s.hardware_asset_id IS NULL"
+
+ query = f"""
+ SELECT
+ s.id,
+ s.anydesk_session_id,
+ s.started_at,
+ s.ended_at,
+ s.duration_minutes,
+ s.status,
+ s.notes,
+ -- linked hardware
+ s.hardware_asset_id,
+ ha.brand AS hw_brand,
+ ha.model AS hw_model,
+ ha.anydesk_id AS hw_anydesk_id,
+ ha.current_owner_customer_id AS hw_customer_id,
+ -- linked contact
+ s.contact_id,
+ c.first_name || ' ' || c.last_name AS contact_name,
+ c.email AS contact_email,
+ -- linked customer
+ s.customer_id,
+ cust.name AS customer_name,
+ -- linked sag
+ s.sag_id,
+ sag.titel AS sag_titel,
+ sag.status AS sag_status,
+ -- raw remote_id from import
+ (s.device_info->>'remote_id')::TEXT AS remote_id,
+ (s.device_info->>'remote_alias')::TEXT AS remote_alias,
+ (s.device_info->>'from_id')::TEXT AS technician_id,
+ (s.device_info->>'to_id')::TEXT AS customer_machine_id,
+ (s.device_info->>'local_alias')::TEXT AS customer_alias,
+ -- technician resolved from user_anydesk_ids
+ tech_u.user_id AS tech_user_id,
+ COALESCE(tech_u.full_name, tech_u.username) AS tech_name
+ FROM anydesk_sessions s
+ LEFT JOIN hardware_assets ha ON s.hardware_asset_id = ha.id
+ LEFT JOIN contacts c ON s.contact_id = c.id
+ LEFT JOIN customers cust ON s.customer_id = cust.id
+ LEFT JOIN sag_sager sag ON s.sag_id = sag.id
+ LEFT JOIN user_anydesk_ids uad
+ ON regexp_replace(COALESCE(uad.anydesk_id, ''), '[^0-9]', '', 'g') =
+ regexp_replace(COALESCE((s.device_info->>'from_id')::TEXT, ''), '[^0-9]', '', 'g')
+ LEFT JOIN users tech_u ON tech_u.user_id = uad.user_id
+ {where}
+ ORDER BY s.started_at DESC
+ LIMIT {int(limit)} OFFSET {int(offset)}
+ """
+ rows = execute_query(query)
+
+ # count
+ count_q = f"SELECT COUNT(*) AS total FROM anydesk_sessions s {where}"
+ total = (execute_query(count_q) or [{"total": 0}])[0]["total"]
+
+ sessions = []
+ for r in (rows or []):
+ sessions.append({
+ "id": r["id"],
+ "anydesk_session_id": r["anydesk_session_id"],
+ "started_at": str(r["started_at"]) if r["started_at"] else None,
+ "ended_at": str(r["ended_at"]) if r["ended_at"] else None,
+ "duration_minutes": r["duration_minutes"],
+ "status": r["status"],
+ "notes": r["notes"],
+ "remote_id": r["remote_id"],
+ "remote_alias": r["remote_alias"],
+ "technician_id": r["technician_id"], # from.cid β teknikkerens maskine
+ "technician_name": r["tech_name"], # resolved from user_anydesk_ids
+ "customer_machine_id": r["customer_machine_id"], # to.cid β kundens maskine
+ "customer_alias": r["customer_alias"],
+ "hardware": {
+ "id": r["hardware_asset_id"],
+ "brand": r["hw_brand"],
+ "model": r["hw_model"],
+ "anydesk_id": r["hw_anydesk_id"],
+ "customer_id": r["hw_customer_id"],
+ } if r["hardware_asset_id"] else None,
+ "contact": {
+ "id": r["contact_id"],
+ "name": r["contact_name"],
+ "email": r["contact_email"],
+ } if r["contact_id"] else None,
+ "customer": {
+ "id": r["customer_id"],
+ "name": r["customer_name"],
+ } if r["customer_id"] else None,
+ "sag": {
+ "id": r["sag_id"],
+ "titel": r["sag_titel"],
+ "status": r["sag_status"],
+ } if r["sag_id"] else None,
+ })
+
+ return JSONResponse(content={"sessions": sessions, "total": total, "limit": limit, "offset": offset})
+
+ except Exception as e:
+ logger.error(f"Error in sessions_overview: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/anydesk/auto-link", tags=["Remote Support"])
+async def auto_link_sessions():
+ """
+ Auto-link unlinked sessions to hardware_assets via anydesk_id match,
+ and carry over contact/customer from the hardware asset.
+ Returns count of newly linked sessions.
+ """
+ try:
+ linked = 0
+
+ # Match sessions to hardware_assets where to_id (customer machine) = anydesk_id
+ # NOTE: from.cid is the TECHNICIAN's machine, to.cid is the CUSTOMER's machine
+ result = execute_query("""
+ UPDATE anydesk_sessions s
+ SET
+ hardware_asset_id = ha.id,
+ customer_id = COALESCE(s.customer_id, ha.current_owner_customer_id),
+ updated_at = NOW()
+ FROM hardware_assets ha
+ WHERE ha.anydesk_id IS NOT NULL
+ AND ha.anydesk_id != ''
+ AND (
+ (s.device_info->>'to_id') = ha.anydesk_id
+ OR
+ -- fallback: older imports without to_id β try remote_id only if it differs from technicians' known IDs
+ (s.device_info->>'to_id' IS NULL AND (s.device_info->>'remote_id') = ha.anydesk_id)
+ )
+ AND s.hardware_asset_id IS NULL
+ RETURNING s.id
+ """)
+ linked = len(result) if result else 0
+
+ logger.info(f"β
Auto-linked {linked} AnyDesk sessions to hardware assets")
+ return JSONResponse(content={"linked": linked})
+
+ except Exception as e:
+ logger.error(f"Error in auto_link_sessions: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.patch("/anydesk/sessions/{session_id}/link", tags=["Remote Support"])
+async def link_session(
+ session_id: int,
+ sag_id: Optional[int] = None,
+ contact_id: Optional[int] = None,
+ customer_id: Optional[int] = None,
+ hardware_asset_id: Optional[int] = None,
+ notes: Optional[str] = None,
+):
+ """
+ Manually link a session to sag, contact, customer, hardware, or add notes.
+ """
+ try:
+ sets = ["updated_at = NOW()"]
+ params = []
+ if sag_id is not None:
+ sets.append("sag_id = %s"); params.append(sag_id)
+ if contact_id is not None:
+ sets.append("contact_id = %s"); params.append(contact_id)
+ if customer_id is not None:
+ sets.append("customer_id = %s"); params.append(customer_id)
+ if hardware_asset_id is not None:
+ sets.append("hardware_asset_id = %s"); params.append(hardware_asset_id)
+ if notes is not None:
+ sets.append("notes = %s"); params.append(notes)
+
+ if len(sets) == 1:
+ return JSONResponse(content={"message": "no changes"})
+
+ params.append(session_id)
+ execute_query(
+ f"UPDATE anydesk_sessions SET {', '.join(sets)} WHERE id = %s",
+ tuple(params)
+ )
+ return JSONResponse(content={"ok": True})
+
+ except Exception as e:
+ logger.error(f"Error linking session {session_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/anydesk/hardware-assets", tags=["Remote Support"])
+async def anydesk_hardware_list():
+ """List all hardware assets that have an anydesk_id (for linking dropdown)"""
+ try:
+ rows = execute_query("""
+ SELECT ha.id, ha.brand, ha.model, ha.anydesk_id, ha.serial_number,
+ ha.current_owner_customer_id AS customer_id, cust.name AS customer_name
+ FROM hardware_assets ha
+ LEFT JOIN customers cust ON ha.current_owner_customer_id = cust.id
+ WHERE ha.deleted_at IS NULL
+ ORDER BY ha.brand, ha.model
+ """)
+ return JSONResponse(content={"assets": rows or []})
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
diff --git a/app/services/anydesk.py b/app/services/anydesk.py
index 101a0b8..c94b0fe 100644
--- a/app/services/anydesk.py
+++ b/app/services/anydesk.py
@@ -5,9 +5,14 @@ Handles integration with AnyDesk API for remote session management
import logging
import json
+import hashlib
+import hmac
+import base64
+import time
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import httpx
+import aiohttp
from app.core.config import settings
from app.core.database import execute_query
@@ -23,79 +28,117 @@ class AnyDeskService:
Respects safety switches: READ_ONLY and DRY_RUN
"""
- BASE_URL = "https://api.anydesk.com"
+ BASE_URL = "https://v1.api.anydesk.com:8081"
def __init__(self):
- self.api_token = settings.ANYDESK_API_TOKEN
- self.license_id = settings.ANYDESK_LICENSE_ID
- self.read_only = settings.ANYDESK_READ_ONLY
- self.dry_run = settings.ANYDESK_DRY_RUN
- self.timeout = settings.ANYDESK_TIMEOUT_SECONDS
+ # Credentials loaded lazily from DB at call-time (via _get_credentials)
+ # Fall back to .env values if DB has nothing
+ self._timeout = settings.ANYDESK_TIMEOUT_SECONDS
self.auto_start = settings.ANYDESK_AUTO_START_SESSION
-
- if not self.api_token or not self.license_id:
- logger.warning("β οΈ AnyDesk credentials not configured - service disabled")
-
+
+ def _get_credentials(self) -> Dict[str, Any]:
+ """Load credentials from DB settings table, fallback to .env"""
+ try:
+ rows = execute_query(
+ "SELECT key, value FROM settings WHERE key LIKE 'anydesk_%'",
+ )
+ db = {r["key"]: r["value"] for r in rows} if rows else {}
+ except Exception:
+ db = {}
+
+ def _bool(val, default: bool) -> bool:
+ if val is None:
+ return default
+ return str(val).lower() in ("true", "1", "yes")
+
+ return {
+ "api_token": db.get("anydesk_api_token") or settings.ANYDESK_API_TOKEN or "",
+ "license_id": db.get("anydesk_license_id") or settings.ANYDESK_LICENSE_ID or "",
+ "read_only": _bool(db.get("anydesk_read_only"), settings.ANYDESK_READ_ONLY),
+ "dry_run": _bool(db.get("anydesk_dry_run"), settings.ANYDESK_DRY_RUN),
+ }
+
+ @property
+ def timeout(self):
+ return self._timeout
+
+ def _generate_auth_header(self, resource: str, content: str = "", method: str = "GET") -> str:
+ """
+ AnyDesk HMAC-SHA1 auth header.
+ Format: AD {license_id}:{timestamp}:{signature}
+ """
+ creds = self._get_credentials()
+ sha1 = hashlib.sha1()
+ sha1.update(content.encode("utf-8"))
+ content_hash = base64.b64encode(sha1.digest()).decode("utf-8")
+ timestamp = str(int(time.time()))
+ request_string = f"{method}\n{resource}\n{timestamp}\n{content_hash}"
+ sig = hmac.new(
+ creds["api_token"].encode("utf-8"),
+ request_string.encode("utf-8"),
+ hashlib.sha1,
+ ).digest()
+ token = base64.b64encode(sig).decode("utf-8")
+ return f"AD {creds['license_id']}:{timestamp}:{token}"
+
def _check_enabled(self) -> bool:
"""Check if AnyDesk is properly configured"""
- if not self.api_token or not self.license_id:
+ creds = self._get_credentials()
+ if not creds["api_token"] or not creds["license_id"]:
logger.warning("AnyDesk service not configured (missing credentials)")
return False
return True
async def _api_call(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict[str, Any]:
- """
- Make HTTP call to AnyDesk API
-
- Args:
- method: HTTP method (GET, POST, PUT, DELETE)
- endpoint: API endpoint (e.g., "/v1/sessions")
- data: Request body data
-
- Returns:
- Response JSON dictionary
- """
if not self._check_enabled():
return {"error": "AnyDesk not configured"}
-
- # Log the intent
+
+ creds = self._get_credentials()
+ dry_run = creds["dry_run"]
+ read_only = creds["read_only"]
+
log_msg = f"π AnyDesk API: {method} {endpoint}"
if data:
log_msg += f" | Data: {json.dumps(data, indent=2)}"
logger.info(log_msg)
-
+
# DRY RUN: Don't actually call API
- if self.dry_run:
+ if dry_run:
logger.warning("β οΈ DRY_RUN=true: Simulating API response (no actual call)")
return self._simulate_response(method, endpoint, data)
-
+
# READ ONLY: Allow gets but not mutations
- if self.read_only and method != "GET":
+ if read_only and method != "GET":
logger.warning(f"π READ_ONLY=true: Blocking {method} request")
return {"error": "Read-only mode: mutations disabled"}
-
+
+ body_str = json.dumps(data) if data else ""
+ auth_header = self._generate_auth_header(endpoint, body_str, method)
+ headers = {
+ "Authorization": auth_header,
+ "Content-Type": "application/json",
+ }
+ url = f"{self.BASE_URL}{endpoint}"
+
try:
- headers = {
- "Authorization": f"Bearer {self.api_token}",
- "Content-Type": "application/json"
- }
-
- url = f"{self.BASE_URL}{endpoint}"
-
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- if method == "GET":
- response = await client.get(url, headers=headers)
- elif method == "POST":
- response = await client.post(url, headers=headers, json=data)
- elif method == "PUT":
- response = await client.put(url, headers=headers, json=data)
- elif method == "DELETE":
- response = await client.delete(url, headers=headers)
- else:
- return {"error": f"Unsupported method: {method}"}
-
- response.raise_for_status()
- return response.json()
+ async with aiohttp.ClientSession() as session:
+ kwargs = {"headers": headers, "timeout": aiohttp.ClientTimeout(total=self.timeout)}
+ if data:
+ kwargs["json"] = data
+ async with getattr(session, method.lower())(url, **kwargs) as response:
+ response_text = await response.text()
+ logger.info(f"π‘ AnyDesk API {response.status}: {response_text[:200]}")
+ if response.status == 200:
+ try:
+ return await response.json(content_type=None)
+ except Exception:
+ return {"raw": response_text}
+ elif response.status == 401:
+ logger.error(f"β AnyDesk auth failed β check license_id + api_token")
+ return {"error": f"Unauthorized (401): {response_text[:200]}"}
+ else:
+ logger.error(f"β AnyDesk API error {response.status}: {response_text[:300]}")
+ return {"error": f"HTTP {response.status}: {response_text[:300]}"}
except httpx.HTTPError as e:
logger.error(f"β AnyDesk API error: {str(e)}")
@@ -156,11 +199,13 @@ class AnyDeskService:
Returns:
Session data with session_id, link, access_code, etc.
"""
+ creds = self._get_credentials()
+
# Prepare session data
session_data = {
"name": f"BMC Support - Customer {customer_id}",
"description": description or f"Support session for customer {customer_id}",
- "license_id": self.license_id,
+ "license_id": creds["license_id"],
"auto_accept": True # Auto-accept connection requests
}
@@ -189,7 +234,7 @@ class AnyDeskService:
device_info = {
"created_via": "api",
"auto_start": self.auto_start,
- "dry_run_mode": self.dry_run
+ "dry_run_mode": creds["dry_run"]
}
metadata = {
@@ -385,7 +430,7 @@ class AnyDeskService:
s.created_by_user_id, s.created_at, s.updated_at,
c.first_name || ' ' || c.last_name as contact_name,
cust.name as customer_name,
- sag.title as sag_title,
+ sag.titel as sag_title,
u.full_name as created_by_user_name,
s.device_info, s.metadata
FROM anydesk_sessions s
@@ -422,3 +467,113 @@ class AnyDeskService:
except Exception as e:
logger.error(f"Error fetching session history: {str(e)}")
return {"error": str(e), "sessions": []}
+
+ async def fetch_sessions_from_api(
+ self,
+ days: int = 30,
+ limit: int = 1000,
+ after: Optional[str] = None,
+ before: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ """
+ Pull session log from AnyDesk REST API and upsert into local DB.
+ AnyDesk API: GET /sessions?from=UNIX&to=UNIX&limit=N
+ Auth: HMAC-SHA1 signature (not Bearer token)
+ Returns summary of imported/updated records.
+ """
+ end_ts = int(time.time())
+ start_ts = end_ts - (days * 86400)
+
+ # Allow ISO override
+ if after:
+ try:
+ start_ts = int(datetime.fromisoformat(after.rstrip("Z")).timestamp())
+ except Exception:
+ pass
+ if before:
+ try:
+ end_ts = int(datetime.fromisoformat(before.rstrip("Z")).timestamp())
+ except Exception:
+ pass
+
+ qs = f"from={start_ts}&to={end_ts}&limit={limit}"
+ result = await self._api_call("GET", f"/sessions?{qs}")
+
+ if "error" in result:
+ return result
+
+ # AnyDesk returns { "list": [...] }
+ entries = result.get("list", result if isinstance(result, list) else [])
+
+ imported = 0
+ updated = 0
+ errors = []
+
+ for i, entry in enumerate(entries):
+ if i < 3:
+ logger.info(f"π AnyDesk session sample: {entry}")
+ try:
+ session_id = str(entry.get("sid") or "")
+ if not session_id:
+ continue
+
+ # AnyDesk timestamps are unix integers
+ started_raw = entry.get("start-time")
+ ended_raw = entry.get("end-time")
+ started = datetime.utcfromtimestamp(started_raw) if started_raw else None
+ ended = datetime.utcfromtimestamp(ended_raw) if ended_raw else None
+ duration_s = entry.get("duration") or 0
+ duration_min = round(int(duration_s) / 60, 1) if duration_s else None
+
+ remote_alias = entry.get("from", {}).get("alias") if isinstance(entry.get("from"), dict) else None
+ from_id = str(entry.get("from", {}).get("cid") or "") if isinstance(entry.get("from"), dict) else None # technician machine
+ to_id = str(entry.get("to", {}).get("cid") or "") if isinstance(entry.get("to"), dict) else None # customer machine
+ local_alias = entry.get("to", {}).get("alias") if isinstance(entry.get("to"), dict) else None
+
+ status = "active" if entry.get("active") else "completed"
+
+ device_info = json.dumps({
+ "remote_alias": remote_alias, # technician alias (from)
+ "remote_id": from_id, # technician machine CID (from.cid) β kept for compat
+ "from_id": from_id, # technician machine CID
+ "to_id": to_id, # customer machine CID β use for hardware linking
+ "local_alias": local_alias, # customer alias (to)
+ "imported_from_api": True,
+ })
+ metadata = json.dumps({"raw": entry})
+
+ # Upsert: insert or update on anydesk_session_id
+ check = execute_query(
+ "SELECT id FROM anydesk_sessions WHERE anydesk_session_id = %s",
+ (session_id,)
+ )
+ if check:
+ execute_query(
+ """UPDATE anydesk_sessions
+ SET status=%s, ended_at=%s, duration_minutes=%s,
+ device_info=%s, metadata=%s, updated_at=NOW()
+ WHERE anydesk_session_id=%s""",
+ (status, ended, duration_min, device_info, metadata, session_id)
+ )
+ updated += 1
+ else:
+ execute_query(
+ """INSERT INTO anydesk_sessions
+ (anydesk_session_id, status, started_at, ended_at,
+ duration_minutes, device_info, metadata)
+ VALUES (%s, %s, %s, %s, %s, %s, %s)""",
+ (session_id, status, started, ended, duration_min, device_info, metadata)
+ )
+ imported += 1
+
+ except Exception as exc:
+ errors.append(str(exc))
+ logger.warning(f"β οΈ Could not import entry: {exc}")
+
+ logger.info(f"β
AnyDesk import done: {imported} new, {updated} updated, {len(errors)} errors")
+ return {
+ "imported": imported,
+ "updated": updated,
+ "total_from_api": len(entries),
+ "errors": errors,
+ }
diff --git a/app/services/brother_label_print_service.py b/app/services/brother_label_print_service.py
new file mode 100644
index 0000000..feec62b
--- /dev/null
+++ b/app/services/brother_label_print_service.py
@@ -0,0 +1,263 @@
+"""Brother QL direct print service for case hardware labels."""
+
+from __future__ import annotations
+
+import logging
+import socket
+from dataclasses import dataclass
+from typing import Iterable, List, Optional
+
+from PIL import Image, ImageDraw, ImageFont
+
+# Compatibility shim: brother_ql may still reference Image.ANTIALIAS,
+# which was removed in newer Pillow releases.
+if not hasattr(Image, "ANTIALIAS") and hasattr(Image, "Resampling"):
+ Image.ANTIALIAS = Image.Resampling.LANCZOS
+
+logger = logging.getLogger(__name__)
+
+try:
+ from brother_ql.backends.helpers import send
+ from brother_ql.conversion import convert
+ from brother_ql.raster import BrotherQLRaster
+ from brother_ql.labels import ALL_LABELS
+except Exception: # pragma: no cover - handled at runtime
+ send = None
+ convert = None
+ BrotherQLRaster = None
+ ALL_LABELS = None
+
+
+_CODE39_PATTERNS = {
+ "0": "nnnwwnwnn", "1": "wnnwnnnnw", "2": "nnwwnnnnw", "3": "wnwwnnnnn",
+ "4": "nnnwwnnnw", "5": "wnnwwnnnn", "6": "nnwwwnnnn", "7": "nnnwnnwnw",
+ "8": "wnnwnnwnn", "9": "nnwwnnwnn", "A": "wnnnnwnnw", "B": "nnwnnwnnw",
+ "C": "wnwnnwnnn", "D": "nnnnwwnnw", "E": "wnnnwwnnn", "F": "nnwnwwnnn",
+ "G": "nnnnnwwnw", "H": "wnnnnwwnn", "I": "nnwnnwwnn", "J": "nnnnwwwnn",
+ "K": "wnnnnnnww", "L": "nnwnnnnww", "M": "wnwnnnnwn", "N": "nnnnwnnww",
+ "O": "wnnnwnnwn", "P": "nnwnwnnwn", "Q": "nnnnnnwww", "R": "wnnnnnwwn",
+ "S": "nnwnnnwwn", "T": "nnnnwnwwn", "U": "wwnnnnnnw", "V": "nwwnnnnnw",
+ "W": "wwwnnnnnn", "X": "nwnnwnnnw", "Y": "wwnnwnnnn", "Z": "nwwnwnnnn",
+ "-": "nwnnnnwnw", ".": "wwnnnnwnn", " ": "nwwnnnwnn", "$": "nwnwnwnnn",
+ "/": "nwnwnnnwn", "+": "nwnnnwnwn", "%": "nnnwnwnwn", "*": "nwnnwnwnn",
+}
+
+
+@dataclass
+class LabelJob:
+ name: str
+ meta_line: str
+ token: str
+
+
+class BrotherLabelPrintService:
+ def __init__(
+ self,
+ model: str,
+ host: str,
+ port: int,
+ label_size: str,
+ ) -> None:
+ self.model = (model or "QL-710W").strip()
+ self.host = (host or "").strip()
+ self.port = int(port or 9100)
+ self.label_size = self._normalize_label_size((label_size or "62").strip())
+ self.label_spec = self._resolve_label_spec(self.label_size)
+ self.printable_width = self._resolve_printable_width(self.label_size)
+ self.printable_height = self._resolve_printable_height(self.label_size)
+ self.is_die_cut = bool(self.label_spec and getattr(self.label_spec, "form_factor", None) and "DIE_CUT" in str(getattr(self.label_spec, "form_factor", "")))
+
+ @property
+ def printer_identifier(self) -> str:
+ return f"tcp://{self.host}:{self.port}"
+
+ def print_jobs(self, jobs: Iterable[LabelJob]) -> int:
+ if not self.host:
+ raise ValueError("Printer host is missing")
+ if not send or not convert or not BrotherQLRaster:
+ raise RuntimeError("brother_ql library is not installed in this environment")
+
+ send_func = send
+ convert_func = convert
+ raster_cls = BrotherQLRaster
+
+ rendered_images = [self._build_label_image(job) for job in jobs]
+ if not rendered_images:
+ return 0
+
+ qlr = raster_cls(self.model)
+ instructions = convert_func(
+ qlr=qlr,
+ images=rendered_images,
+ label=self.label_size,
+ rotate='auto' if self.is_die_cut else 0,
+ cut=True,
+ dither=False,
+ compress=False,
+ red=False,
+ dpi_600=False,
+ )
+
+ self._send_to_printer(instructions, send_func)
+ return len(rendered_images)
+
+ def _send_to_printer(self, instructions: List[bytes], send_func) -> None:
+ target = self.printer_identifier
+ # brother_ql helper changed call signature across versions.
+ try:
+ send_func(instructions, target, "network", blocking=True)
+ return
+ except TypeError:
+ pass
+
+ try:
+ send_func(instructions=instructions, printer_identifier=target, backend_identifier="network", blocking=True)
+ return
+ except TypeError:
+ pass
+
+ # Final fallback to raw socket stream for network printers.
+ payload = b"".join(instructions)
+ with socket.create_connection((self.host, self.port), timeout=10) as conn:
+ conn.sendall(payload)
+
+ def _build_label_image(self, job: LabelJob) -> Image.Image:
+ width = self.printable_width
+ height = self.printable_height if self.printable_height > 0 else 220
+ image = Image.new("RGB", (width, height), "white")
+ draw = ImageDraw.Draw(image)
+ font_title = ImageFont.load_default()
+ font_meta = ImageFont.load_default()
+ font_token = ImageFont.load_default()
+
+ title = (job.name or "Ukendt enhed")[:52]
+ meta = (job.meta_line or "-")[:88]
+ token = (job.token or "")[:64]
+
+ left = 12
+ top = 8
+ right = max(left + 1, width - 12)
+
+ # Compact layout for die-cut labels to fit exact printable area.
+ if self.is_die_cut:
+ title_y = top
+ meta_y = title_y + 18
+ barcode_y = meta_y + 16
+ token_y = min(height - 14, barcode_y + max(26, int(height * 0.28)) + 4)
+ bar_height = max(24, min(int(height * 0.28), height - barcode_y - 22))
+ else:
+ title_y = 12
+ meta_y = 34
+ barcode_y = 64
+ token_y = min(height - 16, 170)
+ bar_height = max(48, min(92, height - barcode_y - 26))
+
+ draw.text((left, title_y), title, fill="black", font=font_title)
+ draw.text((left, meta_y), meta, fill="black", font=font_meta)
+ self._draw_code39(draw, token, x=left, y=barcode_y, max_width=max(60, right - left), bar_height=bar_height)
+ draw.text((left, token_y), token, fill="black", font=font_token)
+ return image
+
+ def _normalize_label_size(self, label_size: str) -> str:
+ wanted = str(label_size or "").strip()
+ if wanted == "29":
+ # Legacy compatibility: old config often used "29" while hardware stock is 62x29 die-cut.
+ logger.warning("β οΈ Label size '29' mapped to '62x29' for Brother QL hardware labels")
+ return "62x29"
+ return wanted or "62"
+
+ @staticmethod
+ def _resolve_label_spec(label_size: str):
+ if not ALL_LABELS:
+ return None
+ wanted = str(label_size or "").strip()
+ for lbl in ALL_LABELS:
+ if getattr(lbl, "identifier", "") == wanted:
+ return lbl
+ return None
+
+ @staticmethod
+ def _resolve_printable_width(label_size: str) -> int:
+ default_width = 696 # 62mm endless printable width
+ if not ALL_LABELS:
+ return default_width
+ try:
+ wanted = str(label_size or "").strip()
+ for lbl in ALL_LABELS:
+ if getattr(lbl, "identifier", "") == wanted:
+ dots = getattr(lbl, "dots_printable", None)
+ if isinstance(dots, tuple) and len(dots) > 0 and int(dots[0]) > 0:
+ return int(dots[0])
+ except Exception:
+ return default_width
+ return default_width
+
+ @staticmethod
+ def _resolve_printable_height(label_size: str) -> int:
+ if not ALL_LABELS:
+ return 220
+ try:
+ wanted = str(label_size or "").strip()
+ for lbl in ALL_LABELS:
+ if getattr(lbl, "identifier", "") == wanted:
+ dots = getattr(lbl, "dots_printable", None)
+ if isinstance(dots, tuple) and len(dots) > 1 and int(dots[1]) > 0:
+ return int(dots[1])
+ return 220
+ except Exception:
+ return 220
+ return 220
+
+ def _draw_code39(
+ self,
+ draw: ImageDraw.ImageDraw,
+ value: str,
+ x: int,
+ y: int,
+ max_width: int,
+ bar_height: int,
+ ) -> None:
+ safe = "".join(ch for ch in (value or "").upper() if ch in _CODE39_PATTERNS and ch != "*")
+ if not safe:
+ safe = "EMPTY"
+ seq = f"*{safe}*"
+
+ # Prefer physically narrower bars first; scanners struggle when Code39
+ # modules become too wide on small die-cut labels.
+ variants = [
+ (1, 2, 0),
+ (1, 3, 1),
+ (2, 5, 1),
+ ]
+
+ narrow, wide, gap = variants[0]
+ for candidate in variants:
+ c_narrow, c_wide, c_gap = candidate
+ width = self._code39_width(seq, c_narrow, c_wide, c_gap)
+ if width <= max_width:
+ narrow, wide, gap = c_narrow, c_wide, c_gap
+ break
+
+ cursor = x
+ for ch in seq:
+ pattern = _CODE39_PATTERNS[ch]
+ for idx, code in enumerate(pattern):
+ stroke = wide if code == "w" else narrow
+ if idx % 2 == 0:
+ draw.rectangle([cursor, y, cursor + stroke - 1, y + bar_height], fill="black")
+ cursor += stroke
+ if idx < len(pattern) - 1:
+ cursor += gap
+ cursor += gap
+
+ @staticmethod
+ def _code39_width(sequence: str, narrow: int, wide: int, gap: int) -> int:
+ total = 0
+ for ch in sequence:
+ pattern = _CODE39_PATTERNS[ch]
+ for idx, code in enumerate(pattern):
+ total += wide if code == "w" else narrow
+ if idx < len(pattern) - 1:
+ total += gap
+ total += gap
+ return total
diff --git a/app/services/cvr_service.py b/app/services/cvr_service.py
index 20410ef..db8201f 100644
--- a/app/services/cvr_service.py
+++ b/app/services/cvr_service.py
@@ -1,20 +1,59 @@
"""
-CVR.dk API service for looking up Danish company information
-Free public API - no authentication required
-Adapted from OmniSync for BMC Hub
+CVR service for looking up Danish company information.
+
+Primary provider: FirmaAPI (authenticated).
+Legacy fallback: cvrapi.dk when no FirmaAPI key is configured.
"""
import asyncio
import aiohttp
import logging
from typing import Optional, Dict
+from app.core.config import settings
+
logger = logging.getLogger(__name__)
class CVRService:
- """Service for CVR.dk API lookups"""
-
- BASE_URL = "https://cvrapi.dk/api"
+ """Service for CVR lookups using FirmaAPI (or legacy fallback)."""
+
+ LEGACY_BASE_URL = "https://cvrapi.dk/api"
+
+ @property
+ def firmaapi_base_url(self) -> str:
+ return settings.FIRMAAPI_BASE_URL.rstrip("/")
+
+ @property
+ def firmaapi_timeout(self) -> aiohttp.ClientTimeout:
+ return aiohttp.ClientTimeout(total=settings.FIRMAAPI_TIMEOUT_SECONDS)
+
+ @property
+ def has_firmaapi_key(self) -> bool:
+ return bool((settings.FIRMAAPI_API_KEY or "").strip())
+
+ def _firmaapi_headers(self) -> Dict[str, str]:
+ api_key = (settings.FIRMAAPI_API_KEY or "").strip()
+ return {
+ "Authorization": f"Bearer {api_key}",
+ "Accept": "application/json",
+ }
+
+ @staticmethod
+ def _normalize_payload(payload: Dict) -> Dict:
+ return {
+ "cvr": payload.get("cvr") or payload.get("vat"),
+ "name": payload.get("name"),
+ "address": payload.get("address"),
+ "city": payload.get("city"),
+ "zipcode": payload.get("zipcode"),
+ "postal_code": payload.get("zipcode") or payload.get("postal_code"),
+ "country": payload.get("country") or "DK",
+ "phone": payload.get("phone"),
+ "email": payload.get("email"),
+ "website": payload.get("website"),
+ "status": payload.get("status"),
+ "source": "firmaapi" if payload.get("meta", {}).get("source") == "FirmaAPI" else payload.get("source", "firmaapi"),
+ }
async def lookup_by_name(self, company_name: str) -> Optional[Dict]:
"""
@@ -33,43 +72,44 @@ class CVRService:
clean_name = company_name.strip()
try:
- params = {
- 'search': clean_name,
- 'country': 'dk'
- }
-
+ if self.has_firmaapi_key:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(
+ f"{self.firmaapi_base_url}/company/search",
+ params={"q": clean_name, "limit": 1},
+ headers=self._firmaapi_headers(),
+ timeout=self.firmaapi_timeout,
+ ) as response:
+ if response.status == 200:
+ data = await response.json()
+ results = data.get("results") or []
+ if results:
+ match = results[0]
+ logger.info("β
Found CVR %s for '%s' via FirmaAPI", match.get("cvr"), company_name)
+ return self._normalize_payload(match)
+ return None
+
+ if response.status == 404:
+ return None
+
+ detail = await response.text()
+ logger.error("β FirmaAPI name lookup error %s for '%s': %s", response.status, company_name, detail[:240])
+ return None
+
+ # Legacy fallback without API key
+ params = {"search": clean_name, "country": "dk"}
async with aiohttp.ClientSession() as session:
async with session.get(
- f"{self.BASE_URL}",
+ f"{self.LEGACY_BASE_URL}",
params=params,
- timeout=aiohttp.ClientTimeout(total=10)
+ timeout=aiohttp.ClientTimeout(total=10),
) as response:
if response.status == 200:
data = await response.json()
-
- if data and 'vat' in data:
- logger.info(f"β
Found CVR {data['vat']} for '{company_name}'")
- return {
- 'cvr': data.get('vat'),
- 'name': data.get('name'),
- 'address': data.get('address'),
- 'city': data.get('city'),
- 'zipcode': data.get('zipcode'),
- 'country': data.get('country'),
- 'phone': data.get('phone'),
- 'email': data.get('email'),
- 'vat': data.get('vat'),
- 'status': data.get('status')
- }
-
- elif response.status == 404:
- logger.warning(f"β οΈ No CVR found for '{company_name}'")
- return None
-
- else:
- logger.error(f"β CVR API error {response.status} for '{company_name}'")
- return None
-
+ if data and "vat" in data:
+ return self._normalize_payload(data)
+ return None
+
except asyncio.TimeoutError:
logger.error(f"β±οΈ CVR API timeout for '{company_name}'")
return None
@@ -99,33 +139,39 @@ class CVRService:
return None
try:
+ if self.has_firmaapi_key:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(
+ f"{self.firmaapi_base_url}/company/{cvr_clean}",
+ headers=self._firmaapi_headers(),
+ timeout=self.firmaapi_timeout,
+ ) as response:
+ if response.status == 200:
+ data = await response.json()
+ logger.info("β
Validated CVR %s via FirmaAPI", cvr_clean)
+ return self._normalize_payload(data)
+
+ if response.status in (400, 404):
+ return None
+
+ detail = await response.text()
+ logger.error("β FirmaAPI CVR lookup error %s for %s: %s", response.status, cvr_clean, detail[:240])
+ return None
+
+ # Legacy fallback without API key
async with aiohttp.ClientSession() as session:
async with session.get(
- f"{self.BASE_URL}",
- params={'vat': cvr_clean, 'country': 'dk'},
- timeout=aiohttp.ClientTimeout(total=10)
+ f"{self.LEGACY_BASE_URL}",
+ params={"vat": cvr_clean, "country": "dk"},
+ timeout=aiohttp.ClientTimeout(total=10),
) as response:
if response.status == 200:
data = await response.json()
-
- if data and 'vat' in data:
- logger.info(f"β
Validated CVR {cvr_clean}")
- return {
- 'cvr': data.get('vat'),
- 'name': data.get('name'),
- 'address': data.get('address'),
- 'city': data.get('city'),
- 'zipcode': data.get('zipcode'),
- 'postal_code': data.get('zipcode'), # Alias for consistency
- 'country': data.get('country'),
- 'phone': data.get('phone'),
- 'email': data.get('email'),
- 'vat': data.get('vat'),
- 'status': data.get('status')
- }
-
+ if data and "vat" in data:
+ logger.info("β
Validated CVR %s via legacy CVR API", cvr_clean)
+ return self._normalize_payload(data)
return None
-
+
except Exception as e:
logger.error(f"β CVR validation error for {cvr_number}: {e}")
return None
diff --git a/app/services/email_service.py b/app/services/email_service.py
index c93e839..0697306 100644
--- a/app/services/email_service.py
+++ b/app/services/email_service.py
@@ -731,12 +731,9 @@ class EmailService:
Priority:
1) First References token (root message id)
2) In-Reply-To
- 3) Message-ID
+ 3) Explicit provider thread key (e.g. Graph conversationId)
+ 4) Message-ID
"""
- explicit_thread_key = self._normalize_message_id_value(email_data.get("thread_key"))
- if explicit_thread_key:
- return explicit_thread_key
-
reference_ids = self._extract_reference_ids(email_data.get("email_references"))
if reference_ids:
return reference_ids[0]
@@ -745,6 +742,10 @@ class EmailService:
if in_reply_to:
return in_reply_to
+ explicit_thread_key = self._normalize_message_id_value(email_data.get("thread_key"))
+ if explicit_thread_key:
+ return explicit_thread_key
+
return self._normalize_message_id_value(email_data.get("message_id"))
def _parse_email_date(self, date_str: str) -> datetime:
@@ -765,12 +766,100 @@ class EmailService:
query = "SELECT id FROM email_messages WHERE message_id = %s AND deleted_at IS NULL"
result = execute_query(query, (message_id,))
return len(result) > 0
+
+ def _adopt_parent_thread_key(self, email_data: Dict, derived_thread_key: Optional[str]) -> Optional[str]:
+ """Look up parent emails by References/In-Reply-To and adopt their thread_key
+ so outgoing+incoming emails share the same canonical group key."""
+
+ # Strategy 1: If the email has an explicit provider thread key (e.g. Graph
+ # conversationId), check if ANY existing email in the DB already uses it as
+ # its thread_key. ConversationId is the most reliable stable identifier
+ # across all emails in an Exchange conversation.
+ explicit_thread_key = self._normalize_message_id_value(email_data.get("thread_key"))
+ if explicit_thread_key:
+ try:
+ rows = execute_query(
+ """
+ SELECT thread_key
+ FROM email_messages
+ WHERE deleted_at IS NULL
+ AND LOWER(REGEXP_REPLACE(COALESCE(thread_key, ''), '[<>\\s]', '', 'g')) = %s
+ LIMIT 1
+ """,
+ (explicit_thread_key,),
+ )
+ if rows:
+ logger.info(
+ "π§΅ Adopted conversationId thread_key '%s' for incoming email (derived was '%s')",
+ explicit_thread_key,
+ derived_thread_key,
+ )
+ return explicit_thread_key
+ except Exception as e:
+ logger.warning("β οΈ Failed conversationId thread_key lookup: %s", e)
+
+ # Strategy 2: Look up parent emails by message_id matching our
+ # References/In-Reply-To headers.
+ parent_ids: List[str] = []
+ ref_ids = self._extract_reference_ids(email_data.get("email_references"))
+ parent_ids.extend(ref_ids)
+ in_reply = self._normalize_message_id_value(email_data.get("in_reply_to"))
+ if in_reply and in_reply not in parent_ids:
+ parent_ids.append(in_reply)
+
+ if not parent_ids:
+ # Strategy 3: No thread headers at all β try conversationId as thread_key
+ # even if no existing email has it yet (new conversation from Graph).
+ if explicit_thread_key:
+ return explicit_thread_key
+ return derived_thread_key
+
+ # Query parent emails that already have a thread_key stored
+ placeholders = ",".join(["%s"] * len(parent_ids))
+ try:
+ rows = execute_query(
+ f"""
+ SELECT thread_key
+ FROM email_messages
+ WHERE deleted_at IS NULL
+ AND thread_key IS NOT NULL
+ AND TRIM(thread_key) != ''
+ AND LOWER(REGEXP_REPLACE(COALESCE(message_id, ''), '[<>\\s]', '', 'g')) IN ({placeholders})
+ ORDER BY received_date ASC
+ LIMIT 1
+ """,
+ tuple(parent_ids),
+ )
+ if rows and rows[0].get("thread_key"):
+ adopted = self._normalize_message_id_value(rows[0]["thread_key"])
+ if adopted:
+ logger.info(
+ "π§΅ Adopted parent thread_key '%s' for incoming email (derived was '%s')",
+ adopted,
+ derived_thread_key,
+ )
+ return adopted
+ except Exception as e:
+ logger.warning("β οΈ Failed to adopt parent thread_key: %s", e)
+
+ # Fallback: prefer the explicit conversationId over derived References[0]
+ # since the References message-id often doesn't match any stored message_id
+ if explicit_thread_key:
+ return explicit_thread_key
+
+ return derived_thread_key
async def save_email(self, email_data: Dict) -> Optional[int]:
"""Save email to database"""
try:
thread_key = self._derive_thread_key(email_data)
+ # When this email is a reply, look up the parent email(s) by
+ # message_id matching our References/In-Reply-To. If the parent
+ # already has a thread_key stored, adopt it so both emails share the
+ # same canonical key and are grouped in the same visual thread.
+ thread_key = self._adopt_parent_thread_key(email_data, thread_key)
+
try:
query = """
INSERT INTO email_messages
diff --git a/app/services/email_workflow_service.py b/app/services/email_workflow_service.py
index 2c7d48f..e3a788c 100644
--- a/app/services/email_workflow_service.py
+++ b/app/services/email_workflow_service.py
@@ -11,10 +11,12 @@ import re
import json
import hashlib
import shutil
+import io
from pathlib import Path
from decimal import Decimal
+from uuid import uuid4
-from app.core.database import execute_query, execute_insert, execute_update
+from app.core.database import execute_query, execute_insert, execute_update, table_has_column
from app.core.config import settings
from app.services.email_activity_logger import email_activity_logger
@@ -37,6 +39,8 @@ class EmailWorkflowService:
'bankruptcy',
'recording'
}
+
+ _SCAN_TOKEN_PATTERN = re.compile(r'\bBMCSCAN-[A-Z0-9-]{10,100}\b', re.IGNORECASE)
async def execute_workflows(self, email_data: Dict) -> Dict:
"""
@@ -91,11 +95,16 @@ class EmailWorkflowService:
logger.info("β
Bankruptcy system workflow executed successfully")
# Special System Workflow: Helpdesk SAG routing
- # - If SAG/trΓ₯d-hint findes => forsΓΈg altid routing til eksisterende sag
+ # - If SAG/trΓ₯d-hint findes => forsΓΈg routing til eksisterende sag
+ # - Newsletters/spam skip routing ENTIRELY (even with thread hints)
# - Uden hints: brug klassifikationsgating som fΓΈr
+ HARD_SKIP = {'newsletter', 'spam'}
should_try_helpdesk = (
- classification not in self.HELPDESK_SKIP_CLASSIFICATIONS
- or has_hint
+ classification not in HARD_SKIP
+ and (
+ classification not in self.HELPDESK_SKIP_CLASSIFICATIONS
+ or has_hint
+ )
)
if should_try_helpdesk:
@@ -223,12 +232,16 @@ class EmailWorkflowService:
return domain or None
def has_helpdesk_routing_hint(self, email_data: Dict) -> bool:
- """Return True when email has explicit routing hints (SAG or thread headers/key)."""
- if self._extract_sag_id(email_data):
+ """Return True when email has explicit routing hints (SAG tag, BMCid, or reply headers).
+
+ NOTE: A bare thread_key (Graph conversationId) is NOT a routing hint
+ because every Graph email has one, including newsletters and spam.
+ Only actual reply indicators (In-Reply-To, References), explicit
+ SAG tags, or BMCid markers count as hints."""
+ if self._extract_bmc_id(email_data):
return True
- explicit_thread_key = self._normalize_message_id(email_data.get('thread_key'))
- if explicit_thread_key:
+ if self._extract_sag_id(email_data):
return True
if self._normalize_message_id(email_data.get('in_reply_to')):
@@ -239,7 +252,33 @@ class EmailWorkflowService:
return False
+ def _extract_bmc_id(self, email_data: Dict) -> Optional[Dict[str, Any]]:
+ """Extract structured BMCid from email body/subject.
+
+ Returns dict with 'sag_id' (int) and 'thread_suffix' (str, e.g. '472193')
+ or None if no BMCid is found.
+ """
+ candidates = [
+ email_data.get('body_html') or '',
+ email_data.get('body_text') or '',
+ email_data.get('subject') or '',
+ ]
+ pattern = r'\bBMCid\s*:\s*s(\d+)t(\d+)\b'
+ for value in candidates:
+ match = re.search(pattern, value, re.IGNORECASE)
+ if match:
+ return {
+ 'sag_id': int(match.group(1)),
+ 'thread_suffix': match.group(2),
+ }
+ return None
+
def _extract_sag_id(self, email_data: Dict) -> Optional[int]:
+ # First try structured BMCid (most reliable)
+ bmc_id = self._extract_bmc_id(email_data)
+ if bmc_id:
+ return bmc_id['sag_id']
+
candidates = [
email_data.get('subject') or '',
email_data.get('in_reply_to') or '',
@@ -249,14 +288,15 @@ class EmailWorkflowService:
]
# Accept both strict and human variants used in real subjects, e.g.:
+ # - [SAG-53] (hidden/subject prefix)
# - SAG-53
# - SAG #53
# - Sag 53
sag_patterns = [
+ r'\[SAG-(\d+)\]',
r'\bSAG-(\d+)\b',
r'\bSAG\s*#\s*(\d+)\b',
r'\bSAG\s+(\d+)\b',
- r'\bBMCid\s*:\s*s(\d+)t\d+\b',
]
for value in candidates:
@@ -299,10 +339,7 @@ class EmailWorkflowService:
return list(dict.fromkeys(tokens))
def _derive_thread_key(self, email_data: Dict) -> Optional[str]:
- """Derive stable conversation key: root References -> In-Reply-To -> Message-ID."""
- explicit = self._normalize_message_id(email_data.get('thread_key'))
- if explicit:
- return explicit
+ """Derive stable conversation key: root References -> In-Reply-To -> explicit -> Message-ID."""
ref_ids = self._extract_reference_message_ids(email_data.get('email_references'))
if ref_ids:
@@ -312,6 +349,10 @@ class EmailWorkflowService:
if in_reply_to:
return in_reply_to
+ explicit = self._normalize_message_id(email_data.get('thread_key'))
+ if explicit:
+ return explicit
+
return self._normalize_message_id(email_data.get('message_id'))
def _find_sag_id_from_thread_key(self, thread_key: Optional[str]) -> Optional[int]:
@@ -326,11 +367,14 @@ class EmailWorkflowService:
FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id
WHERE em.deleted_at IS NULL
- AND LOWER(TRIM(COALESCE(em.thread_key, ''))) = %s
+ AND (
+ LOWER(REGEXP_REPLACE(COALESCE(em.thread_key, ''), '[<>\\s]', '', 'g')) = %s
+ OR LOWER(REGEXP_REPLACE(COALESCE(em.message_id, ''), '[<>\\s]', '', 'g')) = %s
+ )
ORDER BY se.created_at DESC
LIMIT 1
""",
- (thread_key,)
+ (thread_key, thread_key)
)
return rows[0]['sag_id'] if rows else None
except Exception:
@@ -356,11 +400,23 @@ class EmailWorkflowService:
)
return rows[0]['sag_id'] if rows else None
+ # Sender domains that should never trigger customer-domain SAG creation.
+ # Includes own sending domain and common automated senders.
+ _IGNORED_SENDER_DOMAINS = {
+ 'bmcnetworks.dk',
+ 'bmchub.local',
+ }
+
def _find_customer_by_domain(self, domain: str) -> Optional[Dict[str, Any]]:
if not domain:
return None
domain = domain.lower().strip()
+
+ # Never match the system's own sending domain as a customer
+ if domain in self._IGNORED_SENDER_DOMAINS:
+ return None
+
domain_alt = domain[4:] if domain.startswith('www.') else f"www.{domain}"
query = """
@@ -377,6 +433,114 @@ class EmailWorkflowService:
rows = execute_query(query, (domain, domain_alt))
return rows[0] if rows else None
+ def _find_thread_key_by_bmc_suffix(self, sag_id: int, thread_suffix: str) -> Optional[str]:
+ """Find the thread_key of an outgoing email whose BMCid matches s{sag_id}t{thread_suffix}."""
+ try:
+ # Legacy compatibility: older outbound emails used t001 when the
+ # provisional thread key was unknown. In that case, pick the most
+ # recent outbound thread key in the same case as best effort.
+ if str(thread_suffix) == '001':
+ fallback = execute_query(
+ """
+ SELECT em.thread_key
+ FROM sag_emails se
+ JOIN email_messages em ON em.id = se.email_id
+ WHERE se.sag_id = %s
+ AND em.deleted_at IS NULL
+ AND em.thread_key IS NOT NULL
+ AND TRIM(em.thread_key) != ''
+ AND LOWER(COALESCE(em.sender_email, '')) = %s
+ ORDER BY em.received_date DESC
+ LIMIT 1
+ """,
+ (sag_id, 'noreply@bmcnetworks.dk'),
+ )
+ if fallback and fallback[0].get('thread_key'):
+ return fallback[0]['thread_key']
+
+ rows = execute_query(
+ """
+ SELECT em.thread_key
+ FROM sag_emails se
+ JOIN email_messages em ON em.id = se.email_id
+ WHERE se.sag_id = %s
+ AND em.deleted_at IS NULL
+ AND em.thread_key IS NOT NULL
+ AND TRIM(em.thread_key) != ''
+ ORDER BY em.received_date DESC
+ """,
+ (sag_id,),
+ )
+ if not rows:
+ return None
+
+ # Rebuild the BMCid suffix for each candidate thread_key
+ # and return the one that matches our target suffix.
+ for row in rows:
+ tk = row['thread_key']
+ normalized = re.sub(r"[^a-z0-9]+", "", str(tk).lower())
+ if not normalized:
+ continue
+ digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()
+ candidate_suffix = str((int(digest[:8], 16) % 900000) + 100000)
+ if candidate_suffix == thread_suffix:
+ return tk
+ return None
+ except Exception as e:
+ logger.warning("β οΈ Failed BMCid thread_key lookup: %s", e)
+ return None
+
+ def _update_email_thread_key(self, email_id: int, thread_key: str) -> None:
+ """Set the thread_key on an email so it groups correctly."""
+ execute_update(
+ "UPDATE email_messages SET thread_key = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
+ (thread_key, email_id),
+ )
+
+ async def _finalize_sag_routing(
+ self, email_id: int, email_data: Dict, sag_id: int, routing_source: str
+ ) -> Dict[str, Any]:
+ """Link an email to an existing SAG and mark as processed."""
+ case_rows = execute_query(
+ "SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
+ (sag_id,),
+ )
+ if not case_rows:
+ logger.warning("β οΈ Email %s referenced SAG-%s but case was not found", email_id, sag_id)
+ return {'status': 'skipped', 'action': 'sag_id_not_found', 'sag_id': sag_id}
+
+ case = case_rows[0]
+ self._add_helpdesk_comment(sag_id, email_data)
+ self._link_email_to_sag(sag_id, email_id)
+
+ execute_update(
+ """
+ UPDATE email_messages
+ SET linked_case_id = %s,
+ customer_id = COALESCE(customer_id, %s),
+ status = 'processed',
+ folder = 'Processed',
+ processed_at = CURRENT_TIMESTAMP,
+ auto_processed = true
+ WHERE id = %s
+ """,
+ (sag_id, case.get('customer_id'), email_id),
+ )
+
+ token_for_attach = None
+ token_route = self._resolve_scan_token_route(email_id, email_data)
+ if token_route:
+ token_for_attach = token_route.get('token')
+ self._auto_attach_scanner_email(email_id, sag_id, token_for_attach)
+
+ return {
+ 'status': 'completed',
+ 'action': 'updated_existing_sag',
+ 'sag_id': sag_id,
+ 'customer_id': case.get('customer_id'),
+ 'routing_source': routing_source,
+ }
+
def _link_email_to_sag(self, sag_id: int, email_id: int) -> None:
execute_update(
"""
@@ -389,6 +553,379 @@ class EmailWorkflowService:
(sag_id, email_id, sag_id, email_id)
)
+ def _extract_scan_tokens(self, *values: Optional[str]) -> List[str]:
+ tokens: List[str] = []
+ for value in values:
+ if not value:
+ continue
+ found = self._SCAN_TOKEN_PATTERN.findall(str(value))
+ if found:
+ tokens.extend(token.upper() for token in found)
+ return list(dict.fromkeys(tokens))
+
+ def _resolve_scan_token_route(self, email_id: int, email_data: Dict) -> Optional[Dict[str, Any]]:
+ text_tokens = self._extract_scan_tokens(
+ email_data.get('subject'),
+ email_data.get('body_text'),
+ email_data.get('body_html'),
+ email_data.get('in_reply_to'),
+ email_data.get('email_references'),
+ )
+
+ filename_tokens: List[str] = []
+ attachment_content_tokens: List[str] = []
+ try:
+ attachment_rows = execute_query(
+ """
+ SELECT filename, content_type, content_data, file_path
+ FROM email_attachments
+ WHERE email_id = %s
+ ORDER BY id ASC
+ """,
+ (email_id,),
+ ) or []
+ for row in attachment_rows:
+ filename_tokens.extend(self._extract_scan_tokens(row.get('filename')))
+ attachment_content_tokens.extend(
+ self._extract_scan_tokens_from_attachment(
+ filename=row.get('filename'),
+ content_type=row.get('content_type'),
+ content_data=row.get('content_data'),
+ file_path=row.get('file_path'),
+ )
+ )
+ except Exception as exc:
+ logger.warning("β οΈ Failed to inspect attachment filenames for scan token: %s", exc)
+
+ all_tokens = list(dict.fromkeys(text_tokens + filename_tokens + attachment_content_tokens))
+ if not all_tokens:
+ return self._resolve_scan_route_from_scanner_headers(email_data)
+
+ placeholders = ','.join(['%s'] * len(all_tokens))
+ try:
+ rows = execute_query(
+ f"""
+ SELECT token, sag_id, token_type
+ FROM sag_document_tokens
+ WHERE token IN ({placeholders})
+ AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
+ ORDER BY consumed_at IS NULL DESC, created_at DESC
+ LIMIT 1
+ """,
+ tuple(all_tokens),
+ )
+ if rows:
+ return rows[0]
+
+ # Fallback for scanner workflows where token only exists in barcode image
+ # and therefore not in plain text metadata.
+ return self._resolve_scan_route_from_scanner_headers(email_data)
+ except Exception as exc:
+ logger.warning("β οΈ Scan token lookup failed: %s", exc)
+ return self._resolve_scan_route_from_scanner_headers(email_data)
+
+ def _extract_scan_tokens_from_attachment(
+ self,
+ filename: Optional[str],
+ content_type: Optional[str],
+ content_data: Optional[Any],
+ file_path: Optional[str],
+ ) -> List[str]:
+ tokens: List[str] = []
+
+ payload: Optional[bytes] = None
+ if content_data is not None:
+ try:
+ payload = bytes(content_data)
+ except Exception:
+ payload = None
+
+ if payload is None and file_path:
+ try:
+ payload = Path(file_path).read_bytes()
+ except Exception:
+ payload = None
+
+ if not payload:
+ return tokens
+
+ # 1) Cheap text extraction directly from bytes catches tokens in OCR-layer PDFs,
+ # plain text files, or metadata-rich attachments.
+ try:
+ sample = payload[:1_500_000]
+ tokens.extend(self._extract_scan_tokens(sample.decode('utf-8', errors='ignore')))
+ tokens.extend(self._extract_scan_tokens(sample.decode('latin-1', errors='ignore')))
+ except Exception:
+ pass
+
+ ext = (Path(str(filename or '')).suffix or '').lower().strip('.')
+ ctype = (content_type or '').lower()
+
+ # 2) PDF text-layer extraction (when available) for scanned documents with OCR.
+ if ext == 'pdf' or 'pdf' in ctype:
+ try:
+ from pypdf import PdfReader # type: ignore
+
+ reader = PdfReader(io.BytesIO(payload))
+ text_chunks: List[str] = []
+ for page in reader.pages[:5]:
+ extracted = page.extract_text() or ''
+ if extracted:
+ text_chunks.append(extracted)
+ if text_chunks:
+ tokens.extend(self._extract_scan_tokens("\n".join(text_chunks)))
+ except Exception:
+ pass
+
+ # 3) Decode barcode directly from scanned attachments.
+ # This catches cases where BMCSCAN exists only as a barcode image.
+ try:
+ if ext == 'pdf' or 'pdf' in ctype:
+ tokens.extend(self._extract_scan_tokens_from_pdf_barcode(payload))
+ else:
+ tokens.extend(self._extract_scan_tokens_from_image_barcode(payload))
+ except Exception:
+ pass
+
+ return list(dict.fromkeys(token.upper() for token in tokens if token))
+
+ def _extract_scan_tokens_from_image_barcode(self, payload: bytes) -> List[str]:
+ try:
+ from PIL import Image # type: ignore
+ from pyzbar.pyzbar import decode as zbar_decode # type: ignore
+ except Exception:
+ return []
+
+ try:
+ image = Image.open(io.BytesIO(payload))
+ except Exception:
+ return []
+
+ decoded_tokens: List[str] = []
+ variants = [image]
+ try:
+ variants.append(image.convert('L'))
+ variants.append(image.convert('L').point(lambda p: 255 if p > 140 else 0))
+ except Exception:
+ pass
+
+ for variant in variants:
+ try:
+ for item in zbar_decode(variant):
+ raw = item.data.decode('utf-8', errors='ignore')
+ decoded_tokens.extend(self._extract_scan_tokens(raw))
+ except Exception:
+ continue
+
+ return list(dict.fromkeys(decoded_tokens))
+
+ def _extract_scan_tokens_from_pdf_barcode(self, payload: bytes) -> List[str]:
+ try:
+ import pypdfium2 as pdfium # type: ignore
+ from pyzbar.pyzbar import decode as zbar_decode # type: ignore
+ except Exception:
+ return []
+
+ decoded_tokens: List[str] = []
+
+ try:
+ doc = pdfium.PdfDocument(io.BytesIO(payload))
+ except Exception:
+ return []
+
+ page_count = min(len(doc), 3)
+ for page_index in range(page_count):
+ page = None
+ try:
+ page = doc.get_page(page_index)
+ bitmap = page.render(scale=2.2)
+ pil_image = bitmap.to_pil()
+
+ for variant in (pil_image, pil_image.convert('L')):
+ for item in zbar_decode(variant):
+ raw = item.data.decode('utf-8', errors='ignore')
+ decoded_tokens.extend(self._extract_scan_tokens(raw))
+ except Exception:
+ continue
+ finally:
+ try:
+ if page is not None:
+ page.close()
+ except Exception:
+ pass
+
+ return list(dict.fromkeys(decoded_tokens))
+
+ def _resolve_scan_route_from_scanner_headers(self, email_data: Dict) -> Optional[Dict[str, Any]]:
+ """Infer case route from scanner-generated message-id timestamps.
+
+ Some scanner/MFP flows only include the barcode token inside the attached image/PDF,
+ while headers contain a timestamped local message-id such as
+ `<1.20260401075731@172.16.31.35>`. We map that timestamp to the nearest recent,
+ unconsumed document token.
+ """
+
+ header_values = [
+ email_data.get('in_reply_to'),
+ email_data.get('email_references'),
+ email_data.get('message_id'),
+ email_data.get('thread_key'),
+ ]
+
+ candidates: List[datetime] = []
+ ts_pattern = re.compile(r'(20\d{12})')
+
+ for raw in header_values:
+ if not raw:
+ continue
+ for match in ts_pattern.findall(str(raw)):
+ try:
+ candidates.append(datetime.strptime(match, "%Y%m%d%H%M%S"))
+ except ValueError:
+ continue
+
+ if not candidates:
+ return None
+
+ for ts in candidates:
+ try:
+ rows = execute_query(
+ """
+ SELECT token, sag_id, token_type, created_at
+ FROM sag_document_tokens
+ WHERE consumed_at IS NULL
+ AND created_at BETWEEN %s::timestamp - INTERVAL '90 minutes'
+ AND %s::timestamp + INTERVAL '20 minutes'
+ ORDER BY ABS(EXTRACT(EPOCH FROM (created_at - %s::timestamp))) ASC,
+ CASE WHEN token_type = 'work_order' THEN 0 ELSE 1 END,
+ id DESC
+ LIMIT 1
+ """,
+ (ts, ts, ts),
+ ) or []
+ if rows:
+ row = rows[0]
+ logger.info(
+ "π Inferred scanner route via header timestamp %s -> SAG-%s (%s)",
+ ts.isoformat(),
+ row.get('sag_id'),
+ row.get('token'),
+ )
+ return {
+ 'token': row.get('token'),
+ 'sag_id': row.get('sag_id'),
+ 'token_type': row.get('token_type'),
+ }
+ except Exception as exc:
+ logger.warning("β οΈ Scanner header timestamp route lookup failed: %s", exc)
+
+ return None
+
+ def _copy_email_attachments_to_case(self, email_id: int, sag_id: int, source_token: Optional[str]) -> int:
+ attachments = execute_query(
+ """
+ SELECT filename, content_type, size_bytes, file_path, content_data
+ FROM email_attachments
+ WHERE email_id = %s
+ ORDER BY id ASC
+ """,
+ (email_id,),
+ ) or []
+ if not attachments:
+ return 0
+
+ upload_base = Path(settings.UPLOAD_DIR).resolve()
+ (upload_base / "sag_files").mkdir(parents=True, exist_ok=True)
+
+ has_source_email = table_has_column("sag_files", "source_email_id")
+ has_source_type = table_has_column("sag_files", "source_type")
+ has_source_token = table_has_column("sag_files", "source_token")
+
+ copied = 0
+ for attachment in attachments:
+ filename = Path(attachment.get('filename') or 'scanned-document.bin').name
+
+ if has_source_email:
+ existing = execute_query(
+ """
+ SELECT 1
+ FROM sag_files
+ WHERE sag_id = %s
+ AND source_email_id = %s
+ AND filename = %s
+ LIMIT 1
+ """,
+ (sag_id, email_id, filename),
+ ) or []
+ if existing:
+ continue
+
+ payload = attachment.get('content_data')
+ if payload is None and attachment.get('file_path'):
+ try:
+ payload = Path(attachment['file_path']).read_bytes()
+ except Exception as exc:
+ logger.warning("β οΈ Could not read attachment file (%s): %s", filename, exc)
+ continue
+
+ if payload is None:
+ continue
+
+ raw_payload = bytes(payload)
+ stored_name = f"sag_files/{uuid4().hex}_{filename}"
+ target_path = upload_base / stored_name
+
+ try:
+ target_path.write_bytes(raw_payload)
+ except Exception as exc:
+ logger.warning("β οΈ Could not write case file from attachment (%s): %s", filename, exc)
+ continue
+
+ columns = ["sag_id", "filename", "content_type", "size_bytes", "stored_name"]
+ values: List[Any] = [
+ sag_id,
+ filename,
+ attachment.get('content_type') or 'application/octet-stream',
+ attachment.get('size_bytes') or len(raw_payload),
+ stored_name,
+ ]
+ if has_source_email:
+ columns.append("source_email_id")
+ values.append(email_id)
+ if has_source_type:
+ columns.append("source_type")
+ values.append("scanner_email")
+ if has_source_token:
+ columns.append("source_token")
+ values.append(source_token)
+
+ execute_query(
+ f"INSERT INTO sag_files ({', '.join(columns)}) VALUES ({', '.join(['%s'] * len(values))})",
+ tuple(values),
+ )
+ copied += 1
+
+ return copied
+
+ def _auto_attach_scanner_email(self, email_id: int, sag_id: int, token: Optional[str]) -> None:
+ try:
+ copied = self._copy_email_attachments_to_case(email_id, sag_id, token)
+ if copied > 0:
+ logger.info("π Auto-attached %s attachment(s) from email %s to SAG-%s", copied, email_id, sag_id)
+
+ if token:
+ execute_update(
+ """
+ UPDATE sag_document_tokens
+ SET consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP),
+ consumed_email_id = COALESCE(consumed_email_id, %s)
+ WHERE token = %s
+ """,
+ (email_id, token),
+ )
+ except Exception as exc:
+ logger.warning("β οΈ Scanner auto-attach failed for email %s: %s", email_id, exc)
+
def _strip_quoted_email_text(self, body_text: str) -> str:
"""Return only the newest reply content (remove quoted history/signatures)."""
if not body_text:
@@ -490,6 +1027,41 @@ class EmailWorkflowService:
sag_id_from_thread_key = self._find_sag_id_from_thread_key(derived_thread_key)
sag_id_from_thread = self._find_sag_id_from_thread_headers(email_data)
sag_id_from_tag = self._extract_sag_id(email_data)
+ scan_token_route = self._resolve_scan_token_route(email_id, email_data)
+
+ if scan_token_route and scan_token_route.get('sag_id'):
+ matched_sag_id = int(scan_token_route['sag_id'])
+ logger.info("π Scan token matched email %s to SAG-%s", email_id, matched_sag_id)
+ return await self._finalize_sag_routing(email_id, email_data, matched_sag_id, 'scan_token')
+
+ # Priority 0: BMCid is the most reliable signal β it's our own hidden
+ # marker embedded in every outgoing case email. When present, it
+ # provides the sag_id directly and the thread_suffix lets us adopt
+ # the correct thread_key for multi-thread SAGs.
+ bmc_id = self._extract_bmc_id(email_data)
+ if bmc_id:
+ bmc_sag_id = bmc_id['sag_id']
+ bmc_thread_suffix = bmc_id['thread_suffix']
+ # Look up the thread_key of the outgoing email whose BMCid matches
+ bmc_thread_key = self._find_thread_key_by_bmc_suffix(bmc_sag_id, bmc_thread_suffix)
+ if bmc_thread_key:
+ # Adopt the outgoing email's thread_key so reply groups correctly
+ self._update_email_thread_key(email_id, bmc_thread_key)
+ logger.info(
+ "π BMCid s%st%s matched β SAG-%s (thread_key=%s)",
+ bmc_sag_id, bmc_thread_suffix, bmc_sag_id, bmc_thread_key,
+ )
+ sag_id = bmc_sag_id
+ routing_source = 'bmc_id'
+ # Skip the remaining priority chain β BMCid is authoritative
+ return await self._finalize_sag_routing(email_id, email_data, sag_id, routing_source)
+
+ # Fallback: try the explicit provider thread key (e.g. Graph conversationId)
+ # separately when the derived key (References[0]) differs from it.
+ provider_thread_key = self._normalize_message_id(email_data.get('thread_key'))
+ sag_id_from_provider = None
+ if provider_thread_key and provider_thread_key != derived_thread_key:
+ sag_id_from_provider = self._find_sag_id_from_thread_key(provider_thread_key)
routing_source = None
sag_id = None
@@ -512,6 +1084,11 @@ class EmailWorkflowService:
routing_source = 'thread_headers'
logger.info("π Matched email %s to SAG-%s via thread headers", email_id, sag_id)
+ if sag_id_from_provider and not sag_id:
+ sag_id = sag_id_from_provider
+ routing_source = 'provider_thread_key'
+ logger.info("π§΅ Matched email %s to SAG-%s via provider thread key (conversationId)", email_id, sag_id)
+
if sag_id_from_tag:
if sag_id and sag_id != sag_id_from_tag:
logger.warning(
@@ -527,40 +1104,7 @@ class EmailWorkflowService:
# 1) Existing SAG via subject/headers
if sag_id:
- case_rows = execute_query(
- "SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
- (sag_id,)
- )
-
- if not case_rows:
- logger.warning("β οΈ Email %s referenced SAG-%s but case was not found", email_id, sag_id)
- return {'status': 'skipped', 'action': 'sag_id_not_found', 'sag_id': sag_id}
-
- case = case_rows[0]
- self._add_helpdesk_comment(sag_id, email_data)
- self._link_email_to_sag(sag_id, email_id)
-
- execute_update(
- """
- UPDATE email_messages
- SET linked_case_id = %s,
- customer_id = COALESCE(customer_id, %s),
- status = 'processed',
- folder = 'Processed',
- processed_at = CURRENT_TIMESTAMP,
- auto_processed = true
- WHERE id = %s
- """,
- (sag_id, case.get('customer_id'), email_id)
- )
-
- return {
- 'status': 'completed',
- 'action': 'updated_existing_sag',
- 'sag_id': sag_id,
- 'customer_id': case.get('customer_id'),
- 'routing_source': routing_source
- }
+ return await self._finalize_sag_routing(email_id, email_data, sag_id, routing_source)
# 2) No SAG id -> create only if sender domain belongs to known customer
sender_domain = self._extract_sender_domain(email_data)
@@ -588,6 +1132,7 @@ class EmailWorkflowService:
(case['id'], customer['id'], email_id)
)
+ self._auto_attach_scanner_email(email_id, case['id'], None)
logger.info("β
Created SAG-%s from email %s for customer %s", case['id'], email_id, customer['id'])
return {
'status': 'completed',
diff --git a/app/services/reminder_notification_service.py b/app/services/reminder_notification_service.py
index f70d267..ee1e98d 100644
--- a/app/services/reminder_notification_service.py
+++ b/app/services/reminder_notification_service.py
@@ -102,7 +102,7 @@ class ReminderNotificationService:
)
# Get user email
- user_query = "SELECT email FROM users WHERE id = %s"
+ user_query = "SELECT email FROM users WHERE user_id = %s"
user = execute_query(user_query, (user_id,))
user_email = user[0]['email'] if user else None
diff --git a/app/services/vaultwarden_service.py b/app/services/vaultwarden_service.py
new file mode 100644
index 0000000..f2b9595
--- /dev/null
+++ b/app/services/vaultwarden_service.py
@@ -0,0 +1,185 @@
+import logging
+from typing import Any, Dict, List, Optional
+from urllib.parse import quote
+
+import httpx
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class VaultwardenServiceError(Exception):
+ pass
+
+
+def _is_configured() -> bool:
+ return bool((settings.VAULTWARDEN_BASE_URL or "").strip()) and bool((settings.VAULTWARDEN_API_TOKEN or "").strip())
+
+
+def _base_url() -> str:
+ return (settings.VAULTWARDEN_BASE_URL or "").strip().rstrip("/")
+
+
+def _headers() -> Dict[str, str]:
+ token = (settings.VAULTWARDEN_API_TOKEN or "").strip()
+ return {
+ "Authorization": f"Bearer {token}",
+ "X-API-Token": token,
+ "Accept": "application/json",
+ }
+
+
+def _extract_from_cipher(payload: dict) -> Optional[dict]:
+ if not isinstance(payload, dict):
+ return None
+
+ login = payload.get("login") or payload.get("Login") or {}
+ if not isinstance(login, dict):
+ login = {}
+
+ username = login.get("username") or login.get("Username")
+ password = login.get("password") or login.get("Password")
+ totp = login.get("totp") or login.get("Totp")
+
+ uris = login.get("uris") or login.get("Uris") or []
+ url = None
+ if isinstance(uris, list) and uris:
+ first = uris[0] or {}
+ if isinstance(first, dict):
+ url = first.get("uri") or first.get("Uri")
+
+ if not any([username, password, totp, url, payload.get("notes") or payload.get("Notes")]):
+ return None
+
+ return {
+ "item_id": str(payload.get("id") or payload.get("Id") or "") or None,
+ "item_name": payload.get("name") or payload.get("Name"),
+ "username": username,
+ "password": password,
+ "totp": totp,
+ "notes": payload.get("notes") or payload.get("Notes"),
+ "url": url,
+ }
+
+
+def _extract_from_custom_payload(payload: Any) -> Optional[dict]:
+ if isinstance(payload, dict):
+ direct = {
+ "item_id": payload.get("item_id") or payload.get("id"),
+ "item_name": payload.get("item_name") or payload.get("name"),
+ "username": payload.get("username"),
+ "password": payload.get("password"),
+ "totp": payload.get("totp") or payload.get("otp"),
+ "notes": payload.get("notes"),
+ "url": payload.get("url"),
+ }
+ if any(direct.values()):
+ return direct
+
+ nested = payload.get("data")
+ if isinstance(nested, dict):
+ nested_res = _extract_from_custom_payload(nested)
+ if nested_res:
+ return nested_res
+
+ cipher_res = _extract_from_cipher(payload)
+ if cipher_res:
+ return cipher_res
+
+ if isinstance(payload, list):
+ for item in payload:
+ extracted = _extract_from_custom_payload(item)
+ if extracted:
+ return extracted
+
+ return None
+
+
+async def _get_json(client: httpx.AsyncClient, url: str) -> Any:
+ response = await client.get(url)
+ if response.status_code == 404:
+ return None
+ response.raise_for_status()
+ if not response.content:
+ return None
+ return response.json()
+
+
+async def resolve_vault_credentials(
+ *,
+ preferred_item_id: Optional[str],
+ fallback_item_ids: List[str],
+ search_hint: Optional[str],
+) -> dict:
+ if not _is_configured():
+ return {
+ "status": "unavailable",
+ "configured": False,
+ "message": "Vaultwarden er ikke konfigureret.",
+ "checked_item_ids": [],
+ "credential": None,
+ }
+
+ checked_item_ids: List[str] = []
+ item_id_candidates = [preferred_item_id] + list(fallback_item_ids)
+ deduped_candidates: List[str] = []
+ seen = set()
+ for item_id in item_id_candidates:
+ candidate = (item_id or "").strip()
+ if not candidate or candidate in seen:
+ continue
+ seen.add(candidate)
+ deduped_candidates.append(candidate)
+
+ timeout = httpx.Timeout(connect=6.0, read=10.0, write=10.0, pool=6.0)
+ async with httpx.AsyncClient(timeout=timeout, headers=_headers(), follow_redirects=True) as client:
+ base = _base_url()
+
+ for item_id in deduped_candidates:
+ checked_item_ids.append(item_id)
+ try:
+ payload = await _get_json(client, f"{base}/api/ciphers/{quote(item_id)}")
+ extracted = _extract_from_custom_payload(payload)
+ if extracted:
+ return {
+ "status": "ok",
+ "configured": True,
+ "message": "Vault-opslag gennemfoert.",
+ "checked_item_ids": checked_item_ids,
+ "credential": extracted,
+ }
+ except httpx.HTTPError as exc:
+ logger.warning("Vaultwarden item lookup failed for id=%s: %s", item_id, exc)
+
+ hint = (search_hint or "").strip()
+ if hint:
+ encoded_hint = quote(hint)
+ search_endpoints = [
+ f"{base}/api/links/credentials?search={encoded_hint}",
+ f"{base}/api/ciphers?search={encoded_hint}",
+ f"{base}/api/ciphers?url={encoded_hint}",
+ ]
+
+ for endpoint in search_endpoints:
+ try:
+ payload = await _get_json(client, endpoint)
+ extracted = _extract_from_custom_payload(payload)
+ if extracted:
+ return {
+ "status": "ok",
+ "configured": True,
+ "message": "Vault-opslag gennemfoert.",
+ "checked_item_ids": checked_item_ids,
+ "credential": extracted,
+ }
+ except httpx.HTTPError as exc:
+ logger.info("Vaultwarden search endpoint failed (%s): %s", endpoint, exc)
+
+ return {
+ "status": "not_found",
+ "configured": True,
+ "message": "Ingen vault credentials fundet for linket.",
+ "checked_item_ids": checked_item_ids,
+ "credential": None,
+ }
diff --git a/app/settings/backend/router.py b/app/settings/backend/router.py
index a47c026..4fee5a9 100644
--- a/app/settings/backend/router.py
+++ b/app/settings/backend/router.py
@@ -221,6 +221,47 @@ async def update_setting(key: str, setting: SettingUpdate):
)
)
+ # Mission camera settings may not exist on older hubs before migration.
+ # AnyDesk settings may not exist on older hubs β auto-create on first save
+ _anydesk_keys = {
+ "anydesk_api_token": ("integrations", "AnyDesk API token", "string", False),
+ "anydesk_license_id": ("integrations", "AnyDesk license ID", "string", False),
+ "anydesk_read_only": ("integrations", "AnyDesk read-only mode", "boolean", True),
+ "anydesk_dry_run": ("integrations", "AnyDesk dry-run mode", "boolean", True),
+ }
+ if not result and key in _anydesk_keys:
+ category, description, value_type, is_public = _anydesk_keys[key]
+ result = execute_query(
+ """
+ INSERT INTO settings (key, value, category, description, value_type, is_public)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ ON CONFLICT (key)
+ DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
+ RETURNING *
+ """,
+ (key, setting.value, category, description, value_type, is_public),
+ )
+
+ _label_printer_keys = {
+ "label_printer_enabled": ("integrations", "Enable direct label printing", "boolean", True),
+ "label_printer_model": ("integrations", "Brother printer model for direct labels", "string", True),
+ "label_printer_host": ("integrations", "Brother printer host/IP", "string", True),
+ "label_printer_port": ("integrations", "Brother printer TCP port", "integer", True),
+ "label_printer_label_size": ("integrations", "Brother label size code", "string", True),
+ }
+ if not result and key in _label_printer_keys:
+ category, description, value_type, is_public = _label_printer_keys[key]
+ result = execute_query(
+ """
+ INSERT INTO settings (key, value, category, description, value_type, is_public)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ ON CONFLICT (key)
+ DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
+ RETURNING *
+ """,
+ (key, setting.value, category, description, value_type, is_public),
+ )
+
# Mission camera settings may not exist on older hubs before migration.
if not result and key in {"mission_camera_enabled", "mission_camera_name", "mission_camera_feed_url", "mission_camera_spotlight_seconds", "mission_access_pin"}:
defaults = {
@@ -243,7 +284,7 @@ async def update_setting(key: str, setting: SettingUpdate):
""",
(key, setting.value, category, description, value_type, is_public),
)
-
+
if not result:
raise HTTPException(status_code=404, detail="Setting not found")
@@ -259,6 +300,14 @@ async def get_setting_categories():
return [row['category'] for row in result] if result else []
+@router.get("/settings/migrations/status", tags=["Settings"])
+async def get_migration_statuses_api():
+ """Expose migration status via API router (served under /api/v1)."""
+ from app.settings.backend.views import migration_statuses
+
+ return migration_statuses()
+
+
@router.post("/settings/sync-from-env", tags=["Settings"])
async def sync_settings_from_env():
"""Sync settings from .env file into database (only updates empty values)"""
diff --git a/app/settings/backend/views.py b/app/settings/backend/views.py
index d1365e4..1b52bf0 100644
--- a/app/settings/backend/views.py
+++ b/app/settings/backend/views.py
@@ -4,6 +4,7 @@ Settings Frontend Views
from datetime import datetime
from pathlib import Path
+import re
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
@@ -15,6 +16,183 @@ from app.core.database import get_db_connection, release_db_connection, execute_
router = APIRouter()
templates = Jinja2Templates(directory="app")
+CREATE_TABLE_RE = re.compile(
+ r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(",
+ re.IGNORECASE,
+)
+ADD_COLUMN_RE = re.compile(
+ r"ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)",
+ re.IGNORECASE,
+)
+CREATE_INDEX_RE = re.compile(
+ r"CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s+ON\s+([A-Za-z_][A-Za-z0-9_]*)",
+ re.IGNORECASE,
+)
+SKIP_COLUMN_LINE_RE = re.compile(
+ r"^(?:CONSTRAINT|PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CASE|WHEN|ELSE|END)\b",
+ re.IGNORECASE,
+)
+
+
+def _strip_sql_comments(sql: str) -> str:
+ sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.DOTALL)
+ sql = re.sub(r"--[^\n]*", "", sql)
+ return sql
+
+
+def _extract_create_table_block(sql: str, start_pos: int) -> str:
+ open_paren = sql.find("(", start_pos)
+ if open_paren == -1:
+ return ""
+
+ depth = 0
+ for idx in range(open_paren, len(sql)):
+ ch = sql[idx]
+ if ch == "(":
+ depth += 1
+ elif ch == ")":
+ depth -= 1
+ if depth == 0:
+ return sql[open_paren + 1:idx]
+ return ""
+
+
+def _parse_columns_from_create_block(block: str) -> set[str]:
+ columns: set[str] = set()
+ known_types = {
+ "serial", "bigserial", "smallint", "integer", "bigint", "numeric", "decimal", "real", "double",
+ "varchar", "character", "text", "boolean", "bool", "date", "timestamp", "time", "json", "jsonb", "uuid"
+ }
+
+ for raw_line in block.splitlines():
+ line = raw_line.strip().rstrip(",")
+ if not line:
+ continue
+ if SKIP_COLUMN_LINE_RE.match(line):
+ continue
+
+ tokens = line.replace("(", " ").split()
+ if len(tokens) < 2:
+ continue
+
+ second = tokens[1].strip().lower()
+ second_base = re.sub(r"[^a-z]", "", second)
+ if second_base and second_base not in known_types:
+ continue
+
+ match = re.match(r"^\"?([A-Za-z_][A-Za-z0-9_]*)\"?\s+", line)
+ if match:
+ columns.add(match.group(1))
+
+ return columns
+
+
+def _parse_migration_expectations(sql: str) -> tuple[set[str], set[tuple[str, str]], set[str]]:
+ expected_tables: set[str] = set()
+ expected_columns: set[tuple[str, str]] = set()
+ expected_indexes: set[str] = set()
+
+ clean_sql = _strip_sql_comments(sql)
+
+ for match in CREATE_TABLE_RE.finditer(clean_sql):
+ table_name = match.group(1)
+ expected_tables.add(table_name)
+ block = _extract_create_table_block(clean_sql, match.end() - 1)
+ for column_name in _parse_columns_from_create_block(block):
+ expected_columns.add((table_name, column_name))
+
+ for match in ADD_COLUMN_RE.finditer(clean_sql):
+ expected_columns.add((match.group(1), match.group(2)))
+
+ for match in CREATE_INDEX_RE.finditer(clean_sql):
+ expected_indexes.add(match.group(1))
+
+ return expected_tables, expected_columns, expected_indexes
+
+
+def _get_actual_schema_snapshot(conn) -> tuple[set[str], set[tuple[str, str]], set[str]]:
+ with conn.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT table_name
+ FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_type = 'BASE TABLE'
+ """
+ )
+ tables = {row[0] for row in cursor.fetchall()}
+
+ cursor.execute(
+ """
+ SELECT table_name, column_name
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ """
+ )
+ columns = {(row[0], row[1]) for row in cursor.fetchall()}
+
+ cursor.execute(
+ """
+ SELECT indexname
+ FROM pg_indexes
+ WHERE schemaname = 'public'
+ """
+ )
+ indexes = {row[0] for row in cursor.fetchall()}
+
+ return tables, columns, indexes
+
+
+def _status_for_migration_file(
+ migration_sql: str,
+ actual_tables: set[str],
+ actual_columns: set[tuple[str, str]],
+ actual_indexes: set[str],
+) -> dict:
+ expected_tables, expected_columns, expected_indexes = _parse_migration_expectations(migration_sql)
+
+ total_checks = len(expected_tables) + len(expected_columns) + len(expected_indexes)
+ if total_checks == 0:
+ return {
+ "status": "gray",
+ "label": "GrΓ₯",
+ "summary": "Ingen direkte schema-checks fundet i filen",
+ "missing_tables": [],
+ "missing_columns": [],
+ "missing_indexes": [],
+ }
+
+ missing_tables = sorted([tbl for tbl in expected_tables if tbl not in actual_tables])
+ missing_columns = sorted([f"{tbl}.{col}" for (tbl, col) in expected_columns if (tbl, col) not in actual_columns])
+ missing_indexes = sorted([idx for idx in expected_indexes if idx not in actual_indexes])
+
+ if not missing_tables and not missing_columns and not missing_indexes:
+ return {
+ "status": "green",
+ "label": "GrΓΈn",
+ "summary": "Alle schema-elementer fra filen findes i databasen",
+ "missing_tables": [],
+ "missing_columns": [],
+ "missing_indexes": [],
+ }
+
+ parts = []
+ if missing_tables:
+ parts.append(f"tabeller: {len(missing_tables)}")
+ if missing_columns:
+ parts.append(f"kolonner: {len(missing_columns)}")
+ if missing_indexes:
+ parts.append(f"indexes: {len(missing_indexes)}")
+
+ return {
+ "status": "red",
+ "label": "RΓΈd",
+ "summary": "Mangler " + ", ".join(parts),
+ "missing_tables": missing_tables,
+ "missing_columns": missing_columns,
+ "missing_indexes": missing_indexes,
+ }
+
@router.get("/settings", response_class=HTMLResponse, tags=["Frontend"])
async def settings_page(request: Request):
@@ -73,6 +251,36 @@ class MigrationExecution(BaseModel):
file_name: str
+@router.get("/settings/migrations/status", tags=["Frontend"])
+def migration_statuses():
+ """Check migration files against current schema and return per-file color status."""
+ migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
+ files = sorted(migrations_dir.glob("*.sql")) if migrations_dir.exists() else []
+
+ conn = get_db_connection()
+ try:
+ actual_tables, actual_columns, actual_indexes = _get_actual_schema_snapshot(conn)
+ statuses = []
+ for migration_file in files:
+ migration_sql = migration_file.read_text(encoding="utf-8")
+ status_info = _status_for_migration_file(
+ migration_sql,
+ actual_tables,
+ actual_columns,
+ actual_indexes,
+ )
+ statuses.append({
+ "name": migration_file.name,
+ **status_info,
+ })
+
+ return {"statuses": statuses}
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=f"Status check failed: {exc}")
+ finally:
+ release_db_connection(conn)
+
+
@router.post("/settings/migrations/execute", tags=["Frontend"])
def execute_migration(payload: MigrationExecution):
"""Execute a migration SQL file"""
diff --git a/app/settings/frontend/migrations.html b/app/settings/frontend/migrations.html
index d6b0356..cc0df8d 100644
--- a/app/settings/frontend/migrations.html
+++ b/app/settings/frontend/migrations.html
@@ -20,6 +20,11 @@
.command-actions .btn {
min-width: 120px;
}
+ .migration-status-badge {
+ min-width: 72px;
+ display: inline-block;
+ text-align: center;
+ }
{% endblock %}
@@ -45,7 +50,12 @@
-
Tilgængelige migrationer+
+
Tilgængelige migrationer+
{% if migrations and migrations|length > 0 %}
@@ -54,6 +64,7 @@
Fil |
+ Status |
StΓΈrrelse |
Sidst Γ¦ndret |
Handling |
@@ -65,6 +76,9 @@
{{ migration.name }}
|
+
+ GrΓ₯
+ |
{{ migration.size_kb }} KB |
{{ migration.modified }} |
@@ -159,7 +173,6 @@
async function runMigration(migrationName, button) {
const feedback = document.getElementById('migrationFeedback');
- const url = '/settings/migrations/execute';
button.disabled = true;
feedback.className = 'alert alert-info mt-3';
@@ -167,14 +180,38 @@
feedback.classList.remove('d-none');
try {
- const response = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ file_name: migrationName })
- });
+ const urls = buildMigrationActionUrls('execute');
+ const attempts = [];
+ let data = null;
+ let lastError = null;
- const data = await response.json();
- if (!response.ok) throw new Error(data.detail || data.message);
+ for (const url of urls) {
+ try {
+ const response = await fetch(url, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ file_name: migrationName })
+ });
+
+ const payload = await response.json().catch(() => ({}));
+ attempts.push(`${url} -> ${response.status}`);
+
+ if (response.ok) {
+ data = payload;
+ break;
+ }
+
+ lastError = payload.detail || payload.message || `HTTP ${response.status}`;
+ } catch (err) {
+ attempts.push(`${url} -> ERR`);
+ lastError = err.message || 'Netvaerksfejl';
+ }
+ }
+
+ if (!data) {
+ throw new Error(`${lastError || 'Migration fejlede'} (forsΓΈgt: ${attempts.join(' | ')})`);
+ }
feedback.className = 'alert alert-success mt-3';
feedback.innerHTML = `Migration kΓΈrt | ${data.output}`;
@@ -185,5 +222,112 @@
button.disabled = false;
}
}
+
+ function getStatusBadge(migrationName) {
+ const badges = document.querySelectorAll('.migration-status-badge');
+ for (const badge of badges) {
+ if (badge.dataset.migration === migrationName) {
+ return badge;
+ }
+ }
+ return null;
+ }
+
+ function applyMigrationStatus(statusItem) {
+ const badge = getStatusBadge(statusItem.name);
+ if (!badge) return;
+
+ badge.classList.remove('bg-secondary', 'bg-success', 'bg-danger');
+
+ if (statusItem.status === 'green') {
+ badge.classList.add('bg-success');
+ badge.textContent = 'GrΓΈn';
+ } else if (statusItem.status === 'red') {
+ badge.classList.add('bg-danger');
+ badge.textContent = 'RΓΈd';
+ } else {
+ badge.classList.add('bg-secondary');
+ badge.textContent = 'GrΓ₯';
+ }
+
+ badge.title = statusItem.summary || 'Ingen detaljer';
+ }
+
+ function uniqueUrls(urls) {
+ const seen = new Set();
+ return urls.filter((url) => {
+ if (seen.has(url)) return false;
+ seen.add(url);
+ return true;
+ });
+ }
+
+ function buildMigrationActionUrls(action) {
+ const path = (window.location.pathname || '').replace(/\/+$/, '');
+ const dynamicBase = path.endsWith('/migrations') ? path : '/settings/migrations';
+ const candidates = [
+ `${dynamicBase}/${action}`,
+ `/settings/migrations/${action}`,
+ `/api/v1/settings/migrations/${action}`
+ ];
+
+ if (dynamicBase.startsWith('/api/v1/')) {
+ candidates.unshift(`/api/v1/settings/migrations/${action}`);
+ }
+
+ return uniqueUrls(candidates);
+ }
+
+ async function checkMigrationStatuses() {
+ const button = document.getElementById('checkMigrationStatusBtn');
+ const feedback = document.getElementById('migrationFeedback');
+
+ button.disabled = true;
+ feedback.className = 'alert alert-info mt-3';
+ feedback.textContent = 'Tjekker migration status...';
+ feedback.classList.remove('d-none');
+
+ try {
+ const urls = buildMigrationActionUrls('status');
+ let data = null;
+ let lastError = null;
+ const attempts = [];
+
+ for (const url of urls) {
+ try {
+ const response = await fetch(url, { credentials: 'include' });
+ const payload = await response.json().catch(() => ({}));
+ attempts.push(`${url} -> ${response.status}`);
+ if (response.ok) {
+ data = payload;
+ break;
+ }
+ lastError = payload.detail || `HTTP ${response.status}`;
+ } catch (err) {
+ attempts.push(`${url} -> ERR`);
+ lastError = err.message || 'Netvaerksfejl';
+ }
+ }
+
+ if (!data) {
+ throw new Error(`${lastError || 'Status check fejlede'} (forsΓΈgt: ${attempts.join(' | ')})`);
+ }
+
+ const statuses = data.statuses || [];
+ statuses.forEach(applyMigrationStatus);
+
+ const redCount = statuses.filter(item => item.status === 'red').length;
+ const greenCount = statuses.filter(item => item.status === 'green').length;
+ const grayCount = statuses.filter(item => item.status === 'gray').length;
+
+ feedback.className = redCount > 0 ? 'alert alert-warning mt-3' : 'alert alert-success mt-3';
+ feedback.textContent = `Status opdateret: ${greenCount} grΓΈn, ${redCount} rΓΈd, ${grayCount} grΓ₯.`;
+ } catch (error) {
+ feedback.className = 'alert alert-danger mt-3';
+ feedback.textContent = `Fejl ved status check: ${error.message}`;
+ } finally {
+ button.disabled = false;
+ }
+ }
{% endblock %}
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index 7e48b89..0ec9fef 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -204,6 +204,103 @@
+
+
+
+
+
+
+
+
+
+
+
+ AnyDesk Admin Portal
+
+ AnyDesk Remote Support+
+
+
+
+
+
+ Hentes fra AnyDesk admin panel β API β Access tokens
+
+
+
+
+ AnyDesk licens-ID (UUID format)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Advarsel: BΓ₯de read-only og dry-run er deaktiveret. AnyDesk vil foretage rigtige API-kald.
+
+
+
+
+
@@ -1990,6 +2087,8 @@ async function loadSettings() {
await loadCaseStatusesSetting();
await loadTagsManagement();
await loadNextcloudInstances();
+ await loadAnydeskSettings();
+ await loadLabelPrinterSettings();
} catch (error) {
console.error('Error loading settings:', error);
}
@@ -2031,6 +2130,158 @@ function displaySettingsByCategory() {
displaySettings('systemSettings', categories.system);
}
+async function loadAnydeskSettings() {
+ const keys = ['anydesk_api_token', 'anydesk_license_id', 'anydesk_read_only', 'anydesk_dry_run'];
+ try {
+ const results = await Promise.allSettled(
+ keys.map(k => fetch(`/api/v1/settings/${k}`, { credentials: 'include' }).then(r => r.ok ? r.json() : null))
+ );
+ const vals = {};
+ results.forEach((r, i) => { if (r.status === 'fulfilled' && r.value) vals[keys[i]] = r.value.value; });
+
+ if (vals.anydesk_api_token) document.getElementById('anydeskApiToken').value = vals.anydesk_api_token;
+ if (vals.anydesk_license_id) document.getElementById('anydeskLicenseId').value = vals.anydesk_license_id;
+ document.getElementById('anydeskReadOnly').checked = vals.anydesk_read_only === 'true' || vals.anydesk_read_only === undefined;
+ document.getElementById('anydeskDryRun').checked = vals.anydesk_dry_run === 'true' || vals.anydesk_dry_run === undefined;
+ updateAnydeskSafetyAlert();
+ } catch (e) {
+ console.warn('AnyDesk settings load failed:', e);
+ }
+}
+
+function updateAnydeskSafetyAlert() {
+ const ro = document.getElementById('anydeskReadOnly')?.checked;
+ const dr = document.getElementById('anydeskDryRun')?.checked;
+ const alert = document.getElementById('anydeskSafetyAlert');
+ if (alert) alert.style.display = (!ro && !dr) ? '' : 'none';
+}
+document.addEventListener('change', e => {
+ if (e.target.id === 'anydeskReadOnly' || e.target.id === 'anydeskDryRun') updateAnydeskSafetyAlert();
+});
+
+async function saveAnydeskSettings() {
+ const token = document.getElementById('anydeskApiToken').value.trim();
+ const licenseId = document.getElementById('anydeskLicenseId').value.trim();
+ const readOnly = document.getElementById('anydeskReadOnly').checked;
+ const dryRun = document.getElementById('anydeskDryRun').checked;
+ const statusEl = document.getElementById('anydeskSaveStatus');
+
+ statusEl.textContent = 'Gemmer...';
+ statusEl.className = 'small text-muted';
+
+ const upsert = async (key, value, category, description) => {
+ // Try PUT first, fall back to POST (create) if 404
+ const putRes = await fetch(`/api/v1/settings/${key}`, {
+ method: 'PUT', credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ value: String(value) })
+ });
+ if (putRes.status === 404) {
+ await fetch('/api/v1/settings', {
+ method: 'POST', credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key, value: String(value), category, description, value_type: 'string', is_public: false })
+ });
+ } else if (!putRes.ok) {
+ throw new Error(`Fejl ved gem af ${key}`);
+ }
+ };
+
+ try {
+ const saves = [
+ upsert('anydesk_read_only', readOnly, 'integrations', 'AnyDesk read-only mode'),
+ upsert('anydesk_dry_run', dryRun, 'integrations', 'AnyDesk dry-run mode'),
+ ];
+ if (token) saves.push(upsert('anydesk_api_token', token, 'integrations', 'AnyDesk API token'));
+ if (licenseId) saves.push(upsert('anydesk_license_id', licenseId, 'integrations', 'AnyDesk license ID'));
+ await Promise.all(saves);
+ statusEl.textContent = 'β
Gemt';
+ statusEl.className = 'small text-success';
+ setTimeout(() => statusEl.textContent = '', 3000);
+ updateAnydeskSafetyAlert();
+ } catch (err) {
+ statusEl.textContent = 'β ' + err.message;
+ statusEl.className = 'small text-danger';
+ }
+}
+
+async function loadLabelPrinterSettings() {
+ const keys = [
+ 'label_printer_enabled',
+ 'label_printer_model',
+ 'label_printer_host',
+ 'label_printer_port',
+ 'label_printer_label_size'
+ ];
+ try {
+ const results = await Promise.allSettled(
+ keys.map(k => fetch(`/api/v1/settings/${k}`, { credentials: 'include' }).then(r => r.ok ? r.json() : null))
+ );
+ const vals = {};
+ results.forEach((r, i) => { if (r.status === 'fulfilled' && r.value) vals[keys[i]] = r.value.value; });
+
+ document.getElementById('labelPrinterEnabled').checked = vals.label_printer_enabled === 'true';
+ document.getElementById('labelPrinterModel').value = vals.label_printer_model || 'QL-710W';
+ document.getElementById('labelPrinterHost').value = vals.label_printer_host || '172.16.31.32';
+ document.getElementById('labelPrinterPort').value = vals.label_printer_port || '9100';
+ document.getElementById('labelPrinterSize').value = vals.label_printer_label_size || '62';
+ } catch (e) {
+ console.warn('Label printer settings load failed:', e);
+ }
+}
+
+async function saveLabelPrinterSettings() {
+ const enabled = document.getElementById('labelPrinterEnabled').checked;
+ const model = (document.getElementById('labelPrinterModel').value || '').trim() || 'QL-710W';
+ const host = (document.getElementById('labelPrinterHost').value || '').trim();
+ const port = (document.getElementById('labelPrinterPort').value || '').trim() || '9100';
+ const size = (document.getElementById('labelPrinterSize').value || '').trim() || '62';
+ const statusEl = document.getElementById('labelPrinterSaveStatus');
+
+ if (enabled && !host) {
+ showNotification('Angiv printer IP/host', 'error');
+ return;
+ }
+
+ if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
+ showNotification('Ugyldig port', 'error');
+ return;
+ }
+
+ statusEl.textContent = 'Gemmer...';
+ statusEl.className = 'small text-muted';
+
+ const putSettingStrict = async (key, value) => {
+ const response = await fetch(`/api/v1/settings/${key}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ value: String(value) })
+ });
+ if (!response.ok) {
+ throw new Error(await getErrorMessage(response, `Kunne ikke gemme ${key}`));
+ }
+ };
+
+ try {
+ await Promise.all([
+ putSettingStrict('label_printer_enabled', enabled ? 'true' : 'false'),
+ putSettingStrict('label_printer_model', model),
+ putSettingStrict('label_printer_host', host),
+ putSettingStrict('label_printer_port', String(port)),
+ putSettingStrict('label_printer_label_size', size),
+ ]);
+
+ statusEl.textContent = 'β
Gemt';
+ statusEl.className = 'small text-success';
+ setTimeout(() => { statusEl.textContent = ''; }, 3000);
+ showNotification('Label printer indstillinger gemt', 'success');
+ } catch (error) {
+ statusEl.textContent = 'β Kunne ikke gemme';
+ statusEl.className = 'small text-danger';
+ showNotification('Kunne ikke gemme label printer indstillinger', 'error');
+ }
+}
+
async function loadNextcloudInstances() {
try {
const response = await fetch('/api/v1/nextcloud/instances');
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index a6f7f96..26002f7 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -220,6 +220,7 @@
+
+
+
+
+ Brother Label Printer (Direkte print)+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tip: QL-710W bruger typisk port 9100. Label-størrelse kan fx være 62.
+
+
+
+
+
+
+ + Email ++ + Γ bn Email + +
+
+
+
@@ -559,7 +581,51 @@
document.addEventListener('DOMContentLoaded', () => {
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
+ const searchBubbleBtn = document.getElementById('globalSearchBtn');
+ const remindersBubbleBtn = document.getElementById('globalRemindersBtn');
+ const profileModalEl = document.getElementById('profileModal');
+ const profileModalInstance = profileModalEl ? new bootstrap.Modal(profileModalEl) : null;
const globalSearchInput = document.getElementById('globalSearchInput');
+
+ function openGlobalSearchModal() {
+ searchModal.show();
+ setTimeout(() => {
+ if (globalSearchInput) {
+ globalSearchInput.focus();
+ }
+ loadLiveStats();
+ loadRecentActivity();
+ }, 300);
+ }
+
+ function openRemindersModalTab() {
+ if (!profileModalInstance || !profileModalEl) {
+ return;
+ }
+ profileModalInstance.show();
+ setTimeout(() => {
+ const remindersTabBtn = document.getElementById('profile-reminders-tab');
+ if (remindersTabBtn) {
+ bootstrap.Tab.getOrCreateInstance(remindersTabBtn).show();
+ }
+ loadReminderPreferences();
+ loadProfileReminders();
+ }, 220);
+ }
+
+ if (searchBubbleBtn) {
+ searchBubbleBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ openGlobalSearchModal();
+ });
+ }
+
+ if (remindersBubbleBtn) {
+ remindersBubbleBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ openRemindersModalTab();
+ });
+ }
// Search input listener with debounce
let searchTimeout;
@@ -582,6 +648,9 @@
navigateResults(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
+ if (navigateToSagFromScan(e.target.value)) {
+ return;
+ }
selectCurrentResult();
}
});
@@ -592,15 +661,7 @@
// Cmd+K / Ctrl+K for global search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
- console.log('Cmd+K pressed - opening search modal'); // Debug
- searchModal.show();
- setTimeout(() => {
- if (globalSearchInput) {
- globalSearchInput.focus();
- }
- loadLiveStats();
- loadRecentActivity();
- }, 300);
+ openGlobalSearchModal();
}
// '+' key for QuickCreate (not in input fields)
@@ -650,6 +711,7 @@
document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none';
+ if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
});
@@ -810,12 +872,41 @@
}
}
+ function extractSagIdFromScanToken(value) {
+ const cleaned = String(value || '').toUpperCase().replace(/\s+/g, ' ').trim();
+ if (!cleaned) return null;
+
+ // Scanner tokens from work order and hardware labels
+ const workOrderMatch = cleaned.match(/\bBMCSCAN-WO-S(\d+)\b/);
+ if (workOrderMatch) return parseInt(workOrderMatch[1], 10);
+
+ const hardwareMatch = cleaned.match(/\bBMCSCAN-HW-(\d+)\b/);
+ if (hardwareMatch) return parseInt(hardwareMatch[1], 10);
+
+ return null;
+ }
+
+ function navigateToSagFromScan(value) {
+ const sagId = extractSagIdFromScanToken(value);
+ if (!sagId || Number.isNaN(sagId)) {
+ return false;
+ }
+
+ window.location.href = `/sag/${sagId}`;
+ return true;
+ }
+
// Global search function
async function performGlobalSearch(query) {
+ if (navigateToSagFromScan(query)) {
+ return;
+ }
+
if (!query || query.trim().length < 2) {
document.getElementById('emptyState').style.display = 'block';
document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none';
+ if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
return;
@@ -886,6 +977,51 @@
} catch (e) {
console.log('Contacts search not available');
}
+
+ // Search emails
+ try {
+ const emailsResponse = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
+ const emailsData = await emailsResponse.json();
+
+ if (Array.isArray(emailsData) && emailsData.length > 0) {
+ hasResults = true;
+ const emailResults = document.getElementById('emailResults');
+ if (emailResults) {
+ emailResults.style.display = 'block';
+ const emailList = emailResults.querySelector('.result-items');
+ if (emailList) {
+ emailList.innerHTML = emailsData.map(mail => {
+ const received = mail.received_date
+ ? new Date(mail.received_date).toLocaleString('da-DK')
+ : '-';
+ const sender = mail.sender_name || mail.sender_email || '-';
+ const isUnread = !Boolean(mail.is_read);
+ return `
+
+
+ `;
+ }).join('');
+ }
+ }
+ } else {
+ const emailResults = document.getElementById('emailResults');
+ if (emailResults) emailResults.style.display = 'none';
+ }
+ } catch (e) {
+ console.log('Email search not available');
+ const emailResults = document.getElementById('emailResults');
+ if (emailResults) emailResults.style.display = 'none';
+ }
// Search hardware
try {
@@ -1112,8 +1248,43 @@
+
+
+ ${escapeHtml(mail.subject || '(Ingen emne)')}
+
+ ${escapeHtml(sender)}
+ ${mail.linked_case_id ? ` β’ Sag #${mail.linked_case_id}` : ''}
+ ${isUnread ? ' ⒠Ulæst' : ''}
+ β’ ${escapeHtml(received)}
+
+
-
- Profilinformation hentes fra din konto. Flere felter kan tilfΓΈjes her senere.
+
@@ -1297,12 +1468,94 @@
}
}
+ async function loadUserProfile() {
+ try {
+ const res = await fetch('/api/v1/auth/me/profile', { credentials: 'include' });
+ if (!res.ok) return;
+ const p = await res.json();
+ document.getElementById('prof_full_name').value = p.full_name || '';
+ document.getElementById('prof_title').value = p.title || '';
+ document.getElementById('prof_phone').value = p.phone || '';
+ } catch (e) { console.error('Failed to load profile', e); }
+ loadAnyDeskChips();
+ }
+
+ async function loadAnyDeskChips() {
+ try {
+ const res = await fetch('/api/v1/auth/me/anydesk-ids', { credentials: 'include' });
+ if (!res.ok) return;
+ const { ids } = await res.json();
+ const box = document.getElementById('prof-anydesk-chips');
+ box.innerHTML = ids.length
+ ? ids.map(entry => `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TilfΓΈj alle maskiner du bruger som teknikker β bruges til automatisk at genkende dig i remote sessions.
+
+
+
+
+
+
+
${entry.anydesk_id}
+ ${entry.label ? `β ${entry.label}` : ''}
+ Ingen tidsregistreringer endnu ';
+ return;
+ }
+
+ // Saml og sortΓ©r alle tidsregistreringer efter dato, nyeste fΓΈrst
+ const sortedEntries = [...entries].sort((a, b) => {
+ const dateA = new Date(a.worked_date || a.start_tid || 0);
+ const dateB = new Date(b.worked_date || b.start_tid || 0);
+ return dateB - dateA;
+ });
+
+ // GruppΓ©r efter formatert dato
+ const groupedByDate = {};
+ sortedEntries.forEach((entry) => {
+ const rawDate = new Date(entry.worked_date || entry.start_tid || 0);
+ const dateKey = !isNaN(rawDate.getTime())
+ ? rawDate.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
+ : 'Ukendt dato';
+
+ if (!groupedByDate[dateKey]) groupedByDate[dateKey] = [];
+ groupedByDate[dateKey].push(entry);
+ });
+
+ // Byg HTML for den overordnede tidslinje
+ let html = '';
+
+ Object.entries(groupedByDate).forEach(([dateLabel, dateEntries]) => {
+ // Konverter det fΓΈrste bogstav i dato-strengen til stort
+ const formattedDateLab = dateLabel.charAt(0).toUpperCase() + dateLabel.slice(1);
+
+ html += `
+ '; // Luk time-v1-global-timeline
+ timeline.innerHTML = html;
+ }"""
+old_js_pattern = r'function renderTimeV1Timeline\(entries\).*?\n }'
+
+orig_text_len = len(text)
+
+import sys
+if re.search(old_css_pattern, text, re.DOTALL):
+ text = re.sub(old_css_pattern, new_css.strip(), text, flags=re.DOTALL)
+else:
+ print("Could NOT find old CSS!")
+
+if re.search(old_js_pattern, text, re.DOTALL):
+ text = re.sub(old_js_pattern, new_js.strip(), text, flags=re.DOTALL)
+else:
+ print("Could NOT find old JS!")
+
+with open("app/modules/sag/templates/detail.html", "w", encoding="utf-8") as f:
+ f.write(text)
+
+print(f"Replacement complete! Original length {orig_text_len}, new length {len(text)}")
diff --git a/fix_timeline_colors.py b/fix_timeline_colors.py
new file mode 100644
index 0000000..791ebc1
--- /dev/null
+++ b/fix_timeline_colors.py
@@ -0,0 +1,195 @@
+with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
+ text = f.read()
+
+start_marker = " function renderTimeV1Timeline(entries) {"
+end_marker = " async function loadTimeTrackingTab() {"
+
+start_idx = text.index(start_marker)
+end_idx = text.index(end_marker)
+
+print(f"Replacing lines {text[:start_idx].count(chr(10))+1} to {text[:end_idx].count(chr(10))+1}")
+
+new_func = r""" function renderTimeV1Timeline(entries) {
+ const timeline = document.getElementById('timeTimelineColumns');
+ if (!timeline) return;
+
+ if (!entries || entries.length === 0) {
+ timeline.innerHTML = '
+ `; // Luk time-v1-date-node
+ });
+
+ html += '
+ ${formattedDateLab}
+
+ `;
+
+ dateEntries.forEach(entry => {
+ const desc = escapeHtml(entry.beskrivelse || 'Ingen beskrivelse');
+ const userName = escapeHtml(entry.bruger_navn || 'Ukendt');
+
+ // Lav initialer til Avatar
+ const initials = userName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() || '?';
+
+ // FormatΓ©r tid
+ let timeOutput = '0 t';
+ let isRunning = false;
+ let clockClass = "text-muted";
+
+ if (entry.kilde === 'live' && !entry.faktisk_tid_min && !entry.stop_tid) {
+ timeOutput = 'KΓΈrer...';
+ isRunning = true;
+ clockClass = "text-success fw-bold";
+ } else if (entry.is_running) {
+ timeOutput = 'KΓΈrer...';
+ isRunning = true;
+ clockClass = "text-success fw-bold";
+ } else if (entry.faktisk_tid_min !== null && entry.faktisk_tid_min !== undefined) {
+ const h = Math.floor(entry.faktisk_tid_min / 60);
+ const m = Math.floor(entry.faktisk_tid_min % 60);
+ timeOutput = `${h}t ${m}m`;
+ } else {
+ // Reservere for original_hours fallback
+ const origHours = parseFloat(entry.original_hours || 0);
+ const h = Math.floor(origHours);
+ const m = Math.round((origHours - h) * 60);
+ timeOutput = `${h}t ${m}m`;
+ }
+
+ // Tjek synlighed for kunden (intern markering)
+ const isInternal = entry.is_internal ? true : false;
+ const internalBadge = isInternal
+ ? `
+ Intern
+ `
+ : '';
+
+ html += `
+
+
+ `;
+ });
+
+ html += `
+ ${initials}
+
+
+
+
+
+
+
+ ${userName}
+
+
+ ${timeOutput}
+ ${entry.entry_type ? ` · ${escapeHtml(entry.entry_type)}` : ''}
+
+
+ ${internalBadge}
+
+ ${desc}
+ Ingen tidsregistreringer endnu ';
+ return;
+ }
+
+ const START_HOUR = 7;
+ const TOTAL_HOURS = 10;
+ const HOUR_HEIGHT = 60;
+
+ const PALETTE = [
+ { border: '#0f4c75', bg: 'rgba(15,76,117,0.09)', header: 'rgba(15,76,117,0.08)' },
+ { border: '#ef4444', bg: 'rgba(239,68,68,0.09)', header: 'rgba(239,68,68,0.08)' },
+ { border: '#10b981', bg: 'rgba(16,185,129,0.09)', header: 'rgba(16,185,129,0.08)' },
+ { border: '#f59e0b', bg: 'rgba(245,158,11,0.09)', header: 'rgba(245,158,11,0.08)' },
+ { border: '#8b5cf6', bg: 'rgba(139,92,246,0.09)', header: 'rgba(139,92,246,0.08)' },
+ { border: '#ec4899', bg: 'rgba(236,72,153,0.09)', header: 'rgba(236,72,153,0.08)' },
+ { border: '#06b6d4', bg: 'rgba(6,182,212,0.09)', header: 'rgba(6,182,212,0.08)' },
+ { border: '#f97316', bg: 'rgba(249,115,22,0.09)', header: 'rgba(249,115,22,0.08)' },
+ ];
+
+ const allUsers = [...new Set(entries.map(e => e.bruger_navn || e.user_name || 'Ukendt'))].sort();
+ const userColor = {};
+ allUsers.forEach((u, i) => { userColor[u] = PALETTE[i % PALETTE.length]; });
+
+ const groupedByDate = {};
+ entries.forEach(entry => {
+ let dateKey;
+ if (entry.start_tid) dateKey = entry.start_tid.substring(0, 10);
+ else if (entry.worked_date) dateKey = entry.worked_date.substring(0, 10);
+ else if (entry.created_at) dateKey = entry.created_at.substring(0, 10);
+ else dateKey = 'Ukendt dato';
+ 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 dateLab = dateStr;
+ try {
+ const d = new Date(dateStr);
+ if (!isNaN(d.getTime())) {
+ dateLab = d.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
+ dateLab = dateLab.charAt(0).toUpperCase() + dateLab.slice(1);
+ }
+ } catch(e) {}
+
+ const techPlaced = {};
+ const unplaced = [];
+ const userTotals = {};
+
+ dayEntries.forEach(entry => {
+ const tech = entry.bruger_navn || entry.user_name || 'Ukendt';
+ const mins = entry.faktisk_tid_min
+ ? parseInt(entry.faktisk_tid_min)
+ : Math.round(parseFloat(entry.original_hours || entry.timer || 0) * 60);
+ userTotals[tech] = (userTotals[tech] || 0) + mins;
+ if (entry.start_tid) {
+ if (!techPlaced[tech]) techPlaced[tech] = [];
+ techPlaced[tech].push(entry);
+ } else {
+ unplaced.push(entry);
+ }
+ });
+
+ const techNames = Object.keys(techPlaced).sort();
+
+ html += `
+ `;
+ });
+
+ timeline.innerHTML = html;
+ }
+
+"""
+
+text = text[:start_idx] + new_func + text[end_idx:]
+
+with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
+ f.write(text)
+print("Done - renderTimeV1Timeline replaced with user-color version")
diff --git a/get_js.py b/get_js.py
new file mode 100644
index 0000000..d041e01
--- /dev/null
+++ b/get_js.py
@@ -0,0 +1,9 @@
+import re
+with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
+ text = f.read()
+
+m = re.search(r'function renderTimeV1Timeline\(entries\)\s*{.*?timeline\.innerHTML = Object\.entries\(grouped\).*?\}', text, re.DOTALL)
+if m:
+ with open('old_js.txt', 'w') as out:
+ out.write(m.group(0))
+ print("Wrote js")
diff --git a/get_saveTime.py b/get_saveTime.py
new file mode 100644
index 0000000..9bda3a3
--- /dev/null
+++ b/get_saveTime.py
@@ -0,0 +1,6 @@
+with open("app/modules/sag/templates/detail.html") as f:
+ text = f.read()
+
+s = text.find("async function saveTime()")
+e = text.find("}", text.find("fetch", s)) + 200
+print(text[s:e])
diff --git a/main.py b/main.py
index a11b0f9..393662d 100644
--- a/main.py
+++ b/main.py
@@ -109,6 +109,7 @@ from app.auth.backend import admin as auth_admin_api
from app.devportal.backend import router as devportal_api
from app.devportal.backend import views as devportal_views
from app.routers import anydesk
+from app.anydesk.backend import views as anydesk_views
# Modules
from app.modules.webshop.backend import router as webshop_api
@@ -209,6 +210,19 @@ async def lifespan(app: FastAPI):
replace_existing=True
)
logger.info("β
ESET sync job scheduled (every %d minutes)", settings.ESET_SYNC_INTERVAL_MINUTES)
+
+ if settings.LINKS_MODULE_ENABLED and settings.LINKS_DEAD_LINK_CHECK_ENABLED:
+ from app.modules.links.jobs.dead_link_check import check_links_health
+
+ backup_scheduler.scheduler.add_job(
+ func=check_links_health,
+ trigger=IntervalTrigger(minutes=settings.LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES),
+ id='check_links_health',
+ name='Check Links Health',
+ max_instances=1,
+ replace_existing=True
+ )
+ logger.info("β
Links health job scheduled (every %d minutes)", settings.LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES)
logger.info("β
System initialized successfully")
yield
@@ -414,6 +428,10 @@ app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
+if settings.LINKS_MODULE_ENABLED:
+ from app.modules.links.backend import router as links_api
+ app.include_router(links_api.router, prefix="/api/v1", tags=["Links"])
+
# Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"])
app.include_router(customers_views.router, tags=["Frontend"])
@@ -441,6 +459,11 @@ app.include_router(devportal_views.router, tags=["Frontend"])
app.include_router(telefoni_views.router, tags=["Frontend"])
app.include_router(calendar_views.router, tags=["Frontend"])
app.include_router(orders_views.router, tags=["Frontend"])
+app.include_router(anydesk_views.router, tags=["Frontend"])
+
+if settings.LINKS_MODULE_ENABLED:
+ from app.modules.links.frontend import views as links_views
+ app.include_router(links_views.router, tags=["Frontend"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
diff --git a/migrations/150_sag_tidsforbrug_v1.sql b/migrations/150_sag_tidsforbrug_v1.sql
new file mode 100644
index 0000000..712813e
--- /dev/null
+++ b/migrations/150_sag_tidsforbrug_v1.sql
@@ -0,0 +1,110 @@
+-- Migration 150: Sag tidsforbrug v1 foundation
+-- FormΓ₯l: Udvide tmodule_times med felter til live timer, faktisk/fakturerbar minutter,
+-- status-flow og type/kilde uden at bryde eksisterende timetracking-flow.
+
+ALTER TABLE tmodule_times
+ ADD COLUMN IF NOT EXISTS start_tid TIMESTAMP,
+ ADD COLUMN IF NOT EXISTS slut_tid TIMESTAMP,
+ ADD COLUMN IF NOT EXISTS faktisk_tid_min INTEGER,
+ ADD COLUMN IF NOT EXISTS fakturerbar_tid_min INTEGER,
+ ADD COLUMN IF NOT EXISTS entry_type VARCHAR(32) DEFAULT 'ukendt',
+ ADD COLUMN IF NOT EXISTS kilde VARCHAR(32) DEFAULT 'manuel',
+ ADD COLUMN IF NOT EXISTS entry_status VARCHAR(32) DEFAULT 'afventer',
+ ADD COLUMN IF NOT EXISTS medarbejder_id INTEGER,
+ ADD COLUMN IF NOT EXISTS aktiv_timer BOOLEAN DEFAULT FALSE,
+ ADD COLUMN IF NOT EXISTS round_block_min INTEGER DEFAULT 30,
+ ADD COLUMN IF NOT EXISTS ikke_placeret BOOLEAN DEFAULT FALSE;
+
+-- Optional settings per customer/type (fx mail default minutter)
+CREATE TABLE IF NOT EXISTS tmodule_time_defaults (
+ id SERIAL PRIMARY KEY,
+ customer_id INTEGER REFERENCES tmodule_customers(id) ON DELETE CASCADE,
+ entry_type VARCHAR(32) NOT NULL,
+ default_minutes INTEGER NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT tmodule_time_defaults_default_minutes_positive CHECK (default_minutes > 0),
+ CONSTRAINT tmodule_time_defaults_unique UNIQUE (customer_id, entry_type)
+);
+
+-- Backfill for existing rows so gamle data stadig virker i ny UI/API.
+UPDATE tmodule_times
+SET
+ entry_type = COALESCE(entry_type, 'ukendt'),
+ kilde = COALESCE(kilde, 'api'),
+ entry_status = COALESCE(
+ entry_status,
+ CASE
+ WHEN status = 'approved' THEN 'godkendt'
+ WHEN status = 'pending' THEN 'afventer'
+ WHEN status = 'billed' THEN 'godkendt'
+ ELSE 'kladde'
+ END
+ ),
+ faktisk_tid_min = COALESCE(faktisk_tid_min, CEIL(COALESCE(original_hours, 0)::numeric * 60)::int),
+ fakturerbar_tid_min = COALESCE(
+ fakturerbar_tid_min,
+ CEIL(COALESCE(approved_hours, original_hours, 0)::numeric * 60)::int
+ ),
+ round_block_min = COALESCE(round_block_min, 30),
+ aktiv_timer = COALESCE(aktiv_timer, FALSE),
+ ikke_placeret = COALESCE(ikke_placeret, FALSE);
+
+-- Guards
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'tmodule_times_faktisk_tid_min_positive'
+ AND conrelid = 'tmodule_times'::regclass
+ ) THEN
+ ALTER TABLE tmodule_times
+ ADD CONSTRAINT tmodule_times_faktisk_tid_min_positive CHECK (faktisk_tid_min IS NULL OR faktisk_tid_min >= 0);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'tmodule_times_fakturerbar_tid_min_positive'
+ AND conrelid = 'tmodule_times'::regclass
+ ) THEN
+ ALTER TABLE tmodule_times
+ ADD CONSTRAINT tmodule_times_fakturerbar_tid_min_positive CHECK (fakturerbar_tid_min IS NULL OR fakturerbar_tid_min >= 0);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'tmodule_times_entry_status_check'
+ AND conrelid = 'tmodule_times'::regclass
+ ) THEN
+ ALTER TABLE tmodule_times
+ ADD CONSTRAINT tmodule_times_entry_status_check CHECK (entry_status IN ('kladde', 'afventer', 'godkendt'));
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'tmodule_times_entry_type_check'
+ AND conrelid = 'tmodule_times'::regclass
+ ) THEN
+ ALTER TABLE tmodule_times
+ ADD CONSTRAINT tmodule_times_entry_type_check CHECK (entry_type IN ('opkald', 'mail', 'indedesk', 'manuel', 'ukendt'));
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'tmodule_times_kilde_check'
+ AND conrelid = 'tmodule_times'::regclass
+ ) THEN
+ ALTER TABLE tmodule_times
+ ADD CONSTRAINT tmodule_times_kilde_check CHECK (kilde IN ('auto', 'manuel', 'api'));
+ END IF;
+END $$;
+
+CREATE INDEX IF NOT EXISTS idx_tmodule_times_start_tid ON tmodule_times(start_tid);
+CREATE INDEX IF NOT EXISTS idx_tmodule_times_medarbejder_id ON tmodule_times(medarbejder_id);
+CREATE INDEX IF NOT EXISTS idx_tmodule_times_entry_status ON tmodule_times(entry_status);
+CREATE INDEX IF NOT EXISTS idx_tmodule_times_aktiv_timer ON tmodule_times(aktiv_timer);
+CREATE INDEX IF NOT EXISTS idx_tmodule_time_defaults_customer ON tmodule_time_defaults(customer_id);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_tmodule_times_active_timer_per_user
+ ON tmodule_times(medarbejder_id)
+ WHERE aktiv_timer = TRUE AND slut_tid IS NULL;
\ No newline at end of file
diff --git a/migrations/151_fix_opportunity_comment_attachments_schema.sql b/migrations/151_fix_opportunity_comment_attachments_schema.sql
new file mode 100644
index 0000000..8bbd801
--- /dev/null
+++ b/migrations/151_fix_opportunity_comment_attachments_schema.sql
@@ -0,0 +1,57 @@
+-- Migration 151: Reconcile opportunity comment attachments schema with migration 019 expectations
+-- Some environments already had pipeline_opportunity_comment_attachments with legacy columns,
+-- so migration 019 (CREATE TABLE IF NOT EXISTS) did not add these fields.
+
+ALTER TABLE IF EXISTS pipeline_opportunity_comment_attachments
+ ADD COLUMN IF NOT EXISTS content_type VARCHAR(100),
+ ADD COLUMN IF NOT EXISTS stored_name TEXT,
+ ADD COLUMN IF NOT EXISTS uploaded_by_user_id INTEGER;
+
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'pipeline_opportunity_comment_attachments'
+ AND column_name = 'file_path'
+ ) THEN
+ UPDATE pipeline_opportunity_comment_attachments
+ SET stored_name = COALESCE(stored_name, file_path)
+ WHERE stored_name IS NULL;
+ END IF;
+
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'pipeline_opportunity_comment_attachments'
+ AND column_name = 'file_type'
+ ) THEN
+ UPDATE pipeline_opportunity_comment_attachments
+ SET content_type = COALESCE(content_type, file_type)
+ WHERE content_type IS NULL;
+ END IF;
+END
+$$;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_constraint
+ WHERE conname = 'pipeline_opportunity_comment_attachments_uploaded_by_fkey'
+ ) THEN
+ ALTER TABLE pipeline_opportunity_comment_attachments
+ ADD CONSTRAINT pipeline_opportunity_comment_attachments_uploaded_by_fkey
+ FOREIGN KEY (uploaded_by_user_id)
+ REFERENCES users(user_id)
+ ON DELETE SET NULL;
+ END IF;
+END
+$$;
+
+-- Keep existing rows valid while allowing future inserts to set explicit stored_name.
+UPDATE pipeline_opportunity_comment_attachments
+SET stored_name = COALESCE(stored_name, filename)
+WHERE stored_name IS NULL;
diff --git a/migrations/152_users_profile_fields.sql b/migrations/152_users_profile_fields.sql
new file mode 100644
index 0000000..9642a82
--- /dev/null
+++ b/migrations/152_users_profile_fields.sql
@@ -0,0 +1,7 @@
+-- Migration 152: Add anydesk_id to users table
+-- Allows technicians to register their own AnyDesk client ID in their profile
+
+ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS anydesk_id VARCHAR(50),
+ ADD COLUMN IF NOT EXISTS phone VARCHAR(50),
+ ADD COLUMN IF NOT EXISTS title VARCHAR(100);
diff --git a/migrations/153_user_anydesk_ids.sql b/migrations/153_user_anydesk_ids.sql
new file mode 100644
index 0000000..4f65daa
--- /dev/null
+++ b/migrations/153_user_anydesk_ids.sql
@@ -0,0 +1,21 @@
+-- Migration 153: Multiple AnyDesk IDs per technician
+-- Replaces the single anydesk_id column on users with a dedicated table
+
+CREATE TABLE IF NOT EXISTS user_anydesk_ids (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
+ anydesk_id VARCHAR(50) NOT NULL,
+ label VARCHAR(100), -- optional label, e.g. "Privat laptop", "Kontor-PC"
+ created_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE (user_id, anydesk_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_anydesk_ids_user ON user_anydesk_ids(user_id);
+CREATE INDEX IF NOT EXISTS idx_user_anydesk_ids_anydesk_id ON user_anydesk_ids(anydesk_id);
+
+-- Migrate existing single anydesk_id values from users table
+INSERT INTO user_anydesk_ids (user_id, anydesk_id, label)
+SELECT user_id, anydesk_id, 'Primær'
+FROM users
+WHERE anydesk_id IS NOT NULL AND anydesk_id != ''
+ON CONFLICT DO NOTHING;
diff --git a/migrations/154_links_endpoints_module.sql b/migrations/154_links_endpoints_module.sql
new file mode 100644
index 0000000..ccf3b1f
--- /dev/null
+++ b/migrations/154_links_endpoints_module.sql
@@ -0,0 +1,119 @@
+-- Migration 154: Links / Endpoints module foundation
+-- Removable module schema for operational access layer
+
+CREATE TABLE IF NOT EXISTS link_categories (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(100) NOT NULL UNIQUE,
+ icon VARCHAR(100),
+ sort_order INTEGER NOT NULL DEFAULT 100,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS links (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ type VARCHAR(20) NOT NULL,
+ url TEXT,
+ host TEXT,
+ port INTEGER,
+ username TEXT,
+ icon VARCHAR(100),
+ color VARCHAR(32),
+ customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
+ case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
+ hardware_id INTEGER REFERENCES hardware_assets(id) ON DELETE SET NULL,
+ vault_item_id TEXT,
+ vault_item_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
+ is_critical BOOLEAN NOT NULL DEFAULT FALSE,
+ is_favorite BOOLEAN NOT NULL DEFAULT FALSE,
+ environment VARCHAR(20) NOT NULL DEFAULT 'prod',
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW(),
+ deleted_at TIMESTAMP,
+ CONSTRAINT links_type_check CHECK (type IN ('http', 'ssh', 'rdp', 'command')),
+ CONSTRAINT links_environment_check CHECK (environment IN ('prod', 'test', 'dev')),
+ CONSTRAINT links_port_check CHECK (port IS NULL OR (port >= 1 AND port <= 65535))
+);
+
+CREATE TABLE IF NOT EXISTS link_category_map (
+ link_id INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE,
+ category_id INTEGER NOT NULL REFERENCES link_categories(id) ON DELETE CASCADE,
+ created_at TIMESTAMP DEFAULT NOW(),
+ PRIMARY KEY (link_id, category_id)
+);
+
+CREATE TABLE IF NOT EXISTS link_runbooks (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
+ case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW(),
+ deleted_at TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS link_runbook_steps (
+ id SERIAL PRIMARY KEY,
+ runbook_id INTEGER NOT NULL REFERENCES link_runbooks(id) ON DELETE CASCADE,
+ step_order INTEGER NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ link_id INTEGER REFERENCES links(id) ON DELETE SET NULL,
+ command_text TEXT,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW(),
+ CONSTRAINT link_runbook_steps_unique_order UNIQUE (runbook_id, step_order)
+);
+
+CREATE TABLE IF NOT EXISTS link_status_checks (
+ id SERIAL PRIMARY KEY,
+ link_id INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE,
+ status VARCHAR(20) NOT NULL DEFAULT 'unknown',
+ details JSONB NOT NULL DEFAULT '{}'::jsonb,
+ checked_at TIMESTAMP DEFAULT NOW(),
+ CONSTRAINT link_status_checks_status_check CHECK (status IN ('ok', 'down', 'unknown'))
+);
+
+CREATE TABLE IF NOT EXISTS link_access_log (
+ id SERIAL PRIMARY KEY,
+ link_id INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
+ action_type VARCHAR(50) NOT NULL,
+ case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
+ customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS links_audit_log (
+ id SERIAL PRIMARY KEY,
+ link_id INTEGER REFERENCES links(id) ON DELETE SET NULL,
+ event_type VARCHAR(50) NOT NULL,
+ actor_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
+ changes JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_links_scope_case ON links(case_id) WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_links_scope_customer ON links(customer_id) WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_links_scope_hardware ON links(hardware_id) WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_links_name ON links(name);
+CREATE INDEX IF NOT EXISTS idx_links_host ON links(host);
+CREATE INDEX IF NOT EXISTS idx_links_url ON links(url);
+CREATE INDEX IF NOT EXISTS idx_links_type ON links(type);
+CREATE INDEX IF NOT EXISTS idx_links_updated_at ON links(updated_at DESC);
+CREATE INDEX IF NOT EXISTS idx_link_status_checks_link_checked ON link_status_checks(link_id, checked_at DESC);
+CREATE INDEX IF NOT EXISTS idx_link_access_log_created ON link_access_log(created_at DESC);
+
+INSERT INTO link_categories (name, icon, sort_order)
+VALUES
+ ('Network', 'bi-diagram-3', 10),
+ ('Monitoring', 'bi-activity', 20),
+ ('Servers', 'bi-hdd-network', 30),
+ ('Operations', 'bi-tools', 40),
+ ('Runbooks', 'bi-journal-check', 50)
+ON CONFLICT (name) DO NOTHING;
diff --git a/migrations/155_links_permissions.sql b/migrations/155_links_permissions.sql
new file mode 100644
index 0000000..d116ce5
--- /dev/null
+++ b/migrations/155_links_permissions.sql
@@ -0,0 +1,42 @@
+-- Migration 155: Links module permissions
+
+INSERT INTO permissions (code, description, category) VALUES
+('links.read', 'View links and endpoint actions', 'links'),
+('links.create', 'Create links', 'links'),
+('links.update', 'Update links', 'links'),
+('links.delete', 'Delete links', 'links'),
+('links.use', 'Use links and quick actions', 'links'),
+('links.diagnose', 'Run multi-open diagnose actions', 'links')
+ON CONFLICT (code) DO NOTHING;
+
+INSERT INTO group_permissions (group_id, permission_id)
+SELECT g.id, p.id
+FROM groups g
+CROSS JOIN permissions p
+WHERE g.name = 'Administrators'
+ AND p.category = 'links'
+ON CONFLICT DO NOTHING;
+
+INSERT INTO group_permissions (group_id, permission_id)
+SELECT g.id, p.id
+FROM groups g
+CROSS JOIN permissions p
+WHERE g.name = 'Managers'
+ AND p.code IN ('links.read', 'links.create', 'links.update', 'links.use', 'links.diagnose')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO group_permissions (group_id, permission_id)
+SELECT g.id, p.id
+FROM groups g
+CROSS JOIN permissions p
+WHERE g.name = 'Technicians'
+ AND p.code IN ('links.read', 'links.use', 'links.diagnose')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO group_permissions (group_id, permission_id)
+SELECT g.id, p.id
+FROM groups g
+CROSS JOIN permissions p
+WHERE g.name = 'Viewers'
+ AND p.code = 'links.read'
+ON CONFLICT DO NOTHING;
diff --git a/migrations/156_backfill_email_thread_keys.sql b/migrations/156_backfill_email_thread_keys.sql
new file mode 100644
index 0000000..48e0e3e
--- /dev/null
+++ b/migrations/156_backfill_email_thread_keys.sql
@@ -0,0 +1,48 @@
+-- Migration 156: Backfill email thread_keys from parent emails
+-- Ensures replies inherit the same thread_key as their parent so they group together visually.
+
+-- Step 1: For emails that have in_reply_to or email_references pointing to an existing
+-- email with a thread_key, adopt the parent's thread_key.
+UPDATE email_messages child
+SET thread_key = parent.thread_key,
+ updated_at = CURRENT_TIMESTAMP
+FROM email_messages parent
+WHERE child.deleted_at IS NULL
+ AND parent.deleted_at IS NULL
+ AND parent.thread_key IS NOT NULL
+ AND TRIM(parent.thread_key) != ''
+ AND (
+ -- Match via in_reply_to -> parent message_id
+ (
+ child.in_reply_to IS NOT NULL
+ AND TRIM(child.in_reply_to) != ''
+ AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
+ = LOWER(REGEXP_REPLACE(
+ (REGEXP_SPLIT_TO_ARRAY(TRIM(child.in_reply_to), E'[\\s,]+'))[1],
+ '[<>\s]', '', 'g'
+ ))
+ )
+ OR
+ -- Match via first reference -> parent message_id
+ (
+ child.email_references IS NOT NULL
+ AND TRIM(child.email_references) != ''
+ AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
+ = LOWER(REGEXP_REPLACE(
+ (REGEXP_SPLIT_TO_ARRAY(TRIM(child.email_references), E'[\\s,]+'))[1],
+ '[<>\s]', '', 'g'
+ ))
+ )
+ )
+ -- Only update if the thread_key would actually change
+ AND (
+ child.thread_key IS NULL
+ OR TRIM(child.thread_key) = ''
+ OR LOWER(REGEXP_REPLACE(child.thread_key, '[<>\s]', '', 'g'))
+ != LOWER(REGEXP_REPLACE(parent.thread_key, '[<>\s]', '', 'g'))
+ );
+
+-- Step 2: REMOVED - was incorrectly forcing all emails in a SAG to share one thread_key.
+-- Each SAG can have multiple independent email threads (different recipients/subjects).
+-- Thread grouping is based on actual RFC 5322 threading headers, not SAG membership.
+-- See migration 157 for the fix.
diff --git a/migrations/157_fix_thread_keys_multi_thread.sql b/migrations/157_fix_thread_keys_multi_thread.sql
new file mode 100644
index 0000000..f6bb8ff
--- /dev/null
+++ b/migrations/157_fix_thread_keys_multi_thread.sql
@@ -0,0 +1,57 @@
+-- Migration 157: Fix thread_keys - restore correct per-conversation grouping
+-- Migration 156 Step 2 incorrectly forced ALL emails in a SAG to share one thread_key.
+-- This migration restores the correct thread_key based on actual email conversation headers.
+
+-- Step 1: Restore thread_key for emails that have a Graph conversationId stored
+-- (these were overwritten by the dominant-thread backfill).
+-- The conversationId is the most reliable conversation identifier from Exchange/Graph.
+
+-- Step 2: Re-derive thread_keys from actual email headers.
+-- Priority: conversationId (if provider) > parent's thread_key > References[0] > In-Reply-To > message_id
+-- We re-derive for ALL emails to undo the forced unification.
+
+-- First, recalculate based on actual References/In-Reply-To parent chain.
+-- For emails that are replies (have in_reply_to or email_references), adopt the
+-- thread_key of the ACTUAL parent email (matched by message_id), not just any email in the SAG.
+UPDATE email_messages child
+SET thread_key = parent.thread_key,
+ updated_at = CURRENT_TIMESTAMP
+FROM email_messages parent
+WHERE child.deleted_at IS NULL
+ AND parent.deleted_at IS NULL
+ AND parent.thread_key IS NOT NULL
+ AND TRIM(parent.thread_key) != ''
+ AND (
+ -- Match via in_reply_to -> parent message_id
+ (
+ child.in_reply_to IS NOT NULL
+ AND TRIM(child.in_reply_to) != ''
+ AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
+ = LOWER(REGEXP_REPLACE(
+ (REGEXP_SPLIT_TO_ARRAY(TRIM(child.in_reply_to), E'[\\s,]+'))[1],
+ '[<>\s]', '', 'g'
+ ))
+ )
+ OR
+ -- Match via first reference -> parent message_id
+ (
+ child.email_references IS NOT NULL
+ AND TRIM(child.email_references) != ''
+ AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
+ = LOWER(REGEXP_REPLACE(
+ (REGEXP_SPLIT_TO_ARRAY(TRIM(child.email_references), E'[\\s,]+'))[1],
+ '[<>\s]', '', 'g'
+ ))
+ )
+ );
+
+-- For emails that are conversation starters (no in_reply_to, no references),
+-- reset thread_key to their own message_id so they start their own thread.
+UPDATE email_messages
+SET thread_key = LOWER(REGEXP_REPLACE(COALESCE(message_id, ''), '[<>\s]', '', 'g')),
+ updated_at = CURRENT_TIMESTAMP
+WHERE deleted_at IS NULL
+ AND (in_reply_to IS NULL OR TRIM(in_reply_to) = '')
+ AND (email_references IS NULL OR TRIM(email_references) = '')
+ AND message_id IS NOT NULL
+ AND TRIM(message_id) != '';
diff --git a/migrations/158_sag_work_orders_and_scan_tokens.sql b/migrations/158_sag_work_orders_and_scan_tokens.sql
new file mode 100644
index 0000000..5001fb7
--- /dev/null
+++ b/migrations/158_sag_work_orders_and_scan_tokens.sql
@@ -0,0 +1,32 @@
+-- Migration 158: SAG work-order scan tokens and file provenance
+-- Enables token-based auto-linking of scanned documents to cases.
+
+CREATE TABLE IF NOT EXISTS sag_document_tokens (
+ id SERIAL PRIMARY KEY,
+ sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
+ token VARCHAR(120) NOT NULL UNIQUE,
+ token_type VARCHAR(40) NOT NULL,
+ hardware_id INTEGER REFERENCES hardware_assets(id) ON DELETE SET NULL,
+ created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
+ expires_at TIMESTAMP,
+ consumed_at TIMESTAMP,
+ consumed_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT sag_document_tokens_type_check CHECK (token_type IN ('work_order', 'hardware_label'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_sag_document_tokens_sag_id ON sag_document_tokens(sag_id);
+CREATE INDEX IF NOT EXISTS idx_sag_document_tokens_token_type ON sag_document_tokens(token_type);
+CREATE INDEX IF NOT EXISTS idx_sag_document_tokens_consumed ON sag_document_tokens(consumed_at);
+
+ALTER TABLE sag_files
+ ADD COLUMN IF NOT EXISTS source_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
+ ADD COLUMN IF NOT EXISTS source_type VARCHAR(40),
+ ADD COLUMN IF NOT EXISTS source_token VARCHAR(120);
+
+UPDATE sag_files
+SET source_type = 'upload'
+WHERE source_type IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_sag_files_source_email_id ON sag_files(source_email_id);
+CREATE INDEX IF NOT EXISTS idx_sag_files_source_token ON sag_files(source_token);
diff --git a/old_js.txt b/old_js.txt
new file mode 100644
index 0000000..6ca558a
--- /dev/null
+++ b/old_js.txt
@@ -0,0 +1,54 @@
+function renderTimeV1Timeline(entries) {
+ const timeline = document.getElementById('timeTimelineColumns');
+ const unplaced = document.getElementById('timeUnplacedEntries');
+ const activeBanner = document.getElementById('timeActiveBanner');
+ const activeBannerText = document.getElementById('timeActiveBannerText');
+ if (!timeline || !unplaced) return;
+
+ const active = (entries || []).find((entry) => entry.aktiv_timer && !entry.slut_tid);
+ if (active) {
+ activeBanner.classList.remove('d-none');
+ activeBannerText.textContent = `Aktiv pΓ₯ ${active.user_name || 'ukendt bruger'}: ${active.description || 'uden beskrivelse'}`;
+ } else {
+ activeBanner.classList.add('d-none');
+ }
+
+ const unplacedEntries = (entries || []).filter((entry) => entry.ikke_placeret || (!entry.start_tid && !entry.slut_tid));
+ if (!unplacedEntries.length) {
+ unplaced.innerHTML = '
+ ${dateLab}
+
+
+ `;
+
+ if (unplaced.length > 0) {
+ html += ``;
+
+ for (let i = 0; i <= TOTAL_HOURS; i++) {
+ const h = START_HOUR + i;
+ html += ` `;
+
+ techNames.forEach(tech => {
+ const c = userColor[tech] || PALETTE[0];
+ const tot = userTotals[tech] || 0;
+ const totS = tot >= 60
+ ? `${Math.floor(tot/60)}t${tot%60 ? ' '+tot%60+'m' : ''}`
+ : `${tot}m`;
+
+ html += `${String(h).padStart(2,'0')}:00 `;
+ }
+ html += `
+ `;
+ });
+
+ html += `
+
+ ${escapeHtml(tech)}
+ ${totS}
+
+ `;
+
+ const posEntries = [];
+ techPlaced[tech].forEach(entry => {
+ const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse');
+ const startObj = new Date(entry.start_tid);
+ let durMin = 30;
+ if (entry.faktisk_tid_min) durMin = parseInt(entry.faktisk_tid_min);
+ else if (entry.original_hours || entry.timer) durMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60);
+
+ let sH = startObj.getHours(), sM = startObj.getMinutes();
+ if (sH < START_HOUR) { durMin -= (START_HOUR * 60 - sH * 60 - sM); sH = START_HOUR; sM = 0; }
+
+ let topPx = ((sH - START_HOUR) + sM / 60) * HOUR_HEIGHT;
+ let heightPx = (durMin / 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() + durMin * 60000);
+ const timeStr = `${String(startObj.getHours()).padStart(2,'0')}:${String(startObj.getMinutes()).padStart(2,'0')} \u2013 ${String(endObj.getHours()).padStart(2,'0')}:${String(endObj.getMinutes()).padStart(2,'0')}`;
+ posEntries.push({ topPx, heightPx, desc, timeStr, startMin: topPx, endMin: topPx + heightPx });
+ }
+ });
+
+ posEntries.sort((a, b) => a.startMin - b.startMin);
+ const lanes = [];
+ posEntries.forEach(e => {
+ let placed = false;
+ for (let li = 0; li < lanes.length; li++) {
+ if (lanes[li] <= e.startMin) { e.lane = li; lanes[li] = e.endMin; placed = true; break; }
+ }
+ if (!placed) { e.lane = lanes.length; lanes.push(e.endMin); }
+ });
+ const numLanes = lanes.length || 1;
+
+ posEntries.forEach(e => {
+ e.laneSpan = 1;
+ for (let li = e.lane + 1; li < numLanes; li++) {
+ if (!posEntries.some(o => o !== e && o.lane === li && o.startMin < e.endMin && o.endMin > e.startMin)) e.laneSpan++;
+ else break;
+ }
+ const lW = 100 / numLanes;
+ html += `
+ `;
+ });
+
+ html += `${e.timeStr}
+ ${e.desc}
+
+ Uden tidsrum:`;
+ unplaced.forEach(u => {
+ const tech = u.bruger_navn || u.user_name || 'Ukendt';
+ const c = userColor[tech] || PALETTE[0];
+ const mins = u.faktisk_tid_min ? parseInt(u.faktisk_tid_min) : Math.round(parseFloat(u.original_hours || u.timer || 0) * 60);
+ const hStr = mins >= 60 ? `${Math.floor(mins/60)}t${mins%60?' '+mins%60+'m':''}` : `${mins}m`;
+ const desc = escapeHtml(u.beskrivelse || u.description || '');
+ html += ` `;
+ }
+
+ html += `
+ ${escapeHtml(tech)} • ${hStr}${desc ? ' · '+desc+'' : ''}
+ `;
+ });
+ html += `Ingen entries uden tidspunkter. ';
+ } else {
+ unplaced.innerHTML = unplacedEntries.map((entry) => {
+ return `
+
+
+ `;
+ }).join('');
+ }
+
+ if (!entries || !entries.length) {
+ timeline.innerHTML = '${entry.description || 'Uden beskrivelse'}
+ ${minutesToLabel(entry.faktisk_tid_min || Math.round((entry.original_hours || 0) * 60))}
+ ${timeStatusBadge(entry.entry_status || 'afventer')}
+ Ingen tidsregistreringer endnu. ';
+ return;
+ }
+
+ const grouped = {};
+ (entries || []).forEach((entry) => {
+ const key = `${entry.medarbejder_id || 0}:${entry.user_name || 'Ukendt bruger'}`;
+ if (!grouped[key]) grouped[key] = [];
+ grouped[key].push(entry);
+ });
+
+ if (!entries || !entries.length) {
+ timeline.innerHTML = 'Ingen tidsregistreringer endnu. ';
+ return;
+ }
+
+ timeline.innerHTML = Object.entries(grouped).map(([key, rows]) => {
+ const userName = key.split(':')[1] || 'Ukendt bruger';
+ const sortedRows = [...rows].sort((a, b) => {
+ const aDate = new Date(a.start_tid || a.slut_tid || a.worked_date || a.created_at || 0).getTime();
+ const bDate = new Date(b.start_tid || b.slut_tid || b.worked_date || b.created_at || 0).getTime();
+ return bDate - aDate;
+ }
\ No newline at end of file
diff --git a/parse_html.py b/parse_html.py
index 78a147b..9cfc066 100644
--- a/parse_html.py
+++ b/parse_html.py
@@ -1,24 +1,17 @@
import re
-with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
- content = f.read()
+with open('app/modules/sag/templates/detail.html', 'r') as f:
+ text = f.read()
-def extract_between(text, start_marker, end_marker):
- start = text.find(start_marker)
- if start == -1: return "", text
- end = text.find(end_marker, start)
- if end == -1: return "", text
- match = text[start:end+len(end_marker)]
- text = text[:start] + text[end+len(end_marker):]
- return match, text
+# Let's verify the file content is around 6600 lines
+print(f"Total lines: {len(text.splitlines())}")
-def extract_div_by_marker(text, marker):
- start = text.find(marker)
- if start == -1: return "", text
-
- # find the open div tag nearest to the marker looking backwards
- div_start = text.rfind('` handling any attributes
- # Find next tag start
- next_open = html.find(' ', i)
-
- if next_open == -1 and next_close == -1:
- break
-
- if next_open != -1 and (next_open < next_close or next_close == -1):
- tag_count += 1
- i = next_open + 4
- else:
- tag_count -= 1
- i = next_close + 6
- if tag_count == 0:
- return start_idx, i
- return start_idx, -1
+with open('app/modules/sag/templates/detail.html', 'r') as f:
+ text = f.read()
-html = open('app/modules/sag/templates/detail.html').read()
+# Test finding CSS
+start_css = ".time-v1-track {"
+end_css = " .time-v1-metric {\n font-size: 0.8rem;\n margin-top: 0.18rem;\n }"
+if start_css in text and end_css in text:
+ print("Found CSS block")
-def extract_widget(html, data_module_name):
- pattern = f' ]*data-module="{data_module_name}"[^>]*>'
- match = re.search(pattern, html)
- if not match: return "", html
- start, end = get_balanced_div(html, match.start())
- widget = html[start:end]
- html = html[:start] + html[end:]
- return widget, html
-
-# Let's extract assignment card
-# It does not have data-module, but we know it follows: ``
-def extract_by_comment(html, comment_str):
- c_start = html.find(comment_str)
- if c_start == -1: return "", html
- div_start = html.find(' ]*id="{id_name}"[^>]*>'
- match = re.search(pattern, html)
- if not match: return "", html
- start, end = get_balanced_div(html, match.start())
- widget = html[start:end]
- html = html[:start] + html[end:]
- return widget, html
-
-# Test extractions
-ass, _ = extract_by_comment(html, '')
-print(f"Assignment widget len: {len(ass)}")
-
-cust, _ = extract_widget(html, "customers")
-print(f"Customer widget len: {len(cust)}")
-
-rem, _ = extract_widget(html, "reminders")
-print(f"Reminders widget len: {len(rem)}")
+# Test finding JS
+start_js = " function renderTimeV1Timeline(entries) {"
+end_js = " \n `;\n }).join('');\n }"
+if start_js in text and end_js in text:
+ print("Found JS block")
diff --git a/patch_detail.py b/patch_detail.py
new file mode 100644
index 0000000..5e1e44c
--- /dev/null
+++ b/patch_detail.py
@@ -0,0 +1,323 @@
+import re
+import sys
+
+def patch():
+ with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
+ text = f.read()
+
+ css_start = text.find('.time-v1-user-section {')
+ 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_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 += `
+
+ `;
+ });
+
+ 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 = '
+ ${formattedDateLab}
+
+
+ `;
+
+ if (unplaced.length > 0) {
+ html += `
+ `;
+
+ for (let i = 0; i <= TOTAL_HOURS; i++) {
+ const h = START_HOUR + i;
+ const top = i * HOUR_HEIGHT;
+ html += ` `;
+
+ techNames.forEach(tech => {
+ html += `
+ ${h.toString().padStart(2, '0')}:00 `;
+ }
+
+ html += `
+
+ `;
+ });
+
+ html += `
+ ${escapeHtml(tech)}
+
+
+ `;
+
+ techs[tech].forEach(entry => {
+ const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse');
+ const status = entry.entry_status || entry.status || 'kladde';
+ let cssClass = 'time-v1-entry-kladde';
+ if (status === 'afventer' || status === 'pending') cssClass = 'time-v1-entry-pending';
+ if (status === 'godkendt' || status === 'billed' || status === 'approved' || entry.fakturerbar_tid_min > 0) cssClass = 'time-v1-entry-godkendt';
+
+ const startObj = new Date(entry.start_tid);
+ let durationMin = 30; // default length
+ if (entry.faktisk_tid_min) {
+ durationMin = parseInt(entry.faktisk_tid_min);
+ } else if (entry.original_hours || entry.timer) {
+ durationMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60);
+ }
+
+ let startH = startObj.getHours();
+ let startM = startObj.getMinutes();
+
+ if (startH < START_HOUR) {
+ durationMin -= ((START_HOUR * 60) - (startH * 60 + startM));
+ startH = START_HOUR;
+ startM = 0;
+ }
+
+ let topPx = ((startH - START_HOUR) + (startM / 60)) * HOUR_HEIGHT;
+ let heightPx = (durationMin / 60) * HOUR_HEIGHT;
+
+ if (topPx < 0) topPx = 0;
+ if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) {
+ heightPx = (TOTAL_HOURS * HOUR_HEIGHT) - topPx;
+ }
+
+ if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) {
+ const endObj = new Date(startObj.getTime() + durationMin * 60000);
+ const timeStr = `${startObj.getHours().toString().padStart(2,'0')}:${startObj.getMinutes().toString().padStart(2,'0')} - ${endObj.getHours().toString().padStart(2,'0')}:${endObj.getMinutes().toString().padStart(2,'0')}`;
+
+ html += `
+
+
+
+ `;
+ }
+ });
+
+ html += `
+ ${timeStr}
+ ${desc}
+
+ Uden tidsrum:
+ `;
+ unplaced.forEach(u => {
+ const userName = escapeHtml(u.bruger_navn || u.user_name || 'Ukendt');
+ const hrs = u.original_hours || u.timer || 0;
+ html += ` `;
+ }
+
+ html += `
+ ${userName} • ${hrs}t
+ `;
+ });
+ html += `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 += `
+
+ `;
+ });
+
+ 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/requirements.txt b/requirements.txt
index 363702c..67f977f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -20,3 +20,6 @@ APScheduler==3.10.4
pdfplumber==0.11.4
av==13.1.0
Pillow==11.0.0
+brother_ql==0.9.4
+pyzbar==0.1.9
+pypdfium2==4.30.0
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 `
+
+ ${formattedDateLab}
+
+
+ `;
+
+ if (unplaced.length > 0) {
+ html += `
+ `;
+
+ for (let i = 0; i <= TOTAL_HOURS; i++) {
+ const h = START_HOUR + i;
+ const top = i * HOUR_HEIGHT;
+ html += ` `;
+
+ techNames.forEach(tech => {
+ html += `
+ ${h.toString().padStart(2, '0')}:00 `;
+ }
+
+ html += `
+
+ `;
+ });
+
+ html += `
+ ${escapeHtml(tech)}
+
+
+ `;
+
+ techs[tech].forEach(entry => {
+ const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse');
+ const status = entry.entry_status || entry.status || 'kladde';
+ let cssClass = 'time-v1-entry-kladde';
+ if (status === 'afventer' || status === 'pending') cssClass = 'time-v1-entry-pending';
+ if (status === 'godkendt' || status === 'billed' || status === 'approved' || entry.fakturerbar_tid_min > 0) cssClass = 'time-v1-entry-godkendt';
+
+ const startObj = new Date(entry.start_tid);
+ let durationMin = 30; // default length
+ if (entry.faktisk_tid_min) {
+ durationMin = parseInt(entry.faktisk_tid_min);
+ } else if (entry.original_hours || entry.timer) {
+ durationMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60);
+ }
+
+ let startH = startObj.getHours();
+ let startM = startObj.getMinutes();
+
+ if (startH < START_HOUR) {
+ durationMin -= ((START_HOUR * 60) - (startH * 60 + startM));
+ startH = START_HOUR;
+ startM = 0;
+ }
+
+ let topPx = ((startH - START_HOUR) + (startM / 60)) * HOUR_HEIGHT;
+ let heightPx = (durationMin / 60) * HOUR_HEIGHT;
+
+ if (topPx < 0) topPx = 0;
+ if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) {
+ heightPx = (TOTAL_HOURS * HOUR_HEIGHT) - topPx;
+ }
+
+ if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) {
+ const endObj = new Date(startObj.getTime() + durationMin * 60000);
+ const timeStr = `${startObj.getHours().toString().padStart(2,'0')}:${startObj.getMinutes().toString().padStart(2,'0')} - ${endObj.getHours().toString().padStart(2,'0')}:${endObj.getMinutes().toString().padStart(2,'0')}`;
+
+ html += `
+
+
+
+ `;
+ }
+ });
+
+ html += `
+ ${timeStr}
+ ${desc}
+
+ Uden tidsrum:
+ `;
+ unplaced.forEach(u => {
+ const userName = escapeHtml(u.bruger_navn || u.user_name || 'Ukendt');
+ const hrs = u.original_hours || u.timer || 0;
+ html += ` `;
+ }
+
+ html += `
+ ${userName} • ${hrs}t
+ `;
+ });
+ html += `
+ ${escapeHtml(clean)}
+
+ `;
+ }
+
+ 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}
+
+ `).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 = '${c.email || ''} ${c.user_company ? '(' + c.user_company + ')' : ''}
+ Ingen kunder fundet ';
+ } else {
+ resultsDiv.innerHTML = customers.map(c => `
+
+ ${c.name}
+
+ `).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 = '${c.email || ''} ${c.cvr_number ? '(CVR: ' + c.cvr_number + ')' : ''}
+ 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 += `
+ ';
+ 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 = `
+
+ ${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 += `
+
+
+ `;
+ });
+ });
+
+ html += '
+
+
+
+
+ #${c.id}
+ ${escapeHtml(c.titel)}
+
+ ${c.customer_name ? `
+
+ ${escapeHtml(c.customer_name)}
+
+ ` : ''}
+ ${beskrivelse ? `
+ ${escapeHtml(beskrivelse)}
+ ` : ''}
+
+
+ ${createdDate}
+
+ #${caseIdValue}
+ ${escapeHtml(caseTitel)}
+ ${status}
+
+ ${customerName ? `${escapeHtml(customerName)} ` : ''}
+ `;
+
+ previewDiv.style.display = 'block';
+ document.getElementById('relationSearchResults').innerHTML = '';
+ document.getElementById('relationSearchResults').style.display = 'none';
+ document.getElementById('relationCaseSearch').value = '';
+
+ // Enable add button
+ updateAddRelationButton();
+ }
+
+ function clearSelectedRelationCase() {
+ selectedRelationCaseId = null;
+ document.getElementById('selectedCasePreview').style.display = 'none';
+ document.getElementById('relationCaseSearch').value = '';
+ document.getElementById('relationCaseSearch').focus();
+ updateAddRelationButton();
+ }
+
+ function updateAddRelationButton() {
+ const btn = document.getElementById('addRelationBtn');
+ const relationType = document.getElementById('relationTypeSelect').value;
+ btn.disabled = !selectedRelationCaseId || !relationType;
+ }
+
+ async function addRelation() {
+ const relationType = document.getElementById('relationTypeSelect').value;
+ const btn = document.getElementById('addRelationBtn');
+
+ if (!selectedRelationCaseId) {
+ alert('Vælg en sag først');
+ return;
+ }
+
+ if (!relationType) {
+ alert('Vælg en relationstype');
+ return;
+ }
+
+ // Disable button during request
+ btn.disabled = true;
+ btn.innerHTML = 'TilfΓΈjer...';
+
+ try {
+ const response = await fetch(`/api/v1/sag/${caseId}/relationer`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ mΓ₯lsag_id: selectedRelationCaseId,
+ relationstype: relationType
+ })
+ });
+
+ if (response.ok) {
+ selectedRelationCaseId = null;
+ relationModal.hide();
+ window.location.reload();
+ } else {
+ const error = await response.json();
+ alert(`Fejl: ${error.detail}`);
+ btn.disabled = false;
+ btn.innerHTML = ' TilfΓΈj relation';
+ }
+ } catch (err) {
+ alert('Fejl ved tilfΓΈjelse af relation: ' + err.message);
+ btn.disabled = false;
+ btn.innerHTML = ' TilfΓΈj relation';
+ }
+ }
+
+ async function deleteRelation(relationId) {
+ if (confirm('Fjern denne relation?')) {
+ const response = await fetch(`/api/v1/sag/${caseId}/relationer/${relationId}`, {method: 'DELETE'});
+ if (response.ok) {
+ window.location.reload();
+ } else {
+ alert('Fejl ved fjernelse af relation');
+ }
+ }
+ }
+
+ // ============ Hardware Handling ============
+ async function loadCaseHardware() {
+ try {
+ const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`);
+ if (!res.ok) {
+ let message = 'Kunne ikke hente hardware.';
+ try {
+ const err = await res.json();
+ if (err?.detail) {
+ message = err.detail;
+ }
+ } catch (_) {
+ }
+ throw new Error(message);
+ }
+ const hardware = await res.json();
+ if (!Array.isArray(hardware)) {
+ throw new Error('Uventet svar fra serveren ved hardware-hentning.');
+ }
+ const container = document.getElementById('hardware-list');
+
+ if (hardware.length === 0) {
+ container.innerHTML = 'Ingen hardware tilknyttet ';
+ setModuleContentState('hardware', false);
+ return;
+ }
+
+ container.innerHTML = `
+
+ Enhed
+ SN
+ Slet
+
+ ${hardware.map(h => `
+
+
+ ${h.serial_number || '-'}
+
+ `).join('')}
+ `;
+ setModuleContentState('hardware', true);
+ } catch (e) {
+ console.error("Error loading hardware:", e);
+ const message = (e?.message || '').trim() || 'Fejl ved hentning';
+ document.getElementById('hardware-list').innerHTML = `${escapeHtml(message)} `;
+ setModuleContentState('hardware', true);
+ }
+ }
+
+ async function promptLinkHardware() {
+ const id = prompt("Indtast Hardware ID:");
+ if (!id) return;
+
+ try {
+ const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({ hardware_id: parseInt(id) })
+ });
+
+ if (!res.ok) throw await res.json();
+ loadCaseHardware();
+ } catch (e) {
+ alert("Fejl: " + (e.detail || e.message));
+ }
+ }
+
+ async function unlinkHardware(hwId) {
+ if(!confirm("Fjern link til dette hardware?")) return;
+ try {
+ await fetch(`/api/v1/sag/{{ case.id }}/hardware/${hwId}`, { method: 'DELETE' });
+ loadCaseHardware();
+ } catch (e) {
+ alert("Fejl ved sletning");
+ }
+ }
+
+ // ============ Location Handling ============
+ async function loadCaseLocations() {
+ try {
+ const res = await fetch(`/api/v1/sag/{{ case.id }}/locations`);
+ if (!res.ok) {
+ let message = 'Kunne ikke hente lokationer.';
+ try {
+ const err = await res.json();
+ if (err?.detail) {
+ message = err.detail;
+ }
+ } catch (_) {
+ }
+ throw new Error(message);
+ }
+ const locations = await res.json();
+ if (!Array.isArray(locations)) {
+ throw new Error('Uventet svar fra serveren ved lokations-hentning.');
+ }
+ const container = document.getElementById('locations-list');
+
+ if (locations.length === 0) {
+ container.innerHTML = 'Ingen lokationer tilknyttet ';
+ setModuleContentState('locations', false);
+ return;
+ }
+
+ container.innerHTML = `
+
+ Navn
+ Type
+ Slet
+
+ ${locations.map(l => `
+
+
+ `).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 = `
+
+ ${l.name}
+
+ ${l.location_type || '-'}
+ ${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)}
+ 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 `
+
+
+ `;
+ }).join('');
+ } catch (error) {
+ console.error('Error loading tag suggestions:', error);
+ suggestionsContainer.innerHTML = '
+
+ ${tag.icon ? ` ` : ''}${escapeHtml(tag.name || 'Tag')}
+
+ ${matched ? `
+ Match: ${escapeHtml(matched)} ` : ''}
+ 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 ` +
+
+ `;
+ };
+
+ const sections = [];
+ if (openSteps.length) {
+ sections.push(`
+
+
+ ${step.description ? `
+ ${step.title}
+
+
+ ${statusBadge}
+
+
+ ${!step.is_done ? `
+
+ ${step.description} ` : ''}
+
+ Forfald: ${dueLabel}
+
+ Γ
bne (${openSteps.length})
+ ${openSteps.map(renderStep).join('')}
+ `);
+ }
+ if (doneSteps.length) {
+ sections.push(`
+ Færdige (${doneSteps.length})
+ ${doneSteps.map(renderStep).join('')}
+ `);
+ }
+
+ list.innerHTML = sections.join('');
+ if (window.bootstrap) {
+ list.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
+ bootstrap.Tooltip.getOrCreateInstance(el, {
+ trigger: 'hover focus',
+ placement: 'left',
+ container: 'body',
+ html: true
+ });
+ });
+ }
+ setModuleContentState('todo-steps', true);
+ }
+
+ function updateTopbarNextTodo(steps) {
+ const valueEl = document.getElementById('topbarNextTodoValue');
+ const metaEl = document.getElementById('topbarNextTodoMeta');
+ if (!valueEl || !metaEl) return;
+
+ const openSteps = Array.isArray(steps) ? steps.filter((step) => !step.is_done) : [];
+ if (!openSteps.length) {
+ valueEl.textContent = 'Ingen Γ₯bne todo-opgaver';
+ metaEl.textContent = 'Alt er færdigt';
+ setNextTodoOverrideId(null);
+ return;
+ }
+
+ const nextOverrideId = getNextTodoOverrideId();
+ const overrideStep = nextOverrideId ? openSteps.find((step) => step.id === nextOverrideId) : null;
+ const nextStep = overrideStep || openSteps.find((step) => !!step.is_next) || openSteps[0];
+
+ if (!overrideStep && nextOverrideId) {
+ setNextTodoOverrideId(null);
+ }
+
+ valueEl.textContent = nextStep.title || 'Untitled todo';
+ metaEl.textContent = nextStep.due_date
+ ? `Forfald: ${formatTodoDate(nextStep.due_date)}`
+ : 'Ingen forfaldsdato';
+ }
+
+ async function setNextTodoStep(stepId, isNext) {
+ try {
+ const res = await fetch(`/api/v1/sag/todo-steps/${stepId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ is_next: isNext, is_done: false })
+ });
+ if (!res.ok) {
+ const error = await res.json().catch(() => ({}));
+ throw new Error(error.detail || 'Kunne ikke opdatere næste-opgave');
+ }
+
+ setNextTodoOverrideId(isNext ? stepId : null);
+ await loadTodoSteps();
+ } catch (e) {
+ alert('Fejl: ' + e.message);
+ }
+ }
+
+ async function loadTodoSteps() {
+ const list = document.getElementById('todo-steps-list');
+ if (!list) return;
+ list.innerHTML = 'Henter opgaver... ';
+
+ try {
+ const res = await fetch(`/api/v1/sag/${caseId}/todo-steps`);
+ if (!res.ok) throw new Error('Kunne ikke hente steps');
+ const steps = await res.json();
+ renderTodoSteps(steps || []);
+ } catch (e) {
+ console.error('Error loading todo steps:', e);
+ list.innerHTML = 'Fejl ved hentning ';
+ setModuleContentState('todo-steps', true);
+ }
+ }
+
+ function toggleTodoStepForm(forceOpen = null) {
+ const form = document.getElementById('todoStepForm');
+ const moduleCard = document.querySelector('[data-module="todo-steps"]');
+ if (!form) return;
+
+ const shouldOpen = forceOpen === null ? form.classList.contains('d-none') : Boolean(forceOpen);
+
+ if (shouldOpen) {
+ form.classList.remove('d-none');
+ if (moduleCard) {
+ moduleCard.classList.remove('module-empty-compact');
+ }
+ const titleInput = document.getElementById('todoStepTitle');
+ if (titleInput) {
+ titleInput.focus();
+ }
+ } else {
+ form.classList.add('d-none');
+ applyViewLayout(currentCaseView);
+ }
+ }
+
+ async function createTodoStep(event) {
+ event.preventDefault();
+ const titleInput = document.getElementById('todoStepTitle');
+ const descInput = document.getElementById('todoStepDescription');
+ const dueInput = document.getElementById('todoStepDueDate');
+ if (!titleInput) return;
+
+ const title = titleInput.value.trim();
+ if (!title) {
+ alert('Titel er paakraevet');
+ return;
+ }
+
+ const userId = getTodoUserId();
+ if (!userId) {
+ alert('Mangler bruger-id. Log ind igen.');
+ return;
+ }
+
+ try {
+ const res = await fetch(`/api/v1/sag/${caseId}/todo-steps?user_id=${userId}`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title,
+ description: descInput.value.trim() || null,
+ due_date: dueInput.value || null
+ })
+ }
+ );
+ if (!res.ok) {
+ const err = await res.json();
+ throw new Error(err.detail || 'Kunne ikke oprette step');
+ }
+ titleInput.value = '';
+ descInput.value = '';
+ dueInput.value = '';
+ await loadTodoSteps();
+ toggleTodoStepForm(false);
+ } catch (e) {
+ alert('Fejl: ' + e.message);
+ }
+ }
+
+ async function toggleTodoStep(stepId, isDone) {
+ const userId = getTodoUserId();
+ if (!userId) {
+ alert('Mangler bruger-id. Log ind igen.');
+ return;
+ }
+ try {
+ const res = await fetch(`/api/v1/sag/todo-steps/${stepId}?user_id=${userId}`,
+ {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ is_done: isDone })
+ }
+ );
+ if (!res.ok) throw new Error('Kunne ikke opdatere step');
+ await loadTodoSteps();
+ } catch (e) {
+ alert('Fejl: ' + e.message);
+ }
+ }
+
+ async function deleteTodoStep(stepId) {
+ if (!confirm('Slet dette step?')) return;
+ try {
+ const res = await fetch(`/api/v1/sag/todo-steps/${stepId}`, { method: 'DELETE' });
+ if (!res.ok) throw new Error('Kunne ikke slette step');
+ await loadTodoSteps();
+ } catch (e) {
+ alert('Fejl: ' + e.message);
+ }
+ }
+
+ async function promptLinkLocation() {
+ const id = prompt("Indtast Lokations ID:");
+ if (!id) return;
+
+ try {
+ const res = await fetch(`/api/v1/sag/{{ case.id }}/locations`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({ location_id: parseInt(id) })
+ });
+
+ if (!res.ok) throw await res.json();
+ loadCaseLocations();
+ } catch (e) {
+ alert("Fejl: " + (e.detail || e.message));
+ }
+ }
+
+ async function unlinkLocation(locId) {
+ if(!confirm("Fjern link til denne lokation?")) return;
+ try {
+ const res = await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.detail || 'Kunne ikke fjerne lokation');
+ }
+ loadCaseLocations();
+ } catch (e) {
+ alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl'));
+ }
+ }
+
+
+ // Initialize relation search when DOM is ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', setupRelationSearch);
+ } else {
+ setupRelationSearch();
+ }
+
+ // Kontakt Modal functions
+ function showKontaktModal() {
+ const modal = new bootstrap.Modal(document.getElementById('kontaktModal'));
+ modal.show();
+ }
+
+ // Afdeling Modal functions
+ function showAfdelingModal() {
+ const modal = new bootstrap.Modal(document.getElementById('afdelingModal'));
+ modal.show();
+ }
+
+ async function updateAfdeling() {
+ const newAfdeling = document.getElementById('afdelingInput').value.trim();
+
+ try {
+ const response = await fetch('/api/v1/customers/{{ customer.id if customer else 0 }}', {
+ method: 'PATCH',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({ department: newAfdeling })
+ });
+
+ if (!response.ok) throw await response.json();
+
+ // Reload page to show updated data
+ window.location.reload();
+ } catch (e) {
+ alert("Fejl ved opdatering: " + (e.detail || e.message));
+ }
+ }
+
\ No newline at end of file
diff --git a/script_10.js b/script_10.js
new file mode 100644
index 0000000..389d836
--- /dev/null
+++ b/script_10.js
@@ -0,0 +1,918 @@
+
+ (function () {
+ 'use strict';
+
+ let _openPopover = null;
+
+ // ββ helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ function closeAllPopovers() {
+ document.querySelectorAll('.rel-qa-menu').forEach(el => el.remove());
+ _openPopover = null;
+ }
+ document.addEventListener('click', function(e) {
+ if (!e.target.closest('.rel-qa-menu') && !e.target.closest('.btn-rel-action')) closeAllPopovers();
+ });
+ document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape') closeAllPopovers();
+ });
+
+ function popoverPos(btn) {
+ const r = btn.getBoundingClientRect();
+ return { top: r.bottom + window.scrollY + 4, left: r.left + window.scrollX };
+ }
+
+ function esc(s) { return String(s||'').replace(/&/g,'&').replace(//g,'>'); }
+
+ // ββ load global entity tags into rel-tag-row divs (using global tag system) ββ
+ async function loadAllRelationTags() {
+ const rows = Array.from(document.querySelectorAll('.rel-tag-row'));
+ if (!rows.length) return;
+ // Wait briefly for tag-picker.js to initialize
+ const renderFn = () => window.renderEntityTags;
+ await new Promise(res => { const t = setInterval(() => { if (renderFn()) { clearInterval(t); res(); } }, 50); setTimeout(() => { clearInterval(t); res(); }, 2000); });
+ await Promise.all(rows.map(async el => {
+ const caseId = parseInt(el.id.replace('rel-tags-', ''));
+ if (isNaN(caseId) || !window.renderEntityTags) return;
+ await window.renderEntityTags('case', caseId, el.id);
+ }));
+ }
+
+ // ββ tag button β opens global tag picker ββββββββββββββββββββββββββ
+ window.openRelTagPopover = function(caseId) {
+ if (!window.showTagPicker) return;
+ window.showTagPicker('case', caseId, () => {
+ if (window.renderEntityTags) window.renderEntityTags('case', caseId, 'rel-tags-' + caseId);
+ });
+ };
+
+ // ββ quick action menu βββββββββββββββββββββββββββββββββββββββββββββ
+ const QA_ITEMS = [
+ { icon: 'bi-person-check', label: 'Tildel sag', action: 'assign' },
+ { icon: 'bi-clock', label: 'Tidregistrering', action: 'time' },
+ { icon: 'bi-chat-left-text', label: 'Kommentar', action: 'note' },
+ { icon: 'bi-bell', label: 'PΓ₯mindelse', action: 'reminder' },
+ { icon: 'bi-graph-up-arrow', label: 'Salgspipeline', action: 'pipeline' },
+ { icon: 'bi-paperclip', label: 'Filer', action: 'files' },
+ { icon: 'bi-cpu', label: 'Hardware', action: 'hardware' },
+ { icon: 'bi-check2-square', label: 'Opgave', action: 'todo' },
+ { icon: 'bi-lightbulb', label: 'LΓΈsning', action: 'solution' },
+ { icon: 'bi-bag', label: 'VarekΓΈb & salg', action: 'sales' },
+ { icon: 'bi-arrow-repeat', label: 'Abonnement', action: 'subscription' },
+ { icon: 'bi-envelope', label: 'Send email', action: 'email' },
+ ];
+
+ // cache pipeline presence per caseId so we only fetch once per page load
+ const _pipelineCache = {};
+
+ window.openRelQaMenu = async function(caseId, caseTitle, btn) {
+ closeAllPopovers();
+ btn.classList.add('active');
+ const pos = popoverPos(btn);
+ const menu = document.createElement('div');
+ menu.className = 'rel-qa-menu';
+ menu.style.cssText = `position:absolute;top:${pos.top}px;left:${Math.max(0, pos.left - 120)}px;`;
+ menu.innerHTML = `SAG-${caseId} `
+ + ``;
+ document.body.appendChild(menu);
+ _openPopover = menu;
+
+ // Fetch case data to check pipeline presence (cached)
+ if (!(_pipelineCache[caseId] !== undefined)) {
+ try {
+ const r = await fetch(`/api/v1/sag/${caseId}`, { credentials: 'include' });
+ if (r.ok) {
+ const d = await r.json();
+ _pipelineCache[caseId] = !!(d.pipeline_stage_id || d.pipeline_amount || d.pipeline_description);
+ } else {
+ _pipelineCache[caseId] = false;
+ }
+ } catch { _pipelineCache[caseId] = false; }
+ }
+
+ const hasPipeline = _pipelineCache[caseId];
+
+ // Filter: hide pipeline item if case already has one; show "Se pipeline" link instead
+ const items = QA_ITEMS.filter(i => i.action !== 'pipeline' || !hasPipeline);
+ const extra = hasPipeline
+ ? ` Pipeline (se sagen) `
+ : '';
+
+ if (!_openPopover || _openPopover !== menu) return; // closed before fetch returned
+ menu.innerHTML = `SAG-${caseId} `
+ + items.map(item =>
+ `${esc(item.label)} `
+ ).join('')
+ + extra;
+ };
+
+ function getRelQaPrimaryButton() {
+ const sidePanel = document.getElementById('caseAddSidePanel');
+ if (sidePanel && sidePanel.classList.contains('open')) {
+ return sidePanel.querySelector('#relQaModalFooter .btn-primary');
+ }
+ return document.querySelector('#relQaModalEl .btn-primary');
+ }
+
+ function closeRelQaSurfaceAfterSave() {
+ const sidePanel = document.getElementById('caseAddSidePanel');
+ const panelOpen = !!(sidePanel && sidePanel.classList.contains('open'));
+
+ const relModalEl = document.getElementById('relQaModalEl');
+ const relModalInstance = relModalEl ? bootstrap.Modal.getInstance(relModalEl) : null;
+ if (relModalInstance) {
+ relModalInstance.hide();
+ }
+
+ // In sidepanel mode, refresh to reflect new persisted data across modules.
+ if (panelOpen) {
+ setTimeout(() => window.location.reload(), 120);
+ }
+ }
+
+ window.relQaAction = function(action, caseId, caseTitle) {
+ closeAllPopovers();
+ if (action === 'time') openRelTimeModal(caseId, caseTitle);
+ else if (action === 'email') openRelEmailModal(caseId, caseTitle);
+ else if (action === 'note') openRelNoteModal(caseId, caseTitle);
+ else if (action === 'reminder') openRelReminderModal(caseId, caseTitle);
+ else if (action === 'todo') openRelTodoModal(caseId, caseTitle);
+ else if (action === 'assign') openRelAssignModal(caseId, caseTitle);
+ else if (action === 'pipeline') openRelPipelineModal(caseId, caseTitle);
+ else if (action === 'files') openRelFilesModal(caseId, caseTitle);
+ else if (action === 'hardware') openRelHardwareModal(caseId, caseTitle);
+ else if (action === 'solution') openRelSolutionModal(caseId, caseTitle);
+ else if (action === 'sales') openRelSalesModal(caseId, caseTitle);
+ else if (action === 'subscription') openRelSubscriptionModal(caseId, caseTitle);
+ else window.open(`/sag/${caseId}`, '_blank');
+ };
+
+ // ββ Quick Pipeline modal ββββββββββββββββββββββββββββββββββββββββββ
+ window.openRelPipelineModal = function(caseId, caseTitle) {
+ _showRelModal(
+ `Salgspipeline`,
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ `
+
+
+
+
+
+
+ `,
+ `
+
+
+
+
+
+
+
+
+
+ `,
+ `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`,
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Varelinje
+
+
+
+
+
+
+
+
+ `,
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ `
+
+
+ `,
+ `
+
+
+ `,
+ `
+
+
+
+
+
+
+ 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 `
+ `;
+ }).join('');
+ } catch (e) {
+ list.innerHTML = '
+ ${who}
+ ${when}
+
+
+
+ FΓΈr${before}
+ Efter${after}
+ Kunne ikke indlæse historik. ';
+ }
+ };
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', function (e) {
+ const editor = document.getElementById('beskrivelse-editor');
+ if (!editor || editor.classList.contains('d-none')) return;
+ if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); saveBeskrivelsEdit(); }
+ if (e.key === 'Escape') { e.preventDefault(); cancelBeskrivelsEdit(); }
+ });
+
+ // Show history toggle if description already exists on page load
+ if ((document.getElementById('beskrivelse-text').innerText || '').trim()) {
+ document.getElementById('beskrivelse-history-wrap').classList.remove('d-none');
+ }
+ })();
+
\ No newline at end of file
diff --git a/script_2.js b/script_2.js
new file mode 100644
index 0000000..0329df6
--- /dev/null
+++ b/script_2.js
@@ -0,0 +1,578 @@
+
+ function _escapeCommentHtml(value) {
+ return String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function _removeQuotedMailLines(text) {
+ const source = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ const lines = source.split('\n');
+ const kept = [];
+
+ const headerRe = /^(fra|from|sent|date|dato|to|til|emne|subject|cc):\s*/i;
+ const originalMessageRe = /^(original message|oprindelig besked|videresendt besked)/i;
+
+ for (let i = 0; i < lines.length; i += 1) {
+ const line = lines[i];
+ const trimmed = line.trim();
+
+ if (trimmed.startsWith('>')) break;
+ if (originalMessageRe.test(trimmed)) break;
+
+ if (/^[-_]{3,}$/.test(trimmed)) {
+ const lookahead = lines.slice(i + 1, i + 4);
+ if (lookahead.some((candidate) => headerRe.test(String(candidate || '').trim()))) {
+ break;
+ }
+ }
+
+ if (i > 0 && headerRe.test(trimmed) && String(lines[i - 1] || '').trim() === '') {
+ break;
+ }
+
+ kept.push(line);
+ }
+
+ while (kept.length > 0 && String(kept[kept.length - 1] || '').trim() === '') {
+ kept.pop();
+ }
+
+ return kept.join('\n').trim();
+ }
+
+ function _parseEmailComment(rawText) {
+ const normalized = String(rawText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ const emailIdMatch = normalized.match(/^Email-ID:\s*(\d+)\s*$/m);
+ const emailId = emailIdMatch ? Number(emailIdMatch[1]) : null;
+ const withoutMeta = normalized.replace(/^Email-ID:\s*\d+\s*\n?/m, '').trim();
+ return {
+ emailId,
+ visibleText: _removeQuotedMailLines(withoutMeta)
+ };
+ }
+
+ function _formatEmailHeaderTimestamp(value) {
+ if (!value) return '';
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return String(value);
+ return parsed.toLocaleString('da-DK');
+ }
+
+ function _buildEmailHeaderAndBody(visibleText) {
+ const text = String(visibleText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
+ const lines = text.split('\n');
+
+ let idx = 0;
+ let typeLabel = 'Indgaaende email';
+ const firstLine = String(lines[0] || '').trim();
+ if (/^π§\s*IndgΓ₯ende email/i.test(firstLine)) {
+ typeLabel = 'Indgaaende email';
+ idx = 1;
+ } else if (/^π§\s*UdgΓ₯ende email/i.test(firstLine)) {
+ typeLabel = 'Udgaaende email';
+ idx = 1;
+ }
+
+ let fra = '';
+ let til = '';
+ let cc = '';
+ let emne = '';
+ let modtaget = '';
+
+ while (idx < lines.length) {
+ const line = String(lines[idx] || '').trim();
+ if (!line) {
+ idx += 1;
+ break;
+ }
+ if (/^Fra:\s*/i.test(line)) fra = line.replace(/^Fra:\s*/i, '').trim();
+ else if (/^Til:\s*/i.test(line)) til = line.replace(/^Til:\s*/i, '').trim();
+ else if (/^Cc:\s*/i.test(line)) cc = line.replace(/^Cc:\s*/i, '').trim();
+ else if (/^Emne:\s*/i.test(line)) emne = line.replace(/^Emne:\s*/i, '').trim();
+ else if (/^Modtaget:\s*/i.test(line)) modtaget = line.replace(/^Modtaget:\s*/i, '').trim();
+ else break;
+ idx += 1;
+ }
+
+ const bodyText = lines.slice(idx).join('\n').trim();
+ const summaryParts = [typeLabel];
+ if (fra) summaryParts.push(`Fra: ${fra}`);
+ if (til) summaryParts.push(`Til: ${til}`);
+ if (cc) summaryParts.push(`Cc: ${cc}`);
+ if (emne) summaryParts.push(`Emne: ${emne}`);
+ if (modtaget) summaryParts.push(`Modtaget: ${_formatEmailHeaderTimestamp(modtaget)}`);
+
+ return {
+ summary: summaryParts.join(' β’ '),
+ bodyText
+ };
+ }
+
+ function _extractEmailHeaderFields(visibleText) {
+ const text = String(visibleText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
+ const lines = text.split('\n');
+ let idx = 0;
+
+ const firstLine = String(lines[0] || '').trim();
+ const isOutgoing = /^π§\s*UdgΓ₯ende email/i.test(firstLine);
+ if (/^π§\s*(IndgΓ₯ende|UdgΓ₯ende)\s+email/i.test(firstLine)) {
+ idx = 1;
+ }
+
+ let fra = '';
+ let til = '';
+ let emne = '';
+ let modtaget = '';
+
+ while (idx < lines.length) {
+ const line = String(lines[idx] || '').trim();
+ if (!line) break;
+ if (/^Fra:\s*/i.test(line)) fra = line.replace(/^Fra:\s*/i, '').trim();
+ else if (/^Til:\s*/i.test(line)) til = line.replace(/^Til:\s*/i, '').trim();
+ else if (/^Emne:\s*/i.test(line)) emne = line.replace(/^Emne:\s*/i, '').trim();
+ else if (/^Modtaget:\s*/i.test(line)) modtaget = line.replace(/^Modtaget:\s*/i, '').trim();
+ else break;
+ idx += 1;
+ }
+
+ return { fra, til, emne, modtaget, isOutgoing };
+ }
+
+ function _normalizeReplySubject(value) {
+ const subject = String(value || '').trim();
+ return subject.replace(/^(re|fw|fwd)\s*:\s*/ig, '').toLowerCase();
+ }
+
+ function _findBestLinkedEmailByHeader(header) {
+ const targetSubject = _normalizeReplySubject(header?.emne || '');
+ const targetFrom = String(header?.fra || '').trim().toLowerCase();
+ const targetTo = String(header?.til || '').trim().toLowerCase();
+
+ const candidates = (linkedEmailsCache || []).filter((email) => {
+ const emailSubject = _normalizeReplySubject(email?.subject || '');
+ if (targetSubject && emailSubject !== targetSubject) {
+ return false;
+ }
+
+ const sender = String(email?.sender_email || email?.sender_name || '').toLowerCase();
+ const recipient = String(email?.recipient_email || '').toLowerCase();
+
+ if (targetFrom && sender && sender.includes(targetFrom)) {
+ return true;
+ }
+ if (targetTo && recipient && recipient.includes(targetTo)) {
+ return true;
+ }
+
+ return !targetFrom && !targetTo;
+ });
+
+ if (!candidates.length) {
+ return null;
+ }
+
+ candidates.sort((a, b) => {
+ const aTs = a?.received_date ? new Date(a.received_date).getTime() : 0;
+ const bTs = b?.received_date ? new Date(b.received_date).getTime() : 0;
+ return bTs - aTs;
+ });
+
+ return Number(candidates[0]?.id) || null;
+ }
+
+ function _extractEmailAddress(value) {
+ const raw = String(value || '').trim();
+ const match = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
+ return match ? match[0] : raw;
+ }
+
+ function _commentInitials(name) {
+ const clean = String(name || '').trim();
+ if (!clean) return 'EM';
+ const parts = clean.split(/\s+/).filter(Boolean);
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
+ return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase();
+ }
+
+ function _formatCommentTime(value) {
+ const parsed = new Date(value || Date.now());
+ if (Number.isNaN(parsed.getTime())) return '';
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${pad(parsed.getDate())}/${pad(parsed.getMonth() + 1)}-${parsed.getFullYear()} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`;
+ }
+
+ function _refreshCommentCountBadge() {
+ const container = document.getElementById('comments-container');
+ const badge = document.querySelector('#beskrivelse-comments-wrap .badge.bg-secondary');
+ if (!container || !badge) return;
+ badge.textContent = String(container.querySelectorAll('.comment-item').length);
+ }
+
+ function prependCommentToThread(comment) {
+ const container = document.getElementById('comments-container');
+ if (!container || !comment || !comment.indhold) return;
+
+ const emptyState = container.querySelector('p.text-center.text-muted.my-3');
+ if (emptyState) emptyState.remove();
+
+ const author = String(comment.forfatter || 'Email Bot');
+ const createdAtIso = String(comment.created_at || new Date().toISOString());
+ const createdAtMs = new Date(createdAtIso).getTime();
+ const createdAtUnix = Number.isFinite(createdAtMs) ? Math.floor(createdAtMs / 1000) : Math.floor(Date.now() / 1000);
+
+ const item = document.createElement('div');
+ item.className = 'comment-item comment-system';
+ item.dataset.createdAt = String(createdAtUnix);
+
+ const meta = document.createElement('div');
+ meta.className = 'comment-meta';
+ meta.innerHTML = `
+ ${_escapeCommentHtml(_commentInitials(author))}
+ ${_escapeCommentHtml(author)}
+ ${_escapeCommentHtml(_formatCommentTime(createdAtIso))}
+ `;
+
+ const body = document.createElement('div');
+ body.className = 'comment-body';
+ body.setAttribute('data-comment-raw', String(comment.indhold));
+ body.textContent = String(comment.indhold);
+
+ item.appendChild(meta);
+ item.appendChild(body);
+ container.insertBefore(item, container.firstChild);
+
+ processCommentBodies();
+ sortCommentsNewestFirst();
+ _refreshCommentCountBadge();
+ }
+
+ let activeCommentQuickReply = null;
+
+ window.closeInlineCommentQuickReply = function() {
+ const host = document.getElementById('comment-quick-reply-host');
+ if (host) host.innerHTML = '';
+ activeCommentQuickReply = null;
+ }
+
+ window.sendInlineCommentQuickReply = async function() {
+ const host = document.getElementById('comment-quick-reply-host');
+ const textarea = document.getElementById('commentQuickReplyText');
+ const sendBtn = document.getElementById('commentQuickReplySendBtn');
+ const statusEl = document.getElementById('commentQuickReplyStatus');
+ if (!host || !textarea || !sendBtn || !statusEl || !activeCommentQuickReply) return;
+
+ const bodyText = String(textarea.value || '').trim();
+ if (!bodyText) {
+ statusEl.className = 'comment-quick-reply-status text-danger';
+ statusEl.textContent = 'Skriv et svar';
+ return;
+ }
+
+ const recipient = _extractEmailAddress(activeCommentQuickReply.recipient);
+ if (!recipient || recipient.indexOf('@') === -1) {
+ statusEl.className = 'comment-quick-reply-status text-danger';
+ statusEl.textContent = 'Ingen gyldig modtager fundet i kommentaren';
+ return;
+ }
+
+ sendBtn.disabled = true;
+ statusEl.className = 'comment-quick-reply-status';
+ statusEl.textContent = 'Sender...';
+
+ try {
+ await loadLinkedEmails();
+
+ let threadEmailId = Number(activeCommentQuickReply.emailId) || null;
+ if (!threadEmailId) {
+ threadEmailId = _findBestLinkedEmailByHeader(activeCommentQuickReply.header);
+ }
+
+ let threadKey = null;
+ if (threadEmailId) {
+ const linked = linkedEmailsCache.find((entry) => Number(entry.id) === Number(threadEmailId));
+ threadKey = linked?.thread_key || linked?.resolved_thread_key || null;
+ }
+
+ const response = await fetch(`/api/v1/sag/${caseIds}/emails/send`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ to: [recipient],
+ subject: activeCommentQuickReply.subject,
+ body_text: bodyText,
+ thread_email_id: threadEmailId,
+ thread_key: threadKey
+ })
+ });
+
+ if (!response.ok) {
+ let message = `HTTP ${response.status}`;
+ try {
+ const payload = await response.json();
+ message = payload?.detail || payload?.message || message;
+ } catch (_) {
+ }
+ throw new Error(message);
+ }
+
+ const result = await response.json();
+ if (result?.comment) {
+ prependCommentToThread(result.comment);
+ }
+
+ statusEl.className = 'comment-quick-reply-status text-success';
+ statusEl.textContent = 'Svar sendt';
+ textarea.value = '';
+ await loadLinkedEmails();
+ setTimeout(() => {
+ window.closeInlineCommentQuickReply();
+ }, 500);
+ } catch (error) {
+ statusEl.className = 'comment-quick-reply-status text-danger';
+ statusEl.textContent = error?.message || 'Kunne ikke sende svar';
+ } finally {
+ sendBtn.disabled = false;
+ }
+ }
+
+ function openInlineCommentQuickReply(rawText, emailId) {
+ const host = document.getElementById('comment-quick-reply-host');
+ if (!host) return;
+
+ const parsed = _parseEmailComment(rawText || '');
+ const header = _extractEmailHeaderFields(parsed.visibleText || '');
+ const fallbackRecipient = header.isOutgoing ? (header.til || header.fra) : (header.fra || header.til);
+ const subject = /^re:\s*/i.test(header.emne || '')
+ ? (header.emne || `Sag #${caseIds}`)
+ : `Re: ${header.emne || `Sag #${caseIds}`}`;
+
+ activeCommentQuickReply = {
+ rawText,
+ header,
+ emailId: Number(emailId) || parsed.emailId || null,
+ recipient: fallbackRecipient,
+ subject
+ };
+
+ host.innerHTML = `
+
+
+ `;
+
+ 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, 'Quick svar til ${_escapeCommentHtml(String(fallbackRecipient || 'ukendt modtager'))}
+
+
+ '); + return; + } + + const hasEmailHeader = /(^|\n)\s*π§\s*(IndgΓ₯ende|UdgΓ₯ende)\s+email/i.test(String(rawText)); + if (!hasEmailHeader) { + body.innerHTML = _escapeCommentHtml(String(rawText)).replace(/\n/g, ' '); + return; + } + + const parsed = _parseEmailComment(rawText); + const display = _buildEmailHeaderAndBody(parsed.visibleText || ''); + const safeHeader = _escapeCommentHtml(display.summary || 'Indgaaende email'); + const safeBody = _escapeCommentHtml(display.bodyText || '').replace(/\n/g, ' '); + body.innerHTML = ` + ${safeHeader}
+ ${display.bodyText ? `${safeBody} ` : ''}
+ `;
+
+ const existingActions = item.querySelector('.comment-actions');
+ if (existingActions) {
+ existingActions.remove();
+ }
+
+ if (parsed.emailId) {
+ const actions = document.createElement('div');
+ actions.className = 'comment-actions';
+ actions.innerHTML = `
+ Kunne ikke hente data | Kunne ikke hente data | Ingen linjer | ${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} |
+
+ |
+
+
+ Ingen tid registreret | ${entry.worked_date || '-'} |
+ ${formatNumber(hours)} t |
+ ${entry.source_sag_titel || '-'}${sourceBadge} |
+ 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 += `
+
+ `;
+ });
+
+ 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 = '
+ ${formattedDateLab}
+
+
+ `;
+
+ if (unplaced.length > 0) {
+ html += `
+ `;
+
+ for (let i = 0; i <= TOTAL_HOURS; i++) {
+ const h = START_HOUR + i;
+ const top = i * HOUR_HEIGHT;
+ html += ` `;
+
+ techNames.forEach(tech => {
+ html += `
+ ${h.toString().padStart(2, '0')}:00 `;
+ }
+
+ html += `
+
+ `;
+ });
+
+ html += `
+ ${escapeHtml(tech)}
+
+
+ `;
+
+ techs[tech].forEach(entry => {
+ const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse');
+ const status = entry.entry_status || entry.status || 'kladde';
+ let cssClass = 'time-v1-entry-kladde';
+ if (status === 'afventer' || status === 'pending') cssClass = 'time-v1-entry-pending';
+ if (status === 'godkendt' || status === 'billed' || status === 'approved' || entry.fakturerbar_tid_min > 0) cssClass = 'time-v1-entry-godkendt';
+
+ const startObj = new Date(entry.start_tid);
+ let durationMin = 30; // default length
+ if (entry.faktisk_tid_min) {
+ durationMin = parseInt(entry.faktisk_tid_min);
+ } else if (entry.original_hours || entry.timer) {
+ durationMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60);
+ }
+
+ let startH = startObj.getHours();
+ let startM = startObj.getMinutes();
+
+ if (startH < START_HOUR) {
+ durationMin -= ((START_HOUR * 60) - (startH * 60 + startM));
+ startH = START_HOUR;
+ startM = 0;
+ }
+
+ let topPx = ((startH - START_HOUR) + (startM / 60)) * HOUR_HEIGHT;
+ let heightPx = (durationMin / 60) * HOUR_HEIGHT;
+
+ if (topPx < 0) topPx = 0;
+ if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) {
+ heightPx = (TOTAL_HOURS * HOUR_HEIGHT) - topPx;
+ }
+
+ if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) {
+ const endObj = new Date(startObj.getTime() + durationMin * 60000);
+ const timeStr = `${startObj.getHours().toString().padStart(2,'0')}:${startObj.getMinutes().toString().padStart(2,'0')} - ${endObj.getHours().toString().padStart(2,'0')}:${endObj.getMinutes().toString().padStart(2,'0')}`;
+
+ html += `
+
+
+
+ `;
+ }
+ });
+
+ html += `
+ ${timeStr}
+ ${desc}
+
+ Uden tidsrum:
+ `;
+ unplaced.forEach(u => {
+ const userName = escapeHtml(u.bruger_navn || u.user_name || 'Ukendt');
+ const hrs = u.original_hours || u.timer || 0;
+ html += ` `;
+ }
+
+ html += `
+ ${userName} • ${hrs}t
+ `;
+ });
+ html += `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 `
+
+
+ `;
+ }).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 `
+
+
+
+
+
+ ${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}
+
+
+
+
+ `;
+ }
+
+ async function loadCaseCalendar() {
+ const currentList = document.getElementById('caseCalendarCurrent');
+ const childrenList = document.getElementById('caseCalendarChildren');
+ if (!currentList || !childrenList) return;
+
+ currentList.innerHTML = '
+
+ ${event.title || 'Aftale'}
+ ${typeLabel} Β· ${dateLabel}
+ 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 `
+
+
+ `;
+ }).join('');
+ }
+
+ setModuleContentState('calendar', hasAnyEvents);
+ } catch (e) {
+ console.error(e);
+ currentList.innerHTML = '${child.case_title}
+
+ ${eventsHtml}
+
+ 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 = `
+
+
+ `;
+
+ 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 = '${title}
+ ${bodyHtml}
+
+
+ Ingen aktive moduler fundet. ';
+ return;
+ }
+
+ listEl.innerHTML = actions.map((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 `
+
+
+
+ ${title}
+ ${subtitle}
+ 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 `
+
+
+ `;
+ }).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 += '
+
+ ${size} β’ ${new Date(f.created_at).toLocaleDateString()}
+
+
+
+
+
+
+ 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 = ``;
+ })
+ .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 = `
+
+
+
+ `;
+ } else {
+ // Unsupported file type
+ previewContent.innerHTML = `
+
+ `;
+ }
+ }
+
+ 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) => `
+ ${filename}+ +
+
+ `).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 = '
+
+
+
+
+
+
+
+
+
+
+
+ FΓΈr
+ ${escapeHtml(change.before) || '(tom)'}
+
+
+ Efter
+ ${escapeHtml(change.after) || '(tom)'}
+ 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 `
+
+
+
+
+ ${escapeHtml(subject)}
+ ${escapeHtml(sender)}
+ ${escapeHtml(receivedDate)}
+
+ ${group.emails.length}
+ ${unreadCount > 0 ? `${unreadCount} ulæst` : ''}
+
+ 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 `
+
+
+
+
+ ${escapeHtml(subject)}
+ ${escapeHtml(sender)}
+ ${isOutgoing
+ ? 'UdgΓ₯ende'
+ : 'IndgΓ₯ende'
+ }
+ ${escapeHtml(snippet || 'Ingen preview')}
+
+
+ ${escapeHtml(receivedDate)}
+ ${hasAttachments ? 'π' : ''}
+ ${!e.is_read ? 'Ulæst' : ''}
+
+
+
+
+ Vælg en e-mail i listen for at se indhold og vedhæftninger
+
+ `;
+ }
+
+ async function loadLinkedEmailDetail(emailId, skipRefresh = false) {
+ selectedLinkedEmailId = Number(emailId);
+ const panel = document.getElementById('email-preview-panel');
+ if (!panel) return;
+
+ panel.innerHTML = `
+
+
+ Henter e-mail...
+
+ `;
+
+ if (!skipRefresh) {
+ const threadEmails = getCurrentThreadEmails();
+ renderLinkedEmails(threadEmails);
+ }
+
+ try {
+ const res = await fetch(`/api/v1/emails/${emailId}`);
+ if (!res.ok) {
+ panel.innerHTML = 'Kunne ikke hente e-mail detaljer. ';
+ return;
+ }
+
+ const email = await res.json();
+ const subject = email.subject || '(Ingen emne)';
+ const sender = email.sender_name || email.sender_email || '-';
+ const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-';
+ const attachments = Array.isArray(email.attachments) ? email.attachments : [];
+ const bodyText = email.body_text || '';
+ const bodyHtml = email.body_html || '';
+ selectedLinkedEmailDetail = email;
+
+ panel.innerHTML = `
+
+
+ ${escapeHtml(subject)}
+ Fra: ${escapeHtml(sender)}
+ Dato: ${escapeHtml(received)}
+
+
+
+
+ Vedhæftninger (${attachments.length})
+
+
+ ${bodyText ? `
+ `;
+
+ 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 = '${escapeHtml(bodyText)}` : (bodyHtml ? bodyHtml : 'Ingen indhold ')}
+ 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 => `
+ ${e.subject}
+ ${e.sender_email}
+
+
+ |
+
+ |
+ |
+ | 0,00 kr |
+
+ |
+
+
+ |
+
+ |
+ |
+ | 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 | ${item.product_name || '-'} |
+ ${item.description} |
+ ${parseFloat(item.quantity).toFixed(2)} |
+ ${formatSubscriptionCurrency(item.unit_price)} |
+ ${formatSubscriptionCurrency(item.line_total)} |
+ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||