Compare commits

...

4 Commits

Author SHA1 Message Date
Christian
9ed709c9e8 release: v2.8.0 case creation and messaging 2026-09-08 01:55:47 +02:00
Christian
9ca562745a feat(subscriptions): enhance subscription update logic to allow direct edits for drafts and add new endpoint for manual invoice processing
feat(ticket): update email integration to use new priority constants for ticket classification

feat(procurement): add procurement overview page with dynamic data loading and display

test(subscriptions): add tests for billing calendar to ensure correct invoice dates

feat(reminder): implement automated task lists with user-defined rules for reminders

feat(migrations): create tables for managing delefiber product prices and mobile recorder provisioning history

test(mobile_recorder): add tests for provisioning mobile recorders to ensure correct asset creation and updates
2026-09-07 19:05:58 +02:00
Christian
1cfe5aee76 feat(migrations): add AI benchmark and vTiger archive tables
- Created tables for AI benchmark runs and results to facilitate model evaluation.
- Added expected answers column to benchmark results.
- Introduced tables for internet connection change cases and vTiger archive management, including records, relations, and checkpoints.
- Implemented triggers to enforce append-only behavior for vTiger archive records and files.
- Enhanced solution management with soft delete capabilities for sag_solutions and knowledge_articles.

feat(scripts): add CRM benchmarking script for Ollama models

- Developed a Python script to benchmark CRM models exposed through Ollama, including various test cases and scoring mechanisms.

test(tests): add comprehensive tests for new features

- Implemented tests for internet change case service, vTiger archive functionality, and sag solution knowledge management.
- Ensured coverage for edge cases and error handling in the new features.
2026-08-31 13:01:35 +02:00
Christian
adc4fb5876 Implement tests for sag module, add knowledge base templates, and enhance internet connection migrations
- Added multiple test cases for the sag module to ensure proper functionality and data handling.
- Created new templates for knowledge detail and knowledge index pages to display articles and solutions.
- Introduced migrations to enhance the internet connections schema, including new columns for manual sharing and SLA subscriptions.
- Added a script to reconcile known internet connections with verified data.
- Planned the implementation of a new website content administration module for managing customer references and operational status.
2026-08-30 14:34:43 +02:00
104 changed files with 11544 additions and 1051 deletions

View File

@ -3,6 +3,7 @@
# =====================================================
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
HUB_BASE_URL=https://hub.bmcnetworks.dk
MOBILE_RECORDER_PROVISIONING_TOKEN=replace-with-a-long-random-service-token
# Database credentials (bruges af docker-compose)
POSTGRES_USER=bmc_hub

View File

@ -0,0 +1,18 @@
# BMC Hub v2.8.0
## Ny sag
- Redesignet, mere fokuseret oprettelsesflow med kompakte typevalg og progressive paneler.
- Browser-kladdesystem, hurtigskabeloner, tagvælger og tastaturgenvej til oprettelse.
- Duplikatindsigt for kunde og valgt kontakt samt arbejdsbelastning for ansvarlig medarbejder.
- Brand- og type-tagforslag ud fra sagens titel og beskrivelse.
## Interne beskeder
- Nyt, mere overskueligt beskedflow mellem medarbejdere.
- Understøttelse af korte telefonbeskeder med valgfri kontaktperson og tilbageringningsnummer.
## Database
- Kør `migrations/238_case_create_templates.sql`.
- Kør `migrations/239_internal_phone_messages.sql`.

1
app/admin/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Restricted administrative reporting modules."""

254
app/admin/archive_bundle.py Normal file
View File

@ -0,0 +1,254 @@
"""Portable, checksummed Project CT archive bundles. Never contacts vTiger."""
from __future__ import annotations
import hashlib
import json
import os
import socket
import tempfile
import zipfile
from datetime import datetime, timezone
from typing import Any, Iterable
from psycopg2.extras import RealDictCursor
from app.core.database import get_db_connection, release_db_connection
BUNDLE_SCHEMA_VERSION = 1
ENTRY_NAMES = ("versions.jsonl", "records.jsonl", "relations.jsonl", "checkpoints.jsonl", "files.jsonl")
def file_sha256(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _json_line(value: Any) -> bytes:
return (json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + "\n").encode("utf-8")
def _write_cursor_entry(archive: zipfile.ZipFile, name: str, cursor, batch_size: int = 500) -> tuple[int, str]:
digest = hashlib.sha256()
count = 0
with archive.open(name, "w") as target:
while True:
rows = cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
raw = _json_line(dict(row))
target.write(raw)
digest.update(raw)
count += 1
return count, digest.hexdigest()
def create_archive_bundle(through_version_id: int, initiated_by: int | None = None) -> tuple[str, dict]:
fd, path = tempfile.mkstemp(prefix="project-ct-", suffix=".zip")
os.close(fd)
conn = get_db_connection()
manifest = {
"schema_version": BUNDLE_SCHEMA_VERSION,
"created_at": datetime.now(timezone.utc).isoformat(),
"source_instance": socket.gethostname(),
"through_version_id": through_version_id,
"entries": {},
"files": {},
"contains_files": True,
"document_policy": "metadata_and_original_bytes",
}
try:
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
queries = {
"versions.jsonl": ("SELECT * FROM vtiger_archive_versions WHERE id<=%s ORDER BY id", (through_version_id,)),
"records.jsonl": ("SELECT * FROM vtiger_archive_records WHERE version_id<=%s ORDER BY module,vtiger_id,revision_no", (through_version_id,)),
"relations.jsonl": ("SELECT * FROM vtiger_archive_relations WHERE version_id<=%s ORDER BY id", (through_version_id,)),
"checkpoints.jsonl": (
"""SELECT module,MAX(source_modified_at) AS last_modified_at,MAX(vtiger_id) AS last_vtiger_id,
%s::bigint AS last_successful_version_id,NOW() AS updated_at
FROM vtiger_archive_records WHERE version_id<=%s GROUP BY module ORDER BY module""",
(through_version_id, through_version_id),
),
"files.jsonl": (
"""SELECT id,version_id,source_module,document_vtiger_id,resource_vtiger_id,filename,content_type,
size_bytes,content_sha256,archived_at
FROM vtiger_archive_files WHERE version_id<=%s ORDER BY id""",
(through_version_id,),
),
}
for index, (name, (sql, params)) in enumerate(queries.items()):
cursor = conn.cursor(name=f"project_ct_export_{index}", cursor_factory=RealDictCursor)
cursor.itersize = 500
cursor.execute(sql, params)
count, digest = _write_cursor_entry(archive, name, cursor)
cursor.close()
manifest["entries"][name] = {"count": count, "sha256": digest}
file_rows = conn.cursor(cursor_factory=RealDictCursor)
file_rows.execute(
"""SELECT DISTINCT ON (content_sha256) content_sha256,content,size_bytes
FROM vtiger_archive_files WHERE version_id<=%s ORDER BY content_sha256,id""",
(through_version_id,),
)
for row in file_rows:
content = bytes(row["content"])
digest = hashlib.sha256(content).hexdigest()
if digest != row["content_sha256"]:
raise ValueError(f"Lokal filchecksum er ugyldig: {row['content_sha256']}")
entry_name = f"files/{digest}"
archive.writestr(entry_name, content)
manifest["files"][digest] = {"entry": entry_name, "size": len(content), "sha256": digest}
file_rows.close()
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2, default=str))
manifest["bundle_sha256"] = file_sha256(path)
return path, manifest
except Exception:
if os.path.exists(path):
os.unlink(path)
raise
finally:
release_db_connection(conn)
def _verify_entry(archive: zipfile.ZipFile, manifest: dict, name: str) -> None:
expected = ((manifest.get("entries") or {}).get(name) or {}).get("sha256")
if not expected:
raise ValueError(f"Manifest mangler checksum for {name}")
digest = hashlib.sha256()
with archive.open(name) as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
if digest.hexdigest() != expected:
raise ValueError(f"Checksumfejl i {name}")
def _rows(archive: zipfile.ZipFile, name: str) -> Iterable[dict]:
with archive.open(name) as source:
for line_number, raw in enumerate(source, 1):
if not raw.strip():
continue
try:
yield json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Ugyldig JSON i {name}, linje {line_number}") from exc
def import_archive_bundle(path: str, initiated_by: int | None = None) -> dict:
bundle_digest = file_sha256(path)
conn = get_db_connection()
counts = {"versions": 0, "records": 0, "records_skipped": 0, "relations": 0, "checkpoints": 0}
try:
with zipfile.ZipFile(path) as archive:
try:
manifest = json.loads(archive.read("manifest.json"))
except (KeyError, json.JSONDecodeError) as exc:
raise ValueError("Filen er ikke en gyldig Project CT-arkivpakke") from exc
if manifest.get("schema_version") != BUNDLE_SCHEMA_VERSION:
raise ValueError(f"Ikke-understøttet arkivformat: {manifest.get('schema_version')}")
if manifest.get("contains_files") is not True:
raise ValueError("Pakken mangler originale vTiger-dokumentfiler")
for name in ENTRY_NAMES:
_verify_entry(archive, manifest, name)
for digest, info in (manifest.get("files") or {}).items():
entry_name = info.get("entry")
if not entry_name or entry_name not in archive.namelist():
raise ValueError(f"Pakken mangler dokumentfil {digest}")
actual = hashlib.sha256(archive.read(entry_name)).hexdigest()
if actual != digest or actual != info.get("sha256"):
raise ValueError(f"Checksumfejl i dokumentfil {digest}")
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
version_map: dict[int, int] = {}
for row in _rows(archive, "versions.jsonl"):
cursor.execute(
"""INSERT INTO vtiger_archive_versions
(sync_kind,status,started_at,completed_at,source_cutoff,module_counts,warnings,
critical_errors,control_report,control_approved_at,raw_export_sha256)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s::jsonb,%s::jsonb,%s,%s) RETURNING id""",
(row["sync_kind"], row["status"], row["started_at"], row.get("completed_at"), row["source_cutoff"],
json.dumps(row.get("module_counts") or {}), json.dumps(row.get("warnings") or []),
json.dumps(row.get("critical_errors") or []), json.dumps(row.get("control_report") or {}),
row.get("control_approved_at"), row.get("raw_export_sha256")),
)
version_map[int(row["id"])] = int(cursor.fetchone()["id"])
counts["versions"] += 1
for row in _rows(archive, "records.jsonl"):
cursor.execute(
"SELECT id FROM vtiger_archive_records WHERE module=%s AND vtiger_id=%s AND payload_sha256=%s",
(row["module"], row["vtiger_id"], row["payload_sha256"]),
)
if cursor.fetchone():
counts["records_skipped"] += 1
continue
cursor.execute(
"SELECT COALESCE(MAX(revision_no),0)+1 AS revision FROM vtiger_archive_records WHERE module=%s AND vtiger_id=%s",
(row["module"], row["vtiger_id"]),
)
revision = int(cursor.fetchone()["revision"])
cursor.execute(
"""INSERT INTO vtiger_archive_records
(version_id,module,vtiger_id,revision_no,source_created_at,source_modified_at,is_deleted,payload,payload_sha256,archived_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s)""",
(version_map[int(row["version_id"])], row["module"], row["vtiger_id"], revision,
row.get("source_created_at"), row.get("source_modified_at"), row.get("is_deleted", False),
json.dumps(row["payload"], ensure_ascii=False), row["payload_sha256"], row.get("archived_at")),
)
counts["records"] += 1
counts["files"] = 0
counts["files_skipped"] = 0
for row in _rows(archive, "files.jsonl"):
cursor.execute(
"""SELECT id FROM vtiger_archive_files
WHERE source_module=%s AND document_vtiger_id=%s AND resource_vtiger_id=%s AND content_sha256=%s""",
(row.get("source_module") or "Documents", row["document_vtiger_id"], row["resource_vtiger_id"], row["content_sha256"]),
)
if cursor.fetchone():
counts["files_skipped"] += 1
continue
info = manifest["files"][row["content_sha256"]]
content = archive.read(info["entry"])
cursor.execute(
"""INSERT INTO vtiger_archive_files
(version_id,source_module,document_vtiger_id,resource_vtiger_id,filename,content_type,size_bytes,
content_sha256,content,archived_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(version_map[int(row["version_id"])], row.get("source_module") or "Documents", row["document_vtiger_id"], row["resource_vtiger_id"],
row["filename"], row.get("content_type"), row["size_bytes"], row["content_sha256"], content,
row.get("archived_at")),
)
counts["files"] += 1
for row in _rows(archive, "relations.jsonl"):
cursor.execute(
"""INSERT INTO vtiger_archive_relations
(version_id,source_module,source_vtiger_id,field_name,target_vtiger_id,target_module)
VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(version_map[int(row["version_id"])], row["source_module"], row["source_vtiger_id"],
row["field_name"], row["target_vtiger_id"], row.get("target_module")),
)
counts["relations"] += cursor.rowcount
for row in _rows(archive, "checkpoints.jsonl"):
mapped_version = version_map.get(int(row["last_successful_version_id"])) if row.get("last_successful_version_id") else None
cursor.execute(
"""INSERT INTO vtiger_archive_checkpoints
(module,last_modified_at,last_vtiger_id,last_successful_version_id,updated_at)
VALUES (%s,%s,%s,%s,%s) ON CONFLICT(module) DO UPDATE SET
last_modified_at=GREATEST(vtiger_archive_checkpoints.last_modified_at,EXCLUDED.last_modified_at),
last_vtiger_id=EXCLUDED.last_vtiger_id,last_successful_version_id=EXCLUDED.last_successful_version_id,
updated_at=NOW()""",
(row["module"], row.get("last_modified_at"), row.get("last_vtiger_id"), mapped_version, row.get("updated_at")),
)
counts["checkpoints"] += 1
conn.commit()
return {"bundle_sha256": bundle_digest, "manifest": manifest, "counts": counts, "contacted_vtiger": False}
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)

60
app/admin/hub_impact.html Normal file
View File

@ -0,0 +1,60 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Project CT · Hub Impact{% endblock %}
{% block extra_css %}
<style>
.ct-shell{max-width:1500px;margin:auto}.ct-hero{border-radius:24px;padding:2rem;background:linear-gradient(120deg,#082f49,#0f4c75 55%,#2563eb);color:#fff;box-shadow:0 22px 55px rgba(8,47,73,.22)}
.ct-panel{background:var(--bs-body-bg);border:1px solid rgba(15,76,117,.14);border-radius:18px;box-shadow:0 10px 28px rgba(15,76,117,.06)}
.ct-kpi{border-radius:16px;padding:1.1rem;background:linear-gradient(145deg,rgba(15,76,117,.08),rgba(37,99,235,.03));height:100%}.ct-kpi strong{font-size:1.7rem}.ct-delta.positive{color:#16803c}.ct-delta.negative{color:#dc3545}
.ct-status{display:inline-flex;align-items:center;gap:.45rem;padding:.38rem .75rem;border-radius:999px;font-weight:700}.ct-status.ready{background:#dcfce7;color:#166534}.ct-status.blocked{background:#fee2e2;color:#991b1b}
.ct-table th{font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:#64748b}.ct-empty{padding:3rem;text-align:center;color:#64748b}
</style>
{% endblock %}
{% block content %}
<main class="container-fluid px-4 py-4 ct-shell">
<section class="ct-hero mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
<div><div class="text-uppercase small fw-bold opacity-75 mb-2">Fortrolig · kun superadmin</div><h1 class="h2 mb-2">Project CT · Hub Impact</h1><p class="mb-0 opacity-75">Dokumentér effekten af Hub mod et permanent vTiger-arkiv.</p></div>
<div class="text-end"><div id="readinessBadge" class="ct-status blocked">Indlæser arkivstatus…</div><div class="small opacity-75 mt-2" id="readinessReasons"></div></div>
</section>
<section class="ct-panel p-3 p-lg-4 mb-4">
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3"><div><h2 class="h5 mb-1">Ny reproducerbar sammenligning</h2><div class="small text-muted">Perioderne skal have præcis samme antal kalenderdage.</div></div><div class="d-flex flex-wrap gap-2"><button class="btn btn-outline-secondary" id="syncFull">Kør fuld sync</button><button class="btn btn-outline-primary" id="syncIncremental">Kør inkrementel sync</button><button class="btn btn-outline-danger" id="syncFinal">Kør final sync</button><button class="btn btn-outline-success" id="approveFinal">Godkend final kontrol</button><a class="btn btn-dark" id="bundleExport">Download pakke til prod</a><button class="btn btn-outline-dark" id="bundleImportBtn">Importér arkivpakke</button><input type="file" id="bundleImportFile" accept=".zip" hidden></div></div>
<div class="row g-3 align-items-end">
<div class="col-lg-3"><label class="form-label fw-semibold">Arkivversion</label><select id="archiveVersion" class="form-select"></select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">vTiger fra</label><input id="vtFrom" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">vTiger til</label><input id="vtTo" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">Hub fra</label><input id="hubFrom" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">Hub til</label><input id="hubTo" type="date" class="form-control"></div>
<div class="col-lg-1 d-grid gap-2"><button id="runReport" class="btn btn-primary">Kør</button><a id="rawExport" class="btn btn-sm btn-outline-secondary">Rådata</a></div>
</div><div id="feedback" class="small mt-3 text-muted"></div>
</section>
<section id="resultArea" class="d-none">
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h4 mb-0">Resultat</h2><a id="excelExport" class="btn btn-success">Eksportér Excel</a></div>
<div class="row g-3 mb-4" id="kpis"></div>
<div class="ct-panel p-4 mb-4"><div class="d-flex align-items-center gap-2 mb-3"><i class="bi bi-speedometer2 text-primary fs-4"></i><h3 class="h5 mb-0">Hvad Hub effektiviserer og optimerer</h3></div><div id="impactEvidence" class="row g-3"></div><div class="small text-muted mt-3">Positive og negative ændringer vises ensartet. Tallene sammenligner lige lange perioder og er dokumentation—ikke en skjult vægtet score.</div></div>
<div class="ct-panel p-3 p-lg-4 mb-4"><h3 class="h5 mb-3">Pr. medarbejder</h3><div class="table-responsive"><table class="table align-middle ct-table"><thead><tr><th>Kilde</th><th>Medarbejder</th><th>E-mail</th><th>Timer</th><th>Registreringer</th><th>Sager</th><th>Ordrer</th><th>Timer/dag</th></tr></thead><tbody id="employeeRows"></tbody></table></div></div>
<div class="row g-4"><div class="col-lg-7"><div class="ct-panel p-4 h-100"><h3 class="h5">Afvigelser udeladt fra KPI</h3><div id="anomalies"></div></div></div><div class="col-lg-5"><div class="ct-panel p-4 h-100"><h3 class="h5">Datakvalitet</h3><div id="quality"></div></div></div></div>
</section>
<section class="ct-panel p-4 mt-4"><h2 class="h5 mb-3">Gemte rapporter</h2><div id="savedReports" class="list-group list-group-flush"></div></section>
<section class="ct-panel p-4 mt-4"><div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="h5 mb-1">Flytning mellem test og produktion</h2><div class="small text-muted">Checksummede arkivpakker. Import læser kun ZIP-filen og kontakter aldrig vTiger.</div><div class="small fw-semibold mt-1" id="fileArchiveStatus"></div></div><button class="btn btn-sm btn-outline-secondary" id="refreshTransfers">Opdatér</button></div><div id="transferRows" class="table-responsive ct-empty">Ingen overførsler endnu.</div></section>
</main>
{% endblock %}
{% block extra_js %}
<script>
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const iso=d=>d.toISOString().slice(0,10); let optionsData={};
function defaults(){const now=new Date(),hTo=new Date(now),hFrom=new Date(now),vTo=new Date(now),vFrom=new Date(now);hFrom.setDate(hFrom.getDate()-29);vTo.setDate(hFrom.getDate()-1);vFrom.setDate(vTo.getDate()-29);hubTo.value=iso(hTo);hubFrom.value=iso(hFrom);vtTo.value=iso(vTo);vtFrom.value=iso(vFrom)}
async function api(url,opt={}){const r=await fetch(url,{credentials:'include',...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});if(!r.ok){let e={};try{e=await r.json()}catch{}throw new Error(e.detail||`HTTP ${r.status}`)}return r.json()}
async function loadOptions(){optionsData=await api('/api/v1/admin/hub-impact/options');archiveVersion.innerHTML=optionsData.versions.map(v=>`<option value="${v.id}">Version ${v.id} · ${esc(v.sync_kind)} · ${new Date(v.completed_at).toLocaleString('da-DK')}${v.control_approved_at?' · godkendt':''}</option>`).join('');setRawLink();const ready=optionsData.readiness.ready;readinessBadge.className=`ct-status ${ready?'ready':'blocked'}`;readinessBadge.textContent=ready?'Klar til opsigelse':'Ikke klar til opsigelse';readinessReasons.textContent=(optionsData.readiness.reasons||[]).join(' · ');savedReports.innerHTML=optionsData.reports.length?optionsData.reports.map(r=>`<button class="list-group-item list-group-item-action d-flex justify-content-between" onclick="openReport(${r.id})"><span>Rapport #${r.id} · arkiv v${r.archive_version_id}</span><small>${esc(r.vtiger_from)}${esc(r.vtiger_to)} / ${esc(r.hub_from)}${esc(r.hub_to)}</small></button>`).join(''):'<div class="text-muted">Ingen gemte rapporter endnu.</div>'}
async function loadTransfers(){const data=await api('/api/v1/admin/vtiger-archive/status');const files=data.file_archive||[];fileArchiveStatus.textContent=files.length?`Originale filer i arkivet: ${files.map(x=>`${x.source_module} ${x.files} (${(Number(x.size_bytes)/1048576).toFixed(1)} MB)`).join(' · ')}`:'Ingen originale filer arkiveret endnu.';const rows=data.transfers||[];transferRows.className='table-responsive';transferRows.innerHTML=rows.length?`<table class="table align-middle ct-table mb-0"><thead><tr><th>Tid</th><th>Retning</th><th>Status</th><th>Til version</th><th>Checksum</th><th>Indhold</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${new Date(x.completed_at||x.started_at).toLocaleString('da-DK')}</td><td>${x.direction==='export'?'Test → prod':'Pakke → denne server'}</td><td><span class="badge ${x.status==='completed'?'bg-success':x.status==='failed'?'bg-danger':'bg-warning text-dark'}">${esc(x.status)}</span></td><td>${x.through_version_id||'—'}</td><td><code>${esc((x.bundle_sha256||'').slice(0,12))}${x.bundle_sha256?'…':'—'}</code></td><td>${Object.entries(x.counts||{}).map(([k,v])=>`${esc(k)}: ${v}`).join(' · ')||esc(x.error_message||'—')}</td></tr>`).join('')}</tbody></table>`:'<div class="ct-empty">Ingen overførsler endnu.</div>'}
function setRawLink(){rawExport.href=archiveVersion.value?`/api/v1/admin/vtiger-archive/versions/${archiveVersion.value}/raw-export`:'#';bundleExport.href=archiveVersion.value?`/api/v1/admin/vtiger-archive/bundle.zip?through_version_id=${archiveVersion.value}`:'#'}archiveVersion.onchange=setRawLink;
function render(id,r){resultArea.classList.remove('d-none');excelExport.href=`/api/v1/admin/hub-impact/reports/${id}/export.xlsx`;const labels={hours:'Timer',time_entries:'Tidsregistreringer',cases:'Sager',orders:'Ordrer'};kpis.innerHTML=Object.entries(labels).map(([k,l])=>{const d=r.comparison[k],pct=r.improvement?.percent?.[k],cls=d>0?'positive':d<0?'negative':'';return `<div class="col-sm-6 col-xl-3"><div class="ct-kpi"><div class="text-muted small fw-bold">${l}</div><div class="d-flex justify-content-between mt-2"><span>vTiger <strong>${r.vtiger.totals[k]}</strong></span><span>Hub <strong>${r.hub.totals[k]}</strong></span></div><div class="ct-delta ${cls} mt-2 fw-bold">Forskel ${d>0?'+':''}${d}${pct==null?'':` · ${pct>0?'+':''}${pct}%`}</div></div></div>`}).join('');impactEvidence.innerHTML=(r.improvement?.evidence||[]).map((text,i)=>`<div class="col-md-6"><div class="ct-kpi d-flex gap-3 align-items-center"><span class="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style="width:34px;height:34px;flex:0 0 34px">${i+1}</span><strong class="fs-6">${esc(text)}</strong></div></div>`).join('')||'<div class="text-muted">Ikke nok vTiger-baseline til at beregne procentvis effekt.</div>';employeeRows.innerHTML=['vtiger','hub'].flatMap(src=>r[src].employees.map(x=>`<tr><td><span class="badge ${src==='hub'?'bg-primary':'bg-secondary'}">${src}</span></td><td>${esc(x.name)}</td><td>${esc(x.email||'—')}</td><td>${x.hours}</td><td>${x.time_entries}</td><td>${x.cases}</td><td>${x.orders}</td><td>${x.productivity.hours_per_workday}</td></tr>`)).join('');const aa=[...r.vtiger.anomalies,...r.hub.anomalies];anomalies.innerHTML=aa.length?aa.map(a=>`<div class="border-bottom py-2"><strong>${esc(a.source)}</strong> · ${esc(a.type)} · #${esc(a.id)} · ${a.hours} timer</div>`).join(''):'<div class="text-success">Ingen afvigelser i perioderne.</div>';quality.innerHTML=['vtiger','hub'].map(src=>`<div class="mb-3"><strong>${src}</strong><div>Manglende datoer: ${r[src].data_quality.missing_dates}</div><div>Ikke matchede medarbejdere: ${r[src].data_quality.unmatched_employees.length}</div></div>`).join('')}
async function openReport(id){feedback.textContent='Indlæser rapport…';try{const row=await api(`/api/v1/admin/hub-impact/reports/${id}`);render(id,row.result);feedback.textContent=`Rapport #${id} indlæst.`}catch(e){feedback.textContent=e.message}}
runReport.onclick=async()=>{runReport.disabled=true;feedback.textContent='Beregner og gemmer rapport…';try{const x=await api('/api/v1/admin/hub-impact/reports',{method:'POST',body:JSON.stringify({archive_version_id:Number(archiveVersion.value),vtiger_from:vtFrom.value,vtiger_to:vtTo.value,hub_from:hubFrom.value,hub_to:hubTo.value})});render(x.id,x.result);feedback.textContent=`Rapport #${x.id} er gemt reproducerbart.`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{runReport.disabled=false}};
async function sync(kind,button){if(kind==='final'&&!confirm('Final sync bruges som grundlag for opsigelsesgodkendelsen. Fortsæt?'))return;button.disabled=true;feedback.textContent=`Kører ${kind} sync…`;try{const r=await api(`/api/v1/admin/vtiger-archive/sync/${kind}`,{method:'POST'});feedback.textContent=`Arkivversion ${r.version_id}: ${r.status}`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{button.disabled=false}}
approveFinal.onclick=async()=>{const final=optionsData.versions.find(v=>v.sync_kind==='final');if(!final){feedback.textContent='Der findes endnu ingen fuldført final sync.';return}approveFinal.disabled=true;try{await api(`/api/v1/admin/vtiger-archive/versions/${final.id}/approve-control`,{method:'POST'});feedback.textContent=`Kontrolrapport for final version ${final.id} er godkendt.`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{approveFinal.disabled=false}};
bundleImportBtn.onclick=()=>bundleImportFile.click();bundleImportFile.onchange=async()=>{const file=bundleImportFile.files[0];if(!file)return;if(!confirm(`Importér ${file.name}? Importen bruger kun filen og kontakter aldrig vTiger.`))return;bundleImportBtn.disabled=true;feedback.textContent='Validerer checksums og importerer hele arkivpakken…';try{const form=new FormData();form.append('file',file);const response=await fetch('/api/v1/admin/vtiger-archive/import-bundle',{method:'POST',body:form,credentials:'include'});const data=await response.json();if(!response.ok)throw new Error(data.detail||`HTTP ${response.status}`);feedback.textContent=data.already_imported?'Denne præcise pakke var allerede importeret; intet blev duplikeret.':`Import færdig: ${data.counts.records} revisioner, ${data.counts.relations} relationer. vTiger kontaktet: nej.`;await Promise.all([loadOptions(),loadTransfers()])}catch(e){feedback.textContent=e.message}finally{bundleImportBtn.disabled=false;bundleImportFile.value=''}};
refreshTransfers.onclick=()=>loadTransfers().catch(e=>feedback.textContent=e.message);syncFull.onclick=()=>sync('full',syncFull);syncIncremental.onclick=()=>sync('incremental',syncIncremental);syncFinal.onclick=()=>sync('final',syncFinal);defaults();Promise.all([loadOptions(),loadTransfers()]).catch(e=>feedback.textContent=e.message);
</script>
{% endblock %}

235
app/admin/hub_impact.py Normal file
View File

@ -0,0 +1,235 @@
"""Reproducible vTiger-versus-Hub impact metrics for Project CT."""
from __future__ import annotations
import hashlib
import json
from collections import defaultdict
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any
from app.core.database import execute_insert, execute_query, execute_query_single
from app.services.vtiger_service import VTigerService
DATE_FIELDS = ("worked_date", "date", "createdtime", "created_at", "modifiedtime")
EMAIL_FIELDS = ("email1", "email", "email_address", "user_email")
USER_REF_FIELDS = ("assigned_user_id", "creator", "created_by", "userid", "user_id")
def _date(value: Any) -> date | None:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
try:
return datetime.fromisoformat(str(value or "").strip().replace("Z", "+00:00")).date()
except (ValueError, TypeError):
return None
def _first(payload: dict, fields: tuple[str, ...]):
return next((payload.get(field) for field in fields if payload.get(field) not in (None, "")), None)
def _hours(payload: dict) -> Decimal:
return VTigerService._extract_timelog_hours(payload)
def _archive_snapshot(version_id: int) -> list[dict[str, Any]]:
return execute_query(
"""SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,payload,is_deleted
FROM vtiger_archive_records WHERE version_id <= %s
ORDER BY module,vtiger_id,revision_no DESC""", (version_id,),
) or []
def _hub_users() -> tuple[dict[int, dict], dict[str, dict]]:
rows = execute_query("SELECT user_id,email,full_name,username FROM users") or []
by_id = {int(row["user_id"]): dict(row) for row in rows}
by_email = {str(row.get("email") or "").strip().lower(): dict(row) for row in rows if row.get("email")}
return by_id, by_email
def _employee_bucket(store: dict, key: str, name: str, email: str | None):
if key not in store:
store[key] = {"key": key, "name": name, "email": email, "hours": Decimal("0"),
"time_entries": 0, "cases": 0, "orders": 0}
return store[key]
def _finalize(metrics: dict[str, Any], start: date, end: date) -> dict[str, Any]:
days = (end - start).days + 1
workdays = max(sum(1 for offset in range(days) if (start + timedelta(days=offset)).weekday() < 5), 1)
employees = []
for item in metrics["employees"].values():
normalized = dict(item)
normalized["hours"] = round(float(item["hours"]), 2)
normalized["productivity"] = {
"hours_per_workday": round(normalized["hours"] / workdays, 2),
"registrations_per_workday": round(normalized["time_entries"] / workdays, 2),
"cases_per_workday": round(normalized["cases"] / workdays, 2),
"orders_per_workday": round(normalized["orders"] / workdays, 2),
"deliveries_per_workday": round((normalized["time_entries"] + normalized["cases"] + normalized["orders"]) / workdays, 2),
}
employees.append(normalized)
employees.sort(key=lambda row: (row.get("name") or "").lower())
totals = {key: sum(float(row[key]) for row in employees) for key in ("hours", "time_entries", "cases", "orders")}
totals["hours"] = round(totals["hours"], 2)
totals["time_entries"] = int(totals["time_entries"])
totals["cases"] = int(totals["cases"])
totals["orders"] = int(totals["orders"])
totals["productivity"] = {
"hours_per_workday": round(totals["hours"] / workdays, 2),
"registrations_per_workday": round(totals["time_entries"] / workdays, 2),
"cases_per_workday": round(totals["cases"] / workdays, 2),
"orders_per_workday": round(totals["orders"] / workdays, 2),
"deliveries_per_workday": round((totals["time_entries"] + totals["cases"] + totals["orders"]) / workdays, 2),
"hours_per_case_or_order": round(totals["hours"] / max(totals["cases"] + totals["orders"], 1), 2),
}
return {"totals": totals, "employees": employees, "anomalies": metrics["anomalies"],
"data_quality": metrics["data_quality"], "workdays": workdays}
def _vtiger_metrics(records: list[dict], start: date, end: date, hub_email_users: dict[str, dict]) -> dict:
active = [row for row in records if not row.get("is_deleted")]
vt_users = {}
for row in active:
if row["module"] != "Users":
continue
payload = row["payload"] or {}
email = str(_first(payload, EMAIL_FIELDS) or "").strip().lower()
vt_users[row["vtiger_id"]] = {
"email": email or None,
"name": str(payload.get("first_name") or "") + " " + str(payload.get("last_name") or payload.get("user_name") or row["vtiger_id"]),
}
result = {"employees": {}, "anomalies": [], "data_quality": {"unmatched_employees": [], "missing_dates": 0}}
module_metric = {"Timelog": "time_entries", "Cases": "cases", "SalesOrder": "orders"}
for row in active:
metric = module_metric.get(row["module"])
if not metric:
continue
payload = row["payload"] or {}
occurred = _date(_first(payload, DATE_FIELDS))
if not occurred:
result["data_quality"]["missing_dates"] += 1
continue
if occurred < start or occurred > end:
continue
user_ref = str(_first(payload, USER_REF_FIELDS) or "")
vt_user = vt_users.get(user_ref, {"email": None, "name": user_ref or "Ukendt vTiger-bruger"})
email = vt_user.get("email")
matched = hub_email_users.get(email) if email else None
key = f"hub:{matched['user_id']}" if matched else f"vtiger:{user_ref or 'unknown'}"
name = str(matched.get("full_name") or matched.get("username")) if matched else str(vt_user.get("name") or key).strip()
bucket = _employee_bucket(result["employees"], key, name, email)
if not matched and key not in result["data_quality"]["unmatched_employees"]:
result["data_quality"]["unmatched_employees"].append(key)
if metric == "time_entries":
hours = _hours(payload)
if hours > 16:
result["anomalies"].append({"source": "vtiger", "type": "over_16_hours", "id": row["vtiger_id"], "hours": float(hours)})
continue
bucket["hours"] += hours
bucket[metric] += 1
return result
def _hub_metrics(start: date, end: date, hub_users: dict[int, dict]) -> dict:
result = {"employees": {}, "anomalies": [], "data_quality": {"unmatched_employees": [], "missing_dates": 0}}
times = execute_query(
"""SELECT id,medarbejder_id,worked_date,start_tid,created_at,aktiv_timer,
COALESCE(faktisk_tid_min::numeric/60,approved_hours,original_hours,0) AS hours
FROM tmodule_times WHERE vtiger_id IS NULL
AND COALESCE(worked_date,start_tid::date,created_at::date) BETWEEN %s AND %s""", (start, end),
) or []
for row in times:
user = hub_users.get(int(row["medarbejder_id"])) if row.get("medarbejder_id") else None
key = f"hub:{user['user_id']}" if user else "hub:unknown"
bucket = _employee_bucket(result["employees"], key,
str((user or {}).get("full_name") or (user or {}).get("username") or "Ukendt Hub-bruger"),
(user or {}).get("email"))
if not user and key not in result["data_quality"]["unmatched_employees"]:
result["data_quality"]["unmatched_employees"].append(key)
hours = Decimal(str(row.get("hours") or 0))
if row.get("aktiv_timer") or hours > 16:
result["anomalies"].append({"source": "hub", "type": "active_timer" if row.get("aktiv_timer") else "over_16_hours",
"id": row["id"], "hours": float(hours)})
continue
bucket["hours"] += hours
bucket["time_entries"] += 1
cases = execute_query(
"""SELECT id,created_by_user_id,created_at FROM sag_sager
WHERE deleted_at IS NULL AND created_at::date BETWEEN %s AND %s""", (start, end),
) or []
orders = execute_query(
"SELECT id,created_by,order_date FROM tmodule_orders WHERE order_date BETWEEN %s AND %s", (start, end),
) or []
for rows, metric, user_field in ((cases, "cases", "created_by_user_id"), (orders, "orders", "created_by")):
for row in rows:
user = hub_users.get(int(row[user_field])) if row.get(user_field) else None
key = f"hub:{user['user_id']}" if user else "hub:unknown"
bucket = _employee_bucket(result["employees"], key,
str((user or {}).get("full_name") or (user or {}).get("username") or "Ukendt Hub-bruger"),
(user or {}).get("email"))
if not user and key not in result["data_quality"]["unmatched_employees"]:
result["data_quality"]["unmatched_employees"].append(key)
bucket[metric] += 1
return result
def build_impact_report(version_id: int, vtiger_from: date, vtiger_to: date, hub_from: date, hub_to: date) -> dict:
vt_days = (vtiger_to - vtiger_from).days + 1
hub_days = (hub_to - hub_from).days + 1
if vt_days <= 0 or hub_days <= 0 or vt_days != hub_days:
raise ValueError("vTiger- og Hub-perioderne skal være gyldige og lige lange")
version = execute_query_single(
"SELECT id,status,source_cutoff FROM vtiger_archive_versions WHERE id=%s", (version_id,),
)
if not version or version.get("status") != "completed":
raise ValueError("Vælg en fuldført arkivversion")
by_id, by_email = _hub_users()
vtiger = _finalize(_vtiger_metrics(_archive_snapshot(version_id), vtiger_from, vtiger_to, by_email), vtiger_from, vtiger_to)
hub = _finalize(_hub_metrics(hub_from, hub_to, by_id), hub_from, hub_to)
keys = ("hours", "time_entries", "cases", "orders")
delta = {key: round(float(hub["totals"][key]) - float(vtiger["totals"][key]), 2) for key in keys}
percent = {
key: (round(delta[key] / float(vtiger["totals"][key]) * 100, 1) if float(vtiger["totals"][key]) else None)
for key in keys
}
productivity_keys = ("hours_per_workday", "registrations_per_workday", "cases_per_workday",
"orders_per_workday", "deliveries_per_workday", "hours_per_case_or_order")
productivity_delta = {}
for key in productivity_keys:
old, new = float(vtiger["totals"]["productivity"][key]), float(hub["totals"]["productivity"][key])
productivity_delta[key] = {"delta": round(new - old, 2), "percent": round((new - old) / old * 100, 1) if old else None}
evidence = []
for key, label in (("deliveries_per_workday", "samlet registreret output pr. arbejdsdag"),
("cases_per_workday", "sager pr. arbejdsdag"),
("orders_per_workday", "ordrer pr. arbejdsdag"),
("registrations_per_workday", "tidsregistreringer pr. arbejdsdag")):
change = productivity_delta[key]
if change["percent"] is not None:
direction = "mere" if change["percent"] >= 0 else "mindre"
evidence.append(f"Hub registrerer {abs(change['percent']):.1f}% {direction} {label}.")
return {
"archive_version": {"id": version_id, "source_cutoff": version.get("source_cutoff")},
"periods": {"vtiger": {"from": vtiger_from, "to": vtiger_to, "days": vt_days},
"hub": {"from": hub_from, "to": hub_to, "days": hub_days}},
"vtiger": vtiger, "hub": hub,
"comparison": delta,
"improvement": {"percent": percent, "productivity": productivity_delta, "evidence": evidence},
}
def save_impact_report(result: dict, generated_by: int) -> int:
canonical = json.dumps(result, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
periods = result["periods"]
return int(execute_insert(
"""INSERT INTO hub_impact_reports
(archive_version_id,vtiger_from,vtiger_to,hub_from,hub_to,result,result_sha256,generated_by)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s,%s) RETURNING id""",
(result["archive_version"]["id"], periods["vtiger"]["from"], periods["vtiger"]["to"],
periods["hub"]["from"], periods["hub"]["to"], canonical,
hashlib.sha256(canonical.encode()).hexdigest(), generated_by),
))

344
app/admin/router.py Normal file
View File

@ -0,0 +1,344 @@
"""Superadmin-only Project CT archive endpoints."""
from __future__ import annotations
import hashlib
import io
import json
import os
import tempfile
from datetime import date
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse, Response
from starlette.background import BackgroundTask
from pydantic import BaseModel
from app.admin.vtiger_archive import ARCHIVE_MODULES, run_archive_sync, termination_readiness
from app.admin.hub_impact import build_impact_report, save_impact_report
from app.admin.archive_bundle import create_archive_bundle, file_sha256, import_archive_bundle
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query, execute_query_single
router = APIRouter()
class ImpactReportRequest(BaseModel):
archive_version_id: int
vtiger_from: date
vtiger_to: date
hub_from: date
hub_to: date
def require_hidden_superadmin(current_user: dict = Depends(get_current_user)) -> dict:
# This feature must not disclose its existence to ordinary users.
if not current_user.get("is_superadmin"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
return current_user
@router.get("/admin/vtiger-archive/status")
async def archive_status(current_user: dict = Depends(require_hidden_superadmin)):
versions = execute_query(
"""SELECT id,sync_kind,status,started_at,completed_at,source_cutoff,module_counts,
critical_errors,control_report,control_approved_at,raw_export_sha256
FROM vtiger_archive_versions ORDER BY id DESC LIMIT 25"""
) or []
checkpoints = execute_query(
"SELECT * FROM vtiger_archive_checkpoints ORDER BY module"
) or []
transfers = execute_query(
"""SELECT id,direction,bundle_sha256,through_version_id,source_instance,status,counts,
error_message,started_at,completed_at
FROM vtiger_archive_transfers ORDER BY id DESC LIMIT 25"""
) or []
file_archive = execute_query(
"""SELECT source_module,COUNT(*)::integer AS files,SUM(size_bytes)::bigint AS size_bytes
FROM vtiger_archive_files GROUP BY source_module ORDER BY source_module"""
) or []
return {"versions": versions, "checkpoints": checkpoints, "transfers": transfers,
"file_archive": file_archive, "readiness": termination_readiness()}
@router.post("/admin/vtiger-archive/sync/{sync_kind}")
async def archive_sync(sync_kind: str, current_user: dict = Depends(require_hidden_superadmin)):
return await run_archive_sync(sync_kind, int(current_user["id"]))
@router.post("/admin/vtiger-archive/versions/{version_id}/approve-control")
async def approve_control(version_id: int, current_user: dict = Depends(require_hidden_superadmin)):
version = execute_query_single(
"SELECT * FROM vtiger_archive_versions WHERE id=%s", (version_id,),
)
if not version:
raise HTTPException(status_code=404, detail="Arkivversionen findes ikke")
if version.get("status") != "completed" or version.get("critical_errors"):
raise HTTPException(status_code=409, detail="En fejlet eller ukomplet kontrolrapport kan ikke godkendes")
counts = version.get("module_counts") or {}
missing = [module for module in ARCHIVE_MODULES if module not in counts]
if missing:
raise HTTPException(status_code=409, detail=f"Kontrolrapport mangler moduler: {', '.join(missing)}")
execute_query(
"""UPDATE vtiger_archive_versions SET control_approved_at=NOW(),control_approved_by=%s
WHERE id=%s""", (current_user["id"], version_id), fetch=False,
)
return {"approved": True, "version_id": version_id, "readiness": termination_readiness()}
@router.get("/admin/vtiger-archive/versions/{version_id}/raw-export")
async def raw_export(
version_id: int,
module: str | None = Query(None),
current_user: dict = Depends(require_hidden_superadmin),
):
version = execute_query_single("SELECT id FROM vtiger_archive_versions WHERE id=%s", (version_id,))
if not version:
raise HTTPException(status_code=404, detail="Arkivversionen findes ikke")
params: list[object] = [version_id]
module_filter = ""
if module:
module_filter = "AND module=%s"
params.append(module)
rows = execute_query(
f"""SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,revision_no,
source_created_at,source_modified_at,is_deleted,payload,payload_sha256
FROM vtiger_archive_records WHERE version_id <= %s {module_filter}
ORDER BY module,vtiger_id,revision_no DESC""", tuple(params),
) or []
relations = execute_query(
"""SELECT source_module,source_vtiger_id,field_name,target_vtiger_id,target_module
FROM vtiger_archive_relations WHERE version_id <= %s
ORDER BY source_module,source_vtiger_id,field_name,target_vtiger_id""", (version_id,),
) or []
version_meta = execute_query_single(
"""SELECT id,sync_kind,status,started_at,completed_at,source_cutoff,module_counts,
warnings,critical_errors,control_report,control_approved_at
FROM vtiger_archive_versions WHERE id=%s""", (version_id,),
) or {}
export_lines = [{"record_type": "archive_version", "data": dict(version_meta)}]
export_lines.extend({"record_type": "entity", "data": dict(row)} for row in rows)
export_lines.extend({"record_type": "relation", "data": dict(row)} for row in relations)
body = "\n".join(json.dumps(item, ensure_ascii=False, default=str, sort_keys=True) for item in export_lines) + "\n"
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
if not module:
execute_query(
"UPDATE vtiger_archive_versions SET raw_export_sha256=%s WHERE id=%s",
(digest, version_id), fetch=False,
)
filename = f"project-ct-vtiger-v{version_id}{'-' + module if module else ''}.jsonl"
return Response(
content=body,
media_type="application/x-ndjson",
headers={"Content-Disposition": f'attachment; filename="{filename}"', "X-Content-SHA256": digest},
)
@router.get("/admin/vtiger-archive/readiness")
async def readiness(current_user: dict = Depends(require_hidden_superadmin)):
return termination_readiness()
@router.get("/admin/vtiger-archive/bundle.zip")
async def export_archive_bundle(
through_version_id: int | None = Query(None),
current_user: dict = Depends(require_hidden_superadmin),
):
if through_version_id is None:
latest = execute_query_single(
"SELECT id FROM vtiger_archive_versions WHERE status='completed' ORDER BY id DESC LIMIT 1"
)
if not latest:
raise HTTPException(status_code=409, detail="Der findes ingen fuldført arkivversion")
through_version_id = int(latest["id"])
selected = execute_query_single(
"SELECT id,status FROM vtiger_archive_versions WHERE id=%s", (through_version_id,),
)
if not selected:
raise HTTPException(status_code=404, detail="Arkivversionen findes ikke")
if selected.get("status") != "completed":
raise HTTPException(status_code=409, detail="Kun en fuldført arkivversion kan eksporteres til produktion")
transfer_id = execute_query_single(
"""INSERT INTO vtiger_archive_transfers(direction,through_version_id,status,initiated_by)
VALUES ('export',%s,'running',%s) RETURNING id""", (through_version_id, current_user["id"]),
)["id"]
try:
path, manifest = create_archive_bundle(through_version_id, int(current_user["id"]))
counts = {name: info["count"] for name, info in manifest["entries"].items()}
execute_query(
"""UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(),
bundle_sha256=%s,source_instance=%s,counts=%s::jsonb WHERE id=%s""",
(manifest["bundle_sha256"], manifest["source_instance"], json.dumps(counts), transfer_id), fetch=False,
)
return FileResponse(
path, media_type="application/zip",
filename=f"project-ct-vtiger-archive-v{through_version_id}.zip",
background=BackgroundTask(lambda: os.path.exists(path) and os.unlink(path)),
headers={"X-Archive-SHA256": manifest["bundle_sha256"], "X-vTiger-Contacted": "false"},
)
except Exception as exc:
execute_query(
"UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s",
(str(exc), transfer_id), fetch=False,
)
raise HTTPException(status_code=500, detail=f"Arkivpakken kunne ikke oprettes: {exc}") from exc
@router.post("/admin/vtiger-archive/import-bundle")
async def import_bundle(
file: UploadFile = File(...),
current_user: dict = Depends(require_hidden_superadmin),
):
if not str(file.filename or "").lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Vælg en Project CT .zip-arkivpakke")
fd, path = tempfile.mkstemp(prefix="project-ct-upload-", suffix=".zip")
os.close(fd)
transfer_id = execute_query_single(
"""INSERT INTO vtiger_archive_transfers(direction,status,initiated_by)
VALUES ('import','running',%s) RETURNING id""", (current_user["id"],),
)["id"]
try:
size = 0
with open(path, "wb") as target:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > 20 * 1024 * 1024 * 1024:
raise ValueError("Arkivpakken må højst fylde 20 GB")
target.write(chunk)
uploaded_sha256 = file_sha256(path)
previous = execute_query_single(
"""SELECT id,through_version_id,counts FROM vtiger_archive_transfers
WHERE direction='import' AND status='completed' AND bundle_sha256=%s
ORDER BY id DESC LIMIT 1""", (uploaded_sha256,),
)
if previous:
execute_query(
"""UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(),bundle_sha256=%s,
through_version_id=%s,counts=%s::jsonb WHERE id=%s""",
(uploaded_sha256, previous.get("through_version_id"), json.dumps(previous.get("counts") or {}), transfer_id),
fetch=False,
)
return {
"bundle_sha256": uploaded_sha256,
"counts": previous.get("counts") or {},
"contacted_vtiger": False,
"already_imported": True,
"original_transfer_id": previous["id"],
"transfer_id": transfer_id,
}
result = import_archive_bundle(path, int(current_user["id"]))
execute_query(
"""UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(),bundle_sha256=%s,
source_instance=%s,through_version_id=%s,counts=%s::jsonb WHERE id=%s""",
(result["bundle_sha256"], result["manifest"].get("source_instance"),
result["manifest"].get("through_version_id"), json.dumps(result["counts"]), transfer_id), fetch=False,
)
return {**result, "transfer_id": transfer_id}
except ValueError as exc:
execute_query(
"UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s",
(str(exc), transfer_id), fetch=False,
)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
execute_query(
"UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s",
(str(exc), transfer_id), fetch=False,
)
raise HTTPException(status_code=500, detail=f"Arkivpakken kunne ikke importeres: {exc}") from exc
finally:
if os.path.exists(path):
os.unlink(path)
@router.get("/admin/hub-impact/options")
async def impact_options(current_user: dict = Depends(require_hidden_superadmin)):
versions = execute_query(
"""SELECT id,sync_kind,completed_at,source_cutoff,module_counts,control_report,control_approved_at
FROM vtiger_archive_versions WHERE status='completed' ORDER BY id DESC"""
) or []
reports = execute_query(
"""SELECT id,archive_version_id,vtiger_from,vtiger_to,hub_from,hub_to,result_sha256,generated_at
FROM hub_impact_reports ORDER BY id DESC LIMIT 30"""
) or []
return {"versions": versions, "reports": reports, "readiness": termination_readiness()}
@router.post("/admin/hub-impact/reports")
async def create_impact_report(payload: ImpactReportRequest, current_user: dict = Depends(require_hidden_superadmin)):
try:
result = build_impact_report(payload.archive_version_id, payload.vtiger_from, payload.vtiger_to,
payload.hub_from, payload.hub_to)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
report_id = save_impact_report(result, int(current_user["id"]))
return {"id": report_id, "result": result}
@router.get("/admin/hub-impact/reports/{report_id}")
async def get_impact_report(report_id: int, current_user: dict = Depends(require_hidden_superadmin)):
row = execute_query_single("SELECT * FROM hub_impact_reports WHERE id=%s", (report_id,))
if not row:
raise HTTPException(status_code=404, detail="Rapporten findes ikke")
return row
def _impact_workbook(report: dict) -> bytes:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
wb = Workbook()
overview = wb.active
overview.title = "Overblik"
overview.append(["Project CT · Hub Impact", "vTiger", "Hub", "Forskel", "% ændring"])
for cell in overview[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="0F4C75")
labels = {"hours": "Timer", "time_entries": "Tidsregistreringer", "cases": "Sager", "orders": "Ordrer"}
for key, label in labels.items():
overview.append([label, report["vtiger"]["totals"][key], report["hub"]["totals"][key], report["comparison"][key], (report.get("improvement") or {}).get("percent", {}).get(key)])
overview.append([])
overview.append(["Arkivversion", report["archive_version"]["id"]])
overview.append(["vTiger-periode", f"{report['periods']['vtiger']['from']} {report['periods']['vtiger']['to']}"])
overview.append(["Hub-periode", f"{report['periods']['hub']['from']} {report['periods']['hub']['to']}"])
overview.freeze_panes = "A2"
overview.column_dimensions["A"].width = 28
for col in "BCDE": overview.column_dimensions[col].width = 18
evidence = wb.create_sheet("Effekt")
evidence.append(["Dokumenteret ændring"])
for line in (report.get("improvement") or {}).get("evidence", []):
evidence.append([line])
evidence.column_dimensions["A"].width = 90
employees = wb.create_sheet("Pr medarbejder")
employees.append(["Kilde", "Medarbejder", "E-mail", "Timer", "Registreringer", "Sager", "Ordrer", "Timer/arbejdsdag"])
for source in ("vtiger", "hub"):
for row in report[source]["employees"]:
employees.append([source, row["name"], row.get("email"), row["hours"], row["time_entries"], row["cases"], row["orders"], row["productivity"]["hours_per_workday"]])
for cell in employees[1]: cell.font = Font(bold=True)
employees.freeze_panes = "A2"
employees.auto_filter.ref = employees.dimensions
anomalies = wb.create_sheet("Afvigelser")
anomalies.append(["Kilde", "Type", "ID", "Timer"])
for source in ("vtiger", "hub"):
for row in report[source]["anomalies"]:
anomalies.append([row.get("source"), row.get("type"), row.get("id"), row.get("hours")])
quality = wb.create_sheet("Datakvalitet")
quality.append(["Kilde", "Manglende datoer", "Ikke matchede medarbejdere"])
for source in ("vtiger", "hub"):
data = report[source]["data_quality"]
quality.append([source, data["missing_dates"], ", ".join(data["unmatched_employees"])])
output = io.BytesIO()
wb.save(output)
return output.getvalue()
@router.get("/admin/hub-impact/reports/{report_id}/export.xlsx")
async def export_impact_report(report_id: int, current_user: dict = Depends(require_hidden_superadmin)):
row = execute_query_single("SELECT result FROM hub_impact_reports WHERE id=%s", (report_id,))
if not row:
raise HTTPException(status_code=404, detail="Rapporten findes ikke")
return Response(
content=_impact_workbook(row["result"]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="project-ct-impact-{report_id}.xlsx"'},
)

13
app/admin/views.py Normal file
View File

@ -0,0 +1,13 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.admin.router import require_hidden_superadmin
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/admin/hub-impact", response_class=HTMLResponse)
async def hub_impact_page(request: Request, current_user: dict = Depends(require_hidden_superadmin)):
return templates.TemplateResponse("admin/hub_impact.html", {"request": request, "current_user": current_user})

378
app/admin/vtiger_archive.py Normal file
View File

@ -0,0 +1,378 @@
"""Permanent append-only vTiger archive used by Project CT."""
from __future__ import annotations
import hashlib
import asyncio
import json
import logging
import re
from datetime import datetime, timezone
from typing import Any, Iterable
from app.core.database import execute_insert, execute_query, execute_query_single
from app.services.vtiger_service import VTigerService, get_vtiger_service
logger = logging.getLogger(__name__)
# Document metadata and original file bytes are both archived before vTiger is retired.
ARCHIVE_MODULES = (
"Users", "Groups", "Roles", "Currency", "Accounts", "Contacts", "Leads", "Potentials", "Cases", "Project",
"ProjectMilestone", "ModComments", "Timelog", "Calendar", "Events", "Emails",
"SalesOrder", "Quotes", "Invoice", "PurchaseOrder", "Vendors", "ServiceContracts",
"Subscription", "Products", "Services", "Assets", "Documents",
)
RELATION_FIELD_RE = re.compile(r"(?:^|_)(?:id|ids|account|contact|parent|related|assigned_user)$", re.I)
MODULE_BATCH_SIZES = {"Emails": 20, "Invoice": 50, "SalesOrder": 50, "Subscription": 50}
def _transient_vtiger_failure(service) -> bool:
status = service.last_query_status
error_text = json.dumps(service.last_query_error or {}).upper()
return status is None or status == 429 or (isinstance(status, int) and status >= 500) or any(
token in error_text for token in ("TOO_MANY_REQUESTS", "TIMEOUT", "TEMPORAR")
)
def _canonical_payload(record: dict[str, Any]) -> tuple[str, str]:
raw = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
return raw, hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _parse_source_time(value: Any):
text = str(value or "").strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
except ValueError:
return None
def _relation_values(payload: dict[str, Any]) -> Iterable[tuple[str, str]]:
for field, value in payload.items():
if not value or not RELATION_FIELD_RE.search(str(field)):
continue
values = value if isinstance(value, list) else [value]
for candidate in values:
candidate = str(candidate or "").strip()
if re.fullmatch(r"\d+x\d+", candidate):
yield str(field), candidate
def _target_module(vtiger_id: str) -> str | None:
prefix = str(vtiger_id).split("x", 1)[0]
row = execute_query_single(
"""SELECT module FROM vtiger_archive_records
WHERE split_part(vtiger_id,'x',1)=%s ORDER BY id DESC LIMIT 1""", (prefix,),
)
return str(row["module"]) if row else None
def archive_record(version_id: int, module: str, record: dict[str, Any]) -> bool:
vtiger_id = str(record.get("id") or "").strip()
if not vtiger_id:
raise ValueError(f"{module}-post mangler id")
raw, digest = _canonical_payload(record)
previous = execute_query_single(
"""SELECT revision_no, payload_sha256 FROM vtiger_archive_records
WHERE module=%s AND vtiger_id=%s ORDER BY revision_no DESC LIMIT 1""",
(module, vtiger_id),
)
if previous and previous.get("payload_sha256") == digest:
return False
revision = int((previous or {}).get("revision_no") or 0) + 1
execute_query(
"""INSERT INTO vtiger_archive_records
(version_id,module,vtiger_id,revision_no,source_created_at,source_modified_at,is_deleted,payload,payload_sha256)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s)""",
(version_id, module, vtiger_id, revision, _parse_source_time(record.get("createdtime")),
_parse_source_time(record.get("modifiedtime")), bool(record.get("deleted")), raw, digest),
fetch=False,
)
for field, target in _relation_values(record):
execute_query(
"""INSERT INTO vtiger_archive_relations
(version_id,source_module,source_vtiger_id,field_name,target_vtiger_id,target_module)
VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(version_id, module, vtiger_id, field, target, _target_module(target)), fetch=False,
)
return True
async def archive_document_files(version_id: int) -> dict[str, int]:
"""Archive immutable Document and Email bytes via vTiger's files_retrieve API."""
documents = execute_query(
"""SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,payload
FROM vtiger_archive_records
WHERE module IN ('Documents','Emails') AND version_id<=%s AND is_deleted=false
ORDER BY module,vtiger_id,revision_no DESC""", (version_id,),
) or []
stats = {"entities": len(documents), "resources": 0, "archived": 0, "unchanged": 0, "errors": 0}
# A small hard ceiling keeps this one-time preservation fast without flooding vTiger.
semaphore = asyncio.Semaphore(6)
jobs = []
async def archive_resource(document: dict, payload: dict, resource_id: str) -> None:
async with semaphore:
previous_resource = execute_query_single(
"""SELECT id FROM vtiger_archive_files
WHERE source_module=%s AND document_vtiger_id=%s AND resource_vtiger_id=%s
ORDER BY id DESC LIMIT 1""",
(document["module"], document["vtiger_id"], resource_id),
)
if previous_resource:
stats["unchanged"] += 1
return
service = VTigerService()
result = None
for attempt in range(8):
result = await service.retrieve_file(resource_id)
if result or not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if not result:
stats["errors"] += 1
return
content = result.get("content") or b""
digest = hashlib.sha256(content).hexdigest()
filename = str(result.get("filename") or payload.get("filename") or resource_id)
execute_query(
"""INSERT INTO vtiger_archive_files
(version_id,source_module,document_vtiger_id,resource_vtiger_id,filename,content_type,size_bytes,content_sha256,content)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(version_id, document["module"], document["vtiger_id"], resource_id, filename,
result.get("filetype") or payload.get("filetype"), len(content), digest, content),
fetch=False,
)
stats["archived"] += 1
await asyncio.sleep(0.35)
for document in documents:
payload = document.get("payload") or {}
resource_ids = re.findall(r"\d+x\d+", str(payload.get("imageattachmentids") or ""))
for resource_id in resource_ids:
stats["resources"] += 1
jobs.append(archive_resource(document, payload, resource_id))
if jobs:
await asyncio.gather(*jobs)
return stats
async def _fetch_module(module: str, modified_after=None) -> list[dict[str, Any]]:
service = get_vtiger_service()
records: list[dict[str, Any]] = []
seen_ids: set[str] = set()
offset = 0
batch_size = MODULE_BATCH_SIZES.get(module, 100)
while True:
clauses = []
if modified_after:
clauses.append(f"modifiedtime >= '{modified_after.isoformat()}'")
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
# This vTiger deployment paginates reliably with LIMIT offset,count.
# ID-watermarks skip records in modules whose entity IDs are compared as text.
query = f"SELECT * FROM {module}{where} ORDER BY id ASC LIMIT {offset}, {batch_size};"
batch = []
for attempt in range(8):
batch = await service.query(query)
if not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if not batch:
if service.last_query_error or (service.last_query_status not in (None, 200)):
raise RuntimeError(f"vTiger kunne ikke hente {module}: {service.last_query_error or service.last_query_status}")
break
fresh = [row for row in batch if str(row.get("id") or "") not in seen_ids]
if not fresh:
break
records.extend(fresh)
seen_ids.update(str(row.get("id") or "") for row in fresh)
# Payload-heavy modules may return fewer than the requested 100 records
# due to a response-size cap even though later offsets still exist.
offset += len(batch)
await asyncio.sleep(0.35)
return records
async def _source_module_count(module: str) -> int:
service = get_vtiger_service()
rows = []
for attempt in range(8):
rows = await service.query(f"SELECT count(*) FROM {module};")
if not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if not rows or rows[0].get("count") is None:
raise RuntimeError(
f"vTiger kunne ikke kontrollere antal poster i {module}: "
f"{service.last_query_error or service.last_query_status}"
)
return int(rows[0]["count"])
async def _close_relation_targets(version_id: int) -> dict[str, int]:
"""Fetch referenced records omitted from normal module lists (notably inactive users)."""
unresolved = execute_query(
"""SELECT DISTINCT r.target_vtiger_id
FROM vtiger_archive_relations r
WHERE r.version_id <= %s AND NOT EXISTS (
SELECT 1 FROM vtiger_archive_records t
WHERE t.vtiger_id=r.target_vtiger_id AND t.version_id <= %s
) ORDER BY r.target_vtiger_id""", (version_id, version_id),
) or []
prefix_rows = execute_query(
"""SELECT split_part(vtiger_id,'x',1) AS prefix,MIN(module) AS module
FROM vtiger_archive_records GROUP BY 1"""
) or []
module_by_prefix = {str(row["prefix"]): str(row["module"]) for row in prefix_rows}
module_by_prefix.update({"19": "Users", "21": "Currency", "53": "Roles"})
service = get_vtiger_service()
stats = {"requested": 0, "archived": 0, "not_found": 0, "errors": 0}
for item in unresolved:
target_id = str(item.get("target_vtiger_id") or "")
if not re.fullmatch(r"\d+x\d+", target_id):
continue
module = module_by_prefix.get(target_id.split("x", 1)[0])
if not module:
continue
stats["requested"] += 1
rows = []
for attempt in range(8):
rows = await service.query(f"SELECT * FROM {module} WHERE id='{target_id}' LIMIT 1;")
if not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if rows:
stats["archived"] += int(archive_record(version_id, module, rows[0]))
elif _transient_vtiger_failure(service):
stats["errors"] += 1
else:
stats["not_found"] += 1
await asyncio.sleep(0.35)
return stats
async def run_archive_sync(sync_kind: str, initiated_by: int | None = None) -> dict[str, Any]:
if sync_kind not in {"full", "incremental", "final"}:
raise ValueError("sync_kind skal være full, incremental eller final")
running = execute_query_single(
"""SELECT id,started_at FROM vtiger_archive_versions
WHERE status='running' ORDER BY id DESC LIMIT 1"""
)
if running:
age = datetime.now(timezone.utc) - running["started_at"]
if age.total_seconds() < 12 * 3600:
raise RuntimeError(f"Arkivsync version {running['id']} kører allerede")
execute_query(
"""UPDATE vtiger_archive_versions SET status='failed',completed_at=NOW(),
critical_errors='[{"error":"Stale sync blev lukket automatisk"}]'::jsonb WHERE id=%s""",
(running["id"],), fetch=False,
)
version_id = int(execute_insert(
"""INSERT INTO vtiger_archive_versions(sync_kind,initiated_by,source_cutoff)
VALUES (%s,%s,NOW()) RETURNING id""", (sync_kind, initiated_by),
))
counts: dict[str, Any] = {}
errors: list[dict[str, str]] = []
try:
for module in ARCHIVE_MODULES:
await asyncio.sleep(0.45)
checkpoint = execute_query_single(
"SELECT last_modified_at FROM vtiger_archive_checkpoints WHERE module=%s", (module,),
)
watermark = checkpoint.get("last_modified_at") if checkpoint and sync_kind == "incremental" else None
try:
source_total = await _source_module_count(module) if sync_kind in {"full", "final"} else None
records = await _fetch_module(module, watermark)
inserted = sum(1 for record in records if archive_record(version_id, module, record))
counts[module] = {"fetched": len(records), "new_revisions": inserted}
if source_total is not None:
counts[module]["source_total"] = source_total
if len(records) != source_total:
raise RuntimeError(f"Paritetsfejl: vTiger={source_total}, hentet={len(records)}")
source_times = [parsed for parsed in (_parse_source_time(r.get("modifiedtime")) for r in records) if parsed]
newest = max(source_times, default=None)
execute_query(
"""INSERT INTO vtiger_archive_checkpoints(module,last_modified_at,last_vtiger_id,last_successful_version_id)
VALUES (%s,%s,%s,%s) ON CONFLICT(module) DO UPDATE SET
last_modified_at=COALESCE(EXCLUDED.last_modified_at,vtiger_archive_checkpoints.last_modified_at),
last_vtiger_id=EXCLUDED.last_vtiger_id,last_successful_version_id=EXCLUDED.last_successful_version_id,
updated_at=NOW()""",
(module, newest, str(records[-1].get("id")) if records else None, version_id), fetch=False,
)
except Exception as exc:
logger.exception("Project CT sync failed for %s", module)
errors.append({"module": module, "error": str(exc)})
document_files = await archive_document_files(version_id) if sync_kind in {"full", "final"} else {
"entities": 0, "resources": 0, "archived": 0, "unchanged": 0, "errors": 0,
}
if document_files.get("errors"):
errors.append({
"module": "DocumentFiles",
"error": f"{document_files['errors']} dokumentfiler kunne ikke arkiveres",
})
relation_backfill = await _close_relation_targets(version_id)
if relation_backfill.get("errors"):
errors.append({
"module": "relations",
"error": f"{relation_backfill['errors']} relationer kunne ikke kontrolleres mod vTiger",
})
archived_counts = execute_query(
"""SELECT module,COUNT(*) AS records FROM (
SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,is_deleted
FROM vtiger_archive_records WHERE version_id <= %s
ORDER BY module,vtiger_id,revision_no DESC
) snapshot WHERE is_deleted=false GROUP BY module ORDER BY module""", (version_id,),
) or []
unresolved = execute_query_single(
"""SELECT COUNT(*)::integer AS count FROM vtiger_archive_relations r
WHERE r.version_id <= %s AND NOT EXISTS (
SELECT 1 FROM vtiger_archive_records t
WHERE t.vtiger_id=r.target_vtiger_id AND t.version_id <= %s
)""", (version_id, version_id),
) or {"count": 0}
document_quality = execute_query_single(
"""SELECT COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE COALESCE(payload->>'filename',payload->>'notes_title','')='')::integer AS missing_name
FROM (SELECT DISTINCT ON (vtiger_id) vtiger_id,payload,is_deleted
FROM vtiger_archive_records WHERE module='Documents' AND version_id <= %s
ORDER BY vtiger_id,revision_no DESC) d WHERE is_deleted=false""", (version_id,),
) or {"total": 0, "missing_name": 0}
report = {
"modules_expected": len(ARCHIVE_MODULES), "modules_completed": len(counts),
"missing_modules": [module for module in ARCHIVE_MODULES if module not in counts],
"module_sync": counts, "archived_snapshot_counts": archived_counts,
"relations": {"unresolved": int(unresolved.get("count") or 0)},
"relation_backfill": relation_backfill,
"documents": {**document_quality, "files": document_files}, "critical_errors": errors,
}
status = "failed" if errors else "completed"
execute_query(
"""UPDATE vtiger_archive_versions SET status=%s,completed_at=NOW(),module_counts=%s::jsonb,
critical_errors=%s::jsonb,control_report=%s::jsonb WHERE id=%s""",
(status, json.dumps(counts), json.dumps(errors), json.dumps(report), version_id), fetch=False,
)
return {"version_id": version_id, "status": status, "counts": counts, "critical_errors": errors}
except Exception as exc:
execute_query(
"UPDATE vtiger_archive_versions SET status='failed',completed_at=NOW(),critical_errors=%s::jsonb WHERE id=%s",
(json.dumps([{"error": str(exc)}]), version_id), fetch=False,
)
raise
def termination_readiness() -> dict[str, Any]:
final = execute_query_single(
"""SELECT id,status,completed_at,critical_errors,control_approved_at
FROM vtiger_archive_versions WHERE sync_kind='final' ORDER BY id DESC LIMIT 1"""
)
reasons = []
if not final or final.get("status") != "completed":
reasons.append("Final sync mangler eller er ikke fuldført")
if final and final.get("critical_errors"):
reasons.append("Final sync har kritiske fejl")
if not final or not final.get("control_approved_at"):
reasons.append("Kontrolrapporten er ikke godkendt")
return {"ready": not reasons, "reasons": reasons, "final_version": final}

View File

@ -16,6 +16,7 @@ from app.services.economic_service import get_economic_service
from app.services.ollama_service import ollama_service
from app.services.template_service import template_service
from app.services.invoice2data_service import get_invoice2data_service
from app.modules.internet_connections.backend.change_case_service import ensure_external_change_case
import logging
import os
import re
@ -26,15 +27,11 @@ router = APIRouter()
_PURCHASE_CASE_TYPE = "indkøb"
_INTERNET_CASE_RELEVANT_CHANGE_FIELDS = {
"address",
"service_address",
"monthly_cost",
"technology",
"connection_type",
"circuit_number",
"speed_mbps",
"download_mbps",
"upload_mbps",
"address", "service_address", "monthly_cost", "sales_price", "technology",
"connection_type", "circuit_number", "provider_reference", "provider", "vendor_id",
"speed_mbps", "download_mbps", "upload_mbps", "status", "sla_subscription_id",
"sla_price", "sla_status", "cidr", "contract_number", "range_added", "range_removed",
"range_monthly_cost", "range_sales_price",
}
SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist")
@ -267,90 +264,19 @@ def _ensure_internet_change_case(
owner_customer_id: Optional[int],
changes: Dict[str, Dict[str, object]],
) -> Optional[int]:
# Customer ownership, initial activation and internal classification are
# bookkeeping outcomes of a successful import, not operational incidents.
# Only create cases for changes that can affect delivery or billing.
relevant_changes = {
field: change
for field, change in (changes or {}).items()
if field in _INTERNET_CASE_RELEVANT_CHANGE_FIELDS
}
if not relevant_changes:
return None
title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}"
existing = execute_query_single(
"""
SELECT id
FROM sag_sager
WHERE deleted_at IS NULL
AND titel = %s
ORDER BY id DESC
LIMIT 1
""",
(title,),
outcome = ensure_external_change_case(
connection_id=connection_id,
source_type="globalconnect_invoice",
source_key=str(invoice_number),
source_label=f"GlobalConnect faktura {invoice_number}",
source_url="/billing/supplier-invoices",
reference=reference,
connection_name=connection_name,
provider="GlobalConnect",
owner_customer_id=owner_customer_id,
changes=changes,
)
if existing:
return int(existing["id"])
assigned_group_id = _resolve_group_id_by_name_tokens(["økonomi", "okonomi", "economic"])
try:
case_customer_id = owner_customer_id or _resolve_procurement_customer_id()
except Exception as exc:
logger.warning(
"Skipping automatic internet change case for connection %s on invoice %s: could not resolve case customer (%s)",
connection_id,
invoice_number,
exc,
)
return None
change_lines = "\n".join(
f"- {field}: {change.get('from')} -> {change.get('to')}"
for field, change in relevant_changes.items()
)
description = (
"Automatisk oprettet ved import af internetfaktura.\n"
f"Forbindelse: {connection_name}\n"
f"Reference: {reference or '-'}\n"
f"Faktura: {invoice_number}\n"
"Registrerede ændringer:\n"
f"{change_lines}"
)
try:
try:
row = execute_query_single(
"""
INSERT INTO sag_sager (
titel, beskrivelse, type, status, customer_id, assigned_group_id, created_by_user_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(title, description, _PURCHASE_CASE_TYPE, "åben", case_customer_id, assigned_group_id, 1),
)
except Exception as insert_error:
if 'column "type"' not in str(insert_error):
raise
row = execute_query_single(
"""
INSERT INTO sag_sager (
titel, beskrivelse, status, customer_id, assigned_group_id, created_by_user_id
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(title, description, "åben", case_customer_id, assigned_group_id, 1),
)
except Exception as exc:
logger.warning(
"Skipping automatic internet change case for connection %s on invoice %s: %s",
connection_id,
invoice_number,
exc,
)
return None
return int(row["id"]) if row and row.get("id") else None
return outcome.get("case_id")
def _ensure_case_for_supplier_invoice(
@ -705,6 +631,82 @@ def _normalize_provider_reference(value: Optional[str]) -> str:
return re.sub(r"[^A-Z0-9]", "", raw)
def _provider_reference_match_keys(value: Optional[str]) -> set[str]:
normalized = _normalize_provider_reference(value)
if not normalized:
return set()
keys = {normalized}
if normalized.startswith("DSLEB"):
keys.add(normalized[3:])
elif normalized.startswith("EB"):
keys.add(f"DSL{normalized}")
return keys
def _find_unique_globalconnect_connection_by_reference(reference: Optional[str]) -> Optional[int]:
target_keys = _provider_reference_match_keys(reference)
if not target_keys:
return None
rows = execute_query(
"""
SELECT id, circuit_number
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND provider ILIKE 'GlobalConnect%%'
AND NULLIF(BTRIM(circuit_number), '') IS NOT NULL
ORDER BY id
"""
) or []
matches = [row for row in rows if target_keys & _provider_reference_match_keys(row.get("circuit_number"))]
return int(matches[0]["id"]) if len(matches) == 1 else None
def _create_pending_connection_for_ip_reference(line: Dict, invoice_number: str) -> Optional[int]:
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
normalized_reference = _normalize_provider_reference(display_reference)
if not normalized_reference:
return None
existing_id = _find_unique_globalconnect_connection_by_reference(display_reference)
if existing_id:
return existing_id
service_address = _build_service_address(line)
connection_id = execute_insert(
"""
INSERT INTO internet_connections_connections (
name, provider, customer_id, address, status, monthly_cost, sales_price,
technology, connection_type, circuit_number, notes, allocation_model,
value_type, value_label
)
VALUES (%s, %s, NULL, %s, 'pending', 0, 0, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
f"Afventer mapping · {display_reference}",
"GlobalConnect A/S",
service_address,
"Internet",
"Internet",
display_reference,
f"Oprettet fra IP-range på faktura {invoice_number}. Kunde tildeles aldrig automatisk. Serviceadresse kræver manuel kontrol.",
"dedicated",
"other",
"Afventer manuel klassifikation",
),
)
return int(connection_id) if connection_id else None
def _canonical_ip_network(value: Optional[str]) -> str:
"""Canonicalize invoice IP/CIDR values before matching or persistence."""
raw = re.sub(r"\s+", "", str(value or "").strip())
if not raw:
return ""
try:
return str(ipaddress.ip_network(raw, strict=False))
except ValueError:
return ""
def _build_mapping_note(end_customer_name: str, service_address: Optional[str], reference: str) -> str:
parts = [f"Afventer mapping for {reference}."]
if end_customer_name:
@ -757,11 +759,9 @@ def _should_assign_internal_bmc_owner(
return False
if any(str(line.get("end_customer_name") or "").strip() for line in lines):
return False
if any(_looks_like_ip_range_line(line) for line in lines):
return True
if len(lines) > 1:
return True
return bool(service_address)
description = " ".join(str(line.get("description") or "").lower() for line in lines)
explicit_shared_markers = ("delefiber", "shared", "delt forbindelse", "delt transit", "backbone", "carrier transit")
return any(marker in description for marker in explicit_shared_markers)
def _shared_connection_value_type(internal_owner: Optional[Dict], matched_customer: Optional[Dict]) -> str:
@ -1068,17 +1068,21 @@ def _merge_globalconnect_duplicate_connections(connection_ids: List[int], canoni
def _merge_globalconnect_duplicate_ip_ranges(connection_id: int, cidr: str) -> Optional[int]:
matches = execute_query(
canonical_cidr = _canonical_ip_network(cidr)
candidates = execute_query(
"""
SELECT id
SELECT id, cidr
FROM internet_connections_ip_ranges
WHERE connection_id = %s
AND cidr = %s
AND deleted_at IS NULL
ORDER BY id
""",
(connection_id, cidr),
)
(connection_id,),
) or []
matches = [
row for row in candidates
if canonical_cidr and _canonical_ip_network(row.get("cidr")) == canonical_cidr
]
if not matches:
return None
@ -1116,18 +1120,19 @@ def _normalize_service_address_for_match(value: Optional[str]) -> str:
def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]:
if not reference:
return []
match_keys = sorted(_provider_reference_match_keys(reference))
rows = execute_query(
"""
SELECT id, customer_id, address, monthly_cost, technology, connection_type,
circuit_number, speed_mbps, download_mbps, upload_mbps, status,
allocation_model, value_type, value_label
allocation_model, value_type, value_label, is_manual_shared
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND provider ILIKE 'GlobalConnect%%'
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = %s
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = ANY(%s)
ORDER BY id
""",
(reference,),
(match_keys,),
) or []
return [dict(row) for row in rows]
@ -1216,44 +1221,30 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
if existing and merge_ids:
_merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"]))
matched_customer = _match_customer_for_globalconnect_line(primary_line, customers)
if not matched_customer and existing and existing.get("customer_id"):
# Preserve a previously reviewed owner when the new invoice has no
# unambiguous customer name/address instead of replacing it with BMC.
matched_customer = next(
(customer for customer in customers if int(customer.get("id") or 0) == int(existing["customer_id"])),
None,
)
# Supplier data may suggest a customer name, but customer ownership is
# always a manual CRM decision. Existing manually selected owners survive
# because update SQL uses COALESCE(NULL, customer_id); new records stay NULL.
matched_customer = None
description = str(primary_line.get("description") or reference)
end_customer_name = str(primary_line.get("end_customer_name") or "").strip()
# Internal BMC ownership is only a default for a newly discovered
# connection. An existing connection with no customer may deliberately be
# unassigned and must not gain an owner merely because a later invoice is
# ambiguous.
internal_owner = (
_resolve_internal_bmc_customer()
if not existing and _should_assign_internal_bmc_owner(lines, matched_customer, service_address)
else None
)
owner_customer = matched_customer or internal_owner
is_confident = _has_confident_globalconnect_mapping(matched_customer, service_address)
connection_name = (
end_customer_name or service_address or f"GlobalConnect {reference}"
if is_confident
else (f"{internal_owner['name']} · {display_reference}" if internal_owner else f"Afventer mapping · {display_reference}")
)
is_shared_candidate = _should_assign_internal_bmc_owner(lines, None, service_address)
internal_owner = None
owner_customer = None
is_confident = False
connection_name = end_customer_name or service_address or f"Afventer mapping · {display_reference}"
monthly_cost = sum((_line_monthly_cost(line) for line in lines), Decimal("0"))
note_lines = ", ".join(dict.fromkeys(str(line.get("description") or "").strip() for line in lines if line.get("description")))
base_note = f"Synced fra GlobalConnect faktura {invoice_number}. Komponenter: {note_lines}"
mapping_note = _build_mapping_note(end_customer_name, service_address, display_reference)
if internal_owner and not matched_customer:
note_text = f"{base_note} Ejer sat til intern BMC-kunde, da forbindelsen bruges som delt hovedforbindelse eller ikke kan bindes sikkert til én slutkunde."
else:
note_text = base_note if is_confident else f"{base_note} {mapping_note}"
note_text = f"{base_note} {mapping_note} Kunde tildeles aldrig automatisk."
download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description)
target_status = "active" if (is_confident or internal_owner) else "pending"
shared_value_type = _shared_connection_value_type(internal_owner, matched_customer)
target_status = "pending"
shared_value_type = "delefiber" if is_shared_candidate else "other"
payload = (
connection_name,
"GlobalConnect A/S",
@ -1267,14 +1258,27 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
upload_mbps,
download_mbps,
note_text,
"shared" if internal_owner and not matched_customer else "dedicated",
"shared" if is_shared_candidate else "dedicated",
shared_value_type,
None,
)
if existing:
# Delefiber/BMCnet classification is internal operational data. A
# supplier invoice may update speed and cost, but must never undo a
# deliberate manual shared/delefiber designation.
preserve_manual_classification = bool(existing.get("is_manual_shared"))
resolved_allocation_model = (
existing.get("allocation_model") if preserve_manual_classification
else ("shared" if is_shared_candidate else "dedicated")
)
resolved_value_type = (
existing.get("value_type") if preserve_manual_classification
else shared_value_type
)
resolved_value_label = existing.get("value_label") if preserve_manual_classification else None
updated_snapshot = {
"customer_id": owner_customer["id"] if owner_customer else None,
"customer_id": existing.get("customer_id"),
"address": service_address,
"monthly_cost": monthly_cost,
"technology": _infer_technology(description),
@ -1284,9 +1288,9 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
"download_mbps": download_mbps,
"upload_mbps": upload_mbps,
"status": target_status,
"allocation_model": "shared" if internal_owner and not matched_customer else "dedicated",
"value_type": shared_value_type,
"value_label": None,
"allocation_model": resolved_allocation_model,
"value_type": resolved_value_type,
"value_label": resolved_value_label,
}
update_payload = (
connection_name,
@ -1301,9 +1305,9 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
download_mbps,
upload_mbps,
note_text,
"shared" if internal_owner and not matched_customer else "dedicated",
shared_value_type,
None,
resolved_allocation_model,
resolved_value_type,
resolved_value_label,
existing["id"],
)
execute_update(
@ -1333,7 +1337,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
update_payload[1],
update_payload[2],
update_payload[3],
"active" if (is_confident or internal_owner) else "pending",
target_status,
update_payload[4],
update_payload[5],
update_payload[6],
@ -1398,7 +1402,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
payload[1],
payload[2],
payload[3],
"active" if (is_confident or internal_owner) else "pending",
target_status,
payload[4],
payload[5],
payload[6],
@ -1429,13 +1433,14 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_number: str):
cidr = str(line.get("ip_address") or "").strip()
cidr = _canonical_ip_network(line.get("ip_address"))
if not connection_id or not cidr:
return None
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
service_address = _build_service_address(line)
matched_customer = _match_customer_for_globalconnect_line(line, _load_active_customers_for_matching())
# Never infer range ownership from invoice text. A user must select it.
matched_customer = None
canonical_range_id = _merge_globalconnect_duplicate_ip_ranges(connection_id, cidr)
existing = execute_query_single(
"""
@ -1507,12 +1512,16 @@ def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_numbe
{"cidr": cidr, "changes": changes},
)
reference = display_reference or cidr
connection_owner = execute_query_single(
"SELECT customer_id FROM internet_connections_connections WHERE id=%s",
(connection_id,),
) or {}
_ensure_internet_change_case(
connection_id=connection_id,
invoice_number=invoice_number,
reference=reference,
connection_name=f"IP-range {cidr}",
owner_customer_id=matched_customer["id"] if matched_customer else None,
owner_customer_id=connection_owner.get("customer_id"),
changes=changes,
)
return range_id
@ -1557,6 +1566,28 @@ def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_numbe
f"IP-range {cidr} oprettet fra faktura {invoice_number}",
{"cidr": cidr, "provider_reference": params[1], "contract_number": params[2]},
)
# A range added to an existing connection is a commercial change. When the
# connection itself was created by this invoice, it is merely initial data.
created_with_invoice = execute_query_single(
"""SELECT 1 FROM internet_connections_history
WHERE connection_id=%s AND event_type='connection_created_from_supplier_invoice'
AND details->>'invoice_number'=%s LIMIT 1""",
(connection_id, invoice_number),
)
if not created_with_invoice:
connection = execute_query_single(
"""SELECT name, circuit_number, customer_id, provider
FROM internet_connections_connections WHERE id=%s""",
(connection_id,),
) or {}
_ensure_internet_change_case(
connection_id=connection_id,
invoice_number=invoice_number,
reference=str(connection.get("circuit_number") or display_reference or cidr),
connection_name=str(connection.get("name") or f"IP-range {cidr}"),
owner_customer_id=connection.get("customer_id"),
changes={"range_added": {"from": None, "to": cidr}},
)
return range_id
@ -1593,25 +1624,26 @@ def _connection_can_host_ip_range(connection_id: Optional[int], service_address:
def _resolve_existing_ip_range_connection(line: Dict) -> Dict[str, object]:
"""Use an existing CIDR as the strongest key, but never cross service addresses."""
cidr = str(line.get("ip_address") or "").strip()
cidr = _canonical_ip_network(line.get("ip_address"))
if not cidr:
return {"connection_id": None, "conflict_reason": None}
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
service_address = _build_service_address(line)
rows = execute_query(
"""
SELECT range.connection_id, range.service_address, range.provider_reference,
SELECT range.connection_id, range.cidr, range.service_address, range.provider_reference,
connection.address AS connection_address,
connection.circuit_number AS connection_reference
FROM internet_connections_ip_ranges range
JOIN internet_connections_connections connection ON connection.id = range.connection_id
WHERE range.cidr = %s
AND range.deleted_at IS NULL
WHERE range.deleted_at IS NULL
AND connection.deleted_at IS NULL
AND connection.provider ILIKE 'GlobalConnect%%'
ORDER BY range.id
""",
(cidr,),
(),
) or []
rows = [row for row in rows if _canonical_ip_network(row.get("cidr")) == cidr]
if reference:
matching_reference = [
row for row in rows
@ -1829,25 +1861,52 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
for audit_index, line in ip_range_lines:
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
service_address = _build_service_address(line)
reference_connection_id = _find_unique_globalconnect_connection_by_reference(reference)
existing_range_resolution = _resolve_existing_ip_range_connection(line)
if existing_range_resolution.get("conflict_reason"):
if existing_range_resolution.get("conflict_reason") and not reference_connection_id:
skipped_orphan_ip_ranges += 1
line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = existing_range_resolution["conflict_reason"]
continue
connection_id = existing_range_resolution.get("connection_id") or connection_map.get(reference)
mapped_reference_connection_id = connection_map.get(reference)
# An existing CIDR on the same service address is stronger evidence than
# a supplier reference. OCR/extraction can accidentally carry a circuit
# number from a neighbouring invoice line.
address_range_connection_id = existing_range_resolution.get("connection_id")
connection_id = address_range_connection_id or reference_connection_id or mapped_reference_connection_id
resolved_from_existing = False
if existing_range_resolution.get("connection_id"):
matched_by_reference = bool(
not address_range_connection_id
and (reference_connection_id or mapped_reference_connection_id)
)
if reference_connection_id or address_range_connection_id:
resolved_from_existing = True
corrected_service_address = None
if connection_id and not _connection_can_host_ip_range(connection_id, service_address):
connection_id = None
if not connection_id and reference:
if not service_address:
line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = "Mangler serviceadresse til IP-range"
if matched_by_reference:
authoritative_connection = execute_query_single(
"""
SELECT address
FROM internet_connections_connections
WHERE id = %s AND deleted_at IS NULL
""",
(connection_id,),
) or {}
corrected_service_address = str(authoritative_connection.get("address") or "").strip() or None
if not corrected_service_address:
connection_id = None
else:
connection_id = None
if not connection_id:
matched_by_reference = False
skipped_orphan_ip_ranges += 1
line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = "Kredsløbsreferencen findes på en anden serviceadresse"
continue
connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address)
if not connection_id and reference:
connection_id, connection_conflict_reason = (None, None)
if service_address:
connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address)
if connection_id:
connection_map[reference] = connection_id
resolved_from_existing = True
@ -1856,8 +1915,29 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = connection_conflict_reason
continue
elif not simulate and not reference.startswith("EB"):
connection_id = _create_pending_connection_for_ip_reference(line, invoice_number)
if connection_id:
connection_map[reference] = connection_id
created_or_updated_connections += 1
created_connections += 1
line_audit[audit_index]["created_pending_connection"] = True
elif simulate and not reference.startswith("EB"):
connection_id = -(len(connection_map) + 1)
if matched_by_reference:
line_audit[audit_index]["matched_by"] = "unique_circuit_reference"
if service_address and not _connection_can_host_ip_range(connection_id, service_address):
line_audit[audit_index]["address_warning"] = "IP-linjens serviceadresse afviger fra forbindelsen; kredsløbsnummer blev brugt"
sync_line = dict(line)
if corrected_service_address:
sync_line["service_address"] = corrected_service_address
line_audit[audit_index]["address_warning"] = (
f"Fakturaadressen '{service_address}' blev erstattet med kredsløbets adresse "
f"'{corrected_service_address}'"
)
line_audit[audit_index]["service_address_corrected"] = True
if existing_range_resolution.get("canonical_reference"):
sync_line["provider_reference"] = existing_range_resolution["canonical_reference"]
sync_line["circuit_id"] = existing_range_resolution["canonical_reference"]
@ -1883,6 +1963,22 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
connection_groups=len(grouped_connections),
ip_range_candidates=len(ip_range_lines),
)
case_creation_errors = []
if not simulate:
try:
case_creation_errors = execute_query(
"""SELECT connection_id, last_error AS error
FROM internet_connection_change_cases
WHERE source_type='globalconnect_invoice' AND source_key=%s
AND last_error IS NOT NULL
ORDER BY connection_id""",
(str(invoice_number),),
) or []
except Exception as exc:
# The invoice still completes during staggered migration rollout.
logger.warning("Could not load internet change-case control report: %s", exc)
verification["change_case_errors"] = case_creation_errors
verification["requires_manual_review"] = bool(case_creation_errors) or bool(verification.get("requires_manual_review"))
return {
"skipped": False,
@ -1897,6 +1993,7 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
"line_audit": line_audit,
"skipped_items": [entry for entry in line_audit if entry["status"] == "skipped"],
"verification": verification,
"change_case_errors": case_creation_errors,
}

View File

@ -37,6 +37,10 @@ class Settings(BaseSettings):
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
# Non-interactive service token for Apple Configurator/cfgutil provisioning.
# Leave empty to disable the endpoint rather than accepting unauthenticated calls.
MOBILE_RECORDER_PROVISIONING_TOKEN: str = ""
# Elnet supplier lookup
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
ELNET_TIMEOUT_SECONDS: int = 12
@ -225,6 +229,8 @@ class Settings(BaseSettings):
ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30
ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000
ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False
PROJECT_CT_ARCHIVE_SYNC_ENABLED: bool = True
PROJECT_CT_ARCHIVE_SYNC_INTERVAL_MINUTES: int = 360
# Backup System Configuration
BACKUP_ENABLED: bool = True

View File

@ -891,7 +891,7 @@
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
<i class="bi bi-chat-left-text"></i>Kontakt
<i class="bi bi-chat-left-text"></i>Kommunikation
</a>
</li>
<li class="nav-item">
@ -1241,11 +1241,14 @@
<div id="customerEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
</div>
<!-- Kontakt Tab -->
<!-- Kommunikation Tab -->
<div class="tab-pane fade" id="kontakt">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Kontakt historik</h5>
<div class="btn-group btn-group-sm" role="group" aria-label="Kontakt filter">
<div>
<h5 class="fw-bold mb-0">Kommunikationshistorik</h5>
<small class="text-muted">Opkald og SMSer med kunden</small>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Kommunikationsfilter">
<button type="button" class="btn btn-outline-secondary active" id="customerKontaktFilterAll" onclick="setCustomerKontaktFilter('all')">Alle</button>
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterSms" onclick="setCustomerKontaktFilter('sms')">SMS</button>
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterCall" onclick="setCustomerKontaktFilter('call')">Opkald</button>

View File

@ -15,6 +15,7 @@ from app.core.config import settings
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.utils.safe_html import sanitize_safe_html
from app.services.email_processor_service import EmailProcessorService
from app.services.email_service import EmailService
from app.services.email_workflow_service import email_workflow_service
from app.services.ollama_service import ollama_service
from app.services.simple_classifier import simple_classifier
@ -2262,6 +2263,37 @@ async def reprocess_email(email_id: int):
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/recover-attachments")
async def recover_email_attachments(email_id: int):
"""Recover a missing Graph attachment, then run the normal email workflow.
Intended for the old metadata-only attachment imports. It is safe to retry:
the normal invoice checksum and workflow safeguards remain in force.
"""
try:
recovery = await EmailService().recover_graph_attachments(email_id)
if not recovery.get("success"):
raise HTTPException(status_code=409, detail=recovery.get("reason", "Could not recover attachments"))
email_rows = execute_query(
"SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL", (email_id,)
)
if not email_rows:
raise HTTPException(status_code=404, detail="Email not found after recovery")
processing = await EmailProcessorService().process_single_email(email_rows[0])
return {
"success": True,
**recovery,
"workflows_executed": processing.get("workflows_executed", 0),
"awaiting_user_action": processing.get("awaiting_user_action", False),
}
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Error recovering email attachments for %s", email_id)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/process")
async def process_emails(
limit: Optional[int] = Query(default=None, ge=1, le=500),
@ -2379,10 +2411,10 @@ async def upload_emails(files: List[UploadFile] = File(...)):
logger.info(f"💾 Saved to database with ID: {email_id}")
# Log activity
activity_logger.log_fetched(
await activity_logger.log_fetched(
email_id=email_id,
source="manual_upload",
metadata={"filename": file.filename}
message_id=email_data.get("message_id", file.filename)
)
# Auto-classify

View File

@ -29,6 +29,8 @@ async def check_reminders():
try:
logger.info("🔔 Checking for pending reminders...")
from app.modules.sag.backend.reminders import process_task_list_rules
await process_task_list_rules()
# Step 1: Process queued trigger events (status changes)
queue_count = await _process_reminder_queue()

View File

@ -8,6 +8,7 @@ Runs daily at 04:00
import logging
from datetime import datetime, date
import json
from typing import Optional, Sequence
from dateutil.relativedelta import relativedelta
from app.core.database import execute_query, get_db_connection
@ -20,7 +21,7 @@ from app.services.subscription_billing_calendar import (
logger = logging.getLogger(__name__)
async def process_subscriptions():
async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None):
"""
Main job: Process subscriptions due for invoicing.
- Find active subscriptions where next_invoice_date <= today
@ -108,10 +109,19 @@ async def process_subscriptions():
SELECT 1 FROM subscription_billing_runs br
WHERE br.subscription_id = s.id AND br.period_start = s.period_start
)
"""
params = []
if subscription_ids is not None:
selected_ids = sorted({int(item) for item in subscription_ids})
if not selected_ids:
return
query += " AND s.id = ANY(%s)"
params.append(selected_ids)
query += """
ORDER BY s.next_invoice_date, s.id
"""
subscriptions = execute_query(query)
subscriptions = execute_query(query, tuple(params))
if not subscriptions:
logger.info("✅ No subscriptions due for invoicing")

View File

@ -0,0 +1,20 @@
"""Scheduled permanent Project CT vTiger archive sync."""
import logging
from app.admin.vtiger_archive import run_archive_sync
logger = logging.getLogger(__name__)
async def run_project_ct_archive_sync() -> None:
try:
result = await run_archive_sync("incremental")
logger.info("Project CT incremental archive sync completed: %s", result)
except RuntimeError as exc:
if "kører allerede" in str(exc):
logger.info("Project CT scheduled sync skipped: %s", exc)
return
logger.exception("Project CT scheduled archive sync failed")
except Exception:
logger.exception("Project CT scheduled archive sync failed")

View File

@ -3,7 +3,7 @@ Pydantic Models and Schemas
"""
from enum import Enum
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List
from datetime import datetime, date
@ -89,6 +89,7 @@ class VendorBase(BaseModel):
priority: Optional[int] = 100
notes: Optional[str] = None
is_active: bool = True
is_internet_provider: bool = False
class VendorCreate(VendorBase):
@ -103,10 +104,16 @@ class VendorUpdate(BaseModel):
domain: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
website: Optional[str] = None
economic_supplier_number: Optional[int] = None
contact_person: Optional[str] = None
category: Optional[str] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
is_internet_provider: Optional[bool] = None
class Vendor(VendorBase):
@ -159,6 +166,15 @@ class SolutionBase(BaseModel):
description: Optional[str] = None
solution_type: Optional[str] = None # Support, Drift, Konsulent, etc.
result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst
problem: Optional[str] = None
root_cause: Optional[str] = None
investigation: Optional[str] = None
workaround: Optional[str] = None
visibility: str = "internal"
approval_status: str = "draft"
is_final: bool = True
tags: list[str] = Field(default_factory=list)
products: list[str] = Field(default_factory=list)
class SolutionCreate(SolutionBase):
"""Schema for creating a solution"""
@ -171,6 +187,16 @@ class SolutionUpdate(BaseModel):
description: Optional[str] = None
solution_type: Optional[str] = None
result: Optional[str] = None
problem: Optional[str] = None
root_cause: Optional[str] = None
investigation: Optional[str] = None
workaround: Optional[str] = None
visibility: Optional[str] = None
approval_status: Optional[str] = None
is_final: Optional[bool] = None
tags: Optional[list[str]] = None
products: Optional[list[str]] = None
change_note: Optional[str] = None
class Solution(SolutionBase):
"""Full solution schema"""
@ -179,6 +205,9 @@ class Solution(SolutionBase):
created_by_user_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
updated_by_user_id: Optional[int] = None
approved_by_user_id: Optional[int] = None
approved_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)

View File

@ -4,6 +4,7 @@ import logging
from typing import Optional
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi.encoders import jsonable_encoder
from app.core.auth_service import AuthService
from .service import get_active_timer, get_dashboard_status, get_notifications, get_user_messages_summary
@ -79,14 +80,14 @@ async def bottom_bar_ws(websocket: WebSocket):
initial_status = get_dashboard_status()
initial_notifications = get_notifications(user_id, limit=20)
initial_messages = get_user_messages_summary(user_id, limit=20)
await websocket.send_json({"event": "status_delta", "data": initial_status})
await websocket.send_json({
await websocket.send_json(jsonable_encoder({"event": "status_delta", "data": initial_status}))
await websocket.send_json(jsonable_encoder({
"event": "notification_delta",
"data": {
"notifications": initial_notifications,
"messages": initial_messages,
},
})
}))
last_status_json = json.dumps(initial_status, sort_keys=True, default=str)
last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str)
@ -99,7 +100,7 @@ async def bottom_bar_ws(websocket: WebSocket):
timer = get_active_timer(user_id)
elapsed = int(timer.get("elapsed") or 0)
if elapsed != last_timer_elapsed:
await websocket.send_json({"event": "timer_tick", "data": timer})
await websocket.send_json(jsonable_encoder({"event": "timer_tick", "data": timer}))
last_timer_elapsed = elapsed
status_tick += 1
@ -110,19 +111,19 @@ async def bottom_bar_ws(websocket: WebSocket):
status_json = json.dumps(status, sort_keys=True, default=str)
if status_json != last_status_json:
await websocket.send_json({"event": "status_delta", "data": status})
await websocket.send_json(jsonable_encoder({"event": "status_delta", "data": status}))
last_status_json = status_json
notifications_json = json.dumps(notifications, sort_keys=True, default=str)
messages_json = json.dumps(messages, sort_keys=True, default=str)
if notifications_json != last_notifications_json or messages_json != last_messages_json:
await websocket.send_json({
await websocket.send_json(jsonable_encoder({
"event": "notification_delta",
"data": {
"notifications": notifications,
"messages": messages,
},
})
}))
last_notifications_json = notifications_json
last_messages_json = messages_json

View File

@ -1,8 +1,8 @@
from typing import Optional
from typing import Optional, Literal
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.core.auth_service import AuthService
from app.core.auth_dependencies import get_current_user
@ -69,6 +69,10 @@ class BottomBarMessageCreatePayload(BaseModel):
message: str
recipient_user_id: Optional[int] = None
requires_manual_ack: bool = False
message_kind: Literal['message', 'phone'] = 'message'
contact_id: Optional[int] = Field(default=None, gt=0)
caller_name: str = Field(default='', max_length=200)
callback_phone: str = Field(default='', max_length=80)
class BottomBarMessageReadPayload(BaseModel):
@ -566,13 +570,17 @@ async def send_bottom_bar_message(
if recipient_user_id == int(current_user_id):
raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv")
if payload.contact_id is not None:
if not execute_query_single('SELECT id FROM contacts WHERE id=%s', (payload.contact_id,)):
raise HTTPException(status_code=400, detail='Kontaktpersonen findes ikke')
row = execute_query_single(
"""
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack)
VALUES (%s, %s, %s, %s)
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack, message_kind, contact_id, caller_name, callback_phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at
""",
(int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack)),
(int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack), payload.message_kind, payload.contact_id, payload.caller_name.strip(), payload.callback_phone.strip()),
) or {}
return {
@ -582,6 +590,10 @@ async def send_bottom_bar_message(
"from": _resolve_current_user_display_name(current_user),
"to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}",
"text": row.get("message_text") or message_text,
"message_kind": payload.message_kind,
"contact_id": payload.contact_id,
"caller_name": payload.caller_name.strip(),
"callback_phone": payload.callback_phone.strip(),
"requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at"),
"is_own": True,

View File

@ -83,6 +83,10 @@ def ensure_bottom_bar_messages_schema() -> None:
sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE,
message_text TEXT NOT NULL,
message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
caller_name VARCHAR(200) NOT NULL DEFAULT '',
callback_phone VARCHAR(80) NOT NULL DEFAULT '',
requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMP NULL
@ -92,6 +96,12 @@ def ensure_bottom_bar_messages_schema() -> None:
logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically")
else:
columns = set(_table_columns("bottom_bar_messages"))
if not {'message_kind', 'contact_id', 'caller_name', 'callback_phone'}.issubset(columns):
execute_query("""ALTER TABLE bottom_bar_messages
ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT ''""")
if "requires_manual_ack" not in columns:
execute_query(
"""
@ -143,6 +153,8 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
m.sender_user_id,
m.recipient_user_id,
m.message_text,
m.message_kind, m.contact_id, m.caller_name, m.callback_phone,
NULLIF(CONCAT_WS(' ', contact.first_name, contact.last_name), '') AS contact_name,
m.requires_manual_ack,
m.created_at,
receipt.read_at,
@ -152,9 +164,10 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
FROM bottom_bar_messages m
JOIN users sender ON sender.user_id = m.sender_user_id
LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id
LEFT JOIN contacts contact ON contact.id = m.contact_id
LEFT JOIN bottom_bar_message_receipts receipt
ON receipt.message_id = m.id
AND receipt.user_id = %s
AND receipt.user_id = CASE WHEN m.sender_user_id = %s AND m.recipient_user_id IS NOT NULL THEN m.recipient_user_id ELSE %s END
WHERE m.sender_user_id = %s
OR m.recipient_user_id = %s
OR (m.recipient_user_id IS NULL AND EXISTS (
@ -166,7 +179,7 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
ORDER BY m.created_at DESC, m.id DESC
LIMIT %s
""",
(int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
(int(user_id), int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
) or []
unread_row = execute_query_single(
@ -200,9 +213,15 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
"from": row.get("sender_name") or "Ukendt",
"to": recipient_name,
"text": row.get("message_text") or "",
"message_kind": row.get("message_kind") or "message",
"contact_id": row.get("contact_id"),
"contact_name": row.get("contact_name"),
"caller_name": row.get("caller_name") or "",
"callback_phone": row.get("callback_phone") or "",
"requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
"is_own": int(row.get("sender_user_id") or 0) == int(user_id),
"is_read": row.get("read_at") is not None,
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
"is_acknowledged": row.get("acknowledged_at") is not None,
}
@ -971,6 +990,10 @@ def build_bottom_bar_state(
unassigned_open_cases = get_unassigned_open_cases(limit=8)
recent_cases = _get_recent_cases(user_id, limit=10)
notes_summary = get_user_notes_summary(user_id, limit=10)
procurement_attention = execute_query_single(
"""SELECT COUNT(*)::int AS count FROM sag_salgsvarer
WHERE type = 'purchase' AND status = 'draft'"""
) or {"count": 0}
urgent_cases = execute_query(
"""
@ -1155,6 +1178,10 @@ def build_bottom_bar_state(
"list": unassigned_open_cases.get("items") or [],
"filter_meta": unassigned_open_cases.get("filter_meta") or {},
},
"procurement": {
"to_order": int(procurement_attention.get("count") or 0),
"route": "/procurement",
},
"timer": {
"active_count": 1 if timer.get("active") else 0,
"list": timer_list,

View File

@ -8,11 +8,164 @@ from psycopg2.extras import Json
from datetime import datetime, date
import os
import uuid
import secrets
from fastapi import Header, status
from pydantic import BaseModel, Field
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
class MobileRecorderProvisionRequest(BaseModel):
"""Payload posted by the Apple Configurator cfgutil provisioning script."""
name: str = Field(min_length=1, max_length=120)
asset_type: str = "mobile_recorder"
manufacturer: str = "Apple"
recorder_number: Optional[int] = Field(default=None, ge=1, le=99999)
model: Optional[str] = Field(default=None, max_length=100)
device_type: Optional[str] = Field(default=None, max_length=100)
serial_number: str = Field(min_length=1, max_length=100)
udid: Optional[str] = Field(default=None, max_length=160)
ecid: Optional[str] = Field(default=None, max_length=160)
imei: Optional[str] = Field(default=None, max_length=40)
wifi_mac: Optional[str] = Field(default=None, max_length=40)
os: str = "iOS"
os_version: Optional[str] = Field(default=None, max_length=40)
supervised: bool = False
status: str = "ready"
def _provisioning_token_or_401(
authorization: Optional[str], x_provisioning_token: Optional[str],
) -> None:
"""Authenticate a headless provisioning client without accepting user JWTs."""
expected = (settings.MOBILE_RECORDER_PROVISIONING_TOKEN or "").strip()
if not expected:
logger.error("Mobile Recorder provisioning was called but no service token is configured")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Provisioning endpoint is disabled: service token is not configured",
)
bearer = (authorization or "").strip()
supplied = (x_provisioning_token or "").strip()
if not supplied and bearer.lower().startswith("bearer "):
supplied = bearer[7:].strip()
if not supplied or not secrets.compare_digest(supplied, expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid provisioning token",
headers={"WWW-Authenticate": "Bearer"},
)
def _clean_provisioning_value(value: Optional[str]) -> Optional[str]:
return str(value).strip() if value is not None and str(value).strip() else None
@router.post("/assets/provision", status_code=status.HTTP_200_OK)
async def provision_mobile_recorder(
payload: MobileRecorderProvisionRequest,
authorization: Optional[str] = Header(default=None),
x_provisioning_token: Optional[str] = Header(default=None),
):
"""Create or update an Apple BMC Mobile Recorder by its physical serial number."""
_provisioning_token_or_401(authorization, x_provisioning_token)
if payload.asset_type != "mobile_recorder":
raise HTTPException(status_code=422, detail="asset_type must be mobile_recorder")
if payload.status != "ready":
raise HTTPException(status_code=422, detail="Provisioned Mobile Recorders must use status ready")
serial_number = _clean_provisioning_value(payload.serial_number)
if not serial_number:
raise HTTPException(status_code=422, detail="serial_number is required")
manufacturer = _clean_provisioning_value(payload.manufacturer) or "Apple"
model = _clean_provisioning_value(payload.model) or _clean_provisioning_value(payload.device_type)
recorder_name = _clean_provisioning_value(payload.name)
mobile_specs = {
"recorder_number": payload.recorder_number,
"name": recorder_name,
"udid": _clean_provisioning_value(payload.udid),
"ecid": _clean_provisioning_value(payload.ecid),
"imei": _clean_provisioning_value(payload.imei),
"wifi_mac": _clean_provisioning_value(payload.wifi_mac),
"os": _clean_provisioning_value(payload.os) or "iOS",
"os_version": _clean_provisioning_value(payload.os_version),
"supervised": bool(payload.supervised),
"provisioning_status": "ready",
"source": "apple_configurator",
}
existing = execute_query(
"""SELECT id, hardware_specs FROM hardware_assets
WHERE LOWER(TRIM(serial_number)) = LOWER(TRIM(%s)) AND deleted_at IS NULL
ORDER BY id LIMIT 2""",
(serial_number,),
) or []
if len(existing) > 1:
raise HTTPException(
status_code=409,
detail="More than one active Asset has this serial number; merge the duplicate Assets before provisioning",
)
prior_specs = (existing[0].get("hardware_specs") if existing else {}) or {}
if isinstance(prior_specs, str):
try:
prior_specs = json.loads(prior_specs)
except (TypeError, ValueError):
prior_specs = {}
if not isinstance(prior_specs, dict):
prior_specs = {}
prior_specs["mobile_recorder"] = mobile_specs
if existing:
asset_id = int(existing[0]["id"])
rows = execute_query(
"""UPDATE hardware_assets
SET asset_type = 'mobile_recorder', brand = %s,
model = COALESCE(%s, model), internal_asset_id = %s,
recorder_number = %s, status = 'ready', hardware_specs = %s,
last_provisioned_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id""",
(manufacturer, model, recorder_name, payload.recorder_number, Json(prior_specs), asset_id),
)
action = "updated"
else:
rows = execute_query(
"""INSERT INTO hardware_assets
(asset_type, brand, model, serial_number, internal_asset_id, recorder_number,
current_owner_type, status, hardware_specs, provisioned_at, last_provisioned_at)
VALUES ('mobile_recorder', %s, %s, %s, %s, %s, 'bmc', 'ready', %s,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id""",
(manufacturer, model, serial_number, recorder_name, payload.recorder_number, Json(prior_specs)),
)
action = "created"
asset_id = int(rows[0]["id"])
execute_query(
"""INSERT INTO hardware_ownership_history
(hardware_id, owner_type, start_date, notes)
VALUES (%s, 'bmc', CURRENT_DATE, 'Created by Apple Configurator provisioning')""",
(asset_id,),
fetch=False,
)
if not rows:
raise HTTPException(status_code=500, detail="Could not persist provisioned Asset")
execute_query(
"""INSERT INTO hardware_provisioning_history (hardware_id, action, payload)
VALUES (%s, %s, %s)""",
(asset_id, action, Json({"serial_number": serial_number, "mobile_recorder": mobile_specs})),
fetch=False,
)
logger.info("Mobile Recorder %s Asset #%s via serial %s", action, asset_id, serial_number)
return {"success": True, "action": action, "asset_id": asset_id, "recorder_number": payload.recorder_number}
def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
if payload is None:
return None

View File

@ -0,0 +1,203 @@
"""Create deduplicated procurement cases for externally detected internet changes."""
from __future__ import annotations
import json
import logging
from typing import Any, Mapping, Optional
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
RELEVANT_FIELDS = {
"address", "service_address", "monthly_cost", "sales_price", "technology",
"connection_type", "circuit_number", "provider_reference", "provider", "vendor_id",
"speed_mbps", "download_mbps", "upload_mbps", "status", "sla_subscription_id",
"sla_price", "sla_status", "ip_range", "cidr", "contract_number", "range_added",
"range_removed", "range_monthly_cost", "range_sales_price",
}
FIELD_LABELS = {
"address": "Adresse", "service_address": "Serviceadresse", "monthly_cost": "Indkøbspris",
"sales_price": "Salgspris", "technology": "Teknologi", "connection_type": "Forbindelsestype",
"circuit_number": "Kredsløbsnummer", "provider_reference": "Leverandørreference",
"provider": "Leverandør", "vendor_id": "Leverandør", "speed_mbps": "Hastighed",
"download_mbps": "Download", "upload_mbps": "Upload", "status": "Status",
"sla_subscription_id": "SLA-aftale", "sla_price": "SLA-pris", "sla_status": "SLA-status",
"ip_range": "IP-range", "cidr": "IP-range", "contract_number": "Kontraktnummer",
"range_added": "Nyt IP-range", "range_removed": "Fjernet IP-range",
"range_monthly_cost": "IP-range indkøbspris", "range_sales_price": "IP-range salgspris",
}
def filter_relevant_changes(changes: Mapping[str, Any] | None) -> dict[str, dict[str, Any]]:
filtered: dict[str, dict[str, Any]] = {}
for field, raw in (changes or {}).items():
if field not in RELEVANT_FIELDS:
continue
change = raw if isinstance(raw, Mapping) else {"from": None, "to": raw}
before, after = change.get("from"), change.get("to")
if str(before or "").strip() == str(after or "").strip():
continue
filtered[field] = {"from": before, "to": after}
return filtered
def _procurement_customer_id() -> int:
configured = getattr(settings, "PROCUREMENT_CASE_CUSTOMER_ID", None)
if configured:
row = execute_query_single("SELECT id FROM customers WHERE id=%s AND is_active=true", (configured,))
if row:
return int(row["id"])
row = execute_query_single(
"""SELECT id FROM customers WHERE is_active=true AND LOWER(name) LIKE %s
ORDER BY CASE WHEN LOWER(name) LIKE %s THEN 0 ELSE 1 END, id LIMIT 1""",
("%bmc%", "%bmc networks%"),
)
if not row:
raise ValueError("BMC's interne indkøbskunde blev ikke fundet")
return int(row["id"])
def _economy_group_id() -> Optional[int]:
row = execute_query_single(
"""SELECT id FROM groups WHERE LOWER(name) LIKE ANY(%s)
ORDER BY id LIMIT 1""", (["%økonomi%", "%okonomi%", "%economic%"],),
)
return int(row["id"]) if row else None
def _render_description(
*, connection_id: int, connection_name: str, reference: str, provider: str,
source_label: str, source_url: Optional[str], changes: Mapping[str, Mapping[str, Any]],
) -> str:
lines = [
"Automatisk oprettet efter en ekstern ændring af en internetforbindelse.", "",
f"Forbindelse: {connection_name}", f"Reference: {reference or '-'}",
f"Leverandør: {provider or '-'}", f"Kilde: {source_label}",
f"Link til forbindelse: /economy/internet-connections/{connection_id}",
]
if source_url:
lines.append(f"Link til kilde: {source_url}")
lines.extend(["", "Registrerede ændringer:"])
for field, change in changes.items():
lines.append(f"- {FIELD_LABELS.get(field, field)}: {change.get('from')}{change.get('to')}")
return "\n".join(lines)
def ensure_external_change_case(
*, connection_id: int, source_type: str, source_key: str, source_label: str,
changes: Mapping[str, Any], connection_name: str = "Internetforbindelse",
reference: str = "", provider: str = "", owner_customer_id: Optional[int] = None,
source_url: Optional[str] = None,
) -> dict[str, Any]:
relevant = filter_relevant_changes(changes)
if not relevant:
return {"case_id": None, "created": False, "changes": {}, "error": None}
source_type = str(source_type or "external").strip().lower()
source_key = str(source_key or "").strip()
if not source_key:
return {"case_id": None, "created": False, "changes": relevant, "error": "Kilden mangler en stabil nøgle"}
try:
existing = execute_query_single(
"""SELECT id, sag_id, changes FROM internet_connection_change_cases
WHERE connection_id=%s AND source_type=%s AND source_key=%s""",
(connection_id, source_type, source_key),
)
merged = dict((existing or {}).get("changes") or {})
merged.update(relevant)
case_customer_id = int(owner_customer_id) if owner_customer_id else _procurement_customer_id()
title = f"Internetændring {reference or connection_name} · {source_label}"[:255]
description = _render_description(
connection_id=connection_id, connection_name=connection_name, reference=reference,
provider=provider, source_label=source_label, source_url=source_url, changes=merged,
)
case_id = int(existing["sag_id"]) if existing and existing.get("sag_id") else None
created = False
if case_id:
execute_query(
"UPDATE sag_sager SET beskrivelse=%s, updated_at=NOW() WHERE id=%s AND deleted_at IS NULL",
(description, case_id), fetch=False,
)
else:
row = execute_query_single(
"""INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, assigned_group_id, created_by_user_id)
VALUES (%s,%s,'indkøb','åben',%s,%s,1) RETURNING id""",
(title, description, case_customer_id, _economy_group_id()),
)
case_id = int(row["id"])
created = True
if existing:
execute_query(
"""UPDATE internet_connection_change_cases SET sag_id=%s, source_label=%s,
source_url=%s, changes=%s::jsonb, last_error=NULL, updated_at=NOW() WHERE id=%s""",
(case_id, source_label, source_url, json.dumps(merged, ensure_ascii=False), existing["id"]), fetch=False,
)
else:
execute_query(
"""INSERT INTO internet_connection_change_cases
(connection_id,source_type,source_key,source_label,source_url,sag_id,changes)
VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb)""",
(connection_id, source_type, source_key, source_label, source_url, case_id,
json.dumps(merged, ensure_ascii=False)), fetch=False,
)
return {"case_id": case_id, "created": created, "changes": merged, "error": None}
except Exception as exc:
logger.warning("Could not create internet change case for connection %s: %s", connection_id, exc)
# Keep the import operational, but persist a visible control item whenever
# the audit table itself is available.
try:
execute_query(
"""INSERT INTO internet_connection_change_cases
(connection_id,source_type,source_key,source_label,source_url,changes,last_error)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s)
ON CONFLICT (connection_id,source_type,source_key) DO UPDATE
SET changes=internet_connection_change_cases.changes || EXCLUDED.changes,
last_error=EXCLUDED.last_error, updated_at=NOW()""",
(connection_id, source_type, source_key, source_label, source_url,
json.dumps(relevant, ensure_ascii=False), str(exc)),
fetch=False,
)
except Exception:
logger.exception("Could not persist failed internet change-case audit")
return {"case_id": None, "created": False, "changes": relevant, "error": str(exc)}
def retry_failed_change_cases(source_type: str, source_key: str) -> dict[str, int]:
"""Retry audit rows after a transient case-creation failure.
Imports are intentionally idempotent, so a repeated invoice normally does
not apply connection data again. Failed audit rows must nevertheless be
repairable once the underlying case service has been fixed.
"""
rows = execute_query(
"""SELECT audit.connection_id, audit.source_label, audit.source_url, audit.changes,
ic.name, ic.circuit_number, ic.provider, ic.customer_id
FROM internet_connection_change_cases audit
JOIN internet_connections_connections ic ON ic.id = audit.connection_id
WHERE audit.source_type=%s AND audit.source_key=%s
AND audit.sag_id IS NULL AND audit.last_error IS NOT NULL
AND ic.deleted_at IS NULL""",
(source_type, source_key),
) or []
repaired = 0
failed = 0
for row in rows:
result = ensure_external_change_case(
connection_id=int(row["connection_id"]), source_type=source_type, source_key=source_key,
source_label=str(row.get("source_label") or source_key), changes=row.get("changes") or {},
connection_name=str(row.get("name") or "Internetforbindelse"),
reference=str(row.get("circuit_number") or ""),
provider=str(row.get("provider") or ""), owner_customer_id=row.get("customer_id"),
source_url=row.get("source_url"),
)
if result.get("case_id"):
repaired += 1
else:
failed += 1
return {"repaired": repaired, "failed": failed}

File diff suppressed because it is too large Load Diff

View File

@ -12,14 +12,30 @@
border-radius: 24px;
padding: 1.5rem;
box-shadow: 0 16px 36px rgba(15, 76, 117, 0.08);
position: relative;
overflow: hidden;
}
.detail-hero::after { content:""; position:absolute; width:190px; height:190px; right:-65px; bottom:-95px; border-radius:50%; border:28px solid rgba(15,76,117,.06); pointer-events:none; }
.detail-title-wrap { display:flex; align-items:flex-start; gap:1rem; position:relative; z-index:1; }
.detail-title-icon { width:52px; height:52px; flex:0 0 52px; display:grid; place-items:center; border-radius:16px; color:#fff; background:linear-gradient(135deg,#0f4c75,#3282b8); box-shadow:0 9px 20px rgba(15,76,117,.22); font-size:1.35rem; }
.detail-circuit-badge { display:inline-flex; align-items:center; gap:.38rem; padding:.3rem .62rem; margin-top:.55rem; border-radius:999px; background:rgba(15,76,117,.09); color:var(--accent); font-size:.78rem; font-weight:750; letter-spacing:.025em; }
.detail-section-nav { display:flex; flex-wrap:wrap; gap:.4rem; margin-top:1rem; position:relative; z-index:1; }
.detail-section-nav a { display:inline-flex; align-items:center; gap:.35rem; padding:.38rem .65rem; border-radius:9px; color:var(--text-primary); background:rgba(255,255,255,.58); border:1px solid rgba(15,76,117,.1); text-decoration:none; font-size:.78rem; font-weight:650; }
.detail-section-nav a:hover { color:var(--accent); background:#fff; transform:translateY(-1px); }
.detail-metrics-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.8rem; }
.detail-metrics-grid > [class*="col-"] { width:auto; padding:0; }
.detail-panel {
background: var(--bg-card);
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 20px;
box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06);
}
.allocation-banner { border:1px solid #f0c36a; background:linear-gradient(135deg,#fff8e7,#fffdf7); border-radius:18px; box-shadow:0 10px 28px rgba(120,82,20,.08); }
.allocation-suggestion { border:1px solid rgba(120,82,20,.16); background:#fff; border-radius:12px; padding:.7rem .85rem; cursor:pointer; }
.allocation-suggestion:hover { border-color:#d39a2c; background:#fffaf0; }
.detail-metric {
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
@ -27,7 +43,10 @@
border-radius: 16px;
padding: 1rem;
height: 100%;
position:relative;
overflow:hidden;
}
.detail-metric::after { content:""; position:absolute; width:55px; height:55px; right:-22px; bottom:-24px; border-radius:50%; background:rgba(15,76,117,.07); }
.detail-metric-label {
color: var(--text-secondary);
@ -55,6 +74,31 @@
padding: 0.85rem;
}
#ipRangesList { grid-template-columns: 1fr; }
.ip-range-card {
border-left:4px solid #3282b8;
background:linear-gradient(135deg,rgba(50,130,184,.07),rgba(255,255,255,.45));
display:grid;
grid-template-columns:minmax(250px,1.35fr) repeat(3,minmax(115px,.65fr)) auto;
align-items:center;
gap:1rem;
padding:1rem 1.1rem;
}
.ip-range-cidr { display:inline-flex; align-items:center; gap:.42rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:1.03rem; }
.ip-range-main,.ip-range-meta { min-width:0; }
.ip-range-meta .label { margin-bottom:.15rem; }
.ip-range-meta .value { font-size:.92rem; overflow-wrap:anywhere; }
.ip-range-actions { justify-self:end; }
.ip-range-edit { grid-column:1/-1; border-top:1px solid rgba(15,76,117,.1); padding-top:.9rem; }
.ip-range-warning { grid-column:1/-1; display:flex; align-items:flex-start; gap:.75rem; padding:.85rem 1rem; border:1px solid #f4cccc; border-radius:12px; background:#fff8f8; }
@media (max-width: 1000px) {
.ip-range-card { grid-template-columns:repeat(2,minmax(0,1fr)); }
.ip-range-main,.ip-range-actions,.ip-range-edit { grid-column:1/-1; }
.ip-range-actions { justify-self:start; }
}
.ip-empty-state { text-align:center; padding:2.5rem 1rem; border:1px dashed rgba(15,76,117,.22); border-radius:16px; background:rgba(15,76,117,.025); }
.ip-empty-state i { display:block; color:var(--accent); opacity:.55; font-size:2rem; margin-bottom:.55rem; }
.detail-grid-card.editing {
border-color: rgba(15, 76, 117, 0.28);
background: rgba(15, 76, 117, 0.06);
@ -235,6 +279,7 @@
}
@media (max-width: 991.98px) {
.detail-metrics-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
.detail-read-grid {
grid-template-columns: 1fr;
}
@ -245,6 +290,8 @@
gap: 0.2rem;
}
}
@media (max-width: 575.98px) { .detail-metrics-grid { grid-template-columns:1fr; } .detail-title-icon { display:none; } }
</style>
{% endblock %}
@ -252,11 +299,15 @@
<div class="container-fluid py-4">
<div class="detail-hero mb-4">
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-start gap-3">
<div>
<div class="detail-title-wrap">
<div class="detail-title-icon"><i class="bi bi-router"></i></div>
<div>
<div class="small text-uppercase fw-semibold text-muted mb-2">Internetforbindelse</div>
<h2 class="h3 mb-1" id="detailName">Indlæser...</h2>
<div class="text-muted" id="detailSubtitle">Henter forbindelsesdata...</div>
<div class="detail-circuit-badge" id="detailCircuitBadge"><i class="bi bi-diagram-3"></i><span>Henter kredsløb...</span></div>
<div class="small mt-2 text-muted" id="detailSaveFeedback" role="status"></div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="/economy/internet-connections" class="btn btn-outline-secondary">
@ -274,11 +325,51 @@
<button class="btn btn-primary d-none" id="saveCoreDetailsBtn" type="button" onclick="saveCoreDetails()">
<i class="bi bi-save me-1"></i>Gem ændringer
</button>
<a class="btn btn-outline-primary" id="createCaseForConnectionBtn" href="/sag/new">
<i class="bi bi-plus-circle me-1"></i>Opret sag
</a>
</div>
</div>
<nav class="detail-section-nav" aria-label="Sektioner">
<a href="#connection-core"><i class="bi bi-info-circle"></i>Grunddata</a>
<a href="#connection-ip"><i class="bi bi-hdd-network"></i>IP-ranges og adresser</a>
<a href="#connection-pricing"><i class="bi bi-cash-coin"></i>Pris og kontrakt</a>
<a href="#connection-cases"><i class="bi bi-kanban"></i>Sager</a>
<a href="#connection-history"><i class="bi bi-clock-history"></i>Historik</a>
</nav>
</div>
<div class="allocation-banner p-4 mb-4 d-none" id="allocationBanner">
<div class="d-flex flex-column flex-xl-row justify-content-between gap-3">
<div>
<div class="fw-bold text-warning-emphasis"><i class="bi bi-exclamation-circle-fill me-2"></i>Forbindelsen er ikke tildelt en kunde</div>
<div class="small text-muted mt-1">Vælg virksomheden på installationsadressen. Intet bliver tildelt automatisk.</div>
<div class="d-flex flex-wrap gap-2 mt-3" id="allocationSuggestions"></div>
</div>
<div style="min-width:min(100%,420px);" class="vstack gap-2">
<select class="form-select" id="allocationCustomerSelect" onchange="loadAllocationSubscriptions()"><option value="">Vælg kunde…</option></select>
<select class="form-select" id="allocationSubscriptionSelect" disabled><option value="">Kun tildel kunde intet abonnement</option></select>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-warning" id="saveAllocationBtn" type="button" onclick="saveConnectionAllocation()">Tildel kunde</button>
<a class="btn btn-outline-secondary d-none" id="allocationCustomerLink" href="#">Åbn kunde</a>
</div>
<div class="small" id="allocationFeedback" role="status"></div>
</div>
</div>
</div>
<div class="row g-4 mb-4">
<div class="alert mb-4" id="slaBanner" role="status">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3">
<div id="slaBannerContent"></div>
<div class="d-flex gap-2 flex-wrap align-items-center">
<select class="form-select" id="slaSubscriptionSelect" style="min-width:300px"></select>
<button class="btn btn-primary" id="saveSlaBtn" type="button" onclick="saveSlaAllocation()">Gem SLA</button>
</div>
</div>
<div class="small mt-2" id="slaFeedback"></div>
</div>
<div class="detail-metrics-grid mb-4">
<div class="col-6 col-xl-3">
<div class="detail-metric">
<div class="detail-metric-label">Salgspris</div>
@ -314,7 +405,7 @@
<div class="row g-4">
<div class="col-xl-8">
<div class="detail-panel p-4 mb-4">
<div class="detail-panel p-4 mb-4" id="connection-core">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Grunddata</h5>
<span id="detailStatusBadge" class="status-pill inactive">-</span>
@ -327,7 +418,7 @@
</div>
<div class="col-md-3">
<label class="form-label">Leverandør</label>
<input type="text" class="form-control" id="fieldProvider">
<select class="form-select" id="fieldVendorId"><option value="">Ingen valgt</option></select>
</div>
<div class="col-md-3">
<label class="form-label">Kredsløb</label>
@ -383,6 +474,13 @@
<label class="form-label">Overvågning</label>
<input type="text" class="form-control" id="fieldMonitoringUrl" placeholder="https://...">
</div>
<div class="col-12">
<div class="form-check form-switch border rounded-3 p-3 ps-5 bg-light">
<input class="form-check-input" type="checkbox" role="switch" id="fieldManualShared" onchange="toggleManualDelefiber()">
<label class="form-check-label fw-semibold" for="fieldManualShared">BMC Delefiber</label>
<div class="small text-muted">BMC ejer hovedforbindelsen og udstyret og kan dele den ud til flere BMCnet-kunder.</div>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Allokering</label>
<select class="form-select" id="fieldAllocationModel">
@ -424,7 +522,7 @@
</div>
</div>
<div class="detail-panel p-4 mb-4">
<div class="detail-panel p-4 mb-4" id="connection-ip">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-0">IP-ranges</h5>
@ -538,16 +636,16 @@
</div>
</div>
<div class="detail-panel p-4 mb-4">
<div class="detail-panel p-4 mb-4" id="connection-pricing">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Pris og kontrakt</h5>
<div class="small text-muted" id="pricingFeedback"></div>
</div>
<div class="row g-2 mb-3">
<div class="col-md-3"><input type="date" class="form-control" id="pricingEffectiveDateInput"></div>
<div class="col-md-3"><input type="number" class="form-control" id="pricingPurchaseInput" placeholder="Indkøbspris"></div>
<div class="col-md-3"><input type="number" class="form-control" id="pricingSalesInput" placeholder="Salgspris"></div>
<div class="col-md-3"><input type="text" class="form-control" id="pricingNotesInput" placeholder="Note"></div>
<div class="col-md-3"><label class="form-label" for="pricingEffectiveDateInput">Gældende fra</label><input type="date" class="form-control" id="pricingEffectiveDateInput"></div>
<div class="col-md-3"><label class="form-label" for="pricingPurchaseInput">Indkøbspris</label><input type="number" class="form-control" id="pricingPurchaseInput" placeholder="0 kr." step="0.01"></div>
<div class="col-md-3"><label class="form-label" for="pricingSalesInput">Salgspris</label><input type="number" class="form-control" id="pricingSalesInput" placeholder="0 kr." step="0.01"></div>
<div class="col-md-3"><label class="form-label" for="pricingNotesInput">Note</label><input type="text" class="form-control" id="pricingNotesInput" placeholder="Valgfri note"></div>
</div>
<div class="mb-3">
<button class="btn btn-primary" type="button" onclick="createPricingEntry()">Gem prislinje</button>
@ -556,6 +654,12 @@
<div id="pricingHistoryList"></div>
</div>
<div class="detail-panel p-4 mb-4 d-none" id="delefiberProductPricesPanel">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h5 class="mb-0">BMCnet standardpriser</h5><div class="small text-muted">Priser gælder kun for denne delefiber/adresse.</div></div></div>
<div class="row g-2 mb-2"><div class="col-md-6"><select class="form-select" id="delefiberPriceProduct"></select></div><div class="col-md-3"><input class="form-control" id="delefiberPriceAmount" type="number" min="0" step="0.01" placeholder="Kr./md."></div><div class="col-md-3"><button class="btn btn-primary w-100" type="button" onclick="saveDelefiberProductPrice()">Gem pris</button></div></div>
<div id="delefiberProductPricesList" class="small"></div>
</div>
<div class="detail-panel p-4 mb-4 d-none" id="crossFieldPortsPanel">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
@ -580,7 +684,15 @@
</div>
</div>
<div class="detail-panel p-4">
<div class="detail-panel p-4 mb-4" id="connection-cases">
<div class="d-flex justify-content-between align-items-center gap-2 mb-3">
<div><h5 class="mb-0">Sager på forbindelsen</h5><div class="small text-muted">Kun direkte tilknyttede sager.</div></div>
<a class="btn btn-sm btn-primary" id="createCaseForConnectionPanelBtn" href="/sag/new"><i class="bi bi-plus-lg me-1"></i>Opret sag</a>
</div>
<div id="connectionCasesList"></div>
</div>
<div class="detail-panel p-4" id="connection-history">
<h5 class="mb-3">Historik</h5>
<div id="historyList"></div>
</div>
@ -709,6 +821,7 @@
<label class="form-label">Noter</label>
<textarea class="form-control" id="bmcnetWizardNotes" rows="3" placeholder="Interne noter"></textarea>
</div>
<div class="col-12"><details class="border rounded-3 p-3"><summary class="fw-semibold">Periode og aftalevilkår <span class="text-muted fw-normal">— valgfrit</span></summary><div class="row g-2 mt-2"><div class="col-md-4"><label class="form-label">Periode start</label><input type="date" class="form-control" id="bmcnetWizardPeriodStart"></div><div class="col-md-4"><label class="form-label">Slutdato</label><input type="date" class="form-control" id="bmcnetWizardEndDate"></div><div class="col-md-4"><label class="form-label">Opsigelsesvarsel</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardNoticeDays" min="0" value="30"><span class="input-group-text">dage</span></div></div><div class="col-md-4"><label class="form-label">Faktureringsretning</label><select class="form-select" id="bmcnetWizardBillingDirection"><option value="forward">Forud</option><option value="backward">Bagud</option></select></div><div class="col-md-4"><label class="form-label">Perioder pr. faktura</label><input type="number" class="form-control" id="bmcnetWizardAdvanceMonths" min="1" value="1"></div><div class="col-md-4"><label class="form-label">Fakturér før perioden</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardLeadMonths" min="0" value="0"><span class="input-group-text">mdr.</span></div></div><div class="col-md-4"><label class="form-label">Første faktura</label><select class="form-select" id="bmcnetWizardFirstInvoicePolicy"><option value="start_date">På startdato</option><option value="next_cycle">Ved næste cyklus</option></select></div><div class="col-md-4"><label class="form-label">Binding</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardBindingMonths" min="0" value="0"><span class="input-group-text">mdr.</span></div></div></div></details></div>
</div>
</div>
</div>
@ -832,6 +945,13 @@
}
}
function showDetailMessage(message, isError = false) {
const feedback = document.getElementById('detailSaveFeedback');
feedback.className = `small ${isError ? 'text-danger' : 'text-success'}`;
feedback.textContent = message;
feedback.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
async function extractErrorMessage(response, fallback) {
try {
const payload = await response.clone().json();
@ -1000,11 +1120,12 @@
}
async function loadLookups() {
const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse] = await Promise.all([
const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse, vendorsResponse] = await Promise.all([
fetch('/api/v1/customers?limit=1000&is_active=true'),
fetch('/api/v1/internet-connections'),
fetch('/api/v1/internet-connections/subscription-options?status=active'),
fetch('/api/v1/products'),
fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100'),
]);
const customersPayload = customersResponse.ok ? await customersResponse.json() : { customers: [] };
@ -1012,6 +1133,9 @@
connectionOptions = connectionsResponse.ok ? await connectionsResponse.json() : [];
subscriptionOptions = subscriptionsResponse.ok ? await subscriptionsResponse.json() : [];
bmcnetWizardProducts = productsResponse.ok ? await productsResponse.json() : [];
const internetVendors = vendorsResponse.ok ? await vendorsResponse.json() : [];
document.getElementById('fieldVendorId').innerHTML = '<option value="">Ingen valgt</option>' + internetVendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
populateDatalist('customerLookupList', customerOptions);
populateDatalist(
@ -1057,8 +1181,46 @@
)).join('');
}
let delefiberProductPrices = [];
function isDelefiber(connection = currentConnection) {
return Boolean(connection && !connection.parent_id && (connection.is_shared_head || connection.allocation_model === 'shared'));
}
async function loadDelefiberProductPrices() {
const panel = document.getElementById('delefiberProductPricesPanel');
if (!panel || !isDelefiber()) { panel?.classList.add('d-none'); return; }
panel.classList.remove('d-none');
const select = document.getElementById('delefiberPriceProduct');
const priceProducts = bmcnetWizardProducts.filter((product) => ['internet_access', 'ip_allocation'].includes(parseProductAttributes(product.attributes_json)?.network?.kind));
select.innerHTML = '<option value="">Vælg produkt</option>' + priceProducts.map((product) => `<option value="${product.id}">${escapeHtml(product.name || '-')} · global ${formatDKK(product.sales_price || 0)}</option>`).join('');
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`);
if (!response.ok) throw new Error('Kunne ikke hente standardpriser');
delefiberProductPrices = await response.json();
const list = document.getElementById('delefiberProductPricesList');
list.innerHTML = delefiberProductPrices.length ? delefiberProductPrices.map((row) => `<div class="d-flex align-items-center gap-2 border-top py-2"><strong class="flex-grow-1">${escapeHtml(row.product_name || '-')}</strong><span>${formatDKK(row.monthly_price || 0)}/md.</span><button class="btn btn-sm btn-outline-danger" onclick="deleteDelefiberProductPrice(${row.product_id})" title="Fjern"><i class="bi bi-x-lg"></i></button></div>`).join('') : '<div class="text-muted py-2">Ingen lokale standardpriser endnu.</div>';
} catch (error) { document.getElementById('delefiberProductPricesList').innerHTML = `<div class="text-danger">${escapeHtml(error.message)}</div>`; }
}
async function saveDelefiberProductPrice() {
const productId = Number(document.getElementById('delefiberPriceProduct').value || 0);
const amount = document.getElementById('delefiberPriceAmount').value;
if (!productId || amount === '') { document.getElementById('detailSaveFeedback').textContent = 'Vælg produkt og pris.'; return; }
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id:productId,monthly_price:Number(amount)})});
if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke gemme standardpris.'); return; }
document.getElementById('delefiberPriceAmount').value = '';
await loadDelefiberProductPrices();
}
async function deleteDelefiberProductPrice(productId) {
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices/${productId}`, {method:'DELETE'});
if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke fjerne standardpris.'); return; }
await loadDelefiberProductPrices();
}
function getSharedHeadOptions() {
return (connectionOptions || []).filter((item) => item?.is_shared_head);
return (connectionOptions || []).filter((item) => !item?.parent_id);
}
async function loadRangesForSharedHead(sharedHeadId) {
@ -1159,7 +1321,7 @@
: await loadRangesForSharedHead(headId);
const availableRanges = filterAvailableBmcnetRanges(ranges);
rangeSelect.innerHTML = `<option value="">Ingen IP-range endnu</option>${availableRanges.map((range) => `
<option value="${range.id}">${range.cidr} · ${range.available_addresses || 0} ledige · ${range.service_address || head?.address || '-'}</option>
<option value="${range.id}" data-prefix="${String(range.cidr || '').split('/')[1] || ''}">${range.cidr} · ${range.available_addresses || 0} ledige · ${range.service_address || head?.address || '-'}</option>
`).join('')}`;
hint.textContent = availableRanges.length
? `Der er ${availableRanges.length} helt ledige ranges på den valgte hovedforbindelse.`
@ -1169,8 +1331,10 @@
}
function populateBmcnetWizard(connection) {
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !connection?.is_shared_head);
if (!connection?.is_shared_head) return;
const canCreateBmcnet = Boolean(connection && !connection.parent_id);
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet);
if (!canCreateBmcnet) return;
const today = new Date().toISOString().slice(0, 10);
document.getElementById('bmcnetWizardCustomerLookup').value = '';
document.getElementById('bmcnetWizardCustomerId').value = '';
@ -1179,7 +1343,15 @@
document.getElementById('bmcnetWizardAddress').value = connection.address || '';
document.getElementById('bmcnetWizardBillingInterval').value = 'monthly';
document.getElementById('bmcnetWizardBillingDay').value = '1';
document.getElementById('bmcnetWizardStartDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('bmcnetWizardPeriodStart').value = today;
document.getElementById('bmcnetWizardEndDate').value = '';
document.getElementById('bmcnetWizardNoticeDays').value = '30';
document.getElementById('bmcnetWizardBillingDirection').value = 'forward';
document.getElementById('bmcnetWizardAdvanceMonths').value = '1';
document.getElementById('bmcnetWizardLeadMonths').value = '0';
document.getElementById('bmcnetWizardFirstInvoicePolicy').value = 'start_date';
document.getElementById('bmcnetWizardBindingMonths').value = '0';
document.getElementById('bmcnetWizardStartDate').value = today;
document.getElementById('bmcnetWizardInternetProduct').value = '';
document.getElementById('bmcnetWizardIpProduct').value = '';
document.getElementById('bmcnetWizardInternetPrice').value = '';
@ -1197,7 +1369,7 @@
}
function openBmcnetWizard() {
if (!currentConnection?.is_shared_head) return;
if (!currentConnection || currentConnection.parent_id) return;
populateBmcnetWizard(currentConnection);
if (!bmcnetWizardModal) {
bmcnetWizardModal = new bootstrap.Modal(document.getElementById('bmcnetWizardModal'));
@ -1214,7 +1386,21 @@
input.value = '';
return;
}
input.value = option.dataset.price || '';
const local = delefiberProductPrices.find((row) => Number(row.product_id) === Number(option.value));
input.value = local ? Number(local.monthly_price || 0) : (option.dataset.price || '');
}
function selectIpProductForPrefix(prefixLength) {
const select = document.getElementById('bmcnetWizardIpProduct');
if (!select || !prefixLength) return;
const product = bmcnetWizardProducts.find((item) => {
const network = parseProductAttributes(item.attributes_json)?.network || {};
return network.kind === 'ip_allocation' && Number(network.ip_prefix_length) === Number(prefixLength);
});
if (!product) return;
select.value = String(product.id);
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
refreshBmcnetWizardAllocationMode();
}
async function createBmcnetConnection() {
@ -1239,7 +1425,15 @@
address: document.getElementById('bmcnetWizardAddress').value.trim() || null,
billing_interval: document.getElementById('bmcnetWizardBillingInterval').value || 'monthly',
billing_day: Number(document.getElementById('bmcnetWizardBillingDay').value || 1),
billing_direction: document.getElementById('bmcnetWizardBillingDirection').value || 'forward',
advance_months: Number(document.getElementById('bmcnetWizardAdvanceMonths').value || 1),
billing_lead_months: Number(document.getElementById('bmcnetWizardLeadMonths').value || 0),
first_invoice_policy: document.getElementById('bmcnetWizardFirstInvoicePolicy').value || 'start_date',
start_date: document.getElementById('bmcnetWizardStartDate').value,
period_start: document.getElementById('bmcnetWizardPeriodStart').value || null,
end_date: document.getElementById('bmcnetWizardEndDate').value || null,
notice_period_days: Number(document.getElementById('bmcnetWizardNoticeDays').value || 0),
binding_months: Number(document.getElementById('bmcnetWizardBindingMonths').value || 0),
internet_product_id: internetProductId,
internet_unit_price: document.getElementById('bmcnetWizardInternetPrice').value !== ''
? Number(document.getElementById('bmcnetWizardInternetPrice').value)
@ -1308,6 +1502,111 @@
}
}
async function renderAllocationBanner(connection) {
const banner = document.getElementById('allocationBanner');
const customerSelect = document.getElementById('allocationCustomerSelect');
const suggestionsWrap = document.getElementById('allocationSuggestions');
const isBmcSharedFiber = Boolean(
connection.is_manual_shared
|| (connection.parent_id == null
&& connection.allocation_model === 'shared'
&& connection.value_type === 'delefiber')
);
const shouldSuggestCustomer = !connection.customer_id && !isBmcSharedFiber;
banner.classList.toggle('d-none', !shouldSuggestCustomer);
if (!shouldSuggestCustomer) return;
const payload = await safeJson(
await fetch(`/api/v1/internet-connections/${connectionId}/allocation-suggestions`),
{ items: [] }
);
const suggestions = Array.isArray(payload.items) ? payload.items : [];
const suggestedIds = new Set(suggestions.map((item) => Number(item.customer_id)));
const suggestedOptions = suggestions.map((item) => (
`<option value="${item.customer_id}">${escapeHtml(item.customer_name || '-')} · ${escapeHtml(item.address || '')}</option>`
)).join('');
const otherOptions = customerOptions
.filter((item) => !suggestedIds.has(Number(item.id)))
.map((item) => `<option value="${item.id}">${escapeHtml(item.name || '-')}</option>`)
.join('');
customerSelect.innerHTML = '<option value="">Vælg kunde…</option>'
+ (suggestedOptions ? `<optgroup label="Forslag fra adressen">${suggestedOptions}</optgroup>` : '')
+ `<optgroup label="Alle kunder">${otherOptions}</optgroup>`;
suggestionsWrap.innerHTML = suggestions.length
? suggestions.map((item) => `
<button class="allocation-suggestion text-start" type="button" onclick="selectAllocationCustomer(${item.customer_id})">
<span class="fw-semibold d-block">${escapeHtml(item.customer_name || '-')}</span>
<span class="small text-muted">${escapeHtml(item.address || '')}${item.location_name ? ` · ${escapeHtml(item.location_name)}` : ''}</span>
</button>`).join('')
: '<span class="small text-muted">Ingen sikre kundematch på adressen. Vælg manuelt i listen.</span>';
document.getElementById('allocationFeedback').textContent = suggestions.length > 1
? `${suggestions.length} virksomheder er registreret på adressen. Vælg den rigtige.`
: suggestions.length === 1 ? 'Én virksomhed matcher adressen.' : '';
}
async function selectAllocationCustomer(customerId) {
document.getElementById('allocationCustomerSelect').value = String(customerId);
await loadAllocationSubscriptions();
}
async function loadAllocationSubscriptions() {
const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null;
const select = document.getElementById('allocationSubscriptionSelect');
const customerLink = document.getElementById('allocationCustomerLink');
customerLink.classList.toggle('d-none', !customerId);
customerLink.href = customerId ? `/customers/${customerId}` : '#';
if (!customerId) {
select.disabled = true;
select.innerHTML = '<option value="">Kun tildel kunde intet abonnement</option>';
return;
}
select.disabled = true;
select.innerHTML = '<option value="">Henter abonnementer…</option>';
const subscriptions = await safeJson(
await fetch(`/api/v1/internet-connections/subscription-options?customer_id=${customerId}&status=active`),
[]
);
select.innerHTML = '<option value="">Kun tildel kunde intet abonnement</option>'
+ subscriptions.map((item) => `<option value="${item.id}">${escapeHtml(item.subscription_number || `#${item.id}`)} · ${escapeHtml(item.product_name || '-')}</option>`).join('');
select.disabled = false;
document.getElementById('saveAllocationBtn').textContent = subscriptions.length
? 'Tildel kunde / abonnement'
: 'Tildel kunde';
}
async function saveConnectionAllocation() {
const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null;
const subscriptionId = Number(document.getElementById('allocationSubscriptionSelect').value || 0) || null;
const feedback = document.getElementById('allocationFeedback');
const button = document.getElementById('saveAllocationBtn');
if (!customerId) {
feedback.className = 'small text-danger';
feedback.textContent = 'Vælg en kunde først.';
return;
}
const payload = { customer_id: customerId };
if (subscriptionId) {
payload.subscription_id = subscriptionId;
payload.value_type = 'subscription';
payload.value_label = null;
}
button.disabled = true;
feedback.className = 'small text-muted';
feedback.textContent = 'Gemmer tildelingen…';
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Tildelingen kunne ikke gemmes.'));
await loadConnection();
} catch (error) {
feedback.className = 'small text-danger';
feedback.textContent = error.message || 'Tildelingen kunne ikke gemmes.';
} finally {
button.disabled = false;
}
}
async function loadConnection() {
await loadLookups();
@ -1320,6 +1619,7 @@
pricingHistoryResponse,
contractsResponse,
historyResponse,
casesResponse,
crossFieldPortsResponse,
] = await Promise.all([
fetch(`/api/v1/internet-connections/${connectionId}`),
@ -1330,6 +1630,7 @@
fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`),
fetch('/api/v1/internet-connections/contracts'),
fetch(`/api/v1/internet-connections/${connectionId}/history`),
fetch(`/api/v1/internet-connections/${connectionId}/cases`),
fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`),
]);
@ -1340,15 +1641,28 @@
}
const connection = await connectionResponse.json();
const ranges = await rangesResponse.json();
const addresses = await addressesResponse.json();
const summary = await summaryResponse.json();
const pricing = await pricingResponse.json();
const pricingHistory = await pricingHistoryResponse.json();
const contracts = await contractsResponse.json();
const history = await historyResponse.json();
const ranges = await safeJson(rangesResponse, []);
const addresses = await safeJson(addressesResponse, []);
const summary = await safeJson(summaryResponse, { available: 0, in_use: 0, reserved: 0 });
const pricing = await safeJson(pricingResponse, {});
const pricingHistory = await safeJson(pricingHistoryResponse, []);
const contracts = await safeJson(contractsResponse, []);
const history = await safeJson(historyResponse, []);
const cases = await safeJson(casesResponse, []);
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} });
const failedSections = [
[rangesResponse, 'IP-ranges'], [addressesResponse, 'IP-adresser'], [summaryResponse, 'IP-oversigt'],
[pricingResponse, 'priser'], [pricingHistoryResponse, 'prishistorik'], [contractsResponse, 'kontrakter'],
[historyResponse, 'historik'], [crossFieldPortsResponse, 'krydsfelt'],
[casesResponse, 'sager'],
].filter(([response]) => !response.ok).map(([, label]) => label);
if (failedSections.length) {
const feedback = document.getElementById('detailSaveFeedback');
feedback.className = 'small text-danger';
feedback.textContent = `Kunne ikke hente: ${failedSections.join(', ')}.`;
}
currentConnection = connection;
currentAddresses = Array.isArray(addresses) ? addresses : [];
currentRanges = Array.isArray(ranges) ? ranges : [];
@ -1356,15 +1670,35 @@
? await safeJson(await fetch(`/api/v1/internet-connections/${connectionId}/children?bmcnet_only=true`), [])
: [];
await renderAllocationBanner(connection);
renderCore(connection, pricing, summary);
renderRanges(currentRanges);
renderAddresses(currentAddresses);
renderPricing(pricing, pricingHistory);
renderHistory(history);
renderConnectionCases(cases);
renderRelationGrid(connection);
renderBmcnetChildren(connection, currentBmcnetChildren);
await loadDelefiberProductPrices();
renderContractsOverview(contracts);
renderCrossFieldPorts(crossFieldPorts);
const createCaseUrl = `/sag/new?internet_connection_id=${encodeURIComponent(connectionId)}`;
document.getElementById('createCaseForConnectionBtn').href = createCaseUrl;
document.getElementById('createCaseForConnectionPanelBtn').href = createCaseUrl;
}
function renderConnectionCases(cases) {
const list = document.getElementById('connectionCasesList');
if (!Array.isArray(cases) || !cases.length) {
list.innerHTML = '<div class="text-muted">Ingen sager er knyttet direkte til forbindelsen endnu.</div>';
return;
}
list.innerHTML = cases.map(item => `
<a class="d-flex justify-content-between align-items-center gap-3 border rounded-3 p-3 mb-2 text-decoration-none text-reset" href="/sag/${item.id}/v3">
<div><div class="fw-semibold">SAG-${item.id} · ${escapeHtml(item.titel || 'Uden titel')}</div><div class="small text-muted">${escapeHtml(item.customer_name || 'Ingen kunde')} · ${escapeHtml(item.responsible_name || 'Ikke tildelt')} · ændret ${formatDateTime(item.updated_at)}</div></div>
<span class="badge text-bg-light border">${escapeHtml(item.status || '-')}</span>
</a>`).join('');
}
function renderCrossFieldPorts(payload) {
@ -1406,6 +1740,7 @@
function renderCore(connection, pricing, summary) {
document.getElementById('detailName').textContent = connection.name || 'Unavngiven forbindelse';
document.querySelector('#detailCircuitBadge span').textContent = connection.circuit_number || 'Intet kredsløbsnummer';
const ownerText = connection.customer_name || 'Ingen kunde koblet på';
const businessText = connection.allocation_model === 'shared'
? `Ejer: ${ownerText} · kunder fordeles på ranges/IP'er`
@ -1420,19 +1755,23 @@
const childConnections = Array.isArray(currentBmcnetChildren) ? currentBmcnetChildren : [];
const totalDown = Number(connection.download_mbps || 0);
const totalUp = Number(connection.upload_mbps || 0);
const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || child.speed_mbps || 0), 0);
const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || child.speed_mbps || 0), 0);
// Only explicit child allocations count as consumed capacity. A nominal
// connection speed is not proof that bandwidth was allocated to a customer.
const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || 0), 0);
const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || 0), 0);
const downPct = totalDown > 0 ? Math.round((usedDown / totalDown) * 100) : 0;
const upPct = totalUp > 0 ? Math.round((usedUp / totalUp) * 100) : 0;
document.getElementById('detailSubtitle').textContent = `${connection.provider || 'Ingen leverandør'} · ${businessText}`;
document.getElementById('metricSales').textContent = formatDKK(effectiveSales);
document.getElementById('metricPurchase').textContent = formatDKK(effectivePurchase);
document.getElementById('metricMargin').textContent = formatDKK(effectiveMargin);
document.getElementById('metricIps').textContent = String((summary.in_use || 0) + (summary.reserved || 0));
document.getElementById('metricBandwidth').textContent = `${usedDown} / ${totalDown || 0} Mbps`;
document.getElementById('metricBandwidthSub').textContent = totalUp
? `Upload ${usedUp} / ${totalUp} Mbps · ${downPct}% ned / ${upPct}% op`
: `${downPct}%`;
document.getElementById('metricIps').textContent = String((summary.available || 0) + (summary.in_use || 0) + (summary.reserved || 0));
document.getElementById('metricBandwidth').textContent = childConnections.length
? `${usedDown} Mbps allokeret`
: '0 Mbps allokeret';
document.getElementById('metricBandwidthSub').textContent = totalDown || totalUp
? `Kapacitet ${totalDown || '-'} / ${totalUp || '-'} Mbps · ${downPct}% ned / ${upPct}% op${childConnections.length ? '' : ' · Ingen kundebåndbredde registreret'}`
: 'Kapacitet ikke registreret';
document.getElementById('ipAddressSummary').innerHTML = `Tilgængelige: <strong>${summary.available || 0}</strong> · Reserverede: <strong>${summary.reserved || 0}</strong> · I brug: <strong>${summary.in_use || 0}</strong>`;
const contractText = connection.contract_start || connection.contract_end
@ -1445,32 +1784,35 @@
? `<a href="${escapeHtml(connection.monitoring_url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(connection.monitoring_url)}</a>`
: '<span class="detail-read-muted">-</span>';
renderSla(connection);
document.getElementById('coreReadView').innerHTML = `
<div class="detail-read-strip">
<div class="detail-read-chip"><span class="label">Leverandør</span><span class="value">${connection.provider || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Kredsløb</span><span class="value">${connection.circuit_number || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Binding</span><span class="value">${connection.allocation_model_label || '-'} · ${connection.value_type_label || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Type</span><span class="value">${connection.connection_type || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Teknologi</span><span class="value">${connection.technology || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Leverandør</span><span class="value">${escapeHtml(connection.provider || '-')}</span></div>
<div class="detail-read-chip"><span class="label">Kredsløb</span><span class="value">${escapeHtml(connection.circuit_number || '-')}</span></div>
${connection.is_shared_head ? `<div class="detail-read-chip"><span class="label">Netværksmodel</span><span class="value">Delt hovedforbindelse${connection.value_type_label ? ` · ${escapeHtml(connection.value_type_label)}` : ''}</span></div>` : ''}
<div class="detail-read-chip"><span class="label">Type</span><span class="value">${escapeHtml(connection.connection_type || '-')}</span></div>
<div class="detail-read-chip"><span class="label">Teknologi</span><span class="value">${escapeHtml(connection.technology || '-')}</span></div>
<div class="detail-read-chip"><span class="label">Hastighed</span><span class="value">${connection.download_mbps || 0}/${connection.upload_mbps || 0} Mbps</span></div>
</div>
<div class="detail-read-grid">
<div class="detail-read-row full"><span class="label">Navn</span><div class="value">${connection.name || '-'}</div></div>
<div class="detail-read-row full"><span class="label">Adresse</span><div class="value">${connection.address || '-'}</div></div>
<div class="detail-read-row"><span class="label">Kunde</span><div class="value">${connection.customer_name || '-'}</div></div>
<div class="detail-read-row"><span class="label">Parent</span><div class="value">${connection.parent_name || '-'}</div></div>
<div class="detail-read-row"><span class="label">Abonnement</span><div class="value">${subscriptionText}</div></div>
<div class="detail-read-row full"><span class="label">Navn</span><div class="value">${escapeHtml(connection.name || '-')}</div></div>
<div class="detail-read-row full"><span class="label">Adresse</span><div class="value">${escapeHtml(connection.address || '-')}</div></div>
<div class="detail-read-row"><span class="label">Kunde</span><div class="value">${escapeHtml(connection.customer_name || '-')}</div></div>
<div class="detail-read-row"><span class="label">Parent</span><div class="value">${escapeHtml(connection.parent_name || '-')}</div></div>
<div class="detail-read-row"><span class="label">${connection.subscription_number ? 'Abonnement' : 'Klassifikation'}</span><div class="value">${escapeHtml(subscriptionText)}</div></div>
<div class="detail-read-row"><span class="label">Kontrakt</span><div class="value">${contractText}</div></div>
<div class="detail-read-row"><span class="label">Fallback</span><div class="value">${connection.speed_mbps || 0} Mbps</div></div>
<div class="detail-read-row"><span class="label">Nominel hastighed</span><div class="value">${connection.speed_mbps ? `${connection.speed_mbps} Mbps` : '-'}</div></div>
<div class="detail-read-row"><span class="label">Overvågning</span><div class="value">${monitoringText}</div></div>
<div class="detail-read-row full"><span class="label">Noter</span><div class="value">${connection.notes || '<span class="detail-read-muted">-</span>'}</div></div>
<div class="detail-read-row"><span class="label">SLA</span><div class="value">${connection.sla_subscription_number ? `${escapeHtml(connection.sla_product_name || 'SLA')} · ${formatDKK(connection.sla_price || 0)}` : '<span class="text-danger fw-semibold">Ingen SLA-aftale</span>'}</div></div>
<div class="detail-read-row full"><span class="label">Noter</span><div class="value">${connection.notes ? escapeHtml(connection.notes) : '<span class="detail-read-muted">-</span>'}</div></div>
</div>
`;
setStatusBadge(connection.status);
document.getElementById('fieldName').value = connection.name || '';
document.getElementById('fieldProvider').value = connection.provider || '';
document.getElementById('fieldVendorId').value = connection.vendor_id || '';
document.getElementById('fieldCircuit').value = connection.circuit_number || '';
document.getElementById('fieldAddress').value = connection.address || '';
setLookupValue('fieldCustomerLookup', 'fieldCustomerId', customerOptions, connection.customer_id);
@ -1484,6 +1826,7 @@
document.getElementById('fieldMonitoringUrl').value = connection.monitoring_url || '';
document.getElementById('fieldAllocationModel').value = connection.allocation_model || 'dedicated';
document.getElementById('fieldValueType').value = connection.value_type || 'other';
document.getElementById('fieldManualShared').checked = Boolean(connection.is_manual_shared);
document.getElementById('fieldValueLabel').value = connection.value_label || '';
setSubscriptionValue(connection.subscription_id);
document.getElementById('fieldContractStart').value = connection.contract_start || '';
@ -1493,6 +1836,66 @@
toggleCoreEdit(false, true);
}
function renderSla(connection) {
const banner = document.getElementById('slaBanner');
const content = document.getElementById('slaBannerContent');
const select = document.getElementById('slaSubscriptionSelect');
const customerId = Number(connection.customer_id || 0);
const isBmcSharedFiber = Boolean(
connection.is_manual_shared
|| (connection.parent_id == null && connection.allocation_model === 'shared' && connection.value_type === 'delefiber')
);
if (isBmcSharedFiber) {
banner.className = 'd-none';
return;
}
const options = subscriptionOptions.filter((item) =>
Number(item.customer_id) === customerId && /\bsla\b/i.test(String(item.product_name || ''))
);
if (connection.sla_subscription_id && !options.some((item) => Number(item.id) === Number(connection.sla_subscription_id))) {
options.unshift({
id: connection.sla_subscription_id,
product_name: connection.sla_product_name,
subscription_number: connection.sla_subscription_number,
price: connection.sla_price,
status: connection.sla_status,
});
}
select.innerHTML = '<option value="">Ingen SLA-aftale</option>' + options.map((item) =>
`<option value="${item.id}" ${Number(item.id) === Number(connection.sla_subscription_id) ? 'selected' : ''}>${escapeHtml(item.product_name || 'SLA')} · ${escapeHtml(item.subscription_number || `#${item.id}`)} · ${formatDKK(item.price || 0)}</option>`
).join('');
select.disabled = !customerId;
const hasSla = Boolean(connection.sla_subscription_id);
const priceOk = Number(connection.sla_price || 0) > 0;
const statusOk = connection.sla_status === 'active';
banner.className = `alert mb-4 ${hasSla && priceOk && statusOk ? 'alert-success' : 'alert-warning'}`;
content.innerHTML = hasSla
? `<div class="fw-bold"><i class="bi bi-shield-check me-2"></i>${escapeHtml(connection.sla_product_name || 'SLA-aftale')}</div><div class="small mt-1">${escapeHtml(connection.sla_subscription_number || '')} · Pris ${formatDKK(connection.sla_price || 0)} · ${priceOk ? 'Pris registreret' : '<strong>Prisen skal kontrolleres</strong>'}${statusOk ? '' : ' · <strong>SLA-aftalen er ikke aktiv</strong>'}</div>`
: `<div class="fw-bold"><i class="bi bi-shield-exclamation me-2"></i>Ingen SLA-aftale</div><div class="small mt-1">${customerId ? (options.length ? 'Vælg kundens SLA-aftale.' : 'Kunden har ingen aktiv SLA-aftale, der kan allokeres.') : 'Tildel først forbindelsen til en kunde.'}</div>`;
}
async function saveSlaAllocation() {
const value = Number(document.getElementById('slaSubscriptionSelect').value || 0) || null;
const feedback = document.getElementById('slaFeedback');
const button = document.getElementById('saveSlaBtn');
button.disabled = true;
feedback.textContent = 'Gemmer SLA-allokering…';
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sla_subscription_id: value }),
});
if (!response.ok) throw new Error(await extractErrorMessage(response, 'SLA-allokeringen kunne ikke gemmes.'));
feedback.textContent = 'SLA-allokeringen er gemt.';
await loadConnection();
} catch (error) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = error.message;
} finally {
button.disabled = false;
}
}
function toggleCoreEdit(force, silent = false) {
coreEditMode = Boolean(force);
document.getElementById('coreReadView').classList.toggle('d-none', coreEditMode);
@ -1512,27 +1915,38 @@
const mismatchedRanges = ranges.filter((range) => range.belongs_to_connection === false);
if (!visibleRanges.length && !mismatchedRanges.length) {
list.innerHTML = '<div class="text-muted">Ingen IP-ranges registreret endnu.</div>';
list.innerHTML = '<div class="ip-empty-state" style="grid-column:1/-1"><i class="bi bi-hdd-network"></i><strong>Ingen IP-ranges registreret</strong><div class="small text-muted mt-1">Tilføj et CIDR-range eller kontrollér kredsløbsreferencen.</div></div>';
rangeSelect.innerHTML = '<option value="">Ingen ranges</option>';
return;
}
const visibleMarkup = visibleRanges.map((range) => `
<div class="detail-grid-card" id="rangeCard-${range.id}">
<span class="label">${range.name || 'Range'}</span>
<div class="value">${range.cidr || '-'}</div>
<div class="small text-muted mt-2">Brugbare: ${range.usable_hosts || 0} · Brugt: ${range.used_addresses || 0} · Ledige: ${range.available_addresses || 0}</div>
${range.customer_name ? `<div class="small text-muted mt-1">Kunde: ${range.customer_name}</div>` : ''}
${range.provider_reference ? `<div class="small text-muted mt-1">Ref: ${range.provider_reference}</div>` : ''}
${range.contract_number ? `<div class="small text-muted mt-1">Kontrakt: ${range.contract_number}</div>` : ''}
${range.service_address ? `<div class="small text-muted mt-1">${range.service_address}</div>` : ''}
${(Number(range.monthly_cost || 0) || Number(range.sales_price || 0)) ? `<div class="small text-muted mt-1">Kost ${formatDKK(range.monthly_cost || 0)} · Salg ${formatDKK(range.sales_price || 0)}</div>` : ''}
${range.description ? `<div class="small text-muted mt-1">${range.description}</div>` : ''}
<div class="mt-3 d-flex gap-2">
<div class="detail-grid-card ip-range-card" id="rangeCard-${range.id}">
<div class="ip-range-main">
<span class="label">${range.name || 'Range'}</span>
<div class="value ip-range-cidr"><i class="bi bi-globe2"></i>${range.cidr || '-'}</div>
<div class="small text-muted mt-1">${range.service_address || 'Ingen serviceadresse'}</div>
</div>
<div class="ip-range-meta">
<span class="label">Adresser</span>
<div class="value">${range.used_addresses || 0} brugt · ${range.available_addresses || 0} ledige</div>
<div class="small text-muted">${range.usable_hosts || 0} brugbare i alt</div>
</div>
<div class="ip-range-meta">
<span class="label">Reference</span>
<div class="value">${range.provider_reference || '-'}</div>
<div class="small text-muted">Kontrakt ${range.contract_number || '-'}</div>
</div>
<div class="ip-range-meta">
<span class="label">Økonomi</span>
<div class="value">${formatDKK(range.monthly_cost || 0)} kost</div>
<div class="small text-muted">${formatDKK(range.sales_price || 0)} salg${range.customer_name ? ` · ${range.customer_name}` : ''}</div>
</div>
<div class="ip-range-actions d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleRangeEdit(${range.id}, true)">Rediger</button>
</div>
<div class="small text-muted mt-2" id="rangeFeedback-${range.id}"></div>
<div class="d-none" id="rangeEdit-${range.id}">
<div class="small text-muted ip-range-edit" id="rangeFeedback-${range.id}"></div>
<div class="ip-range-edit d-none" id="rangeEdit-${range.id}">
<div class="range-edit-grid">
<div>
<label class="form-label small text-muted mb-1">Navn</label>
@ -1581,15 +1995,12 @@
`).join('');
const mismatchMarkup = mismatchedRanges.length ? `
<div class="detail-grid-card" style="grid-column: 1 / -1; border-color: #f3c2c2; background: #fff8f8;">
<span class="label text-danger">Afvigelser</span>
<div class="small text-muted mb-2">Disse ranges matcher ikke forbindelsens adresse eller kunde og bør kontrolleres.</div>
${mismatchedRanges.map((range) => `
<div class="small mb-2">
<strong>${range.cidr}</strong> · ${range.customer_name || 'Ingen kunde'}<br>
<span class="text-danger">${range.alignment_warning || 'Matcher ikke forbindelsen'}</span>
</div>
`).join('')}
<div class="ip-range-warning">
<i class="bi bi-exclamation-triangle-fill text-danger mt-1"></i>
<div>
<div class="fw-semibold text-danger">${mismatchedRanges.length} range${mismatchedRanges.length === 1 ? '' : 's'} kræver kontrol</div>
${mismatchedRanges.map((range) => `<div class="small mt-1"><strong>${range.cidr}</strong> · ${range.alignment_warning || 'Matcher ikke forbindelsen'}</div>`).join('')}
</div>
</div>
` : '';
@ -1661,7 +2072,7 @@
});
if (!filtered.length) {
body.innerHTML = '<tr><td colspan="5" class="text-muted py-4">Ingen IP-adresser matcher filtrene.</td></tr>';
body.innerHTML = '<tr><td colspan="5"><div class="ip-empty-state my-2"><i class="bi bi-search"></i><strong>Ingen IP-adresser matcher</strong><div class="small text-muted mt-1">Prøv at rydde søgning eller statusfilter.</div></div></td></tr>';
return;
}
@ -1913,7 +2324,7 @@
document.getElementById('fieldSubscriptionId').value = resolveSubscriptionId(document.getElementById('fieldSubscriptionLookup').value) || '';
const payload = {
name: document.getElementById('fieldName').value.trim(),
provider: document.getElementById('fieldProvider').value.trim() || null,
vendor_id: Number(document.getElementById('fieldVendorId').value || 0) || null,
circuit_number: document.getElementById('fieldCircuit').value.trim() || null,
address: document.getElementById('fieldAddress').value.trim() || null,
customer_id: Number(document.getElementById('fieldCustomerId').value || 0) || null,
@ -1929,6 +2340,7 @@
value_type: document.getElementById('fieldValueType').value || 'other',
value_label: document.getElementById('fieldValueLabel').value.trim() || null,
subscription_id: Number(document.getElementById('fieldSubscriptionId').value || 0) || null,
is_manual_shared: document.getElementById('fieldManualShared').checked,
contract_start: document.getElementById('fieldContractStart').value || null,
contract_end: document.getElementById('fieldContractEnd').value || null,
notes: document.getElementById('fieldNotes').value.trim() || null,
@ -1976,6 +2388,19 @@
document.getElementById('fieldSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription');
}
function toggleManualDelefiber() {
const enabled = document.getElementById('fieldManualShared').checked;
if (enabled) {
document.getElementById('fieldAllocationModel').value = 'shared';
document.getElementById('fieldValueType').value = 'delefiber';
} else if (document.getElementById('fieldValueType').value === 'delefiber') {
document.getElementById('fieldAllocationModel').value = 'dedicated';
document.getElementById('fieldValueType').value = 'other';
document.getElementById('fieldValueLabel').value = 'Internetforbindelse';
}
toggleValueFields();
}
async function createRange() {
const feedback = document.getElementById('rangeCreateFeedback');
const button = document.getElementById('createRangeButton');
@ -2039,7 +2464,7 @@
assigned_connection_id: Number(document.getElementById('addressAssignedConnectionIdInput').value || 0) || null,
};
if (!payload.range_id || !payload.ip_address) {
alert('Range og IP-adresse er påkrævet');
showDetailMessage('Range og IP-adresse er påkrævet.', true);
return;
}
@ -2049,7 +2474,7 @@
body: JSON.stringify(payload),
});
if (!response.ok) {
alert('Kunne ikke oprette IP-adresse');
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke oprette IP-adresse.'), true);
return;
}
document.getElementById('addressIpInput').value = '';
@ -2090,7 +2515,7 @@
body: JSON.stringify(payload),
});
if (!response.ok) {
alert('Kunne ikke opdatere IP-adressen');
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke opdatere IP-adressen.'), true);
return;
}
editingAddressId = null;
@ -2150,6 +2575,14 @@
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
await refreshBmcnetWizardAllocationMode();
});
document.getElementById('bmcnetWizardRangeSelect').addEventListener('change', (event) => {
const option = event.target.options[event.target.selectedIndex];
selectIpProductForPrefix(option?.dataset?.prefix);
});
document.getElementById('bmcnetWizardIpSelect').addEventListener('change', () => {
// A single selected public address is sold as a Static WAN IP.
if (document.getElementById('bmcnetWizardIpSelect').value) selectIpProductForPrefix(32);
});
document.getElementById('bmcnetWizardAddress').addEventListener('change', async () => {
await refreshBmcnetWizardAllocationMode();
});

View File

@ -173,13 +173,13 @@
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-danger" href="#invoiceProcessingOverview">
<i class="bi bi-receipt-cutoff me-1"></i>Fakturabehandling
</a>
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">
<button class="btn btn-outline-secondary" type="button" onclick="refreshActiveTab()">
<i class="bi bi-arrow-repeat me-1"></i>Opdater
</button>
<button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock">
<button class="btn btn-outline-primary" type="button" data-bs-toggle="modal" data-bs-target="#ipNordicImportModal">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Importér IP Nordic
</button>
<button class="btn btn-primary" id="newConnectionBtn" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock">
<i class="bi bi-plus-lg me-1"></i>Ny forbindelse
</button>
</div>
@ -188,10 +188,13 @@
<button class="internet-tab active" type="button" id="tabAll" onclick="setActiveTab('all')">Alle forbindelser</button>
<button class="internet-tab" type="button" id="tabShared" onclick="setActiveTab('shared')">Delte hovedforbindelser</button>
<button class="internet-tab" type="button" id="tabBmcnet" onclick="setActiveTab('bmcnet')">BMCnet</button>
<button class="internet-tab" type="button" id="tabDedicated" onclick="setActiveTab('dedicated')">Dedikerede</button>
<button class="internet-tab" type="button" id="tabUnallocated" onclick="setActiveTab('unallocated')">Ikke allokeret</button>
<button class="internet-tab" type="button" id="tabInvoices" onclick="setActiveTab('invoices')">Behandlede internetfakturaer</button>
</div>
</div>
<div class="row g-3 mb-4">
<div class="row g-3 mb-4" id="connectionsMetrics">
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label">Forbindelser</div>
@ -200,7 +203,7 @@
</div>
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label">Aktive</div>
<div class="internet-kpi-label">Aktive / afventer</div>
<div class="internet-kpi-value" id="metricActive">0</div>
</div>
</div>
@ -232,7 +235,7 @@
<input type="text" class="form-control" id="connectionNameInput" placeholder="Navn" />
</div>
<div class="col-lg-2">
<input type="text" class="form-control" id="connectionProviderInput" placeholder="Leverandør" />
<select class="form-select" id="connectionVendorInput"><option value="">Vælg internetleverandør</option></select>
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionCustomerIdInput" placeholder="Kunde-ID" />
@ -296,13 +299,14 @@
</div>
</div>
<div class="internet-panel p-4 mb-4">
<div class="internet-panel p-4 mb-4" id="connectionsOverview">
<div class="internet-toolbar mb-3">
<input type="search" class="form-control" id="searchInput" placeholder="Søg navn, kunde, leverandør, kredsløb eller adresse" />
<input type="text" class="form-control" id="providerFilter" placeholder="Filtrer leverandør" />
<select class="form-select" id="statusFilter">
<option value="">Alle statusser</option>
<option value="active">Aktive</option>
<option value="pending">Afventer kontrol</option>
<option value="planned">Planlagte</option>
<option value="inactive">Inaktive</option>
<option value="terminated">Opsagte</option>
@ -349,13 +353,14 @@
</div>
</div>
<div class="internet-panel p-4 mb-4" id="invoiceProcessingOverview">
<div class="internet-panel p-4 mb-4 d-none" id="invoiceProcessingOverview">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
<div>
<h3 class="h5 mb-1">Behandlede internetfakturaer</h3>
<div class="internet-mini">GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.</div>
</div>
<div class="d-flex gap-2">
<span class="internet-mini align-self-center" id="invoiceReconcileFeedback" role="status"></span>
<select class="form-select form-select-sm" id="invoiceSyncStatusFilter" onchange="loadInvoiceSyncRuns()">
<option value="">Alle resultater</option>
<option value="success">Gennemført</option>
@ -364,6 +369,7 @@
<option value="skipped">Sprunget over</option>
<option value="not_logged">Ældre uden detaljer</option>
</select>
<button class="btn btn-sm btn-outline-primary" id="invoiceReconcileBtn" type="button" onclick="reconcileInvoiceSyncRuns()"><i class="bi bi-arrow-clockwise me-1"></i>Genkontrollér</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadInvoiceSyncRuns()"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button>
</div>
</div>
@ -382,6 +388,36 @@
</div>
</div>
<div class="modal fade" id="ipNordicImportModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title mb-1">Importér IP Nordic-forbindelser</h5>
<div class="internet-mini">Excel-filen kontrolleres før import. Kunder tilknyttes aldrig automatisk.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label fw-semibold" for="ipNordicFileInput">IP Nordic Excel-fil (.xlsx)</label>
<input class="form-control" id="ipNordicFileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
<div class="small mt-2" id="ipNordicImportFeedback" role="status"></div>
<div class="table-responsive mt-3 d-none" id="ipNordicPreviewWrap">
<table class="table table-sm align-middle">
<thead><tr><th>Handling</th><th>Rapporteret firma</th><th>Adresse</th><th>Start</th><th class="text-end">Kost</th><th class="text-end">Salg</th></tr></thead>
<tbody id="ipNordicPreviewBody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button>
<button class="btn btn-outline-primary" id="ipNordicPreviewBtn" type="button" onclick="previewIpNordicImport()">Kontrollér fil</button>
<button class="btn btn-primary d-none" id="ipNordicCommitBtn" type="button" onclick="commitIpNordicImport()">Importér nye forbindelser</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="invoiceReviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
@ -391,6 +427,10 @@
</div>
<div class="modal-body">
<div class="alert alert-info small">Vælg en eksisterende forbindelse, opret en separat afventende forbindelse, eller ignorér linjen med en begrundelse. Fakturaen markeres gennemført, når alle kontrollinjer er afklaret.</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="showResolvedInvoiceLines" onchange="toggleResolvedInvoiceLines()">
<label class="form-check-label" for="showResolvedInvoiceLines">Vis allerede løste linjer</label>
</div>
<div id="invoiceReviewLines"></div>
</div>
</div>
@ -402,6 +442,65 @@
let invoiceSyncItems = [];
let activeTab = 'all';
let subscriptionOptions = [];
let ipNordicPreview = null;
let allocationSuggestionsByConnection = new Map();
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
}[char]));
}
async function runIpNordicImport(commit) {
const input = document.getElementById('ipNordicFileInput');
const feedback = document.getElementById('ipNordicImportFeedback');
const previewButton = document.getElementById('ipNordicPreviewBtn');
const commitButton = document.getElementById('ipNordicCommitBtn');
const file = input.files?.[0];
if (!file) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = 'Vælg først en Excel-fil.';
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('commit', commit ? 'true' : 'false');
previewButton.disabled = true;
commitButton.disabled = true;
feedback.className = 'small mt-2 text-muted';
feedback.textContent = commit ? 'Importerer...' : 'Kontrollerer filen...';
try {
const response = await fetch('/api/v1/internet-connections/import/ip-nordic', { method: 'POST', body: formData });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || 'Importen kunne ikke gennemføres.');
ipNordicPreview = payload;
document.getElementById('ipNordicPreviewWrap').classList.remove('d-none');
document.getElementById('ipNordicPreviewBody').innerHTML = (payload.items || []).map((item) => `
<tr>
<td>${item.action === 'skip' ? `<span class="badge text-bg-secondary">Findes #${item.existing_connection_id}</span>` : '<span class="badge text-bg-success">Ny</span>'}</td>
<td>${escapeHtml(item.reported_company || '-')}<div class="internet-mini">Nr. ${escapeHtml(item.company_number || '-')} · ${Number(item.line_count || 0)} linjer</div></td>
<td>${escapeHtml(item.address || '-')}</td>
<td>${escapeHtml(item.start_date || '-')}</td>
<td class="text-end">${formatDKK(item.monthly_cost)}</td>
<td class="text-end">${formatDKK(item.sales_price)}</td>
</tr>`).join('');
feedback.className = 'small mt-2 text-success';
feedback.textContent = commit
? `${payload.created_count} forbindelser oprettet, ${payload.skipped_count} eksisterende sprunget over.`
: `${payload.create_count} nye og ${payload.existing_count} eksisterende forbindelser fundet.`;
commitButton.classList.toggle('d-none', commit || payload.create_count === 0);
if (commit) await loadInternetPage();
} catch (error) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = error.message || 'Importen kunne ikke gennemføres.';
} finally {
previewButton.disabled = false;
commitButton.disabled = false;
}
}
function previewIpNordicImport() { return runIpNordicImport(false); }
function commitIpNordicImport() { return runIpNordicImport(true); }
function formatDKK(value) {
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
@ -431,17 +530,19 @@
function allocationBadge(item) {
const label = item.allocation_model_label || (item.allocation_model === 'shared' ? 'Delt' : 'Dedikeret');
return `<span class="internet-tag">${label}</span>`;
return `<span class="internet-tag">${escapeHtml(label)}</span>`;
}
function valueBadge(item) {
const label = item.value_type_label || 'Anden';
return `<span class="internet-tag">${label}</span>`;
return `<span class="internet-tag">${escapeHtml(label)}</span>`;
}
function currentTabDescription() {
if (activeTab === 'shared') return ' i delte hovedforbindelser';
if (activeTab === 'bmcnet') return ' i BMCnet';
if (activeTab === 'dedicated') return ' i dedikerede forbindelser';
if (activeTab === 'unallocated') return ' uden allokeret kunde';
return '';
}
@ -551,6 +652,27 @@
}
}
async function reconcileInvoiceSyncRuns() {
const button = document.getElementById('invoiceReconcileBtn');
const feedback = document.getElementById('invoiceReconcileFeedback');
button.disabled = true;
feedback.className = 'internet-mini align-self-center text-muted';
feedback.textContent = 'Genkontrollerer…';
try {
const response = await fetch('/api/v1/internet-connections/invoice-sync-runs/reconcile', { method: 'POST' });
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Genkontrollen fejlede.'));
const payload = await response.json();
feedback.className = 'internet-mini align-self-center text-success';
feedback.textContent = `${Number(payload.resolved_lines || 0)} linjer løst`;
await loadInvoiceSyncRuns();
} catch (error) {
feedback.className = 'internet-mini align-self-center text-danger';
feedback.textContent = error.message || 'Genkontrollen fejlede.';
} finally {
button.disabled = false;
}
}
function reviewConnectionOptions(selectedId = '') {
return '<option value="">Vælg forbindelse…</option>' + allConnections
.filter(item => String(item.provider || '').toLowerCase().includes('globalconnect'))
@ -558,14 +680,33 @@
.join('');
}
function openInvoiceReview(runId) {
const item = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
function toggleResolvedInvoiceLines() {
const show = document.getElementById('showResolvedInvoiceLines').checked;
document.querySelectorAll('#invoiceReviewLines .resolved-review-line').forEach((element) => {
element.classList.toggle('d-none', !show);
});
}
async function openInvoiceReview(runId) {
const summaryItem = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
if (!summaryItem) return;
const linesWrap = document.getElementById('invoiceReviewLines');
linesWrap.innerHTML = '<div class="text-muted py-4">Henter kontrollinjer…</div>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs/${runId}`);
if (!response.ok) {
linesWrap.innerHTML = `<div class="text-danger py-4">${escapeInvoiceText(await extractErrorMessage(response, 'Kunne ikke hente kontrollinjer.'))}</div>`;
return;
}
const detail = await response.json();
const item = { ...summaryItem, ...detail };
if (!item) return;
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
const decisions = Array.isArray(item.review_decisions) ? item.review_decisions : [];
const decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision]));
document.getElementById('invoiceReviewNumber').textContent = item.invoice_number || '-';
document.getElementById('invoiceReviewSummary').textContent = `${Number(item.resolved_lines || 0)} løst · ${Number(item.unresolved_lines || 0)} mangler`;
document.getElementById('showResolvedInvoiceLines').checked = false;
const groups = skippedItems.reduce((result, line) => {
const reason = line.reason || 'Anden kontrol';
(result[reason] ||= []).push(line);
@ -577,7 +718,7 @@
<div class="vstack gap-2">
${lines.map(line => {
const decision = decisionsByLine.get(Number(line.line_number));
return `<div class="border rounded p-3 ${decision ? 'bg-light opacity-75' : ''}">
return `<div class="border rounded p-3 ${decision ? 'bg-light opacity-75 resolved-review-line d-none' : ''}">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div class="flex-grow-1">
<div class="fw-semibold">Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}</div>
@ -600,7 +741,6 @@
</div>
</section>
`).join('') || '<div class="text-success">Alle kontrollinjer er løst.</div>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
}
async function submitInvoiceReview(runId, lineNumber, action) {
@ -645,22 +785,43 @@
if (valueType) params.set('value_type', valueType);
if (activeTab === 'shared') params.set('shared_only', 'true');
if (activeTab === 'bmcnet') params.set('bmcnet_only', 'true');
if (activeTab === 'dedicated') {
params.set('allocation_model', 'dedicated');
params.set('allocated_only', 'true');
}
if (activeTab === 'unallocated') params.set('unallocated_only', 'true');
try {
const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`);
const connections = await safeJson(connectionsResponse, []);
allConnections = Array.isArray(connections) ? connections : [];
allocationSuggestionsByConnection = new Map();
if (activeTab === 'unallocated') {
const allocationResponse = await fetch('/api/v1/internet-connections/allocation-overview');
const allocationPayload = await safeJson(allocationResponse, { items: [] });
allocationSuggestionsByConnection = new Map(
(allocationPayload.items || []).map((entry) => [Number(entry.connection_id), entry])
);
}
const total = allConnections.length;
const active = allConnections.filter((item) => item.status === 'active').length;
const pending = allConnections.filter((item) => item.status === 'pending').length;
const sharedHeads = allConnections.filter((item) => item.is_shared_head).length;
const bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length;
const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0);
document.getElementById('metricTotal').textContent = String(total);
document.getElementById('metricActive').textContent = String(active);
document.getElementById('metricSharedLabel').textContent = activeTab === 'bmcnet' ? 'BMCnet' : 'Delte hoveder';
document.getElementById('metricShared').textContent = String(activeTab === 'bmcnet' ? bmcnetConnections : sharedHeads);
document.getElementById('metricActive').textContent = `${active} / ${pending}`;
const focusMetric = activeTab === 'bmcnet'
? ['BMCnet', bmcnetConnections]
: activeTab === 'dedicated'
? ['Dedikerede', total]
: activeTab === 'unallocated'
? ['Uden kunde', total]
: ['Delte hoveder', sharedHeads];
document.getElementById('metricSharedLabel').textContent = focusMetric[0];
document.getElementById('metricShared').textContent = String(focusMetric[1]);
document.getElementById('metricMargin').textContent = formatDKK(margin);
renderConnections(allConnections);
@ -679,23 +840,32 @@
return;
}
body.innerHTML = connections.map((item) => `
body.innerHTML = connections.map((item) => {
const allocation = allocationSuggestionsByConnection.get(Number(item.id));
const uniqueSuggestion = allocation?.unique_suggestion;
const suggestionCount = Number(allocation?.suggestions?.length || 0);
return `
<tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'">
<td>
<div class="fw-semibold">${item.name || '-'}</div>
<div class="fw-semibold">${escapeHtml(item.name || '-')}</div>
<div class="mb-1">${allocationBadge(item)}${valueBadge(item)}</div>
<div class="internet-mini">${item.address || '-'}</div>
${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${item.parent_name}</div>` : ''}
<div class="internet-mini">${escapeHtml(item.address || '-')}</div>
${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${escapeHtml(item.parent_name)}</div>` : ''}
${item.is_shared_head ? `<div class="internet-mini"><i class="bi bi-diagram-3 me-1"></i>${Number(item.bmcnet_child_count || 0)} BMCnet-kunder · ${formatDKK(item.bmcnet_child_sales_price || 0)} salg</div>` : ''}
</td>
<td>
<div>${item.customer_name || '-'}</div>
<div>${escapeHtml(item.customer_name || '-')}</div>
<div class="internet-mini">Kunde-ID: ${item.customer_id || '-'}</div>
${uniqueSuggestion ? `<button class="btn btn-sm btn-outline-success mt-1" type="button" onclick="event.stopPropagation(); assignSuggestedCustomer(${Number(item.id)}, ${Number(uniqueSuggestion.customer_id)})"><i class="bi bi-person-check me-1"></i>${escapeHtml(uniqueSuggestion.customer_name || 'Tildel foreslået kunde')}</button>` : ''}
${!uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
${item.sla_subscription_id
? `<div class="internet-mini ${Number(item.sla_price || 0) > 0 && item.sla_status === 'active' ? 'text-success' : 'text-warning'} fw-semibold"><i class="bi bi-shield-check me-1"></i>${escapeHtml(item.sla_product_name || 'SLA')} · ${formatDKK(item.sla_price || 0)}${Number(item.sla_price || 0) > 0 ? '' : ' · Kontrollér pris'}${item.sla_status === 'active' ? '' : ' · Ikke aktiv'}</div>`
: `<div class="internet-mini text-danger fw-semibold"><i class="bi bi-shield-exclamation me-1"></i>Ingen SLA-aftale</div>`}
</td>
<td>
<div>${item.provider || '-'}</div>
<div class="internet-mini">${item.circuit_number || 'Intet kredsløb'}</div>
${item.subscription_number ? `<div class="internet-mini">Abonnement ${item.subscription_number} · ${item.subscription_product_name || '-'}</div>` : (item.value_label ? `<div class="internet-mini">${item.value_label}</div>` : '')}
<div>${escapeHtml(item.provider || '-')}</div>
<div class="internet-mini">${escapeHtml(item.circuit_number || 'Intet kredsløb')}</div>
${item.subscription_number ? `<div class="internet-mini">Abonnement ${escapeHtml(item.subscription_number)} · ${escapeHtml(item.subscription_product_name || '-')}</div>` : (item.value_label ? `<div class="internet-mini">${escapeHtml(item.value_label)}</div>` : '')}
</td>
<td>
<div>${formatSpeed(item)}</div>
@ -713,7 +883,19 @@
</a>
</td>
</tr>
`).join('');
`}).join('');
}
async function assignSuggestedCustomer(connectionId, customerId) {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customerId }),
});
if (!response.ok) {
document.getElementById('pageSummaryText').textContent = await extractErrorMessage(response, 'Kunden kunne ikke tildeles.');
return;
}
await loadInternetPage();
}
async function submitConnectionForm() {
@ -721,7 +903,7 @@
const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary');
const payload = {
name: document.getElementById('connectionNameInput').value.trim(),
provider: document.getElementById('connectionProviderInput').value.trim() || null,
vendor_id: Number(document.getElementById('connectionVendorInput').value || 0) || null,
customer_id: Number(document.getElementById('connectionCustomerIdInput').value || 0) || null,
circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null,
address: document.getElementById('connectionAddressInput').value.trim() || null,
@ -737,6 +919,8 @@
value_type: document.getElementById('connectionValueTypeInput').value || 'other',
value_label: document.getElementById('connectionValueLabelInput').value.trim() || null,
subscription_id: Number(document.getElementById('connectionSubscriptionIdInput').value || 0) || null,
is_manual_shared: document.getElementById('connectionAllocationInput').value === 'shared'
&& document.getElementById('connectionValueTypeInput').value === 'delefiber',
};
if (!payload.name) {
@ -769,7 +953,7 @@
feedback.textContent = 'Forbindelse oprettet.';
[
'connectionNameInput',
'connectionProviderInput',
'connectionVendorInput',
'connectionCustomerIdInput',
'connectionCircuitInput',
'connectionAddressInput',
@ -811,7 +995,23 @@
document.getElementById('tabAll').classList.toggle('active', tab === 'all');
document.getElementById('tabShared').classList.toggle('active', tab === 'shared');
document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet');
loadInternetPage();
document.getElementById('tabDedicated').classList.toggle('active', tab === 'dedicated');
document.getElementById('tabUnallocated').classList.toggle('active', tab === 'unallocated');
document.getElementById('tabInvoices').classList.toggle('active', tab === 'invoices');
const invoiceMode = tab === 'invoices';
document.getElementById('connectionsMetrics').classList.toggle('d-none', invoiceMode);
document.getElementById('connectionsOverview').classList.toggle('d-none', invoiceMode);
document.getElementById('createConnectionBlock').classList.add('collapse');
document.getElementById('createConnectionBlock').classList.remove('show');
document.getElementById('newConnectionBtn').classList.toggle('d-none', invoiceMode);
document.getElementById('invoiceProcessingOverview').classList.toggle('d-none', !invoiceMode);
if (invoiceMode) loadInvoiceSyncRuns();
else loadInternetPage();
}
function refreshActiveTab() {
if (activeTab === 'invoices') return loadInvoiceSyncRuns();
return loadInternetPage();
}
function toggleCreateValueFields() {
@ -845,6 +1045,18 @@
.join('');
}
async function loadInternetVendors() {
const select = document.getElementById('connectionVendorInput');
try {
const response = await fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100');
const vendors = response.ok ? await response.json() : [];
select.innerHTML = '<option value="">Vælg internetleverandør</option>' + vendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
} catch (error) {
select.innerHTML = '<option value="">Kunne ikke hente leverandører</option>';
}
}
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadInternetPage();
@ -853,7 +1065,7 @@
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
});
toggleCreateValueFields();
await loadSubscriptionOptions();
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
});
</script>

View File

@ -0,0 +1,195 @@
"""Creation helpers: transactional associations and advisory case lookups."""
import json
import re
from difflib import SequenceMatcher
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field, StrictInt
from psycopg2.extras import Json
from app.core.auth_dependencies import require_any_permission
from app.core.database import execute_query, execute_query_single
read_access = require_any_permission('cases.view', 'tickets.view', 'cases.create', 'tickets.create', 'users.manage', 'system.admin')
admin_access = require_any_permission('users.manage', 'system.admin')
router = APIRouter(prefix='/case-create', dependencies=[Depends(read_access)])
def ids(value, field):
if not isinstance(value, list) or any(isinstance(v, bool) or not isinstance(v, int) or v <= 0 for v in value):
raise HTTPException(400, f'{field} skal være en liste med positive heltal')
return list(dict.fromkeys(value))
def attach_create_relations(cursor, case_id, data, user_id):
hardware_ids = ids(data.get('hardware_ids', []), 'hardware_ids')
tag_ids = ids(data.get('tag_ids', []), 'tag_ids')
if hardware_ids:
cursor.execute('SELECT id FROM hardware_assets WHERE id = ANY(%s) AND deleted_at IS NULL', (hardware_ids,))
if {r['id'] for r in cursor.fetchall()} != set(hardware_ids):
raise HTTPException(400, 'En valgt hardware findes ikke længere')
for hardware_id in hardware_ids:
cursor.execute('INSERT INTO sag_hardware (sag_id, hardware_id) VALUES (%s, %s) ON CONFLICT DO NOTHING', (case_id, hardware_id))
actions = []
if tag_ids:
cursor.execute('SELECT t.id, t.name, t.tag_group_id, g.behavior FROM tags t LEFT JOIN tag_groups g ON g.id=t.tag_group_id WHERE t.id=ANY(%s) AND t.is_active=TRUE', (tag_ids,))
tags = {r['id']: r for r in cursor.fetchall()}
if set(tags) != set(tag_ids):
raise HTTPException(400, 'Et valgt tag findes ikke længere eller er inaktivt')
# Same single/toggle group semantics as the global picker: last choice wins.
chosen = []
for tag_id in tag_ids:
tag = tags[tag_id]
if tag['behavior'] in ('single', 'toggle'):
chosen = [i for i in chosen if tags[i]['tag_group_id'] != tag['tag_group_id']]
chosen.append(tag_id)
for tag_id in chosen:
cursor.execute("INSERT INTO entity_tags (entity_type, entity_id, tag_id, tagged_by) VALUES ('case', %s, %s, %s) ON CONFLICT DO NOTHING", (case_id, tag_id, user_id))
cursor.execute("SELECT action_type, action_config FROM tag_workflows WHERE tag_id=%s AND trigger_event='on_add' AND is_active=TRUE ORDER BY id DESC LIMIT 1", (tag_id,))
action = cursor.fetchone()
if action:
actions.append({'tag': {'id': tag_id, 'name': tags[tag_id]['name']}, 'action': {'type': action['action_type'], 'config': action['action_config'] or {}}, 'entity_type': 'case', 'entity_id': case_id})
return actions
def closed_statuses():
row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'")
try:
configured = json.loads((row or {}).get('value') or '[]')
values = [str(s['value']).strip().lower() for s in configured if isinstance(s, dict) and s.get('is_closed') and s.get('value')]
except (ValueError, TypeError):
values = []
return values or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done']
def title_similarity(left, right):
def normalize(value):
return ' '.join(re.findall(r'\w+', value.casefold()))
left, right = normalize(left), normalize(right)
if not left or not right:
return 0
a, b = set(left.split()), set(right.split())
return max(SequenceMatcher(None, left, right).ratio(), len(a & b) / len(a | b))
@router.get('/duplicates')
def duplicates(customer_id: int, title: str = Query(min_length=5, max_length=1000)):
rows = execute_query("""SELECT s.id,s.titel,s.status,COALESCE(u.full_name,u.username) AS ansvarlig_navn
FROM sag_sager s LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
WHERE s.customer_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s))""", (customer_id, closed_statuses())) or []
ranked = [(title_similarity(title, r['titel'] or ''), r) for r in rows]
ranked.sort(key=lambda item: (-item[0], -item[1]['id']))
return [dict(row, similarity=round(score, 3)) for score, row in ranked if score >= 0.45][:5]
@router.get('/workload')
def workload(user_id: int):
rows = execute_query("""SELECT s.id,s.titel,s.status,s.deadline,c.name AS customer_name,COUNT(*) OVER() AS total
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
WHERE s.ansvarlig_bruger_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s))
ORDER BY s.deadline ASC NULLS LAST,s.id DESC LIMIT 10""", (user_id, closed_statuses())) or []
return {'total': rows[0]['total'] if rows else 0, 'items': rows}
@router.get('/contacts-open-cases')
def contacts_open_cases(contact_ids: list[int] = Query(min_length=1, max_length=20)):
"""A small, advisory view used while selecting contacts on a new case."""
contact_ids = ids(contact_ids, 'contact_ids')
rows = execute_query("""WITH ranked AS (
SELECT sc.contact_id, s.id, s.titel, s.status, s.deadline,
COALESCE(u.full_name, u.username, 'Ingen ansvarlig') AS ansvarlig_navn,
COUNT(*) OVER (PARTITION BY sc.contact_id) AS total,
ROW_NUMBER() OVER (PARTITION BY sc.contact_id ORDER BY s.deadline ASC NULLS LAST, s.id DESC) AS position
FROM sag_kontakter sc
JOIN sag_sager s ON s.id=sc.sag_id
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
WHERE sc.contact_id=ANY(%s) AND sc.deleted_at IS NULL AND s.deleted_at IS NULL
AND NOT (LOWER(TRIM(s.status))=ANY(%s))
) SELECT contact_id,id,titel,status,deadline,ansvarlig_navn,total
FROM ranked WHERE position<=5 ORDER BY contact_id,position""", (contact_ids, closed_statuses())) or []
result = {contact_id: {'total': 0, 'items': []} for contact_id in contact_ids}
for row in rows:
bucket = result[row['contact_id']]
bucket['total'] = row['total']
bucket['items'].append({key: row[key] for key in ('id', 'titel', 'status', 'deadline', 'ansvarlig_navn')})
return result
class PipelineDefaults(BaseModel):
model_config = ConfigDict(extra='forbid')
stage_id: Optional[int] = Field(default=None, gt=0)
amount: Optional[float] = Field(default=None, ge=0, allow_inf_nan=False)
probability: Optional[int] = Field(default=None, ge=0, le=100)
description: Optional[str] = Field(default=None, max_length=10000)
class TemplateValues(BaseModel):
model_config = ConfigDict(extra='forbid')
type: str = Field(default='ticket', min_length=1, max_length=80)
titel: str = Field(default='', max_length=1000)
beskrivelse: str = Field(default='', max_length=50000)
status: str = Field(default='åben', min_length=1, max_length=80)
assigned_group_id: Optional[int] = Field(default=None, gt=0)
tag_ids: list[StrictInt] = Field(default_factory=list, max_length=100)
pipeline: Optional[PipelineDefaults] = None
class CaseTemplate(BaseModel):
model_config = ConfigDict(extra='forbid')
name: str = Field(min_length=1, max_length=120)
icon: str = Field(default='bi-lightning', pattern=r'^bi-[a-z0-9-]+$', max_length=80)
is_active: bool = True
sort_order: int = 0
values: TemplateValues
@router.get('/templates')
def templates():
return execute_query('SELECT *, template_values AS "values" FROM case_create_templates WHERE is_active=TRUE ORDER BY sort_order,name,id') or []
@router.get('/template-options', dependencies=[Depends(admin_access)])
def template_options():
return execute_query('SELECT id,name FROM groups ORDER BY name') or []
@router.get('/templates/admin', dependencies=[Depends(admin_access)])
def admin_templates():
return execute_query('SELECT *, template_values AS "values" FROM case_create_templates ORDER BY sort_order,name,id') or []
def template_args(data):
values = data.values.model_dump()
values['tag_ids'] = ids(values['tag_ids'], 'tag_ids')
if values['tag_ids']:
found = execute_query('SELECT id FROM tags WHERE id=ANY(%s) AND is_active=TRUE', (values['tag_ids'],)) or []
if {r['id'] for r in found} != set(values['tag_ids']):
raise HTTPException(400, 'Ugyldige tags')
if values['assigned_group_id'] and not execute_query_single('SELECT id FROM groups WHERE id=%s', (values['assigned_group_id'],)):
raise HTTPException(400, 'Ugyldig gruppe')
if values['pipeline'] and values['pipeline']['stage_id'] and not execute_query_single('SELECT id FROM pipeline_stages WHERE id=%s', (values['pipeline']['stage_id'],)):
raise HTTPException(400, 'Ugyldig pipeline-stage')
if not data.name.strip():
raise HTTPException(400, 'Navn er påkrævet')
return (data.name.strip(), data.icon, data.is_active, data.sort_order, Json(values))
@router.post('/templates', dependencies=[Depends(admin_access)])
def create_template(data: CaseTemplate):
return execute_query_single('INSERT INTO case_create_templates (name,icon,is_active,sort_order,template_values) VALUES (%s,%s,%s,%s,%s) RETURNING *, template_values AS "values"', template_args(data))
@router.put('/templates/{template_id}', dependencies=[Depends(admin_access)])
def update_template(template_id: int, data: CaseTemplate):
row = execute_query_single('UPDATE case_create_templates SET name=%s,icon=%s,is_active=%s,sort_order=%s,template_values=%s,updated_at=NOW() WHERE id=%s RETURNING *, template_values AS "values"', template_args(data) + (template_id,))
if not row:
raise HTTPException(404, 'Skabelonen findes ikke')
return row
@router.delete('/templates/{template_id}', dependencies=[Depends(admin_access)])
def delete_template(template_id: int):
row = execute_query_single('DELETE FROM case_create_templates WHERE id=%s RETURNING id', (template_id,))
if not row:
raise HTTPException(404, 'Skabelonen findes ikke')
return row

View File

@ -5,11 +5,13 @@ CRUD operations, user preferences, snooze/dismiss functionality
import logging
from typing import List, Optional
import json
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, status, Depends, Request
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert
from app.core.config import settings
from app.core.auth_dependencies import require_any_permission
from app.services.reminder_notification_service import reminder_notification_service
@ -19,6 +21,94 @@ router = APIRouter()
case_read_access = require_any_permission("cases.view", "tickets.view")
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
def _ensure_task_list_rules():
execute_query("""CREATE TABLE IF NOT EXISTS reminder_task_list_rules (
id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL,
times_json JSONB NOT NULL DEFAULT '[]'::jsonb, include_groups BOOLEAN NOT NULL DEFAULT true,
notify_mattermost BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true, last_sent_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)""", fetch=False)
execute_query("ALTER TABLE reminder_task_list_rules ADD COLUMN IF NOT EXISTS last_sent_at TIMESTAMP", fetch=False)
def _task_list(user_id: int, include_groups: bool):
group_filter = "OR s.assigned_group_id IN (SELECT group_id FROM user_groups WHERE user_id=%s)" if include_groups else ""
params = (user_id, user_id) if include_groups else (user_id,)
return execute_query(f"""SELECT s.id, s.titel, s.status, s.priority, s.deadline, c.name AS customer_name
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) NOT IN ('lukket','løst','closed','resolved','udsat','deferred')
AND (s.ansvarlig_bruger_id=%s {group_filter})
ORDER BY s.deadline NULLS LAST, s.updated_at DESC LIMIT 50""", params) or []
async def _send_task_list(rule, user_id: int):
tasks = _task_list(user_id, bool(rule.get('include_groups')))
if not tasks: return {'sent': False, 'message': 'Ingen åbne sager at sende'}
base_url = str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").rstrip('/')
def cell(value, fallback=''):
value = str(value or fallback).replace('|', '\\|').replace('\n', ' ')
return value[:120]
lines = [
'| Sag | Kunde | Status | Prioritet | Deadline |',
'| :-- | :-- | :-- | :-- | :-- |',
]
for row in tasks:
title = cell(row.get('titel'), 'Uden titel')
link = f"[#{row['id']} · {title}]({base_url}/sag/{row['id']}/v3)"
deadline = row.get('deadline')
if hasattr(deadline, 'strftime'):
deadline = deadline.strftime('%d.%m.%Y')
lines.append(
f"| {link} | {cell(row.get('customer_name'))} | {cell(row.get('status'))} "
f"| {cell(row.get('priority'))} | {cell(deadline)} |"
)
result = await reminder_notification_service.send_reminder(
reminder_id=0, sag_id=int(tasks[0]['id']), case_title='Opgaveliste', customer_name=None,
reminder_title=rule['title'], reminder_message='\n'.join(lines), recipient_user_ids=[user_id],
recipient_emails=[], priority='normal', notify_mattermost=bool(rule.get('notify_mattermost')),
notify_email=False, notify_frontend=True, override_user_preferences=True)
return {'sent': bool(result.get('success')), 'count': len(tasks), 'result': result}
async def process_task_list_rules():
_ensure_task_list_rules()
now = datetime.now()
window_start = now - timedelta(minutes=5)
for rule in execute_query("SELECT * FROM reminder_task_list_rules WHERE is_active=true") or []:
times = rule.get('times_json') or []
if isinstance(times, str): times = json.loads(times)
for scheduled_time in times:
try:
hour, minute = (int(value) for value in scheduled_time.split(':', 1))
scheduled_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
except (AttributeError, TypeError, ValueError):
logger.warning("Ignoring invalid task-list schedule %r for rule %s", scheduled_time, rule['id'])
continue
last_sent = rule.get('last_sent_at')
if window_start < scheduled_at <= now and (not last_sent or last_sent < scheduled_at):
await _send_task_list(rule, int(rule['user_id']))
execute_query('UPDATE reminder_task_list_rules SET last_sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s',(rule['id'],),fetch=False)
break
@router.get('/api/v1/reminder-task-rules')
async def list_task_rules(request: Request):
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
return execute_query('SELECT * FROM reminder_task_list_rules WHERE user_id=%s ORDER BY id DESC',(user_id,)) or []
@router.post('/api/v1/reminder-task-rules')
async def save_task_rule(request: Request, data: dict):
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
title=str(data.get('title') or 'Min opgaveliste').strip(); times=[t for t in (data.get('times') or []) if isinstance(t,str)]
if not times: raise HTTPException(status_code=400, detail='Vælg mindst ét tidspunkt')
rows=execute_query("""INSERT INTO reminder_task_list_rules(user_id,title,times_json,include_groups,notify_mattermost)
VALUES(%s,%s,%s::jsonb,%s,%s) RETURNING *""",(user_id,title,json.dumps(times),bool(data.get('include_groups',True)),bool(data.get('notify_mattermost',True))))
return rows[0]
@router.post('/api/v1/reminder-task-rules/{rule_id}/send-now')
async def send_task_rule_now(rule_id:int, request:Request):
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
rule=execute_query("SELECT * FROM reminder_task_list_rules WHERE id=%s AND user_id=%s",(rule_id,user_id))
if not rule: raise HTTPException(status_code=404, detail='Reglen findes ikke')
return await _send_task_list(rule[0],user_id)
# ============================================================================
# Helper Functions

View File

@ -6,6 +6,7 @@ import re
import hashlib
import base64
import html
import math
from pathlib import Path
from datetime import datetime, timedelta, timezone
from typing import Any, List, Optional, Dict
@ -89,6 +90,23 @@ def _get_user_id_from_request(request: Request) -> int:
raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
def _ensure_case_internet_connection_tag(sag_id: int) -> None:
"""Mark a case as internet-related without creating duplicate tags."""
existing = execute_query_single(
"""SELECT id FROM sag_tags
WHERE sag_id = %s AND deleted_at IS NULL
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
LIMIT 1""",
(sag_id,),
)
if not existing:
execute_query(
"INSERT INTO sag_tags (sag_id, tag_navn) VALUES (%s, 'internet forbindelse')",
(sag_id,),
fetch=False,
)
def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = []
seen = set()
@ -419,7 +437,15 @@ class SagBuzzwordSelectionRequest(BaseModel):
class SagListPreferencesUpdate(BaseModel):
type_filters: List[str] = Field(default_factory=list)
type_filters: Optional[List[str]] = None
column_order: Optional[List[str]] = None
hidden_columns: Optional[List[str]] = None
SAG_LIST_COLUMN_KEYS = (
"id", "company", "contact", "description", "type", "priority", "status",
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
)
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
@ -1011,8 +1037,25 @@ async def create_sag(request: Request, data: dict):
_validate_group_id(assigned_group_id)
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
pipeline = data.get("pipeline") if case_type == "pipeline" else None
order_items = data.get("order_items") if case_type == "ordre" else []
pipeline = data.get("pipeline")
order_items = data.get("order_items", [])
raw_contact_ids = data.get("contact_ids") or []
if not isinstance(raw_contact_ids, list):
raise HTTPException(status_code=400, detail="contact_ids skal være en liste")
contact_ids = []
for raw_contact_id in raw_contact_ids:
contact_id = _coerce_optional_int(raw_contact_id, "contact_id")
if contact_id and contact_id not in contact_ids:
contact_ids.append(contact_id)
telefoni_opkald_id = _coerce_optional_int(data.get("telefoni_opkald_id"), "telefoni_opkald_id")
raw_connection_ids = data.get("internet_connection_ids") or []
if not isinstance(raw_connection_ids, list):
raise HTTPException(status_code=400, detail="internet_connection_ids skal være en liste")
internet_connection_ids = []
for raw_connection_id in raw_connection_ids:
connection_id = _coerce_optional_int(raw_connection_id, "internet_connection_id")
if connection_id and connection_id not in internet_connection_ids:
internet_connection_ids.append(connection_id)
if pipeline is not None and not isinstance(pipeline, dict):
raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
if not isinstance(order_items, list):
@ -1027,6 +1070,8 @@ async def create_sag(request: Request, data: dict):
if pipeline_values["amount"] not in (None, ""):
try:
pipeline_values["amount"] = float(pipeline_values["amount"])
if not math.isfinite(pipeline_values["amount"]) or pipeline_values["amount"] < 0:
raise ValueError("Invalid amount")
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
else:
@ -1081,6 +1126,8 @@ async def create_sag(request: Request, data: dict):
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
if any(value is not None and (not math.isfinite(value) or value < 0) for value in (normalized_items[-1][key] for key in ("quantity", "unit_price", "amount"))):
raise HTTPException(status_code=400, detail="Ordrelinjens tal skal være endelige og mindst nul")
cursor.execute(
"""
@ -1098,6 +1145,64 @@ async def create_sag(request: Request, data: dict):
if not result:
raise HTTPException(status_code=500, detail="Failed to create case")
if contact_ids:
cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,))
existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()}
missing_contact_ids = [contact_id for contact_id in contact_ids if contact_id not in existing_contact_ids]
if missing_contact_ids:
raise HTTPException(status_code=400, detail=f"Ugyldig kontakt: {missing_contact_ids[0]}")
for index, contact_id in enumerate(contact_ids):
cursor.execute(
"""INSERT INTO sag_kontakter (sag_id, contact_id, role, is_primary)
VALUES (%s, %s, 'Kontakt', %s)""",
(result["id"], contact_id, index == 0),
)
cursor.execute(
"""INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, FALSE, 'Kontakt')
ON CONFLICT (contact_id, customer_id) DO NOTHING""",
(contact_id, data.get("customer_id")),
)
if internet_connection_ids:
cursor.execute(
"""SELECT id FROM internet_connections_connections
WHERE id = ANY(%s) AND deleted_at IS NULL""",
(internet_connection_ids,),
)
existing_connection_ids = {int(row["id"]) for row in cursor.fetchall()}
missing_connection_ids = [item for item in internet_connection_ids if item not in existing_connection_ids]
if missing_connection_ids:
raise HTTPException(status_code=400, detail=f"Internetforbindelsen findes ikke: {missing_connection_ids[0]}")
for connection_id in internet_connection_ids:
cursor.execute(
"""INSERT INTO sag_internet_connections (sag_id, connection_id, linked_by_user_id)
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(result["id"], connection_id, current_user_id),
)
cursor.execute(
"""INSERT INTO sag_tags (sag_id, tag_navn)
SELECT %s, 'internet forbindelse'
WHERE NOT EXISTS (
SELECT 1 FROM sag_tags
WHERE sag_id = %s AND deleted_at IS NULL
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
)""",
(result["id"], result["id"]),
)
if telefoni_opkald_id:
cursor.execute(
"""UPDATE telefoni_opkald
SET sag_id = %s,
kontakt_id = COALESCE(%s, kontakt_id)
WHERE id = %s
RETURNING id""",
(result["id"], contact_ids[0] if contact_ids else None, telefoni_opkald_id),
)
if not cursor.fetchone():
raise HTTPException(status_code=400, detail="Det valgte opkald findes ikke")
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
for item in normalized_items:
if has_purchase_columns:
@ -1114,7 +1219,11 @@ async def create_sag(request: Request, data: dict):
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]),
)
from app.modules.sag.backend.create_support import attach_create_relations
tag_actions = attach_create_relations(cursor, result["id"], data, current_user_id)
conn.commit()
result = dict(result)
result["tag_actions"] = tag_actions
logger.info("✅ Case created: %s", result["id"])
return dict(result)
except Exception:
@ -1128,6 +1237,46 @@ async def create_sag(request: Request, data: dict):
logger.error("❌ Error creating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to create case")
@router.get("/sag/{sag_id}/internet-connections")
async def list_sag_internet_connections(sag_id: int):
return execute_query(
"""SELECT link.connection_id,link.created_at,ic.name,ic.circuit_number,ic.provider_reference,
ic.address,ic.customer_id
FROM sag_internet_connections link
JOIN internet_connections_connections ic ON ic.id=link.connection_id AND ic.deleted_at IS NULL
WHERE link.sag_id=%s ORDER BY link.created_at DESC""", (sag_id,),
) or []
@router.post("/sag/{sag_id}/internet-connections", dependencies=[Depends(case_edit_access)])
async def link_sag_internet_connection(sag_id: int, request: Request, data: dict):
connection_id = _coerce_optional_int(data.get("connection_id"), "connection_id")
if not connection_id:
raise HTTPException(status_code=400, detail="connection_id er påkrævet")
if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)):
raise HTTPException(status_code=404, detail="Sagen findes ikke")
if not execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id=%s AND deleted_at IS NULL", (connection_id,),
):
raise HTTPException(status_code=404, detail="Internetforbindelsen findes ikke")
execute_query(
"""INSERT INTO sag_internet_connections (sag_id,connection_id,linked_by_user_id)
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(sag_id, connection_id, _get_user_id_from_request(request)), fetch=False,
)
_ensure_case_internet_connection_tag(sag_id)
return {"linked": True, "sag_id": sag_id, "connection_id": connection_id}
@router.delete("/sag/{sag_id}/internet-connections/{connection_id}", dependencies=[Depends(case_edit_access)])
async def unlink_sag_internet_connection(sag_id: int, connection_id: int):
execute_query(
"DELETE FROM sag_internet_connections WHERE sag_id=%s AND connection_id=%s",
(sag_id, connection_id), fetch=False,
)
return {"unlinked": True}
@router.get("/sag/{sag_id:int}")
async def get_sag(sag_id: int):
"""Get a specific case."""
@ -1259,16 +1408,14 @@ async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10
async def get_my_sag_list_preferences(request: Request):
user_id = _get_user_id_from_request(request)
try:
has_columns = table_has_column("user_sag_list_preferences", "column_order")
select_fields = "type_filters, column_order, hidden_columns" if has_columns else "type_filters"
rows = execute_query(
"""
SELECT type_filters
FROM user_sag_list_preferences
WHERE user_id = %s
""",
f"SELECT {select_fields} FROM user_sag_list_preferences WHERE user_id = %s",
(user_id,),
) or []
if not rows:
return {"type_filters": []}
return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
raw = rows[0].get("type_filters")
parsed = []
@ -1291,20 +1438,43 @@ async def get_my_sag_list_preferences(request: Request):
seen.add(item)
normalized.append(item)
return {"type_filters": normalized}
raw_order = rows[0].get("column_order") if has_columns else None
raw_hidden = rows[0].get("hidden_columns") if has_columns else None
if isinstance(raw_order, str):
try: raw_order = json.loads(raw_order)
except Exception: raw_order = []
if isinstance(raw_hidden, str):
try: raw_hidden = json.loads(raw_hidden)
except Exception: raw_hidden = []
order = [str(key) for key in (raw_order or []) if str(key) in SAG_LIST_COLUMN_KEYS]
order.extend(key for key in SAG_LIST_COLUMN_KEYS if key not in order)
hidden = [str(key) for key in (raw_hidden or []) if str(key) in SAG_LIST_COLUMN_KEYS]
return {"type_filters": normalized, "column_order": order, "hidden_columns": hidden}
except Exception as e:
if "user_sag_list_preferences" in str(e):
return {"type_filters": []}
return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
logger.error("❌ Could not load sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to load list preferences")
@router.get("/sag/me/quick-filter-context")
async def get_my_sag_quick_filter_context(request: Request):
"""Return the authenticated employee and their groups for list quick filters."""
user_id = _get_user_id_from_request(request)
rows = execute_query(
"SELECT group_id FROM user_groups WHERE user_id = %s ORDER BY group_id",
(user_id,),
) or []
return {"user_id": user_id, "group_ids": [row["group_id"] for row in rows]}
@router.patch("/sag/me/list-preferences")
async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate):
user_id = _get_user_id_from_request(request)
try:
normalized = []
seen = set()
for value in payload.type_filters or []:
existing = await get_my_sag_list_preferences(request)
for value in (payload.type_filters if payload.type_filters is not None else existing["type_filters"]):
item = str(value or "").strip().lower()
if not item or item in seen:
continue
@ -1313,18 +1483,31 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
seen.add(item)
normalized.append(item)
requested_order = payload.column_order if payload.column_order is not None else existing["column_order"]
column_order = [str(key) for key in requested_order if str(key) in SAG_LIST_COLUMN_KEYS]
column_order.extend(key for key in SAG_LIST_COLUMN_KEYS if key not in column_order)
requested_hidden = payload.hidden_columns if payload.hidden_columns is not None else existing["hidden_columns"]
hidden_columns = list(dict.fromkeys(str(key) for key in requested_hidden if str(key) in SAG_LIST_COLUMN_KEYS))
if not table_has_column("user_sag_list_preferences", "column_order"):
raise HTTPException(status_code=503, detail="Kolonnepræferencer kræver den nyeste databasemigration")
execute_query(
"""
INSERT INTO user_sag_list_preferences (user_id, type_filters, updated_at)
VALUES (%s, %s::jsonb, NOW())
INSERT INTO user_sag_list_preferences (user_id, type_filters, column_order, hidden_columns, updated_at)
VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, NOW())
ON CONFLICT (user_id)
DO UPDATE SET
type_filters = EXCLUDED.type_filters,
column_order = EXCLUDED.column_order,
hidden_columns = EXCLUDED.hidden_columns,
updated_at = NOW()
""",
(user_id, json.dumps(normalized)),
(user_id, json.dumps(normalized), json.dumps(column_order), json.dumps(hidden_columns)),
)
return {"type_filters": normalized}
return {"type_filters": normalized, "column_order": column_order, "hidden_columns": hidden_columns}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Could not update sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to save list preferences")
@ -1541,6 +1724,7 @@ async def delete_todo_step(step_id: int):
async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"""Update a case."""
try:
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
# Check if case exists
check = execute_query(
"""
@ -1562,6 +1746,23 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
if "status" in updates:
updates["status"] = _normalize_case_status(updates.get("status"))
closing_statuses = {"lukket", "løst", "afsluttet", "closed", "resolved", "done"}
new_status = str(updates.get("status") or "").strip().lower()
case_type = str(previous_row.get("template_key") or "").strip().lower()
is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"}
if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case:
has_registered_time = execute_query_single(
"SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time",
(sag_id,),
) or {}
if not bool(has_registered_time.get("has_time")) and not confirm_close_without_time:
raise HTTPException(
status_code=409,
detail={
"code": "close_without_time_confirmation_required",
"message": "Der er ikke registreret tid på supportsagen. Bekræft at den skal lukkes uden tid.",
},
)
if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
if "start_date" in updates:
@ -2412,6 +2613,28 @@ async def list_case_contacts(sag_id: int):
logger.error("❌ Error listing case contacts: %s", e)
raise HTTPException(status_code=500, detail="Failed to list case contacts")
@router.get("/sag/{sag_id}/reply-recipients")
async def list_case_reply_recipients(sag_id: int, q: str = ""):
"""Email-capable case contacts first, followed by contacts from the case customer."""
rows = execute_query(
"""
WITH target AS (SELECT customer_id FROM sag_sager WHERE id=%s), candidates AS (
SELECT c.id, c.first_name, c.last_name, c.email, c.title, true AS linked
FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id
WHERE sk.sag_id=%s AND sk.deleted_at IS NULL
UNION
SELECT c.id, c.first_name, c.last_name, c.email, c.title, false AS linked
FROM contact_companies cc JOIN contacts c ON c.id=cc.contact_id JOIN target t ON t.customer_id=cc.customer_id
) SELECT DISTINCT ON (id) * FROM candidates
WHERE NULLIF(TRIM(email),'') IS NOT NULL
AND (%s='' OR CONCAT_WS(' ',first_name,last_name,email,title) ILIKE '%%' || %s || '%%')
ORDER BY id, linked DESC
""",
(sag_id, sag_id, q.strip(), q.strip()),
) or []
return rows
@router.post("/sag/{sag_id}/contacts")
async def add_case_contact(sag_id: int, data: dict):
"""Add a contact to a case."""
@ -3079,6 +3302,44 @@ async def list_sale_items(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to list sale items")
@router.get("/procurement/overview")
async def procurement_overview():
"""Operational queue for purchase lines awaiting order, receipt or delivery."""
rows = execute_query(
"""
SELECT p.id, p.sag_id, p.description, p.quantity, p.unit, p.unit_price, p.amount,
p.status, p.line_date, p.external_ref, p.purchase_purpose,
p.supplier_invoice_id, p.supplier_invoice_line_id,
s.titel AS case_title, c.name AS customer_name,
EXISTS (
SELECT 1 FROM sag_salgsvarer sale
WHERE sale.sag_id = p.sag_id AND sale.type = 'sale'
AND sale.status <> 'cancelled'
) AS has_sales_line,
CASE
WHEN p.supplier_invoice_line_id IS NOT NULL THEN 'received'
WHEN p.status = 'confirmed' THEN 'ordered'
ELSE 'to_order'
END AS fulfilment_state
FROM sag_salgsvarer p
JOIN sag_sager s ON s.id = p.sag_id AND s.deleted_at IS NULL
LEFT JOIN customers c ON c.id = s.customer_id
WHERE p.type = 'purchase' AND p.status <> 'cancelled'
ORDER BY CASE
WHEN p.supplier_invoice_line_id IS NOT NULL THEN 2
WHEN p.status = 'confirmed' THEN 1 ELSE 0 END,
p.line_date NULLS LAST, p.id DESC
"""
) or []
counts = {"to_order": 0, "ordered": 0, "received": 0, "missing_sales_order": 0}
for row in rows:
state = row.get("fulfilment_state") or "to_order"
counts[state] = counts.get(state, 0) + 1
if not row.get("has_sales_line"):
counts["missing_sales_order"] += 1
return {"items": rows, "counts": counts}
@router.post("/sag/{sag_id}/sale-items")
async def create_sale_item(sag_id: int, data: dict):
"""Create a sale item for a case."""

View File

@ -1,118 +1,500 @@
import json
import logging
from fastapi import APIRouter, HTTPException, Request, Depends
import re
from typing import Optional
from app.core.database import execute_query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.core.auth_dependencies import get_current_user, require_any_permission
from app.core.database import execute_query, execute_query_single
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
from app.core.auth_dependencies import require_any_permission
from app.services.case_analysis_service import CaseAnalysisService
logger = logging.getLogger(__name__)
router = APIRouter()
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
VISIBILITIES = {"internal", "general", "customer"}
APPROVAL_STATUSES = {"draft", "pending", "approved", "outdated", "rejected"}
RESULT_ALIASES = {"resolved": "Løst", "partial": "Delvist", "unresolved": "Ej løst", "løst": "Løst", "delvist": "Delvist", "workaround": "Workaround", "ej løst": "Ej løst"}
TYPE_ALIASES = {"standard": "Support", "permanent": "Support", "external": "Ekstern", "support": "Support", "drift": "Drift", "konsulent": "Konsulent", "infrastruktur": "Infrastruktur", "workaround": "Support"}
SECRET_PATTERNS = (
(re.compile(r"(?i)\b(password|passwd|kodeord|api[_ -]?key|secret|token)\s*[:=]\s*([^\s,;]+)"), r"\1: [FJERNET]"),
(re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+\-/]+=*"), "Bearer [FJERNET]"),
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), "[PRIVAT NØGLE FJERNET]"),
(re.compile(r"(?i)(?:postgres|mysql|mongodb(?:\+srv)?)://[^\s]+"), "[DATABASEFORBINDELSE FJERNET]"),
)
def _user_id(current_user: dict) -> Optional[int]:
value = current_user.get("id") or current_user.get("user_id")
return int(value) if value is not None else None
def _clean_list(values) -> list[str]:
cleaned, seen = [], set()
for value in values or []:
item = str(value or "").strip()
key = item.casefold()
if item and key not in seen:
seen.add(key)
cleaned.append(item[:100])
return cleaned[:30]
def _normalize_payload(data: dict) -> dict:
if "title" in data:
data["title"] = str(data.get("title") or "").strip()
if not data["title"]:
raise HTTPException(status_code=422, detail="Løsningen skal have en titel")
if "visibility" in data:
data["visibility"] = str(data.get("visibility") or "internal").lower()
if data["visibility"] not in VISIBILITIES:
raise HTTPException(status_code=422, detail="Ugyldig synlighed")
if "approval_status" in data:
data["approval_status"] = str(data.get("approval_status") or "draft").lower()
if data["approval_status"] not in APPROVAL_STATUSES:
raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus")
if data.get("result") is not None:
raw = str(data["result"]).strip()
data["result"] = RESULT_ALIASES.get(raw.casefold(), raw)
if data.get("solution_type") is not None:
raw = str(data["solution_type"]).strip()
data["solution_type"] = TYPE_ALIASES.get(raw.casefold(), raw)
for field in ("tags", "products"):
if field in data:
data[field] = _clean_list(data[field])
return data
def _redact_sensitive(text: str) -> tuple[str, list[str]]:
cleaned = str(text or "")
warnings = []
for pattern, replacement in SECRET_PATTERNS:
cleaned, count = pattern.subn(replacement, cleaned)
if count:
warnings.append(f"{count} mulig(e) hemmelighed(er) blev fjernet før AI-behandling")
return cleaned, warnings
def _knowledge_tokens(text: str) -> list[str]:
ignored = {"eller", "ikke", "med", "den", "det", "der", "som", "for", "fra", "til", "har", "kan", "skal", "sag", "sagen", "test", "viden"}
tokens, seen = [], set()
for token in re.findall(r"[A-Za-zÀ-ÿ0-9_.-]{3,}", str(text or "").lower()):
if token in ignored or token in seen:
continue
seen.add(token)
tokens.append(token)
return tokens[:24]
def _normalize_source_refs(raw_refs, allowed_refs: set[str], sag_id: int) -> list[str]:
if isinstance(raw_refs, str):
raw_refs = re.split(r"[,;\n]+", raw_refs)
normalized = [f"Sag {sag_id}"]
for raw in raw_refs or []:
value = str(raw or "").strip().strip("[]")
match = re.search(r"(?i)\b(sag|kommentar|mail|tid|artikel)\s*#?\s*(\d+)\b", value)
if not match:
continue
kind = match.group(1).capitalize()
canonical = f"{kind} {int(match.group(2))}"
if canonical in allowed_refs and canonical not in normalized:
normalized.append(canonical)
return normalized
def _version_solution(solution: dict, user_id: Optional[int], change_note: Optional[str] = None) -> None:
version_row = execute_query_single("SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM sag_solution_versions WHERE solution_id = %s", (solution["id"],)) or {"next_version": 1}
snapshot = dict(solution)
for key, value in list(snapshot.items()):
if hasattr(value, "isoformat"):
snapshot[key] = value.isoformat()
execute_query(
"INSERT INTO sag_solution_versions (solution_id, version_number, snapshot, changed_by_user_id, change_note) VALUES (%s,%s,%s::jsonb,%s,%s)",
(solution["id"], version_row["next_version"], json.dumps(snapshot), user_id, change_note),
)
def _case_knowledge_context(sag_id: int, limit: int = 6) -> tuple[dict, list[dict]]:
case = execute_query_single(
"""SELECT s.id,s.titel,s.beskrivelse,s.status,
(SELECT sk.customer_id FROM sag_kunder sk WHERE sk.sag_id=s.id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1) AS customer_id,
COALESCE((SELECT string_agg(t.name,' ') FROM entity_tags et JOIN tags t ON t.id=et.tag_id WHERE et.entity_type='case' AND et.entity_id=s.id),'') AS tag_text
FROM sag_sager s WHERE s.id=%s AND s.deleted_at IS NULL""",
(sag_id,),
)
if not case:
raise HTTPException(status_code=404, detail="Sagen findes ikke")
tokens = _knowledge_tokens(" ".join([str(case.get("titel") or ""), str(case.get("beskrivelse") or ""), str(case.get("tag_text") or "")]))
if not tokens:
return case, []
search_query = " OR ".join(tokens)
articles = execute_query(
"""SELECT ka.id,ka.title,ka.summary,ka.problem,ka.root_cause,ka.solution,ka.workaround,
ka.visibility,ka.customer_id,ka.tags,ka.products,ka.sag_id,ka.updated_at,
ts_rank_cd(ka.search_document,websearch_to_tsquery('simple',%s)) AS relevance
FROM knowledge_articles ka
WHERE ka.status='published' AND ka.archived_at IS NULL
AND (ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))
AND ka.search_document @@ websearch_to_tsquery('simple',%s)
ORDER BY relevance DESC,ka.updated_at DESC LIMIT %s""",
(search_query, case.get("customer_id"), search_query, limit),
) or []
if articles:
top_relevance = float(articles[0].get("relevance") or 0)
relative_floor = max(0.05, top_relevance * 0.35)
articles = [row for row in articles if float(row.get("relevance") or 0) >= relative_floor]
return case, articles
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
async def get_solution(sag_id: int):
"""Get the solution associated with a case."""
try:
query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
result = execute_query(query, (sag_id,))
if not result:
return None
return result[0]
except Exception as e:
logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to get solution")
async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)):
result = execute_query("SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,))
return result[0] if result else None
@router.post(
"/sag/{sag_id}/solution",
response_model=Solution,
dependencies=[Depends(case_edit_access)],
)
async def create_solution(sag_id: int, solution: SolutionCreate, request: Request):
"""Create a solution for a case."""
try:
# Check if case exists
case_check = execute_query("SELECT id FROM sag_sager WHERE id = %s", (sag_id,))
if not case_check:
raise HTTPException(status_code=404, detail="Case not found")
# Check if solution already exists
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
if check:
raise HTTPException(status_code=400, detail="Solution already exists for this case")
query = """
INSERT INTO sag_solutions
(sag_id, title, description, solution_type, result, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
sag_id,
solution.title,
solution.description,
solution.solution_type,
solution.result,
getattr(request.state, "user_id", None)
)
result = execute_query(query, params)
if result:
logger.info("✅ Solution created for case: %s", sag_id)
return result[0]
raise HTTPException(status_code=500, detail="Failed to create solution")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating solution: %s", e)
raise HTTPException(status_code=500, detail="Failed to create solution")
@router.patch(
"/sag/{sag_id}/solution",
response_model=Solution,
dependencies=[Depends(case_edit_access)],
)
async def update_solution(sag_id: int, updates: SolutionUpdate):
"""Update a solution."""
try:
# Check if solution exists
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Solution not found")
# Build dynamic update query
set_clauses = []
params = []
# Helper to check and add params
if updates.title is not None:
set_clauses.append("title = %s")
params.append(updates.title)
if updates.description is not None:
set_clauses.append("description = %s")
params.append(updates.description)
if updates.solution_type is not None:
set_clauses.append("solution_type = %s")
params.append(updates.solution_type)
if updates.result is not None:
set_clauses.append("result = %s")
params.append(updates.result)
if not set_clauses:
raise HTTPException(status_code=400, detail="No fields to update")
set_clauses.append("updated_at = NOW()")
params.append(sag_id)
query = f"UPDATE sag_solutions SET {', '.join(set_clauses)} WHERE sag_id = %s RETURNING *"
result = execute_query(query, tuple(params))
if result:
logger.info("✅ Solution updated for case: %s", sag_id)
return result[0]
raise HTTPException(status_code=500, detail="Failed to update solution")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating solution: %s", e)
raise HTTPException(status_code=500, detail="Failed to update solution")
@router.get("/sag/{sag_id}/solution/versions")
async def get_solution_versions(sag_id: int, _current_user: dict = Depends(get_current_user)):
solution = execute_query_single("SELECT id FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,))
if not solution:
return {"items": [], "total": 0}
items = execute_query(
"""SELECT v.id, v.version_number, v.change_note, v.created_at,
COALESCE(u.full_name, u.username) AS changed_by
FROM sag_solution_versions v LEFT JOIN users u ON u.user_id=v.changed_by_user_id
WHERE v.solution_id=%s ORDER BY v.version_number DESC""",
(solution["id"],),
) or []
return {"items": items, "total": len(items)}
@router.post("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
async def create_solution(sag_id: int, solution: SolutionCreate, current_user: dict = Depends(get_current_user)):
if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)):
raise HTTPException(status_code=404, detail="Sagen findes ikke")
existing = execute_query_single("SELECT id, deleted_at FROM sag_solutions WHERE sag_id=%s", (sag_id,))
if existing:
detail = "Sagen har en arkiveret løsning, som skal gendannes" if existing.get("deleted_at") else "Der findes allerede en løsning på sagen"
raise HTTPException(status_code=409, detail=detail)
data = _normalize_payload(solution.model_dump(exclude={"sag_id", "created_by_user_id"}))
user_id = _user_id(current_user)
result = execute_query(
"""INSERT INTO sag_solutions (
sag_id,title,description,solution_type,result,problem,root_cause,investigation,workaround,
visibility,approval_status,is_final,tags,products,created_by_user_id,updated_by_user_id
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s) RETURNING *""",
(sag_id, data["title"], data.get("description"), data.get("solution_type"), data.get("result"), data.get("problem"), data.get("root_cause"), data.get("investigation"), data.get("workaround"), data.get("visibility", "internal"), data.get("approval_status", "draft"), data.get("is_final", True), json.dumps(data.get("tags", [])), json.dumps(data.get("products", [])), user_id, user_id),
)
created = result[0]
_version_solution(created, user_id, "Løsning oprettet")
return created
@router.patch("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
async def update_solution(sag_id: int, updates: SolutionUpdate, current_user: dict = Depends(get_current_user)):
if not execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,)):
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
data = _normalize_payload(updates.model_dump(exclude_unset=True))
change_note = data.pop("change_note", None)
allowed = {"title", "description", "solution_type", "result", "problem", "root_cause", "investigation", "workaround", "visibility", "approval_status", "is_final", "tags", "products"}
fields, params = [], []
for key, value in data.items():
if key not in allowed:
continue
fields.append(f"{key} = %s" + ("::jsonb" if key in {"tags", "products"} else ""))
params.append(json.dumps(value) if key in {"tags", "products"} else value)
if not fields:
raise HTTPException(status_code=400, detail="Ingen ændringer at gemme")
user_id = _user_id(current_user)
fields.extend(["updated_by_user_id = %s", "updated_at = NOW()"])
params.extend([user_id, sag_id])
updated = execute_query(f"UPDATE sag_solutions SET {', '.join(fields)} WHERE sag_id=%s RETURNING *", tuple(params))[0]
_version_solution(updated, user_id, change_note or "Løsning redigeret")
return updated
@router.post("/sag/{sag_id}/solution/workflow", dependencies=[Depends(case_edit_access)])
async def solution_workflow(sag_id: int, request: Request, current_user: dict = Depends(get_current_user)):
action = str((await request.json()).get("action") or "").lower()
solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,))
if not solution:
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
user_id = _user_id(current_user)
if action == "submit":
status = "pending"
elif action == "approve":
if not str(solution.get("description") or "").strip():
raise HTTPException(status_code=422, detail="Beskriv den endelige løsning før godkendelse")
status = "approved"
elif action in {"reject", "outdate"}:
status = "rejected" if action == "reject" else "outdated"
else:
raise HTTPException(status_code=422, detail="Ukendt handling")
if status == "approved":
updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=%s,approved_at=NOW(),updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, user_id, sag_id))
else:
updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=NULL,approved_at=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, sag_id))
_version_solution(updated, user_id, f"Status ændret til {status}")
return updated
@router.post("/sag/{sag_id}/solution/publish", dependencies=[Depends(case_edit_access)])
async def publish_solution(sag_id: int, current_user: dict = Depends(get_current_user)):
solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,))
if not solution:
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
if solution.get("approval_status") != "approved":
raise HTTPException(status_code=409, detail="Løsningen skal godkendes før udgivelse")
publish_text = "\n".join(str(solution.get(field) or "") for field in ("title", "problem", "root_cause", "investigation", "description", "workaround"))
_clean_publish_text, secret_warnings = _redact_sensitive(publish_text)
if secret_warnings:
raise HTTPException(status_code=422, detail="Løsningen indeholder muligvis password, token eller anden hemmelighed. Fjern det før udgivelse.")
customer_id = None
if solution.get("visibility") == "customer":
customer = execute_query_single("SELECT customer_id FROM sag_kunder WHERE sag_id=%s AND deleted_at IS NULL ORDER BY id LIMIT 1", (sag_id,))
if not customer:
raise HTTPException(status_code=409, detail="Kundespecifik viden kræver en kunde på sagen")
customer_id = customer["customer_id"]
description = str(solution.get("description") or "").strip()
summary = description[:300] + ("" if len(description) > 300 else "")
article = execute_query_single(
"""INSERT INTO knowledge_articles (
solution_id,sag_id,customer_id,title,summary,problem,root_cause,investigation,solution,workaround,
visibility,status,tags,products,published_by_user_id,reviewed_at
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'published',%s::jsonb,%s::jsonb,%s,NOW())
ON CONFLICT (solution_id) DO UPDATE SET customer_id=EXCLUDED.customer_id,title=EXCLUDED.title,
summary=EXCLUDED.summary,problem=EXCLUDED.problem,root_cause=EXCLUDED.root_cause,
investigation=EXCLUDED.investigation,solution=EXCLUDED.solution,workaround=EXCLUDED.workaround,
visibility=EXCLUDED.visibility,tags=EXCLUDED.tags,products=EXCLUDED.products,status='published',
version_number=knowledge_articles.version_number+1,published_by_user_id=EXCLUDED.published_by_user_id,
reviewed_at=NOW(),updated_at=NOW(),archived_at=NULL,archived_by_user_id=NULL RETURNING *""",
(solution["id"], sag_id, customer_id, solution["title"], summary, solution.get("problem"), solution.get("root_cause"), solution.get("investigation"), description, solution.get("workaround"), solution.get("visibility", "internal"), json.dumps(solution.get("tags") or []), json.dumps(solution.get("products") or []), _user_id(current_user)),
)
return article
@router.get("/sag/{sag_id}/knowledge-suggestions")
async def case_knowledge_suggestions(
sag_id: int,
limit: int = Query(6, ge=1, le=20),
_current_user: dict = Depends(get_current_user),
):
_case, articles = _case_knowledge_context(sag_id, limit)
items = []
for article in articles:
item = dict(article)
item["reason"] = "Matcher sagens titel, beskrivelse eller tags"
item["relevance_percent"] = min(99, max(1, round(float(item.get("relevance") or 0) * 100)))
items.append(item)
return {"items": items, "total": len(items), "source": "approved_knowledge_only"}
@router.post("/sag/{sag_id}/solution/ai-draft", dependencies=[Depends(case_edit_access)])
async def generate_solution_ai_draft(sag_id: int, current_user: dict = Depends(get_current_user)):
case, articles = _case_knowledge_context(sag_id, 5)
comments = execute_query(
"""SELECT id,forfatter,indhold,created_at FROM sag_kommentarer
WHERE sag_id=%s AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 60""",
(sag_id,),
) or []
emails = execute_query(
"""SELECT DISTINCT e.id,e.subject,e.sender_email,e.recipient_email,e.body_text,e.received_date
FROM email_messages e LEFT JOIN sag_emails se ON se.email_id=e.id
WHERE e.deleted_at IS NULL AND (se.sag_id=%s OR e.linked_case_id=%s)
ORDER BY e.received_date DESC NULLS LAST LIMIT 25""",
(sag_id, sag_id),
) or []
time_entries = execute_query(
"""SELECT id,description,original_hours,worked_date FROM tmodule_times
WHERE sag_id=%s ORDER BY worked_date DESC NULLS LAST LIMIT 30""",
(sag_id,),
) or []
sections = [f"SAG #{sag_id}\nTitel: {case.get('titel') or ''}\nBeskrivelse: {case.get('beskrivelse') or ''}"]
if comments:
sections.append("KOMMENTARER:\n" + "\n".join(f"[Kommentar {row['id']}] {row.get('forfatter') or 'Ukendt'}: {row.get('indhold') or ''}" for row in reversed(comments)))
if emails:
sections.append("MAILS:\n" + "\n".join(f"[Mail {row['id']}] {row.get('subject') or '(uden emne)'}: {row.get('body_text') or ''}" for row in reversed(emails)))
if time_entries:
sections.append("TIDSNOTER:\n" + "\n".join(f"[Tid {row['id']}] {row.get('description') or ''}" for row in time_entries))
if articles:
sections.append("GODKENDT VIDEN:\n" + "\n".join(f"[Artikel {row['id']}] {row['title']}\nProblem: {row.get('problem') or ''}\nLøsning: {row.get('solution') or ''}" for row in articles))
context, warnings = _redact_sensitive("\n\n".join(sections))
context = context[:24000]
prompt = """Du laver et UDKAST til dokumentation af en IT-supportløsning.
Returner KUN gyldig JSON med felterne title, problem, root_cause, investigation, description, workaround, solution_type, result, tags, products, confidence, uncertainties og source_refs.
Regler:
- Brug kun fakta fra den vedlagte sag og de nummererede kilder.
- Opfind aldrig en årsag eller handling. Sæt feltet tomt og beskriv manglen i uncertainties.
- description er den endelige løsning. Hvis sagen endnu ikke dokumenterer en løsning, skal description være tom.
- source_refs skal indeholde de præcise kilder, fx "Kommentar 12" eller "Artikel 4".
- Medtag aldrig passwords, tokens, API-nøgler eller andre hemmeligheder.
- Skriv kort, teknisk og professionelt dansk.
- confidence er et tal mellem 0 og 1.
"""
service = CaseAnalysisService()
# A full case history is materially larger than QuickCreate input. Keep the
# global QuickCreate timeout unchanged, but allow the local model time to
# produce a source-grounded solution draft.
service.ai_timeout = max(service.ai_timeout, 60)
result = await service._call_ollama(prompt, context)
if not result:
warnings.append("Den lokale AI-tjeneste er utilgængelig; der vises en sikker grundkladde uden AI-genererede konklusioner")
result = {
"title": case.get("titel") or "",
"problem": case.get("beskrivelse") or "",
"root_cause": "",
"investigation": "",
"description": "",
"workaround": "",
"solution_type": "Support",
"result": "Ej løst",
"tags": _knowledge_tokens(case.get("tag_text") or "")[:10],
"products": [],
"confidence": 0.2,
"uncertainties": ["Årsag og endelig løsning skal udfyldes af en medarbejder"],
"source_refs": [f"Sag {sag_id}"],
}
allowed_refs = {f"Kommentar {row['id']}" for row in comments} | {f"Mail {row['id']}" for row in emails} | {f"Tid {row['id']}" for row in time_entries} | {f"Artikel {row['id']}" for row in articles} | {f"Sag {sag_id}"}
source_refs = _normalize_source_refs(result.get("source_refs", []), allowed_refs, sag_id)
draft = {
"title": str(result.get("title") or case.get("titel") or "")[:255],
"problem": str(result.get("problem") or ""),
"root_cause": str(result.get("root_cause") or ""),
"investigation": str(result.get("investigation") or ""),
"description": str(result.get("description") or ""),
"workaround": str(result.get("workaround") or ""),
"solution_type": TYPE_ALIASES.get(str(result.get("solution_type") or "Support").casefold(), "Support"),
"result": RESULT_ALIASES.get(str(result.get("result") or "Ej løst").casefold(), "Ej løst"),
"tags": _clean_list(result.get("tags")),
"products": _clean_list(result.get("products")),
"confidence": max(0.0, min(1.0, float(result.get("confidence") or 0))),
"uncertainties": _clean_list(result.get("uncertainties")),
"source_refs": source_refs,
}
if not draft["description"]:
warnings.append("Sagen indeholder ikke en sikkert dokumenteret endelig løsning")
if not source_refs:
warnings.append("AI-udkastet indeholdt ingen gyldige kildehenvisninger")
return {"draft": draft, "warnings": warnings, "model": service.ollama_model, "review_required": True}
@router.get("/solutions")
async def list_solutions(
q: str = Query("", max_length=200),
approval_status: Optional[str] = None,
visibility: Optional[str] = None,
include_archived: bool = False,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
_current_user: dict = Depends(get_current_user),
):
where = ["s.deleted_at IS NOT NULL" if include_archived else "s.deleted_at IS NULL"]
params: list = []
if approval_status:
if approval_status not in APPROVAL_STATUSES:
raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus")
where.append("s.approval_status=%s")
params.append(approval_status)
if visibility:
if visibility not in VISIBILITIES:
raise HTTPException(status_code=422, detail="Ugyldig synlighed")
where.append("s.visibility=%s")
params.append(visibility)
term = q.strip()
if term:
where.append("(s.title ILIKE %s OR s.description ILIKE %s OR s.problem ILIKE %s OR sg.titel ILIKE %s OR c.name ILIKE %s OR s.tags::text ILIKE %s OR s.products::text ILIKE %s)")
like = f"%{term}%"
params.extend([like] * 7)
where_sql = " AND ".join(where)
count = execute_query_single(
f"""SELECT COUNT(*) AS total FROM sag_solutions s
JOIN sag_sager sg ON sg.id=s.sag_id
LEFT JOIN LATERAL (
SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id
WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1
) c ON TRUE WHERE {where_sql}""",
tuple(params),
) or {"total": 0}
items = execute_query(
f"""SELECT s.*, sg.titel AS case_title, sg.status AS case_status, c.name AS customer_name,
COALESCE(creator.full_name,creator.username) AS created_by,
COALESCE(updater.full_name,updater.username) AS updated_by,
ka.id AS article_id, ka.status AS article_status
FROM sag_solutions s JOIN sag_sager sg ON sg.id=s.sag_id
LEFT JOIN LATERAL (
SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id
WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1
) c ON TRUE
LEFT JOIN users creator ON creator.user_id=s.created_by_user_id
LEFT JOIN users updater ON updater.user_id=s.updated_by_user_id
LEFT JOIN knowledge_articles ka ON ka.solution_id=s.id
WHERE {where_sql} ORDER BY s.updated_at DESC,s.id DESC LIMIT %s OFFSET %s""",
tuple(params + [limit, offset]),
) or []
return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset, "archived": include_archived}
@router.delete("/solutions/{solution_id}", dependencies=[Depends(case_edit_access)])
async def archive_solution(solution_id: int, current_user: dict = Depends(get_current_user)):
solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NULL", (solution_id,))
if not solution:
raise HTTPException(status_code=404, detail="Løsningen findes ikke eller er allerede arkiveret")
user_id = _user_id(current_user)
execute_query("UPDATE knowledge_articles SET status='archived',archived_at=NOW(),archived_by_user_id=%s,updated_at=NOW() WHERE solution_id=%s", (user_id, solution_id))
archived = execute_query_single("UPDATE sag_solutions SET deleted_at=NOW(),deleted_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id))
_version_solution(archived, user_id, "Løsning arkiveret")
return {"status": "archived", "id": solution_id, "sag_id": solution["sag_id"]}
@router.post("/solutions/{solution_id}/restore", dependencies=[Depends(case_edit_access)])
async def restore_solution(solution_id: int, current_user: dict = Depends(get_current_user)):
solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NOT NULL", (solution_id,))
if not solution:
raise HTTPException(status_code=404, detail="Den arkiverede løsning findes ikke")
user_id = _user_id(current_user)
restored = execute_query_single("UPDATE sag_solutions SET deleted_at=NULL,deleted_by_user_id=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id))
_version_solution(restored, user_id, "Løsning gendannet")
return {"status": "restored", "id": solution_id, "sag_id": solution["sag_id"]}
@router.get("/knowledge/articles")
async def search_knowledge_articles(q: str = Query("", max_length=200), customer_id: Optional[int] = None, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0), _current_user: dict = Depends(get_current_user)):
term = q.strip()
scope = "(ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))" if customer_id else "ka.visibility IN ('general','internal')"
params: list = [customer_id] if customer_id else []
search = ""
if term:
search = " AND (ka.search_document @@ websearch_to_tsquery('simple', %s) OR ka.title ILIKE %s)"
params.extend([term, f"%{term}%"])
count = execute_query_single(f"SELECT COUNT(*) AS total FROM knowledge_articles ka WHERE ka.status='published' AND {scope}{search}", tuple(params)) or {"total": 0}
item_params = []
rank_expr = "ts_rank_cd(ka.search_document, websearch_to_tsquery('simple', %s))" if term else "0"
if term:
item_params.append(term)
item_params.extend(params)
item_params.extend([limit, offset])
items = execute_query(
f"""SELECT ka.id,ka.title,ka.summary,ka.visibility,ka.customer_id,ka.tags,ka.products,
ka.version_number,ka.updated_at,ka.sag_id,{rank_expr} AS relevance,c.name AS customer_name
FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
WHERE ka.status='published' AND {scope}{search}
ORDER BY relevance DESC,ka.updated_at DESC,ka.id DESC LIMIT %s OFFSET %s""",
tuple(item_params),
) or []
return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset}
@router.get("/knowledge/articles/{article_id}")
async def get_knowledge_article(article_id: int, _current_user: dict = Depends(get_current_user)):
article = execute_query_single(
"""SELECT ka.*,c.name AS customer_name,COALESCE(u.full_name,u.username) AS published_by
FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
LEFT JOIN users u ON u.user_id=ka.published_by_user_id
WHERE ka.id=%s AND ka.status='published'""",
(article_id,),
)
if not article:
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
return article

View File

@ -13,6 +13,48 @@ logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/knowledge", response_class=HTMLResponse)
async def knowledge_index(request: Request):
"""Read-only first release of the case knowledge base."""
return templates.TemplateResponse("modules/sag/templates/knowledge_index.html", {"request": request})
@router.get("/solutions", response_class=HTMLResponse)
async def solutions_management(request: Request):
"""Central management surface for case solutions and their publication flow."""
return templates.TemplateResponse("modules/sag/templates/solutions_management.html", {"request": request})
@router.get("/procurement", response_class=HTMLResponse)
async def procurement_overview_page(request: Request):
return templates.TemplateResponse("modules/sag/templates/procurement_overview.html", {"request": request})
@router.get("/reminder-rules", response_class=HTMLResponse)
async def reminder_rules_page(request: Request):
return templates.TemplateResponse("modules/sag/templates/reminder_rules.html", {"request": request})
@router.get("/knowledge/{article_id:int}", response_class=HTMLResponse)
async def knowledge_detail(request: Request, article_id: int):
article = execute_query(
"""
SELECT ka.*, c.name AS customer_name,
COALESCE(u.full_name, u.username) AS published_by
FROM knowledge_articles ka
LEFT JOIN customers c ON c.id = ka.customer_id
LEFT JOIN users u ON u.user_id = ka.published_by_user_id
WHERE ka.id = %s AND ka.status = 'published'
""",
(article_id,),
)
if not article:
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
return templates.TemplateResponse(
"modules/sag/templates/knowledge_detail.html",
{"request": request, "article": article[0]},
)
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)
@ -203,6 +245,7 @@ async def sager_liste(
assigned_group_id: str = Query(None),
unassigned: bool = Query(False),
include_deferred: bool = Query(False),
quick: str = Query(None),
):
"""Display list of all cases."""
try:
@ -215,6 +258,7 @@ async def sager_liste(
query = """
SELECT s.*,
c.name as customer_name,
sk_first.contact_id AS kontakt_id,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
@ -278,7 +322,8 @@ async def sager_liste(
query += ")"
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
normalized_status = str(status or "").strip().lower()
normalized_quick = str(quick or "").strip().lower()
normalized_status = "all" if normalized_quick == "closed" else str(status or "").strip().lower()
normalized_priority = str(priority or "").strip().lower()
if normalized_status == "all":
pass
@ -316,6 +361,7 @@ async def sager_liste(
fallback_query = """
SELECT s.*,
c.name as customer_name,
NULL::integer AS kontakt_id,
'' as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
@ -439,6 +485,7 @@ async def sager_liste(
"relations_map": relations_map,
"child_ids": list(child_ids),
"statuses": status_options,
"status_options": status_options,
"all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status,
"current_priority": normalized_priority,
@ -451,16 +498,19 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
"current_quick_filter": normalized_quick or "all",
"closed_statuses": closed_statuses,
})
except Exception:
logger.exception("❌ Error displaying case list")
fallback_status_options = _fetch_case_status_options()
return templates.TemplateResponse("modules/sag/templates/index.html", {
"request": request,
"sager": [],
"relations_map": {},
"child_ids": [],
"statuses": _fetch_case_status_options(),
"statuses": fallback_status_options,
"status_options": fallback_status_options,
"all_tags": [],
"current_status": status,
"current_priority": str(priority or "").strip().lower(),
@ -473,6 +523,7 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
"current_quick_filter": str(quick or "").strip().lower() or "all",
"closed_statuses": _fetch_closed_case_statuses(),
})
@ -821,7 +872,7 @@ async def sag_detaljer(request: Request, sag_id: int):
comments = execute_query(comments_query, (sag_id,))
# Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL"
solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None
@ -1182,7 +1233,7 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
comments = execute_query(comments_query, (sag_id,))
# Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL"
solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None

View File

@ -4,6 +4,21 @@
{% block extra_css %}
<style>
.cc-panel { border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; margin: 0 0 12px; background: var(--bg-surface, #fff); }
.cc-panel > summary { padding: 14px 16px; cursor: pointer; font-weight: 600; }
.cc-summary { display: block; color: var(--text-secondary, #65758a); font-weight: 400; margin-left: 18px; overflow-wrap: anywhere; }
.cc-panel-body { padding: 8px 16px 16px; }
.cc-panel-body > h5, .cc-panel-body > section > h5, .cc-panel-body > section > hr, #createForm > hr { display: none; }
.cc-types { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.cc-types .btn { min-height: 44px; }
#cc-more > summary { padding: 10px; cursor: pointer; }
.cc-actions { position: sticky; bottom: 55px; z-index: 1020; padding: 16px; border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; background: var(--bg-surface, #fff); box-shadow: 0 -4px 18px #0001; flex-wrap: wrap; }
.cc-workload { margin: 12px 0; padding: 12px; border-radius: 8px; background: var(--bg-hover, #f5f7fa); }
.cc-workload summary { cursor: pointer; }
.cc-work-item { padding: 8px 0; border-bottom: 1px solid var(--border-color, #dce3eb); }
.cc-work-item small { display: block; }
[data-bs-theme="dark"] #createForm .bg-light { background: var(--bg-surface) !important; color: var(--text-primary); }
@media(max-width: 600px) { .cc-actions { bottom: 55px; padding: 10px; gap: 8px !important; } .cc-actions #cc-save { width: 100%; } .cc-actions .btn { padding: 10px 16px !important; } .cc-panel-body { padding: 8px 12px; } }
/* Gradient Header for the Card */
.card-header-custom {
background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%);
@ -136,18 +151,144 @@
.case-top-alerts .alert-danger {
border-left-color: #e03131;
}
.cc-page { max-width: 1440px; padding-bottom: 100px !important; }
.cc-pagebar { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:0 0 20px; }
.cc-pagebar > a { color:var(--text-secondary,#63758c); text-decoration:none; font-size:.88rem; }
.cc-pagebar .btn { border-radius:12px; font-size:.85rem; padding:10px 16px; }
.cc-page .card-custom { border:1px solid var(--border-color,#dce5ed); border-radius:22px; box-shadow:0 16px 48px #143b5610; }
.cc-page .card-header-custom { display:flex; justify-content:space-between; align-items:center; gap:20px; padding:34px 36px; border-radius:21px 21px 0 0; background:linear-gradient(115deg,#122f46,#164b62); }
.cc-eyebrow { display:block; color:#9ed7de; font-size:.66rem; font-weight:700; letter-spacing:.15em; margin-bottom:10px; }
.cc-page h1 { font-size:1.9rem; font-weight:700; letter-spacing:-.04em; margin-bottom:10px; }
.cc-page .card-header-custom p { color:#c5dce6; font-size:.9rem; max-width:520px; line-height:1.6; }
.cc-hero-icon { display:grid; place-items:center; flex-shrink:0; width:72px; height:72px; border-radius:22px; background:#ffffff0d; border:1px solid #ffffff25; font-size:2rem; color:#a9e4df; }
.cc-page .card-custom > .card-body { padding:32px 42px !important; }
.cc-page .form-control,.cc-page .form-select { border-radius:10px; min-height:43px; background-color:var(--bg-surface,#fff); border-color:var(--border-color,#dbe4ec); }
.cc-page .input-group-text { border-radius:10px 0 0 10px; background:transparent !important; color:#7891a5; }
.cc-page .input-group .form-control { border-top-left-radius:0; border-bottom-left-radius:0; }
.cc-page textarea.form-control { line-height:1.7; padding:14px 16px; }
.cc-page .form-label { font-size:.82rem; }
.cc-page .form-text { font-size:.75rem; line-height:1.5; }
.cc-page .cc-type-section > label { font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--text-secondary,#63758c); }
.cc-page #cc-types { align-items:stretch; gap:10px; }
.cc-page #cc-types > button { flex:1; border:1px solid var(--border-color,#dbe4ec); color:var(--text-primary,#26465d); border-radius:13px; text-align:left; padding:14px; background:var(--bg-surface,#fff); transition:border-color .15s,background .15s; }
.cc-type-name { display:block; font-size:.9rem; font-weight:600; }
.cc-type-hint { display:block; color:var(--text-secondary,#708397); margin-top:5px; font-size:.7rem; font-weight:400; }
.cc-page #cc-types > button.active { border-color:#258783; background:var(--accent-light,#eaf6f4); box-shadow:0 0 0 1px #258783; }
.cc-page #cc-types > .cc-message-type { border-style:dashed; border-color:#86aaa8; background:color-mix(in srgb,var(--accent-light,#eaf6f4) 62%,var(--bg-surface,#fff)); }
.cc-page #cc-types > .cc-message-type:hover { border-style:solid; background:var(--accent-light,#eaf6f4); }
.cc-message-type .cc-type-name { display:flex; align-items:center; gap:7px; }
.cc-message-type .cc-type-name::after { content:'INTERN'; padding:2px 5px; border-radius:4px; background:#267b74; color:#fff; font-size:.52rem; font-weight:700; letter-spacing:.08em; }
.cc-message-fallback { display:flex; width:100%; margin-top:12px; text-align:left; padding:14px; border-radius:13px; }
.cc-page #cc-types > button:hover { border-color:#258783; }
.cc-page #cc-more { flex-basis:100%; font-size:.78rem; }
.cc-page #cc-more > summary { padding:4px 0; color:var(--text-secondary,#63758c); }
.cc-page #cc-more .cc-types { padding-top:10px; }
.cc-page .cc-panel { border-radius:13px; margin-bottom:12px; }
.cc-page .cc-panel > summary { display:flex; align-items:center; gap:12px; list-style:none; padding:15px 17px; }
.cc-page .cc-panel > summary::-webkit-details-marker { display:none; }
.cc-page .cc-panel > summary::after { content:'⌄'; margin-left:auto; color:#8196a8; transition:transform .15s; }
.cc-page .cc-panel[open] > summary::after { transform:rotate(180deg); }
.cc-panel-icon { display:grid; place-items:center; flex-shrink:0; width:35px; height:35px; border-radius:10px; color:#3e777e; background:var(--accent-light,#eef5f6); }
.cc-panel-label { min-width:0; font-size:.86rem; }
.cc-page .cc-summary { margin:3px 0 0; font-size:.72rem; }
.cc-page .cc-panel[open] { border-color:#a7c5cc; }
.cc-page .cc-panel-body { padding:8px 18px 18px; }
.cc-page .cc-actions { border-radius:14px; box-shadow:0 8px 30px #143b561c; margin-top:28px !important; }
.cc-page .cc-actions .btn { border-radius:10px; }
.cc-page #submitBtn { background:#176967; border-color:#176967; }
.cc-page #cc-save { color:var(--text-secondary,#63758c); font-size:.73rem; }
.cc-template-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding-bottom:20px; margin-bottom:20px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
.cc-template-toolbar .form-label { margin:0; }
.cc-template-toolbar .form-select { flex:1; width:auto; max-width:320px; min-width:180px; font-size:.83rem; }
.cc-page .cc-workload { background:var(--bg-hover,#f5f8fa); border:1px solid var(--border-color,#e3eaf0); font-size:.8rem; }
.cc-contact-cases { margin-top:14px; padding:0; border:1px solid #bdd9da; border-radius:12px; background:var(--bg-surface,#fff); overflow:hidden; font-size:.8rem; box-shadow:0 4px 14px #164b620c; }
.cc-contact-cases[hidden] { display:none; }
.cc-contact-cases__header { display:flex; align-items:center; gap:10px; padding:11px 13px; background:#edf8f7; color:#195f5e; }
.cc-contact-cases__header-icon { display:grid; place-items:center; width:29px; height:29px; border-radius:8px; background:#cfeceb; font-size:.85rem; }
.cc-contact-cases__eyebrow { display:block; color:#4d8382; font-size:.62rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; }
.cc-contact-cases__title { display:block; font-weight:700; line-height:1.25; }
.cc-contact-cases__body { padding:6px 13px 10px; }
.cc-contact-cases__group { padding-top:8px; }
.cc-contact-cases__group-name { display:block; color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin:0 0 4px; }
.cc-contact-cases__item { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 0; border-bottom:1px solid var(--border-color,#e6eff0); }
.cc-contact-cases__item:last-child { border-bottom:0; }
.cc-contact-cases__item a { color:var(--text-primary,#213547); font-weight:600; text-decoration:none; }
.cc-contact-cases__item a:hover { color:#176967; text-decoration:underline; }
.cc-contact-cases__item small { color:var(--text-secondary,#65758a); white-space:nowrap; font-size:.69rem; }
.cc-contact-cases__more { display:block; padding-top:6px; color:var(--text-secondary,#65758a); font-size:.69rem; }
.cc-contact-cases__empty { padding:11px 13px; color:var(--text-secondary,#65758a); }
.cc-tag-suggestions { margin:10px 0 4px; padding-top:10px; border-top:1px solid var(--border-color,#e3eaf0); }
.cc-tag-suggestions__label { color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin-bottom:6px; }
.cc-tag-suggestions .btn { border-radius:999px; font-size:.72rem; padding:3px 8px; }
[data-bs-theme="dark"] .cc-contact-cases { background:var(--bg-surface); border-color:#326b6b; }
[data-bs-theme="dark"] .cc-contact-cases__header { background:#173e3e; color:#c9edeb; }
@media(min-width:1200px) {
.cc-page #cc-relations .row { grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); display:grid; gap:28px !important; }
.cc-page #cc-relations .row > [class*="col-"] { width:auto; max-width:none; padding:0; }
.cc-page .cc-panel-body { padding:14px 24px 24px; }
.cc-contact-cases__item { display:grid; grid-template-columns:minmax(0, 1fr) auto; column-gap:14px; align-items:center; }
.cc-contact-cases__item small { text-align:right; margin:0; }
}
@media(max-width:600px) { .cc-page .card-header-custom { padding:25px 20px; } .cc-hero-icon { display:none; } .cc-page h1 { font-size:1.6rem; } .cc-page .card-custom > .card-body { padding:20px 16px !important; } .cc-page #cc-types > button { flex:1 1 40%; } .cc-pagebar { align-items:flex-start; } .cc-pagebar .btn { max-width:190px; } }
/* Calm, task-first layout: the form is the product, not the decoration. */
.cc-page { max-width:1240px; }
.cc-page .card-custom { border-radius:16px; box-shadow:0 8px 28px #143b560d; }
.cc-page .card-header-custom { min-height:auto; padding:20px 28px; border-radius:15px 15px 0 0; background:var(--bg-surface,#fff); color:var(--text-primary,#213547); border-bottom:1px solid var(--border-color,#dce5ed); }
.cc-page .card-header-custom p { display:none; }
.cc-page .card-header-custom .cc-eyebrow { color:#267b74; margin:0 0 4px; font-size:.59rem; }
.cc-page .card-header-custom h1 { margin:0; font-size:1.35rem; letter-spacing:-.02em; color:inherit; }
.cc-page .cc-hero-icon { width:42px; height:42px; border-radius:12px; font-size:1.15rem; color:#267b74; background:var(--accent-light,#eaf6f4); border:0; }
.cc-page .card-custom > .card-body { padding:24px 28px 32px !important; }
.cc-template-toolbar { padding:12px 14px; margin:0 0 16px !important; border:1px solid var(--border-color,#e3eaf0); border-radius:10px; background:var(--bg-hover,#f7fafb); }
.cc-template-toolbar .form-label { font-weight:600; font-size:.76rem; }
.cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; }
.cc-page .cc-type-section { padding:0 0 16px; margin-bottom:16px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
.cc-page .cc-type-section > label { display:block; margin-bottom:8px !important; text-transform:none; letter-spacing:0; color:var(--text-primary,#213547); font-weight:600; font-size:.82rem; }
.cc-page #caseTypeHelp { display:none; }
.cc-page #cc-types { gap:7px; }
.cc-page #cc-types > button { flex:0 1 auto; min-height:0; padding:8px 11px; border-radius:9px; font-size:.78rem; }
.cc-page .cc-type-name { display:inline; font-size:.78rem; }
.cc-page .cc-type-hint { display:none; }
.cc-page #cc-types > button.active { box-shadow:none; background:#176967; border-color:#176967; color:#fff; }
.cc-page #cc-types > button.active .cc-type-name::after { background:#fff3; color:#fff; }
.cc-page #cc-types > .cc-message-type { margin-left:auto; border-style:solid; background:transparent; }
.cc-page #cc-more { flex-basis:auto; }
.cc-page #cc-more > summary { padding:8px 4px; }
.cc-page .cc-panel { border-radius:10px; margin-bottom:8px; box-shadow:none; }
.cc-page .cc-panel > summary { padding:12px 14px; }
.cc-panel-icon { width:30px; height:30px; border-radius:8px; font-size:.82rem; }
.cc-page .cc-panel-label { font-size:.8rem; }
.cc-page .cc-summary { display:inline; margin-left:7px; font-size:.69rem; }
.cc-page .cc-panel-body { padding:8px 14px 16px; }
.cc-page .cc-panel-body > .row { margin-bottom:0 !important; }
.cc-page #cc-relations[open] { border-color:#9dc7c3; }
.cc-page #cc-metadata { margin-top:16px; }
.cc-page #cc-workload { margin:8px 0 16px; padding:10px 14px; }
.cc-page .cc-actions { margin-top:20px !important; padding:12px 14px; }
/* The panels have very different heights. Keep them in a single stable flow
instead of pretending they are equal dashboard cards. */
@media(min-width:992px) {
.cc-page .cc-panel:not(#cc-relations):not(#cc-metadata) { width:100%; display:block; margin-right:0; }
}
@media(max-width:700px) {
.cc-page .card-header-custom { padding:16px 18px; }
.cc-page .card-custom > .card-body { padding:18px 14px 28px !important; }
.cc-page #cc-types > .cc-message-type { margin-left:0; }
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="container py-4 cc-page">
<div class="cc-pagebar"><a href="/sag"><i class="bi bi-arrow-left me-2"></i>Alle sager</a></div>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="col-12">
<!-- Main Card -->
<div class="card card-custom">
<div class="card-header-custom">
<h2 class="mb-0 fs-4 fw-bold"><i class="bi bi-plus-circle me-2"></i>Opret Ny Sag</h2>
<p class="mb-0 opacity-75 small mt-1">Udfyld formularen for at oprette en ny sag i systemet.</p>
<div><span class="cc-eyebrow">ET GODT STED AT STARTE</span><h1>Opret en ny sag</h1><p class="mb-0">Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.</p></div>
<span class="cc-hero-icon" aria-hidden="true"><i class="bi bi-folder-plus"></i></span>
</div>
<div class="card-body p-4">
@ -162,10 +303,14 @@
</div>
<form id="createForm" novalidate>
<div class="mb-4 p-3 rounded-3 border bg-light">
<div class="mb-4 cc-type-section">
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
<select class="form-select form-select-lg" id="type" required></select>
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
<button type="button" id="caseMessageTypeButton" class="btn btn-outline-primary cc-message-type cc-message-fallback" onclick="window.caseCreateUI?.openMessage?.() || window.openInternalMessage?.()">
<span class="cc-type-name"><i class="bi bi-chat-square-text"></i>Besked til kollega</span>
<small class="cc-type-hint">Kort besked eller telefonbesked</small>
</button>
</div>
<!-- Section: Relations -->
@ -199,6 +344,7 @@
<input type="hidden" id="customer_id" name="customer_id">
</div>
</div>
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
<hr class="my-4 opacity-25">
@ -386,7 +532,7 @@
let customerContactsLoadToken = 0;
let successAlertTimeout;
let orderLineCounter = 0;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null, internetConnectionId: null };
let topAlertLoadToken = 0;
function escapeTopAlertHtml(value) {
@ -475,6 +621,13 @@
}, duration);
}
function showCreateError(message) {
const errorDiv = document.getElementById('error');
errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = String(message || 'Ukendt fejl');
errorDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// --- Character Counter ---
const beskrInput = document.getElementById('beskrivelse');
if (beskrInput) {
@ -494,7 +647,7 @@
if (!source) {
descriptionInput?.focus();
alert('Skriv en beskrivelse først.');
showCreateError('Skriv en beskrivelse først.');
return;
}
@ -529,7 +682,7 @@
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
} catch (error) {
console.error('Case create rewrite failed:', error);
alert(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
showCreateError(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
} finally {
if (button) {
button.disabled = false;
@ -545,10 +698,13 @@
const titleInput = document.getElementById('titel');
const descriptionInput = document.getElementById('beskrivelse');
if (suggestedTitle) titleInput.value = suggestedTitle;
if (suggestedTitle) {
titleInput.value = suggestedTitle;
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (suggestedDescription) {
descriptionInput.value = suggestedDescription;
descriptionInput.dispatchEvent(new Event('input'));
descriptionInput.dispatchEvent(new Event('input', { bubbles: true }));
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
});
@ -572,13 +728,20 @@
const contactInput = document.getElementById('contactSearch');
if (contactInput) {
contactInput.addEventListener('input', (e) => handleSearch(e, 'contact'));
contactInput.addEventListener('keydown', e => {
if (e.key === 'Escape') {
clearTimeout(contactSearchTimeout);
document.getElementById('contactResults').classList.add('d-none');
contactInput.blur();
}
});
contactInput.addEventListener('focus', () => {
if (selectedCustomer) {
if (selectedCustomer && contactInput.value.trim().length >= 2) {
renderCustomerContactResults(contactInput.value);
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-position-relative')) {
if (!contactInput.closest('.search-position-relative').contains(e.target)) {
const cr = document.getElementById('contactResults');
if(cr) cr.classList.add('d-none');
}
@ -597,6 +760,12 @@
clearTimeout(timeoutVar);
if (query.length < 2) {
resultsDiv.classList.add('d-none');
resultsDiv.innerHTML = '';
return;
}
if (type === 'contact' && selectedCustomer) {
renderCustomerContactResults(query);
return;
@ -608,6 +777,7 @@
}
const timeout = setTimeout(async () => {
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
try {
// Show loading state
resultsDiv.classList.remove('d-none');
@ -624,6 +794,7 @@
return;
}
const data = await response.json();
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
if (!Array.isArray(data)) {
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
return;
@ -686,6 +857,10 @@
if (!resultsDiv || !selectedCustomer) return;
const normalizedQuery = String(query || '').trim().toLocaleLowerCase('da-DK');
if (normalizedQuery.length < 2 || document.activeElement !== document.getElementById('contactSearch')) {
resultsDiv.classList.add('d-none');
return;
}
const contacts = selectedCustomerContacts.filter((contact) => {
if (!normalizedQuery) return true;
return [
@ -731,9 +906,9 @@
selectedCustomerContacts = [];
contactInput.placeholder = `Søg blandt kontakter hos ${selectedCustomer.name}...`;
context.textContent = `Viser kontakter hos ${selectedCustomer.name}.`;
context.textContent = `Søg blandt kontakter hos ${selectedCustomer.name} (mindst 2 tegn).`;
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Henter firmaets kontakter...</div>';
resultsDiv.classList.remove('d-none');
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
try {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' });
@ -747,7 +922,7 @@
if (loadToken !== customerContactsLoadToken) return;
console.error('Failed to load customer contacts:', error);
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Firmaets kontakter kunne ikke hentes.</div>';
resultsDiv.classList.remove('d-none');
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
}
}
@ -827,6 +1002,7 @@
}
loadHardwareForContacts();
window.caseCreateUI?.contactsChanged();
}
function readTelefoniPrefill() {
@ -836,6 +1012,7 @@
const callIdRaw = params.get('telefoni_opkald_id');
const customerIdRaw = params.get('customer_id');
const descriptionRaw = params.get('description');
const internetConnectionIdRaw = params.get('internet_connection_id');
const contactId = contactIdRaw ? parseInt(contactIdRaw) : null;
const customerId = customerIdRaw ? parseInt(customerIdRaw) : null;
@ -844,6 +1021,8 @@
telefoniPrefill.title = titleRaw ? String(titleRaw) : null;
telefoniPrefill.callId = callIdRaw ? String(callIdRaw) : null;
telefoniPrefill.description = descriptionRaw ? String(descriptionRaw) : null;
const internetConnectionId = internetConnectionIdRaw ? parseInt(internetConnectionIdRaw) : null;
telefoniPrefill.internetConnectionId = Number.isFinite(internetConnectionId) ? internetConnectionId : null;
}
async function applyTelefoniPrefill() {
@ -891,6 +1070,23 @@
console.error('Telefoni prefill failed', e);
}
}
if (telefoniPrefill.internetConnectionId) {
const panel = document.getElementById('internetConnectionPrefill');
try {
const res = await fetch(`/api/v1/internet-connections/${telefoniPrefill.internetConnectionId}`, { credentials: 'include' });
if (!res.ok) throw new Error('Forbindelsen findes ikke');
const connection = await res.json();
const reference = connection.circuit_number || connection.provider_reference || `#${telefoniPrefill.internetConnectionId}`;
panel.innerHTML = `<i class="bi bi-router me-2"></i><strong>Internetforbindelse knyttes til sagen:</strong> ${escapeTopAlertHtml(connection.name || 'Internetforbindelse')} · ${escapeTopAlertHtml(reference)}${connection.address ? ` · ${escapeTopAlertHtml(connection.address)}` : ''}. <a href="/economy/internet-connections/${telefoniPrefill.internetConnectionId}" class="alert-link">Åbn forbindelse</a><div class="small mt-1">Kunden vælges stadig manuelt og ændres ikke på forbindelsen.</div>`;
panel.classList.remove('d-none');
const titleInput = document.getElementById('titel');
if (titleInput && !titleInput.value.trim()) titleInput.value = `Vedr. forbindelse ${reference}`;
} catch (error) {
panel.textContent = `Internetforbindelse #${telefoniPrefill.internetConnectionId} kunne ikke hentes.`;
panel.className = 'alert alert-warning mb-4';
}
}
}
function removeContact(id) {
@ -898,6 +1094,7 @@
delete selectedContactsCompanies[id];
renderSelections();
loadHardwareForContacts();
window.caseCreateUI?.contactsChanged();
}
function renderSelections() {
@ -999,7 +1196,7 @@
if (!anydeskId) return;
try {
await navigator.clipboard.writeText(anydeskId);
alert('AnyDesk ID kopieret');
showSuccessAlert('AnyDesk ID kopieret');
} catch (err) {
console.error('Copy failed', err);
}
@ -1012,13 +1209,13 @@
const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null;
if (!name) {
alert('Navn er påkrævet');
showCreateError('Navn er påkrævet');
return;
}
const customerId = selectedCustomer?.id || getSingleContactCompanyId();
if (!customerId) {
alert('Vælg et firma før du opretter hardware');
showCreateError('Vælg et firma før du opretter hardware');
return;
}
@ -1044,7 +1241,7 @@
document.getElementById('hardwareAnyDeskIdInput').value = '';
await loadHardwareForContacts();
} catch (err) {
alert('Fejl: ' + err.message);
showCreateError('Fejl: ' + err.message);
}
}
@ -1139,14 +1336,13 @@
function updateCaseTypeSections() {
const type = document.getElementById('type')?.value || 'ticket';
document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket');
document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline');
document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre');
['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none'));
window.caseCreateUI?.typeChanged(type);
const help = document.getElementById('caseTypeHelp');
if (help) help.textContent = type === 'ticket' ? 'Hardware og AnyDesk vises for tickets.'
if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket. Alle ekstra funktioner er tilgængelige.'
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
: 'Denne sagstype bruger kun de fælles sagsfelter.';
: 'Tilføj hardware, pipeline, tags og køb/salg efter behov.';
}
function renderOrderLinesEmptyState() {
@ -1221,7 +1417,7 @@
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
const profile = profileRes.ok ? await profileRes.json() : {};
const configured = JSON.parse(setting.value || '[]');
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
const types = Array.isArray(configured) && configured.length ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : Object.keys(caseTypeLabels);
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
if (!types.includes('abonnement')) types.push('abonnement');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
@ -1251,18 +1447,18 @@
}
// --- Initialization ---
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
initializeSearch();
loadCaseTypesSelect();
loadPipelineStages();
selectCurrentUserAsResponsible();
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]);
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
applyTelefoniPrefill();
await applyTelefoniPrefill();
await window.caseCreateUI?.init();
});
// --- Form Submission ---
document.getElementById('createForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (window.caseCreateUI && !window.caseCreateUI.validate()) return;
// UI Reset
const errorDiv = document.getElementById('error');
@ -1335,10 +1531,15 @@
customer_id: selectedCustomer ? selectedCustomer.id : null,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
deadline: document.getElementById('deadline').value || null
deadline: document.getElementById('deadline').value || null,
contact_ids: Object.keys(selectedContacts).map(id => parseInt(id)).filter(Number.isFinite),
telefoni_opkald_id: telefoniPrefill.callId ? parseInt(telefoniPrefill.callId) : null
};
if (telefoniPrefill.internetConnectionId) {
data.internet_connection_ids = [telefoniPrefill.internetConnectionId];
}
if (data.type === 'pipeline') {
if (['pipeline_stage_id', 'pipeline_amount', 'pipeline_probability', 'pipeline_description'].some(id => document.getElementById(id).value !== '')) {
data.pipeline = {
stage_id: document.getElementById('pipeline_stage_id').value || null,
amount: document.getElementById('pipeline_amount').value || null,
@ -1346,9 +1547,10 @@
description: document.getElementById('pipeline_description').value || null
};
}
if (data.type === 'ordre') {
if (document.querySelector('#orderLines .order-line')) {
data.order_items = collectOrderItems();
}
Object.assign(data, window.caseCreateUI?.relations() || {});
try {
const response = await fetch('/api/v1/sag', {
@ -1360,64 +1562,11 @@
if (response.ok) {
const result = await response.json();
// Add contacts if any
const contactPromises = Object.keys(selectedContacts).map(cid =>
fetch(`/api/v1/sag/${result.id}/contacts`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contact_id: parseInt(cid),
role: 'Kontakt',
is_primary: false
})
})
);
await Promise.all(contactPromises);
// Link telephony call -> case (best-effort)
if (telefoniPrefill.callId) {
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,
kontakt_id: telefoniPrefill.contactId || null
})
});
} catch (e) {
console.warn('Telefoni link failed', e);
}
}
// Ensure contact-company link exists
if (selectedCustomer) {
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 })
})
);
const linkResponses = await Promise.all(linkPromises);
const linkFailed = linkResponses.find(res => !res.ok);
if (linkFailed) {
const err = await linkFailed.json();
throw new Error(err.detail || 'Kunne ikke linke kontakt til firma');
}
}
window.caseCreateUI?.created(result);
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
setTimeout(() => {
window.location.href = `/sag/${result.id}/v3`;
}, 1000);
document.getElementById('success-text').textContent = "Sag oprettet";
window.location.href = `/sag/${result.id}/v3`;
} else {
const errorText = await response.text();
let errMsg = "Kunne ikke oprette sag";
@ -1437,4 +1586,5 @@
}
});
</script>
<script src="/static/js/case-create.js?v=5"></script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -299,8 +299,6 @@
deadline: document.getElementById('deadline').value || null
};
console.log('Updating case with data:', data);
try {
const response = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH',
@ -310,17 +308,11 @@
body: JSON.stringify(data)
});
console.log('Response status:', response.status);
if (response.ok) {
const result = await response.json();
console.log('Updated case:', result);
document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
await response.json();
document.getElementById('success').textContent = `✅ Sag opdateret`;
document.getElementById('success').style.display = 'block';
setTimeout(() => {
window.location.href = `/sag/${caseId}/v3`;
}, 1000);
window.location.href = `/sag/${caseId}/v3`;
} else {
const errorText = await response.text();
console.error('Error response:', errorText);

View File

@ -5,17 +5,54 @@
{% block extra_css %}
<style>
.search-bar {
position: relative;
margin-bottom: 0;
flex: 1 1 380px;
flex: 1 1 460px;
min-width: 240px;
}
.search-bar input {
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
padding: 0.45rem 0.85rem;
min-height: 46px;
border-radius: 12px;
border: 1px solid rgba(15,76,117,.15);
padding: 0.6rem 4.5rem 0.6rem 2.7rem;
background: var(--bg-card);
box-shadow: 0 4px 16px rgba(15, 76, 117, .06);
transition: border-color .16s ease, box-shadow .16s ease;
}
.search-bar input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15,76,117,.12), 0 5px 18px rgba(15,76,117,.08);
}
.search-bar .search-icon {
position: absolute; left: .95rem; top: 50%; transform: translateY(-50%);
color: var(--accent); pointer-events: none;
}
.search-clear-btn {
position: absolute; right: .45rem; top: 50%; transform: translateY(-50%);
width: 34px; height: 34px; border: 0; border-radius: 9px;
background: transparent; color: var(--text-secondary);
}
.search-clear-btn:hover { background: rgba(15,76,117,.09); color: var(--accent); }
.search-shortcut {
position:absolute; right:2.85rem; top:50%; transform:translateY(-50%);
padding:.1rem .35rem; border:1px solid rgba(0,0,0,.12); border-radius:5px;
color:var(--text-secondary); font-size:.68rem; background:rgba(255,255,255,.7);
}
.sag-toolbar-card { padding: .85rem; margin-bottom: .9rem; background: var(--bg-card); border:1px solid rgba(15,76,117,.09); border-radius:16px; box-shadow:0 5px 20px rgba(15,76,117,.055); }
.sag-quick-filters { display:flex; flex-wrap:wrap; align-items:center; gap:.48rem; padding-top:.7rem; margin-top:.7rem; border-top:1px solid rgba(15,76,117,.08); }
.sag-quick-filter { display:inline-flex; align-items:center; gap:.4rem; padding:.43rem .72rem; border:1px solid rgba(15,76,117,.14); border-radius:999px; background:rgba(15,76,117,.035); color:var(--text-primary); font-size:.8rem; font-weight:650; transition:all .16s ease; }
.sag-quick-filter:hover { border-color:rgba(15,76,117,.32); background:rgba(15,76,117,.09); color:var(--accent); transform:translateY(-1px); }
.sag-quick-filter.active { color:#fff; background:var(--accent); border-color:var(--accent); box-shadow:0 4px 12px rgba(15,76,117,.2); }
.sag-quick-filter[data-quick-filter="overdue"].active { background:#c92a2a; border-color:#c92a2a; }
.sag-advanced-filters { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:1rem; padding:.6rem .7rem; border-radius:12px; background:rgba(15,76,117,.035); border:1px solid rgba(15,76,117,.07); }
.top-controls-row {
display: flex;
align-items: center;
@ -89,6 +126,21 @@
.sag-table tbody tr:hover {
background: var(--accent-light);
}
.sag-table tbody tr.sag-deadline-overdue {
background: rgba(201, 42, 42, 0.045);
box-shadow: inset 5px 0 0 #c92a2a, inset 0 0 18px rgba(201, 42, 42, 0.11);
}
.sag-table tbody tr.sag-deadline-overdue:hover {
background: rgba(201, 42, 42, 0.09);
box-shadow: inset 5px 0 0 #b42318, inset 0 0 22px rgba(201, 42, 42, 0.16);
}
.sag-table tbody tr.sag-deadline-overdue td:last-child {
color: #b42318 !important;
font-weight: 700;
}
.sag-table tbody td {
padding: 0.5rem 0.75rem;
@ -116,11 +168,76 @@
min-width: 260px;
max-width: 360px;
}
.sag-column-list { min-width: 260px; max-height: 420px; overflow-y: auto; }
.sag-column-item { display:flex; align-items:center; gap:.55rem; padding:.45rem .6rem; border-radius:8px; cursor:grab; }
.sag-column-item:hover, .sag-column-item.dragging { background:rgba(15,76,117,.1); }
.sag-column-item .drag-handle { color:var(--text-secondary); cursor:grab; }
.sag-id {
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 0.28rem;
padding: 0.22rem 0.48rem;
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 7px;
background: rgba(15, 76, 117, 0.07);
color: var(--accent);
font-size: 0.95rem;
font-weight: 700;
font-size: 0.82rem;
line-height: 1.2;
text-decoration: none;
transition: background-color .16s ease, border-color .16s ease, transform .16s ease;
}
.sag-id:hover {
color: var(--accent);
background: rgba(15, 76, 117, 0.14);
border-color: rgba(15, 76, 117, 0.3);
transform: translateY(-1px);
text-decoration: none;
}
.sag-entity-link {
display: inline-flex;
align-items: center;
gap: 0.38rem;
max-width: 100%;
padding: 0.25rem 0.5rem;
border-radius: 7px;
color: var(--text-primary);
font-weight: 600;
line-height: 1.25;
text-decoration: none;
transition: color .16s ease, background-color .16s ease, transform .16s ease;
}
.sag-entity-link i {
flex: 0 0 auto;
color: var(--text-secondary);
font-size: 0.78rem;
}
.sag-entity-link span {
overflow: hidden;
text-overflow: ellipsis;
}
.sag-entity-link:hover {
color: var(--accent);
background: rgba(15, 76, 117, 0.09);
text-decoration: none;
transform: translateX(2px);
}
.sag-entity-link:hover i {
color: var(--accent);
}
.sag-id:focus-visible,
.sag-entity-link:focus-visible {
outline: 3px solid rgba(15, 76, 117, 0.22);
outline-offset: 2px;
}
.sag-unread-badge {
@ -287,6 +404,64 @@
background: #d1fae5;
color: #065f46;
}
.sag-inline-select {
min-height: 34px;
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 9px;
background-color: rgba(15, 76, 117, 0.045);
color: var(--text-primary);
font-size: 0.8rem;
font-weight: 600;
padding: 0.34rem 2rem 0.34rem 0.62rem;
box-shadow: none;
cursor: pointer;
transition: border-color .16s ease, background-color .16s ease, box-shadow .16s ease, transform .16s ease;
}
.sag-inline-select:hover:not(:disabled) {
border-color: rgba(15, 76, 117, 0.38);
background-color: rgba(15, 76, 117, 0.09);
}
.sag-inline-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.14);
}
.sag-inline-select:disabled {
opacity: .62;
cursor: wait;
}
.sag-owner-select {
background-color: rgba(99, 102, 241, 0.055);
border-color: rgba(99, 102, 241, 0.17);
}
.sag-status-select.status-tone-open {
color: #8a5a00;
background-color: #fff8df;
border-color: #efd991;
}
.sag-status-select.status-tone-progress {
color: #174a89;
background-color: #eaf3ff;
border-color: #b8d5fa;
}
.sag-status-select.status-tone-waiting {
color: #8a4600;
background-color: #fff1df;
border-color: #f0c58e;
}
.sag-status-select.status-tone-done {
color: #087044;
background-color: #e5f8ef;
border-color: #acdcbc;
}
.filter-pills {
display: flex;
@ -649,7 +824,8 @@
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
<div class="top-controls-row">
<div class="sag-toolbar-card">
<div class="top-controls-row mb-0">
<h1 style="margin: 0; color: var(--accent); flex-shrink: 0;">
<i class="bi bi-list-check"></i>
</h1>
@ -666,11 +842,14 @@
</div>
<div class="search-bar">
<i class="bi bi-search search-icon"></i>
<input type="text"
class="form-control"
id="searchInput"
placeholder="🔍 Søg efter sag ID, titel, beskrivelse..."
placeholder="Søg på sagsnr., firma, kontakt, titel eller ansvarlig..."
autocomplete="off">
<span class="search-shortcut">/</span>
<button class="search-clear-btn d-none" id="clearSearchBtn" type="button" title="Ryd søgning" aria-label="Ryd søgning"><i class="bi bi-x-lg"></i></button>
</div>
<div class="top-controls-actions">
@ -682,13 +861,17 @@
</button>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<div class="filter-pills">
<div class="filter-pill active" data-filter="all">Alle</div>
<div class="filter-pill" data-filter="åben">Åbne</div>
<div class="filter-pill" data-filter="lukket">Lukkede</div>
<div class="sag-quick-filters" aria-label="Hurtigfiltre">
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'all' else '' }}" type="button" data-quick-filter="all"><i class="bi bi-grid"></i>Alle aktive</button>
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'mine-open' else '' }}" type="button" data-quick-filter="mine-open"><i class="bi bi-person-check"></i>Mine åbne sager</button>
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'overdue' else '' }}" type="button" data-quick-filter="overdue"><i class="bi bi-alarm"></i>Overskredet deadline</button>
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'my-groups' else '' }}" type="button" data-quick-filter="my-groups"><i class="bi bi-people"></i>Mine grupper</button>
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'unassigned' else '' }}" type="button" data-quick-filter="unassigned"><i class="bi bi-person-dash"></i>Ikke tildelt</button>
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'closed' else '' }}" type="button" data-quick-filter="closed"><i class="bi bi-check2-circle"></i>Lukkede</button>
</div>
</div>
<div class="sag-advanced-filters">
<div class="type-filter-wrap">
<div class="dropdown type-filter-dropdown mb-1">
<button class="btn dropdown-toggle" type="button" id="typeFilterDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
@ -746,6 +929,19 @@
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
</a>
<div class="dropdown ms-auto">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-layout-three-columns me-1"></i>Kolonner
</button>
<div class="dropdown-menu dropdown-menu-end p-2 shadow">
<div class="small text-muted px-2 pb-2">Træk for at ændre rækkefølge</div>
<div id="sagColumnList" class="sag-column-list"></div>
<div class="d-flex gap-2 border-top pt-2 mt-2">
<button id="resetSagColumnsBtn" class="btn btn-sm btn-outline-secondary" type="button">Nulstil</button>
<button id="saveSagColumnsBtn" class="btn btn-sm btn-primary ms-auto" type="button">Gem for mig</button>
</div>
</div>
</div>
</div>
<!-- Table -->
@ -780,14 +976,15 @@
data-status="{{ sag.status }}"
data-type="{{ sag.template_key or sag.type or 'ticket' }}"
data-assignee-id="{{ sag.ansvarlig_bruger_id if sag.ansvarlig_bruger_id else '' }}"
data-group-id="{{ sag.assigned_group_id if sag.assigned_group_id else '' }}">
data-group-id="{{ sag.assigned_group_id if sag.assigned_group_id else '' }}"
data-deadline="{{ sag.deadline.isoformat() if sag.deadline else '' }}">
<td class="col-expand" onclick="event.stopPropagation();">
{% if has_relations %}
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
{% endif %}
</td>
<td>
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ sag.id }}/v3'">#{{ sag.id }}</span>
<a class="sag-id" href="/sag/{{ sag.id }}/v3"><i class="bi bi-folder2-open"></i>#{{ sag.id }}</a>
{% if (sag.unread_email_count or 0) > 0 %}
{% set unread_level = sag.unread_email_level or 'fresh' %}
<span class="sag-unread-badge sag-unread-{{ unread_level }}" title="{{ sag.unread_email_count }} ulæste e-mails">
@ -796,10 +993,10 @@
{% endif %}
</td>
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.customer_name if sag.customer_name else '-' }}
{% if sag.customer_id and sag.customer_name %}<a class="sag-entity-link" href="/customers/{{ sag.customer_id }}" onclick="event.stopPropagation()" title="Åbn {{ sag.customer_name }}"><i class="bi bi-building"></i><span>{{ sag.customer_name }}</span></a>{% else %}-{% endif %}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
{% if sag.kontakt_id and sag.kontakt_navn and sag.kontakt_navn.strip() %}<a class="sag-entity-link" href="/contacts/{{ sag.kontakt_id }}" onclick="event.stopPropagation()" title="Åbn {{ sag.kontakt_navn }}"><i class="bi bi-person"></i><span>{{ sag.kontakt_navn }}</span></a>{% else %}-{% endif %}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
<div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
@ -812,13 +1009,15 @@
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'">
{% set status_raw = sag.status if sag.status else 'åben' %}
{% set status_class = status_raw|lower|replace(' ', '-') %}
<span class="status-badge status-{{ status_class }}">{{ status_raw }}</span>
<select class="form-select form-select-sm sag-inline-select sag-status-select" style="min-width:120px" data-previous="{{ status_raw }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ sag.id }}, 'status', this.value, this)">
{% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %}
</select>
</td>
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
<div class="owner-cell">
{{ initials_bubble(sag.ansvarlig_navn) }}
</div>
<select class="form-select form-select-sm sag-inline-select sag-owner-select" style="min-width:150px" data-previous="{{ sag.ansvarlig_bruger_id or '' }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ sag.id }}, 'ansvarlig_bruger_id', this.value || null, this)">
<option value="">Ikke tildelt</option>
{% for user in assignment_users or [] %}<option value="{{ user.user_id }}" {% if sag.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>{% endfor %}
</select>
</td>
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
<div class="owner-cell">
@ -855,10 +1054,10 @@
{% if related_sag and rel.target_id not in seen_targets %}
{% set _ = seen_targets.append(rel.target_id) %}
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" data-assignee-id="{{ related_sag.ansvarlig_bruger_id if related_sag.ansvarlig_bruger_id else '' }}" data-group-id="{{ related_sag.assigned_group_id if related_sag.assigned_group_id else '' }}" style="display: none;">
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" data-assignee-id="{{ related_sag.ansvarlig_bruger_id if related_sag.ansvarlig_bruger_id else '' }}" data-group-id="{{ related_sag.assigned_group_id if related_sag.assigned_group_id else '' }}" data-deadline="{{ related_sag.deadline.isoformat() if related_sag.deadline else '' }}" style="display: none;">
<td class="col-expand"><span class="child-branch"></span></td>
<td>
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">#{{ related_sag.id }}</span>
<a class="sag-id" href="/sag/{{ related_sag.id }}/v3"><i class="bi bi-folder2-open"></i>#{{ related_sag.id }}</a>
{% if (related_sag.unread_email_count or 0) > 0 %}
{% set child_unread_level = related_sag.unread_email_level or 'fresh' %}
<span class="sag-unread-badge sag-unread-{{ child_unread_level }}" title="{{ related_sag.unread_email_count }} ulæste e-mails">
@ -867,10 +1066,10 @@
{% endif %}
</td>
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
{% if related_sag.customer_id and related_sag.customer_name %}<a class="sag-entity-link" href="/customers/{{ related_sag.customer_id }}" onclick="event.stopPropagation()" title="Åbn {{ related_sag.customer_name }}"><i class="bi bi-building"></i><span>{{ related_sag.customer_name }}</span></a>{% else %}-{% endif %}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
{% if related_sag.kontakt_id and related_sag.kontakt_navn and related_sag.kontakt_navn.strip() %}<a class="sag-entity-link" href="/contacts/{{ related_sag.kontakt_id }}" onclick="event.stopPropagation()" title="Åbn {{ related_sag.kontakt_navn }}"><i class="bi bi-person"></i><span>{{ related_sag.kontakt_navn }}</span></a>{% else %}-{% endif %}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% for rt in all_rel_types %}
@ -886,13 +1085,15 @@
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% set related_status_raw = related_sag.status if related_sag.status else 'åben' %}
{% set related_status_class = related_status_raw|lower|replace(' ', '-') %}
<span class="status-badge status-{{ related_status_class }}">{{ related_status_raw }}</span>
<select class="form-select form-select-sm sag-inline-select sag-status-select" style="min-width:120px" data-previous="{{ related_status_raw }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ related_sag.id }}, 'status', this.value, this)">
{% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == related_status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %}
</select>
</td>
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
<div class="owner-cell">
{{ initials_bubble(related_sag.ansvarlig_navn) }}
</div>
<select class="form-select form-select-sm sag-inline-select sag-owner-select" style="min-width:150px" data-previous="{{ related_sag.ansvarlig_bruger_id or '' }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ related_sag.id }}, 'ansvarlig_bruger_id', this.value || null, this)">
<option value="">Ikke tildelt</option>
{% for user in assignment_users or [] %}<option value="{{ user.user_id }}" {% if related_sag.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>{% endfor %}
</select>
</td>
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
<div class="owner-cell">
@ -938,9 +1139,127 @@
</div>
</div>
<div class="modal fade" id="closeCaseWithoutTimeModal" tabindex="-1" aria-labelledby="closeCaseWithoutTimeTitle" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-danger text-white border-0 py-4">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-exclamation-triangle-fill" style="font-size:3rem;line-height:1"></i>
<div>
<div class="text-uppercase fw-bold small opacity-75 mb-1">Vigtig advarsel</div>
<h2 class="modal-title fw-bold mb-0" id="closeCaseWithoutTimeTitle">Der er ikke registreret tid på sagen</h2>
</div>
</div>
</div>
<div class="modal-body p-4 p-md-5 text-center">
<p class="fs-4 fw-semibold mb-3">Du er ved at lukke sag <span id="closeWithoutTimeCaseNumber"></span> uden tidsregistrering.</p>
<p class="fs-5 text-muted mb-0">Kontrollér, at det er korrekt. Sagen bliver kun lukket, hvis du aktivt bekræfter nedenfor.</p>
</div>
<div class="modal-footer border-0 bg-light p-4 justify-content-center gap-2">
<button type="button" class="btn btn-lg btn-outline-secondary px-4" data-bs-dismiss="modal">
<i class="bi bi-arrow-left me-2"></i>Gå tilbage
</button>
<button type="button" class="btn btn-lg btn-danger px-4" id="confirmCloseCaseWithoutTimeBtn">
<i class="bi bi-check-circle-fill me-2"></i>Ja, luk sagen uden tid
</button>
</div>
</div>
</div>
</div>
<script>
const topAlertCustomerId = {{ current_customer_id if current_customer_id else 'null' }};
function applyStatusSelectTone(control) {
if (!control) return;
const status = String(control.value || '').trim().toLowerCase();
control.classList.remove('status-tone-open', 'status-tone-progress', 'status-tone-waiting', 'status-tone-done');
let tone = 'status-tone-open';
if (['under behandling', 'i gang', 'in progress'].includes(status)) tone = 'status-tone-progress';
else if (['afventer', 'on hold'].includes(status)) tone = 'status-tone-waiting';
else if (['løst', 'lukket', 'afsluttet', 'resolved', 'closed', 'done'].includes(status)) tone = 'status-tone-done';
control.classList.add(tone);
}
document.querySelectorAll('.sag-status-select').forEach(applyStatusSelectTone);
function confirmCloseCaseWithoutTime(caseId, message) {
return new Promise(resolve => {
const modalElement = document.getElementById('closeCaseWithoutTimeModal');
const confirmButton = document.getElementById('confirmCloseCaseWithoutTimeBtn');
const caseNumber = document.getElementById('closeWithoutTimeCaseNumber');
if (!modalElement || !confirmButton || typeof bootstrap === 'undefined') {
resolve(window.confirm(`${message}\n\nVil du lukke sagen uden tidsregistrering?`));
return;
}
if (caseNumber) caseNumber.textContent = `#${caseId}`;
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
let confirmed = false;
const handleConfirm = () => {
confirmed = true;
modal.hide();
};
const handleHidden = () => {
confirmButton.removeEventListener('click', handleConfirm);
modalElement.removeEventListener('hidden.bs.modal', handleHidden);
resolve(confirmed);
};
confirmButton.addEventListener('click', handleConfirm, { once: true });
modalElement.addEventListener('hidden.bs.modal', handleHidden, { once: true });
modal.show();
});
}
async function updateCaseListField(caseId, field, value, control) {
const previous = control?.dataset?.previous ?? '';
if (control) control.disabled = true;
try {
const saveField = async (confirmedWithoutTime = false) => {
const body = { [field]: value === '' ? null : value };
if (confirmedWithoutTime) body.confirm_close_without_time = true;
const response = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
};
let { response, payload } = await saveField();
const detail = payload?.detail;
if (response.status === 409 && detail?.code === 'close_without_time_confirmation_required') {
const confirmed = await confirmCloseCaseWithoutTime(caseId, detail.message);
if (!confirmed) {
if (control) control.value = previous;
if (control && field === 'status') applyStatusSelectTone(control);
return;
}
({ response, payload } = await saveField(true));
}
if (!response.ok) {
const message = typeof payload.detail === 'string' ? payload.detail : payload.detail?.message;
throw new Error(message || 'Ændringen kunne ikke gemmes');
}
if (control) control.dataset.previous = String(value ?? '');
if (control && field === 'status') applyStatusSelectTone(control);
const row = control?.closest('tr');
if (row && field === 'status') row.dataset.status = String(value || '');
if (row && field === 'status') updateOverdueDeadlineMarker(row);
if (row && field === 'ansvarlig_bruger_id') row.dataset.assigneeId = String(value || '');
if (typeof applyFilters === 'function') applyFilters();
} catch (error) {
if (control) control.value = previous;
if (control && field === 'status') applyStatusSelectTone(control);
if (typeof showNotification === 'function') showNotification(error.message, 'error');
else window.alert(error.message);
} finally {
if (control) control.disabled = false;
}
}
function escapeTopAlertHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
@ -1026,11 +1345,35 @@
const allRows = document.querySelectorAll('.tree-row');
let currentSearch = '';
let currentFilter = 'all';
let currentQuickFilter = {{ (current_quick_filter or 'all')|tojson }};
let currentEmployeeId = '';
let currentEmployeeGroupIds = new Set();
let currentTypes = new Set();
let currentAssignees = new Set();
let currentGroups = new Set();
const closedStatuses = new Set({{ (closed_statuses or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'])|tojson }});
function isDeadlinePast(deadlineValue) {
if (!deadlineValue) return false;
const deadline = new Date(deadlineValue);
if (Number.isNaN(deadline.getTime())) return false;
deadline.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
return deadline.getTime() < today.getTime();
}
function updateOverdueDeadlineMarker(row) {
if (!row) return;
const status = String(row.dataset.status || '').trim().toLowerCase();
const deadlineValue = String(row.dataset.deadline || '').trim();
const isOverdue = isDeadlinePast(deadlineValue)
&& !closedStatuses.has(status);
row.classList.toggle('sag-deadline-overdue', isOverdue);
}
document.querySelectorAll('.sag-table tbody tr[data-deadline]').forEach(updateOverdueDeadlineMarker);
const assigneeFilter = document.getElementById('assigneeFilter');
const groupFilter = document.getElementById('groupFilter');
const assigneeFilterList = document.getElementById('assigneeFilterList');
@ -1043,6 +1386,20 @@
function applyFilters() {
const search = currentSearch;
const matchesQuickFilter = (row, isClosed) => {
const assigneeId = String(row.dataset.assigneeId || '').trim();
const groupId = String(row.dataset.groupId || '').trim();
if (currentQuickFilter === 'mine-open') return !isClosed && !!currentEmployeeId && assigneeId === currentEmployeeId;
if (currentQuickFilter === 'my-groups') return !isClosed && currentEmployeeGroupIds.has(groupId);
if (currentQuickFilter === 'unassigned') return !isClosed && !assigneeId;
if (currentQuickFilter === 'closed') return isClosed;
if (currentQuickFilter === 'overdue') {
const deadline = String(row.dataset.deadline || '');
return !isClosed && isDeadlinePast(deadline);
}
return !isClosed;
};
allRows.forEach(row => {
const text = row.textContent.toLowerCase();
const status = String(row.dataset.status || '').toLowerCase();
@ -1051,7 +1408,7 @@
const groupRaw = String(row.dataset.groupId || '').trim();
const assigneeId = assigneeRaw || '__UNASSIGNED__';
const groupId = groupRaw;
const matchesSearch = text.includes(search);
const matchesSearch = search.split(/\s+/).filter(Boolean).every(term => text.includes(term));
const isClosed = closedStatuses.has(status);
const matchesFilter = currentFilter === 'all'
|| (currentFilter === 'åben' && !isClosed)
@ -1060,7 +1417,7 @@
const matchesType = currentTypes.size === 0 || currentTypes.has(type);
const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId);
const matchesGroup = currentGroups.size === 0 || currentGroups.has(groupId);
const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup;
const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup && matchesQuickFilter(row, isClosed);
row.style.display = visible ? '' : 'none';
@ -1075,7 +1432,7 @@
const childGroupRaw = String(child.dataset.groupId || '').trim();
const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__';
const childGroupId = childGroupRaw;
const childMatchesSearch = childText.includes(search);
const childMatchesSearch = search.split(/\s+/).filter(Boolean).every(term => childText.includes(term));
const childIsClosed = closedStatuses.has(childStatus);
const childMatchesFilter = currentFilter === 'all'
|| (currentFilter === 'åben' && !childIsClosed)
@ -1084,7 +1441,7 @@
const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType);
const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId);
const childMatchesGroup = currentGroups.size === 0 || currentGroups.has(childGroupId);
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup;
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup && matchesQuickFilter(child, childIsClosed);
child.style.display = childVisible ? '' : 'none';
});
}
@ -1195,10 +1552,54 @@
if (searchInput) {
searchInput.addEventListener('input', function(e) {
currentSearch = e.target.value.toLowerCase();
currentSearch = e.target.value.trim().toLowerCase();
document.getElementById('clearSearchBtn')?.classList.toggle('d-none', !currentSearch);
applyFilters();
});
}
document.getElementById('clearSearchBtn')?.addEventListener('click', () => {
searchInput.value = '';
currentSearch = '';
document.getElementById('clearSearchBtn')?.classList.add('d-none');
searchInput.focus();
applyFilters();
});
document.addEventListener('keydown', event => {
if (event.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) {
event.preventDefault();
searchInput?.focus();
}
if (event.key === 'Escape' && document.activeElement === searchInput && searchInput.value) {
document.getElementById('clearSearchBtn')?.click();
}
});
document.querySelectorAll('.sag-quick-filter').forEach(button => {
button.addEventListener('click', () => {
const quick = button.dataset.quickFilter || 'all';
const url = new URL(window.location.href);
if (quick === 'all') url.searchParams.delete('quick');
else url.searchParams.set('quick', quick);
url.searchParams.delete('status');
window.location.assign(url.toString());
});
});
async function loadQuickFilterContext() {
try {
const response = await fetch('/api/v1/sag/me/quick-filter-context', { credentials: 'include' });
if (!response.ok) return;
const data = await response.json();
currentEmployeeId = String(data.user_id || '');
currentEmployeeGroupIds = new Set((data.group_ids || []).map(String));
applyFilters();
} catch (error) {
console.error('Kunne ikke hente hurtigfilter-kontekst', error);
}
}
loadQuickFilterContext();
// Filter functionality
const filterPills = document.querySelectorAll('.filter-pill');
@ -1328,6 +1729,121 @@
}
}
const SAG_COLUMN_DEFINITIONS = [
['id', 'SagsID'], ['company', 'Virksomhed'], ['contact', 'Kontakt'],
['description', 'Beskrivelse'], ['type', 'Type'], ['priority', 'Prioritet'],
['status', 'Status'], ['owner', 'Ansvarlig'], ['group', 'Gruppe/Level'],
['next_todo', 'Næste todo'], ['created', 'Oprettet'], ['start', 'Arbejdsstart'],
['deferred', 'Start senest'], ['deadline', 'Deadline']
];
const SAG_DEFAULT_COLUMN_ORDER = SAG_COLUMN_DEFINITIONS.map(([key]) => key);
let sagColumnOrder = [...SAG_DEFAULT_COLUMN_ORDER];
let sagHiddenColumns = new Set();
function normalizeSagColumnOrder(order) {
const allowed = new Set(SAG_DEFAULT_COLUMN_ORDER);
const normalized = Array.from(new Set((Array.isArray(order) ? order : []).filter(key => allowed.has(key))));
SAG_DEFAULT_COLUMN_ORDER.forEach(key => { if (!normalized.includes(key)) normalized.push(key); });
return normalized;
}
function applySagColumnPreferences() {
document.querySelectorAll('.sag-table tr').forEach(row => {
const cells = Array.from(row.children).filter(cell => cell.matches('th,td'));
if (!cells.length) return;
const expandCell = cells.find(cell => cell.dataset.columnKey === 'expand') || cells[0];
if (!expandCell.dataset.columnKey) {
expandCell.dataset.columnKey = 'expand';
cells.slice(1).forEach((cell, index) => {
if (SAG_DEFAULT_COLUMN_ORDER[index]) cell.dataset.columnKey = SAG_DEFAULT_COLUMN_ORDER[index];
});
}
const byKey = new Map(Array.from(row.children).filter(cell => cell.dataset.columnKey).map(cell => [cell.dataset.columnKey, cell]));
if (byKey.get('expand')) row.appendChild(byKey.get('expand'));
sagColumnOrder.forEach(key => {
const cell = byKey.get(key);
if (!cell) return;
cell.style.display = sagHiddenColumns.has(key) ? 'none' : '';
row.appendChild(cell);
});
});
}
function renderSagColumnChooser() {
const host = document.getElementById('sagColumnList');
if (!host) return;
const labels = Object.fromEntries(SAG_COLUMN_DEFINITIONS);
host.innerHTML = sagColumnOrder.map(key => `
<div class="sag-column-item" draggable="true" data-column-key="${key}">
<i class="bi bi-grip-vertical drag-handle"></i>
<input class="form-check-input sag-column-visible" type="checkbox" ${sagHiddenColumns.has(key) ? '' : 'checked'}>
<span>${labels[key]}</span>
</div>`).join('');
}
function applySagColumnPayload(data) {
sagColumnOrder = normalizeSagColumnOrder(data?.column_order);
sagHiddenColumns = new Set((Array.isArray(data?.hidden_columns) ? data.hidden_columns : []).filter(key => SAG_DEFAULT_COLUMN_ORDER.includes(key)));
renderSagColumnChooser();
applySagColumnPreferences();
}
async function saveSagColumnPreferences() {
const button = document.getElementById('saveSagColumnsBtn');
if (button) button.disabled = true;
try {
const response = await fetch('/api/v1/sag/me/list-preferences', {
method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type_filters: getSelectedTypesFromUi(),
column_order: sagColumnOrder,
hidden_columns: Array.from(sagHiddenColumns)
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || 'Kolonnerne kunne ikke gemmes');
applySagColumnPayload(payload);
if (typeof showNotification === 'function') showNotification('Kolonner gemt for din bruger', 'success');
} catch (error) {
if (typeof showNotification === 'function') showNotification(error.message, 'error');
} finally {
if (button) button.disabled = false;
}
}
const sagColumnList = document.getElementById('sagColumnList');
let draggedSagColumn = null;
sagColumnList?.addEventListener('change', event => {
const item = event.target.closest('.sag-column-item');
if (!item || !event.target.matches('.sag-column-visible')) return;
if (event.target.checked) sagHiddenColumns.delete(item.dataset.columnKey);
else sagHiddenColumns.add(item.dataset.columnKey);
applySagColumnPreferences();
});
sagColumnList?.addEventListener('dragstart', event => {
draggedSagColumn = event.target.closest('.sag-column-item')?.dataset.columnKey || null;
event.target.closest('.sag-column-item')?.classList.add('dragging');
});
sagColumnList?.addEventListener('dragend', event => event.target.closest('.sag-column-item')?.classList.remove('dragging'));
sagColumnList?.addEventListener('dragover', event => event.preventDefault());
sagColumnList?.addEventListener('drop', event => {
event.preventDefault();
const targetKey = event.target.closest('.sag-column-item')?.dataset.columnKey;
if (!draggedSagColumn || !targetKey || draggedSagColumn === targetKey) return;
sagColumnOrder = sagColumnOrder.filter(key => key !== draggedSagColumn);
sagColumnOrder.splice(sagColumnOrder.indexOf(targetKey), 0, draggedSagColumn);
draggedSagColumn = null;
renderSagColumnChooser();
applySagColumnPreferences();
});
document.getElementById('resetSagColumnsBtn')?.addEventListener('click', () => {
sagColumnOrder = [...SAG_DEFAULT_COLUMN_ORDER];
sagHiddenColumns = new Set();
renderSagColumnChooser();
applySagColumnPreferences();
});
document.getElementById('saveSagColumnsBtn')?.addEventListener('click', saveSagColumnPreferences);
async function loadTypeFilterPreferences() {
try {
const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' });
@ -1335,6 +1851,7 @@
const data = await res.json();
const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : [];
currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
applySagColumnPayload(data);
applySelectedTypesToUi();
applyFilters();
} catch (err) {
@ -1351,7 +1868,7 @@
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type_filters: selected }),
body: JSON.stringify({ type_filters: selected, column_order: sagColumnOrder, hidden_columns: Array.from(sagHiddenColumns) }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (typeof showNotification === 'function') {

View File

@ -0,0 +1,18 @@
{% extends "shared/frontend/base.html" %}
{% block title %}{{ article.title }} - Viden{% endblock %}
{% block content %}
<div class="container py-4" style="max-width:980px">
<div class="d-flex justify-content-between align-items-center mb-4"><a href="/knowledge" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Vidensdatabase</a><a href="/sag/{{ article.sag_id }}/v3#solution" class="btn btn-outline-primary"><i class="bi bi-folder2-open me-1"></i>Kildesag</a></div>
<article class="card border-0 shadow-sm rounded-4"><div class="card-body p-4 p-lg-5">
<div class="d-flex flex-wrap gap-2 mb-3"><span class="badge text-bg-success">Godkendt</span><span class="badge text-bg-light border">Version {{ article.version_number }}</span><span class="badge text-bg-light border">{{ 'Generel' if article.visibility == 'general' else ('Kundespecifik' if article.visibility == 'customer' else 'Intern') }}</span></div>
<h1 class="display-6 fw-bold">{{ article.title }}</h1><p class="lead text-muted">{{ article.summary or '' }}</p><hr class="my-4">
{% if article.problem %}<section class="mb-4"><h2 class="h5"><i class="bi bi-exclamation-circle text-danger me-2"></i>Problem og symptomer</h2><div style="white-space:pre-wrap">{{ article.problem }}</div></section>{% endif %}
{% if article.root_cause %}<section class="mb-4"><h2 class="h5"><i class="bi bi-diagram-3 text-warning me-2"></i>Årsag</h2><div style="white-space:pre-wrap">{{ article.root_cause }}</div></section>{% endif %}
{% if article.investigation %}<section class="mb-4"><h2 class="h5"><i class="bi bi-search text-info me-2"></i>Undersøgelse</h2><div style="white-space:pre-wrap">{{ article.investigation }}</div></section>{% endif %}
<section class="p-4 rounded-4 bg-success-subtle mb-4"><h2 class="h5 text-success-emphasis"><i class="bi bi-check-circle me-2"></i>Endelig løsning</h2><div style="white-space:pre-wrap">{{ article.solution }}</div></section>
{% if article.workaround %}<section class="mb-4"><h2 class="h5"><i class="bi bi-cone-striped text-primary me-2"></i>Workaround</h2><div style="white-space:pre-wrap">{{ article.workaround }}</div></section>{% endif %}
<div class="d-flex flex-wrap gap-2 mt-4">{% for tag in article.tags or [] %}<span class="badge rounded-pill text-bg-light border">{{ tag }}</span>{% endfor %}{% for product in article.products or [] %}<span class="badge rounded-pill text-bg-primary">{{ product }}</span>{% endfor %}</div>
<hr class="my-4"><div class="small text-muted">Udgivet af {{ article.published_by or 'ukendt' }}{% if article.customer_name %} · Kun {{ article.customer_name }}{% endif %} · Kilde: sag {{ article.sag_id }}</div>
</div></article>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Vidensdatabase - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.kb-hero{background:linear-gradient(135deg,#0f4c75,#2563eb);color:#fff;border-radius:22px;padding:2rem;box-shadow:0 18px 45px rgba(15,76,117,.2)}
.kb-search{border:0;border-radius:14px;min-height:56px;padding-left:3.1rem;box-shadow:0 8px 24px rgba(15,23,42,.14)}
.kb-card{border:1px solid rgba(15,76,117,.12);border-radius:16px;transition:.18s ease;background:var(--bg-card,#fff)}
.kb-card:hover{transform:translateY(-2px);box-shadow:0 12px 28px rgba(15,76,117,.12);border-color:rgba(37,99,235,.3)}
.kb-card a{text-decoration:none;color:inherit}.kb-tag{background:rgba(37,99,235,.09);color:#1d4ed8;border-radius:999px;padding:.25rem .6rem;font-size:.75rem}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4 px-4">
<section class="kb-hero mb-4">
<div class="d-flex flex-wrap justify-content-between gap-3 align-items-start">
<div><div class="small text-uppercase opacity-75 fw-semibold">BMC Viden</div><h1 class="h2 fw-bold mb-2">Find en gennemprøvet løsning</h1><p class="mb-0 opacity-75">Kun godkendte og udgivne løsninger vises her.</p></div>
<div class="d-flex gap-2"><a href="/solutions" class="btn btn-light"><i class="bi bi-sliders me-1"></i>Administrer løsninger</a><a href="/sag" class="btn btn-outline-light"><i class="bi bi-arrow-left me-1"></i>Sager</a></div>
</div>
<div class="position-relative mt-4"><i class="bi bi-search position-absolute top-50 translate-middle-y ms-3 fs-5 text-secondary"></i><input id="kbSearch" class="form-control kb-search" type="search" placeholder="Søg efter problem, fejlbesked, produkt eller løsning…" autocomplete="off"></div>
</section>
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h5 mb-0">Vidensartikler</h2><span id="kbCount" class="badge rounded-pill text-bg-light border">Henter…</span></div>
<div id="kbState" class="text-center text-muted py-5"><div class="spinner-border spinner-border-sm me-2"></div>Henter viden…</div>
<div id="kbResults" class="row g-3"></div>
</div>
<script>
(() => {
const input=document.getElementById('kbSearch'), results=document.getElementById('kbResults'), state=document.getElementById('kbState'), count=document.getElementById('kbCount'); let timer, controller;
const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
async function load(){ controller?.abort(); controller=new AbortController(); const q=input.value.trim(); state.classList.remove('d-none'); state.innerHTML='<div class="spinner-border spinner-border-sm me-2"></div>Søger…'; results.innerHTML='';
try{const r=await fetch(`/api/v1/knowledge/articles?q=${encodeURIComponent(q)}`,{signal:controller.signal,credentials:'include'});if(!r.ok)throw new Error('Kunne ikke hente vidensartikler');const d=await r.json();count.textContent=`${d.total} artikler`;state.classList.toggle('d-none',d.items.length>0);if(!d.items.length)state.innerHTML='<i class="bi bi-journal-x fs-1 d-block mb-2"></i>Ingen godkendte artikler matcher søgningen.';
results.innerHTML=d.items.map(a=>`<div class="col-xl-4 col-md-6"><article class="kb-card h-100 p-4"><a href="/knowledge/${a.id}"><div class="d-flex justify-content-between gap-2 mb-2"><span class="small text-uppercase text-primary fw-semibold">${a.visibility==='general'?'Generel':a.visibility==='customer'?'Kundespecifik':'Intern'}</span><span class="small text-muted">v${a.version_number}</span></div><h3 class="h5 fw-bold">${esc(a.title)}</h3><p class="text-muted mb-3">${esc(a.summary||'Ingen kort beskrivelse')}</p><div class="d-flex flex-wrap gap-1">${(a.tags||[]).slice(0,5).map(t=>`<span class="kb-tag">${esc(t)}</span>`).join('')}</div><div class="small text-muted mt-3"><i class="bi bi-folder2-open me-1"></i>Sag ${a.sag_id}${a.customer_name?' · '+esc(a.customer_name):''}</div></a></article></div>`).join('');
}catch(e){if(e.name==='AbortError')return;state.classList.remove('d-none');state.innerHTML=`<i class="bi bi-exclamation-triangle text-danger fs-2 d-block"></i>${esc(e.message)}`;count.textContent='Fejl';}}
input.addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(load,280)});load();
})();
</script>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Indkøbsoversigt{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-4"><div><div class="text-uppercase small text-primary fw-bold">Drift · indkøb</div><h1 class="h3 mb-1">Varekø og levering</h1><p class="text-muted mb-0">Alt, der mangler at blive bestilt, modtaget eller sendt til kunden.</p></div><button class="btn btn-outline-primary" onclick="loadProcurement()"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button></div>
<div class="row g-3 mb-4" id="procurementKpis"></div>
<div class="card border-0 shadow-sm rounded-4"><div class="card-body p-0"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead class="table-light"><tr><th class="ps-4">Status</th><th>Vare</th><th>Sag / kunde</th><th>Antal</th><th>Indkøb</th><th>Salgsordre</th><th class="pe-4"></th></tr></thead><tbody id="procurementRows"><tr><td colspan="7" class="text-center py-5 text-muted">Henter indkøbskø…</td></tr></tbody></table></div></div></div>
</div>
<script>
const stateLabel={to_order:['Skal bestilles','danger'],ordered:['Bestilt','warning'],received:['Modtaget','success']};
const money=v=>new Intl.NumberFormat('da-DK',{style:'currency',currency:'DKK'}).format(Number(v||0));
async function loadProcurement(){const res=await fetch('/api/v1/procurement/overview');const data=await res.json();const c=data.counts||{};document.getElementById('procurementKpis').innerHTML=[['Skal bestilles',c.to_order,'danger','cart-plus'],['Bestilt',c.ordered,'warning','truck'],['Modtaget',c.received,'success','box-seam'],['Mangler salgsordre',c.missing_sales_order,'secondary','receipt']].map(([l,n,col,i])=>`<div class="col-sm-6 col-xl-3"><div class="card border-0 shadow-sm rounded-4"><div class="card-body d-flex justify-content-between"><div><div class="small text-muted">${l}</div><div class="display-6 fw-bold text-${col}">${n||0}</div></div><i class="bi bi-${i} fs-3 text-${col} opacity-75"></i></div></div></div>`).join('');document.getElementById('procurementRows').innerHTML=(data.items||[]).map(x=>{const s=stateLabel[x.fulfilment_state]||stateLabel.to_order;return `<tr><td class="ps-4"><span class="badge text-bg-${s[1]}">${s[0]}</span></td><td><strong>${x.description||'Uden beskrivelse'}</strong><div class="small text-muted">${x.external_ref||'Ingen reference'}</div></td><td><a href="/sag/${x.sag_id}/v3">#${x.sag_id} · ${x.case_title||''}</a><div class="small text-muted">${x.customer_name||'Ingen kunde'}</div></td><td>${x.quantity||'—'} ${x.unit||''}</td><td>${money(x.amount)}</td><td>${x.has_sales_line?'<span class="text-success"><i class="bi bi-check-circle me-1"></i>Oprettet</span>':'<span class="text-danger"><i class="bi bi-exclamation-circle me-1"></i>Mangler</span>'}</td><td class="pe-4"><a class="btn btn-sm btn-outline-primary" href="/sag/${x.sag_id}/v3?tab=sales">Åbn sag</a></td></tr>`}).join('')||'<tr><td colspan="7" class="text-center py-5 text-success">Ingen åbne indkøbslinjer.</td></tr>'}
loadProcurement();
</script>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Automatiske opgavelister{% endblock %}
{% block content %}
<div class="container py-4 py-lg-5 reminder-rules-page">
<section class="rule-hero mb-4"><div><div class="eyebrow hero-eyebrow">SUPPORT · AUTOMATISERING</div><h1>Automatiske opgavelister</h1><p>Få dine åbne sager og valgfrit gruppens samlet i en kort, klikbar besked på de tidspunkter, der passer din arbejdsdag.</p></div><div class="hero-icon"></div></section>
<div class="card rule-card border-0 shadow-sm"><div class="card-body p-4 p-lg-5"><div class="d-flex align-items-start gap-3 mb-4"><div class="section-icon"></div><div><h2 class="h4 mb-1">Ny regel</h2><p class="text-muted mb-0">Reglen kører automatisk; brug <strong>Send nu</strong> for at teste den med det samme.</p></div></div><div class="row g-4"><div class="col-lg-6"><label class="form-label fw-semibold" for="ruleTitle">Navn på listen</label><input id="ruleTitle" class="form-control form-control-lg" value="Min opgaveliste"><div class="form-text">Kun til din egen oversigt.</div></div><div class="col-lg-6"><label class="form-label fw-semibold" for="ruleTimes">Tidspunkter</label><input id="ruleTimes" class="form-control form-control-lg" value="09:00, 12:00, 14:00" placeholder="09:00, 12:00"><div class="form-text">Skriv flere tider adskilt med komma, fx 09:00, 12:00, 14:00.</div></div><div class="col-12"><div class="rule-options"><label class="form-check option"><input id="ruleGroups" class="form-check-input" type="checkbox" checked><span><strong>Medtag mine gruppers sager</strong><small>Viser også åbne sager, som er tildelt dine grupper.</small></span></label><label class="form-check option"><input id="ruleMattermost" class="form-check-input" type="checkbox" checked><span><strong>Send til Mattermost</strong><small>Hver sag i beskeden får et direkte link til Hub.</small></span></label></div></div><div class="col-12 d-flex justify-content-end"><button id="saveButton" class="btn btn-primary px-4 py-2" onclick="saveRule()">Gem regel</button></div></div></div></div>
<section class="mt-5"><div class="mb-3"><div class="eyebrow">DINE REGLER</div><h2 class="h4 mb-0">Planlagte opgavelister</h2></div><div id="rules"></div></section>
</div>
<style>.reminder-rules-page{max-width:1180px}.rule-hero{background:linear-gradient(120deg,#0b3e68,#126d91 65%,#18a6a6);color:#fff;border-radius:24px;padding:34px 40px;display:flex;justify-content:space-between;align-items:center}.rule-hero h1{font-size:clamp(1.7rem,3vw,2.4rem);margin:.35rem 0 .6rem;font-weight:750}.rule-hero p{margin:0;max-width:680px;color:#d8edf5;font-size:1.05rem}.eyebrow{font-size:.73rem;letter-spacing:.11em;font-weight:800;color:#5a7890}.hero-eyebrow{color:#a9edf0}.hero-icon{width:74px;height:74px;border-radius:50%;display:grid;place-items:center;font-size:2.4rem;font-weight:700;background:#ffffff22;border:1px solid #ffffff40}.rule-card{border-radius:22px}.section-icon{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;background:#e6f4fa;color:#0c6895;font-size:1.35rem}.rule-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.option{margin:0;border:1px solid #dce8ef;border-radius:14px;padding:16px 17px 16px 40px;background:#fbfdff}.option .form-check-input{margin-left:-24px;margin-top:.35rem}.option small{display:block;color:#6a7c89;margin-top:3px}.rule-entry{border:1px solid #dde8ef;border-radius:17px;background:#fff;padding:18px 20px;display:flex;align-items:center;justify-content:space-between;gap:16px}.rule-time{display:inline-flex;border-radius:999px;padding:5px 10px;background:#edf6fa;color:#155c82;font-size:.86rem;font-weight:650}.empty-rules{border:1px dashed #bfd2df;border-radius:16px;padding:28px;color:#647684;text-align:center;background:#fcfeff}@media(max-width:700px){.rule-hero{padding:27px}.hero-icon{display:none}.rule-options{grid-template-columns:1fr}.rule-entry{align-items:flex-start;flex-direction:column}}</style>
<script>
const rulesEl=document.getElementById('rules');
const escapeHtml=value=>String(value||'').replace(/[&<>'"]/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[char]));
async function responseMessage(response,fallback){const body=await response.json().catch(()=>({}));return body.detail||body.message||fallback}
async function load(){const response=await fetch('/api/v1/reminder-task-rules');if(!response.ok){rulesEl.innerHTML=`<div class="alert alert-danger">${escapeHtml(await responseMessage(response,'Kunne ikke hente regler'))}</div>`;return}const list=await response.json();rulesEl.innerHTML=list.map(rule=>`<article class="rule-entry"><div><div class="fw-bold fs-5">${escapeHtml(rule.title)}</div><div class="mt-2">${(rule.times_json||[]).map(time=>`<span class="rule-time me-1">${escapeHtml(time)}</span>`).join('')} ${rule.include_groups?'<span class="text-muted small ms-1">· Mine grupper er med</span>':''}</div></div><button class="btn btn-outline-primary" onclick="sendRule(${rule.id},this)">Send nu ↗</button></article>`).join('')||'<div class="empty-rules">Ingen regler endnu. Opret den første ovenfor, og test den derefter med <strong>Send nu</strong>.</div>'}
async function saveRule(){const button=document.getElementById('saveButton');const times=document.getElementById('ruleTimes').value.split(',').map(value=>value.trim()).filter(Boolean);if(!times.length)return alert('Vælg mindst ét tidspunkt');button.disabled=true;button.textContent='Gemmer…';try{const response=await fetch('/api/v1/reminder-task-rules',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:document.getElementById('ruleTitle').value,times,include_groups:document.getElementById('ruleGroups').checked,notify_mattermost:document.getElementById('ruleMattermost').checked})});if(!response.ok)throw new Error(await responseMessage(response,'Kunne ikke gemme'));await load()}catch(error){alert(error.message)}finally{button.disabled=false;button.textContent='Gem regel'}}
async function sendRule(id,button){const original=button.innerHTML;button.disabled=true;button.textContent='Sender…';try{const response=await fetch(`/api/v1/reminder-task-rules/${id}/send-now`,{method:'POST'});const result=await response.json().catch(()=>({}));if(!response.ok||!result.sent)throw new Error(result.detail||result.message||'Kunne ikke sende');alert(`Opgavelisten er sendt med ${result.count} sager.`)}catch(error){alert(error.message)}finally{button.disabled=false;button.innerHTML=original}}
load();
</script>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Løsninger - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.sol-hero{background:linear-gradient(125deg,#12263f,#0f4c75 55%,#2563eb);color:#fff;border-radius:22px;padding:1.6rem 1.8rem;box-shadow:0 16px 40px rgba(15,76,117,.2)}
.sol-toolbar,.sol-card{background:var(--bg-card,#fff);border:1px solid rgba(15,76,117,.12);border-radius:16px}
.sol-card{transition:.16s ease}.sol-card:hover{border-color:rgba(37,99,235,.3);box-shadow:0 10px 24px rgba(15,76,117,.09)}
.sol-summary{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.sol-filter.active{background:#0f4c75;color:#fff;border-color:#0f4c75}.sol-chip{border-radius:999px;padding:.25rem .65rem;font-size:.75rem;font-weight:600}
.offcanvas.sol-editor{width:min(760px,100vw)}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<section class="sol-hero mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
<div><div class="small text-uppercase fw-semibold opacity-75">Sager · Viden</div><h1 class="h2 fw-bold mb-1">Løsninger</h1><p class="mb-0 opacity-75">Rediger, kvalitetssikr og udgiv sagernes løsninger.</p></div>
<div class="d-flex gap-2"><a href="/knowledge" class="btn btn-light"><i class="bi bi-journal-richtext me-1"></i>Vidensdatabase</a><a href="/sag" class="btn btn-outline-light"><i class="bi bi-folder2 me-1"></i>Sager</a></div>
</section>
<section class="sol-toolbar p-3 mb-4">
<div class="row g-3 align-items-center"><div class="col-xl-5"><div class="input-group"><span class="input-group-text bg-transparent"><i class="bi bi-search"></i></span><input id="solSearch" class="form-control" type="search" placeholder="Søg i titel, problem, løsning, sag eller kunde…"></div></div>
<div class="col-xl-7 d-flex flex-wrap gap-2 justify-content-xl-end"><button class="btn btn-sm btn-outline-secondary sol-filter active" data-status="">Alle</button><button class="btn btn-sm btn-outline-secondary sol-filter" data-status="draft">Kladder</button><button class="btn btn-sm btn-outline-secondary sol-filter" data-status="pending">Afventer</button><button class="btn btn-sm btn-outline-secondary sol-filter" data-status="approved">Godkendte</button><button id="archivedToggle" class="btn btn-sm btn-outline-danger"><i class="bi bi-archive me-1"></i>Arkiverede</button></div></div>
</section>
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h5 mb-0" id="solHeading">Aktive løsninger</h2><span class="badge rounded-pill text-bg-light border" id="solCount">Henter…</span></div>
<div id="solState" class="text-center text-muted py-5"><div class="spinner-border spinner-border-sm me-2"></div>Henter løsninger…</div><div id="solGrid" class="row g-3"></div>
</div>
<div class="offcanvas offcanvas-end sol-editor" tabindex="-1" id="solutionEditor"><div class="offcanvas-header border-bottom"><div><div class="small text-uppercase text-primary fw-semibold">Løsning</div><h2 class="offcanvas-title h4 mb-0" id="editorHeading">Rediger løsning</h2></div><button class="btn-close" data-bs-dismiss="offcanvas"></button></div><div class="offcanvas-body">
<input type="hidden" id="editSagId"><div class="mb-3"><label class="form-label">Titel *</label><input class="form-control" id="editTitle"></div>
<div class="row g-3 mb-3"><div class="col-md-4"><label class="form-label">Type</label><select class="form-select" id="editType"><option>Support</option><option>Drift</option><option>Konsulent</option><option>Infrastruktur</option><option>Ekstern</option></select></div><div class="col-md-4"><label class="form-label">Resultat</label><select class="form-select" id="editResult"><option>Løst</option><option>Delvist</option><option>Workaround</option><option>Ej løst</option></select></div><div class="col-md-4"><label class="form-label">Synlighed</label><select class="form-select" id="editVisibility"><option value="internal">Kun internt</option><option value="general">Generel viden</option><option value="customer">Kun denne kunde</option></select></div></div>
<div class="mb-3"><label class="form-label">Problem og symptomer</label><textarea class="form-control" id="editProblem" rows="3"></textarea></div>
<div class="row g-3 mb-3"><div class="col-md-6"><label class="form-label">Årsag</label><textarea class="form-control" id="editCause" rows="3"></textarea></div><div class="col-md-6"><label class="form-label">Undersøgelse</label><textarea class="form-control" id="editInvestigation" rows="3"></textarea></div></div>
<div class="mb-3"><label class="form-label">Endelig løsning *</label><textarea class="form-control" id="editDescription" rows="5"></textarea></div><div class="mb-3"><label class="form-label">Workaround</label><textarea class="form-control" id="editWorkaround" rows="2"></textarea></div>
<div class="row g-3 mb-3"><div class="col-md-6"><label class="form-label">Tags</label><input class="form-control" id="editTags"></div><div class="col-md-6"><label class="form-label">Produkter/systemer</label><input class="form-control" id="editProducts"></div></div><div class="mb-4"><label class="form-label">Ændringsnote</label><input class="form-control" id="editNote" placeholder="Hvad er ændret?"></div>
<div class="d-flex justify-content-between gap-2 border-top pt-3"><a id="editorCaseLink" class="btn btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag</a><button class="btn btn-primary" onclick="saveManagedSolution()"><i class="bi bi-check2 me-1"></i>Gem ændringer</button></div>
</div></div>
<div class="modal fade" id="archiveSolutionModal" tabindex="-1" data-bs-backdrop="static"><div class="modal-dialog modal-dialog-centered"><div class="modal-content border-0 shadow"><div class="modal-body p-4"><div class="d-flex gap-3"><div class="fs-2 text-danger"><i class="bi bi-archive"></i></div><div><h2 class="h4">Arkivér løsningen?</h2><p class="text-muted mb-2">Løsningen fjernes fra sagen og en udgivet vidensartikel skjules. Historik og data bevares.</p><div class="fw-semibold" id="archiveSolutionName"></div></div></div></div><div class="modal-footer border-0 pt-0"><button class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-danger" id="archiveConfirmBtn">Ja, arkivér</button></div></div></div></div>
<script>
(() => {
const state={items:new Map(),status:'',archived:false,timer:null,archiveId:null}; const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const labels={draft:'Kladde',pending:'Afventer godkendelse',approved:'Godkendt',outdated:'Forældet',rejected:'Afvist'};
async function api(url,options={}){const r=await fetch(url,{credentials:'include',...options});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(typeof e.detail==='string'?e.detail:'Handlingen mislykkedes');}return r.json();}
async function load(){const box=document.getElementById('solState'),grid=document.getElementById('solGrid'),q=document.getElementById('solSearch').value.trim();box.classList.remove('d-none');box.innerHTML='<div class="spinner-border spinner-border-sm me-2"></div>Henter…';grid.innerHTML='';
try{const d=await api(`/api/v1/solutions?q=${encodeURIComponent(q)}&approval_status=${encodeURIComponent(state.status)}&include_archived=${state.archived}`);state.items=new Map(d.items.map(x=>[x.id,x]));document.getElementById('solCount').textContent=`${d.total} løsninger`;document.getElementById('solHeading').textContent=state.archived?'Arkiverede løsninger':'Aktive løsninger';box.classList.toggle('d-none',d.items.length>0);if(!d.items.length)box.innerHTML='<i class="bi bi-lightbulb fs-1 d-block mb-2"></i>Ingen løsninger matcher.';
grid.innerHTML=d.items.map(s=>`<div class="col-xxl-4 col-lg-6"><article class="sol-card h-100 p-4"><div class="d-flex justify-content-between gap-2 mb-2"><div class="d-flex gap-1"><span class="sol-chip ${s.approval_status==='approved'?'bg-success-subtle text-success-emphasis':s.approval_status==='pending'?'bg-warning-subtle text-warning-emphasis':'bg-secondary-subtle text-secondary-emphasis'}">${labels[s.approval_status]||esc(s.approval_status)}</span><span class="sol-chip bg-light border">${s.visibility==='general'?'Generel':s.visibility==='customer'?'Kunde':'Intern'}</span></div><span class="small text-muted">#${s.sag_id}</span></div><h3 class="h5 fw-bold">${esc(s.title)}</h3><p class="sol-summary text-muted">${esc(s.problem||s.description||'Ingen beskrivelse')}</p><div class="small text-muted mb-3">${esc(s.customer_name||'Ingen kunde')} · ${esc(s.updated_by||s.created_by||'Ukendt')}</div><div class="d-flex flex-wrap gap-2"><a class="btn btn-sm btn-outline-secondary" href="/sag/${s.sag_id}/v3#solution">Sag</a>${state.archived?`<button class="btn btn-sm btn-success" onclick="restoreManagedSolution(${s.id})"><i class="bi bi-arrow-counterclockwise me-1"></i>Gendan</button>`:`<button class="btn btn-sm btn-outline-primary" onclick="openManagedSolution(${s.id})"><i class="bi bi-pencil me-1"></i>Rediger</button>${s.approval_status==='pending'?`<button class="btn btn-sm btn-success" onclick="solutionAction(${s.sag_id},'approve')">Godkend</button>`:''}${s.approval_status==='approved'?`<button class="btn btn-sm btn-primary" onclick="publishManagedSolution(${s.sag_id})">Udgiv</button>`:''}<button class="btn btn-sm btn-outline-danger ms-auto" onclick="askArchiveSolution(${s.id})" title="Arkivér"><i class="bi bi-archive"></i></button>`}</div></article></div>`).join('');
}catch(e){box.classList.remove('d-none');box.innerHTML=`<i class="bi bi-exclamation-triangle text-danger d-block fs-2"></i>${esc(e.message)}`;}}
window.openManagedSolution=id=>{const s=state.items.get(id);if(!s)return;document.getElementById('editSagId').value=s.sag_id;document.getElementById('editorHeading').textContent=s.title;document.getElementById('editTitle').value=s.title||'';document.getElementById('editType').value=s.solution_type||'Support';document.getElementById('editResult').value=s.result||'Løst';document.getElementById('editVisibility').value=s.visibility||'internal';document.getElementById('editProblem').value=s.problem||'';document.getElementById('editCause').value=s.root_cause||'';document.getElementById('editInvestigation').value=s.investigation||'';document.getElementById('editDescription').value=s.description||'';document.getElementById('editWorkaround').value=s.workaround||'';document.getElementById('editTags').value=(s.tags||[]).join(', ');document.getElementById('editProducts').value=(s.products||[]).join(', ');document.getElementById('editNote').value='';document.getElementById('editorCaseLink').href=`/sag/${s.sag_id}/v3#solution`;bootstrap.Offcanvas.getOrCreateInstance(document.getElementById('solutionEditor')).show();};
window.saveManagedSolution=async()=>{const id=document.getElementById('editSagId').value,payload={title:document.getElementById('editTitle').value.trim(),solution_type:document.getElementById('editType').value,result:document.getElementById('editResult').value,visibility:document.getElementById('editVisibility').value,problem:document.getElementById('editProblem').value.trim(),root_cause:document.getElementById('editCause').value.trim(),investigation:document.getElementById('editInvestigation').value.trim(),description:document.getElementById('editDescription').value.trim(),workaround:document.getElementById('editWorkaround').value.trim(),tags:document.getElementById('editTags').value.split(',').map(x=>x.trim()).filter(Boolean),products:document.getElementById('editProducts').value.split(',').map(x=>x.trim()).filter(Boolean),change_note:document.getElementById('editNote').value.trim()||'Redigeret fra Løsninger'};if(!payload.title||!payload.description){alert('Titel og endelig løsning skal udfyldes');return;}try{await api(`/api/v1/sag/${id}/solution`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});bootstrap.Offcanvas.getInstance(document.getElementById('solutionEditor'))?.hide();load();}catch(e){alert(e.message);}};
window.solutionAction=async(sag,action)=>{try{await api(`/api/v1/sag/${sag}/solution/workflow`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action})});load();}catch(e){alert(e.message);}};
window.publishManagedSolution=async sag=>{try{const a=await api(`/api/v1/sag/${sag}/solution/publish`,{method:'POST'});location.href=`/knowledge/${a.id}`;}catch(e){alert(e.message);}};
window.askArchiveSolution=id=>{state.archiveId=id;document.getElementById('archiveSolutionName').textContent=state.items.get(id)?.title||'';bootstrap.Modal.getOrCreateInstance(document.getElementById('archiveSolutionModal')).show();};
document.getElementById('archiveConfirmBtn').addEventListener('click',async()=>{try{await api(`/api/v1/solutions/${state.archiveId}`,{method:'DELETE'});bootstrap.Modal.getInstance(document.getElementById('archiveSolutionModal'))?.hide();load();}catch(e){alert(e.message);}});
window.restoreManagedSolution=async id=>{try{await api(`/api/v1/solutions/${id}/restore`,{method:'POST'});load();}catch(e){alert(e.message);}};
document.querySelectorAll('.sol-filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.sol-filter').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.status=b.dataset.status;load();}));document.getElementById('archivedToggle').addEventListener('click',e=>{state.archived=!state.archived;e.currentTarget.classList.toggle('btn-danger',state.archived);e.currentTarget.classList.toggle('btn-outline-danger',!state.archived);load();});document.getElementById('solSearch').addEventListener('input',()=>{clearTimeout(state.timer);state.timer=setTimeout(load,280)});load();
})();
</script>
{% endblock %}

View File

@ -145,7 +145,7 @@
<input type="date" class="form-control" id="ordersDateTo">
</div>
<div class="col-md-2">
<button class="btn btn-primary w-100" onclick="loadOrders()"><i class="bi bi-search me-1"></i>Filtrér</button>
<button class="btn btn-outline-secondary w-100" onclick="resetOrderFilters()"><i class="bi bi-x-lg me-1"></i>Ryd filtre</button>
</div>
<div class="col-md-3 text-end">
<span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span>
@ -358,7 +358,26 @@
document.getElementById('purchaseSubtotal').textContent = formatCurrency(purchaseSum);
}
let ordersFilterTimer = null;
function resetOrderFilters() {
['ordersSearch', 'ordersStatus', 'ordersCaseId', 'ordersCustomerId', 'ordersDateFrom', 'ordersDateTo'].forEach(id => {
const element = document.getElementById(id);
if (element) element.value = '';
});
loadOrders();
}
document.addEventListener('DOMContentLoaded', () => {
['ordersStatus', 'ordersDateFrom', 'ordersDateTo'].forEach(id => {
document.getElementById(id)?.addEventListener('change', loadOrders);
});
['ordersSearch', 'ordersCaseId', 'ordersCustomerId'].forEach(id => {
document.getElementById(id)?.addEventListener('input', () => {
window.clearTimeout(ordersFilterTimer);
ordersFilterTimer = window.setTimeout(loadOrders, 300);
});
});
loadOrders();
});
</script>

View File

@ -136,6 +136,23 @@ class EmailProcessorService:
}
try:
# Do not let a Graph metadata-only attachment turn into a completed
# invoice workflow. This also protects historic rows imported before
# attachment byte retrieval was made reliable.
attachment_state = execute_query(
"""SELECT has_attachments,
EXISTS(SELECT 1 FROM email_attachments WHERE email_id = %s) AS has_saved_attachment
FROM email_messages WHERE id = %s""",
(email_id, email_id),
) if email_id else []
if attachment_state:
state = attachment_state[0]
if state.get('has_attachments') and not state.get('has_saved_attachment'):
await self._set_awaiting_user_action(email_id, reason='missing_attachment_content')
stats['awaiting_user_action'] = True
logger.warning("🛑 Email %s was not processed because its attachment is missing", email_id)
return stats
# Step 2.5: Detect and transcribe audio attachments
# This is done BEFORE classification so the AI can "read" the voice note
if settings.WHISPER_ENABLED:

View File

@ -447,6 +447,11 @@ class EmailService:
)
parsed_email['attachments'] = attachments
parsed_email['attachment_count'] = len(attachments)
# `hasAttachments` is Graph metadata, not a guarantee that
# contentBytes was included in the list response. Keep this
# fact so the processor can never silently mark an invoice as
# completed without its actual file.
parsed_email['attachment_fetch_failed'] = not bool(attachments)
else:
parsed_email['attachments'] = []
@ -891,7 +896,24 @@ class EmailService:
import base64
content = base64.b64decode(content_bytes)
else:
# Graph may omit contentBytes for larger fileAttachment
# objects. Fetch the attachment stream explicitly instead
# of creating an empty, unusable attachment record.
attachment_id = att.get('id')
content = b''
if attachment_id:
value_url = (
f"https://graph.microsoft.com/v1.0/users/{user_email}"
f"/messages/{message_id}/attachments/{attachment_id}/$value"
)
async with session.get(value_url, headers=headers) as value_response:
if value_response.status == 200:
content = await value_response.read()
else:
logger.warning(
"⚠️ Failed to download attachment bytes for %s/%s: %s",
message_id, attachment_id, value_response.status,
)
# Handle missing filenames for audio (FALLBACK)
filename = att.get('name')
@ -905,6 +927,10 @@ class EmailService:
filename = f"audio_attachment{ext}"
logger.info(f"⚠️ Found (Graph) audio attachment without filename. Generated: {filename}")
if not content:
logger.warning("⚠️ Skipping empty Graph attachment %s", filename or attachment_id)
continue
attachments.append({
'filename': filename or 'unknown',
'content': content,
@ -918,6 +944,84 @@ class EmailService:
logger.error(f"❌ Error fetching attachments for message {message_id}: {e}")
return attachments
async def recover_graph_attachments(self, email_id: int) -> Dict[str, Any]:
"""Recover missing attachments for an already imported Graph email.
We store the internet message-id rather than Graph's opaque message id,
so first resolve it through Graph and then download the real bytes.
This is deliberately limited to a single known email; it never creates a
new email record or changes customer allocations.
"""
row = execute_query(
"""SELECT id, message_id, subject, has_attachments
FROM email_messages WHERE id = %s AND deleted_at IS NULL""",
(email_id,),
)
if not row:
return {"success": False, "reason": "Email not found"}
email_row = row[0]
if not email_row.get("has_attachments"):
return {"success": False, "reason": "Email has no declared attachments"}
if not self.use_graph or not self._graph_send_available():
return {"success": False, "reason": "Microsoft Graph is not configured"}
access_token = await self._get_graph_access_token()
if not access_token:
return {"success": False, "reason": "Could not authenticate to Microsoft Graph"}
user_email = self.graph_config["user_email"]
message_id = str(email_row.get("message_id") or "")
if not message_id:
return {"success": False, "reason": "Email has no message id"}
headers = {"Authorization": f"Bearer {access_token}"}
params = {
"$filter": "internetMessageId eq '{}'".format(message_id.replace("'", "''")),
"$select": "id,subject,hasAttachments",
"$top": 2,
}
try:
async with ClientSession() as session:
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages"
async with session.get(url, params=params, headers=headers) as response:
if response.status != 200:
detail = await response.text()
logger.warning("⚠️ Could not resolve Graph email %s: %s %s", email_id, response.status, detail)
return {"success": False, "reason": "Email could not be found in Microsoft Graph"}
matches = (await response.json()).get("value", [])
if not matches:
return {"success": False, "reason": "Email is no longer available in Microsoft Graph"}
graph_message = matches[0]
attachments = await self._fetch_graph_attachments(
user_email, graph_message["id"], access_token, session
)
if not attachments:
return {"success": False, "reason": "Graph returned no downloadable attachments"}
await self._save_attachments(email_id, attachments)
saved = execute_query(
"SELECT COUNT(*) AS count FROM email_attachments WHERE email_id = %s",
(email_id,),
)
saved_count = int((saved[0] if saved else {}).get("count") or 0)
if not saved_count:
return {"success": False, "reason": "Attachment could not be saved"}
execute_update(
"""UPDATE email_messages
SET has_attachments = true, attachment_count = %s,
status = 'new', auto_processed = false, processed_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(saved_count, email_id),
)
return {"success": True, "attachments_recovered": len(attachments), "attachments_saved": saved_count}
except Exception as exc:
logger.exception("❌ Failed recovering Graph attachments for email %s", email_id)
return {"success": False, "reason": str(exc)}
def _decode_header(self, header: str) -> str:
"""Decode email header (handles MIME encoding)"""
@ -1166,6 +1270,20 @@ class EmailService:
# Save attachments if any
if email_data.get('attachments'):
await self._save_attachments(email_id, email_data['attachments'])
# A mail that Graph says has attachments, but where no file could be
# retrieved, must be visible for review and must not enter automatic
# invoice processing as a successful/empty mail.
if email_data.get('has_attachments') and not email_data.get('attachments'):
execute_update(
"""UPDATE email_messages
SET status = 'awaiting_user_action', auto_processed = false,
processed_at = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(email_id,),
)
email_data['attachment_fetch_failed'] = True
logger.warning("⚠️ Email %s has declared attachments but none were saved", email_id)
return email_id
@ -1477,8 +1595,46 @@ class EmailService:
existing = execute_query(check_query, (email_data["message_id"],))
if existing:
logger.info(f"⏭️ Email already exists: {email_data['message_id']}")
return None
# A user may upload the original .eml again specifically to
# restore an attachment that was missing in an old Graph import.
# Treat that as a repair, not as a dead duplicate.
email_id = int(existing[0]["id"])
incoming_attachments = email_data.get("attachments") or []
if not incoming_attachments:
logger.info(f"⏭️ Email already exists: {email_data['message_id']}")
return None
saved_rows = execute_query(
"SELECT filename FROM email_attachments WHERE email_id = %s",
(email_id,),
) or []
saved_names = {str(row.get("filename") or "") for row in saved_rows}
missing_attachments = [
item for item in incoming_attachments
if str(item.get("filename") or "") not in saved_names
]
if missing_attachments:
await self._save_attachments(email_id, missing_attachments)
count_row = execute_query(
"SELECT COUNT(*) AS count FROM email_attachments WHERE email_id = %s",
(email_id,),
) or []
saved_count = int((count_row[0] if count_row else {}).get("count") or 0)
if not saved_count:
logger.warning("⚠️ Existing upload %s still has no saved attachments", email_id)
return None
execute_update(
"""UPDATE email_messages
SET has_attachments = true, attachment_count = %s,
status = 'new', auto_processed = false, processed_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(saved_count, email_id),
)
logger.info("✅ Restored %s attachment(s) on existing email %s", len(missing_attachments), email_id)
return email_id
# Insert email
thread_key = self._derive_thread_key(email_data)

View File

@ -1370,12 +1370,25 @@ class EmailWorkflowService:
steps = workflow['workflow_steps']
steps_completed = 0
step_results = []
has_failed_step = False
try:
# Execute each step
for idx, step in enumerate(steps):
action = step.get('action')
params = step.get('params', {})
# A final "mark as processed" must never conceal a failed
# invoice extraction (or any other earlier workflow failure).
if action == 'mark_as_processed' and has_failed_step:
step_results.append({
'step': idx + 1,
'action': action,
'status': 'skipped',
'result': None,
'error': 'Skipped because an earlier workflow step failed',
})
continue
logger.info(f" ➡️ Step {idx + 1}/{len(steps)}: {action}")
@ -1389,6 +1402,7 @@ class EmailWorkflowService:
})
if step_result['status'] == 'failed':
has_failed_step = True
logger.error(f" ❌ Step failed: {step_result.get('error')}")
# Continue to next step even on failure (configurable later)
else:
@ -1396,37 +1410,49 @@ class EmailWorkflowService:
steps_completed += 1
# Mark execution as completed
# Preserve a real failure in the execution record instead of
# reporting a green workflow with red individual steps.
completed_at = datetime.now()
execution_time_ms = int((completed_at - started_at).total_seconds() * 1000)
execution_status = 'failed' if has_failed_step else 'completed'
execute_update(
"""UPDATE email_workflow_executions
SET status = 'completed', steps_completed = %s,
SET status = %s, steps_completed = %s,
result_json = %s, completed_at = CURRENT_TIMESTAMP,
execution_time_ms = %s
WHERE id = %s""",
(steps_completed, json.dumps(step_results), execution_time_ms, execution_id)
(execution_status, steps_completed, json.dumps(step_results), execution_time_ms, execution_id)
)
# Update workflow statistics
execute_update(
"""UPDATE email_workflows
SET execution_count = execution_count + 1,
success_count = success_count + 1,
success_count = success_count + %s,
failure_count = failure_count + %s,
last_executed_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(workflow_id,)
(0 if has_failed_step else 1, 1 if has_failed_step else 0, workflow_id)
)
if has_failed_step:
execute_update(
"""UPDATE email_messages
SET status = 'awaiting_user_action', auto_processed = false,
processed_at = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(email_id,),
)
logger.info(f"✅ Workflow '{workflow_name}' completed ({execution_time_ms}ms)")
logger.info("%s Workflow '%s' %s (%sms)", "" if not has_failed_step else "⚠️", workflow_name, execution_status, execution_time_ms)
# Log: Workflow execution completed
await email_activity_logger.log_workflow_executed(
email_id=email_id,
workflow_id=workflow_id,
workflow_name=workflow_name,
status='completed',
status=execution_status,
steps_completed=steps_completed,
execution_time_ms=execution_time_ms
)
@ -1435,7 +1461,7 @@ class EmailWorkflowService:
'workflow_id': workflow_id,
'workflow_name': workflow_name,
'execution_id': execution_id,
'status': 'completed',
'status': execution_status,
'steps_completed': steps_completed,
'steps_total': len(steps),
'execution_time_ms': execution_time_ms,
@ -1515,6 +1541,12 @@ class EmailWorkflowService:
}
result = await handler(params, email_data)
if isinstance(result, dict) and result.get('success') is False:
return {
'status': 'failed',
'result': result,
'error': result.get('note') or result.get('reason') or f"Action {action} did not complete",
}
return {
'status': 'success',
'result': result
@ -1817,7 +1849,9 @@ class EmailWorkflowService:
attachments = execute_query(
"""SELECT filename, file_path, size_bytes, content_type
FROM email_attachments
WHERE email_id = %s AND content_type = 'application/pdf'""",
WHERE email_id = %s
AND (LOWER(COALESCE(content_type, '')) = 'application/pdf'
OR LOWER(filename) LIKE '%%.pdf')""",
(email_id,)
)

View File

@ -112,7 +112,22 @@ def billing_date_for_period(
target = period_start - relativedelta(months=max(0, int(lead_months or 0)))
if schedule_type == "interval_anchor":
return target
return resolve_month_date(target.year, target.month, schedule_type, billing_day)
resolved = resolve_month_date(target.year, target.month, schedule_type, billing_day)
# With no billing lead, an arbitrary period start (for example 31 August)
# must never yield an invoice date that has already passed (1 August).
# In that situation the first valid scheduled invoice date is in the next
# calendar month. A positive lead intentionally permits a date before the
# coverage period and is left unchanged.
if int(lead_months or 0) == 0 and resolved < period_start:
following_month = target + relativedelta(months=1)
resolved = resolve_month_date(
following_month.year,
following_month.month,
schedule_type,
billing_day,
)
return resolved
def advance_billing_periods(value: date, interval: str, periods: int = 1) -> date:

View File

@ -7,6 +7,7 @@ import json
import re
import html as html_lib
import aiohttp
import base64
from decimal import Decimal
from typing import List, Dict, Optional, Any
from app.core.config import settings
@ -164,6 +165,40 @@ class VTigerService:
self.last_query_error = {"message": str(e)}
logger.error(f"❌ vTiger query error: {e}")
return []
async def retrieve_file(self, resource_id: str) -> Optional[Dict[str, Any]]:
"""Retrieve the original bytes for a vTiger file resource."""
safe_id = self._sanitize_vtiger_id(resource_id)
if not safe_id or not self.rest_endpoint:
raise ValueError("Ugyldigt vTiger fil-id eller manglende VTIGER_URL")
self.last_query_status = None
self.last_query_error = None
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/files_retrieve",
params={"id": safe_id},
auth=self._get_auth(),
) as response:
self.last_query_status = response.status
data = await response.json(content_type=None)
if response.status != 200 or not data.get("success"):
self.last_query_error = data.get("error") or {"message": f"HTTP {response.status}"}
return None
result = data.get("result") or {}
if isinstance(result, list):
result = result[0] if result else {}
encoded = result.get("filecontents") or ""
try:
content = base64.b64decode(encoded, validate=True)
except Exception as exc:
self.last_query_error = {"message": "Ugyldigt base64-filindhold"}
raise ValueError("vTiger returnerede ugyldigt filindhold") from exc
return {**result, "content": content}
except Exception as exc:
if not self.last_query_error:
self.last_query_error = {"message": str(exc)}
return None
async def get_account_by_id(self, account_id: str) -> Optional[Dict]:
"""

View File

@ -0,0 +1,278 @@
"""Persistent Ollama model benchmark used by the Settings UI."""
from __future__ import annotations
import json
import logging
import re
import time
from typing import Any, Callable
import httpx
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
Check = tuple[str, Callable[[dict], bool]]
def _digits(value: Any) -> str:
return re.sub(r"\D", "", str(value or ""))
def _text(result: dict, key: str) -> str:
return str(result.get(key) or "")
BENCHMARK_TESTS: list[dict] = [
{
"key": "contact_signature", "name": "Mailsignatur → kontakt", "category": "CRM",
"description": "Finder navn, korrekt titel, firma, mobil og e-mail uden at bruge hilsenen som titel.",
"expected": {"name": "Ida Gundersen", "title": "Technical Advisor & Co-owner", "company": "Createx", "mobile": "+45 42 25 59 08", "email": "ida@createx-onstage.com"},
"prompt": """Udtræk kontaktdata fra mailen som JSON med præcis nøglerne name, title, company, mobile, email. Brug null hvis feltet mangler. 'Kind regards' er en hilsen og ikke en titel.\n\nKind regards\nIda Gundersen\nTechnical Advisor & Co-owner\nMobile: +45 42 25 59 08 DK: +45 55 86 05 00\nEmail: ida@createx-onstage.com\nWeb: createx-onstage.com\nCreatex\nStoregade 4C | 4780 Stege, DK""",
"checks": [
("Navn", lambda r: "ida gundersen" in _text(r, "name").lower()),
("Titel", lambda r: "technical advisor" in _text(r, "title").lower() and "kind regards" not in _text(r, "title").lower()),
("Firma", lambda r: "createx" in _text(r, "company").lower()),
("Mobil", lambda r: "42255908" in _digits(r.get("mobile"))),
("E-mail", lambda r: _text(r, "email").lower() == "ida@createx-onstage.com"),
],
},
{
"key": "internet_invoice", "name": "Internetfaktura", "category": "Økonomi",
"description": "Skelner fakturanummer, kredsløbsreference, adresse, IP-ranges og indkøbspris.",
"expected": {"reference": "NKA-027964", "address": "Firskovvej 36", "postal_code": "2800", "city": "Kongens Lyngby", "ip_ranges": ["217.74.219.56/30", "152.115.61.32/27"], "purchase_price_dkk": 2495},
"prompt": """Udtræk linjen som JSON med præcis nøglerne reference, address, postal_code, city, ip_ranges, purchase_price_dkk. ip_ranges er en liste og purchase_price_dkk et tal.\n\nGlobalConnect faktura 3018657\nNKA-027964 | Firskovvej 36 | 2800 Kongens Lyngby | Internet 1 Gbit\nIP: 217.74.219.56/30 og 152.115.61.32/27 | Månedlig kostpris: 2.495,00 kr.""",
"checks": [
("Reference", lambda r: "027964" in _text(r, "reference")),
("Adresse", lambda r: "firskovvej 36" in _text(r, "address").lower()),
("Postnummer", lambda r: str(r.get("postal_code")) == "2800"),
("By", lambda r: "lyngby" in _text(r, "city").lower()),
("IP-ranges", lambda r: set(r.get("ip_ranges") or []) == {"217.74.219.56/30", "152.115.61.32/27"}),
("Indkøbspris", lambda r: abs(float(r.get("purchase_price_dkk")) - 2495) < .01),
],
},
{
"key": "support_solution", "name": "Løsningsforslag", "category": "Support",
"description": "Diagnosticerer DNS uden at påstå, at problemet allerede er løst.",
"expected": {"category": "DNS/netværk", "probable_cause": "Forkert DNS-konfiguration", "diagnostic_steps": ["Kontrollér DNS-adresser", "Test navneopslag", "Sammenlign eller rul ændringen tilbage"], "customer_reply": "Må ikke påstå at fejlen er løst"},
"prompt": """Analysér supportsagen som JSON med nøglerne category, probable_cause, diagnostic_steps, customer_reply. diagnostic_steps er en liste. Påstå ikke at fejlen er løst.\n\nInternet virker via IP-adresser, men websites åbner ikke via navn på alle PC'er. Routeren svarer, og 8.8.8.8 svarer på ping. Fejlen begyndte efter ændring af DNS i morges.""",
"checks": [
("DNS-årsag", lambda r: "dns" in json.dumps(r, ensure_ascii=False).lower()),
("Mindst to trin", lambda r: isinstance(r.get("diagnostic_steps"), list) and len(r["diagnostic_steps"]) >= 2),
("Ingen falsk løsning", lambda r: not any(x in _text(r, "customer_reply").lower() for x in ("løst", "resolved", "fixed"))),
],
},
{
"key": "danish_rewrite", "name": "Dansk omskrivning", "category": "Kommunikation",
"description": "Forbedrer sproget uden at ændre reference, adresse, tider eller leverandør.",
"expected": {"text": "Professionel dansk tekst, som bevarer NKA-027964, Firskovvej 36, 08:15, 10:00 og GlobalConnect uden at opfinde en løsning."},
"prompt": """Omskriv til en kort professionel dansk kundemail. Bevar alle fakta og opfind ikke en løsning. Returnér JSON med nøglen text.\n\nhej vi kan se jeres forbindelse NKA-027964 på Firskovvej 36 har været nede siden kl 08:15. vi undersøger det hos globalconnect og vender tilbage senest kl 10:00.""",
"checks": [
("Reference", lambda r: "NKA-027964" in _text(r, "text")),
("Adresse", lambda r: "Firskovvej 36" in _text(r, "text")),
("Tider", lambda r: "08:15" in _text(r, "text") and "10:00" in _text(r, "text")),
("Leverandør", lambda r: "globalconnect" in _text(r, "text").lower()),
],
},
{
"key": "ticket_triage", "name": "Sagsklassificering", "category": "Support",
"description": "Finder netværkskategori og kritisk prioritet ved driftsstop for mange brugere.",
"expected": {"category": "Netværk", "priority": "Kritisk", "affected_scope": "Alle 34 medarbejdere", "suggested_actions": ["Kontrollér fiber/LOS", "Eskalér forbindelsesfejlen til leverandøren"]},
"prompt": """Klassificér som JSON med nøglerne category, priority, affected_scope, suggested_actions.\n\nAlle 34 medarbejdere mistede internet og telefoni kl. 09:02. Fiberboksen har rødt LOS-lys, og virksomheden kan ikke ekspedere ordrer.""",
"checks": [
("Netværk", lambda r: any(x in _text(r, "category").lower() for x in ("netværk", "network"))),
("Kritisk", lambda r: any(x in _text(r, "priority").lower() for x in ("kritisk", "critical", "urgent"))),
("Omfang", lambda r: "34" in _text(r, "affected_scope") or "alle" in _text(r, "affected_scope").lower()),
("Handlinger", lambda r: isinstance(r.get("suggested_actions"), list) and len(r["suggested_actions"]) >= 2),
],
},
{
"key": "sentiment", "name": "Kundestemning", "category": "CRM",
"description": "Registrerer frustration og høj risiko uden at kalde beskeden positiv.",
"expected": {"sentiment": "Frustreret eller vred", "urgency": "Høj/kritisk", "risk_score": "7-10"},
"prompt": """Analysér tonen som JSON med nøglerne sentiment, urgency, risk_score. risk_score er 0-10.\n\nDet er tredje gang på en uge systemet går ned. Vi mister salg, og hvis det fortsætter, finder vi en anden leverandør. Ring straks.""",
"checks": [
("Frustration", lambda r: any(x in _text(r, "sentiment").lower() for x in ("frustr", "vred", "angry", "negative"))),
("Haster", lambda r: any(x in _text(r, "urgency").lower() for x in ("høj", "high", "krit", "urgent"))),
("Høj risiko", lambda r: 7 <= float(r.get("risk_score")) <= 10),
],
},
{
"key": "meeting_actions", "name": "Møde → opgaver", "category": "Produktivitet",
"description": "Udtrækker ansvarlige og deadlines fra dansk mødetekst.",
"expected": {"tasks": [{"action": "Opdatér firewallen", "owner": "Christian", "deadline": "fredag 4. september"}, {"action": "Send status til kunden", "owner": "Anna", "deadline": "mandag"}], "must_not_include": "Peter/kaffesnak"},
"prompt": """Udtræk kun konkrete opgaver som JSON med nøglen tasks. Hver opgave har action, owner, deadline.\n\nVi talte om printere. Christian opdaterer firewallen fredag 4. september. Anna sender status til kunden mandag. Peter synes kaffen var god.""",
"checks": [
("To opgaver", lambda r: isinstance(r.get("tasks"), list) and len(r["tasks"]) == 2),
("Christian", lambda r: "christian" in json.dumps(r.get("tasks"), ensure_ascii=False).lower()),
("Anna", lambda r: "anna" in json.dumps(r.get("tasks"), ensure_ascii=False).lower()),
("Ingen Peter-opgave", lambda r: "peter" not in json.dumps(r.get("tasks"), ensure_ascii=False).lower()),
],
},
{
"key": "knowledge_anonymize", "name": "Vidensartikel", "category": "Viden",
"description": "Omdanner en løsning til generel viden og fjerner kunde, bruger og offentlig IP.",
"expected": {"title": "Generel DNS-fejlsøgning", "problem": "Navneopslag virker ikke på en klient", "solution_steps": ["Sæt korrekt/automatisk DNS", "Kør ipconfig /flushdns", "Test igen"], "must_not_include": ["Garant", "Bo Rasmussen", "Ida", "217.74.219.58"]},
"prompt": """Lav en kort vidensartikel som JSON med nøglerne title, problem, solution_steps. Fjern alle kundespecifikke oplysninger og persondata.\n\nHos Garant v/ Bo Rasmussen kunne bruger Ida ikke åbne intranettet. DNS på PC 217.74.219.58 pegede forkert. Teknikeren satte automatisk DNS og kørte ipconfig /flushdns; derefter virkede det.""",
"checks": [
("Løsningstrin", lambda r: isinstance(r.get("solution_steps"), list) and len(r["solution_steps"]) >= 2),
("Ingen kunde", lambda r: "garant" not in json.dumps(r, ensure_ascii=False).lower()),
("Ingen person", lambda r: all(x not in json.dumps(r, ensure_ascii=False).lower() for x in ("bo rasmussen", "ida"))),
("Ingen IP", lambda r: "217.74.219.58" not in json.dumps(r, ensure_ascii=False)),
("Teknisk fakta", lambda r: "flushdns" in json.dumps(r, ensure_ascii=False).lower()),
],
},
{
"key": "billing_summary", "name": "Fakturatekst", "category": "Økonomi",
"description": "Opsummerer udført arbejde og medtager ikke intern fejlsøgning, der ikke blev udført.",
"expected": {"text": "Kontrol af MFA, geninstallation af VPN-profil og efterfølgende test med kunden. Firewall må ikke nævnes som udført arbejde."},
"prompt": """Lav professionel dansk fakturatekst som JSON med nøglen text. Beskriv kun udført arbejde.\n\nSag: VPN virkede ikke. 09:10 kontrolleret brugerens MFA. 09:18 geninstalleret VPN-profil. 09:25 testet login sammen med kunden, nu OK. Overvejede firewallændring, men udførte den ikke.""",
"checks": [
("VPN-profil", lambda r: "vpn" in _text(r, "text").lower() and any(x in _text(r, "text").lower() for x in ("geninstall", "installer"))),
("MFA", lambda r: "mfa" in _text(r, "text").lower()),
("Test", lambda r: "test" in _text(r, "text").lower()),
("Ingen firewallændring", lambda r: "firewall" not in _text(r, "text").lower()),
],
},
{
"key": "ip_reference_safety", "name": "IP-reference sikkerhed", "category": "Internet",
"description": "Holder IP-ranges på den eksplicit angivne kredsløbsreference og undgår forkert adressekobling.",
"expected": {"reference": "NKA-027964", "service_address": "Firskovvej 36, 2800 Kongens Lyngby", "ip_ranges": ["217.74.219.56/30", "152.115.61.32/27"], "conflicting_address": "Mileparken 22 (tilhører NKA-024219 og må ikke kobles)"},
"prompt": """Returnér JSON med nøglerne reference, service_address, ip_ranges, conflicting_address. Brug kun eksplicitte relationer.\n\nNKA-027964, serviceadresse Firskovvej 36, 2800 Kongens Lyngby. Tilknyttede ranges: 217.74.219.56/30 og 152.115.61.32/27. Teksten 'Mileparken 22' står i en uvedkommende fakturalinje for NKA-024219 og må ikke kobles hertil.""",
"checks": [
("Reference", lambda r: "027964" in _text(r, "reference")),
("Korrekt adresse", lambda r: "firskovvej" in _text(r, "service_address").lower()),
("Begge ranges", lambda r: set(r.get("ip_ranges") or []) == {"217.74.219.56/30", "152.115.61.32/27"}),
("Konflikt markeret", lambda r: "mileparken" in _text(r, "conflicting_address").lower()),
],
},
]
def public_tests() -> list[dict]:
return [{key: test[key] for key in ("key", "name", "category", "description")} | {"max_score": len(test["checks"])} for test in BENCHMARK_TESTS]
def _parse_json(content: str) -> dict:
cleaned = (content or "").strip()
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.I)
cleaned = re.sub(r"\s*```$", "", cleaned)
value = json.loads(cleaned)
if not isinstance(value, dict):
raise ValueError("Svaret var ikke et JSON-objekt")
return value
async def fetch_models(endpoint: str) -> list[dict]:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get(f"{endpoint.rstrip('/')}/api/tags")
response.raise_for_status()
return [
{
"name": item.get("name"), "size": item.get("size"),
"parameters": (item.get("details") or {}).get("parameter_size"),
"quantization": (item.get("details") or {}).get("quantization_level"),
}
for item in response.json().get("models", []) if item.get("name")
]
async def _run_case(endpoint: str, model: str, test: dict) -> dict:
payload = {
"model": model, "stream": False, "think": False, "format": "json",
"messages": [
{"role": "system", "content": "Returnér kun gyldig JSON og følg det ønskede schema præcist."},
{"role": "user", "content": test["prompt"]},
],
"options": {"temperature": 0, "num_ctx": 8192, "num_predict": 700},
}
started = time.perf_counter()
async with httpx.AsyncClient(timeout=httpx.Timeout(240, connect=15)) as client:
response = await client.post(f"{endpoint.rstrip('/')}/api/chat", json=payload)
response.raise_for_status()
elapsed_ms = round((time.perf_counter() - started) * 1000)
envelope = response.json()
raw = str((envelope.get("message") or {}).get("content") or "")
result = _parse_json(raw)
passed, failed = [], []
for label, check in test["checks"]:
try:
(passed if check(result) else failed).append(label)
except (AttributeError, KeyError, TypeError, ValueError):
failed.append(label)
eval_count = int(envelope.get("eval_count") or 0)
eval_duration = int(envelope.get("eval_duration") or 0)
tokens_per_second = round(eval_count / (eval_duration / 1_000_000_000), 2) if eval_count and eval_duration else None
return {
"score": len(passed), "max_score": len(test["checks"]), "duration_ms": elapsed_ms,
"prompt_tokens": envelope.get("prompt_eval_count"), "response_tokens": eval_count or None,
"tokens_per_second": tokens_per_second, "passed": passed, "failed": failed,
"response_json": result, "response_text": raw, "error": None,
}
async def execute_run(run_id: int, endpoint: str, models: list[str], test_keys: list[str]) -> None:
selected = [test for test in BENCHMARK_TESTS if test["key"] in test_keys]
execute_query("UPDATE ai_benchmark_runs SET status='running', started_at=NOW() WHERE id=%s", (run_id,))
try:
for model in models:
for test in selected:
try:
result = await _run_case(endpoint, model, test)
except Exception as exc:
logger.warning("AI benchmark failed run=%s model=%s test=%s: %s", run_id, model, test["key"], exc)
result = {
"score": 0, "max_score": len(test["checks"]), "duration_ms": 0,
"prompt_tokens": None, "response_tokens": None, "tokens_per_second": None,
"passed": [], "failed": [label for label, _ in test["checks"]],
"response_json": None, "response_text": None, "error": str(exc),
}
execute_query(
"""INSERT INTO ai_benchmark_results
(run_id, model, test_key, test_name, category, score, max_score, duration_ms,
prompt_tokens, response_tokens, tokens_per_second, passed_checks, failed_checks,
response_json, response_text, error_text, expected_json)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s::jsonb,%s,%s,%s::jsonb)
ON CONFLICT (run_id, model, test_key) DO NOTHING""",
(run_id, model, test["key"], test["name"], test["category"], result["score"],
result["max_score"], result["duration_ms"], result["prompt_tokens"],
result["response_tokens"], result["tokens_per_second"], json.dumps(result["passed"]),
json.dumps(result["failed"]), json.dumps(result["response_json"]) if result["response_json"] is not None else None,
result["response_text"], result["error"], json.dumps(test["expected"], ensure_ascii=False)),
)
execute_query("UPDATE ai_benchmark_runs SET completed_cases=completed_cases+1 WHERE id=%s", (run_id,))
execute_query("UPDATE ai_benchmark_runs SET status='completed', completed_at=NOW() WHERE id=%s", (run_id,))
except Exception as exc:
logger.exception("AI benchmark run %s crashed", run_id)
execute_query("UPDATE ai_benchmark_runs SET status='failed', completed_at=NOW(), error_text=%s WHERE id=%s", (str(exc), run_id))
def get_run(run_id: int) -> dict | None:
run = execute_query_single(
"""SELECT r.*, COALESCE(u.full_name, u.username) AS created_by_name
FROM ai_benchmark_runs r LEFT JOIN users u ON u.user_id=r.created_by WHERE r.id=%s""", (run_id,)
)
if not run:
return None
results = execute_query("SELECT * FROM ai_benchmark_results WHERE run_id=%s ORDER BY model, test_key", (run_id,)) or []
expected_by_key = {test["key"]: test["expected"] for test in BENCHMARK_TESTS}
for result in results:
if result.get("expected_json") is None:
result["expected_json"] = expected_by_key.get(result.get("test_key"))
run["results"] = results
return run
def list_runs(limit: int = 30) -> list[dict]:
return execute_query(
"""SELECT r.*, COALESCE(u.full_name, u.username) AS created_by_name,
COALESCE((SELECT jsonb_agg(summary ORDER BY model) FROM (
SELECT model, SUM(score) AS score, SUM(max_score) AS max_score,
SUM(duration_ms) AS duration_ms, ROUND(AVG(tokens_per_second), 2) AS tokens_per_second
FROM ai_benchmark_results br WHERE br.run_id=r.id GROUP BY model
) summary), '[]'::jsonb) AS model_summaries
FROM ai_benchmark_runs r LEFT JOIN users u ON u.user_id=r.created_by
ORDER BY r.created_at DESC LIMIT %s""", (limit,)
) or []

View File

@ -2,11 +2,11 @@
Settings and User Management API Router
"""
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends
from typing import List, Optional, Dict
from pydantic import BaseModel
from datetime import datetime
from app.core.database import execute_query
from app.core.database import execute_query, execute_query_single
from app.core.config import settings
from app.core.auth_dependencies import require_superadmin
from app.core.auth_service import AuthService
@ -70,6 +70,11 @@ class SagTestRunRequest(BaseModel):
customer_id: Optional[int] = None
class AIBenchmarkRunRequest(BaseModel):
models: List[str]
test_keys: Optional[List[str]] = None
MATTERMOST_SETTING_DEFAULTS = (
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
@ -182,6 +187,91 @@ async def run_sag_test(
_sag_test_lock.release()
@router.get("/settings/ai-benchmarks/catalog", tags=["AI Benchmarks"])
async def get_ai_benchmark_catalog(
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import fetch_models, public_tests
try:
models = await fetch_models(settings.OLLAMA_ENDPOINT)
model_error = None
except Exception as exc:
logger.warning("Could not load Ollama models for benchmark: %s", exc)
models = []
model_error = str(exc)
return {
"active_model": settings.OLLAMA_MODEL,
"endpoint": settings.OLLAMA_ENDPOINT,
"models": models,
"tests": public_tests(),
"model_error": model_error,
}
@router.get("/settings/ai-benchmarks/runs", tags=["AI Benchmarks"])
async def get_ai_benchmark_runs(
limit: int = 30,
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import list_runs
return {"items": list_runs(max(1, min(limit, 100)))}
@router.get("/settings/ai-benchmarks/runs/{run_id}", tags=["AI Benchmarks"])
async def get_ai_benchmark_run(
run_id: int,
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import get_run
run = get_run(run_id)
if not run:
raise HTTPException(status_code=404, detail="Testkørslen findes ikke")
return run
@router.post("/settings/ai-benchmarks/runs", tags=["AI Benchmarks"])
async def create_ai_benchmark_run(
payload: AIBenchmarkRunRequest,
background_tasks: BackgroundTasks,
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import BENCHMARK_TESTS, execute_run, fetch_models
models = list(dict.fromkeys(model.strip() for model in payload.models if model.strip()))
if not 1 <= len(models) <= 4:
raise HTTPException(status_code=400, detail="Vælg mellem 1 og 4 modeller")
installed = {item["name"] for item in await fetch_models(settings.OLLAMA_ENDPOINT)}
missing = [model for model in models if model not in installed]
if missing:
raise HTTPException(status_code=400, detail=f"Modellen er ikke installeret: {', '.join(missing)}")
available_keys = {test["key"] for test in BENCHMARK_TESTS}
test_keys = list(dict.fromkeys(payload.test_keys or [test["key"] for test in BENCHMARK_TESTS]))
unknown = [key for key in test_keys if key not in available_keys]
if unknown or not test_keys:
raise HTTPException(status_code=400, detail="Vælg mindst én gyldig test")
running = execute_query_single(
"SELECT id FROM ai_benchmark_runs WHERE status IN ('queued', 'running') ORDER BY id DESC LIMIT 1"
)
if running:
raise HTTPException(status_code=409, detail=f"Testkørsel #{running['id']} kører allerede")
user_id = current_user.get("id") or current_user.get("user_id")
rows = execute_query(
"""INSERT INTO ai_benchmark_runs
(status, models, test_keys, total_cases, completed_cases, created_by)
VALUES ('queued', %s::jsonb, %s::jsonb, %s, 0, %s) RETURNING *""",
(json.dumps(models), json.dumps(test_keys), len(models) * len(test_keys), user_id),
)
run = rows[0]
background_tasks.add_task(execute_run, run["id"], settings.OLLAMA_ENDPOINT, models, test_keys)
return run
# Settings Endpoints
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
async def get_settings(category: Optional[str] = None):
@ -273,7 +363,7 @@ async def get_setting(key: str):
query = "SELECT * FROM settings WHERE key = %s"
result = execute_query(query, (key,))
if not result and key in {"case_types", "case_type_module_defaults", "case_statuses"}:
if not result and key in {"case_types", "case_type_module_defaults", "case_statuses", "time_multiplier_presets"}:
seed_query = """
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
@ -325,6 +415,19 @@ async def get_setting(key: str):
)
)
if key == "time_multiplier_presets":
execute_query(
seed_query,
(
"time_multiplier_presets",
'[{"label":"Haster","text":"Haster","multiplier":3},{"label":"Avanceret netværk","text":"Avanceret netværk","multiplier":2},{"label":"Haster + ava. network","text":"Haster + ava. network","multiplier":6}]',
"system",
"Valgbare multiplikator presets til tidsregistrering",
"json",
True,
),
)
result = execute_query(query, (key,))
if not result and key == "email_default_signature_template":

View File

@ -0,0 +1,30 @@
<div id="case-template-admin">
<h5>Sagsskabeloner</h5>
<p class="text-muted">Fælles hurtigskabeloner til Opret ny sag.</p>
<div id="cta-message" role="status"></div>
<div id="cta-list" class="list-group mb-3"></div>
<button type="button" id="cta-new" class="btn btn-outline-primary mb-3">Ny skabelon</button>
<form id="cta-form" class="card card-body">
<input id="cta-id" type="hidden">
<div class="row g-3">
<div class="col-md-6"><label for="cta-name" class="form-label">Navn</label><input id="cta-name" class="form-control" required maxlength="120"></div>
<div class="col-md-3"><label for="cta-icon" class="form-label">Ikon</label><select id="cta-icon" class="form-select"><option value="bi-lightning">Lyn</option><option value="bi-ticket">Ticket</option><option value="bi-router">Netværk</option><option value="bi-person-plus">Medarbejder</option><option value="bi-pc-display">Hardware</option><option value="bi-graph-up">Tilbud</option></select></div>
<div class="col-md-3"><label for="cta-sort" class="form-label">Rækkefølge</label><input id="cta-sort" type="number" step="1" class="form-control" value="0"></div>
<div class="col-md-4"><label for="cta-type" class="form-label">Sagstype</label><select id="cta-type" class="form-select"></select></div>
<div class="col-md-4"><label for="cta-status" class="form-label">Status</label><select id="cta-status" class="form-select"><option value="åben">Åben</option><option value="afventer">Afventer</option><option value="lukket">Lukket</option></select></div>
<div class="col-md-4"><label for="cta-group" class="form-label">Ansvarlig gruppe</label><select id="cta-group" class="form-select"><option value="">Ingen</option></select></div>
<div class="col-12"><label for="cta-title" class="form-label">Titel</label><input id="cta-title" class="form-control" maxlength="1000"></div>
<div class="col-12"><label for="cta-description" class="form-label">Beskrivelse</label><textarea id="cta-description" rows="5" class="form-control"></textarea></div>
<div class="col-12"><div id="cta-tags" class="mb-2"></div><button type="button" id="cta-add-tag" class="btn btn-sm btn-outline-primary">Tilføj tag</button></div>
<div class="col-12"><details><summary>Pipeline-standarder</summary><div class="row g-3 mt-1">
<div class="col-md-4"><label for="cta-stage" class="form-label">Stage</label><select id="cta-stage" class="form-select"><option value="">Ikke sat</option></select></div>
<div class="col-md-4"><label for="cta-amount" class="form-label">Beløb</label><input id="cta-amount" type="number" min="0" step="0.01" class="form-control"></div>
<div class="col-md-4"><label for="cta-probability" class="form-label">Sandsynlighed (%)</label><input id="cta-probability" type="number" min="0" max="100" step="1" class="form-control"></div>
<div class="col-12"><label for="cta-pipeline-description" class="form-label">Pipelinebeskrivelse</label><textarea id="cta-pipeline-description" class="form-control"></textarea></div>
</div></details></div>
<div class="col-12"><label><input id="cta-active" type="checkbox" checked> Aktiv</label></div>
<div class="col-12"><button type="submit" class="btn btn-primary">Gem skabelon</button></div>
</div>
</form>
</div>
<script src="/static/js/case-template-admin.js?v=1"></script>

View File

@ -81,6 +81,62 @@
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.ai-benchmark-hero {
color: #fff;
background: linear-gradient(135deg, #0b3b5a 0%, #0f6b87 55%, #18a0a8 100%);
border: 0;
overflow: hidden;
position: relative;
}
.ai-benchmark-hero::after {
content: '';
position: absolute;
width: 260px;
height: 260px;
border-radius: 50%;
right: -80px;
top: -130px;
background: rgba(255,255,255,.1);
}
.ai-model-option, .ai-test-option {
border: 1px solid var(--border-color, #dde5eb);
border-radius: 14px;
padding: .9rem 1rem;
cursor: pointer;
transition: .18s ease;
background: var(--bs-body-bg, #fff);
height: 100%;
}
.ai-model-option:hover, .ai-test-option:hover,
.ai-model-option:has(input:checked), .ai-test-option:has(input:checked) {
border-color: var(--accent);
box-shadow: 0 8px 24px rgba(15, 76, 117, .1);
transform: translateY(-1px);
}
.ai-benchmark-score {
width: 58px;
height: 58px;
border-radius: 16px;
display: grid;
place-items: center;
font-weight: 800;
background: var(--accent-light);
color: var(--accent);
}
.ai-benchmark-result-table td { vertical-align: middle; }
.ai-benchmark-json { max-height: 320px; overflow: auto; white-space: pre-wrap; font-size: .78rem; }
.ai-benchmark-comparison-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
align-items: stretch;
}
</style>
{% endblock %}
@ -127,12 +183,16 @@
<a class="nav-link" href="#ai-prompts" data-tab="ai-prompts">
<i class="bi bi-robot me-2"></i>AI Prompts
</a>
<a class="nav-link" href="#ai-modeltest" data-tab="ai-modeltest">
<i class="bi bi-speedometer2 me-2"></i>AI Modeltest
</a>
<a class="nav-link" href="#email-templates" data-tab="email-templates">
<i class="bi bi-envelope-paper me-2"></i>Email skabeloner
</a>
<a class="nav-link" href="#task-templates" data-tab="task-templates">
<i class="bi bi-list-check me-2"></i>Opgave-templates
</a>
<a class="nav-link" href="#case-templates" data-tab="case-templates"><i class="bi bi-lightning me-2"></i>Sagsskabeloner</a>
<a class="nav-link" href="/admin/bmc-office-upload">
<i class="bi bi-cloud-upload me-2"></i>BMC Office Import
</a>
@ -616,6 +676,9 @@
</div>
<!-- Task Templates -->
<div class="tab-pane fade" id="case-templates">
{% include "settings/frontend/case_templates.html" %}
</div>
<div class="tab-pane fade" id="task-templates">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
@ -1275,6 +1338,79 @@
</div>
</div>
<!-- AI Model Benchmark -->
<div class="tab-pane fade" id="ai-modeltest">
<div class="card ai-benchmark-hero p-4 mb-4">
<div class="position-relative" style="z-index:1">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
<div>
<div class="text-uppercase small fw-semibold opacity-75 mb-2">Ollama · Kontrolleret A/B-test</div>
<h3 class="fw-bold mb-2">Hvilken model er faktisk bedst til BMC Hub?</h3>
<p class="mb-0 opacity-75">Sammenlign præcision, JSON-stabilitet, hastighed og svar på virkelige CRM-opgaver.</p>
</div>
<div class="text-end">
<div class="small opacity-75">Aktiv model</div>
<div class="fs-5 fw-bold" id="aiBenchmarkActiveModel"></div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-xl-8">
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div><h5 class="fw-bold mb-1">1. Vælg modeller</h5><p class="text-muted small mb-0">Op til fire installerede Ollama-modeller.</p></div>
<button class="btn btn-sm btn-outline-secondary" onclick="loadAIBenchmark()"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button>
</div>
<div class="row g-3" id="aiBenchmarkModels"><div class="text-muted">Henter modeller…</div></div>
</div>
<div class="card p-4 mb-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div><h5 class="fw-bold mb-1">2. Vælg tests</h5><p class="text-muted small mb-0">Alle tests giver objektive point for kendte fakta.</p></div>
<div class="btn-group btn-group-sm"><button class="btn btn-outline-secondary" onclick="toggleAllAIBenchmarkTests(true)">Vælg alle</button><button class="btn btn-outline-secondary" onclick="toggleAllAIBenchmarkTests(false)">Ryd</button></div>
</div>
<div class="row g-3" id="aiBenchmarkTests"></div>
</div>
<div class="card p-4 mb-4 d-none" id="aiBenchmarkRunningCard">
<div class="d-flex justify-content-between align-items-center mb-3">
<div><h5 class="fw-bold mb-1">Testkørsel <span id="aiBenchmarkRunNumber"></span></h5><div class="text-muted small" id="aiBenchmarkRunState">Forbereder…</div></div>
<span class="spinner-border text-primary" id="aiBenchmarkSpinner"></span>
</div>
<div class="progress" style="height:12px"><div class="progress-bar progress-bar-striped progress-bar-animated" id="aiBenchmarkProgress" style="width:0%"></div></div>
<div class="small text-muted mt-2" id="aiBenchmarkProgressText">0 / 0 tests</div>
</div>
<div id="aiBenchmarkResult"></div>
</div>
<div class="col-xl-4">
<div class="card p-4 mb-4">
<h5 class="fw-bold mb-3">Start sammenligning</h5>
<div class="d-flex justify-content-between py-2 border-bottom"><span class="text-muted">Modeller</span><strong id="aiBenchmarkModelCount">0</strong></div>
<div class="d-flex justify-content-between py-2 border-bottom"><span class="text-muted">Tests</span><strong id="aiBenchmarkTestCount">0</strong></div>
<div class="d-flex justify-content-between py-2 mb-3"><span class="text-muted">Modelsvar i alt</span><strong id="aiBenchmarkCaseCount">0</strong></div>
<div class="alert alert-light small"><i class="bi bi-info-circle me-1"></i>Kørslen ændrer ikke den aktive AI-model.</div>
<button class="btn btn-primary btn-lg w-100" id="startAIBenchmarkBtn" onclick="startAIBenchmark()"><i class="bi bi-play-fill me-2"></i>Kør modeltest</button>
<div class="alert alert-primary mt-3 mb-0 d-none" id="aiBenchmarkLiveStatus">
<div class="d-flex align-items-center gap-2">
<span class="spinner-border spinner-border-sm flex-shrink-0" id="aiBenchmarkLiveSpinner"></span>
<div><strong class="d-block" id="aiBenchmarkLiveTitle">Testen starter…</strong><span class="small" id="aiBenchmarkLiveDetail">Forbereder modellerne</span></div>
</div>
</div>
<div class="small text-danger mt-2" id="aiBenchmarkError"></div>
</div>
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-3"><h5 class="fw-bold mb-0">Historik</h5><span class="badge text-bg-light" id="aiBenchmarkHistoryCount">0</span></div>
<div id="aiBenchmarkHistory"><div class="text-muted small">Ingen kørsler endnu.</div></div>
</div>
</div>
</div>
</div>
<!-- Modules Documentation -->
<div class="tab-pane fade" id="modules">
<div class="card p-4">
@ -5062,7 +5198,7 @@ async function loadSagModuleTests() {
const state = document.getElementById('sagTestState');
try {
const response = await fetch('/api/v1/settings/tests/sag', { credentials: 'include' });
if (!response.ok) throw new Error(await extractApiError(response, 'Kunne ikke hente tests'));
if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke hente tests'));
const data = await response.json();
renderSagTestReport(data.latest);
renderSagTestHistory(data.history);
@ -5094,7 +5230,7 @@ async function runSagModuleTest() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (!response.ok) throw new Error(await extractApiError(response, 'Sag-testen fejlede'));
if (!response.ok) throw new Error(await getErrorMessage(response, 'Sag-testen fejlede'));
const report = await response.json();
renderSagTestReport(report);
await loadSagModuleTests();
@ -5109,6 +5245,255 @@ async function runSagModuleTest() {
}
}
let aiBenchmarkCatalog = null;
let aiBenchmarkPollTimer = null;
function aiBenchEscape(value) {
return String(value ?? '').replace(/[&<>'"]/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[ch]));
}
function aiBenchBytes(value) {
const bytes = Number(value || 0);
return bytes ? `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : '';
}
function updateAIBenchmarkSelection() {
const models = document.querySelectorAll('.ai-benchmark-model:checked').length;
const tests = document.querySelectorAll('.ai-benchmark-test:checked').length;
document.getElementById('aiBenchmarkModelCount').textContent = models;
document.getElementById('aiBenchmarkTestCount').textContent = tests;
document.getElementById('aiBenchmarkCaseCount').textContent = models * tests;
const button = document.getElementById('startAIBenchmarkBtn');
if (button) button.disabled = !models || !tests || models > 4;
}
function toggleAllAIBenchmarkTests(checked) {
document.querySelectorAll('.ai-benchmark-test').forEach(input => { input.checked = checked; });
updateAIBenchmarkSelection();
}
async function loadAIBenchmark() {
const error = document.getElementById('aiBenchmarkError');
error.textContent = '';
try {
const [catalogResponse, historyResponse] = await Promise.all([
fetch('/api/v1/settings/ai-benchmarks/catalog', {credentials:'include'}),
fetch('/api/v1/settings/ai-benchmarks/runs?limit=30', {credentials:'include'})
]);
if (!catalogResponse.ok) throw new Error(await getErrorMessage(catalogResponse, 'Kunne ikke hente AI-modeller'));
aiBenchmarkCatalog = await catalogResponse.json();
document.getElementById('aiBenchmarkActiveModel').textContent = aiBenchmarkCatalog.active_model || '';
renderAIBenchmarkModels(aiBenchmarkCatalog.models || []);
renderAIBenchmarkTests(aiBenchmarkCatalog.tests || []);
if (aiBenchmarkCatalog.model_error) error.textContent = `Ollama: ${aiBenchmarkCatalog.model_error}`;
if (historyResponse.ok) renderAIBenchmarkHistory((await historyResponse.json()).items || []);
} catch (exc) {
error.textContent = exc.message || 'Kunne ikke indlæse modeltesten';
}
}
function renderAIBenchmarkModels(models) {
const root = document.getElementById('aiBenchmarkModels');
if (!models.length) {
root.innerHTML = '<div class="col-12"><div class="alert alert-warning mb-0">Ingen installerede Ollama-modeller fundet.</div></div>';
return;
}
const preferred = new Set([aiBenchmarkCatalog?.active_model, 'qwen3.5:9b']);
root.innerHTML = models.map(model => `
<div class="col-md-6">
<label class="ai-model-option d-flex align-items-start gap-3">
<input class="form-check-input mt-1 ai-benchmark-model" type="checkbox" value="${aiBenchEscape(model.name)}" ${preferred.has(model.name) ? 'checked' : ''} onchange="updateAIBenchmarkSelection()">
<span class="min-w-0">
<span class="d-flex align-items-center gap-2 flex-wrap"><strong>${aiBenchEscape(model.name)}</strong>${model.name === aiBenchmarkCatalog?.active_model ? '<span class="badge text-bg-primary">Aktiv</span>' : ''}</span>
<span class="small text-muted">${aiBenchEscape(model.parameters || 'Ukendt størrelse')} · ${aiBenchEscape(model.quantization || '')} · ${aiBenchBytes(model.size)}</span>
</span>
</label>
</div>`).join('');
updateAIBenchmarkSelection();
}
function renderAIBenchmarkTests(tests) {
const colors = {CRM:'primary', Support:'success', 'Økonomi':'warning', Kommunikation:'info', Produktivitet:'secondary', Viden:'dark', Internet:'danger'};
document.getElementById('aiBenchmarkTests').innerHTML = tests.map(test => `
<div class="col-md-6">
<label class="ai-test-option d-flex align-items-start gap-3">
<input class="form-check-input mt-1 ai-benchmark-test" type="checkbox" value="${aiBenchEscape(test.key)}" checked onchange="updateAIBenchmarkSelection()">
<span><span class="d-flex align-items-center gap-2 mb-1"><strong>${aiBenchEscape(test.name)}</strong><span class="badge text-bg-${colors[test.category] || 'light'}">${aiBenchEscape(test.category)}</span></span><span class="small text-muted d-block">${aiBenchEscape(test.description)}</span><span class="small fw-semibold text-primary">${test.max_score} point</span></span>
</label>
</div>`).join('');
updateAIBenchmarkSelection();
}
async function startAIBenchmark() {
const models = [...document.querySelectorAll('.ai-benchmark-model:checked')].map(input => input.value);
const testKeys = [...document.querySelectorAll('.ai-benchmark-test:checked')].map(input => input.value);
const button = document.getElementById('startAIBenchmarkBtn');
const error = document.getElementById('aiBenchmarkError');
error.textContent = '';
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starter…';
showAIBenchmarkProgress({id: '…', status: 'queued', completed_cases: 0, total_cases: models.length * testKeys.length});
try {
const response = await fetch('/api/v1/settings/ai-benchmarks/runs', {
method:'POST', credentials:'include', headers:{'Content-Type':'application/json'},
body: JSON.stringify({models, test_keys:testKeys})
});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke starte testen'));
const run = await response.json();
showAIBenchmarkProgress(run);
pollAIBenchmarkRun(run.id);
} catch (exc) {
error.textContent = exc.message || 'Testen kunne ikke startes';
button.disabled = false;
document.getElementById('aiBenchmarkLiveStatus').classList.add('d-none');
document.getElementById('aiBenchmarkRunningCard').classList.add('d-none');
} finally {
if (!button.disabled) button.innerHTML = '<i class="bi bi-play-fill me-2"></i>Kør modeltest';
}
}
function showAIBenchmarkProgress(run) {
const card = document.getElementById('aiBenchmarkRunningCard');
card.classList.remove('d-none');
document.getElementById('aiBenchmarkRunNumber').textContent = `#${run.id}`;
const complete = Number(run.completed_cases || 0);
const total = Number(run.total_cases || 0);
const pct = total ? Math.round(complete / total * 100) : 0;
document.getElementById('aiBenchmarkProgress').style.width = `${pct}%`;
document.getElementById('aiBenchmarkProgressText').textContent = `${complete} / ${total} modelsvar · ${pct}%`;
document.getElementById('aiBenchmarkRunState').textContent = run.status === 'queued' ? 'Venter på Ollama…' : run.status === 'running' ? 'Tester modellerne én ad gangen…' : run.status;
const finished = ['completed','failed'].includes(run.status);
document.getElementById('aiBenchmarkSpinner').classList.toggle('d-none', finished);
document.getElementById('aiBenchmarkProgress').classList.toggle('progress-bar-animated', !finished);
const live = document.getElementById('aiBenchmarkLiveStatus');
const liveSpinner = document.getElementById('aiBenchmarkLiveSpinner');
const liveTitle = document.getElementById('aiBenchmarkLiveTitle');
const liveDetail = document.getElementById('aiBenchmarkLiveDetail');
const startButton = document.getElementById('startAIBenchmarkBtn');
live.classList.remove('d-none', 'alert-success', 'alert-danger');
live.classList.add(finished ? (run.status === 'completed' ? 'alert-success' : 'alert-danger') : 'alert-primary');
liveSpinner.classList.toggle('d-none', finished);
liveTitle.textContent = run.status === 'completed' ? 'Testen er færdig' : run.status === 'failed' ? 'Testen fejlede' : `AI-test #${run.id} kører`;
liveDetail.textContent = `${complete} af ${total} modelsvar · ${pct}%`;
if (!finished) {
startButton.disabled = true;
startButton.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Test kører · ${complete}/${total}`;
} else {
startButton.disabled = false;
startButton.innerHTML = '<i class="bi bi-arrow-repeat me-2"></i>Kør ny modeltest';
}
}
function pollAIBenchmarkRun(runId) {
clearTimeout(aiBenchmarkPollTimer);
const poll = async () => {
try {
const response = await fetch(`/api/v1/settings/ai-benchmarks/runs/${runId}`, {credentials:'include'});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke hente teststatus'));
const run = await response.json();
showAIBenchmarkProgress(run);
if (['completed','failed'].includes(run.status)) {
renderAIBenchmarkResult(run);
const history = await fetch('/api/v1/settings/ai-benchmarks/runs?limit=30', {credentials:'include'});
if (history.ok) renderAIBenchmarkHistory((await history.json()).items || []);
return;
}
aiBenchmarkPollTimer = setTimeout(poll, 2500);
} catch (exc) {
document.getElementById('aiBenchmarkError').textContent = exc.message;
aiBenchmarkPollTimer = setTimeout(poll, 5000);
}
};
poll();
}
function renderAIBenchmarkResult(run) {
const root = document.getElementById('aiBenchmarkResult');
const grouped = {};
(run.results || []).forEach(result => (grouped[result.model] ||= []).push(result));
const summaries = Object.entries(grouped).map(([model, results]) => ({
model, results,
score: results.reduce((sum, item) => sum + Number(item.score || 0), 0),
max: results.reduce((sum, item) => sum + Number(item.max_score || 0), 0),
duration: results.reduce((sum, item) => sum + Number(item.duration_ms || 0), 0),
tps: results.filter(item => item.tokens_per_second).reduce((sum, item, _, arr) => sum + Number(item.tokens_per_second) / arr.length, 0)
})).sort((a,b) => (b.score/b.max) - (a.score/a.max) || a.duration-b.duration);
if (!summaries.length) {
root.innerHTML = `<div class="alert alert-danger">Kørslen gav ingen resultater. ${aiBenchEscape(run.error_text || '')}</div>`;
return;
}
const winner = summaries[0]?.model;
const testKeys = [...new Set((run.results || []).map(item => item.test_key))];
const comparisonCards = testKeys.map((testKey, testIndex) => {
const answers = summaries.map(summary => summary.results.find(item => item.test_key === testKey)).filter(Boolean);
const first = answers[0];
const expected = answers.find(item => item.expected_json)?.expected_json;
return `
<div class="border rounded-4 overflow-hidden mb-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 bg-body-tertiary">
<div><span class="badge text-bg-light border me-2">${aiBenchEscape(first.category)}</span><strong>${aiBenchEscape(first.test_name)}</strong></div>
<div class="d-flex gap-2 flex-wrap">${answers.map(item => `<span class="badge ${item.score === item.max_score ? 'text-bg-success' : item.score ? 'text-bg-warning' : 'text-bg-danger'}">${aiBenchEscape(item.model)} · ${item.score}/${item.max_score}</span>`).join('')}</div>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" data-bs-target="#aiCompare${run.id}_${testIndex}"><i class="bi bi-layout-three-columns me-1"></i>Sammenlign alle svar</button>
</div>
<div class="collapse" id="aiCompare${run.id}_${testIndex}">
<div class="p-3">
<div class="ai-benchmark-comparison-grid">
<div class="border border-success-subtle rounded-3 overflow-hidden">
<div class="px-3 py-2 bg-success-subtle fw-bold small text-uppercase"><i class="bi bi-bullseye me-1"></i>Forventet svar</div>
<pre class="ai-benchmark-json p-3 mb-0">${aiBenchEscape(JSON.stringify(expected, null, 2) || 'Intet facit gemt')}</pre>
</div>
${answers.map(item => `
<div class="border rounded-3 overflow-hidden">
<div class="px-3 py-2 bg-body-tertiary d-flex justify-content-between align-items-center"><strong>${aiBenchEscape(item.model)}</strong><span class="badge ${item.score === item.max_score ? 'text-bg-success' : item.score ? 'text-bg-warning' : 'text-bg-danger'}">${item.score}/${item.max_score}</span></div>
<pre class="ai-benchmark-json p-3 mb-0">${aiBenchEscape(item.error_text || JSON.stringify(item.response_json, null, 2) || item.response_text || '[Tomt svar]')}</pre>
<div class="border-top p-3 small">
<div class="mb-2">${(item.passed_checks || []).map(x=>`<span class="badge text-bg-success me-1 mb-1"><i class="bi bi-check me-1"></i>${aiBenchEscape(x)}</span>`).join('') || ''}</div>
<div>${(item.failed_checks || []).map(x=>`<span class="badge text-bg-danger me-1 mb-1"><i class="bi bi-x me-1"></i>${aiBenchEscape(x)}</span>`).join('') || ''}</div>
<div class="text-muted mt-2">${(Number(item.duration_ms)/1000).toFixed(1)} sek. · ${item.tokens_per_second ? Number(item.tokens_per_second).toFixed(1)+' tok/s' : ''}</div>
</div>
</div>`).join('')}
</div>
</div>
</div>
</div>`;
}).join('');
root.innerHTML = `
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-start mb-4"><div><div class="text-uppercase text-muted small fw-semibold">Resultat fra kørsel #${run.id}</div><h4 class="fw-bold mb-0">${aiBenchEscape(winner)} vandt testen</h4></div><span class="badge text-bg-success fs-6">Færdig</span></div>
<div class="row g-3 mb-4">${summaries.map((summary, index) => `
<div class="col-md-6"><div class="border rounded-4 p-3 h-100">
<div class="d-flex gap-3 align-items-center"><div class="ai-benchmark-score">${Math.round(summary.score/summary.max*100)}%</div><div><div class="d-flex gap-2 align-items-center"><strong>${aiBenchEscape(summary.model)}</strong>${index === 0 ? '<span class="badge text-bg-success">Bedst</span>' : ''}</div><div class="text-muted small">${summary.score}/${summary.max} point · ${(summary.duration/1000).toFixed(1)} sek. · ${summary.tps ? summary.tps.toFixed(1) + ' tok/s' : ''}</div></div></div>
<div class="progress mt-3" style="height:7px"><div class="progress-bar" style="width:${summary.score/summary.max*100}%"></div></div>
</div></div>`).join('')}</div>
<div class="d-flex justify-content-between align-items-center mt-4 mb-3"><h5 class="fw-bold mb-0">Svar sammenlignet test for test</h5><span class="text-muted small">Facit + ${summaries.length} modeller</span></div>
${comparisonCards}
</div>`;
}
function renderAIBenchmarkHistory(runs) {
document.getElementById('aiBenchmarkHistoryCount').textContent = runs.length;
const root = document.getElementById('aiBenchmarkHistory');
if (!runs.length) { root.innerHTML = '<div class="text-muted small">Ingen kørsler endnu.</div>'; return; }
root.innerHTML = runs.map(run => {
const summaries = run.model_summaries || [];
const best = [...summaries].sort((a,b) => (Number(b.score)/Number(b.max_score))-(Number(a.score)/Number(a.max_score)))[0];
const statusClass = run.status === 'completed' ? 'success' : run.status === 'failed' ? 'danger' : 'primary';
return `<button class="btn btn-light text-start w-100 p-3 mb-2 border" onclick="openAIBenchmarkRun(${run.id})"><div class="d-flex justify-content-between"><strong>#${run.id} ${best ? aiBenchEscape(best.model) : 'Testkørsel'}</strong><span class="badge text-bg-${statusClass}">${aiBenchEscape(run.status)}</span></div><div class="small text-muted mt-1">${new Date(run.created_at).toLocaleString('da-DK')} · ${run.completed_cases}/${run.total_cases} svar</div>${best ? `<div class="small fw-semibold text-primary mt-1">Bedst: ${best.score}/${best.max_score} point</div>` : ''}</button>`;
}).join('');
const active = runs.find(run => ['queued','running'].includes(run.status));
if (active) { showAIBenchmarkProgress(active); pollAIBenchmarkRun(active.id); }
}
async function openAIBenchmarkRun(runId) {
const response = await fetch(`/api/v1/settings/ai-benchmarks/runs/${runId}`, {credentials:'include'});
if (!response.ok) return;
const run = await response.json();
showAIBenchmarkProgress(run);
renderAIBenchmarkResult(run);
document.getElementById('aiBenchmarkResult').scrollIntoView({behavior:'smooth', block:'start'});
if (!['completed','failed'].includes(run.status)) pollAIBenchmarkRun(run.id);
}
// Tab navigation
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
link.addEventListener('click', (e) => {
@ -5146,6 +5531,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
renderMissionSettings();
} else if (tab === 'ai-prompts') {
loadAIPrompts();
} else if (tab === 'ai-modeltest') {
loadAIBenchmark();
} else if (tab === 'modules') {
loadModules();
} else if (tab === 'tests') {

View File

@ -26,6 +26,17 @@
--bottom-bar-zindex: 1030;
}
/* Keep the content viewport stable when a tall dropdown opens/closes.
Without a reserved scrollbar gutter, wide case hero sections visibly
jump a few pixels on smaller monitors. */
html {
scrollbar-gutter: stable;
}
@supports not (scrollbar-gutter: stable) {
html { overflow-y: scroll; }
}
[data-bs-theme="dark"] {
--bg-body: #212529;
--bg-card: #2c3034;
@ -44,7 +55,7 @@
background-color: var(--bg-body);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px;
padding-top: 68px;
transition: background-color 0.3s, color 0.3s;
}
@ -228,6 +239,12 @@
display: none;
}
.global-bottom-bar .bb-activity-chip.is-paused {
border-color: rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.12);
color: #9a6700;
}
.global-bottom-bar .bb-notification-count {
background: var(--accent);
color: #fff;
@ -806,32 +823,357 @@
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
background: color-mix(in srgb, var(--bg-card) 94%, transparent);
-webkit-backdrop-filter: blur(18px) saturate(1.15);
backdrop-filter: blur(18px) saturate(1.15);
box-shadow: 0 8px 28px rgba(15, 47, 72, 0.07);
padding: 1rem 0;
border-bottom: 1px solid rgba(0,0,0,0.1);
border-bottom: 1px solid color-mix(in srgb, var(--accent) 14%, transparent);
}
/* The complete BMC menu needs more room than Bootstrap's normal lg
breakpoint. Use a dedicated wide-desktop breakpoint so 1315 inch
monitors get a clean collapsible menu instead of clipped items. */
@media (min-width: 1180px) {
.navbar-expand-wide { flex-wrap: nowrap; justify-content: flex-start; }
.navbar-expand-wide .navbar-toggler { display: none; }
.navbar-expand-wide .navbar-collapse { display: flex !important; flex-basis: auto; }
.navbar-expand-wide .navbar-nav { flex-direction: row; }
.navbar-expand-wide .navbar-nav .dropdown-menu,
.navbar-expand-wide .navbar-actions .dropdown-menu {
position: absolute;
}
}
@media (min-width: 1180px) and (max-width: 1679.98px) {
.navbar-expand-wide {
padding: .55rem 0;
}
.navbar-expand-wide > .container-fluid {
padding-left: .75rem !important;
padding-right: .75rem !important;
}
.navbar-expand-wide .navbar-brand {
width: 44px;
min-width: 44px;
overflow: visible;
margin-right: .55rem;
}
.navbar-expand-wide .navbar-brand > div {
margin-right: 0 !important;
}
.navbar-expand-wide .bmc-brand-copy {
display: none;
}
.navbar-expand-wide .navbar-nav {
margin-left: 0 !important;
margin-right: .25rem !important;
padding: 0;
border: 0;
background: transparent;
}
.navbar-expand-wide .nav-link {
min-height: 38px !important;
padding: .45rem .68rem !important;
margin: 0 .03rem;
font-size: 1rem;
white-space: nowrap;
}
.navbar-expand-wide .navbar-nav .nav-link > i,
.navbar-expand-wide .navbar-actions > .dropdown:first-child .nav-link > i {
display: none;
}
.navbar-expand-wide .navbar-actions {
flex: 1 1 auto;
gap: .12rem !important;
flex-wrap: nowrap;
}
.navbar-expand-wide .navbar-actions #globalSearchBtn {
margin-left: auto;
}
.navbar-expand-wide .navbar-actions > .btn.rounded-circle {
width: 34px;
height: 34px;
min-width: 34px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.navbar-expand-wide .navbar-utility-dock {
gap: .18rem;
padding: .18rem;
border-color: color-mix(in srgb, var(--accent) 8%, transparent);
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
}
.navbar-expand-wide .navbar-utility-dock > .btn.rounded-circle {
width: 35px;
height: 35px;
min-width: 35px;
}
.navbar-expand-wide #currentUserDisplayName {
display: none;
}
.navbar-expand-wide #currentUserAvatar {
margin-right: 0 !important;
width: 32px;
height: 32px;
}
}
@media (min-width: 1280px) {
.navbar-expand-wide > .container-fluid {
position: relative;
}
.navbar-expand-wide .navbar-collapse {
position: static;
}
#navbarNav > .navbar-nav {
position: absolute;
left: 50%;
transform: translateX(-50%);
margin-left: 0 !important;
margin-right: 0 !important;
}
#navbarNav > .navbar-actions {
margin-left: auto;
}
}
@media (max-width: 1179.98px) {
.navbar-expand-wide {
padding: .65rem 0;
}
.navbar-expand-wide > .container-fluid {
padding-left: 1rem !important;
padding-right: 1rem !important;
}
.navbar-expand-wide .navbar-toggler {
display: inline-flex;
align-items: center;
gap: .45rem;
border: 1px solid rgba(15, 76, 117, .18);
border-radius: 10px;
padding: .45rem .7rem;
color: var(--accent);
background: var(--accent-light);
font-size: .88rem;
font-weight: 700;
}
.navbar-expand-wide .navbar-toggler:focus {
box-shadow: 0 0 0 .2rem rgba(15, 76, 117, .12);
}
.navbar-expand-wide .navbar-collapse {
max-height: calc(100vh - 76px);
overflow-y: auto;
padding: .85rem 0 1rem;
}
.navbar-expand-wide .navbar-nav {
width: 100%;
margin: 0 !important;
gap: .2rem;
}
.navbar-expand-wide .nav-link {
display: flex;
align-items: center;
min-height: 44px;
margin: 0;
}
.navbar-expand-wide .navbar-actions {
width: 100%;
flex-wrap: wrap;
justify-content: flex-start;
gap: .65rem !important;
border-top: 1px solid rgba(15, 76, 117, .12);
margin-top: .75rem;
padding-top: .85rem;
}
.navbar-expand-wide .navbar-actions > .dropdown:first-child {
flex: 1 0 100%;
}
.navbar-expand-wide .navbar-utility-dock {
width: 100%;
margin-left: 0;
border-radius: 14px;
justify-content: flex-start;
}
.navbar-expand-wide .dropdown-menu {
max-height: min(60vh, 520px);
overflow-y: auto;
}
}
.navbar-brand {
font-weight: 700;
color: var(--accent);
font-size: 1.25rem;
letter-spacing: -0.025em;
}
.navbar-brand > div {
background: linear-gradient(145deg, var(--accent), #2488bd) !important;
box-shadow: 0 6px 14px rgba(15, 76, 117, .2);
}
.bmc-brand-copy {
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1.02;
}
.bmc-brand-name {
font-size: 1.08rem;
font-weight: 820;
}
.bmc-brand-subtitle {
margin-top: .16rem;
color: var(--text-secondary);
font-size: .58rem;
font-weight: 750;
letter-spacing: .12em;
text-transform: uppercase;
}
@media (min-width: 1180px) {
#navbarNav > .navbar-nav {
align-items: center;
padding: .24rem;
border: 1px solid color-mix(in srgb, var(--accent) 9%, transparent);
border-radius: 14px;
background: color-mix(in srgb, var(--accent) 4%, var(--bg-card));
}
#navbarNav > .navbar-nav > .nav-item > .nav-link {
min-height: 40px;
padding-top: .52rem !important;
padding-bottom: .52rem !important;
}
#navbarNav > .navbar-nav > .nav-item > .nav-link.active,
#navbarNav > .navbar-nav > .nav-item > .nav-link.show,
#navbarNav > .navbar-nav > .nav-item > .nav-link.section-active {
background: var(--accent);
color: #fff;
box-shadow: 0 7px 16px rgba(15, 76, 117, .2);
}
#navbarNav > .navbar-nav > .nav-item > .nav-link.active::after,
#navbarNav > .navbar-nav > .nav-item > .nav-link.show::after,
#navbarNav > .navbar-nav > .nav-item > .nav-link.section-active::after {
border-top-color: currentColor;
}
}
@media (min-width: 1180px) and (max-width: 1679.98px) {
#navbarNav > .navbar-nav {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
#navbarNav > .navbar-nav > .nav-item > .nav-link {
min-height: 38px;
padding-top: .45rem !important;
padding-bottom: .45rem !important;
box-shadow: none;
}
#navbarNav > .navbar-nav > .nav-item > .nav-link.active,
#navbarNav > .navbar-nav > .nav-item > .nav-link.show,
#navbarNav > .navbar-nav > .nav-item > .nav-link.section-active {
box-shadow: 0 5px 13px rgba(15, 76, 117, .18);
}
.navbar-actions > .dropdown:first-child > .nav-link {
min-height: 38px;
border-radius: 10px;
}
}
.navbar-utility-dock {
display: flex;
align-items: center;
gap: .35rem;
margin-left: auto;
padding: .28rem;
border: 1px solid color-mix(in srgb, var(--accent) 10%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 4%, var(--bg-card));
}
.navbar-utility-dock > .btn.rounded-circle {
width: 38px;
height: 38px;
min-width: 38px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0 !important;
background: transparent !important;
box-shadow: none;
}
.navbar-utility-dock > .btn.rounded-circle:hover {
background: var(--accent-light) !important;
transform: none;
box-shadow: none;
}
.navbar-utility-dock > .dropdown:last-child > a {
min-height: 38px;
padding: .15rem .3rem .15rem .2rem;
border-radius: 999px;
}
.navbar-utility-dock > .dropdown:last-child > a:hover {
background: var(--accent-light);
}
@media (min-width: 1180px) and (max-width: 1679.98px) {
.navbar-expand-wide .navbar-utility-dock {
gap: .16rem;
padding: .16rem;
border-color: color-mix(in srgb, var(--accent) 8%, transparent);
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
}
.navbar-expand-wide .navbar-utility-dock > .btn.rounded-circle {
width: 35px;
height: 35px;
min-width: 35px;
}
}
.nav-link {
color: var(--text-secondary);
padding: 0.6rem 1.2rem !important;
border-radius: var(--border-radius);
transition: all 0.2s;
font-weight: 500;
border-radius: 11px;
transition: background-color .16s ease, color .16s ease, box-shadow .16s ease;
font-weight: 650;
margin: 0 0.2rem;
}
.nav-link:hover, .nav-link.active {
.nav-link:hover, .nav-link.active, .nav-link.show, .nav-link.section-active {
background-color: var(--accent-light);
color: var(--accent);
}
#navbarNav > .navbar-nav > .nav-item > .nav-link.active,
#navbarNav > .navbar-nav > .nav-item > .nav-link.show,
#navbarNav > .navbar-nav > .nav-item > .nav-link.section-active {
box-shadow: inset 0 -2px 0 var(--accent);
}
/* Top-level labels are clearer and use materially less horizontal room
without decorative icons. Icons inside dropdown items stay visible. */
#navbarNav > .navbar-nav > .nav-item > .nav-link > i,
#navbarNav > .navbar-actions > .dropdown:first-child > .nav-link > i {
display: none;
}
.card {
border: 2px solid var(--frame-border-strong);
border-left: 4px solid var(--accent);
@ -948,11 +1290,75 @@
}
.dropdown-menu {
border: none;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
border-radius: 12px;
padding: 0.5rem;
background-color: var(--bg-card);
min-width: 245px;
border: 1px solid color-mix(in srgb, var(--accent) 13%, transparent);
box-shadow: 0 18px 46px rgba(15, 47, 72, .16), 0 3px 10px rgba(15, 47, 72, .07);
border-radius: 16px;
padding: .55rem;
background: color-mix(in srgb, var(--bg-card) 97%, transparent);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
animation: bmcMenuReveal .14s ease-out;
}
@keyframes bmcMenuReveal {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
#navbarNav .dropdown-item {
display: flex;
align-items: center;
min-height: 39px;
border-radius: 10px;
color: var(--text-primary);
font-size: .95rem;
font-weight: 560;
transition: background-color .14s ease, color .14s ease, padding-left .14s ease;
}
#navbarNav .dropdown-item:hover,
#navbarNav .dropdown-item:focus {
padding-left: .85rem;
background: color-mix(in srgb, var(--accent) 9%, var(--bg-card));
color: var(--accent);
}
#navbarNav .dropdown-item.active {
background: color-mix(in srgb, var(--accent) 14%, var(--bg-card));
color: var(--accent);
font-weight: 750;
}
#navbarNav .dropdown-item > i {
width: 1.35rem;
color: color-mix(in srgb, var(--accent) 82%, var(--text-secondary));
}
#navbarNav .dropdown-header {
padding: .55rem .65rem .3rem;
color: color-mix(in srgb, var(--text-secondary) 84%, var(--accent));
font-size: .66rem;
font-weight: 800;
letter-spacing: .09em;
text-transform: uppercase;
}
#navbarNav .dropdown-divider {
margin: .45rem .35rem;
border-color: color-mix(in srgb, var(--accent) 12%, transparent);
}
.navbar-actions > .btn.rounded-circle {
border: 1px solid transparent !important;
box-shadow: 0 3px 9px rgba(15, 76, 117, .07);
transition: transform .14s ease, box-shadow .14s ease, background-color .14s ease;
}
.navbar-actions > .btn.rounded-circle:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent) 16%, transparent) !important;
box-shadow: 0 7px 16px rgba(15, 76, 117, .13);
}
/* Nested dropdown support - simplified click-based approach */
@ -1034,6 +1440,7 @@
}
</style>
{% block extra_css %}{% endblock %}
<link rel="stylesheet" href="/static/messages.css?v=1">
</head>
{% set _xff = request.headers.get('x-forwarded-for') if request and request.headers else '' %}
{% set _xff_first = _xff.split(',')[0].strip() if _xff else '' %}
@ -1041,16 +1448,16 @@
{% set _can_click_to_call = true %}
<body>
<nav class="navbar navbar-expand-lg fixed-top">
<nav class="navbar navbar-expand-wide fixed-top">
<div class="container-fluid px-4">
<a class="navbar-brand d-flex align-items-center" href="/">
<div class="bg-primary text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; background-color: var(--accent) !important;">
<div class="bmc-brand-mark text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 36px; height: 36px;">
<i class="bi bi-hdd-network-fill" style="font-size: 16px;"></i>
</div>
BMC Hub
<span class="bmc-brand-copy"><span class="bmc-brand-name">BMC Hub</span><span class="bmc-brand-subtitle">Operations</span></span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Åbn hovedmenu">
<i class="bi bi-list fs-5"></i><span>Menu</span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav mx-auto">
@ -1094,6 +1501,8 @@
<li data-menu-key="menu-support-tickets"><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li data-menu-key="menu-support-emails"><a class="dropdown-item py-2" href="/emails"><i class="bi bi-envelope me-2"></i>Email</a></li>
<li data-menu-key="menu-support-telefoni"><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
<li data-menu-key="menu-support-internet-connections"><a class="dropdown-item py-2" href="/economy/internet-connections"><i class="bi bi-hdd-network me-2"></i>Internetforbindelser</a></li>
<li data-menu-key="menu-support-losninger"><a class="dropdown-item py-2 {{ 'active' if request and (request.url.path.startswith('/solutions') or request.url.path.startswith('/knowledge')) else '' }}" href="/solutions"><i class="bi bi-lightbulb me-2"></i>Løsninger</a></li>
<li data-menu-key="menu-support-mission"><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
<li data-menu-key="menu-support-anydesk"><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
<li><hr class="dropdown-divider"></li>
@ -1132,14 +1541,13 @@
<li data-menu-key="menu-okonomi-prepaid"><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li data-menu-key="menu-okonomi-fixed-price"><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li data-menu-key="menu-okonomi-subscriptions"><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li data-menu-key="menu-okonomi-internet-connections"><a class="dropdown-item py-2" href="/economy/internet-connections"><i class="bi bi-hdd-network me-2"></i>Internetforbindelser</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Kontrol</h6></li>
<li data-menu-key="menu-okonomi-invoice-error-finder"><a class="dropdown-item py-2" href="/invoice-error-finder"><i class="bi bi-search me-2"></i>Faktura-fejl-finder</a></li>
</ul>
</li>
</ul>
<div class="d-flex align-items-center gap-3">
<div class="navbar-actions d-flex align-items-center gap-3">
<div class="dropdown" data-menu-key="menu-datamigration">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-clock-history me-2"></i>Data migration
@ -1158,6 +1566,7 @@
<li data-menu-key="menu-datamigration-customers"><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul>
</div>
<div class="navbar-utility-dock">
<button class="btn btn-light rounded-circle border-0" id="globalSearchBtn" style="background: var(--accent-light); color: var(--accent);" title="Global søgning (Cmd/Ctrl+K)">
<i class="bi bi-search"></i>
</button>
@ -1187,10 +1596,12 @@
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li>
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
<li id="projectCtProfileLink" class="d-none"><a class="dropdown-item py-2" href="/admin/hub-impact"><i class="bi bi-graph-up-arrow me-2"></i>Project CT · Hub Impact</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2 text-danger" href="#" onclick="logoutUser(event)"><i class="bi bi-box-arrow-right me-2"></i>Log ud</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
@ -1428,6 +1839,7 @@
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-label">Ulæste mails</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Ulæste mails: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="urgent"><i class="bi bi-exclamation-octagon"></i> <span class="bb-chip-label">Hastesager</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Hastesager: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="unassigned"><i class="bi bi-person-x"></i> <span class="bb-chip-label">Uden ansvarlig</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Uden ansvarlig: 0</span></button>
<a class="bb-chip" href="/procurement" data-bb-key="procurement"><i class="bi bi-cart-plus"></i> <span class="bb-chip-label">Indkøb</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Skal bestilles: 0</span></a>
<a class="bb-chip" href="/drift" data-bb-key="drift"><i class="bi bi-broadcast"></i> <span class="bb-chip-label">Drift</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Drift: 0</span></a>
</div>
<div class="bb-zone bb-zone-center">
@ -1585,13 +1997,15 @@ if (bmcOriginalFetch) {
}
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/tag-picker.js?v=2.2"></script>
<script src="/static/js/tag-picker.js?v=2.3"></script>
<script src="/static/js/task-template-selector.js?v=1.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.5"></script>
<script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.65"></script>
{% include "shared/frontend/internal_message.html" %}
<script src="/static/js/message-ui.js?v=1"></script>
<script src="/static/js/bottom-bar.js?v=2.71"></script>
<script>
// Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = true;
@ -1638,6 +2052,7 @@ if (bmcOriginalFetch) {
if (!li) return false;
const anchor = li.querySelector('a.dropdown-item');
if (!anchor) return false;
if (li.classList.contains('bmc-menu-hidden') || anchor.classList.contains('bmc-menu-hidden')) return false;
if (li.style.display === 'none') return false;
if (anchor.style.display === 'none') return false;
return true;
@ -1714,7 +2129,14 @@ if (bmcOriginalFetch) {
document.querySelectorAll('[data-menu-key]').forEach((node) => {
const key = String(node.getAttribute('data-menu-key') || '').trim().toLowerCase();
if (!key) return;
node.style.display = hidden.has(key) ? 'none' : '';
const shouldHide = hidden.has(key);
node.classList.toggle('bmc-menu-hidden', shouldHide);
node.hidden = shouldHide;
if (shouldHide) {
node.style.setProperty('display', 'none', 'important');
} else {
node.style.removeProperty('display');
}
});
cleanupNavbarDropdowns(hidden);
window.__bmcMenuHiddenKeys = Array.from(hidden);
@ -1749,7 +2171,29 @@ if (bmcOriginalFetch) {
let allResults = [];
document.addEventListener('DOMContentLoaded', () => {
const primaryMenu = document.querySelector('#navbarNav > .navbar-nav');
const dataMigrationMenu = document.querySelector('#navbarNav > .navbar-actions > [data-menu-key="menu-datamigration"]');
if (primaryMenu && dataMigrationMenu) {
const menuItem = document.createElement('li');
menuItem.className = 'nav-item dropdown';
menuItem.setAttribute('data-menu-key', 'menu-datamigration');
while (dataMigrationMenu.firstChild) menuItem.appendChild(dataMigrationMenu.firstChild);
primaryMenu.appendChild(menuItem);
dataMigrationMenu.remove();
}
loadAndApplyMenuVisibility();
const currentPath = String(window.location.pathname || '/').replace(/\/$/, '') || '/';
const menuLinks = Array.from(document.querySelectorAll('#navbarNav a[href^="/"]'))
.filter((link) => {
const href = String(link.getAttribute('href') || '').replace(/\/$/, '') || '/';
return href !== '/' && (currentPath === href || currentPath.startsWith(`${href}/`));
})
.sort((a, b) => String(b.getAttribute('href')).length - String(a.getAttribute('href')).length);
const bestMenuLink = menuLinks[0];
if (bestMenuLink) bestMenuLink.classList.add('active');
document.querySelectorAll('#navbarNav .dropdown-item.active').forEach((item) => {
item.closest('.dropdown')?.querySelector(':scope > .nav-link')?.classList.add('section-active');
});
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
const searchBubbleBtn = document.getElementById('globalSearchBtn');
const contextManualBtn = document.getElementById('contextManualBtn');
@ -2475,7 +2919,12 @@ if (bmcOriginalFetch) {
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-menu-tab" data-bs-toggle="tab" data-bs-target="#profile-menu" type="button" role="tab">
<button class="nav-link" id="profile-task-lists-tab" data-bs-toggle="tab" data-bs-target="#profile-task-lists" type="button" role="tab">
Opgavelister
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-menu-tab" data-bs-toggle="tab" data-bs-target="#profile-menu" type="button" role="tab" onclick="loadProfileMenuPreferences()">
Menu
</button>
</li>
@ -2580,18 +3029,36 @@ if (bmcOriginalFetch) {
</div>
</div>
<div class="tab-pane fade" id="profile-task-lists" role="tabpanel" tabindex="0">
<div class="card border-0 bg-light-subtle">
<div class="card-body">
<div class="d-flex align-items-start gap-3 mb-3">
<div class="rounded-3 text-primary bg-primary-subtle d-grid" style="width:42px;height:42px;place-items:center"><i class="bi bi-list-check fs-5"></i></div>
<div><h6 class="mb-1 fw-bold">Automatiske opgavelister</h6><p class="small text-muted mb-0">Send dine egne og gruppens åbne sager på faste tidspunkter. Beskeden indeholder direkte links til sagerne.</p></div>
</div>
<div class="row g-3 align-items-end">
<div class="col-md-6"><label class="form-label small fw-semibold" for="profileTaskListTitle">Navn</label><input id="profileTaskListTitle" class="form-control" value="Min opgaveliste"></div>
<div class="col-md-6"><label class="form-label small fw-semibold" for="profileTaskListTimes">Tidspunkter</label><input id="profileTaskListTimes" class="form-control" value="09:00, 12:00, 14:00"><div class="form-text">Fx 09:00, 12:00, 14:00</div></div>
<div class="col-12 d-flex flex-wrap gap-4"><label class="form-check mb-0"><input id="profileTaskListGroups" class="form-check-input" type="checkbox" checked><span class="form-check-label">Medtag mine gruppers sager</span></label><label class="form-check mb-0"><input id="profileTaskListMattermost" class="form-check-input" type="checkbox" checked><span class="form-check-label">Send til Mattermost</span></label></div>
<div class="col-12 d-flex justify-content-end"><button id="profileTaskListSave" class="btn btn-primary btn-sm px-3" type="button" onclick="saveProfileTaskList()"><i class="bi bi-plus-lg me-1"></i>Gem opgaveliste</button></div>
</div>
</div>
</div>
<div class="mt-3" id="profileTaskLists"><div class="p-3 text-muted small">Indlæser opgavelister...</div></div>
</div>
<div class="tab-pane fade" id="profile-menu" role="tabpanel" tabindex="0">
<div class="card border-0">
<div class="card-body px-0">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 text-primary"><i class="bi bi-layout-text-window-reverse me-2"></i>Menuvisning (min konto)</h6>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" type="button" id="profMenuShowAllBtn">Vis alle</button>
<button class="btn btn-sm btn-primary" type="button" id="profMenuSaveBtn">Gem</button>
<button class="btn btn-sm btn-outline-secondary" type="button" id="profMenuShowAllBtn" onclick="showAllProfileMenuItems()">Vis alle</button>
<button class="btn btn-sm btn-primary" type="button" id="profMenuSaveBtn" onclick="saveProfileMenuPreferences()">Gem</button>
</div>
</div>
<div id="profMenuFeedback" class="small text-muted mb-2"></div>
<div id="profMenuPrefsGrid" class="row g-2"></div>
<div id="profMenuFeedback" class="small text-muted mb-2">Vælg hvilke menupunkter der skal vises for dig.</div>
<div id="profMenuPrefsGrid" class="row g-2"><div class="col-12 text-muted small py-3"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser menuindstillinger…</div></div>
</div>
</div>
</div>
@ -2604,6 +3071,10 @@ if (bmcOriginalFetch) {
</div>
</div>
<style>
/* Personal menu preferences must also win over responsive navbar layout rules. */
#navbarNav .bmc-menu-hidden { display: none !important; }
</style>
<script>
const PROFILE_MENU_PREF_ITEMS = [
{ key: 'menu-crm', label: 'CRM' },
@ -2624,6 +3095,8 @@ if (bmcOriginalFetch) {
{ key: 'menu-support-tickets', label: 'Support: Arkiverede Tickets' },
{ key: 'menu-support-emails', label: 'Support: Email' },
{ key: 'menu-support-telefoni', label: 'Support: Telefoni' },
{ key: 'menu-support-internet-connections', label: 'Support: Internetforbindelser' },
{ key: 'menu-support-losninger', label: 'Support: Løsninger' },
{ key: 'menu-support-mission', label: 'Support: Mission Control' },
{ key: 'menu-support-anydesk', label: 'Support: AnyDesk Sessions' },
{ key: 'menu-support-hardware', label: 'Support: BMC Assets' },
@ -2730,6 +3203,9 @@ if (bmcOriginalFetch) {
window.dispatchEvent(new CustomEvent('bmc:menu-preferences-updated', {
detail: { hidden_menu_keys: hiddenKeys }
}));
// Apply immediately as well. This avoids relying on a custom event
// when a page has scripts from an older browser cache.
applyMenuVisibility(hiddenKeys);
setProfileMenuFeedback('Menu gemt.', 'success');
} catch (e) {
setProfileMenuFeedback(e.message || 'Kunne ikke gemme menu', 'error');
@ -2870,6 +3346,59 @@ if (bmcOriginalFetch) {
}
}
async function loadProfileTaskLists() {
const target = document.getElementById('profileTaskLists');
if (!target) return;
try {
const res = await fetch('/api/v1/reminder-task-rules', { credentials: 'include' });
if (!res.ok) throw new Error('Kunne ikke hente opgavelister');
const rules = await res.json();
target.innerHTML = rules.length ? rules.map(rule => `
<div class="border rounded-3 bg-white p-3 mb-2 d-flex justify-content-between align-items-center gap-3">
<div><div class="fw-semibold">${escapeHtml(rule.title)}</div><div class="small text-muted mt-1"><i class="bi bi-clock me-1"></i>${(rule.times_json || []).map(escapeHtml).join(' · ')}${rule.include_groups ? ' · Mine grupper' : ''}</div></div>
<button class="btn btn-sm btn-outline-primary flex-shrink-0" type="button" onclick="sendProfileTaskList(${rule.id}, this)"><i class="bi bi-send me-1"></i>Send nu</button>
</div>`).join('') : '<div class="border rounded-3 bg-white p-3 text-muted small">Du har endnu ingen automatiske opgavelister.</div>';
} catch (e) {
target.innerHTML = `<div class="alert alert-danger py-2 small mb-0">${escapeHtml(e.message || 'Kunne ikke hente opgavelister.')}</div>`;
}
}
async function saveProfileTaskList() {
const button = document.getElementById('profileTaskListSave');
const times = (document.getElementById('profileTaskListTimes')?.value || '').split(',').map(value => value.trim()).filter(Boolean);
if (!times.length) return alert('Skriv mindst ét tidspunkt, fx 09:00.');
button.disabled = true;
const original = button.innerHTML;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Gemmer';
try {
const res = await fetch('/api/v1/reminder-task-rules', {
method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
title: document.getElementById('profileTaskListTitle').value,
times,
include_groups: document.getElementById('profileTaskListGroups').checked,
notify_mattermost: document.getElementById('profileTaskListMattermost').checked
})
});
const result = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(result.detail || 'Kunne ikke gemme opgavelisten');
await loadProfileTaskLists();
} catch (e) { alert('Fejl: ' + e.message); }
finally { button.disabled = false; button.innerHTML = original; }
}
async function sendProfileTaskList(ruleId, button) {
const original = button.innerHTML;
button.disabled = true; button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sender';
try {
const res = await fetch(`/api/v1/reminder-task-rules/${ruleId}/send-now`, {method: 'POST', credentials: 'include'});
const result = await res.json().catch(() => ({}));
if (!res.ok || !result.sent) throw new Error(result.detail || result.message || 'Kunne ikke sende opgavelisten');
alert(`Opgavelisten er sendt med ${result.count} sager.`);
} catch (e) { alert('Fejl: ' + e.message); }
finally { button.disabled = false; button.innerHTML = original; }
}
async function loadUserProfile() {
try {
const res = await fetch('/api/v1/auth/me/profile', { credentials: 'include' });
@ -2926,6 +3455,10 @@ if (bmcOriginalFetch) {
avatarEl.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=0f4c75&color=fff`;
avatarEl.alt = displayName;
}
const projectCtLink = document.getElementById('projectCtProfileLink');
if (projectCtLink && user.is_superadmin === true) {
projectCtLink.classList.remove('d-none');
}
} catch (e) {
console.error('Failed to load current user identity', e);
}
@ -3005,11 +3538,6 @@ if (bmcOriginalFetch) {
document.addEventListener('DOMContentLoaded', () => {
loadCurrentUserMenuIdentity();
const saveMenuBtn = document.getElementById('profMenuSaveBtn');
if (saveMenuBtn) saveMenuBtn.addEventListener('click', saveProfileMenuPreferences);
const showAllBtn = document.getElementById('profMenuShowAllBtn');
if (showAllBtn) showAllBtn.addEventListener('click', showAllProfileMenuItems);
const profileModalEl = document.getElementById('profileModal');
if (profileModalEl) {
profileModalEl.addEventListener('shown.bs.modal', () => {
@ -3017,6 +3545,7 @@ if (bmcOriginalFetch) {
loadProfileReminders();
loadUserProfile();
loadProfileMenuPreferences();
loadProfileTaskLists();
});
}
});

View File

@ -0,0 +1,20 @@
<div class="modal fade" id="internalMessageModal" tabindex="-1" aria-labelledby="internalMessageTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg modal-dialog-scrollable"><div class="modal-content rounded-4 border-0 shadow">
<div class="modal-header border-0 px-4 pt-4"><div><span class="text-uppercase small text-muted">BMC · INTERNE BESKEDER</span><h4 id="internalMessageTitle" class="mb-0">Hvad skal din kollega vide?</h4></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div>
<div class="modal-body px-4"><form id="internalMessageForm">
<div class="d-flex gap-2 mb-4" role="group" aria-label="Beskedtype"><button type="button" class="btn btn-primary" data-internal-kind="message" aria-pressed="true"><i class="bi bi-chat-text me-2"></i>Kort besked</button><button type="button" class="btn btn-outline-primary" data-internal-kind="phone" aria-pressed="false"><i class="bi bi-telephone me-2"></i>Telefonbesked</button></div>
<div id="im-error" class="alert alert-danger d-none" role="alert"></div>
<label for="im-recipient" class="form-label">Modtager <span class="text-danger">*</span></label><select id="im-recipient" class="form-select mb-3" required><option value="">Vælg medarbejder…</option></select>
<label for="im-text" class="form-label">Besked <span class="text-danger">*</span></label><textarea id="im-text" rows="4" maxlength="2000" required class="form-control mb-3" placeholder="Skriv din besked her…"></textarea>
<details id="im-contact-panel" class="mb-3"><summary class="small text-muted mb-2"><i class="bi bi-person-plus me-1"></i>Tilknyt en kontaktperson <span class="small">· valgfrit</span></summary>
<label for="im-contact-search" class="form-label">Kontaktperson <small class="text-muted fw-normal">· valgfrit</small></label>
<div class="position-relative mb-3"><input id="im-contact-search" class="form-control" placeholder="Søg navn, telefon eller e-mail…" autocomplete="off"><div id="im-contact-results" class="list-group shadow-sm mt-1 d-none" style="max-height:200px;overflow:auto"></div><div id="im-contact" class="mt-2"></div></div>
</details>
<div id="im-phone-fields" class="row g-3 mb-3 d-none"><div class="col-sm-6"><label for="im-caller" class="form-label">Hvem ringede?</label><input id="im-caller" class="form-control" maxlength="200" placeholder="Navn, også uden kontaktkort"></div><div class="col-sm-6"><label for="im-phone" class="form-label">Telefonnummer til tilbageringning</label><input id="im-phone" type="tel" class="form-control" maxlength="80" placeholder="Telefonnummer"></div></div>
<label class="form-check mt-3"><input id="im-ack" type="checkbox" class="form-check-input"><span class="form-check-label small">Bed modtageren bekræfte, at beskeden er læst</span></label>
<p class="small text-muted mt-3 mb-0">Beskeden leveres i kollegaens interne beskeder i bundlinjen.</p>
</form></div>
<div class="modal-footer border-0 px-4 pb-4"><button type="button" class="btn btn-light border" data-bs-dismiss="modal">Luk</button><button type="submit" form="internalMessageForm" id="im-send" class="btn btn-primary px-4"><i class="bi bi-send me-2"></i>Send besked</button></div>
</div></div>
</div>
<script src="/static/js/internal-message.js?v=2"></script>

View File

@ -549,7 +549,11 @@ async def get_subscription_change_request_by_case(
(change_sag_id,),
)
if not change:
raise HTTPException(status_code=404, detail="Change request not found")
return {
"change_request": None,
"items": [],
"allowed_actions": _permissions_for(current_user),
}
items = execute_query(
"""SELECT ci.*, s.subscription_number, s.product_name
FROM subscription_change_request_items ci
@ -1380,20 +1384,39 @@ async def update_subscription(
payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")),
):
"""Direct edits are limited to notes; business changes require an approved request."""
"""A draft can be corrected directly; live agreements use change requests."""
try:
forbidden = set(payload) - {"notes"}
subscription = execute_query_single(
"""SELECT id, status, period_start, billing_lead_months,
billing_schedule_type, billing_day
FROM sag_subscriptions WHERE id = %s""",
(subscription_id,),
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
# A draft has not created an invoice or a committed agreement yet, so
# the creator must be able to correct it without a change case.
draft_fields = {
"product_name", "billing_interval", "billing_schedule_type", "billing_day", "price",
"start_date", "end_date", "period_start", "notice_period_days", "notes",
"billing_direction", "advance_months", "billing_lead_months", "first_full_period_start",
"binding_months", "binding_start_date", "binding_end_date", "binding_group_key",
"invoice_merge_key", "price_type", "custom_price_override", "first_invoice_policy",
"line_items", "first_invoice_items",
}
allowed_direct_fields = draft_fields if subscription.get("status") == "draft" else {"notes"}
forbidden = set(payload) - allowed_direct_fields
if forbidden:
raise HTTPException(
status_code=409,
detail="Business fields must be changed through a subscription change request",
)
subscription = execute_query_single(
"SELECT id, status FROM sag_subscriptions WHERE id = %s",
(subscription_id,)
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
# The case editor always posts this collection. First-invoice lines
# are intentionally left untouched here until their dedicated draft
# editor is used; they are not a recurring agreement change.
payload.pop("first_invoice_items", None)
# Extract line_items before processing other fields
line_items = payload.pop("line_items", None)
@ -1436,10 +1459,23 @@ async def update_subscription(
if len(normalized_line_items) > 1
else first_description
)
if subscription.get("status") == "draft" and any(
field in payload for field in {"period_start", "billing_lead_months", "billing_schedule_type", "billing_day"}
):
period_start = _safe_date(payload.get("period_start") or subscription.get("period_start"))
if not period_start:
raise HTTPException(status_code=400, detail="period_start must be a valid date")
payload["next_invoice_date"] = billing_date_for_period(
period_start,
int(payload.get("billing_lead_months", subscription.get("billing_lead_months") or 0)),
payload.get("billing_schedule_type") or subscription.get("billing_schedule_type") or "fixed_day",
int(payload.get("billing_day", subscription.get("billing_day") or 1)),
)
# Build dynamic update query
allowed_fields = {
"product_name", "billing_interval", "billing_day", "price",
"product_name", "billing_interval", "billing_schedule_type", "billing_day", "price",
"start_date", "end_date", "next_invoice_date", "period_start",
"notice_period_days", "status", "notes",
"billing_direction", "advance_months", "billing_lead_months", "first_full_period_start",
@ -1527,9 +1563,27 @@ async def update_subscription(
@router.patch("/sag-subscriptions/{subscription_id}/status", response_model=Dict[str, Any])
async def update_subscription_status(subscription_id: int, payload: Dict[str, Any]):
"""Compatibility guard: lifecycle changes require four-eyes workflow."""
raise HTTPException(status_code=409, detail="Status must be changed through a subscription change request")
async def update_subscription_status(
subscription_id: int,
payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")),
):
"""Activate an initial draft. Changes to a live agreement still need approval."""
status = str(payload.get("status") or "").strip().lower()
subscription = execute_query_single(
"SELECT id, status FROM sag_subscriptions WHERE id = %s", (subscription_id,)
)
if not subscription:
raise HTTPException(status_code=404, detail="Abonnementet findes ikke")
if subscription.get("status") != "draft" or status != "active":
raise HTTPException(status_code=409, detail="Status must be changed through a subscription change request")
execute_query(
"""UPDATE sag_subscriptions SET status = 'active', updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(subscription_id,),
fetch=False,
)
return _load_subscription_with_context(subscription_id)
@router.get("/sag-subscriptions", response_model=List[Dict[str, Any]])
@ -1758,6 +1812,28 @@ async def trigger_subscription_processing():
raise HTTPException(status_code=500, detail=str(e))
@router.post("/sag-subscriptions/{subscription_id}/process-invoice")
async def trigger_single_subscription_processing(subscription_id: int):
"""Process one due subscription without starting billing for other customers."""
subscription = execute_query_single(
"SELECT id, status, next_invoice_date FROM sag_subscriptions WHERE id = %s",
(subscription_id,),
)
if not subscription:
raise HTTPException(status_code=404, detail="Abonnementet findes ikke")
if subscription.get("status") != "active":
raise HTTPException(status_code=409, detail="Abonnementet skal være aktivt, før det kan faktureres")
if not subscription.get("next_invoice_date") or subscription["next_invoice_date"] > date.today():
raise HTTPException(status_code=409, detail="Abonnementet er ikke klar til fakturering endnu")
try:
from app.jobs.process_subscriptions import process_subscriptions
await process_subscriptions(subscription_ids=[subscription_id])
return {"status": "success", "message": "Abonnementets fakturering er kørt"}
except Exception as e:
logger.error("❌ Manual processing failed for subscription %s: %s", subscription_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sag-subscriptions/{subscription_id}/price-changes", response_model=List[Dict[str, Any]])
async def list_subscription_price_changes(subscription_id: int):
"""List planned price changes for one subscription."""

View File

@ -258,17 +258,17 @@ class EmailTicketIntegration:
# Critical keywords
if any(word in all_text for word in ['kritisk', 'critical', 'down', 'nede', 'urgent', 'akut']):
return TicketPriority.critical
return TicketPriority.URGENT
# High priority keywords
if any(word in all_text for word in ['høj', 'high', 'vigtig', 'important', 'haster']):
return TicketPriority.high
return TicketPriority.HIGH
# Low priority keywords
if any(word in all_text for word in ['lav', 'low', 'spørgsmål', 'question', 'info']):
return TicketPriority.low
return TicketPriority.LOW
return TicketPriority.normal
return TicketPriority.NORMAL
@staticmethod
def _format_description(email_data: Dict[str, Any]) -> str:

View File

@ -18,7 +18,7 @@
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="container-fluid pt-2 pb-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">🛠️ Tekniker Dashboard V1</h1>

View File

@ -142,7 +142,10 @@ def _elapsed_minutes_excluding_pause(entry: Dict[str, Any], end: datetime) -> in
paused_seconds += _seconds_between(paused_at, end)
effective_seconds = max(0, total_seconds - paused_seconds)
return effective_seconds // 60
# A running timer represents actual work as soon as at least one second has
# elapsed. Flooring sub-minute timers to zero later produced an invalid
# approved_hours=0 value when the timer was stopped.
return (effective_seconds + 59) // 60 if effective_seconds else 0
def _pause_total_seconds_at(entry: Dict[str, Any], end: datetime) -> int:
@ -2559,7 +2562,7 @@ async def stop_live_timer_v1(
faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01),
approved_hours = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
approved_hours = CASE WHEN billable AND %s::numeric > 0 THEN (%s::numeric / 60.0) ELSE NULL END,
rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
worked_date = COALESCE(worked_date, %s),
entry_status = %s,
@ -2575,6 +2578,7 @@ async def stop_live_timer_v1(
billable_minutes,
actual_minutes,
billable_minutes,
billable_minutes,
block_minutes,
now.date(),
entry_status,

View File

@ -115,6 +115,7 @@ async def list_vendors(
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),
category: Optional[str] = Query(None, description="Filter by category"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
is_internet_provider: Optional[bool] = Query(None, description="Filter internet providers"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100)
):
@ -134,6 +135,10 @@ async def list_vendors(
if is_active is not None:
query += " AND is_active = %s"
params.append(is_active)
if is_internet_provider is not None:
query += " AND is_internet_provider = %s"
params.append(is_internet_provider)
query += " ORDER BY name LIMIT %s OFFSET %s"
params.extend([limit, skip])
@ -218,31 +223,69 @@ async def get_vendor_invoices(vendor_id: int):
return rows or []
@router.get("/vendors/{vendor_id}/internet-connections", tags=["Vendors"])
async def get_vendor_internet_connections(vendor_id: int):
vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail="Leverandør ikke fundet")
return execute_query(
"""
SELECT ic.id, ic.name, ic.circuit_number, ic.address, ic.status,
ic.download_mbps, ic.upload_mbps, ic.monthly_cost, ic.sales_price,
ic.customer_id, c.name AS customer_name, ic.updated_at
FROM internet_connections_connections ic
LEFT JOIN customers c ON c.id = ic.customer_id
WHERE ic.vendor_id = %s AND ic.deleted_at IS NULL
ORDER BY ic.name ASC, ic.id ASC
""",
(vendor_id,),
) or []
@router.post("/vendors", response_model=Vendor, tags=["Vendors"])
async def create_vendor(vendor: VendorCreate):
"""Create a new vendor"""
name = str(vendor.name or "").strip()
cvr_number = str(vendor.cvr_number or "").strip() or None
if not name:
raise HTTPException(status_code=422, detail="Virksomhedsnavn er påkrævet")
if cvr_number and (len(cvr_number) != 8 or not cvr_number.isdigit()):
raise HTTPException(status_code=422, detail="CVR-nummer skal bestå af 8 cifre")
duplicate = execute_query_single(
"""SELECT id, name FROM vendors
WHERE LOWER(BTRIM(name)) = LOWER(BTRIM(%s))
OR (%s::text IS NOT NULL AND cvr_number = %s)
ORDER BY id LIMIT 1""",
(name, cvr_number, cvr_number),
)
if duplicate:
raise HTTPException(
status_code=409,
detail=f"Leverandøren findes allerede: {duplicate['name']} (#{duplicate['id']})",
)
try:
query = """
INSERT INTO vendors (
name, cvr_number, email, phone, address, postal_code, city,
website, domain, email_pattern, category, priority, notes, is_active
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
website, domain, email_pattern, category, priority, notes, is_active, is_internet_provider
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
vendor.name, vendor.cvr_number, vendor.email, vendor.phone,
name, cvr_number, vendor.email, vendor.phone,
vendor.address, vendor.postal_code, vendor.city, vendor.website,
vendor.domain, vendor.email_pattern, vendor.category, vendor.priority,
vendor.notes, vendor.is_active
vendor.notes, vendor.is_active, vendor.is_internet_provider
)
result = execute_query(query, params)
if not result or len(result) == 0:
raise HTTPException(status_code=500, detail="Failed to create vendor")
logger.info(f"✅ Created vendor: {vendor.name}")
logger.info(f"✅ Created vendor: {name}")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating vendor: {e}")
raise HTTPException(status_code=500, detail=str(e))
@ -272,6 +315,11 @@ async def update_vendor(vendor_id: int, vendor: VendorUpdate):
if vendor.phone is not None:
update_fields.append("phone = %s")
params.append(vendor.phone)
for field in ("address", "postal_code", "city", "website", "economic_supplier_number"):
value = getattr(vendor, field)
if value is not None:
update_fields.append(f"{field} = %s")
params.append(value)
if vendor.address is not None:
update_fields.append("address = %s")
params.append(vendor.address)
@ -302,6 +350,9 @@ async def update_vendor(vendor_id: int, vendor: VendorUpdate):
if vendor.is_active is not None:
update_fields.append("is_active = %s")
params.append(vendor.is_active)
if vendor.is_internet_provider is not None:
update_fields.append("is_internet_provider = %s")
params.append(vendor.is_internet_provider)
if not update_fields:
raise HTTPException(status_code=400, detail="No fields to update")

View File

@ -134,6 +134,9 @@
<a class="nav-link" href="#kunder" data-tab="kunder">
<i class="bi bi-building me-2"></i>Kunder
</a>
<a class="nav-link d-none" href="#internetforbindelser" data-tab="internetforbindelser" id="internetConnectionsNav">
<i class="bi bi-router me-2"></i>Internetforbindelser
</a>
<a class="nav-link" href="#aktivitet" data-tab="aktivitet">
<i class="bi bi-clock-history me-2"></i>Aktivitet
</a>
@ -266,6 +269,21 @@
</div>
</div>
<div class="tab-pane fade" id="internetforbindelser">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold">Internetforbindelser</h5>
<span class="badge bg-primary" id="internetConnectionsCount">0</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead><tr><th>Forbindelse</th><th>Kredsløb</th><th>Adresse</th><th>Kunde</th><th>Hastighed</th><th>Status</th><th></th></tr></thead>
<tbody id="internetConnectionsBody"><tr><td colspan="7" class="text-center text-muted py-4">Indlæser…</td></tr></tbody>
</table>
</div>
</div>
</div>
<!-- Aktivitet Tab -->
<div class="tab-pane fade" id="aktivitet">
<div class="card p-4">
@ -356,6 +374,11 @@
<input class="form-check-input" type="checkbox" id="editIsActive">
<label class="form-check-label" for="editIsActive">Aktiv leverandør</label>
</div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" id="editIsInternetProvider">
<label class="form-check-label" for="editIsInternetProvider">Internetleverandør</label>
<div class="form-text">Kan vælges som leverandør på internetforbindelser.</div>
</div>
</form>
</div>
<div class="modal-footer">
@ -575,6 +598,8 @@ function displayVendor(vendor) {
document.getElementById('vendorStatus').className = `badge ${vendor.is_active ? 'bg-success' : 'bg-secondary'}`;
document.getElementById('vendorDomain').innerHTML = vendor.domain ? `<i class="bi bi-globe me-2"></i>${vendor.domain}` : '';
document.getElementById('vendorCategory').innerHTML = `${getCategoryIcon(vendor.category)} ${vendor.category}`;
document.getElementById('internetConnectionsNav').classList.toggle('d-none', !vendor.is_internet_provider);
if (vendor.is_internet_provider) loadVendorInternetConnections();
// Update page title
document.title = `${vendor.name} - BMC Hub`;
@ -701,6 +726,28 @@ async function loadVendorInvoices() {
}
}
async function loadVendorInternetConnections() {
const body = document.getElementById('internetConnectionsBody');
try {
const response = await fetch(`/api/v1/vendors/${vendorId}/internet-connections`);
if (!response.ok) throw new Error('Kunne ikke hente forbindelser');
const rows = await response.json();
document.getElementById('internetConnectionsCount').textContent = rows.length;
body.innerHTML = rows.length ? rows.map((row) => `
<tr>
<td class="fw-semibold">${escapeHtml(row.name || '-')}</td>
<td>${escapeHtml(row.circuit_number || '-')}</td>
<td>${escapeHtml(row.address || '-')}</td>
<td>${escapeHtml(row.customer_name || 'Ikke allokeret')}</td>
<td>${row.download_mbps || row.upload_mbps ? `${Number(row.download_mbps || 0)}/${Number(row.upload_mbps || 0)} Mbps` : '-'}</td>
<td><span class="badge bg-light text-dark border">${escapeHtml(row.status || '-')}</span></td>
<td class="text-end"><a class="btn btn-sm btn-outline-primary" href="/economy/internet-connections/${row.id}"><i class="bi bi-box-arrow-up-right"></i></a></td>
</tr>`).join('') : '<tr><td colspan="7" class="text-center text-muted py-4">Ingen forbindelser koblet til leverandøren.</td></tr>';
} catch (error) {
body.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-4">Kunne ikke hente forbindelser.</td></tr>';
}
}
function displayInvoices(invoices) {
const tbody = document.getElementById('invoicesTableBody');
const count = document.getElementById('invoiceCount');
@ -832,6 +879,7 @@ function editVendor() {
document.getElementById('editEconomicNumber').value = vendor.economic_supplier_number || '';
document.getElementById('editNotes').value = vendor.notes || '';
document.getElementById('editIsActive').checked = vendor.is_active;
document.getElementById('editIsInternetProvider').checked = Boolean(vendor.is_internet_provider);
new bootstrap.Modal(document.getElementById('editVendorModal')).show();
})
@ -854,7 +902,8 @@ async function saveVendor() {
city: document.getElementById('editCity').value.trim() || null,
economic_supplier_number: document.getElementById('editEconomicNumber').value.trim() || null,
notes: document.getElementById('editNotes').value.trim() || null,
is_active: document.getElementById('editIsActive').checked
is_active: document.getElementById('editIsActive').checked,
is_internet_provider: document.getElementById('editIsInternetProvider').checked
};
if (!data.name) {

View File

@ -51,6 +51,28 @@
font-weight: bold;
font-size: 0.75rem;
}
.vendor-form-section {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 14px;
padding: 1.15rem;
background: var(--bg-card, #fff);
}
.vendor-form-section-title {
display: flex;
align-items: center;
gap: .55rem;
font-weight: 700;
margin-bottom: 1rem;
}
.vendor-type-switch {
border: 1px solid rgba(13, 110, 253, .2);
background: rgba(13, 110, 253, .05);
border-radius: 12px;
padding: 1rem 1rem 1rem 3rem;
}
</style>
{% endblock %}
@ -129,42 +151,80 @@
<!-- Create Vendor Modal -->
<div class="modal fade" id="createVendorModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret Ny Leverandør</h5>
<div>
<div class="small text-uppercase text-muted fw-semibold">Leverandørkartotek</div>
<h5 class="modal-title">Opret ny leverandør</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createVendorForm">
<form id="createVendorForm" novalidate>
<div class="alert d-none" id="createVendorFeedback" role="alert"></div>
<div class="row g-3">
<div class="col-lg-7">
<div class="vendor-form-section h-100">
<div class="vendor-form-section-title"><i class="bi bi-building"></i>Virksomhed</div>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Virksomhedsnavn *</label>
<input type="text" class="form-control" id="name" required>
<label class="form-label" for="name">Virksomhedsnavn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" required maxlength="255" autocomplete="organization" autofocus>
<div class="invalid-feedback">Angiv leverandørens navn.</div>
</div>
<div class="col-md-4">
<label class="form-label">CVR-nummer</label>
<input type="text" class="form-control" id="cvr_number" maxlength="8">
<label class="form-label" for="cvr_number">CVR-nummer</label>
<div class="input-group">
<input type="text" inputmode="numeric" class="form-control" id="cvr_number" maxlength="8" pattern="[0-9]{8}" placeholder="12345678">
<button class="btn btn-outline-primary" type="button" id="vendorCvrLookupBtn" onclick="lookupVendorCvr()">Hent</button>
</div>
<div class="invalid-feedback">CVR skal bestå af 8 cifre.</div>
<div class="form-text" id="vendorCvrLookupStatus">Indtast CVR og klik Hent for autofyld.</div>
</div>
<div class="col-md-6">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="email">
<label class="form-label" for="category">Kategori</label>
<select class="form-select" id="category">
<option value="general">Generel</option><option value="hardware">Hardware</option>
<option value="software">Software</option><option value="telecom">Telekom</option>
<option value="services">Services</option><option value="hosting">Hosting</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Telefon</label>
<input type="text" class="form-control" id="phone">
<label class="form-label" for="domain">Domæne</label>
<input type="text" class="form-control" id="domain" placeholder="example.com" autocomplete="off">
<div class="form-text">Bruges til sikkert mailmatch.</div>
</div>
<div class="col-12"><div class="form-check form-switch vendor-type-switch">
<input class="form-check-input" type="checkbox" id="is_internet_provider">
<label class="form-check-label fw-semibold" for="is_internet_provider">Internetleverandør</label>
<div class="small text-muted">Leverandøren kan vælges på internetforbindelser og får sin egen forbindelsesfane.</div>
</div></div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="vendor-form-section h-100">
<div class="vendor-form-section-title"><i class="bi bi-person-lines-fill"></i>Kontakt</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="email">E-mail</label>
<input type="email" class="form-control" id="email" autocomplete="email">
<div class="invalid-feedback">Angiv en gyldig e-mailadresse.</div>
</div>
<div class="col-md-6">
<label class="form-label">Website</label>
<input type="url" class="form-control" id="website">
<label class="form-label" for="phone">Telefon</label>
<input type="tel" class="form-control" id="phone" autocomplete="tel">
</div>
<div class="col-md-6">
<label class="form-label">Domain</label>
<input type="text" class="form-control" id="domain" placeholder="example.com">
<div class="col-12"><label class="form-label" for="website">Website</label><input type="text" class="form-control" id="website" placeholder="https://example.com" autocomplete="url"></div>
</div>
</div>
</div>
<div class="col-12"><div class="vendor-form-section">
<div class="vendor-form-section-title"><i class="bi bi-geo-alt"></i>Adresse og noter</div><div class="row g-3">
<div class="col-12">
<label class="form-label">Adresse</label>
<input type="text" class="form-control" id="address">
<label class="form-label" for="address">Adresse</label>
<input type="text" class="form-control" id="address" autocomplete="street-address">
</div>
<div class="col-md-3">
<label class="form-label">Postnummer</label>
@ -174,28 +234,20 @@
<label class="form-label">By</label>
<input type="text" class="form-control" id="city">
</div>
<div class="col-md-4">
<label class="form-label">Kategori</label>
<select class="form-select" id="category">
<option value="general">General</option>
<option value="hardware">Hardware</option>
<option value="software">Software</option>
<option value="telecom">Telekom</option>
<option value="services">Services</option>
<option value="hosting">Hosting</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" id="is_active" checked><label class="form-check-label" for="is_active">Aktiv leverandør</label></div></div>
<div class="col-12">
<label class="form-label">Noter</label>
<textarea class="form-control" id="notes" rows="3"></textarea>
</div>
</div>
</div></div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="createVendor()">
<i class="bi bi-check-lg me-2"></i>Opret Leverandør
<button type="submit" form="createVendorForm" class="btn btn-primary" id="createVendorSubmitBtn">
<span class="spinner-border spinner-border-sm me-2 d-none" id="createVendorSpinner"></span><i class="bi bi-check-lg me-2" id="createVendorIcon"></i>Opret leverandør
</button>
</div>
</div>
@ -354,27 +406,109 @@ function nextPage() {
}
function showCreateVendorModal() {
const form = document.getElementById('createVendorForm');
form.reset();
form.classList.remove('was-validated');
document.getElementById('is_active').checked = true;
setVendorCvrStatus('Indtast CVR og klik Hent for autofyld.');
setCreateVendorFeedback();
const modal = new bootstrap.Modal(document.getElementById('createVendorModal'));
modal.show();
}
async function createVendor() {
function setVendorCvrStatus(message, isError = false, isSuccess = false) {
const status = document.getElementById('vendorCvrLookupStatus');
status.textContent = message;
status.className = `form-text${isError ? ' text-danger' : ''}${isSuccess ? ' text-success' : ''}`;
}
function applyVendorCvrData(data) {
if (data.name) document.getElementById('name').value = data.name;
if (data.email) document.getElementById('email').value = data.email;
if (data.phone) document.getElementById('phone').value = data.phone;
if (data.address) document.getElementById('address').value = data.address;
if (data.postal_code || data.zipcode) document.getElementById('postal_code').value = data.postal_code || data.zipcode;
if (data.city) document.getElementById('city').value = data.city;
if (data.website) document.getElementById('website').value = data.website;
const domain = normalizeDomain(data.domain || data.website || (data.email?.split('@')[1] || ''));
if (domain) document.getElementById('domain').value = domain;
}
async function lookupVendorCvr() {
const input = document.getElementById('cvr_number');
const button = document.getElementById('vendorCvrLookupBtn');
const cvr = input.value.replace(/\D/g, '');
input.value = cvr;
if (cvr.length !== 8) {
input.classList.add('is-invalid');
setVendorCvrStatus('CVR skal være præcis 8 cifre.', true);
return;
}
input.classList.remove('is-invalid');
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>';
setVendorCvrStatus('Henter data fra FirmaAPI…');
try {
const response = await fetch(`/api/v1/cvr/${cvr}`);
if (!response.ok) {
if (response.status === 404) throw new Error('CVR blev ikke fundet.');
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || `Opslaget fejlede (HTTP ${response.status}).`);
}
applyVendorCvrData(await response.json());
setVendorCvrStatus('CVR-data hentet og felter autofyldt.', false, true);
} catch (error) {
setVendorCvrStatus(error.message || 'Kunne ikke hente CVR-data.', true);
} finally {
button.disabled = false;
button.textContent = 'Hent';
}
}
function setCreateVendorFeedback(message = '', type = 'danger') {
const feedback = document.getElementById('createVendorFeedback');
feedback.textContent = message;
feedback.className = message ? `alert alert-${type}` : 'alert d-none';
}
function normalizeDomain(value) {
return String(value || '').trim().toLowerCase()
.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].replace(/\.+$/, '');
}
async function createVendor(event) {
event?.preventDefault();
const form = document.getElementById('createVendorForm');
const cvrInput = document.getElementById('cvr_number');
cvrInput.value = cvrInput.value.replace(/\D/g, '');
form.classList.add('was-validated');
if (!form.checkValidity()) {
form.querySelector(':invalid')?.focus();
setCreateVendorFeedback('Kontrollér de markerede felter.');
return;
}
const websiteValue = document.getElementById('website').value.trim();
const submitButton = document.getElementById('createVendorSubmitBtn');
submitButton.disabled = true;
document.getElementById('createVendorSpinner').classList.remove('d-none');
document.getElementById('createVendorIcon').classList.add('d-none');
setCreateVendorFeedback('Opretter leverandøren…', 'info');
const vendor = {
name: document.getElementById('name').value,
cvr_number: document.getElementById('cvr_number').value || null,
email: document.getElementById('email').value || null,
phone: document.getElementById('phone').value || null,
website: document.getElementById('website').value || null,
domain: document.getElementById('domain').value || null,
address: document.getElementById('address').value || null,
postal_code: document.getElementById('postal_code').value || null,
city: document.getElementById('city').value || null,
name: document.getElementById('name').value.trim(),
cvr_number: cvrInput.value || null,
email: document.getElementById('email').value.trim().toLowerCase() || null,
phone: document.getElementById('phone').value.trim() || null,
website: websiteValue ? (/^https?:\/\//i.test(websiteValue) ? websiteValue : `https://${websiteValue}`) : null,
domain: normalizeDomain(document.getElementById('domain').value) || null,
address: document.getElementById('address').value.trim() || null,
postal_code: document.getElementById('postal_code').value.trim() || null,
city: document.getElementById('city').value.trim() || null,
category: document.getElementById('category').value,
priority: parseInt(document.getElementById('priority').value),
notes: document.getElementById('notes').value || null,
is_active: true
notes: document.getElementById('notes').value.trim() || null,
is_active: document.getElementById('is_active').checked,
is_internet_provider: document.getElementById('is_internet_provider').checked
};
try {
@ -384,16 +518,16 @@ async function createVendor() {
body: JSON.stringify(vendor)
});
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('createVendorModal')).hide();
form.reset();
loadVendors();
} else {
alert('Fejl ved oprettelse af leverandør');
}
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || 'Leverandøren kunne ikke oprettes.');
setCreateVendorFeedback('Leverandøren er oprettet. Åbner leverandørkortet…', 'success');
window.setTimeout(() => { window.location.href = `/vendors/${payload.id}`; }, 350);
} catch (error) {
console.error('Error creating vendor:', error);
alert('Kunne ikke oprette leverandør');
setCreateVendorFeedback(error.message || 'Kunne ikke oprette leverandør.');
submitButton.disabled = false;
document.getElementById('createVendorSpinner').classList.add('d-none');
document.getElementById('createVendorIcon').classList.remove('d-none');
}
}
@ -409,6 +543,17 @@ document.getElementById('searchInput').addEventListener('input', (e) => {
});
// Load on page ready
document.addEventListener('DOMContentLoaded', loadVendors);
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('createVendorForm').addEventListener('submit', createVendor);
document.getElementById('cvr_number').addEventListener('input', (event) => {
event.target.value = event.target.value.replace(/\D/g, '').slice(0, 8);
event.target.classList.remove('is-invalid');
setVendorCvrStatus('Indtast CVR og klik Hent for autofyld.');
});
document.getElementById('cvr_number').addEventListener('keydown', (event) => {
if (event.key === 'Enter') { event.preventDefault(); lookupVendorCvr(); }
});
loadVendors();
});
</script>
{% endblock %}

21
main.py
View File

@ -108,6 +108,8 @@ from app.opportunities.frontend import views as opportunities_views
from app.auth.backend import router as auth_api
from app.auth.backend import views as auth_views
from app.auth.backend import admin as auth_admin_api
from app.admin import router as project_ct_admin_api
from app.admin import views as project_ct_admin_views
from app.devportal.backend import router as devportal_api
from app.devportal.backend import views as devportal_views
from app.routers import anydesk
@ -280,6 +282,21 @@ async def lifespan(app: FastAPI):
settings.ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES,
)
if settings.PROJECT_CT_ARCHIVE_SYNC_ENABLED:
from app.jobs.project_ct_archive_sync import run_project_ct_archive_sync
backup_scheduler.scheduler.add_job(
func=run_project_ct_archive_sync,
trigger=IntervalTrigger(minutes=settings.PROJECT_CT_ARCHIVE_SYNC_INTERVAL_MINUTES),
id='project_ct_archive_sync',
name='Project CT permanent vTiger archive sync',
max_instances=1,
replace_existing=True,
)
logger.info(
"✅ Project CT archive sync scheduled (every %d minutes)",
settings.PROJECT_CT_ARCHIVE_SYNC_INTERVAL_MINUTES,
)
backup_scheduler.scheduler.add_job(
func=run_uptime_kuma_sync,
trigger=IntervalTrigger(seconds=120),
@ -485,6 +502,8 @@ app.include_router(bug_reports_api.router, prefix="/api/v1", tags=["Bug Reports"
# Module Routers
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
app.include_router(sag_api.router, prefix="/api/v1", tags=["Cases"])
from app.modules.sag.backend.create_support import router as case_create_support_router
app.include_router(case_create_support_router, prefix="/api/v1", tags=["Cases"])
app.include_router(sag_reminders_api.router, tags=["Reminders"]) # No prefix - endpoints have full path
app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware Module"])
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
@ -505,6 +524,7 @@ app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Bi
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"])
app.include_router(project_ct_admin_api.router, prefix="/api/v1", tags=["Project CT"])
app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"])
app.include_router(migration_center_api.router, prefix="/api/v1/migration-center", tags=["Migration Center"])
app.include_router(website_content_api.router, prefix="/api/v1/website-content", tags=["Website Content"])
@ -549,6 +569,7 @@ app.include_router(internet_connections_views.router, tags=["Frontend"])
app.include_router(invoice_error_finder_views.router, tags=["Frontend"])
app.include_router(migration_center_views.router, tags=["Frontend"])
app.include_router(website_content_views.router, tags=["Frontend"])
app.include_router(project_ct_admin_views.router, tags=["Project CT Frontend"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views

View File

@ -0,0 +1,3 @@
ALTER TABLE user_sag_list_preferences
ADD COLUMN IF NOT EXISTS column_order JSONB NOT NULL DEFAULT '["id","company","contact","description","type","priority","status","owner","group","next_todo","created","start","deferred","deadline"]'::jsonb,
ADD COLUMN IF NOT EXISTS hidden_columns JSONB NOT NULL DEFAULT '[]'::jsonb;

View File

@ -0,0 +1,19 @@
ALTER TABLE internet_connections_connections
ADD COLUMN IF NOT EXISTS is_manual_shared BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE internet_connections_connections parent
SET is_manual_shared = TRUE
WHERE parent.deleted_at IS NULL
AND parent.parent_id IS NULL
AND parent.allocation_model = 'shared'
AND parent.value_type = 'delefiber'
AND NOT EXISTS (
SELECT 1
FROM internet_connections_connections child
WHERE child.parent_id = parent.id
AND child.deleted_at IS NULL
AND (
child.value_type = 'subscription'
OR LOWER(COALESCE(child.value_label, '')) IN ('bmcnet', 'bmc networks')
)
);

View File

@ -0,0 +1,6 @@
ALTER TABLE internet_connections_connections
ADD COLUMN IF NOT EXISTS sla_subscription_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_internet_connections_sla_subscription
ON internet_connections_connections(sla_subscription_id)
WHERE deleted_at IS NULL;

View File

@ -0,0 +1,59 @@
ALTER TABLE vendors
ADD COLUMN IF NOT EXISTS is_internet_provider BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE internet_connections_connections
ADD COLUMN IF NOT EXISTS vendor_id INTEGER REFERENCES vendors(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_vendors_internet_provider
ON vendors(is_internet_provider, is_active);
CREATE INDEX IF NOT EXISTS idx_internet_connections_vendor
ON internet_connections_connections(vendor_id)
WHERE deleted_at IS NULL;
-- Link existing provider text only when it identifies exactly one vendor.
UPDATE internet_connections_connections connection
SET vendor_id = candidate.vendor_id
FROM (
SELECT LOWER(BTRIM(connection.provider)) AS provider_key, MIN(vendor.id) AS vendor_id
FROM internet_connections_connections connection
JOIN vendors vendor ON LOWER(BTRIM(vendor.name)) = LOWER(BTRIM(connection.provider))
WHERE connection.deleted_at IS NULL AND NULLIF(BTRIM(connection.provider), '') IS NOT NULL
GROUP BY LOWER(BTRIM(connection.provider))
HAVING COUNT(DISTINCT vendor.id) = 1
) candidate
WHERE connection.deleted_at IS NULL
AND connection.vendor_id IS NULL
AND LOWER(BTRIM(connection.provider)) = candidate.provider_key;
CREATE OR REPLACE FUNCTION assign_internet_connection_vendor()
RETURNS TRIGGER AS $$
DECLARE
matched_vendor_id INTEGER;
BEGIN
IF NEW.vendor_id IS NULL AND NULLIF(BTRIM(NEW.provider), '') IS NOT NULL THEN
SELECT id INTO matched_vendor_id
FROM vendors
WHERE is_active = TRUE
AND is_internet_provider = TRUE
AND regexp_replace(
regexp_replace(LOWER(name), '(denmark|danmark|a/s|as)', '', 'g'),
'[^a-z0-9]', '', 'g'
) = regexp_replace(
regexp_replace(LOWER(NEW.provider), '(denmark|danmark|a/s|as)', '', 'g'),
'[^a-z0-9]', '', 'g'
)
ORDER BY id
LIMIT 1;
NEW.vendor_id := matched_vendor_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS internet_connections_assign_vendor ON internet_connections_connections;
CREATE TRIGGER internet_connections_assign_vendor
BEFORE INSERT OR UPDATE OF provider, vendor_id
ON internet_connections_connections
FOR EACH ROW
EXECUTE FUNCTION assign_internet_connection_vendor();

View File

@ -0,0 +1,41 @@
CREATE TABLE IF NOT EXISTS ai_benchmark_runs (
id BIGSERIAL PRIMARY KEY,
status VARCHAR(24) NOT NULL DEFAULT 'queued',
models JSONB NOT NULL DEFAULT '[]'::jsonb,
test_keys JSONB NOT NULL DEFAULT '[]'::jsonb,
total_cases INTEGER NOT NULL DEFAULT 0,
completed_cases INTEGER NOT NULL DEFAULT 0,
created_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_text TEXT
);
CREATE TABLE IF NOT EXISTS ai_benchmark_results (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES ai_benchmark_runs(id) ON DELETE CASCADE,
model VARCHAR(160) NOT NULL,
test_key VARCHAR(100) NOT NULL,
test_name VARCHAR(200) NOT NULL,
category VARCHAR(80) NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
max_score INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
prompt_tokens INTEGER,
response_tokens INTEGER,
tokens_per_second NUMERIC(10, 2),
passed_checks JSONB NOT NULL DEFAULT '[]'::jsonb,
failed_checks JSONB NOT NULL DEFAULT '[]'::jsonb,
response_json JSONB,
response_text TEXT,
error_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(run_id, model, test_key)
);
CREATE INDEX IF NOT EXISTS idx_ai_benchmark_runs_created
ON ai_benchmark_runs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ai_benchmark_results_run
ON ai_benchmark_results(run_id, model);

View File

@ -0,0 +1,2 @@
ALTER TABLE ai_benchmark_results
ADD COLUMN IF NOT EXISTS expected_json JSONB;

View File

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS internet_connection_change_cases (
id BIGSERIAL PRIMARY KEY,
connection_id INTEGER NOT NULL REFERENCES internet_connections_connections(id) ON DELETE CASCADE,
source_type VARCHAR(80) NOT NULL,
source_key VARCHAR(255) NOT NULL,
source_label TEXT,
source_url TEXT,
sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(connection_id, source_type, source_key)
);
CREATE INDEX IF NOT EXISTS idx_internet_change_cases_sag
ON internet_connection_change_cases(sag_id);

View File

@ -0,0 +1,85 @@
-- Project CT: permanent, versioned and application-read-only vTiger archive.
CREATE TABLE IF NOT EXISTS vtiger_archive_versions (
id BIGSERIAL PRIMARY KEY,
sync_kind VARCHAR(20) NOT NULL CHECK (sync_kind IN ('full','incremental','final')),
status VARCHAR(20) NOT NULL DEFAULT 'running' CHECK (status IN ('running','completed','failed')),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
source_cutoff TIMESTAMPTZ NOT NULL DEFAULT NOW(),
initiated_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
module_counts JSONB NOT NULL DEFAULT '{}'::jsonb,
warnings JSONB NOT NULL DEFAULT '[]'::jsonb,
critical_errors JSONB NOT NULL DEFAULT '[]'::jsonb,
control_report JSONB NOT NULL DEFAULT '{}'::jsonb,
control_approved_at TIMESTAMPTZ,
control_approved_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
raw_export_sha256 VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS vtiger_archive_records (
id BIGSERIAL PRIMARY KEY,
version_id BIGINT NOT NULL REFERENCES vtiger_archive_versions(id) ON DELETE RESTRICT,
module VARCHAR(80) NOT NULL,
vtiger_id VARCHAR(120) NOT NULL,
revision_no INTEGER NOT NULL,
source_created_at TIMESTAMPTZ,
source_modified_at TIMESTAMPTZ,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
payload JSONB NOT NULL,
payload_sha256 VARCHAR(64) NOT NULL,
archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(module, vtiger_id, revision_no),
UNIQUE(version_id, module, vtiger_id, payload_sha256)
);
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_records_lookup
ON vtiger_archive_records(module, vtiger_id, revision_no DESC);
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_records_version
ON vtiger_archive_records(version_id, module);
CREATE TABLE IF NOT EXISTS vtiger_archive_relations (
id BIGSERIAL PRIMARY KEY,
version_id BIGINT NOT NULL REFERENCES vtiger_archive_versions(id) ON DELETE RESTRICT,
source_module VARCHAR(80) NOT NULL,
source_vtiger_id VARCHAR(120) NOT NULL,
field_name VARCHAR(120) NOT NULL,
target_vtiger_id VARCHAR(120) NOT NULL,
target_module VARCHAR(80),
UNIQUE(version_id, source_module, source_vtiger_id, field_name, target_vtiger_id)
);
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_relations_target
ON vtiger_archive_relations(target_vtiger_id);
CREATE TABLE IF NOT EXISTS vtiger_archive_checkpoints (
module VARCHAR(80) PRIMARY KEY,
last_modified_at TIMESTAMPTZ,
last_vtiger_id VARCHAR(120),
last_successful_version_id BIGINT REFERENCES vtiger_archive_versions(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS hub_impact_reports (
id BIGSERIAL PRIMARY KEY,
archive_version_id BIGINT NOT NULL REFERENCES vtiger_archive_versions(id) ON DELETE RESTRICT,
vtiger_from DATE NOT NULL,
vtiger_to DATE NOT NULL,
hub_from DATE NOT NULL,
hub_to DATE NOT NULL,
filters JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB NOT NULL,
result_sha256 VARCHAR(64) NOT NULL,
generated_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE OR REPLACE FUNCTION deny_vtiger_archive_mutation() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'vTiger archive revisions are append-only';
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_vtiger_archive_records_immutable ON vtiger_archive_records;
CREATE TRIGGER trg_vtiger_archive_records_immutable
BEFORE UPDATE OR DELETE ON vtiger_archive_records
FOR EACH ROW EXECUTE FUNCTION deny_vtiger_archive_mutation();

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS vtiger_archive_transfers (
id BIGSERIAL PRIMARY KEY,
direction VARCHAR(10) NOT NULL CHECK (direction IN ('export','import')),
bundle_sha256 VARCHAR(64),
through_version_id BIGINT,
source_instance VARCHAR(255),
status VARCHAR(20) NOT NULL CHECK (status IN ('running','completed','failed')),
counts JSONB NOT NULL DEFAULT '{}'::jsonb,
error_message TEXT,
initiated_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_transfers_started ON vtiger_archive_transfers(started_at DESC);

View File

@ -0,0 +1,21 @@
-- Project CT: preserve actual vTiger document bytes before the subscription ends.
CREATE TABLE IF NOT EXISTS vtiger_archive_files (
id BIGSERIAL PRIMARY KEY,
version_id BIGINT NOT NULL REFERENCES vtiger_archive_versions(id) ON DELETE RESTRICT,
document_vtiger_id VARCHAR(120) NOT NULL,
resource_vtiger_id VARCHAR(120) NOT NULL,
filename TEXT NOT NULL,
content_type TEXT,
size_bytes BIGINT NOT NULL,
content_sha256 VARCHAR(64) NOT NULL,
content BYTEA NOT NULL,
archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(document_vtiger_id, resource_vtiger_id, content_sha256)
);
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_files_version
ON vtiger_archive_files(version_id);
DROP TRIGGER IF EXISTS trg_vtiger_archive_files_immutable ON vtiger_archive_files;
CREATE TRIGGER trg_vtiger_archive_files_immutable
BEFORE UPDATE OR DELETE ON vtiger_archive_files
FOR EACH ROW EXECUTE FUNCTION deny_vtiger_archive_mutation();

View File

@ -0,0 +1,5 @@
-- Document bytes can originate from Documents or email attachments.
ALTER TABLE vtiger_archive_files
ADD COLUMN IF NOT EXISTS source_module VARCHAR(80) NOT NULL DEFAULT 'Documents';
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_files_source
ON vtiger_archive_files(source_module, document_vtiger_id);

View File

@ -0,0 +1,11 @@
-- Direct, customer-independent links between cases and internet connections.
CREATE TABLE IF NOT EXISTS sag_internet_connections (
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
connection_id INTEGER NOT NULL REFERENCES internet_connections_connections(id) ON DELETE RESTRICT,
linked_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (sag_id, connection_id)
);
CREATE INDEX IF NOT EXISTS idx_sag_internet_connections_connection
ON sag_internet_connections(connection_id, created_at DESC);

View File

@ -0,0 +1,73 @@
-- Production-ready case solutions and the first, read-only knowledge base release.
ALTER TABLE sag_solutions
ADD COLUMN IF NOT EXISTS problem TEXT,
ADD COLUMN IF NOT EXISTS root_cause TEXT,
ADD COLUMN IF NOT EXISTS investigation TEXT,
ADD COLUMN IF NOT EXISTS workaround TEXT,
ADD COLUMN IF NOT EXISTS visibility VARCHAR(30) NOT NULL DEFAULT 'internal',
ADD COLUMN IF NOT EXISTS approval_status VARCHAR(30) NOT NULL DEFAULT 'draft',
ADD COLUMN IF NOT EXISTS is_final BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS tags JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS products JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS updated_by_user_id INTEGER,
ADD COLUMN IF NOT EXISTS approved_by_user_id INTEGER,
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP;
CREATE TABLE IF NOT EXISTS sag_solution_versions (
id BIGSERIAL PRIMARY KEY,
solution_id INTEGER NOT NULL REFERENCES sag_solutions(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
snapshot JSONB NOT NULL,
changed_by_user_id INTEGER,
change_note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (solution_id, version_number)
);
CREATE INDEX IF NOT EXISTS idx_sag_solution_versions_solution
ON sag_solution_versions(solution_id, version_number DESC);
CREATE TABLE IF NOT EXISTS knowledge_articles (
id BIGSERIAL PRIMARY KEY,
solution_id INTEGER NOT NULL UNIQUE REFERENCES sag_solutions(id) ON DELETE RESTRICT,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE RESTRICT,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
summary TEXT,
problem TEXT,
root_cause TEXT,
investigation TEXT,
solution TEXT NOT NULL,
workaround TEXT,
visibility VARCHAR(30) NOT NULL DEFAULT 'internal',
status VARCHAR(30) NOT NULL DEFAULT 'published',
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
products JSONB NOT NULL DEFAULT '[]'::jsonb,
version_number INTEGER NOT NULL DEFAULT 1,
published_by_user_id INTEGER,
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
reviewed_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
search_document TSVECTOR GENERATED ALWAYS AS (
to_tsvector('simple',
coalesce(title, '') || ' ' || coalesce(summary, '') || ' ' ||
coalesce(problem, '') || ' ' || coalesce(root_cause, '') || ' ' ||
coalesce(investigation, '') || ' ' || coalesce(solution, '') || ' ' ||
coalesce(workaround, '') || ' ' || coalesce(tags::text, '') || ' ' ||
coalesce(products::text, '')
)
) STORED
);
CREATE INDEX IF NOT EXISTS idx_knowledge_articles_search
ON knowledge_articles USING GIN(search_document);
CREATE INDEX IF NOT EXISTS idx_knowledge_articles_scope
ON knowledge_articles(status, visibility, customer_id, updated_at DESC);
-- Preserve the two existing solutions as drafts; publication always requires an explicit approval.
UPDATE sag_solutions
SET approval_status = COALESCE(NULLIF(approval_status, ''), 'draft'),
visibility = COALESCE(NULLIF(visibility, ''), 'internal')
WHERE approval_status IS NULL OR approval_status = '' OR visibility IS NULL OR visibility = '';

View File

@ -0,0 +1,12 @@
-- Central solution management: reversible archiving without losing case history.
ALTER TABLE sag_solutions
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS deleted_by_user_id INTEGER;
CREATE INDEX IF NOT EXISTS idx_sag_solutions_management
ON sag_solutions(deleted_at, approval_status, visibility, updated_at DESC);
ALTER TABLE knowledge_articles
ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS archived_by_user_id INTEGER;

View File

@ -0,0 +1,16 @@
-- Local standard selling prices for products offered from a shared/delefiber.
CREATE TABLE IF NOT EXISTS internet_connections_delefiber_product_prices (
id SERIAL PRIMARY KEY,
connection_id INTEGER NOT NULL REFERENCES internet_connections_connections(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
monthly_price NUMERIC(12,2) NOT NULL CHECK (monthly_price >= 0),
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (connection_id, product_id)
);
CREATE INDEX IF NOT EXISTS idx_delefiber_product_prices_connection
ON internet_connections_delefiber_product_prices(connection_id)
WHERE is_active = TRUE;

View File

@ -0,0 +1,32 @@
-- Apple BMC Mobile Recorder provisioning support.
-- The serial number remains the physical-device identity. Device-specific values
-- (UDID, ECID, IMEI, Wi-Fi MAC and iOS) live in hardware_specs for extensibility.
ALTER TABLE hardware_assets
ADD COLUMN IF NOT EXISTS recorder_number INTEGER,
ADD COLUMN IF NOT EXISTS provisioned_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS last_provisioned_at TIMESTAMP;
ALTER TABLE hardware_assets DROP CONSTRAINT IF EXISTS hardware_assets_asset_type_check;
ALTER TABLE hardware_assets ADD CONSTRAINT hardware_assets_asset_type_check
CHECK (asset_type IN ('pc', 'laptop', 'printer', 'skærm', 'telefon', 'server', 'netværk', 'andet', 'mobile_recorder'));
ALTER TABLE hardware_assets DROP CONSTRAINT IF EXISTS hardware_assets_status_check;
ALTER TABLE hardware_assets ADD CONSTRAINT hardware_assets_status_check
CHECK (status IN ('active', 'ready', 'faulty_reported', 'in_repair', 'replaced', 'retired', 'unsupported'));
CREATE INDEX IF NOT EXISTS idx_hardware_mobile_recorder_number
ON hardware_assets(recorder_number)
WHERE deleted_at IS NULL AND recorder_number IS NOT NULL;
CREATE TABLE IF NOT EXISTS hardware_provisioning_history (
id BIGSERIAL PRIMARY KEY,
hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE,
action VARCHAR(16) NOT NULL CHECK (action IN ('created', 'updated')),
source VARCHAR(80) NOT NULL DEFAULT 'apple_configurator',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
provisioned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_hardware_provisioning_history_asset
ON hardware_provisioning_history(hardware_id, provisioned_at DESC);

View File

@ -0,0 +1,106 @@
-- Manual: Apple Configurator / cfgutil provisioning of BMC Mobile Recorders.
INSERT INTO manual_articles (title, slug, content, summary, module, tags, difficulty)
VALUES (
'Provisionér en BMC Mobile Recorder med Apple Configurator',
'provisioner-bmc-mobile-recorder-med-apple-configurator',
$guide$
Formål
Denne guide kobler provisioning-scriptet Macen direkte til Hub Assets. Når cfgutil er færdig med en iPhone, sender scriptet dens hardwaredata til Hub. Hub identificerer altid den fysiske telefon serienummeret.
Workflow
iPhone tilsluttes cfgutil provisionerer scriptet aflæser enhedsdata Hub opretter eller opdaterer Asset Recorder står som Ready.
Endpoint
POST /api/v1/assets/provision
Autentificering
Endpointet er kun til servicekonti og kræver ikke brugerlogin. Send token i én af disse headers:
X-Provisioning-Token: <token>
eller
Authorization: Bearer <token>
Hub-serveren skal token ligge som miljøvariabel:
MOBILE_RECORDER_PROVISIONING_TOKEN=<lang-tilfældig-hemmelig-token>
Sikkerhed
Gem aldrig token i Git, i et shared script eller i en e-mail. Gem den i Macens Keychain eller et lokalt, adgangsbeskyttet environment-script. Er token ikke sat Hub-serveren, afviser endpointet alle kald.
Vigtige regler
serial_number er den eneste identitet for den fysiske iPhone.
Findes serienummeret allerede blandt aktive Assets, opdateres samme Asset.
Findes serienummeret ikke, oprettes et nyt Asset.
Hvis to aktive Assets har samme serienummer, stopper Hub med HTTP 409. Flet dubletterne før provisioning fortsætter.
Provisioning sætter typen mobile_recorder, BMC som ejer og status ready.
Data som gemmes
Navn, recorder-nummer, Apple-producent, model/device type, serienummer, UDID, ECID, IMEI, WiFi MAC, iOS-version og supervised-status. Hvert kald gemmes også i Assetets provisioning-historik.
Eksempel JSON-payload
{
"name": "BMC Recorder #24",
"asset_type": "mobile_recorder",
"manufacturer": "Apple",
"recorder_number": 24,
"model": "iPhone 15",
"serial_number": "XXXXXXXX",
"udid": "XXXXXXXX",
"ecid": "XXXXXXXX",
"imei": "XXXXXXXX",
"wifi_mac": "XX:XX:XX:XX:XX:XX",
"os": "iOS",
"os_version": "18.0",
"supervised": true,
"status": "ready"
}
Succesrespons
{
"success": true,
"action": "created",
"asset_id": 1842,
"recorder_number": 24
}
action er created ved første registrering og updated ved senere provisioning af samme serienummer.
Fejlhåndtering
401: Forkert eller manglende token.
409: Flere aktive Assets bruger samme serienummer. Flet/ryd dubletten op først.
422: Payload mangler serienummer eller har en forkert asset_type/status.
503: MOBILE_RECORDER_PROVISIONING_TOKEN er ikke sat Hub-serveren.
Fremtidig udlevering
En Recorder oprettes som BMC-ejet og Ready. Når den udleveres, skal den efterfølgende knyttes til kunde og kontaktperson via Assetets ejerskabs- og kontaktfunktioner. Det bevarer historikken for udlevering, returnering og eventuel udskiftning.
$guide$,
'Provisionér en iPhone med cfgutil og opret eller opdatér automatisk BMC Mobile Recorder-assetet i Hub.',
'hardware',
'["apple", "cfgutil", "iphone", "mobile-recorder", "provisioning", "assets"]'::jsonb,
'advanced'
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
content = EXCLUDED.content,
summary = EXCLUDED.summary,
module = EXCLUDED.module,
tags = EXCLUDED.tags,
difficulty = EXCLUDED.difficulty,
deleted_at = NULL,
updated_at = CURRENT_TIMESTAMP;
WITH article AS (
SELECT id FROM manual_articles
WHERE slug = 'provisioner-bmc-mobile-recorder-med-apple-configurator'
)
DELETE FROM manual_steps WHERE manual_id IN (SELECT id FROM article);
INSERT INTO manual_steps (manual_id, step_number, title, content)
SELECT article.id, step.step_number, step.title, step.content
FROM (SELECT id FROM manual_articles WHERE slug = 'provisioner-bmc-mobile-recorder-med-apple-configurator') article
CROSS JOIN (VALUES
(1, 'Sæt service-token på Hub', 'Sæt MOBILE_RECORDER_PROVISIONING_TOKEN som hemmelig miljøvariabel på Hub-serveren og genstart API-containeren. Brug en lang, tilfældig token.'),
(2, 'Gem token sikkert på provisioning-Mac', 'Læg token i Keychain eller et lokalt environment-script. Den må ikke committed til Git eller ligge i en fælles mappe.'),
(3, 'Provisionér iPhone med cfgutil', 'Kør den sædvanlige Apple Configurator/cfgutil-provisionering. Aflæs først device name, serienummer, UDID, ECID, IMEI, WiFi MAC, model og iOS-version.'),
(4, 'Post data til Hub', 'Kald POST /api/v1/assets/provision med Content-Type application/json og X-Provisioning-Token. Brug serienummeret fra den tilsluttede iPhone.'),
(5, 'Kontrollér responsen', 'success=true og action=created betyder nyt Asset. action=updated betyder, at samme fysiske iPhone er opdateret. Ved 409 skal serienummer-dubletter i Assets ryddes op før ny kørsel.'),
(6, 'Udlever eller returnér senere', 'Recorderen er nu BMC-ejet og Ready. Brug Assetets ejerskab/kontakter, når den udleveres til en kunde eller bruger, så historikken holdes samlet.')
) AS step(step_number, title, content);

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS case_create_templates (
id SERIAL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
icon VARCHAR(80) NOT NULL DEFAULT 'bi-lightning',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
template_values JSONB NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(template_values) = 'object'),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

View File

@ -0,0 +1,5 @@
ALTER TABLE bottom_bar_messages
ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT '';

View File

@ -0,0 +1,34 @@
## Plan: Website Content Administration
Implementere et nyt `website_content`-modul i HUB, der skriver direkte til den eksterne MySQL-database `bmcnetworks_26`.
**Hoveddele**
1. Tilføje MySQL-konfiguration, connection pool og transaktionshåndtering.
2. Oprette CRUD for:
- Kundereferencer
- Aktuel driftsstatus
- Driftshistorik
3. Implementere “Afslut hændelse og flyt til historik” som én MySQL-transaktion.
4. Bruge soft-hide via `is_active`/`is_public`; ingen DELETE-operationer.
5. Tilføje adgangskontrol med egne rettigheder som `website_content.view` og `website_content.edit`.
6. Tilføje responsiv Nordic Top-administrationsside med:
- Logo-upload og preview
- Rækkefølge
- Status-severity
- Planlagte start/sluttider
- Historik
- Dark mode-kompatibilitet
7. Registrere API, frontend-route og navigation i HUB.
8. Tilføje fokuserede tests med mocket MySQL.
**Logo-upload**
Den eksisterende `logo_url VARCHAR(500)` kan ikke indeholde almindelige billedfiler. Derfor skal website-databasen udvides med eksempelvis `logo_blob` og `logo_mime_type`, og website-projektets `content.php` skal suppleres med en billedendpoint.
HUB-delen kan implementeres i dette workspace. Website-ændringerne kræver adgang til det separate projekt:
- `/Users/christianthomas/DEV/new bmcnetworks/api/content.php`
- `/Users/christianthomas/DEV/new bmcnetworks/sql/pending/001_schema.sql`
Planen er gemt i sessionen. Godkend planen, så går jeg videre med implementationen.

View File

@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Small, deterministic CRM benchmark for models exposed through Ollama."""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.request
CASES = [
{
"name": "contact_signature",
"prompt": """Extract contact data from this email as JSON with exactly these keys:
name, title, company, mobile, email. Do not mistake greetings such as 'Kind regards'
for a job title. Use null when a value is absent.
Kind regards
Ida Gundersen
Technical Advisor & Co-owner
Mobile: +45 42 25 59 08 DK: +45 55 86 05 00
Email: ida@createx-onstage.com
Web: createx-onstage.com
Createx
Storegade 4C | 4780 Stege, DK""",
"checks": [
("name", lambda value: "ida gundersen" in value.lower()),
("title", lambda value: "technical advisor" in value.lower() and "kind regards" not in value.lower()),
("company", lambda value: "createx" in value.lower()),
("mobile", lambda value: "42255908" in re.sub(r"\D", "", value)),
("email", lambda value: value.lower() == "ida@createx-onstage.com"),
],
},
{
"name": "internet_invoice",
"prompt": """Extract this supplier invoice line as JSON with exactly these keys:
reference, address, postal_code, city, ip_ranges, purchase_price_dkk.
ip_ranges must be an array and purchase_price_dkk a number.
GlobalConnect faktura 3018657
NKA-027964 | Firskovvej 36 | 2800 Kongens Lyngby | Internet 1 Gbit
IP: 217.74.219.56/30 og 152.115.61.32/27 | Månedlig kostpris: 2.495,00 kr.""",
"checks": [
("reference", lambda value: "027964" in value),
("address", lambda value: "firskovvej 36" in value.lower()),
("postal_code", lambda value: str(value) == "2800"),
("city", lambda value: "lyngby" in value.lower()),
("ip_ranges", lambda value: set(value) == {"217.74.219.56/30", "152.115.61.32/27"}),
("purchase_price_dkk", lambda value: abs(float(value) - 2495.0) < 0.01),
],
},
{
"name": "support_solution",
"prompt": """Analyze this support case and return JSON with exactly these keys:
category, probable_cause, diagnostic_steps, customer_reply. diagnostic_steps must be
an array of short actions. Do not claim the problem has been fixed.
Customer: Internet works by IP address, but websites do not open by name on all PCs.
The router is reachable and 8.8.8.8 responds to ping. The issue started after the
customer changed DNS settings this morning.""",
"checks": [
("cause", lambda value: "dns" in json.dumps(value).lower()),
("steps", lambda value: isinstance(value, list) and len(value) >= 2),
("uncertainty", lambda value: not any(word in value.lower() for word in ("løst", "fixed", "resolved"))),
],
},
{
"name": "danish_rewrite",
"prompt": """Rewrite this as a concise, professional Danish customer email. Preserve every
fact and do not invent a resolution. Return JSON with exactly one key: text.
hej vi kan se jeres forbindelse NKA-027964 Firskovvej 36 har været nede siden
kl 08:15. vi undersøger det hos globalconnect og vender tilbage senest kl 10:00.""",
"checks": [
("reference", lambda value: "NKA-027964" in value),
("address", lambda value: "Firskovvej 36" in value),
("times", lambda value: "08:15" in value and "10:00" in value),
("supplier", lambda value: "globalconnect" in value.lower()),
],
},
]
def ask(endpoint: str, model: str, prompt: str, timeout: int) -> tuple[dict, float]:
payload = {
"model": model,
"stream": False,
"think": False,
"format": "json",
"messages": [
{"role": "system", "content": "Return only valid JSON. Follow the requested schema exactly."},
{"role": "user", "content": prompt},
],
"options": {"temperature": 0, "num_ctx": 8192},
}
started = time.perf_counter()
request = urllib.request.Request(
f"{endpoint.rstrip('/')}/api/chat",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
envelope = json.load(response)
elapsed = time.perf_counter() - started
content = envelope.get("message", {}).get("content", "")
return json.loads(content), elapsed
def score(case: dict, result: dict) -> tuple[int, list[str]]:
passed = []
if case["name"] == "support_solution":
values = {
"cause": result,
"steps": result.get("diagnostic_steps"),
"uncertainty": str(result.get("customer_reply", "")),
}
elif case["name"] == "danish_rewrite":
values = {label: str(result.get("text", "")) for label, _ in case["checks"]}
else:
values = result
for label, check in case["checks"]:
try:
if check(values.get(label) if isinstance(values, dict) else values):
passed.append(label)
except (AttributeError, TypeError, ValueError):
pass
return len(passed), passed
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--endpoint", default="http://172.16.31.195:11434")
parser.add_argument("--models", nargs="+", default=["qwen2.5:7b", "qwen3.5:9b"])
parser.add_argument("--cases", nargs="+", help="Run only the named benchmark cases")
parser.add_argument("--timeout", type=int, default=180)
args = parser.parse_args()
report = []
for model in args.models:
model_score = 0
model_max = 0
model_seconds = 0.0
cases = []
selected_cases = [case for case in CASES if not args.cases or case["name"] in args.cases]
for case in selected_cases:
maximum = len(case["checks"])
model_max += maximum
try:
result, seconds = ask(args.endpoint, model, case["prompt"], args.timeout)
points, passed = score(case, result)
error = None
except Exception as exc: # benchmark must report failures and continue
result, seconds, points, passed, error = None, 0.0, 0, [], str(exc)
model_score += points
model_seconds += seconds
cases.append({
"case": case["name"], "score": points, "max": maximum,
"seconds": round(seconds, 2), "passed": passed,
"error": error, "result": result,
})
report.append({
"model": model, "score": model_score, "max": model_max,
"seconds": round(model_seconds, 2), "cases": cases,
})
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Idempotent correction of verified circuit, address, speed and IP reference data."""
from __future__ import annotations
import ipaddress
from app.core.database import execute_insert, execute_query_single, execute_update, init_db
CONNECTIONS = [
("NKA-021426", "Tobaksvej 25, 2860 Søborg", 100, ["83.151.156.52/30", "77.233.233.208/28"]),
("NKA-023762", "Lundbygaardsvej 100, 4750 Lundby", 200, ["77.233.233.80/28", "152.115.111.240/30"]),
("NKA-020900", "Rydagervej 27, 2620 Albertslund", 1000, ["87.116.1.200/29"]),
("NKA-023036", "Slotsmarken 18, 2970 Hørsholm", 500, ["152.115.140.8/30", "217.195.179.0/26"]),
("NKA-022948", "Oldenburg Alle 7, 2630 Taastrup", 200, ["130.185.140.120/30", "62.116.202.0/28"]),
("NKA-022949", "Borupvang 2B, 2750 Ballerup", 500, ["152.115.70.140/30", "87.116.23.0/28"]),
("NKA-023763", "Slotsmarken 10, 2970 Hørsholm", 200, ["152.115.137.60/30", "62.116.202.96/28"]),
("NKA-024219", "Mileparken 22, 2740 Skovlunde", 1000, ["152.115.111.232/30", "152.115.63.128/26"]),
("NML-024495", "Lundbygaardsvej 100, 4750 Lundby", None, ["152.115.111.236/30"]),
("NKA-027783", "Ejby Industrivej 1, 2600 Glostrup", 1000, []),
("NKA-027784", "Møgelbakken 8, 8520 Lystrup", 1000, ["130.185.141.92/30"]),
("NKA-028639", "Slotsmarken 11, 2970 Hørsholm", 100, ["152.115.36.80/28"]),
("NKA-025021", "Broenge 4, 2635 Ishøj", 300, ["152.115.107.168/30", "130.185.134.44/30"]),
("NKA-031137", "Marielundvej 30, 2730 Herlev", 500, ["217.74.209.220/30"]),
("NKA-031083", "Slotsmarken 17, 2970 Hørsholm", 100, ["93.176.69.96/30"]),
("NKA-031131", "Sankt Kunds vej 26, 1903 Frederiksberg C", 500, ["152.115.178.192/30"]),
("NKA-021047", "Herstedvang 14, 2620 Albertslund", 300, ["83.136.94.128/26"]),
("NKA-027964", "Firskovvej 36, 2800 Kongens Lyngby", 1000, ["217.74.219.56/30", "152.115.61.32/27"]),
("HB944140", "Lejrvej 17-19, 3500 Værløse", 5000, []),
("NKA-021275", "Ejby Industrivej 1, 2600 Glostrup", 1000, ["87.116.30.96/27", "5.56.159.32/29"]),
]
def reference_key(value: str) -> str:
return "".join(character for character in value.upper() if character.isalnum())
def reconcile() -> dict[str, int]:
result = {"created_connections": 0, "updated_connections": 0, "created_ranges": 0, "updated_ranges": 0}
for reference, address, speed, cidrs in CONNECTIONS:
connection = execute_query_single(
"""SELECT id FROM internet_connections_connections
WHERE deleted_at IS NULL
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = %s
ORDER BY id DESC LIMIT 1""",
(reference_key(reference),),
)
if connection:
connection_id = int(connection["id"])
execute_update(
"""UPDATE internet_connections_connections
SET circuit_number=%s, address=%s,
speed_mbps=COALESCE(%s, speed_mbps),
download_mbps=COALESCE(%s, download_mbps),
upload_mbps=COALESCE(%s, upload_mbps), updated_at=CURRENT_TIMESTAMP
WHERE id=%s""",
(reference, address, speed, speed, speed, connection_id),
)
result["updated_connections"] += 1
else:
connection_id = execute_insert(
"""INSERT INTO internet_connections_connections
(name,provider,address,status,circuit_number,speed_mbps,download_mbps,upload_mbps,
monthly_cost,sales_price,allocation_model,value_type,notes)
VALUES (%s,'GlobalConnect A/S',%s,'pending',%s,%s,%s,%s,0,0,'dedicated','other',
'Oprettet fra verificeret kredsløbsoversigt. Kunde tildeles manuelt.') RETURNING id""",
(address, address, reference, speed, speed, speed),
)
result["created_connections"] += 1
for raw_cidr in cidrs:
cidr = str(ipaddress.ip_network(raw_cidr.replace(" ", ""), strict=False))
ip_range = execute_query_single(
"""SELECT id FROM internet_connections_ip_ranges
WHERE deleted_at IS NULL AND cidr::cidr=%s::cidr ORDER BY id LIMIT 1""",
(cidr,),
)
if ip_range:
execute_update(
"""UPDATE internet_connections_ip_ranges
SET connection_id=%s, provider_reference=%s, service_address=%s,
updated_at=CURRENT_TIMESTAMP WHERE id=%s""",
(connection_id, reference, address, ip_range["id"]),
)
result["updated_ranges"] += 1
else:
execute_insert(
"""INSERT INTO internet_connections_ip_ranges
(connection_id,name,cidr,description,provider_reference,service_address,monthly_cost,sales_price)
VALUES (%s,%s,%s,'Oprettet fra verificeret kredsløbsoversigt.',%s,%s,0,0) RETURNING id""",
(connection_id, f"IPv4 · {cidr}", cidr, reference, address),
)
result["created_ranges"] += 1
return result
if __name__ == "__main__":
init_db()
print(reconcile())

View File

@ -31,6 +31,79 @@ def latest_globalconnect_extractions() -> list[dict]:
return [dict(row) for row in rows]
def snapshot_manual_allocations() -> list[dict]:
"""Keep deliberate CRM ownership across a destructive invoice rebuild.
Supplier imports must never infer a customer. A clean rebuild previously
also discarded customers that a user had already selected, which allowed a
later, shifted invoice address to become the new canonical address.
"""
rows = execute_query(
"""
SELECT DISTINCT ON (
regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g')
)
regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') AS reference_key,
circuit_number,
customer_id,
address
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND customer_id IS NOT NULL
AND BTRIM(COALESCE(circuit_number, '')) <> ''
ORDER BY
regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g'),
updated_at DESC,
id DESC
"""
) or []
return [dict(row) for row in rows]
def restore_manual_allocations(allocations: list[dict]) -> int:
restored = 0
for allocation in allocations:
reference_key = str(allocation.get("reference_key") or "").strip()
if not reference_key:
continue
connection = execute_query_single(
"""
SELECT id
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = %s
ORDER BY id DESC
LIMIT 1
""",
(reference_key,),
)
if not connection:
continue
connection_id = int(connection["id"])
execute_update(
"""
UPDATE internet_connections_connections
SET customer_id = %s,
address = COALESCE(NULLIF(BTRIM(%s), ''), address),
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(allocation.get("customer_id"), allocation.get("address"), connection_id),
)
if str(allocation.get("address") or "").strip():
execute_update(
"""
UPDATE internet_connections_ip_ranges
SET service_address = %s,
updated_at = CURRENT_TIMESTAMP
WHERE connection_id = %s AND deleted_at IS NULL
""",
(allocation["address"], connection_id),
)
restored += 1
return restored
def reset_all_internet_data() -> dict:
summary: dict[str, int] = {}
counts = execute_query_single(
@ -94,7 +167,7 @@ def reset_all_internet_data() -> dict:
return summary
def rebuild_from_globalconnect() -> dict:
def rebuild_from_globalconnect(manual_allocations: list[dict] | None = None) -> dict:
extractions = latest_globalconnect_extractions()
results = []
totals = defaultdict(int)
@ -110,7 +183,9 @@ def rebuild_from_globalconnect() -> dict:
)
if not extraction:
continue
result = _sync_globalconnect_extraction_to_internet(dict(extraction))
# A reset intentionally rebuilds previously processed invoices, so the
# normal idempotency guard must not skip their historical sync runs.
result = _sync_globalconnect_extraction_to_internet(dict(extraction), force=True)
result_summary = {
"file_id": extraction_stub["file_id"],
"extraction_id": extraction_stub["extraction_id"],
@ -133,6 +208,7 @@ def rebuild_from_globalconnect() -> dict:
totals["skipped_connection_lines"] += result_summary["skipped_connection_lines"]
totals["skipped_ip_range_lines"] += result_summary["skipped_ip_range_lines"]
restored_allocations = restore_manual_allocations(manual_allocations or [])
counts = execute_query_single(
"""
SELECT
@ -151,6 +227,7 @@ def rebuild_from_globalconnect() -> dict:
"active_ip_ranges": int(counts.get("active_ip_ranges") or 0),
"active_ip_addresses": int(counts.get("active_ip_addresses") or 0),
},
"restored_manual_allocations": restored_allocations,
}
@ -160,9 +237,10 @@ def main() -> int:
args = parser.parse_args()
init_db()
manual_allocations = snapshot_manual_allocations()
payload = {
"reset": reset_all_internet_data(),
"rebuild": None if args.skip_rebuild else rebuild_from_globalconnect(),
"rebuild": None if args.skip_rebuild else rebuild_from_globalconnect(manual_allocations),
}
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
return 0

View File

@ -47,6 +47,7 @@
activeThreadKey: ''
};
const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1';
window.addEventListener('hub:internal-message-sent', function () { fetchBottomBarState(); });
let notesApiUnavailable = false;
let driftSummaryRefreshTimer = null;
let markMessagesReadPromise = null;
@ -198,7 +199,7 @@
messageItems.forEach(function (item) {
const own = !!item.is_own;
const partnerId = own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0);
const partnerId = item.recipient_user_id == null ? 0 : (own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0));
const key = partnerId > 0 ? ('user:' + partnerId) : 'broadcast';
const label = partnerId > 0
? String(own ? (item.to || ('Bruger #' + partnerId)) : (item.from || ('Bruger #' + partnerId)))
@ -309,20 +310,8 @@
}
function getRenderableMessageThreads(activeThread) {
const seen = new Set();
const out = [];
if (activeThread && activeThread.key) {
out.push(activeThread);
seen.add(activeThread.key);
}
getMessageThreads().forEach(function (thread) {
if (seen.has(thread.key)) return;
seen.add(thread.key);
out.push(thread);
});
const out = getMessageThreads();
if (activeThread?.key && !out.some(thread => thread.key === activeThread.key)) out.unshift(activeThread);
return out;
}
@ -805,6 +794,7 @@
cases: Number(cases.open || 0),
urgent: Number(urgent.count || 0),
unassigned: Number(unassigned.count || 0),
procurement: Number((sections.procurement || {}).to_order || 0),
timer: Number(timer.active_count || 0),
drift: Number(drift.down || 0),
eset: Number(eset.incidents || 0)
@ -818,6 +808,7 @@
cases: 'Åbne sager',
urgent: 'Hastesager',
unassigned: 'Sager uden ansvarlig',
procurement: 'Varer der skal bestilles',
timer: 'Aktive timere',
drift: 'Drift alerts',
eset: 'ESET incidents'
@ -844,6 +835,7 @@
if (val > 0) return 'sev-warn';
return 'sev-ok';
}
if (key === 'procurement') return val > 0 ? 'sev-critical' : 'sev-ok';
return val > 0 ? 'sev-warn' : 'sev-ok';
}
@ -914,39 +906,8 @@
const activeThread = ensureActiveMessageThread();
syncChatRecipientToActiveThread(activeThread);
const messageItems = activeThread && Array.isArray(activeThread.items) ? activeThread.items : [];
if (messageItems.length > 0) {
return messageItems.map(function (m) {
const own = !!m.is_own;
const unreadBadge = m.is_unread ? ' <span class="badge text-bg-warning ms-1">Ny</span>' : '';
const importantBadge = m.requires_manual_ack ? ' <span class="badge text-bg-danger ms-1">Vigtig</span>' : '';
const ackBadge = own && m.requires_manual_ack
? (m.is_acknowledged
? ' <span class="badge text-bg-success ms-1">Bekræftet læst</span>'
: ' <span class="badge text-bg-secondary ms-1">Afventer læst-bekræftelse</span>')
: '';
const targetMeta = own && m.to ? '<div class="small text-muted mb-1">Til: ' + esc(m.to) + '</div>' : '';
const replyTargetId = own ? Number(m.recipient_user_id || 0) : Number(m.sender_user_id || 0);
const replyBtn = replyTargetId > 0
? '<div class="mt-2"><button type="button" class="btn btn-sm ' + (own ? 'btn-light' : 'btn-outline-secondary') + '" data-bb-reply-message="' + Number(m.id || 0) + '"><i class="bi bi-reply me-1"></i>Svar</button></div>'
: '';
const ackBtn = (!own && m.requires_manual_ack && m.is_unread)
? '<div class="mt-2"><button type="button" class="btn btn-sm btn-danger" data-bb-ack-message="' + Number(m.id || 0) + '"><i class="bi bi-check2-circle me-1"></i>Bekræft læst</button></div>'
: '';
return ''
+ '<div class="' + (own ? 'text-end' : '') + '">'
+ targetMeta
+ '<div class="d-inline-block ' + (own ? 'bg-primary text-white' : (m.requires_manual_ack ? 'bg-warning-subtle border border-warning-subtle' : 'bg-light')) + ' p-2 rounded-3 text-start shadow-sm" style="max-width: 85%;">'
+ '<strong class="' + (own ? 'text-white' : 'text-accent') + '">' + esc(m.from) + ':</strong> '
+ esc(m.text)
+ unreadBadge
+ importantBadge
+ ackBadge
+ replyBtn
+ ackBtn
+ '</div></div>';
});
}
return ['Ingen beskeder i denne tråd endnu.'];
if (messageItems.length > 0) return messageItems.map(window.BmcMessageUI.message);
return ['<div class="msg-empty"><span><i class="bi bi-chat-square-text"></i></span><strong>Her starter samtalen</strong><p>Send en kort besked eller giv en telefonbesked videre.</p></div>'];
}
if (key === 'tasks') {
@ -1130,6 +1091,7 @@
cases: 'Sager',
urgent: 'Hastesager',
unassigned: 'Uden ansvarlig',
procurement: 'Skal bestilles',
timer: 'Timere',
drift: 'Drift',
eset: 'ESET'
@ -1180,14 +1142,23 @@
const timer = ((latestSections || {}).timer || {}).active || {};
const ownTimers = ((latestSections || {}).timer || {}).own || {};
const hasPausedTimer = Array.isArray(ownTimers.paused) && ownTimers.paused.length > 0;
const pausedTimers = Array.isArray(ownTimers.paused) ? ownTimers.paused : [];
const pausedTimer = pausedTimers[0] || null;
const hasPausedTimer = !!pausedTimer;
const hasActiveTimer = !!timer.active;
if (timerChip && timerText) {
timerChip.classList.toggle('is-hidden', !hasActiveTimer);
timerChip.classList.toggle('is-hidden', !hasActiveTimer && !hasPausedTimer);
timerChip.classList.toggle('is-paused', !hasActiveTimer && hasPausedTimer);
if (hasActiveTimer) {
const elapsed = timer.elapsed_hhmmss || '00:00:00';
const name = timer.sag_navn || ('Sag #' + (timer.sag_id || ''));
timerText.textContent = name + ' - ' + elapsed;
timerChip.title = 'Aktiv timer på ' + name;
} else if (hasPausedTimer) {
const name = pausedTimer.sag_navn || ('Sag #' + (pausedTimer.sag_id || ''));
const elapsed = pausedTimer.elapsed_hhmmss || '00:00:00';
timerText.textContent = 'Pauset · ' + name + ' · ' + elapsed;
timerChip.title = 'Pauset timer på ' + name + ' klik for at åbne sagen';
}
}
@ -1198,7 +1169,8 @@
}
if (pauseBtn) {
pauseBtn.disabled = !hasActiveTimer && !hasPausedTimer;
pauseBtn.title = hasActiveTimer ? 'Pause timer' : (hasPausedTimer ? 'Genoptag senest pausede timer' : 'Ingen timer at pause');
const pausedName = hasPausedTimer ? (pausedTimer.sag_navn || ('Sag #' + (pausedTimer.sag_id || ''))) : '';
pauseBtn.title = hasActiveTimer ? 'Pause timer' : (hasPausedTimer ? 'Genoptag ' + pausedName : 'Ingen timer at pause');
pauseBtn.innerHTML = hasActiveTimer ? '<i class="bi bi-pause-fill"></i>' : '<i class="bi bi-play-fill"></i>';
}
if (stopBtn) {
@ -1215,6 +1187,8 @@
}
let messageFocusState = null;
const previousMessages = innerContent.querySelector('.bb-messages-list');
const previousScroll = previousMessages ? {key:previousMessages.dataset.threadKey, top:previousMessages.scrollTop, bottom:previousMessages.scrollHeight-previousMessages.scrollTop-previousMessages.clientHeight < 40} : null;
if (activeKey === 'messages') {
const activeEl = document.activeElement;
const activeId = activeEl && activeEl.id ? activeEl.id : '';
@ -1315,17 +1289,18 @@
const threadItems = getRenderableMessageThreads(activeThread);
syncChatRecipientToActiveThread(activeThread);
if (threadItems.length > 0) {
{
const threadList = document.createElement('div');
threadList.className = 'bb-message-threads';
threadList.innerHTML = '<div class="msg-inbox-heading"><span>Samtaler</span><button type="button" title="Ny besked" aria-label="Ny besked" onclick="window.openInternalMessage()"><i class="bi bi-pencil-square"></i></button></div>';
if (!threadItems.length) threadList.insertAdjacentHTML('beforeend', '<p class="small text-muted px-2">Dine samtaler vises her.</p>');
threadItems.forEach(function (thread) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'bb-message-thread' + (activeThread && thread.key === activeThread.key ? ' is-active' : '');
button.setAttribute('data-bb-thread-key', thread.key);
button.innerHTML = ''
+ '<span class="bb-message-thread-label">' + escapeHtml(thread.label || 'Samtale') + '</span>'
+ (thread.unread > 0 ? '<span class="bb-message-thread-count">' + Number(thread.unread || 0) + '</span>' : '');
button.setAttribute('aria-current', activeThread && thread.key === activeThread.key ? 'true' : 'false');
button.innerHTML = window.BmcMessageUI.thread(thread);
threadList.appendChild(button);
});
chatContainer.appendChild(threadList);
@ -1333,6 +1308,9 @@
const replyBox = document.createElement('div');
replyBox.className = 'bb-messages-composer';
const conversation = document.createElement('div');
conversation.className = 'msg-conversation';
conversation.innerHTML = '<header class="msg-conversation-header"><div><strong>' + escapeHtml(activeThread?.label || 'Vælg en samtale') + '</strong><small>' + (!activeThread ? 'Brug blyanten til at skrive til en kollega' : activeThread.partnerUserId ? 'Intern samtale' : 'Fælles beskeder til alle på vagt') + '</small></div><button type="button" class="msg-header-action" onclick="window.openInternalMessage({kind:\'phone\',recipient:' + Number(activeThread?.partnerUserId || 0) + '})"><i class="bi bi-telephone-plus"></i><span>Telefonbesked</span></button></header>';
const threadMeta = activeThread
? '<div class="small text-muted mb-2"><i class="bi bi-chat-square-text me-1"></i>'
+ (activeThread.partnerUserId ? ('Samtale med ' + escapeHtml(activeThread.label || 'Bruger')) : 'Besked til alle på vagt')
@ -1345,27 +1323,23 @@
</div>`
: '';
replyBox.innerHTML = `
${threadMeta}
${replyBanner}
<div class="input-group input-group-sm mb-1">
<span class="input-group-text bg-light text-muted border-0"><i class="bi bi-person"></i></span>
<select id="chatRecipient" class="form-select border-0 bg-light">
<select id="chatRecipient" hidden aria-label="Modtager">
<option value="all">Alle vagt</option>
</select>
<div class="msg-compose-field">
<textarea id="chatInputQuick" rows="2" maxlength="2000" aria-label="Besked" ${!activeThread?'disabled':''} placeholder="${activeThread?'Skriv til '+escapeHtml(activeThread.label)+'…':'Vælg en samtale eller opret en ny besked'}">${escapeHtml(chatComposerState.draft || '')}</textarea>
<button type="button" class="msg-send" id="btnSendMsg" aria-label="Send besked" ${!activeThread?'disabled':''}><i class="bi bi-arrow-up"></i></button>
</div>
<label class="form-check form-switch small mb-2">
<input class="form-check-input" type="checkbox" id="chatRequiresAck" ${chatComposerState.requiresManualAck ? 'checked' : ''}>
<span class="form-check-label">Kræv manuel læst-bekræftelse</span>
</label>
<div class="input-group">
<input type="text" id="chatInputQuick" class="form-control form-control-sm" placeholder="Skriv en besked..." value="${escapeHtml(chatComposerState.draft || '')}">
<button class="btn btn-outline-primary btn-sm" id="btnSendMsg"><i class="bi bi-send"></i></button>
</div>
<div class="msg-compose-footer"><label><input type="checkbox" id="chatRequiresAck" ${chatComposerState.requiresManualAck ? 'checked' : ''}> Bed om læsebekræftelse</label><small>Ctrl / + Enter for at sende</small></div>
`;
chatContainer.appendChild(ul);
chatContainer.appendChild(replyBox);
conversation.appendChild(ul);
conversation.appendChild(replyBox);
chatContainer.appendChild(conversation);
innerContent.appendChild(chatContainer);
ul.dataset.threadKey = activeThread?.key || '';
window.requestAnimationFrame(function () { ul.scrollTop = previousScroll && previousScroll.key === ul.dataset.threadKey && !previousScroll.bottom ? previousScroll.top : ul.scrollHeight; });
const recipientSelect = document.getElementById('chatRecipient');
const input = document.getElementById('chatInputQuick');
@ -1382,6 +1356,9 @@
});
}
if (input) {
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); event.stopPropagation(); document.getElementById('btnSendMsg')?.click(); }
});
input.addEventListener('input', function () {
chatComposerState.draft = input.value || '';
});
@ -1905,6 +1882,16 @@
return stopTimer(active.time_entry_id);
}
function notifyTimerStateChanged(action, payload) {
const detail = Object.assign({ action: action, changed_at: new Date().toISOString() }, payload || {});
window.dispatchEvent(new CustomEvent('bb:timer-state-changed', { detail: detail }));
try {
window.localStorage.setItem('bmc:timer-state-changed', JSON.stringify(detail));
} catch (error) {
console.debug('Could not broadcast timer state', error);
}
}
function pauseActiveTimer() {
return fetch('/api/v1/timetracking/time/pause', {
method: 'POST',
@ -1913,7 +1900,7 @@
body: '{}'
}).then(function (res) {
if (!res.ok) {
throw new Error('Kunne ikke pause timer');
return readApiError(res, 'Kunne ikke pause timer.').then(function (message) { throw new Error(message); });
}
return res.json().catch(function () { return {}; });
});
@ -1932,7 +1919,7 @@
body: JSON.stringify(payload)
}).then(function (res) {
if (!res.ok) {
throw new Error('Kunne ikke genoptage timer');
return readApiError(res, 'Kunne ikke genoptage timer.').then(function (message) { throw new Error(message); });
}
return res.json().catch(function () { return {}; });
});
@ -2777,21 +2764,43 @@
const paused = Array.isArray(own.paused) ? own.paused : [];
if (activeTimer.active) {
pauseBtn.disabled = true;
pauseActiveTimer()
.then(fetchBottomBarState)
.then(applyState)
.then(function () {
notifyTimerStateChanged('paused');
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-pause-circle me-1 text-success"></i>Timer sat på pause.';
})
.catch(function (err) {
console.warn('Failed pausing timer', err);
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke pause timer.');
})
.finally(function () {
updateActivityZone();
});
return;
}
const pausedTimeId = Number((((paused[0] || {}).time_entry_id) || ((paused[0] || {}).id) || 0));
pauseBtn.disabled = true;
resumeTimer(pausedTimeId || null)
.then(fetchBottomBarState)
.then(applyState)
.then(function () {
notifyTimerStateChanged('resumed', { time_id: pausedTimeId || null });
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-play-circle me-1 text-success"></i>Timer genoptaget.';
})
.catch(function (err) {
console.warn('Failed resuming timer', err);
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke genoptage timer.');
})
.finally(function () {
updateActivityZone();
});
});
}
@ -2801,6 +2810,7 @@
stopActiveTimer()
.then(fetchBottomBarState)
.then(applyState)
.then(function () { notifyTimerStateChanged('stopped'); })
.catch(function (err) {
console.warn('Failed stopping timer', err);
});
@ -2816,7 +2826,10 @@
if (timerChip) {
timerChip.addEventListener('click', function () {
const timer = (((latestSections || {}).timer || {}).active || {});
const sagId = Number(timer.sag_id || 0);
const own = (((latestSections || {}).timer || {}).own || {});
const paused = Array.isArray(own.paused) ? own.paused : [];
const visibleTimer = timer.active ? timer : (paused[0] || {});
const sagId = Number(visibleTimer.sag_id || 0);
window.location.href = sagId > 0 ? ('/sag/' + sagId + '/v3') : '/timetracking';
});
}
@ -3098,6 +3111,7 @@
if (switchAction === 'pause-now') {
pauseActiveTimer().then(function () {
notifyTimerStateChanged('paused');
switchCaseState.decision = 'pause';
switchCaseState.activeTimer = null;
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Timer sat på pause. Du kan nu starte ny timer.');
@ -3110,6 +3124,7 @@
if (switchAction === 'stop-now') {
stopActiveTimer().then(function () {
notifyTimerStateChanged('stopped');
switchCaseState.decision = 'stop';
switchCaseState.activeTimer = null;
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Aktiv timer stoppet. Du kan nu starte ny timer.');
@ -3461,7 +3476,7 @@
if (activeKey === 'messages') {
renderTabPanel();
}
const tabInner = document.getElementById('bbTabInnerContent');
const tabInner = document.querySelector('.bb-messages-list');
if (tabInner) {
tabInner.scrollTop = tabInner.scrollHeight + 500;
}

256
static/js/case-create.js Normal file
View File

@ -0,0 +1,256 @@
/* Progressive case creation. Existing searches and submit retain their API contracts. */
(() => {
'use strict';
const $ = id => document.getElementById(id);
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state = {tags: [], hardware: [], tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false};
let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = [];
async function api(url, options) {
const response = await fetch(url, {credentials: 'include', ...options});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(typeof body.detail === 'string' ? body.detail : 'Kunne ikke hente oplysninger');
return body;
}
function panel(id, title, elements, open = false) {
const detail = document.createElement('details');
detail.className = 'cc-panel'; detail.id = `cc-${id}`; detail.open = open;
const icon = {relations:'bi-people',tags:'bi-tags',hardware:'bi-pc-display',pipeline:'bi-graph-up-arrow',order:'bi-bag',metadata:'bi-sliders'}[id];
detail.innerHTML = `<summary><span class="cc-panel-icon"><i class="bi ${icon}" aria-hidden="true"></i></span><span class="cc-panel-label">${esc(title)}<small class="cc-summary"></small></span></summary><div class="cc-panel-body"></div>`;
elements[0].before(detail);
elements.forEach(el => detail.lastElementChild.append(el));
state.panels[id] = detail;
detail.addEventListener('toggle', scheduleSave);
return detail;
}
function summaries() {
const values = {
relations: `${selectedCustomer?.name || 'Vælg firma'} · ${Object.keys(selectedContacts).length} kontakter`,
tags: state.tags.map(t => t.name).join(', ') || 'Ingen tags',
hardware: `${state.hardware.length} enheder valgt`,
pipeline: [$('pipeline_stage_id').selectedOptions[0]?.text, $('pipeline_amount').value ? `${$('pipeline_amount').value} kr.` : ''].filter(Boolean).join(' · '),
order: `${document.querySelectorAll('#orderLines .order-line').length} linjer`,
metadata: [$('status').selectedOptions[0]?.text, $('ansvarlig_bruger_id').selectedOptions[0]?.text, $('deadline').value.replace('T', ' ')].filter(Boolean).join(' · ')
};
Object.entries(values).forEach(([key, value]) => { if (state.panels[key]) state.panels[key].querySelector('.cc-summary').textContent = value; });
}
function fieldSnapshot() {
return Object.fromEntries(Array.from($('createForm').querySelectorAll('input[id],select[id],textarea[id]')).filter(el => !['customerSearch','contactSearch'].includes(el.id)).map(el => [el.id, el.type === 'checkbox' ? el.checked : el.value]));
}
function snapshot() {
return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies,
tags: state.tags, hardware: state.hardware, orders: collectOrderItems(), prefill: telefoniPrefill,
customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value,
panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))};
}
function save() {
if (!state.ready || state.pending || state.restoring || state.completed || !state.key) return;
try { localStorage.setItem(state.key, JSON.stringify(snapshot())); $('cc-save').textContent = 'Kladde gemt lokalt'; }
catch (error) { console.error('Kunne ikke gemme kladde', error); $('cc-save').textContent = 'Kladde kunne ikke gemmes i browseren'; }
}
function scheduleSave() {
summaries();
if (!state.ready || state.pending || state.restoring || state.completed) return;
$('cc-save').textContent = 'Gemmer kladde…'; clearTimeout(saveTimer); saveTimer = setTimeout(save, 500);
}
function renderTags() {
$('cc-tag-chips').innerHTML = state.tags.map(t => `<span class="badge me-1 mb-1" style="background-color:#64748b" data-tag-id="${Number(t.id)}">${/^bi-[a-z0-9-]+$/.test(t.icon || '') ? `<i class="bi ${esc(t.icon)}"></i> ` : ''}${esc(t.name)} <button type="button" class="btn-close btn-close-white" aria-label="Fjern ${esc(t.name)}"></button></span>`).join('');
state.tags.forEach(t => { const chip = $('cc-tag-chips').querySelector(`[data-tag-id="${Number(t.id)}"]`); if (/^#[0-9a-f]{6}$/i.test(t.color)) chip.style.backgroundColor = t.color; });
summaries();
renderTagSuggestions();
}
function renderTagSuggestions() {
const box = $('cc-tag-suggestions');
if (!box) return;
const haystack = `${$('titel')?.value || ''} ${$('beskrivelse')?.value || ''}`.toLocaleLowerCase('da-DK');
if (haystack.trim().length < 3 || !state.tagCatalog.length) { box.innerHTML = ''; return; }
const selected = new Set(state.tags.map(tag => Number(tag.id)));
const suggestions = state.tagCatalog.filter(tag => {
if (selected.has(Number(tag.id)) || !['brand', 'type'].includes(tag.type)) return false;
const words = Array.isArray(tag.catch_words) ? tag.catch_words : [];
return words.some(word => String(word).trim() && haystack.includes(String(word).trim().toLocaleLowerCase('da-DK')));
}).slice(0, 8);
box.innerHTML = suggestions.length ? `<div class="cc-tag-suggestions__label">Foreslået ud fra teksten</div><div class="d-flex flex-wrap gap-1">${suggestions.map(tag => `<button type="button" class="btn btn-sm btn-outline-primary" data-suggested-tag="${Number(tag.id)}" title="Tilføj ${esc(tag.name)}">${tag.icon ? `<i class="bi ${esc(tag.icon)}"></i> ` : ''}${esc(tag.name)} <i class="bi bi-plus-lg"></i></button>`).join('')}</div>` : '';
}
function scheduleTagSuggestions() {
clearTimeout(tagSuggestionTimer);
tagSuggestionTimer = setTimeout(renderTagSuggestions, 350);
}
async function addTag(tag) {
if (!state.tags.some(t => t.id === tag.id)) {
const groups = await api('/api/v1/tags/groups').catch(() => []);
if (groups.some(g => g.id === tag.tag_group_id && ['single','toggle'].includes(g.behavior))) state.tags = state.tags.filter(t => t.tag_group_id !== tag.tag_group_id);
state.tags.push(tag); renderTags(); scheduleSave();
}
}
function renderSelectedHardware() {
$('cc-hardware-chips').innerHTML = state.hardware.map(h => `<span class="badge bg-secondary me-1">${esc(h.name)} <button type="button" class="btn-close btn-close-white" data-remove-hardware="${Number(h.id)}" aria-label="Fjern hardware"></button></span>`).join('');
document.querySelectorAll('[data-hardware-choice]').forEach(el => { el.checked = state.hardware.some(h => h.id === Number(el.dataset.hardwareChoice)); });
summaries();
}
async function duplicates() {
const token = ++duplicateToken, customerId = selectedCustomer?.id, title = $('titel').value.trim();
$('cc-duplicates').innerHTML = '';
if (!customerId || title.length < 5) return;
try {
const rows = await api(`/api/v1/case-create/duplicates?customer_id=${customerId}&title=${encodeURIComponent(title)}`);
if (token !== duplicateToken) return;
$('cc-duplicates').innerHTML = rows.length ? `<div class="alert alert-warning"><strong>Mulige eksisterende sager</strong>${rows.map(r => `<div><a target="_blank" rel="noopener" href="/sag/${r.id}/v3">SAG-${r.id} · ${esc(r.titel)}</a><small> · ${esc(r.status)} · ${esc(r.ansvarlig_navn || 'Ingen ansvarlig')}</small></div>`).join('')}</div>` : '';
} catch { if (token === duplicateToken) $('cc-duplicates').textContent = 'Duplikatkontrol er midlertidigt utilgængelig. Du kan stadig oprette sagen.'; }
}
function scheduleDuplicates() { ++duplicateToken; clearTimeout(duplicateTimer); duplicateTimer = setTimeout(duplicates, 500); }
async function contactOpenCases() {
const box = $('cc-contact-cases');
if (!box) return;
const contacts = Object.values(selectedContacts);
const token = ++contactCasesToken;
if (!contacts.length) { box.hidden = true; box.innerHTML = ''; return; }
box.hidden = false;
box.innerHTML = '<span class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Tjekker kontaktens åbne sager…</span>';
const query = contacts.map(contact => `contact_ids=${encodeURIComponent(contact.id)}`).join('&');
try {
const data = await api(`/api/v1/case-create/contacts-open-cases?${query}`);
if (token !== contactCasesToken) return;
const groups = contacts.map(contact => ({contact, data: data[contact.id] || {total:0, items:[]}}));
const total = groups.reduce((sum, group) => sum + Number(group.data.total || 0), 0);
const title = total ? `${total} åben${total === 1 ? '' : 'ne'} sag${total === 1 ? '' : 'er'} på valgt kontakt` : 'Ingen åbne sager på valgt kontakt';
box.innerHTML = `<div class="cc-contact-cases__header"><span class="cc-contact-cases__header-icon"><i class="bi ${total ? 'bi-clipboard2-check' : 'bi-check2-circle'}"></i></span><div><span class="cc-contact-cases__eyebrow">Dublettjek</span><span class="cc-contact-cases__title">${title}</span></div></div>${total ? `<div class="cc-contact-cases__body">${groups.map(({contact,data}) => data.total ? `<div class="cc-contact-cases__group"><span class="cc-contact-cases__group-name">${esc(contact.name)}</span>${data.items.map(item => `<div class="cc-contact-cases__item"><a href="/sag/${Number(item.id)}/v3" target="_blank" rel="noopener">SAG-${Number(item.id)} · ${esc(item.titel)}</a><small>${esc(item.status || '')} · ${esc(item.ansvarlig_navn || 'Ingen ansvarlig')}${item.deadline ? ` · ${esc(new Date(item.deadline).toLocaleDateString('da-DK'))}` : ''}</small></div>`).join('')}${data.total > data.items.length ? `<span class="cc-contact-cases__more">Viser ${data.items.length} af ${data.total} åbne sager</span>` : ''}</div>` : '').join('')}</div>` : '<div class="cc-contact-cases__empty">Du kan oprette sagen uden risiko for en kendt, åben sag på kontakten.</div>'}`;
} catch {
if (token === contactCasesToken) box.innerHTML = '<span class="text-muted">Kunne ikke tjekke kontaktens åbne sager. Du kan stadig oprette sagen.</span>';
}
}
async function workload() {
const token = ++workloadToken, id = $('ansvarlig_bruger_id').value;
$('cc-workload').textContent = id ? 'Henter åbne sager…' : 'Vælg en ansvarlig for at se åbne sager.';
if (!id) return;
try {
const data = await api(`/api/v1/case-create/workload?user_id=${id}`);
if (token !== workloadToken) return;
$('cc-workload').innerHTML = `<details><summary>${data.total} åbne sager · ${esc($('ansvarlig_bruger_id').selectedOptions[0].text)}</summary>${data.items.map(r => `<div class="cc-work-item"><a target="_blank" rel="noopener" href="/sag/${r.id}/v3">SAG-${r.id} · ${esc(r.titel)}</a><small>${esc(r.customer_name || '')} · ${esc(r.status)} <span class="${r.deadline && new Date(r.deadline) < new Date() ? 'text-danger' : ''}">${r.deadline ? esc(new Date(r.deadline).toLocaleString('da-DK')) : ''}</span></small></div>`).join('') || '<p class="text-success">Ingen åbne sager.</p>'}${data.total > 10 ? `<a href="/sag?ansvarlig_bruger_id=${id}" target="_blank" rel="noopener">Se alle ${data.total} sager</a>` : ''}</details>`;
} catch { if (token === workloadToken) $('cc-workload').textContent = 'Kunne ikke hente åbne sager.'; }
}
function typeChanged(type) {
document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); });
const key = {ticket:'hardware', pipeline:'pipeline', ordre:'order'}[type];
if (key && state.panels[key] && !state.restoring) state.panels[key].open = true;
if ($('cc-more')) $('cc-more').open = Boolean($('cc-more').querySelector(`[data-case-type="${CSS.escape(type)}"]`));
scheduleSave();
}
function buildTypes() {
const options = Array.from($('type').options), current = $('type').value;
const keys = [...new Set([current, 'ticket','pipeline','ordre','opgave', ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k));
const hints = {ticket:'Support og hjælp',pipeline:'Muligheder og tilbud',ordre:'Indkøb og salg',opgave:'Noget der skal løses',projekt:'Større forløb',service:'Service og vedligehold',abonnement:'Løbende aftaler'};
const button = key => `<button type="button" class="btn btn-outline-primary" data-case-type="${esc(key)}" aria-pressed="false"><span class="cc-type-name">${esc(options.find(o => o.value === key).text)}</span><small class="cc-type-hint">${esc(hints[key] || 'Opret sag')}</small></button>`;
const messageButton = $('caseMessageTypeButton');
$('type').classList.add('d-none'); $('type').removeAttribute('required');
const div = document.createElement('div'); div.id = 'cc-types'; div.className = 'cc-types'; div.setAttribute('role', 'group'); div.setAttribute('aria-label', 'Sagstype');
div.innerHTML = keys.slice(0,4).map(button).join('') + (keys.length > 4 ? `<details id="cc-more"><summary>Flere</summary><div class="cc-types">${keys.slice(4).map(button).join('')}</div></details>` : '');
if (messageButton) { messageButton.classList.remove('cc-message-fallback'); messageButton.dataset.caseMessage = 'true'; div.insertBefore(messageButton, div.querySelector('#cc-more')); }
$('type').after(div);
div.addEventListener('click', e => { const message = e.target.closest('[data-case-message]'); if (message) { window.caseCreateUI?.openMessage(); return; } const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } });
}
async function applyTemplate(id) {
const template = templates.find(t => t.id === Number(id)); if (!template) return;
const values = template.values, fields = {type: values.type, titel: values.titel, beskrivelse: values.beskrivelse, status: values.status, assigned_group_id: values.assigned_group_id ?? ''};
Object.entries(values.pipeline || {}).forEach(([k,v]) => { fields[`pipeline_${k === 'stage_id' ? 'stage_id' : k}`] = v ?? ''; });
if (Object.entries(fields).some(([k,v]) => $(k)?.value && String(v) !== $(k).value) && !confirm('Skabelonen ændrer udfyldte felter. Vil du bruge den?')) return;
Object.entries(fields).forEach(([k,v]) => { const el = $(k); if (el && (el.tagName !== 'SELECT' || Array.from(el.options).some(o => o.value === String(v)))) el.value = v; });
const tags = await api('/api/v1/tags?is_active=true');
for (const id of values.tag_ids || []) { const tag = tags.find(t => t.id === id); if (tag) await addTag(tag); }
updateCaseTypeSections(); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates();
}
async function restore(draft) {
state.restoring = true;
try {
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {};
telefoniPrefill = draft.prefill || telefoniPrefill;
Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } });
state.tags = draft.tags || []; state.hardware = draft.hardware || [];
$('orderLines').innerHTML = '';
(draft.orders || []).forEach(item => { addOrderLine(item.type); const row = $('orderLines').lastElementChild; Object.entries({description:'description',quantity:'quantity',unit:'unit',unit_price:'unit-price',amount:'amount',currency:'currency',status:'status',external_ref:'reference',purchase_purpose:'purpose'}).forEach(([k,cls]) => { const el = row.querySelector(`.order-${cls}`); if (el) el.value = item[k] ?? ''; }); });
renderSelections(); await loadHardwareForContacts(); renderTags(); renderSelectedHardware(); renderOrderLinesEmptyState();
await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null);
if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id);
$('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || '';
updateCaseTypeSections(); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; });
$('charCount').textContent = `${$('beskrivelse').value.length} tegn`;
} finally { state.restoring = false; }
state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload();
}
function validate() {
document.querySelectorAll('.cc-error').forEach(el => el.remove());
document.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
const errors = [];
const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `<div class="text-danger small cc-error">${esc(text)}</div>`); const detail = el.closest('details'); if (detail) { detail.open = true; if (!detail.querySelector('summary .cc-error')) detail.querySelector('summary').insertAdjacentHTML('beforeend','<span class="text-danger small cc-error"> · Kontrollér felter</span>'); } };
if (!$('titel').value.trim()) add($('titel'), 'Titel er påkrævet');
if (!selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma');
$('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); });
document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } });
if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; }
return true;
}
async function init() {
const form = $('createForm');
try {
const setting = await api('/api/v1/settings/case_statuses');
const statuses = JSON.parse(setting.value || '[]');
if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); });
} catch { /* Keep the built-in statuses available. */ }
const headings = Array.from(form.querySelectorAll(':scope > h5'));
panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], true);
for (const [key,id,title] of [['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]);
const metadata = headings[1]; panel('metadata', 'Status, ansvar og deadline', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling]);
const tags = document.createElement('section'); tags.innerHTML = '<div id="cc-tag-chips"></div><button type="button" id="cc-tag-add" class="btn btn-sm btn-outline-primary">Tilføj tag</button><div id="cc-tag-suggestions" class="cc-tag-suggestions"></div>'; state.panels.hardware.before(tags); panel('tags', 'Tags', [tags]);
$('cc-tag-add').onclick = () => window.tagPicker.showSelection(addTag);
window.setTagPickerContext?.('case', null, addTag);
$('cc-tag-chips').onclick = e => { const chip = e.target.closest('[data-tag-id]'); if (chip && e.target.closest('button')) { state.tags = state.tags.filter(t => t.id !== Number(chip.dataset.tagId)); renderTags(); scheduleSave(); } };
$('cc-tag-suggestions').onclick = e => { const button = e.target.closest('[data-suggested-tag]'); const tag = state.tagCatalog.find(item => item.id === Number(button?.dataset.suggestedTag)); if (tag) addTag(tag).catch(error => showCreateError(error.message)); };
const chips = document.createElement('div'); chips.id = 'cc-hardware-chips'; $('hardwareList').before(chips);
chips.onclick = e => { const b = e.target.closest('[data-remove-hardware]'); if (b) { state.hardware = state.hardware.filter(h => h.id !== Number(b.dataset.removeHardware)); renderSelectedHardware(); scheduleSave(); } };
const originalRenderHardware = window.renderHardwareList;
window.renderHardwareList = items => { originalRenderHardware(items); Array.from($('hardwareList').children).forEach((row,i) => { if (!items[i]) return; const item = items[i]; const input = document.createElement('input'); input.type = 'checkbox'; input.className = 'form-check-input me-2'; input.dataset.hardwareChoice = item.id; input.setAttribute('aria-label', `Vælg ${item.model || item.brand || item.id}`); input.onchange = () => { state.hardware = state.hardware.filter(h => h.id !== item.id); if (input.checked) state.hardware.push({id:item.id,name:item.model || item.brand || `Hardware #${item.id}`}); renderSelectedHardware(); scheduleSave(); }; row.prepend(input); }); renderSelectedHardware(); };
window.quickCreateHardware = async () => {
const name = $('hardwareNameInput').value.trim(), customerId = selectedCustomer?.id || getSingleContactCompanyId();
if (!name || !customerId) { showCreateError('Udfyld hardwarenavn og vælg et firma'); return; }
const anydeskId = $('hardwareAnyDeskIdInput').value.trim();
const button = $('hardwareNameInput').closest('.card-body').querySelector('button'); button.disabled = true;
try { const item = await api('/api/v1/hardware/quick', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,customer_id:customerId,anydesk_id:anydeskId || null,anydesk_link:anydeskId ? `anydesk://${anydeskId}` : null})}); state.hardware.push({id:item.id,name}); $('hardwareNameInput').value = ''; $('hardwareAnyDeskIdInput').value = ''; renderSelectedHardware(); scheduleSave(); } catch(e) { showCreateError(e.message); } finally { button.disabled = false; }
};
const originalRender = window.renderSelections;
window.renderSelections = () => { originalRender(); if (state.ready) { scheduleSave(); scheduleDuplicates(); contactOpenCases(); } };
const contactCases = document.createElement('aside'); contactCases.id = 'cc-contact-cases'; contactCases.className = 'cc-contact-cases'; contactCases.hidden = true; $('selectedContacts').after(contactCases);
const workloadEl = document.createElement('div'); workloadEl.id = 'cc-workload'; workloadEl.className = 'cc-workload'; state.panels.metadata.after(workloadEl);
const duplicateEl = document.createElement('div'); duplicateEl.id = 'cc-duplicates'; $('titel').closest('.row').after(duplicateEl);
const toolbar = document.createElement('div'); toolbar.className = 'mb-3'; toolbar.innerHTML = '<label class="form-label" for="cc-template">Hurtigskabelon</label><select id="cc-template" class="form-select"><option value="">Start uden skabelon</option></select><small id="cc-template-error" class="text-muted"></small>'; form.prepend(toolbar);
try { templates = await api('/api/v1/case-create/templates'); $('cc-template').insertAdjacentHTML('beforeend',templates.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('')); } catch { $('cc-template-error').textContent = 'Skabeloner er ikke tilgængelige.'; }
$('cc-template').onchange = e => applyTemplate(e.target.value).catch(err => showCreateError(err.message));
const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','<small id="cc-save" role="status" class="me-auto align-self-center">Klargør kladde…</small>');
toolbar.classList.add('cc-template-toolbar');
buildTypes(); updateCaseTypeSections();
form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0));
$('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); });
$('beskrivelse').addEventListener('input', scheduleTagSuggestions); $('ansvarlig_bruger_id').addEventListener('change', workload);
document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !document.querySelector('.modal.show')) { e.preventDefault(); if (!$('submitBtn').disabled) form.requestSubmit(); } });
window.addEventListener('pagehide', save);
try {
const user = await api('/api/v1/auth/me');
if (!(user.user_id || user.id)) throw new Error('Mangler bruger');
const params = new URLSearchParams(location.search); params.sort();
state.key = `bmc:case-draft:v1:${user.user_id || user.id}:${params.toString() || 'new'}`;
const raw = localStorage.getItem(state.key); let draft;
try { draft = raw ? JSON.parse(raw) : null; } catch { localStorage.removeItem(state.key); }
if (draft && (draft.version !== 1 || !Number.isFinite(draft.savedAt) || !draft.fields || !Array.isArray(draft.orders) || Date.now()-draft.savedAt > 7*86400000)) { localStorage.removeItem(state.key); draft=null; }
if (draft) {
state.pending = true; const prompt = document.createElement('div'); prompt.id = 'cc-draft'; prompt.className = 'alert alert-info'; prompt.innerHTML = 'Der findes en lokal kladde. <button type="button" class="btn btn-sm btn-primary" id="cc-restore">Gendan kladde</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="cc-discard">Kassér</button>'; form.prepend(prompt);
$('cc-restore').onclick = () => restore(draft).catch(e => showCreateError(e.message));
$('cc-discard').onclick = () => { localStorage.removeItem(state.key); state.pending=false; prompt.remove(); scheduleSave(); };
}
$('cc-save').textContent = draft ? 'Vælg om kladden skal gendannes' : 'Kladde gemmes i denne browser';
} catch { $('cc-save').textContent = 'Autosave er ikke tilgængelig'; }
state.tagCatalog = await api('/api/v1/tags?is_active=true').catch(() => []);
state.ready = true; summaries(); renderTagSuggestions(); workload(); scheduleDuplicates(); contactOpenCases(); await loadHardwareForContacts();
}
window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => ({tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id)}),created: result => {
state.completed = true; clearTimeout(saveTimer);
try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ }
}};
})();

View File

@ -0,0 +1,39 @@
(() => {
const $ = id => document.getElementById(`cta-${id}`);
const esc = v => String(v ?? '').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let rows = [], tags = [], selected = [];
async function api(path, options) { const r = await fetch(`/api/v1/${path}`, {credentials:'include',...options}); const data = await r.json(); if (!r.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Kontrollér skabelonens felter'); return data; }
function message(text, error=false) { $('message').textContent=text; $('message').className=error?'alert alert-danger':'alert alert-success'; }
function renderTags() { $('tags').innerHTML=selected.map(id=>`<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-tag="${id}">${esc(tags.find(t=>t.id===id)?.name || `Tag #${id}`)} ×</button>`).join(''); }
function edit(row) {
$('form').reset(); const v=row?.values || {};
for (const [key,value] of Object.entries({id:row?.id || '',name:row?.name || '',icon:row?.icon || 'bi-lightning',sort:row?.sort_order || 0,type:v.type || 'ticket',status:v.status || 'åben',group:v.assigned_group_id || '',title:v.titel || '',description:v.beskrivelse || '',stage:v.pipeline?.stage_id || '',amount:v.pipeline?.amount ?? '',probability:v.pipeline?.probability ?? '', 'pipeline-description':v.pipeline?.description || ''})) $(key).value=value;
$('active').checked=row?.is_active ?? true; selected=[...(v.tag_ids || [])]; renderTags();
}
async function load() { rows=await api('case-create/templates/admin'); $('list').innerHTML=rows.map(r=>`<div class="list-group-item d-flex align-items-center gap-2"><button type="button" class="btn btn-link flex-grow-1 text-start" data-edit="${r.id}"><i class="bi ${esc(r.icon)} me-2"></i>${esc(r.name)} · ${r.is_active?'Aktiv':'Inaktiv'} · ${r.sort_order}</button><button type="button" class="btn btn-sm btn-outline-danger" data-delete="${r.id}">Slet</button></div>`).join('') || '<p class="text-muted">Ingen skabeloner endnu.</p>'; }
async function init() {
try {
const [types, groups, stages, tagRows] = await Promise.all([api('settings/case_types'),api('case-create/template-options'),api('pipeline/stages'),api('tags?is_active=true')]);
let typeKeys; try { typeKeys=JSON.parse(types.value || '[]'); } catch { typeKeys=[]; }
if (!Array.isArray(typeKeys) || !typeKeys.length) typeKeys=['ticket','pipeline','ordre','opgave','projekt','service','abonnement'];
$('type').innerHTML=[...new Set([...typeKeys,'pipeline','abonnement'])].map(t=>`<option value="${esc(t)}">${esc(t)}</option>`).join('');
$('group').insertAdjacentHTML('beforeend',groups.map(g=>`<option value="${g.id}">${esc(g.name)}</option>`).join(''));
$('stage').insertAdjacentHTML('beforeend',stages.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join(''));
tags=tagRows; await load(); edit(null);
const setting = await api('settings/case_statuses');
const statuses = JSON.parse(setting.value || '[]');
if (Array.isArray(statuses)) statuses.forEach(s => { const value=typeof s==='string'?s:s.value; if(value && !Array.from($('status').options).some(o=>o.value===value)) $('status').add(new Option(typeof s==='string'?s:(s.label || value),value)); });
} catch(e) { message(e.message,true); }
$('new').onclick=()=>edit(null);
$('tags').onclick=e=>{ const b=e.target.closest('[data-tag]'); if(b){selected=selected.filter(id=>id!==Number(b.dataset.tag));renderTags();} };
$('add-tag').onclick=()=>window.tagPicker.showSelection(t=>{if(!selected.includes(t.id))selected.push(t.id);renderTags();});
$('list').onclick=async e=>{try{const b=e.target.closest('[data-edit],[data-delete]');if(!b)return;if(b.dataset.edit)edit(rows.find(r=>r.id===Number(b.dataset.edit)));else if(confirm('Slet denne fælles skabelon?')){await api(`case-create/templates/${b.dataset.delete}`,{method:'DELETE'});await load();edit(null);}}catch(err){message(err.message,true);}};
$('form').onsubmit=async e=>{e.preventDefault();const button=$('form').querySelector('[type=submit]');button.disabled=true;try{
const number=k=>$(k).value===''?null:Number($(k).value);
const pipeline={stage_id:number('stage'),amount:number('amount'),probability:number('probability'),description:$('pipeline-description').value || null};
const body={name:$('name').value.trim(),icon:$('icon').value,sort_order:number('sort') || 0,is_active:$('active').checked,values:{type:$('type').value,titel:$('title').value,beskrivelse:$('description').value,status:$('status').value,assigned_group_id:number('group'),tag_ids:selected,pipeline:Object.values(pipeline).some(v=>v!==null)?pipeline:null}};
const id=$('id').value;const saved=await api(`case-create/templates${id?`/${id}`:''}`,{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});await load();edit(saved);message('Skabelon gemt');
}catch(err){message(err.message,true);}finally{button.disabled=false;}};
}
document.addEventListener('DOMContentLoaded',init);
})();

View File

@ -0,0 +1,68 @@
(() => {
'use strict';
const $ = id => document.getElementById(id);
let kind = 'message', contact = null, searchTimer, searchVersion = 0, sending = false;
async function api(url, options = {}) {
const response = await fetch(`/api/v1/${url}`, {credentials:'include',...options});
const data = await response.json().catch(()=>({}));
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Handlingen mislykkedes. Prøv igen.');
return data;
}
function showError(message) { $('im-error').textContent=message; $('im-error').classList.remove('d-none'); }
function setKind(value) {
kind=value;
$('im-phone-fields').classList.toggle('d-none',kind!=='phone');
document.querySelectorAll('[data-internal-kind]').forEach(b=>{const active=b.dataset.internalKind===kind;b.classList.toggle('btn-primary',active);b.classList.toggle('btn-outline-primary',!active);b.setAttribute('aria-pressed',String(active));});
$('im-text').placeholder=kind==='phone'?'Fx: Har ringet om tilbuddet og vil gerne ringes op i eftermiddag.':'Hvad skal din kollega vide?';
}
function selectContact(value) {
contact=value; ++searchVersion; clearTimeout(searchTimer);
$('im-contact').replaceChildren(); $('im-contact-search').value=''; $('im-contact-results').classList.add('d-none');
if (!contact) return;
$('im-contact-panel').open = true;
const chip=document.createElement('button');chip.type='button';chip.className='btn btn-sm btn-outline-primary';chip.textContent=`${contact.name} ×`;chip.setAttribute('aria-label',`Fjern ${contact.name}`);chip.onclick=()=>selectContact(null);$('im-contact').append(chip);
if (!$('im-caller').value) $('im-caller').value=contact.name;
if (!$('im-phone').value) $('im-phone').value=contact.phone || '';
}
window.openInternalMessage = async (context={}) => {
if (sending) return;
$('im-error').classList.add('d-none');
if (!$('im-text').value.trim() && context.kind) setKind(context.kind);
if (!$('im-text').value.trim() && context.contact) selectContact(context.contact);
bootstrap.Modal.getOrCreateInstance($('internalMessageModal')).show();
try {
const [payload, me]=await Promise.all([api('users?is_active=true'),api('auth/me')]);
const selected=!$('im-text').value.trim() && context.recipient ? String(context.recipient) : $('im-recipient').value;
const users=Array.isArray(payload)?payload:(payload.data || []);
$('im-recipient').replaceChildren(new Option('Vælg medarbejder…',''));
users.filter(u=>Number(u.id || u.user_id)!==Number(me.id || me.user_id)).forEach(u=>$('im-recipient').add(new Option(u.full_name || u.username || u.email, u.id || u.user_id)));
$('im-recipient').value=selected;
} catch(error) { showError(error.message); }
};
document.querySelectorAll('[data-internal-kind]').forEach(b=>b.onclick=()=>setKind(b.dataset.internalKind));
$('internalMessageModal').addEventListener('hidden.bs.modal',()=>{++searchVersion;clearTimeout(searchTimer);$('im-contact-results').classList.add('d-none');});
$('im-contact-search').addEventListener('input',()=>{
clearTimeout(searchTimer);const version=++searchVersion,query=$('im-contact-search').value.trim();
$('im-contact-results').classList.add('d-none');
if(query.length<2)return;
searchTimer=setTimeout(async()=>{try{
const rows=await api(`search/contacts?q=${encodeURIComponent(query)}`);
if(version!==searchVersion || document.activeElement!==$('im-contact-search'))return;
$('im-contact-results').replaceChildren();
if(!Array.isArray(rows) || !rows.length){$('im-contact-results').textContent='Ingen kontakter fundet. Du kan sende uden kontaktperson.';}
else rows.slice(0,10).forEach(r=>{const name=[r.first_name,r.last_name].filter(Boolean).join(' ') || r.name || `Kontakt #${r.id}`,phone=r.mobile || r.phone || '';const b=document.createElement('button');b.type='button';b.className='list-group-item list-group-item-action';b.textContent=`${name}${phone?' · '+phone:''}`;b.onclick=()=>selectContact({id:r.id,name,phone});$('im-contact-results').append(b);});
$('im-contact-results').classList.remove('d-none');
}catch(error){if(version===searchVersion)showError(error.message);}},300);
});
$('internalMessageForm').addEventListener('submit',async e=>{
e.preventDefault();if(sending)return;
if(!$('im-recipient').value || !$('im-text').value.trim()){showError('Vælg en modtager og skriv en besked.');return;}
sending=true;$('im-send').disabled=true;$('im-send').textContent='Sender…';$('im-error').classList.add('d-none');
try {
await api('bottom-bar/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:$('im-text').value.trim(),recipient_user_id:Number($('im-recipient').value),requires_manual_ack:$('im-ack').checked,message_kind:kind,contact_id:contact?.id || null,caller_name:kind==='phone'?$('im-caller').value.trim():'',callback_phone:kind==='phone'?$('im-phone').value.trim():''})});
$('internalMessageForm').reset();selectContact(null);$('im-contact-panel').open=false;setKind('message');bootstrap.Modal.getOrCreateInstance($('internalMessageModal')).hide();
window.dispatchEvent(new CustomEvent('hub:internal-message-sent'));
if(window.showNotification)window.showNotification('Besked sendt til din kollega','success');
}catch(error){showError(error.message);}finally{sending=false;$('im-send').disabled=false;$('im-send').innerHTML='<i class="bi bi-send me-2"></i>Send besked';}
});
})();

23
static/js/message-ui.js Normal file
View File

@ -0,0 +1,23 @@
/* Presentational helpers shared by the internal inbox. No network or mutations. */
window.BmcMessageUI = (() => {
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const initials = name => String(name || '?').trim().split(/\s+/).slice(0,2).map(p=>p[0]).join('').toUpperCase();
const time = value => { const d=new Date(value); return value && !Number.isNaN(d.getTime()) ? d.toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}) : ''; };
function message(m) {
const own=!!m.is_own, phone=m.message_kind==='phone';
const number=String(m.callback_phone || '').replace(/[^+0-9*#,;]/g,'');
const person=m.caller_name || m.contact_name || 'Telefonbesked';
const contact=Number(m.contact_id)>0?`<a class="msg-contact" href="/contacts/${Number(m.contact_id)}"><i class="bi bi-person"></i> ${escape(m.contact_name || 'Åbn kontakt')}</a>`:'';
const replyId=own?Number(m.recipient_user_id):Number(m.sender_user_id);
const stamp=time(m.created_at);
const status=own?(m.is_acknowledged?'Bekræftet læst':m.is_read?'Læst':'Sendt'):'';
const phoneCard=phone?`<div class="msg-call"><span class="msg-call-icon"><i class="bi bi-telephone"></i></span><div><small>TELEFONBESKED</small><strong>${escape(person)}</strong>${m.callback_phone?`<span>${escape(m.callback_phone)}</span>`:''}</div>${number?`<a class="msg-call-action" href="tel:${escape(number)}" aria-label="Ring tilbage til ${escape(person)}"><i class="bi bi-telephone-outbound"></i> Ring tilbage</a>`:''}</div>`:'';
return `<article class="msg-message ${own?'is-own':''} ${phone?'is-phone':''}"><div class="msg-byline"><span>${own?'Dig':escape(m.from || 'Kollega')}</span>${stamp?`<time datetime="${escape(m.created_at)}" title="${escape(new Date(m.created_at).toLocaleString('da-DK'))}">${stamp}</time>`:''}${m.is_unread?'<span class="msg-new-dot" title="Ulæst" aria-label="Ulæst"></span>':''}</div><div class="msg-bubble">${phoneCard}<div class="msg-text">${escape(m.text)}</div>${contact}</div><div class="msg-message-actions">${status?`<span class="msg-delivery"><i class="bi bi-check2${m.is_read || m.is_acknowledged?'-all':''}"></i> ${status}</span>`:''}${m.requires_manual_ack && !m.is_acknowledged?'<span class="msg-ack-hint">Læsebekræftelse ønskes</span>':''}${!own && m.requires_manual_ack && !m.is_acknowledged?`<button type="button" class="msg-ack" data-bb-ack-message="${Number(m.id)}"><i class="bi bi-check2-circle"></i> Bekræft læst</button>`:''}${replyId>0?`<button type="button" class="msg-reply" data-bb-reply-message="${Number(m.id)}"><i class="bi bi-reply"></i> Svar</button>`:''}</div></article>`;
}
function thread(t, active) {
const last=t.items?.[t.items.length-1];
const preview=last?`${last.message_kind==='phone'?'Telefonbesked · ':''}${last.text || ''}`:'Start en samtale';
return `<span class="msg-avatar ${t.partnerUserId?'':'is-group'}">${t.partnerUserId?escape(initials(t.label)):'<i class="bi bi-people"></i>'}</span><span class="msg-thread-copy"><span class="msg-thread-top"><strong>${escape(t.label)}</strong><time>${time(t.lastCreatedAt)}</time></span><span class="msg-thread-preview">${escape(preview)}</span></span>${t.unread?`<span class="bb-message-thread-count">${Number(t.unread)}</span>`:''}`;
}
return {message,thread,escape,initials};
})();

View File

@ -256,6 +256,11 @@ class TagPicker {
async selectTag(tag) {
if (!tag) return;
if (this.selectionOnly) {
this.onSelectCallback?.(tag, null);
this.hide();
return;
}
console.log(`🏷️ Selecting tag ${tag.name} for context: ${this.contextType} #${this.contextId}`);
@ -356,6 +361,7 @@ class TagPicker {
}
show(contextType = null, contextId = null, onSelect = null) {
this.selectionOnly = false;
// Use provided context OR fall back to page defaults
// Note: arguments are undefined if not passed, so check for null/undefined
this.contextType = (contextType !== null && contextType !== undefined) ? contextType : this.defaultContextType;
@ -380,6 +386,13 @@ class TagPicker {
this.defaultOnSelectCallback = onSelect;
}
showSelection(onSelect) {
this.show(null, null, onSelect);
this.selectionOnly = true;
this.contextType = null;
this.contextId = null;
}
hide() {
const modalInstance = bootstrap.Modal.getInstance(this.modal);
if (modalInstance) {
@ -413,6 +426,18 @@ class TagPicker {
// Initialize global tag picker
window.tagPicker = new TagPicker();
// Deliver creation-time workflow events on the saved case, where handlers exist.
document.addEventListener('DOMContentLoaded', () => {
const match = location.pathname.match(/^\/sag\/(\d+)(?:\/v3)?\/?$/);
if (!match) return;
const key = `bmc:case-tag-actions:${match[1]}`;
try {
const actions = JSON.parse(sessionStorage.getItem(key) || '[]');
sessionStorage.removeItem(key);
setTimeout(() => actions.forEach(detail => window.dispatchEvent(new CustomEvent('hub:tag-action', {detail}))), 0);
} catch { /* Invalid or unavailable session storage must not block the page. */ }
});
// Helper function to show tag picker with context
window.showTagPicker = function(entityType, entityId, onSelect = null) {
window.tagPicker.show(entityType, entityId, onSelect);

82
static/messages.css Normal file
View File

@ -0,0 +1,82 @@
/* Internal messages: a quiet inbox, a readable conversation, one composer. */
.global-bottom-bar .bb-messages-layout{display:grid;grid-template-columns:248px minmax(0,1fr);gap:0;border:1px solid var(--border-color,#dce4e9);border-radius:14px;background:var(--bg-card,#fff);height:100%;min-height:0;overflow:hidden}
.global-bottom-bar .bb-message-threads{display:flex;flex-direction:column;gap:3px;overflow:auto;border-right:1px solid var(--border-color,#e2e8ed);padding:0 8px 10px;background:color-mix(in srgb,var(--bg-card,#fff) 97%,#59798c);scrollbar-width:thin}
.msg-inbox-heading{display:flex;align-items:center;justify-content:space-between;padding:16px 8px 12px;font-size:.72rem;font-weight:650;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#718293)}
.msg-inbox-heading button,.msg-header-action{border:0;background:transparent;color:var(--text-secondary,#657b8b);border-radius:8px;padding:7px;cursor:pointer}
.msg-inbox-heading button:hover,.msg-header-action:hover{background:var(--accent-light,#eaf1f4)}
.global-bottom-bar .bb-message-thread{width:100%;border:0;border-radius:10px;padding:12px 9px;gap:10px;white-space:normal;box-shadow:none;text-align:left;background:transparent;flex-shrink:0;min-height:66px}
.global-bottom-bar .bb-message-thread:hover{background:var(--bg-hover,#edf2f5)}
.global-bottom-bar .bb-message-thread.is-active{background:color-mix(in srgb,var(--bg-card,#fff) 90%,#278e87);color:var(--text-primary,#233f50);box-shadow:none;border:0}
.msg-avatar{display:grid;place-items:center;width:34px;height:34px;border-radius:11px;flex-shrink:0;background:color-mix(in srgb,var(--bg-card,#fff) 84%,#607a8e);font-size:.7rem;letter-spacing:.03em;color:var(--text-primary,#3d5868)}
.msg-avatar.is-group{background:color-mix(in srgb,var(--bg-card,#fff) 88%,#319387);color:var(--text-primary,#347369)}
.msg-thread-copy{min-width:0;flex:1;display:block}
.msg-thread-top{display:flex;align-items:center;justify-content:space-between;gap:8px}
.msg-thread-top strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:.79rem}
.msg-thread-top time{font-size:.62rem;flex-shrink:0;color:var(--text-secondary,#7d8d99);font-weight:400}
.msg-thread-preview{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#7d8d99);font-weight:400;font-size:.71rem;margin-top:4px}
.global-bottom-bar .bb-message-thread-count,.global-bottom-bar .bb-message-thread.is-active .bb-message-thread-count{background:#267f79;color:white;font-size:.63rem;min-width:17px;height:17px;padding:0 4px}
.msg-conversation{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden}
.msg-conversation-header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-shrink:0;padding:14px 20px;border-bottom:1px solid var(--border-color,#e6ebef)}
.msg-conversation-header strong{font-size:.87rem;font-weight:650;color:var(--text-primary,#233f50)}
.msg-conversation-header small{display:block;font-size:.68rem;color:var(--text-secondary,#7a8c99);margin-top:3px}
.msg-header-action{display:flex;gap:7px;align-items:center;font-size:.7rem;white-space:nowrap}
.global-bottom-bar .bb-messages-list{display:flex;flex-direction:column;gap:17px;flex:1;min-height:0;overflow-y:auto;padding:20px!important;margin:0;background:color-mix(in srgb,var(--bg-card,#fff) 98%,#678190)}
.global-bottom-bar .bb-messages-list>li{display:block!important;flex-shrink:0;margin:0;padding:0!important;border:0!important;box-shadow:none!important;background:transparent!important;list-style:none}
.msg-message{max-width:84%;width:fit-content;min-width:130px}
.msg-message.is-own{margin-left:auto}
.msg-byline{display:flex;align-items:center;gap:9px;padding:0 3px 5px;font-size:.64rem;color:var(--text-secondary,#7a8a97)}
.msg-byline>span:first-child{font-weight:600;color:var(--text-primary,#466072)}
.msg-message.is-own .msg-byline{justify-content:flex-end}
.msg-new-dot{width:5px;height:5px;border-radius:50%;background:#2c9589}
.msg-bubble{padding:12px 15px;border:1px solid var(--border-color,#e0e7ec);border-radius:3px 13px 13px;background:var(--bg-card,#fff);color:var(--text-primary,#304b5d)}
.msg-message.is-own .msg-bubble{background:color-mix(in srgb,var(--bg-card,#fff) 92%,#349b90);border-color:color-mix(in srgb,var(--border-color,#e0e7ec) 65%,#349b90);border-radius:13px 3px 13px 13px}
.msg-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.82rem;line-height:1.65}
.msg-message-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:5px;padding:0 3px;min-height:18px;color:var(--text-secondary,#81909c);font-size:.61rem}
.is-own .msg-message-actions{justify-content:flex-end}
.msg-message-actions button{font-size:.64rem;background:transparent;border:0;padding:1px 0;color:var(--text-secondary,#6b8291)}
.msg-message-actions .msg-ack{color:var(--text-primary,#266e65);font-weight:600}
.msg-reply:hover,.msg-ack:hover{text-decoration:underline}
.msg-contact{display:inline-flex;gap:5px;margin-top:9px;text-decoration:none;font-size:.68rem;color:var(--text-primary,#397480)}
.msg-call{display:flex;gap:10px;align-items:center;padding-bottom:12px;margin-bottom:10px;border-bottom:1px solid var(--border-color,#dce5e8)}
.msg-call-icon{width:33px;height:33px;border-radius:10px;display:grid;place-items:center;background:color-mix(in srgb,var(--bg-card,#fff) 83%,#cda65e);color:var(--text-primary,#80662f);flex-shrink:0}
.msg-call>div{min-width:0;flex:1}
.msg-call small{display:block;font-size:.54rem;letter-spacing:.08em;color:var(--text-secondary,#88919a);margin-bottom:3px}
.msg-call strong{display:block;font-weight:600;font-size:.8rem;overflow-wrap:anywhere}
.msg-call>div>span{display:block;font-size:.71rem;color:var(--text-secondary,#7a8c99);margin-top:3px}
.msg-call-action{display:flex;align-items:center;gap:5px;flex-shrink:0;text-decoration:none;color:var(--text-primary,#286e65);font-size:.66rem;padding:6px 8px;border:1px solid var(--border-color,#d4e0e4);border-radius:7px}
.global-bottom-bar .bb-messages-composer{margin:0;padding:12px 18px 10px;flex-shrink:0;border:0;border-top:1px solid var(--border-color,#e5ebee);background:var(--bg-card,#fff);border-radius:0}
.msg-compose-field{display:flex;align-items:flex-end;border:1px solid var(--border-color,#dbe4ea);border-radius:11px;background:var(--bg-card,#fff);padding:8px;gap:9px}
.msg-compose-field:focus-within{border-color:#639f99;box-shadow:0 0 0 2px #349b9012}
.msg-compose-field textarea{width:100%;min-width:0;resize:vertical;max-height:110px;min-height:43px;border:0;background:transparent;outline:none;padding:3px 5px;color:var(--text-primary,#304b5d);font-size:.82rem;line-height:1.6}
.msg-send{height:33px;width:33px;border:0;border-radius:9px;background:#267b74;color:white;flex-shrink:0}
.msg-send:disabled{opacity:.5}
.msg-compose-footer{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:7px;font-size:.63rem;color:var(--text-secondary,#84919c)}
.msg-compose-footer label{display:flex;gap:6px;align-items:center;cursor:pointer}
.msg-compose-footer input{accent-color:#267b74}
.msg-empty{display:grid;place-items:center;text-align:center;color:var(--text-secondary,#81919e);padding:24px 10px}
.msg-empty>span{width:46px;height:46px;border-radius:15px;background:var(--accent-light,#edf4f4);display:grid;place-items:center;margin-bottom:13px;font-size:1.25rem}
.msg-empty strong{font-size:.84rem;color:var(--text-primary,#3d5667)}
.msg-empty p{font-size:.73rem;max-width:260px;margin:7px 0}
/* Compose dialog shares the inbox palette and places the message first. */
#internalMessageModal .modal-dialog{max-width:640px}
#internalMessageModal .modal-content{border:1px solid var(--border-color,#dce4e9)!important;border-radius:18px!important;background:var(--bg-card,#fff);overflow:hidden}
#internalMessageModal .modal-header{padding:24px 28px 20px!important;align-items:flex-start}
#internalMessageModal .modal-header h4{font-size:1.3rem;letter-spacing:-.03em;font-weight:650;color:var(--text-primary,#233f50)}
#internalMessageModal .modal-header .text-uppercase{font-size:.6rem!important;letter-spacing:.12em;display:block;margin-bottom:7px}
#internalMessageModal .modal-body{padding:0 28px 8px!important}
#internalMessageModal .form-label{font-size:.76rem;font-weight:550;color:var(--text-primary,#445f71);margin-bottom:7px}
#internalMessageModal .form-control,#internalMessageModal .form-select{font-size:.83rem;border:1px solid var(--border-color,#dce4e9);border-radius:9px;background:var(--bg-card,#fff);color:var(--text-primary,#304b5d);padding:10px 12px}
#internalMessageModal .form-control:focus,#internalMessageModal .form-select:focus{border-color:#639f99;box-shadow:0 0 0 3px #349b9012}
#internalMessageModal #im-text{min-height:145px;line-height:1.7;resize:vertical}
#internalMessageModal [role=group]{display:flex;gap:4px!important;background:var(--bg-hover,#f1f5f6);padding:4px;border-radius:10px;margin-bottom:22px!important}
#internalMessageModal [data-internal-kind]{flex:1;border:0!important;background:transparent;color:var(--text-secondary,#7c8b96);border-radius:7px;font-size:.78rem;padding:10px}
#internalMessageModal [data-internal-kind][aria-pressed=true]{background:var(--bg-card,#fff);color:var(--text-primary,#2d635d);box-shadow:0 1px 5px #163b4912}
#internalMessageModal #im-phone-fields{padding:14px 4px;background:var(--bg-hover,#f5f7f8);border-radius:10px;margin:0 0 17px}
#internalMessageModal .modal-footer{padding:16px 28px 22px!important;background:var(--bg-hover,#f7f9fa);gap:8px}
#internalMessageModal .modal-footer .btn{border-radius:9px;font-size:.8rem;padding:10px 19px}
#internalMessageModal #im-send{background:#267b74;border-color:#267b74}
#internalMessageModal .form-check-label,#internalMessageModal p.small{font-size:.7rem!important;color:var(--text-secondary,#7c8c98)}
#internalMessageModal #im-contact .btn{font-size:.7rem;border-radius:7px;border-color:var(--border-color,#d6e4e5);color:var(--text-primary,#397480)}
#internalMessageModal .form-check-input{accent-color:#267b74}
@media(max-width:820px){.global-bottom-bar .bb-messages-layout{grid-template-columns:195px minmax(0,1fr)}.msg-avatar{display:none}.msg-conversation-header{padding:11px 13px}.global-bottom-bar .bb-messages-list{padding:14px!important}.msg-message{max-width:94%}.msg-compose-footer>small{display:none}.msg-call{flex-wrap:wrap}.msg-call-action{margin-left:43px}}
@media(max-width:600px){.global-bottom-bar .bb-messages-layout{display:flex;flex-direction:column}.global-bottom-bar .bb-message-threads{flex-direction:row;flex-shrink:0;overflow-x:auto;max-height:66px;border-right:0;border-bottom:1px solid var(--border-color,#e2e8ed);padding:5px;gap:4px}.msg-inbox-heading{padding:0 5px}.msg-inbox-heading>span{display:none}.global-bottom-bar .bb-message-thread{min-height:50px;width:160px;padding:7px 9px}.msg-thread-top time{display:none}.msg-conversation{flex:1}.msg-header-action span{display:none}.global-bottom-bar .bb-messages-composer{padding:8px 10px}.msg-compose-field textarea{min-height:32px}.msg-message-actions{gap:6px}.msg-ack-hint{display:none}#internalMessageModal .modal-body{padding:0 20px 8px!important}#internalMessageModal .modal-header{padding:22px 20px 18px!important}}

View File

@ -0,0 +1,87 @@
// Run with NODE_PATH pointing to a jsdom installation (no application dependency).
const {JSDOM} = require('jsdom');
const fs = require('node:fs');
const assert = require('node:assert/strict');
const source = fs.readFileSync('app/modules/sag/templates/create.html','utf8');
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1];
const html = source.split('{% block content %}')[1].split('<script>')[0]
.replace(/\{% for user[\s\S]*?\{% endfor %\}/, '<option value="7">Test bruger</option>')
.replace(/\{% for group[\s\S]*?\{% endfor %\}/, '<option value="2">Support</option>');
const extension = fs.readFileSync('static/js/case-create.js','utf8');
const delay = ms => new Promise(r=>setTimeout(r,ms));
async function page(storage, userId=7, search='') {
const dom = new JSDOM(html,{url:`http://localhost:8001/sag/new${search}`,runScripts:'outside-only'});
const w=dom.window;
await new Promise(r=>w.addEventListener('DOMContentLoaded',r));
w.CSS = {escape:s=>s}; w.confirm=()=>true; w.alert=()=>{};
w.setTagPickerContext=()=>{}; w.tagPicker={showSelection:cb=>cb({id:9,name:'Hardware',color:'#112233'})};
w.bootstrap={Modal:class {show(){} hide(){}}};
w.fetch=async url=>({ok:true,json:async()=>{
if(url.includes('/settings/case_types'))return {value:'["ticket","pipeline","ordre","opgave","service","abonnement"]'};
if(url.includes('/auth/me/profile'))return {default_case_type:'ticket'};
if(url.includes('/auth/me'))return {id:userId};
if(url.includes('/pipeline/stages'))return [{id:1,name:'Tilbud'}];
if(url.includes('/case-create/workload'))return {total:0,items:[]};
if(url.includes('/case-create/contacts-open-cases'))return {5:{total:2,items:[{id:44,titel:'Eksisterende routerfejl',status:'åben',ansvarlig_navn:'Test bruger',deadline:null}]}};
if(url.includes('/case-create/templates'))return [{id:1,name:'Test',values:{type:'pipeline',titel:'Tilbud',beskrivelse:'Tekst',status:'åben',tag_ids:[9]}}];
if(url.includes('/tags?'))return [{id:9,name:'Hardware'},{id:10,name:'Router',type:'brand',catch_words:['router'],color:'#112233'}];
return [];
}});
if(storage)for(const [k,v] of Object.entries(storage))w.localStorage.setItem(k,v);
w.eval(script + '\n' + extension + '\nwindow.testSetCustomer = () => { selectedCustomer={id:11,name:"Firma"}; selectedContacts={}; renderSelections(); }; window.testSetContact = () => { selectedContacts={5:{id:5,name:"Ada"}}; renderSelections(); window.caseCreateUI.contactsChanged(); };'); w.document.dispatchEvent(new w.Event('DOMContentLoaded'));
await delay(60);
return dom;
}
(async()=>{
const dom=await page(),w=dom.window,d=w.document;
assert.equal(d.querySelectorAll('#cc-types > button[data-case-type]').length,4);
assert.equal(d.querySelectorAll('#cc-types > button[data-case-message]').length,1);
assert.equal(d.querySelectorAll('.cc-panel').length,6);
assert.equal(d.querySelectorAll('form#createForm').length,1);
for(const id of ['customerSearch','titel','beskrivelse','status','deadline','submitBtn'])assert.ok(d.getElementById(id).closest('#createForm'),id);
assert.match(d.getElementById('cc-workload').textContent,/0 åbne sager/);
assert.equal(w.caseCreateUI.validate(),false);
assert.equal(d.getElementById('cc-relations').open,true);
w.testSetCustomer();
await w.loadSelectedCustomerContacts(11);
const search=d.getElementById('contactSearch'), results=d.getElementById('contactResults');
assert.ok(results.classList.contains('d-none'),'Selecting company must not open contact results');
search.focus();assert.ok(results.classList.contains('d-none'),'Empty focus must not open results');
search.value='Fi';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(!results.classList.contains('d-none'),'Explicit search opens results');
search.value='';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(results.classList.contains('d-none'),'Clearing closes results');
w.testSetContact(); await delay(30);
assert.match(d.getElementById('cc-contact-cases').textContent,/Eksisterende routerfejl/);
assert.match(d.getElementById('cc-contact-cases').innerHTML,/\/sag\/44\/v3/);
d.getElementById('titel').value='Test oprettelse';
d.getElementById('beskrivelse').value='Router mister forbindelsen';d.getElementById('beskrivelse').dispatchEvent(new w.Event('input',{bubbles:true}));await delay(400);
assert.match(d.getElementById('cc-tag-suggestions').textContent,/Router/);
d.getElementById('pipeline_amount').value='123';
d.querySelector('[data-case-type="ordre"]').click();
w.addOrderLine('sale');d.querySelector('.order-description').value='Router';d.querySelector('.order-amount').value='50';
d.querySelector('[data-case-type="ticket"]').click();
assert.equal(d.getElementById('pipeline_amount').value,'123');
assert.equal(w.caseCreateUI.validate(),true);
w.confirm=()=>false;
d.getElementById('cc-template').value='1';d.getElementById('cc-template').dispatchEvent(new w.Event('change',{bubbles:true}));await delay(30);
assert.equal(d.getElementById('titel').value,'Test oprettelse');
w.confirm=()=>true;
d.getElementById('cc-tag-add').click();
d.getElementById('titel').dispatchEvent(new w.Event('input',{bubbles:true}));
await delay(650);
const key=Object.keys(w.localStorage)[0];assert.ok(key?.startsWith('bmc:case-draft:v1:7:'), d.getElementById('cc-save').textContent);
const draft=JSON.parse(w.localStorage.getItem(key));assert.equal(draft.orders.length,1);assert.equal(draft.tags[0].id,9);
const saved={[key]:w.localStorage.getItem(key)};
const restored=await page(saved);restored.window.document.getElementById('cc-restore').click();await delay(80);
assert.equal(restored.window.document.getElementById('titel').value,'Test oprettelse');
assert.equal(restored.window.document.querySelector('.order-description').value,'Router');
assert.deepEqual(Array.from(restored.window.caseCreateUI.relations().tag_ids),[9]);
restored.window.caseCreateUI.created({id:99});await delay(600);assert.equal(restored.window.localStorage.getItem(key),null);
const other=await page(saved,8);assert.equal(other.window.document.getElementById('cc-restore'),null);
const prefill=await page(saved,7,'?title=Other');assert.equal(prefill.window.document.getElementById('cc-restore'),null);
const expired=await page({[key]:JSON.stringify({...draft,savedAt:Date.now()-8*86400000})});assert.equal(expired.window.document.getElementById('cc-restore'),null);
const td=expired.window.document;td.getElementById('cc-template').value='1';td.getElementById('cc-template').dispatchEvent(new expired.window.Event('change',{bubbles:true}));await delay(40);
assert.equal(td.getElementById('titel').value,'Tilbud');assert.equal(td.getElementById('type').value,'pipeline');assert.deepEqual(Array.from(expired.window.caseCreateUI.relations().tag_ids),[9]);
assert.equal(new Set(Array.from(d.querySelectorAll('[id]')).map(el=>el.id)).size,d.querySelectorAll('[id]').length,'IDs must remain unique');
[dom,restored,other,prefill,expired].forEach(x=>x.window.close());
console.log('PASS: panel structure, type switching, validation, tags, autosave, restore, user/prefill isolation, expiration, successful cleanup');
})().catch(e=>{console.error(e);process.exit(1);});

View File

@ -0,0 +1,125 @@
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.modules.sag.backend import create_support as support
def test_similarity_handles_danish_case_and_punctuation():
assert support.title_similarity('NETVÆRK: fejl!', 'netværk fejl') == 1
assert support.title_similarity('', '') == 0
assert support.title_similarity('Router virker ikke', 'Router virker ikke hos kunde') > .6
def test_template_rejects_forbidden_relationships_and_invalid_numbers():
with pytest.raises(ValidationError):
support.TemplateValues(customer_id=12)
with pytest.raises(ValidationError):
support.PipelineDefaults(probability=101)
with pytest.raises(ValidationError):
support.PipelineDefaults(amount=float('nan'))
@pytest.mark.parametrize('value', [None, {}, [True], [0], [-1], ['1']])
def test_relationship_ids_are_strict(value):
with pytest.raises(HTTPException):
support.ids(value, 'ids')
def test_duplicates_rank_and_limit(monkeypatch):
monkeypatch.setattr(support, 'closed_statuses', lambda: ['lukket'])
def query(sql, params):
assert 'deleted_at IS NULL' in sql
assert params == (7, ['lukket'])
return [{'id': i, 'titel': 'Netværk fejl'} for i in range(8)] + [{'id': 9, 'titel': 'xyz'}]
monkeypatch.setattr(support, 'execute_query', query)
assert [r['id'] for r in support.duplicates(7, 'Netværk fejl')] == [7, 6, 5, 4, 3]
def test_workload_empty_and_total(monkeypatch):
monkeypatch.setattr(support, 'closed_statuses', lambda: ['closed'])
monkeypatch.setattr(support, 'execute_query', lambda *args: [])
assert support.workload(7) == {'total': 0, 'items': []}
monkeypatch.setattr(support, 'execute_query', lambda *args: [{'id': 4, 'total': 25}])
assert support.workload(7)['total'] == 25
def test_contact_open_cases_returns_only_active_cases_and_first_five(monkeypatch):
monkeypatch.setattr(support, 'closed_statuses', lambda: ['lukket'])
def query(sql, params):
assert 'FROM sag_kontakter' in sql
assert 'position<=5' in sql
assert params == ([3], ['lukket'])
return [
{'contact_id': 3, 'id': index, 'titel': f'Sag {index}', 'status': 'åben',
'deadline': None, 'ansvarlig_navn': 'Ada', 'total': 7}
for index in range(1, 6)
]
monkeypatch.setattr(support, 'execute_query', query)
result = support.contacts_open_cases([3])
assert result[3]['total'] == 7
assert [item['id'] for item in result[3]['items']] == [1, 2, 3, 4, 5]
def test_associations_deduplicate_and_single_group_last_wins():
class Cursor:
def __init__(self): self.calls = []
def execute(self, sql, args): self.calls.append((sql, args))
def fetchall(self):
if 'hardware_assets' in self.calls[-1][0]: return [{'id': 4}]
return [{'id': i, 'name': str(i), 'tag_group_id': 5, 'behavior': 'single'} for i in [8, 9]]
def fetchone(self): return {'action_type': 'open_task_template_modal', 'action_config': {}}
cursor = Cursor()
actions = support.attach_create_relations(cursor, 20, {'hardware_ids': [4, 4], 'tag_ids': [8, 9]}, 7)
inserts = [args for sql, args in cursor.calls if 'INSERT INTO entity_tags' in sql]
assert inserts == [(20, 9, 7)]
assert len([sql for sql, _ in cursor.calls if 'INSERT INTO sag_hardware' in sql]) == 1
assert actions[0]['entity_id'] == 20
def test_missing_hardware_fails_before_association_insert():
class Cursor:
def execute(self, sql, args): assert 'SELECT' in sql
def fetchall(self): return []
with pytest.raises(HTTPException):
support.attach_create_relations(Cursor(), 20, {'hardware_ids': [999]}, 7)
@pytest.mark.parametrize('missing_hardware', [False, True])
def test_ticket_creation_commits_all_optional_data_or_rolls_back(monkeypatch, missing_hardware):
import asyncio
from app.modules.sag.backend import router as cases
class Cursor:
def __init__(self): self.calls = []
def __enter__(self): return self
def __exit__(self, *_): pass
def execute(self, sql, args): self.calls.append((sql, args))
def fetchone(self):
if 'INSERT INTO sag_sager' in self.calls[-1][0]: return {'id': 20}
return None
def fetchall(self):
if 'hardware_assets' in self.calls[-1][0]: return [] if missing_hardware else [{'id': 4}]
return [{'id': 9, 'name': 'Test', 'tag_group_id': None, 'behavior': None}]
class Connection:
def __init__(self): self.cur = Cursor(); self.committed = False; self.rolled_back = False
def cursor(self, **kwargs): return self.cur
def commit(self): self.committed = True
def rollback(self): self.rolled_back = True
conn = Connection()
monkeypatch.setattr(cases, 'get_db_connection', lambda: conn)
monkeypatch.setattr(cases, 'release_db_connection', lambda c: None)
monkeypatch.setattr(cases, '_normalize_case_status', lambda v: 'åben')
monkeypatch.setattr(cases, '_get_user_id_from_request', lambda r: 7)
monkeypatch.setattr(cases, '_validate_user_id', lambda v: None)
monkeypatch.setattr(cases, '_validate_group_id', lambda v: None)
monkeypatch.setattr(cases, 'table_has_column', lambda *a: True)
data = {'titel': 'Test', 'customer_id': 3, 'type': 'ticket', 'pipeline': {'amount': 123}, 'hardware_ids': [4], 'tag_ids': [9], 'order_items': [{'description': 'Router', 'amount': 50}]}
if missing_hardware:
with pytest.raises(HTTPException): asyncio.run(cases.create_sag(None, data))
assert conn.rolled_back and not conn.committed
else:
result = asyncio.run(cases.create_sag(None, data))
assert result['id'] == 20 and conn.committed and not conn.rolled_back
sql = '\n'.join(query for query, _ in conn.cur.calls)
assert all(table in sql for table in ['sag_sager','sag_salgsvarer','sag_hardware','entity_tags'])
case_args = next(args for query, args in conn.cur.calls if 'INSERT INTO sag_sager' in query)
assert 123.0 in case_args

View File

@ -99,12 +99,14 @@ def test_sync_globalconnect_extraction_creates_connections_and_ip_ranges(monkeyp
assert created_ranges
assert ensured_ranges
assert created_connections[0][0] == "Malerfirmaet Gert Jensen ApS"
assert created_connections[0][2] is None
assert created_connections[0][9] == 100
assert created_connections[0][10] == 100
assert created_connections[0][11] == 100
assert created_connections[0][13] == "dedicated"
assert created_connections[0][14] == "other"
assert created_ranges[0][2] == "152.115.84.232/29"
assert created_ranges[0][6] is None
def test_sync_globalconnect_infers_dsl_kbps_speed(monkeypatch):
@ -187,6 +189,7 @@ def test_sync_globalconnect_marks_uncertain_connection_pending(monkeypatch):
monkeypatch.setattr(supplier_module, "_load_extraction_lines", lambda _: lines)
monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", lambda: [])
monkeypatch.setattr(supplier_module, "execute_query", lambda query, params=None: [])
monkeypatch.setattr(supplier_module, "execute_query_single", lambda query, params=None: None)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
@ -219,7 +222,7 @@ def test_upsert_globalconnect_connection_requires_service_address(monkeypatch):
assert connection_id is None
def test_sync_globalconnect_assigns_shared_bmc_value_model(monkeypatch):
def test_sync_globalconnect_creates_pending_reference_shell_without_customer(monkeypatch):
created_connections = []
extraction = {
@ -264,13 +267,14 @@ def test_sync_globalconnect_assigns_shared_bmc_value_model(monkeypatch):
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["connections_synced"] == 0
assert result["ip_ranges_synced"] == 0
assert result["skipped_orphan_ip_ranges"] == 1
assert created_connections == []
assert result["connections_synced"] == 1
assert result["ip_ranges_synced"] == 1
assert result["skipped_orphan_ip_ranges"] == 0
assert created_connections
assert created_connections[0][2] is None
def test_upsert_globalconnect_connection_assigns_delefiber_for_internal_shared_owner(monkeypatch):
def test_upsert_globalconnect_connection_does_not_infer_shared_from_address_or_mpls(monkeypatch):
created_connections = []
line = {
"description": "1 Gbps fiberforbindelse MPLS VPN",
@ -306,9 +310,68 @@ def test_upsert_globalconnect_connection_assigns_delefiber_for_internal_shared_o
assert connection_id == 901
assert created_connections
assert created_connections[0][2] == 1662
assert created_connections[0][-3] == "shared"
assert created_connections[0][-2] == "delefiber"
assert created_connections[0][2] is None
assert created_connections[0][-3] == "dedicated"
assert created_connections[0][-2] == "other"
def test_shared_classification_requires_explicit_invoice_wording():
assert supplier_module._should_assign_internal_bmc_owner(
[{"description": "Delt transit backbone"}], None, "Testvej 1"
) is True
assert supplier_module._should_assign_internal_bmc_owner(
[{"description": "MPLS VPN 1 Gbps"}], None, "Testvej 1"
) is False
def test_existing_ip_range_matches_canonical_network_not_raw_text(monkeypatch):
monkeypatch.setattr(
supplier_module,
"execute_query",
lambda query, params=None: [{
"connection_id": 44,
"cidr": "192.0.2.0/29",
"service_address": "Testvej 1, 8000 Aarhus",
"provider_reference": "NKA123456",
"connection_address": "Testvej 1, 8000 Aarhus",
"connection_reference": "NKA123456",
}],
)
result = supplier_module._resolve_existing_ip_range_connection({
"ip_address": "192.0.2.1 / 29",
"provider_reference": "NKA-123456",
"service_address": "Testvej 1, 8000 Aarhus",
})
assert result["connection_id"] == 44
assert result["conflict_reason"] is None
def test_unique_circuit_reference_matches_dash_and_dsl_eb_alias(monkeypatch):
monkeypatch.setattr(
supplier_module,
"execute_query",
lambda query, params=None: [
{"id": 11, "circuit_number": "NKA020900"},
{"id": 12, "circuit_number": "DSL-EB528263"},
],
)
assert supplier_module._find_unique_globalconnect_connection_by_reference("NKA-020900") == 11
assert supplier_module._find_unique_globalconnect_connection_by_reference("EB528263") == 12
def test_reference_match_keys_treat_eb_and_dsl_eb_as_same_circuit():
assert supplier_module._provider_reference_match_keys("EB528263") == {"EB528263", "DSLEB528263"}
assert supplier_module._provider_reference_match_keys("DSL-EB528263") == {"EB528263", "DSLEB528263"}
def test_bare_eb_ip_reference_is_not_allowed_to_create_a_pending_connection():
source = Path('app/billing/backend/supplier_invoices.py').read_text()
assert 'not simulate and not reference.startswith("EB")' in source
assert 'simulate and not reference.startswith("EB")' in source
def test_sync_globalconnect_attaches_ip_range_to_existing_connection(monkeypatch):
@ -511,6 +574,7 @@ def test_sync_globalconnect_skips_reference_when_address_conflicts(monkeypatch):
"execute_query",
lambda query, params=None: [{"id": 16, "address": "Andenvej 99, 2100 København Ø"}] if "FROM internet_connections_connections" in query else [],
)
monkeypatch.setattr(supplier_module, "execute_query_single", lambda query, params=None: None)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
@ -519,7 +583,7 @@ def test_sync_globalconnect_skips_reference_when_address_conflicts(monkeypatch):
assert "anden adresse" in result["skipped_items"][0]["reason"].lower()
def test_sync_globalconnect_skips_ip_range_when_connection_reference_has_other_service_address(monkeypatch):
def test_sync_globalconnect_uses_circuit_address_when_ip_line_address_is_shifted(monkeypatch):
extraction = {
"extraction_id": 180,
"vendor_name": "GlobalConnect A/S",
@ -585,7 +649,7 @@ def test_sync_globalconnect_skips_ip_range_when_connection_reference_has_other_s
created_connections.append(params)
return 96
if "INSERT INTO internet_connections_ip_ranges" in query:
raise AssertionError("IP-range should not be created when service address conflicts")
return 97
return 1
monkeypatch.setattr(supplier_module, "execute_query", fake_execute_query)
@ -596,13 +660,14 @@ def test_sync_globalconnect_skips_ip_range_when_connection_reference_has_other_s
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["connections_synced"] == 1
assert result["ip_ranges_synced"] == 0
assert result["skipped_orphan_ip_ranges"] == 1
assert result["ip_ranges_synced"] == 1
assert result["skipped_orphan_ip_ranges"] == 0
assert created_connections
skipped_ip_entries = [entry for entry in result["line_audit"] if entry["classification"] == "ip_range"]
assert skipped_ip_entries
assert skipped_ip_entries[0]["status"] == "skipped"
assert "ingen forbindelse fundet" in (skipped_ip_entries[0]["reason"] or "").lower()
ip_entries = [entry for entry in result["line_audit"] if entry["classification"] == "ip_range"]
assert ip_entries[0]["status"] == "synced"
assert ip_entries[0]["matched_by"] == "unique_circuit_reference"
assert ip_entries[0]["service_address_corrected"] is True
assert "Rydagervej 27" in ip_entries[0]["address_warning"]
def test_upsert_globalconnect_connection_logs_changes_and_creates_case(monkeypatch):

View File

@ -0,0 +1,30 @@
const {JSDOM}=require('jsdom');
const fs=require('node:fs');
const assert=require('node:assert/strict');
(async()=>{
const dom=new JSDOM(fs.readFileSync('app/shared/frontend/internal_message.html','utf8'),{url:'http://localhost:8001',runScripts:'outside-only'});
const w=dom.window,d=w.document;let sent=null,fail=false;
w.bootstrap={Modal:{getOrCreateInstance:()=>({show(){},hide(){}})}};
w.fetch=async(url,options={})=>{
if(options.method==='POST'){sent=JSON.parse(options.body);return {ok:!fail,json:async()=>fail?{detail:'Testfejl'}:{message:'Besked sendt'}};}
return {ok:true,json:async()=>url.includes('auth/me')?{id:1}:url.includes('users')?[{id:1,full_name:'Mig'},{id:2,full_name:'Kollega'}]:[]};
};
w.eval(fs.readFileSync('static/js/internal-message.js','utf8'));
await w.openInternalMessage();
assert.equal(d.getElementById('im-recipient').options.length,2);
assert.equal(sent,null,'Opening must not send anything');
d.getElementById('im-recipient').value='2';d.getElementById('im-text').value='Kort besked';
d.getElementById('internalMessageForm').dispatchEvent(new w.Event('submit',{cancelable:true}));await new Promise(r=>setTimeout(r,10));
assert.equal(sent.contact_id,null);assert.equal(sent.message_kind,'message');assert.equal(sent.recipient_user_id,2);
await w.openInternalMessage({contact:{id:4,name:'Kontakt'}});
d.querySelector('[data-internal-kind="phone"]').click();
assert.ok(!d.getElementById('im-phone-fields').classList.contains('d-none'));
d.getElementById('im-recipient').value='2';d.getElementById('im-phone').value='12345678';d.getElementById('im-text').value='Ring venligst tilbage';
fail=true;d.getElementById('internalMessageForm').dispatchEvent(new w.Event('submit',{cancelable:true}));await new Promise(r=>setTimeout(r,10));
assert.equal(sent.contact_id,4);assert.equal(sent.caller_name,'Kontakt');assert.equal(sent.callback_phone,'12345678');assert.equal(sent.message_kind,'phone');
assert.equal(d.getElementById('im-text').value,'Ring venligst tilbage','Failure must preserve draft');
d.getElementById('im-contact').querySelector('button').click();fail=false;
d.getElementById('internalMessageForm').dispatchEvent(new w.Event('submit',{cancelable:true}));await new Promise(r=>setTimeout(r,10));
assert.equal(sent.contact_id,null);assert.equal(sent.caller_name,'Kontakt');assert.equal(d.getElementById('im-text').value,'');
dom.window.close();console.log('PASS: explicit sending, recipient choice, normal/phone messages, optional/removable contact, failure preserves text');
})().catch(e=>{console.error(e);process.exit(1);});

View File

@ -0,0 +1,68 @@
"""Internal message tests use a fake database; no messages are delivered."""
import asyncio
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.modules.bottom_bar.backend import router as messages
@pytest.mark.parametrize('contact_id', [None, 4])
def test_phone_message_stores_optional_contact(monkeypatch, contact_id):
calls = []
monkeypatch.setattr(messages, 'ensure_bottom_bar_messages_schema', lambda: None)
monkeypatch.setattr(messages, '_ensure_user_exists', lambda uid: None)
def query(sql, args):
calls.append((sql, args))
if 'SELECT id FROM contacts' in sql:
return {'id': 4}
return {'id': 8}
monkeypatch.setattr(messages, 'execute_query_single', query)
payload = messages.BottomBarMessageCreatePayload(message=' Ring tilbage ', recipient_user_id=2, contact_id=contact_id, message_kind='phone', caller_name='Navn', callback_phone='12345678')
result = asyncio.run(messages.send_bottom_bar_message(payload, {'id': 1, 'full_name': 'Test'}))
args = next(args for sql, args in calls if 'INSERT' in sql)
assert args == (1, 2, 'Ring tilbage', False, 'phone', contact_id, 'Navn', '12345678')
assert result['item']['message_kind'] == 'phone'
def test_invalid_contact_does_not_send(monkeypatch):
monkeypatch.setattr(messages, 'ensure_bottom_bar_messages_schema', lambda: None)
monkeypatch.setattr(messages, '_ensure_user_exists', lambda uid: None)
def query(sql, args):
assert 'INSERT' not in sql
return None
monkeypatch.setattr(messages, 'execute_query_single', query)
payload = messages.BottomBarMessageCreatePayload(message='Test', recipient_user_id=2, contact_id=999)
with pytest.raises(HTTPException) as error:
asyncio.run(messages.send_bottom_bar_message(payload, {'id': 1}))
assert error.value.status_code == 400
def test_legacy_message_defaults_are_preserved():
payload = messages.BottomBarMessageCreatePayload(message='Almindelig besked')
assert payload.message_kind == 'message'
assert payload.contact_id is None
assert payload.recipient_user_id is None
with pytest.raises(ValidationError):
messages.BottomBarMessageCreatePayload(message='Test', message_kind='unknown')
def test_self_message_is_rejected(monkeypatch):
monkeypatch.setattr(messages, 'ensure_bottom_bar_messages_schema', lambda: None)
monkeypatch.setattr(messages, '_ensure_user_exists', lambda uid: None)
payload = messages.BottomBarMessageCreatePayload(message='Test', recipient_user_id=1)
with pytest.raises(HTTPException):
asyncio.run(messages.send_bottom_bar_message(payload, {'id': 1}))
def test_outgoing_status_uses_recipient_receipt(monkeypatch):
from datetime import datetime
from app.modules.bottom_bar.backend import service
monkeypatch.setattr(service, 'ensure_bottom_bar_messages_schema', lambda: None)
def query(sql, args):
assert 'THEN m.recipient_user_id ELSE %s END' in sql
assert len(args) == 6
return [{'id': 8, 'sender_user_id': 1, 'recipient_user_id': 2, 'message_text': 'Hej', 'created_at': datetime(2026,9,8), 'read_at': datetime(2026,9,8), 'acknowledged_at': None}]
monkeypatch.setattr(service, 'execute_query', query)
monkeypatch.setattr(service, 'execute_query_single', lambda *a: {'count': 0})
item = service.get_user_messages_summary(1)['list'][0]
assert item['is_own'] and item['is_read'] and not item['is_unread']

View File

@ -0,0 +1,47 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.modules.internet_connections.backend import change_case_service as service
def test_relevant_change_filter_excludes_manual_classification_and_customer():
changes = service.filter_relevant_changes({
'monthly_cost': {'from': 100, 'to': 125},
'customer_id': {'from': None, 'to': 7},
'allocation_model': {'from': 'dedicated', 'to': 'shared'},
'notes': {'from': 'før', 'to': 'efter'},
})
assert changes == {'monthly_cost': {'from': 100, 'to': 125}}
def test_same_import_merges_into_existing_case(monkeypatch):
writes = []
monkeypatch.setattr(service, 'execute_query_single', lambda query, params=None: (
{'id': 4, 'sag_id': 91, 'changes': {'monthly_cost': {'from': 100, 'to': 125}}}
if 'FROM internet_connection_change_cases' in query else None
))
monkeypatch.setattr(service, 'execute_query', lambda query, params=None, fetch=True: writes.append((query, params)))
result = service.ensure_external_change_case(
connection_id=5, source_type='test_import', source_key='same-file',
source_label='Testfil', connection_name='Fiber', reference='NKA-1', provider='Leverandør',
owner_customer_id=7, changes={'speed_mbps': {'from': 100, 'to': 200}},
)
assert result['case_id'] == 91
assert result['created'] is False
assert set(result['changes']) == {'monthly_cost', 'speed_mbps'}
assert any('UPDATE sag_sager' in query for query, _ in writes)
def test_case_failure_is_reported_without_raising(monkeypatch):
monkeypatch.setattr(service, 'execute_query_single', lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError('database down')))
monkeypatch.setattr(service, 'execute_query', lambda *args, **kwargs: None)
result = service.ensure_external_change_case(
connection_id=5, source_type='test', source_key='run-1', source_label='Test',
changes={'status': {'from': 'active', 'to': 'inactive'}},
)
assert result['case_id'] is None
assert result['error'] == 'database down'

View File

@ -1,5 +1,7 @@
import sys
import asyncio
import io
import zipfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
@ -9,6 +11,33 @@ from fastapi.testclient import TestClient
from main import app
def _build_ip_nordic_test_xlsx():
rows = [
["Company", "Name", "Startdate", "Salgspris", "Kostpris", "InstallationAddress"],
["99773", "BMC Denmark ApS", "43418", "2495", "1386", "Engholm Parkvej 8, 3450 Allerød"],
["99773", "BMC Denmark ApS", "43418", "129", "88", "Engholm Parkvej 8, 3450 Allerød "],
]
xml_rows = []
for row_number, values in enumerate(rows, start=1):
cells = []
for column_number, value in enumerate(values):
column = chr(ord('A') + column_number)
if row_number == 1 or column in {'A', 'B', 'F'}:
cells.append(f'<c r="{column}{row_number}" t="inlineStr"><is><t>{value}</t></is></c>')
else:
cells.append(f'<c r="{column}{row_number}"><v>{value}</v></c>')
xml_rows.append(f'<row r="{row_number}">{"".join(cells)}</row>')
sheet = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f'<sheetData>{"".join(xml_rows)}</sheetData></worksheet>'
)
output = io.BytesIO()
with zipfile.ZipFile(output, 'w') as archive:
archive.writestr('xl/worksheets/sheet1.xml', sheet)
return output.getvalue()
def test_internet_connections_module_routes_are_available():
client = TestClient(app)
@ -22,7 +51,6 @@ def test_internet_connections_module_routes_are_available():
'Internetforbindelser' in page_response.text
or "window.location.href = '/login'" in page_response.text
)
detail_response = client.get('/economy/internet-connections/1')
assert detail_response.status_code == 200
assert (
@ -31,6 +59,74 @@ def test_internet_connections_module_routes_are_available():
)
def test_ip_nordic_xlsx_parser_groups_lines_by_company_and_address():
from app.modules.internet_connections.backend import router as internet_router
items = internet_router._parse_ip_nordic_xlsx(_build_ip_nordic_test_xlsx())
assert len(items) == 1
assert items[0]['line_count'] == 2
assert float(items[0]['monthly_cost']) == 1474
assert float(items[0]['sales_price']) == 2624
assert items[0]['address'] == 'Engholm Parkvej 8, 3450 Allerød'
def test_ip_nordic_import_preview_never_assigns_customer(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: None)
client = TestClient(app)
response = client.post(
'/api/v1/internet-connections/import/ip-nordic',
files={'file': ('IP_Nordic.xlsx', _build_ip_nordic_test_xlsx(), 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')},
data={'commit': 'false'},
)
assert response.status_code == 200
assert response.json()['create_count'] == 1
assert response.json()['customer_auto_assignment'] is False
def test_ip_nordic_existing_connection_creates_one_change_case_without_customer_assignment(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
writes = []
cases = []
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: {
'id': 44, 'name': 'IP Nordic forbindelse', 'address': 'Engholm Parkvej 8, 3450 Allerød',
'customer_id': None, 'monthly_cost': 1000, 'sales_price': 2000,
'provider': 'IP Nordic', 'status': 'active', 'technology': 'fiber',
'connection_type': 'fiber', 'circuit_number': None,
})
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None, fetch=True: writes.append((query, params)))
monkeypatch.setattr(internet_router, '_create_history_entry', lambda *args: writes.append(args))
monkeypatch.setattr(internet_router, 'ensure_external_change_case', lambda **kwargs: cases.append(kwargs) or {
'case_id': 88, 'created': True, 'error': None,
})
response = TestClient(app).post(
'/api/v1/internet-connections/import/ip-nordic',
files={'file': ('IP_Nordic.xlsx', _build_ip_nordic_test_xlsx(), 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')},
data={'commit': 'true'},
)
assert response.status_code == 200
assert response.json()['updated_count'] == 1
assert response.json()['change_case_ids'] == [88]
assert len(cases) == 1
assert cases[0]['owner_customer_id'] is None
assert cases[0]['changes']['monthly_cost']['to'] == 1474
assert all('customer_id=' not in str(query).lower() for query, *_ in writes if isinstance(query, str))
def test_internet_connections_page_has_ip_nordic_preview_import():
template = Path('app/modules/internet_connections/templates/index.html').read_text()
assert 'Importér IP Nordic' in template
assert 'previewIpNordicImport()' in template
assert 'commitIpNordicImport()' in template
def test_create_ip_range_rejects_invalid_cidr(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
@ -50,6 +146,17 @@ def test_create_ip_range_rejects_invalid_cidr(monkeypatch):
assert 'CIDR' in response.json()['detail']
def test_document_entity_extraction_canonicalizes_cidr_with_host_bits_and_spaces():
from app.modules.internet_connections.backend import router as internet_router
entities = internet_router._extract_segment_entities(
"WAN range 192.0.2.1 / 29 og gateway 192.0.2.2"
)
assert entities["cidr_blocks"] == ["192.0.2.0/29"]
assert entities["ip_addresses"] == ["192.0.2.2"]
def test_create_connection_requires_address(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
@ -559,7 +666,7 @@ def test_contract_overview_returns_empty_list_when_query_fails(monkeypatch):
assert response.json() == []
def test_list_connections_returns_empty_list_when_query_fails(monkeypatch):
def test_list_connections_returns_visible_error_when_query_fails(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fail_execute_query(query, params=None):
@ -570,8 +677,8 @@ def test_list_connections_returns_empty_list_when_query_fails(monkeypatch):
client = TestClient(app)
response = client.get('/api/v1/internet-connections')
assert response.status_code == 200
assert response.json() == []
assert response.status_code == 500
assert response.json()['detail'] == 'Kunne ikke hente internetforbindelser'
def test_pricing_summary_returns_zeroes_when_query_fails(monkeypatch):
@ -683,6 +790,210 @@ def test_list_connections_supports_shared_only_filter(monkeypatch):
assert payload['is_shared_head'] is True
def test_first_bmcnet_child_promotes_head_and_preserves_customer(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
single_results = iter([
{'id': 12, 'allocation_model': 'dedicated', 'value_type': 'other', 'value_label': 'Internetforbindelse'},
{'child_count': 1},
])
writes = []
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: next(single_results))
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: writes.append((query, params)) or [])
assert internet_router._sync_bmcnet_parent_classification(12) is True
classification_query, params = writes[0]
assert 'customer_id' not in classification_query
assert params == ('shared', 'delefiber', None, 12)
def test_last_bmcnet_child_removal_returns_head_to_dedicated(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
single_results = iter([
{'id': 12, 'allocation_model': 'shared', 'value_type': 'delefiber', 'value_label': None},
{'child_count': 0},
])
writes = []
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: next(single_results))
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: writes.append((query, params)) or [])
assert internet_router._sync_bmcnet_parent_classification(12) is True
assert writes[0][1] == ('dedicated', 'other', 'Internetforbindelse', 12)
def test_manually_marked_delefiber_stays_shared_without_children(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
single_results = iter([
{'id': 12, 'allocation_model': 'shared', 'value_type': 'delefiber', 'value_label': None, 'is_manual_shared': True},
{'child_count': 0},
])
writes = []
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: next(single_results))
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: writes.append((query, params)) or [])
assert internet_router._sync_bmcnet_parent_classification(12) is False
assert writes == []
def test_bmcnet_wizard_is_available_on_dedicated_root_connection():
template = Path('app/modules/internet_connections/templates/detail.html').read_text()
assert "const canCreateBmcnet = Boolean(connection && !connection.parent_id);" in template
assert "filter((item) => !item?.parent_id)" in template
def test_list_connections_supports_unallocated_filter(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
assert "ic.customer_id IS NULL" in query
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
response = TestClient(app).get('/api/v1/internet-connections', params={'unallocated_only': 'true'})
assert response.status_code == 200
assert response.json() == []
def test_internet_connection_tabs_include_dedicated_and_unallocated():
template = Path('app/modules/internet_connections/templates/index.html').read_text()
assert "setActiveTab('dedicated')" in template
assert "setActiveTab('unallocated')" in template
assert "params.set('allocation_model', 'dedicated')" in template
assert "params.set('allocated_only', 'true')" in template
assert "params.set('unallocated_only', 'true')" in template
def test_processed_internet_invoices_have_their_own_tab():
template = Path('app/modules/internet_connections/templates/index.html').read_text()
assert "setActiveTab('invoices')" in template
assert 'id="invoiceProcessingOverview"' in template
assert "document.getElementById('connectionsOverview').classList.toggle('d-none', invoiceMode)" in template
assert "document.getElementById('invoiceProcessingOverview').classList.toggle('d-none', !invoiceMode)" in template
assert "if (activeTab === 'invoices') return loadInvoiceSyncRuns();" in template
def test_connections_show_and_allocate_sla_subscriptions():
index_template = Path('app/modules/internet_connections/templates/index.html').read_text()
detail_template = Path('app/modules/internet_connections/templates/detail.html').read_text()
migration = Path('migrations/1025_internet_connections_sla_subscription.sql').read_text()
assert 'sla_subscription_id' in migration
assert 'Ingen SLA-aftale' in index_template
assert 'id="slaSubscriptionSelect"' in detail_template
assert 'Prisen skal kontrolleres' in detail_template
assert "JSON.stringify({ sla_subscription_id: value })" in detail_template
def test_manual_bmc_shared_fiber_does_not_suggest_customer_allocation():
template = Path('app/modules/internet_connections/templates/detail.html').read_text()
assert 'const isBmcSharedFiber = Boolean(' in template
assert 'connection.is_manual_shared' in template
assert 'const shouldSuggestCustomer = !connection.customer_id && !isBmcSharedFiber;' in template
def test_invoice_review_reconciles_ranges_that_are_already_allocated():
template = Path('app/modules/internet_connections/templates/index.html').read_text()
from app.modules.internet_connections.backend import router as internet_router
assert "invoice-sync-runs/reconcile', { method: 'POST' }" in template
assert hasattr(internet_router, 'reconcile_internet_invoice_reviews')
def test_invoice_reconciliation_is_explicit_and_shared_fiber_has_no_sla_warning():
index_template = Path('app/modules/internet_connections/templates/index.html').read_text()
detail_template = Path('app/modules/internet_connections/templates/detail.html').read_text()
load_body = index_template.split('async function loadInvoiceSyncRuns()', 1)[1].split('async function reconcileInvoiceSyncRuns', 1)[0]
assert "await fetch('/api/v1/internet-connections/invoice-sync-runs/reconcile'" not in load_body
assert 'onclick="reconcileInvoiceSyncRuns()"' in index_template
assert "if (isBmcSharedFiber)" in detail_template
assert "banner.className = 'd-none';" in detail_template
def test_unallocated_tab_has_compact_customer_suggestion_workflow(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
query_results = iter([[], []])
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: next(query_results))
response = TestClient(app).get('/api/v1/internet-connections/allocation-overview')
template = Path('app/modules/internet_connections/templates/index.html').read_text()
assert response.status_code == 200
assert response.json() == {'items': []}
assert 'assignSuggestedCustomer' in template
assert 'unique_suggestion' in template
def test_list_connections_supports_allocated_filter(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
assert "ic.customer_id IS NOT NULL" in query
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
response = TestClient(app).get('/api/v1/internet-connections', params={
'allocation_model': 'dedicated', 'allocated_only': 'true',
})
assert response.status_code == 200
assert response.json() == []
def test_allocation_suggestions_return_all_customers_on_exact_address(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: {
'id': 155, 'address': 'Testvej 1, 8000 Aarhus C', 'customer_id': None,
})
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: [
{'customer_id': 10, 'customer_name': 'Firma A', 'candidate_address': 'Testvej 1, 8000 Aarhus C', 'address_source': 'customer', 'location_name': None},
{'customer_id': 11, 'customer_name': 'Firma B', 'candidate_address': 'Testvej 1, 8000 Aarhus C', 'address_source': 'location', 'location_name': 'Kontor'},
])
response = TestClient(app).get('/api/v1/internet-connections/155/allocation-suggestions')
assert response.status_code == 200
assert [item['customer_id'] for item in response.json()['items']] == [10, 11]
def test_allocation_suggestions_match_boulevard_abbreviation_and_house_range(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: {
'id': 155, 'address': 'Arnold Nielsens Boulevard 81, 2650 Hvidovre', 'customer_id': None,
})
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: [{
'customer_id': 214, 'customer_name': 'Glarmester Svensson ApS',
'candidate_address': 'Arnold Nielsens Blv. 81 - 83, 2650 Hvidovre',
'address_source': 'customer', 'location_name': None,
}])
response = TestClient(app).get('/api/v1/internet-connections/155/allocation-suggestions')
assert response.status_code == 200
assert response.json()['items'][0]['customer_id'] == 214
assert response.json()['items'][0]['match_score'] == 90
def test_connection_detail_has_unallocated_customer_banner_and_subscription_linking():
template = Path('app/modules/internet_connections/templates/detail.html').read_text()
assert 'Forbindelsen er ikke tildelt en kunde' in template
assert '/allocation-suggestions' in template
assert 'customer_id=${customerId}' in template
assert 'saveConnectionAllocation()' in template
assert 'BMC Delefiber' in template
assert 'fieldManualShared' in template
def test_subscription_options_endpoint_returns_lookup_rows(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
@ -705,3 +1016,16 @@ def test_subscription_options_endpoint_returns_lookup_rows(monkeypatch):
assert response.status_code == 200
assert response.json()[0]['subscription_number'] == 'SUB-1001'
def test_connection_detail_has_polished_network_header_and_ip_overview():
template = Path("app/modules/internet_connections/templates/detail.html").read_text()
assert 'id="detailCircuitBadge"' in template
assert 'class="detail-section-nav"' in template
assert 'class="detail-metrics-grid mb-4"' in template
assert 'id="connection-ip"' in template
assert "(summary.available || 0) + (summary.in_use || 0) + (summary.reserved || 0)" in template
assert "detail-grid-card ip-range-card" in template
assert "<span class=\"label\">Binding</span>" not in template
assert "Netværksmodel" in template

View File

@ -0,0 +1,29 @@
const {JSDOM}=require('jsdom');
const fs=require('node:fs');
const assert=require('node:assert/strict');
(async()=>{
const dom=new JSDOM('<div class="global-bottom-bar"><div id="bbTabTitle"><span class="bb-tab-title-text"></span></div><div id="bbTabDescription"></div><div id="bbTabInnerContent"></div></div>',{url:'http://localhost:8001',runScripts:'outside-only',pretendToBeVisual:true});
const w=dom.window,d=w.document;
await new Promise(r=>w.addEventListener('DOMContentLoaded',r));
w.fetch=async()=>({ok:true,json:async()=>[]});
w.eval(fs.readFileSync('static/js/message-ui.js','utf8'));
const src=fs.readFileSync('static/js/bottom-bar.js','utf8').replace(/\}\)\(\);\s*$/, 'window.inboxTest={render(items,key=""){latestSections={messages:{list:items,count:0}};activeKey="messages";chatComposerState.activeThreadKey=key;chatComposerState.loaded=true;renderTabPanel();},threads:getMessageThreads};})();');
w.eval(src);
w.inboxTest.render([]);
assert.ok(d.querySelector('.bb-message-threads'));
assert.ok(d.querySelector('.msg-conversation'));
assert.ok(d.getElementById('btnSendMsg').disabled,'Empty inbox must not silently broadcast');
const items=[{id:1,sender_user_id:2,recipient_user_id:null,from:'Kollega',text:'Fælles besked',created_at:'2026-09-08T10:00:00'}, {id:2,sender_user_id:2,recipient_user_id:1,from:'Kollega',text:'Ring tilbage <script>alert(1)</script>',message_kind:'phone',caller_name:'Anne',callback_phone:'+45 12345678',contact_id:4,created_at:'2026-09-08T11:00:00'}, {id:3,sender_user_id:1,recipient_user_id:2,to:'Kollega',from:'Dig',text:'Jeg ringer',is_own:true,is_read:true,created_at:'2026-09-08T11:01:00'}];
w.inboxTest.render(items,'user:2');
assert.equal(w.inboxTest.threads().length,2,'Broadcast is separate from sender conversation');
assert.equal(d.querySelectorAll('.msg-message').length,2);
assert.equal(d.querySelectorAll('.msg-call').length,1);
assert.equal(d.querySelector('.msg-call-action').getAttribute('href'),'tel:+4512345678');
assert.equal(d.querySelector('.msg-bubble script'),null,'Messages must be escaped');
assert.match(d.querySelector('.is-own .msg-delivery').textContent,/Læst/);
assert.equal(d.getElementById('chatInputQuick').tagName,'TEXTAREA');
let sends=0;d.getElementById('btnSendMsg').onclick=()=>sends++;
d.getElementById('chatInputQuick').dispatchEvent(new w.KeyboardEvent('keydown',{key:'Enter',bubbles:true}));assert.equal(sends,0);
d.getElementById('chatInputQuick').dispatchEvent(new w.KeyboardEvent('keydown',{key:'Enter',ctrlKey:true,bubbles:true}));assert.equal(sends,1);
dom.window.close();console.log('PASS: inbox layout, empty-state safety, separate broadcasts, phone cards, escaping, read status, multiline and shortcut');
})().catch(e=>{console.error(e);process.exit(1);});

View File

@ -0,0 +1,73 @@
import asyncio
from app.modules.hardware.backend import router as hardware_router
def _payload(**overrides):
data = {
"name": "BMC Recorder #24",
"recorder_number": 24,
"serial_number": "SERIAL-24",
"udid": "udid-24",
"ecid": "ecid-24",
"imei": "imei-24",
"wifi_mac": "AA:BB:CC:DD:EE:FF",
"os_version": "18.0",
"supervised": True,
}
data.update(overrides)
return hardware_router.MobileRecorderProvisionRequest(**data)
def test_mobile_recorder_provisioning_creates_asset_by_serial(monkeypatch):
calls = []
def fake_query(query, params=None, fetch=True):
calls.append((query, params, fetch))
if "SELECT id, hardware_specs" in query:
return []
if "INSERT INTO hardware_assets" in query:
return [{"id": 1842}]
return []
monkeypatch.setattr(hardware_router, "execute_query", fake_query)
monkeypatch.setattr(hardware_router.settings, "MOBILE_RECORDER_PROVISIONING_TOKEN", "provision-token")
result = asyncio.run(hardware_router.provision_mobile_recorder(_payload(), "Bearer provision-token", None))
assert result == {"success": True, "action": "created", "asset_id": 1842, "recorder_number": 24}
insert = next(params for query, params, _ in calls if "INSERT INTO hardware_assets" in query)
assert insert[2] == "SERIAL-24"
assert insert[5].adapted["mobile_recorder"]["supervised"] is True
def test_mobile_recorder_provisioning_updates_existing_serial(monkeypatch):
calls = []
def fake_query(query, params=None, fetch=True):
calls.append((query, params, fetch))
if "SELECT id, hardware_specs" in query:
return [{"id": 99, "hardware_specs": {"other_connector": {"keep": True}}}]
if "UPDATE hardware_assets" in query:
return [{"id": 99}]
return []
monkeypatch.setattr(hardware_router, "execute_query", fake_query)
monkeypatch.setattr(hardware_router.settings, "MOBILE_RECORDER_PROVISIONING_TOKEN", "provision-token")
result = asyncio.run(hardware_router.provision_mobile_recorder(_payload(), None, "provision-token"))
assert result["action"] == "updated"
update = next(params for query, params, _ in calls if "UPDATE hardware_assets" in query)
assert update[-1] == 99
assert update[4].adapted["other_connector"] == {"keep": True}
assert update[4].adapted["mobile_recorder"]["udid"] == "udid-24"
def test_mobile_recorder_provisioning_requires_service_token(monkeypatch):
monkeypatch.setattr(hardware_router.settings, "MOBILE_RECORDER_PROVISIONING_TOKEN", "provision-token")
try:
asyncio.run(hardware_router.provision_mobile_recorder(_payload(), None, "wrong"))
assert False, "Expected an authentication error"
except hardware_router.HTTPException as exc:
assert exc.status_code == 401

View File

@ -0,0 +1,257 @@
import asyncio
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.admin import router
from app.admin import vtiger_archive as archive
from app.admin import hub_impact
from app.admin import archive_bundle
from app.services.vtiger_service import VTigerService
def test_hidden_admin_dependency_returns_404_for_non_superadmin():
with pytest.raises(HTTPException) as exc:
router.require_hidden_superadmin({'username': 'employee', 'is_superadmin': False})
assert exc.value.status_code == 404
def test_archive_record_is_append_only_and_deduplicated(monkeypatch):
writes = []
previous = {'revision_no': 1, 'payload_sha256': 'different'}
monkeypatch.setattr(archive, 'execute_query_single', lambda query, params=None: previous if 'payload_sha256' in query else None)
monkeypatch.setattr(archive, 'execute_query', lambda query, params=None, fetch=True: writes.append((query, params)))
inserted = archive.archive_record(3, 'Accounts', {
'id': '3x44', 'accountname': 'Test', 'contact_id': '4x99', 'modifiedtime': '2026-08-30 10:00:00',
})
assert inserted is True
assert any('INSERT INTO vtiger_archive_records' in sql for sql, _ in writes)
assert any('INSERT INTO vtiger_archive_relations' in sql for sql, _ in writes)
def test_unchanged_payload_does_not_create_revision(monkeypatch):
record = {'id': '3x44', 'accountname': 'Test'}
_, digest = archive._canonical_payload(record)
monkeypatch.setattr(archive, 'execute_query_single', lambda *args, **kwargs: {'revision_no': 2, 'payload_sha256': digest})
monkeypatch.setattr(archive, 'execute_query', lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('must not write')))
assert archive.archive_record(4, 'Accounts', record) is False
def test_readiness_requires_successful_approved_final(monkeypatch):
monkeypatch.setattr(archive, 'execute_query_single', lambda *args, **kwargs: {
'id': 9, 'status': 'completed', 'critical_errors': [], 'control_approved_at': '2026-08-30',
})
assert archive.termination_readiness()['ready'] is True
monkeypatch.setattr(archive, 'execute_query_single', lambda *args, **kwargs: None)
result = archive.termination_readiness()
assert result['ready'] is False
assert len(result['reasons']) == 2
def test_failed_vtiger_query_is_not_treated_as_empty_module(monkeypatch):
class Service:
last_query_error = {'message': 'rate limited'}
last_query_status = 429
async def query(self, query):
return []
monkeypatch.setattr(archive, 'get_vtiger_service', lambda: Service())
async def no_sleep(_seconds):
return None
monkeypatch.setattr(archive.asyncio, 'sleep', no_sleep)
with pytest.raises(RuntimeError):
asyncio.run(archive._fetch_module('Accounts'))
def test_failed_vtiger_count_cannot_pass_full_archive_control(monkeypatch):
class Service:
last_query_error = {'message': 'timeout'}
last_query_status = None
async def query(self, query):
return []
async def no_sleep(_seconds):
return None
monkeypatch.setattr(archive, 'get_vtiger_service', lambda: Service())
monkeypatch.setattr(archive.asyncio, 'sleep', no_sleep)
with pytest.raises(RuntimeError, match='kontrollere antal poster'):
asyncio.run(archive._source_module_count('Accounts'))
def test_vtiger_file_retrieve_accepts_list_result(monkeypatch):
import base64
class Response:
status = 200
async def __aenter__(self): return self
async def __aexit__(self, *args): return None
async def json(self, content_type=None):
return {'success': True, 'result': [{
'fileid': '7x2', 'filename': 'test.txt', 'filecontents': base64.b64encode(b'hello').decode(),
}]}
class Session:
async def __aenter__(self): return self
async def __aexit__(self, *args): return None
def get(self, *args, **kwargs): return Response()
monkeypatch.setattr('app.services.vtiger_service.aiohttp.ClientSession', Session)
service = VTigerService()
service.rest_endpoint = 'https://example.invalid'
service.api_key = 'key'
service.username = 'user'
result = asyncio.run(service.retrieve_file('7x2'))
assert result['content'] == b'hello'
def test_vtiger_archive_uses_offset_pagination(monkeypatch):
queries = []
class Service:
last_query_error = None
last_query_status = 200
async def query(self, query):
queries.append(query)
if 'LIMIT 0, 100' in query:
return [{'id': f'3x{i}'} for i in range(100)]
if 'LIMIT 100, 100' in query:
return [{'id': '3x101'}, {'id': '3x102'}]
return []
async def no_sleep(_seconds):
return None
monkeypatch.setattr(archive, 'get_vtiger_service', lambda: Service())
monkeypatch.setattr(archive.asyncio, 'sleep', no_sleep)
rows = asyncio.run(archive._fetch_module('Accounts'))
assert len(rows) == 102
assert 'LIMIT 0, 100' in queries[0]
assert 'LIMIT 100, 100' in queries[1]
assert 'LIMIT 102, 100' in queries[2]
def test_project_ct_migration_enforces_immutable_revisions():
sql = Path('migrations/1030_project_ct_vtiger_archive.sql').read_text()
assert 'vtiger_archive_versions' in sql
assert 'vtiger_archive_records' in sql
assert 'vtiger_archive_relations' in sql
assert 'vtiger_archive_checkpoints' in sql
assert 'hub_impact_reports' in sql
assert 'BEFORE UPDATE OR DELETE ON vtiger_archive_records' in sql
file_sql = Path('migrations/1032_project_ct_archive_files.sql').read_text()
assert 'vtiger_archive_files' in file_sql
assert 'BEFORE UPDATE OR DELETE ON vtiger_archive_files' in file_sql
assert 'source_module' in Path('migrations/1033_project_ct_archive_file_sources.sql').read_text()
def test_impact_periods_must_be_equal(monkeypatch):
from datetime import date
with pytest.raises(ValueError, match='lige lange'):
hub_impact.build_impact_report(1, date(2025, 1, 1), date(2025, 1, 10),
date(2026, 1, 1), date(2026, 1, 9))
def test_vtiger_employee_matching_and_long_entry_exclusion():
from datetime import date
records = [
{'module': 'Users', 'vtiger_id': '19x1', 'is_deleted': False,
'payload': {'id': '19x1', 'email1': 'TECH@EXAMPLE.COM', 'first_name': 'Old', 'last_name': 'Name'}},
{'module': 'Timelog', 'vtiger_id': '36x1', 'is_deleted': False,
'payload': {'createdtime': '2025-01-03 10:00:00', 'assigned_user_id': '19x1', 'time_spent': '02:30'}},
{'module': 'Timelog', 'vtiger_id': '36x2', 'is_deleted': False,
'payload': {'createdtime': '2025-01-04 10:00:00', 'assigned_user_id': '19x1', 'hours': '17'}},
{'module': 'Cases', 'vtiger_id': '17x3', 'is_deleted': False,
'payload': {'createdtime': '2025-01-05 10:00:00', 'assigned_user_id': '19x1'}},
]
result = hub_impact._finalize(
hub_impact._vtiger_metrics(records, date(2025, 1, 1), date(2025, 1, 10), {
'tech@example.com': {'user_id': 7, 'email': 'tech@example.com', 'full_name': 'Hub Technician'}
}), date(2025, 1, 1), date(2025, 1, 10),
)
assert result['totals']['hours'] == 2.5
assert result['totals']['time_entries'] == 1
assert result['totals']['cases'] == 1
assert result['employees'][0]['key'] == 'hub:7'
assert result['anomalies'][0]['type'] == 'over_16_hours'
def test_hub_active_timer_is_anomaly_not_kpi(monkeypatch):
from datetime import date
def fake_query(sql, params=None):
if 'FROM tmodule_times' in sql:
return [{'id': 1, 'medarbejder_id': 4, 'aktiv_timer': True, 'hours': 8},
{'id': 2, 'medarbejder_id': 4, 'aktiv_timer': False, 'hours': 2}]
return []
monkeypatch.setattr(hub_impact, 'execute_query', fake_query)
metrics = hub_impact._finalize(hub_impact._hub_metrics(
date(2026, 1, 1), date(2026, 1, 2),
{4: {'user_id': 4, 'email': 'a@example.com', 'full_name': 'A'}},
), date(2026, 1, 1), date(2026, 1, 2))
assert metrics['totals']['hours'] == 2
assert metrics['totals']['time_entries'] == 1
assert metrics['anomalies'][0]['type'] == 'active_timer'
def test_excel_export_contains_required_sheets():
from app.admin.router import _impact_workbook
load_workbook = pytest.importorskip('openpyxl').load_workbook
import io
result = {
'archive_version': {'id': 2},
'periods': {'vtiger': {'from': '2025-01-01', 'to': '2025-01-31'}, 'hub': {'from': '2026-01-01', 'to': '2026-01-31'}},
'comparison': {'hours': 1, 'time_entries': 2, 'cases': 3, 'orders': 4},
'vtiger': {'totals': {'hours': 1, 'time_entries': 1, 'cases': 1, 'orders': 1}, 'employees': [], 'anomalies': [], 'data_quality': {'missing_dates': 0, 'unmatched_employees': []}},
'hub': {'totals': {'hours': 2, 'time_entries': 3, 'cases': 4, 'orders': 5}, 'employees': [], 'anomalies': [], 'data_quality': {'missing_dates': 0, 'unmatched_employees': []}},
}
workbook = load_workbook(io.BytesIO(_impact_workbook(result)))
assert workbook.sheetnames == ['Overblik', 'Effekt', 'Pr medarbejder', 'Afvigelser', 'Datakvalitet']
def test_secret_page_and_profile_link_are_registered():
assert '@router.get("/admin/hub-impact"' in Path('app/admin/views.py').read_text()
base = Path('app/shared/frontend/base.html').read_text()
assert 'projectCtProfileLink' in base
assert "user.is_superadmin === true" in base
def test_bundle_checksum_rejects_changed_content(tmp_path):
import hashlib
import json
import zipfile
bundle = tmp_path / 'archive.zip'
with zipfile.ZipFile(bundle, 'w') as archive_file:
archive_file.writestr('records.jsonl', b'{"id":1}\n')
archive_file.writestr('manifest.json', json.dumps({
'schema_version': 1,
'entries': {'records.jsonl': {'sha256': hashlib.sha256(b'other').hexdigest()}},
}))
with zipfile.ZipFile(bundle) as archive_file:
manifest = json.loads(archive_file.read('manifest.json'))
with pytest.raises(ValueError, match='Checksumfejl'):
archive_bundle._verify_entry(archive_file, manifest, 'records.jsonl')
def test_bundle_transfer_contains_original_files_and_never_uses_vtiger():
source = Path('app/admin/archive_bundle.py').read_text()
assert 'document_policy' in source
assert 'metadata_and_original_bytes' in source
assert 'files.jsonl' in source
assert 'contacted_vtiger": False' in source
assert 'vtiger_service' not in source
router_source = Path('app/admin/router.py').read_text()
assert '/admin/vtiger-archive/bundle.zip' in router_source
assert '/admin/vtiger-archive/import-bundle' in router_source
def test_productivity_exposes_clear_daily_efficiency_metrics():
from datetime import date
metrics = {'employees': {
'hub:1': {'key': 'hub:1', 'name': 'A', 'email': 'a@example.com', 'hours': hub_impact.Decimal('8'),
'time_entries': 4, 'cases': 2, 'orders': 1}},
'anomalies': [], 'data_quality': {'unmatched_employees': [], 'missing_dates': 0}}
result = hub_impact._finalize(metrics, date(2026, 8, 31), date(2026, 8, 31))
assert result['totals']['productivity']['deliveries_per_workday'] == 7
assert result['totals']['productivity']['hours_per_case_or_order'] == 2.67

View File

@ -2,6 +2,7 @@ from datetime import datetime
from io import BytesIO
from pathlib import Path
import asyncio
import re
import sys
import types
@ -158,11 +159,64 @@ def test_case_create_lists_contacts_for_selected_customer():
def test_case_create_defaults_responsible_to_current_user():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
assert "selectCurrentUserAsResponsible();" in template
assert "selectCurrentUserAsResponsible()" in template
assert "raw_responsible = data.get" in router
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
def test_case_create_sends_relations_in_atomic_create_payload():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
assert "contact_ids: Object.keys(selectedContacts)" in template
assert "telefoni_opkald_id: telefoniPrefill.callId" in template
assert 'raw_contact_ids = data.get("contact_ids")' in router
assert "INSERT INTO sag_kontakter" in router
assert "UPDATE telefoni_opkald" in router
assert "window.location.href = `/sag/${result.id}/v3`;" in template
assert "Omdirigerer..." not in template
def test_case_can_link_internet_connection_without_customer_allocation():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
sag_router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
internet_router = Path("app/modules/internet_connections/backend/router.py").read_text(encoding="utf-8")
detail = Path("app/modules/internet_connections/templates/detail.html").read_text(encoding="utf-8")
migration = Path("migrations/1034_sag_internet_connections.sql").read_text(encoding="utf-8")
assert "internet_connection_ids" in template
assert "INSERT INTO sag_internet_connections" in sag_router
assert "/sag/{sag_id}/internet-connections" in sag_router
assert "/internet-connections/{connection_id:int}/cases" in internet_router
assert "Sager på forbindelsen" in detail
assert "sag_internet_connections" in migration
def test_case_detail_has_no_overwriting_relation_functions_or_blocking_alerts():
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert len(re.findall(r"function\s+removeContact\s*\(", template)) == 1
assert len(re.findall(r"function\s+removeCustomer\s*\(", template)) == 1
assert "#caseAddWorkspaceFooter .btn-primary" in template
assert 'id="caseAddWorkspaceBody"' in template
assert not re.search(r"(?<![\w.])alert\(", template)
assert "reloadCasePreservingContext" in template
def test_case_list_has_direct_entity_links_and_inline_updates():
template = Path("app/modules/sag/templates/index.html").read_text(encoding="utf-8")
assert 'href="/customers/{{ sag.customer_id }}"' in template
assert 'href="/contacts/{{ sag.kontakt_id }}"' in template
assert "updateCaseListField({{ sag.id }}, 'status'" in template
assert "updateCaseListField({{ sag.id }}, 'ansvarlig_bruger_id'" in template
def test_case_optional_data_endpoints_do_not_use_expected_404s():
settings_router = Path("app/settings/backend/router.py").read_text(encoding="utf-8")
subscriptions_router = Path("app/subscriptions/backend/router.py").read_text(encoding="utf-8")
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert '"time_multiplier_presets"' in settings_router
assert '"change_request": None' in subscriptions_router
assert "if (changePayload.change_request)" in template
def test_case_v3_contact_actions_and_company_link_include_case_context():
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert 'href="/customers/{{ customer.id }}"' in template
@ -368,3 +422,72 @@ def test_time_tab_and_history_include_linked_case_activities():
assert "renderCaseLinkedActivities(linkedActivities)" in template
assert 'event_type": activity_type' in source
assert 'source": "call" if activity_type == "call" else "anydesk"' in source
def test_sag_list_has_per_user_column_preferences():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
migration = Path("migrations/1023_user_sag_list_columns.sql").read_text()
assert 'id="sagColumnList"' in template
assert 'id="saveSagColumnsBtn"' in template
assert 'draggable="true"' in template
assert "function applySagColumnPreferences()" in template
assert "function saveSagColumnPreferences()" in template
assert "column_order: sagColumnOrder" in template
assert "hidden_columns: Array.from(sagHiddenColumns)" in template
assert "column_order: Optional[List[str]] = None" in source
assert "hidden_columns: Optional[List[str]] = None" in source
assert "ON CONFLICT (user_id)" in source
assert "ADD COLUMN IF NOT EXISTS column_order JSONB" in migration
assert "ADD COLUMN IF NOT EXISTS hidden_columns JSONB" in migration
def test_sag_list_status_dropdown_receives_options_and_links_are_styled():
template = Path("app/modules/sag/templates/index.html").read_text()
views = Path("app/modules/sag/frontend/views.py").read_text()
assert '"status_options": status_options' in views
assert "{% for status_option in status_options %}" in template
assert 'class="sag-entity-link"' in template
assert 'class="sag-id"' in template
assert ".sag-entity-link:hover" in template
assert "sag-inline-select sag-status-select" in template
assert "sag-inline-select sag-owner-select" in template
assert "function applyStatusSelectTone(control)" in template
def test_support_case_close_without_time_requires_explicit_confirmation():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
assert '"close_without_time_confirmation_required"' in source
assert 'SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s)' in source
assert 'confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True' in source
assert "detail?.code === 'close_without_time_confirmation_required'" in template
assert "body.confirm_close_without_time = true" in template
assert 'id="closeCaseWithoutTimeModal"' in template
assert "await confirmCloseCaseWithoutTime(caseId, detail.message)" in template
def test_sag_list_has_smart_toolbar_search_and_employee_quick_filters():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
assert 'data-quick-filter="mine-open"' in template
assert 'data-quick-filter="overdue"' in template
assert 'data-quick-filter="my-groups"' in template
assert 'data-quick-filter="unassigned"' in template
assert 'id="clearSearchBtn"' in template
assert "search.split(/\\s+/).filter(Boolean).every" in template
assert "/sag/me/quick-filter-context" in source
assert "SELECT group_id FROM user_groups WHERE user_id = %s" in source
def test_overdue_active_cases_have_red_row_shadow():
template = Path("app/modules/sag/templates/index.html").read_text()
assert ".sag-table tbody tr.sag-deadline-overdue" in template
assert "function updateOverdueDeadlineMarker(row)" in template
assert "!closedStatuses.has(status)" in template
assert "row.classList.toggle('sag-deadline-overdue', isOverdue)" in template

Some files were not shown because too many files have changed in this diff Show More