bmc_hub/app/settings/frontend/settings.html
Christian a8eaf6e2a9 feat: enhance tag management and search functionality
- Updated the index.html template to include a new column for "Næste todo" in the sag table.
- Added new JavaScript functions to load and manage case statuses in settings.html, including normalization and rendering of statuses.
- Introduced a new tag search feature in tags_admin.html, allowing users to filter tags by name, type, and module with pagination support.
- Enhanced the backend router.py to include a new endpoint for listing tag usage across modules with server-side filtering and pagination.
- Improved the overall UI and UX of the tag administration page, including responsive design adjustments and better error handling.
2026-03-20 18:43:45 +01:00

4584 lines
199 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "shared/frontend/base.html" %}
{% block title %}Indstillinger - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.settings-nav {
position: sticky;
top: 100px;
}
.settings-nav .nav-link {
color: var(--text-secondary);
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 0.5rem;
transition: all 0.2s;
border-left: 3px solid transparent;
}
.settings-nav .nav-link:hover,
.settings-nav .nav-link.active {
background: var(--accent-light);
color: var(--accent);
border-left-color: var(--accent);
}
.setting-group {
margin-bottom: 2rem;
}
.setting-item {
padding: 1.25rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.setting-item:last-child {
border-bottom: none;
}
.setting-info h6 {
margin-bottom: 0.25rem;
font-weight: 600;
}
.setting-info small {
color: var(--text-secondary);
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-5">
<div>
<h2 class="fw-bold mb-1">Indstillinger</h2>
<p class="text-muted mb-0">System konfiguration og brugerstyring</p>
</div>
</div>
<div class="row">
<!-- Vertical Navigation -->
<div class="col-lg-2">
<div class="settings-nav">
<nav class="nav flex-column">
<a class="nav-link active" href="#company" data-tab="company">
<i class="bi bi-building me-2"></i>Firma
</a>
<a class="nav-link" href="#integrations" data-tab="integrations">
<i class="bi bi-plugin me-2"></i>Integrationer
</a>
<a class="nav-link" href="#telefoni" data-tab="telefoni">
<i class="bi bi-telephone me-2"></i>Telefoni
</a>
<a class="nav-link" href="#notifications" data-tab="notifications">
<i class="bi bi-bell me-2"></i>Notifikationer
</a>
<a class="nav-link" href="#users" data-tab="users">
<i class="bi bi-people me-2"></i>Brugere
</a>
<a class="nav-link" href="#tags" data-tab="tags">
<i class="bi bi-tags me-2"></i>Tags
</a>
<a class="nav-link" href="#pipeline" data-tab="pipeline">
<i class="bi bi-diagram-3 me-2"></i>Pipeline
</a>
<a class="nav-link" href="#sync" data-tab="sync">
<i class="bi bi-arrow-repeat me-2"></i>Sync
</a>
<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="#email-templates" data-tab="email-templates">
<i class="bi bi-envelope-paper me-2"></i>Email skabeloner
</a>
<a class="nav-link" href="/admin/bmc-office-upload">
<i class="bi bi-cloud-upload me-2"></i>BMC Office Import
</a>
<a class="nav-link" href="#modules" data-tab="modules">
<i class="bi bi-box-seam me-2"></i>Moduler
</a>
<a class="nav-link" href="#system" data-tab="system">
<i class="bi bi-gear me-2"></i>System
</a>
<a class="nav-link" href="/settings/migrations">
<i class="bi bi-database me-2"></i>DB Migrationer
</a>
</nav>
</div>
</div>
<!-- Content Area -->
<div class="col-lg-10">
<div class="tab-content">
<!-- Company Settings -->
<div class="tab-pane fade show active" id="company">
<div class="card p-4">
<h5 class="mb-4 fw-bold">Firma Oplysninger</h5>
<div id="companySettings">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
</div>
<!-- Integrations -->
<div class="tab-pane fade" id="integrations">
<div class="card p-4 mb-4">
<h5 class="mb-4 fw-bold">vTiger CRM</h5>
<div id="vtigerSettings">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
<div class="card p-4">
<h5 class="mb-4 fw-bold">e-conomic</h5>
<div id="economicSettings">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="mb-1 fw-bold">Nextcloud</h5>
<p class="text-muted mb-0">Administrer kundeinstanser, credentials og auditlog</p>
</div>
<button class="btn btn-primary" onclick="openNextcloudInstanceModal()">
<i class="bi bi-plus-lg me-2"></i>Opret instans
</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Kunde</th>
<th>Base URL</th>
<th>Bruger</th>
<th>Status</th>
<th>Sidst opdateret</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="nextcloudInstancesTable">
<tr>
<td colspan="6" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-4">
<div>
<h6 class="fw-bold mb-1">Auditlog retention</h6>
<small class="text-muted">Manuel sletning pr. kunde (tidsbaseret)</small>
</div>
<button class="btn btn-outline-danger" onclick="openNextcloudPurgeModal()">
<i class="bi bi-trash me-2"></i>Slet ældre events
</button>
</div>
</div>
</div>
<!-- Telefoni -->
<div class="tab-pane fade" id="telefoni">
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1 fw-bold">Click-to-Call (Action URL)</h5>
<p class="text-muted mb-0">Konfigurer URL-template til at starte opkald via telefon/PBX endpoint.</p>
</div>
</div>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Callback shared secret</label>
<div class="input-group">
<input type="text" class="form-control" id="telefoniSharedSecret" placeholder="Hemmelig token til Yealink callbacks">
<button type="button" class="btn btn-outline-secondary" onclick="generateTelefoniToken()">
<i class="bi bi-magic me-1"></i>Generér
</button>
</div>
<small class="text-muted">Bruges som <code>?token=...</code> på established/terminated callback URLs.</small>
</div>
<div class="col-md-4">
<label class="form-label">Aktivér click-to-call</label>
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" id="telefoniClickEnabled">
</div>
</div>
<div class="col-md-8">
<label class="form-label">Standard extension (valgfri)</label>
<input type="text" class="form-control" id="telefoniDefaultExtension" placeholder="fx 101">
<small class="text-muted">Bruges som fallback i test og i senere call-knapper.</small>
</div>
<div class="col-12">
<label class="form-label">Template preset</label>
<div class="d-flex flex-wrap gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="applyTelefoniTemplatePreset('generic')">
Generic
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="applyTelefoniTemplatePreset('yealink-basic-auth')">
Yealink Basic Auth
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="applyTelefoniTemplatePreset('yealink-open')">
Yealink (uden auth)
</button>
</div>
</div>
<div class="col-12">
<label class="form-label">Action URL template</label>
<input type="text" class="form-control" id="telefoniActionTemplate" placeholder="http://PHONE_IP/servlet?number={number}&ext={extension}">
<small class="text-muted">Pladsholdere: <code>{number}</code> (påkrævet), <code>{raw_number}</code>, <code>{extension}</code>, <code>{phone_ip}</code>, <code>{phone_username}</code>, <code>{phone_password}</code>.</small>
</div>
<div class="col-12">
<label class="form-label">Preview</label>
<div class="form-control bg-light" id="telefoniActionPreview" style="min-height: 38px;">-</div>
</div>
<div class="col-12 d-flex justify-content-end">
<button class="btn btn-primary" onclick="saveTelefoniSettings()">
<i class="bi bi-save me-2"></i>Gem telefoni-indstillinger
</button>
</div>
</div>
</div>
<div class="card p-4">
<h5 class="mb-3 fw-bold">Yealink URL Builder</h5>
<p class="text-muted mb-3">Generér de præcise URL-strenge til telefonernes Action URL felter (Established + Terminated).</p>
<div class="alert alert-info py-2 small mb-3">
Brug Yealink variable values (fx <code>$call_id</code>, <code>$remote</code>, <code>$local</code>, <code>$active_user</code>). Ikke-dokumenterede placeholders som <code>$callid</code>/<code>$caller</code>/<code>$callee</code> bliver ofte ikke erstattet.
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Hub base URL</label>
<input type="text" class="form-control" id="yealinkBuilderBaseUrl" placeholder="http://hub.local">
<small class="text-muted">Eksempel: http://hub.local eller https://hub.bmcnetworks.dk</small>
</div>
<div class="col-md-6">
<label class="form-label">Shared secret token (valgfri)</label>
<input type="text" class="form-control" id="yealinkBuilderToken" placeholder="Samme token som TELEFONI_SHARED_SECRET">
<small class="text-muted">Hvis udfyldt, tilføjes <code>?token=...</code> automatisk.</small>
</div>
<div class="col-12">
<label class="form-label">Established URL</label>
<div class="input-group">
<input type="text" class="form-control" id="yealinkEstablishedUrl" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copyYealinkUrl('yealinkEstablishedUrl')">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div class="col-12">
<label class="form-label">Terminated URL</label>
<div class="input-group">
<input type="text" class="form-control" id="yealinkTerminatedUrl" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copyYealinkUrl('yealinkTerminatedUrl')">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
</div>
</div>
<div class="card p-4 mt-4">
<h5 class="mb-3 fw-bold">Test opkald</h5>
<div class="row g-3 align-items-end">
<div class="col-md-5">
<label class="form-label">Nummer</label>
<input type="text" class="form-control" id="telefoniTestNumber" placeholder="fx 22334455 eller +4522334455">
</div>
<div class="col-md-4">
<label class="form-label">Extension (valgfri)</label>
<input type="text" class="form-control" id="telefoniTestExtension" placeholder="fx 101">
</div>
<div class="col-md-3">
<button class="btn btn-outline-primary w-100" id="telefoniTestBtn" onclick="testTelefoniCall()">
<i class="bi bi-telephone-outbound me-2"></i>Start testopkald
</button>
</div>
</div>
<div class="mt-3 small text-muted" id="telefoniTestResult">-</div>
</div>
</div>
<!-- Notifications -->
<div class="tab-pane fade" id="notifications">
<div class="card p-4">
<h5 class="mb-4 fw-bold">Notifikation Indstillinger</h5>
<div id="notificationSettings">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
</div>
<!-- Email Templates -->
<div class="tab-pane fade" id="email-templates">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-1">Email Skabeloner</h5>
<p class="text-muted mb-0">Administrer system- og kundespecifikke email skabeloner</p>
</div>
<button class="btn btn-primary" onclick="openEmailTemplateModal()">
<i class="bi bi-plus-lg me-2"></i>Ny Skabelon
</button>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label small text-muted">Kategori</label>
<select class="form-select" id="emailTemplateCategoryFilter" onchange="loadEmailTemplates()">
<option value="">Alle kategorier</option>
<option value="general">Generelt</option>
<option value="internal">Internt</option>
<option value="nextcloud">Nextcloud</option>
<option value="billing">Fakturering</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">Kunde</label>
<select class="form-select" id="emailTemplateCustomerFilter" onchange="loadEmailTemplates()">
<option value="">Alle kunder (Globale)</option>
<!-- Populated via JS -->
</select>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th>Navn</th>
<th>Emne</th>
<th>Kategori</th>
<th>Type</th>
<th>Sidst opdateret</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="emailTemplatesTableBody">
<tr>
<td colspan="6" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Users & Groups -->
<div class="tab-pane fade" id="users">
<div class="row g-4">
<div class="col-xl-7">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="mb-1 fw-bold">Brugere</h5>
<p class="text-muted mb-0">Opret og administrer brugere og deres grupper</p>
</div>
<button class="btn btn-primary" onclick="showCreateUserModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Bruger
</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Bruger</th>
<th>Email</th>
<th>Grupper</th>
<th>Status</th>
<th>Telefoni ext.</th>
<th>Telefoni IP</th>
<th>Telefoni bruger</th>
<th>Telefoni kode</th>
<th>Telefoni aktiv</th>
<th>Oprettet</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="usersTableBody">
<tr>
<td colspan="11" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-xl-5">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="mb-1 fw-bold">Grupper & Rettigheder</h5>
<p class="text-muted mb-0">Opret grupper og tildel rettigheder</p>
</div>
<button class="btn btn-outline-primary" onclick="showCreateGroupModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Gruppe
</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Gruppe</th>
<th>Rettigheder</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="groupsTableBody">
<tr>
<td colspan="3" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Tags Management -->
<div class="tab-pane fade" id="tags">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-1">Tag Administration</h5>
<p class="text-muted mb-0">Administrer tags der bruges på tværs af hele systemet</p>
</div>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#tagModal">
<i class="bi bi-plus-lg me-2"></i>Opret Tag
</button>
</div>
<!-- Quick Stats -->
<div class="row mb-4">
<div class="col-md-2">
<div class="card border-0 shadow-sm">
<div class="card-body text-center">
<div class="display-6 fw-bold text-primary" id="totalTagsCount">0</div>
<small class="text-muted">Total Tags</small>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card border-0 shadow-sm" style="border-left: 4px solid #ff6b35 !important;">
<div class="card-body text-center">
<div class="h4 fw-bold" id="workflowTagsCount" style="color: #ff6b35;">0</div>
<small class="text-muted">Workflow</small>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card border-0 shadow-sm" style="border-left: 4px solid #ffd700 !important;">
<div class="card-body text-center">
<div class="h4 fw-bold" id="statusTagsCount" style="color: #e6c200;">0</div>
<small class="text-muted">Status</small>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card border-0 shadow-sm" style="border-left: 4px solid #0f4c75 !important;">
<div class="card-body text-center">
<div class="h4 fw-bold" id="categoryTagsCount" style="color: #0f4c75;">0</div>
<small class="text-muted">Category</small>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card border-0 shadow-sm" style="border-left: 4px solid #dc3545 !important;">
<div class="card-body text-center">
<div class="h4 fw-bold" id="priorityTagsCount" style="color: #dc3545;">0</div>
<small class="text-muted">Priority</small>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card border-0 shadow-sm" style="border-left: 4px solid #2d6a4f !important;">
<div class="card-body text-center">
<div class="h4 fw-bold" id="billingTagsCount" style="color: #2d6a4f;">0</div>
<small class="text-muted">Billing</small>
</div>
</div>
</div>
</div>
<!-- Filter Pills -->
<div class="mb-3 d-flex justify-content-between align-items-center">
<div class="btn-group btn-group-sm" role="group" id="tagTypeFilter">
<input type="radio" class="btn-check" name="tagType" id="typeAll" value="all" checked>
<label class="btn btn-outline-secondary" for="typeAll">Alle</label>
<input type="radio" class="btn-check" name="tagType" id="typeWorkflow" value="workflow">
<label class="btn btn-outline-secondary" for="typeWorkflow">
<i class="bi bi-diagram-3 me-1"></i>Workflow
</label>
<input type="radio" class="btn-check" name="tagType" id="typeStatus" value="status">
<label class="btn btn-outline-secondary" for="typeStatus">
<i class="bi bi-hourglass-split me-1"></i>Status
</label>
<input type="radio" class="btn-check" name="tagType" id="typeCategory" value="category">
<label class="btn btn-outline-secondary" for="typeCategory">
<i class="bi bi-bookmark me-1"></i>Category
</label>
<input type="radio" class="btn-check" name="tagType" id="typePriority" value="priority">
<label class="btn btn-outline-secondary" for="typePriority">
<i class="bi bi-exclamation-triangle me-1"></i>Priority
</label>
<input type="radio" class="btn-check" name="tagType" id="typeBilling" value="billing">
<label class="btn btn-outline-secondary" for="typeBilling">
<i class="bi bi-currency-dollar me-1"></i>Billing
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="showInactiveToggle">
<label class="form-check-label small text-muted" for="showInactiveToggle">
Vis inaktive
</label>
</div>
</div>
<!-- Tags Grid -->
<div class="row g-3" id="tagsGrid">
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
<!-- Pipeline Settings -->
<div class="tab-pane fade" id="pipeline">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-1">Pipeline Stages</h5>
<p class="text-muted mb-0">Administrer faser i salgspipelinen</p>
</div>
<button class="btn btn-primary" onclick="openStageModal()">
<i class="bi bi-plus-lg me-2"></i>Opret stage
</button>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Sortering</th>
<th>Standard %</th>
<th>Status</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody id="stagesTableBody">
<tr>
<td colspan="5" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Sync Integration -->
<div class="tab-pane fade" id="sync">
<div class="mb-4">
<h5 class="fw-bold mb-1">Data Synkronisering</h5>
<p class="text-muted mb-0">Synkroniser firmaer og kontakter fra vTiger og e-conomic</p>
<div class="alert alert-info mt-3 mb-0 py-2 px-3 small">
<i class="bi bi-info-circle me-2"></i>
Sync bruger integration credentials fra <strong>miljøvariabler (.env)</strong> ved runtime.
</div>
</div>
<!-- Sync Status Cards -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle" style="width: 48px; height: 48px; background: #f0f9ff; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-building text-primary" style="font-size: 1.5rem;"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="small text-muted">Firmaer i Hub</div>
<div class="h4 mb-0" id="syncStatsCustomers">-</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle" style="width: 48px; height: 48px; background: #fff4ed; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-diagram-3 text-warning" style="font-size: 1.5rem;"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="small text-muted">Med vTiger ID</div>
<div class="h4 mb-0" id="syncStatsVtiger">-</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle" style="width: 48px; height: 48px; background: #f0fdf4; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-currency-dollar text-success" style="font-size: 1.5rem;"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="small text-muted">Med e-conomic ID</div>
<div class="h4 mb-0" id="syncStatsEconomic">-</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Sync Actions -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-start mb-3">
<div class="flex-shrink-0">
<i class="bi bi-diagram-3 text-warning" style="font-size: 2rem;"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="card-title fw-bold">Sync fra vTiger</h6>
<p class="card-text small text-muted">Hent firmaer og kontakter fra vTiger CRM. Matcher på CVR nummer eller firma navn.</p>
</div>
</div>
<div class="d-grid gap-2">
<button class="btn btn-warning" onclick="syncFromVtiger()" id="btnSyncVtiger">
<i class="bi bi-download me-2"></i>Sync Firmaer fra vTiger
</button>
<button class="btn btn-outline-warning btn-sm" onclick="syncVtigerContacts()" id="btnSyncVtigerContacts">
<i class="bi bi-people me-2"></i>Sync Kontakter fra vTiger
</button>
</div>
<div class="mt-3 small">
<div class="d-flex align-items-center text-muted">
<i class="bi bi-info-circle me-2"></i>
<span>Sidst synkroniseret: <span id="lastSyncVtiger">Aldrig</span></span>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-start mb-3">
<div class="flex-shrink-0">
<i class="bi bi-currency-dollar text-success" style="font-size: 2rem;"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="card-title fw-bold">Sync fra e-conomic</h6>
<p class="card-text small text-muted">Hent kunder fra e-conomic. Matcher kun på entydigt e-conomic kundenummer.</p>
</div>
</div>
<div class="d-grid gap-2">
<button class="btn btn-success" onclick="syncFromEconomic()" id="btnSyncEconomic">
<i class="bi bi-download me-2"></i>Sync Firmaer fra e-conomic
</button>
<button class="btn btn-outline-secondary btn-sm" id="btnSyncCvrEconomic" disabled>
<i class="bi bi-pause-circle me-2"></i>CVR→e-conomic midlertidigt deaktiveret
</button>
</div>
<div class="mt-3 small">
<div class="d-flex align-items-center text-muted">
<i class="bi bi-info-circle me-2"></i>
<span>Sidst synkroniseret: <span id="lastSyncEconomic">Aldrig</span></span>
</div>
<div class="d-flex align-items-center text-muted mt-1">
<i class="bi bi-exclamation-circle me-2"></i>
<span>CVR-søgning er slået fra midlertidigt for stabil drift.</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Archived Ticket Sync + Monitor -->
<div class="card mb-4">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0 fw-bold">Archived Tickets Sync</h6>
<small class="text-muted">Overvaager om alle archived tickets er synket ned (kildeantal vs lokal DB)</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary" id="archivedOverallBadge">Status ukendt</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadArchivedSyncStatus()" id="btnCheckArchivedSync">
<i class="bi bi-arrow-repeat me-1"></i>Tjek nu
</button>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="mb-0">Simply archived</h6>
<span class="badge bg-secondary" id="archivedSimplyBadge">Ukendt</span>
</div>
<div class="small text-muted mb-2">Remote: <span id="archivedSimplyRemoteCount">-</span> | Lokal: <span id="archivedSimplyLocalCount">-</span> | Diff: <span id="archivedSimplyDiff">-</span></div>
<div class="small text-muted mb-3">Beskeder lokalt: <span id="archivedSimplyMessagesCount">-</span></div>
<div class="d-grid">
<button class="btn btn-outline-primary btn-sm" onclick="syncArchivedSimply()" id="btnSyncArchivedSimply">
<i class="bi bi-cloud-download me-2"></i>Sync Simply Archived
</button>
</div>
</div>
</div>
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="mb-0">vTiger Cases archived</h6>
<span class="badge bg-secondary" id="archivedVtigerBadge">Ukendt</span>
</div>
<div class="small text-muted mb-2">Remote: <span id="archivedVtigerRemoteCount">-</span> | Lokal: <span id="archivedVtigerLocalCount">-</span> | Diff: <span id="archivedVtigerDiff">-</span></div>
<div class="small text-muted mb-3">Beskeder lokalt: <span id="archivedVtigerMessagesCount">-</span></div>
<div class="d-grid">
<button class="btn btn-outline-primary btn-sm" onclick="syncArchivedVtiger()" id="btnSyncArchivedVtiger">
<i class="bi bi-cloud-download me-2"></i>Sync vTiger Archived
</button>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted">Sidst tjekket: <span id="archivedLastChecked">Aldrig</span></small>
<small class="text-muted" id="archivedStatusHint">Polling aktiv naar Sync-fanen er aaben.</small>
</div>
</div>
</div>
<!-- Sync Log -->
<div class="card">
<div class="card-header bg-white">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-bold">Synkroniserings Log</h6>
<button class="btn btn-sm btn-outline-secondary" onclick="loadSyncLog()">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
</button>
</div>
</div>
<div class="card-body p-0">
<div id="syncLogContainer" style="max-height: 400px; overflow-y: auto;">
<div class="text-center py-5">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<p class="text-muted small mt-2 mb-0">Indlæser log...</p>
</div>
</div>
</div>
</div>
</div>
<!-- /div>
</div>
<!-- AI Prompts -->
<div class="tab-pane fade" id="ai-prompts">
<div class="card p-4">
<h5 class="mb-4 fw-bold">
<i class="bi bi-robot me-2"></i>AI System Prompts
</h5>
<p class="text-muted mb-4">
Her kan du se de prompts der bruges til forskellige AI funktioner i systemet.
</p>
<div id="aiPromptsContent">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
</div>
<!-- Modules Documentation -->
<div class="tab-pane fade" id="modules">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h5 class="fw-bold mb-2">📦 Modul System</h5>
<p class="text-muted mb-0">Dynamisk feature loading - udvikl moduler isoleret fra core systemet</p>
</div>
<!-- <a href="/api/v1/modules" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-box-arrow-up-right me-1"></i>API
</a> -->
<span class="badge bg-secondary">API ikke implementeret endnu</span>
</div>
<!-- Quick Start -->
<div class="alert alert-info">
<h6 class="alert-heading"><i class="bi bi-rocket me-2"></i>Quick Start</h6>
<p class="mb-2">Opret nyt modul på 5 minutter:</p>
<pre class="bg-white p-3 rounded mb-2" style="font-size: 0.85rem;"><code># 1. Opret modul
python3 scripts/create_module.py invoice_scanner "Scan fakturaer"
# 2. Kør migration
docker-compose exec db psql -U bmc_hub -d bmc_hub \\
-f app/modules/invoice_scanner/migrations/001_init.sql
# 3. Enable modul (rediger module.json)
"enabled": true
# 4. Restart API
docker-compose restart api
# 5. Test
curl http://localhost:8000/api/v1/invoice_scanner/health</code></pre>
</div>
<!-- Active Modules Status -->
<div class="card mb-4">
<div class="card-header bg-white">
<h6 class="mb-0 fw-bold">Aktive Moduler</h6>
</div>
<div class="card-body" id="activeModules">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<p class="text-muted small mt-2 mb-0">Indlæser moduler...</p>
</div>
</div>
</div>
<!-- Features -->
<div class="row mb-4">
<div class="col-md-6 mb-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<h6 class="fw-bold mb-3"><i class="bi bi-shield-check text-success me-2"></i>Safety First</h6>
<ul class="list-unstyled mb-0">
<li class="mb-2">✅ Moduler starter disabled</li>
<li class="mb-2">✅ READ_ONLY og DRY_RUN defaults</li>
<li class="mb-2">✅ Error isolation - crashes påvirker ikke core</li>
<li class="mb-0">✅ Graceful degradation</li>
</ul>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<h6 class="fw-bold mb-3"><i class="bi bi-database text-primary me-2"></i>Database Isolering</h6>
<ul class="list-unstyled mb-0">
<li class="mb-2">✅ Table prefix pattern (fx <code>mymod_customers</code>)</li>
<li class="mb-2">✅ Separate migration tracking</li>
<li class="mb-2">✅ Helper functions til queries</li>
<li class="mb-0">✅ Core database uberørt</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Module Structure -->
<div class="card mb-4">
<div class="card-header bg-white">
<h6 class="mb-0 fw-bold">Modul Struktur</h6>
</div>
<div class="card-body">
<pre class="bg-light p-3 rounded mb-0" style="font-size: 0.85rem;"><code>app/modules/my_module/
├── module.json # Metadata og konfiguration
├── README.md # Dokumentation
├── backend/
│ ├── __init__.py
│ └── router.py # FastAPI endpoints (API)
├── frontend/
│ ├── __init__.py
│ └── views.py # HTML view routes
├── templates/
│ └── index.html # Jinja2 templates
└── migrations/
└── 001_init.sql # Database migrations</code></pre>
</div>
</div>
<!-- Configuration Pattern -->
<div class="card mb-4">
<div class="card-header bg-white">
<h6 class="mb-0 fw-bold">Konfiguration</h6>
</div>
<div class="card-body">
<p class="text-muted mb-3">Modul-specifik konfiguration i <code>.env</code>:</p>
<pre class="bg-light p-3 rounded mb-3" style="font-size: 0.85rem;"><code># Pattern: MODULES__{MODULE_NAME}__{KEY}
MODULES__MY_MODULE__API_KEY=secret123
MODULES__MY_MODULE__READ_ONLY=false
MODULES__MY_MODULE__DRY_RUN=false</code></pre>
<p class="text-muted mb-2">I kode:</p>
<pre class="bg-light p-3 rounded mb-0" style="font-size: 0.85rem;"><code>from app.core.config import get_module_config
api_key = get_module_config("my_module", "API_KEY")
read_only = get_module_config("my_module", "READ_ONLY", "true")</code></pre>
</div>
</div>
<!-- Code Example -->
<div class="card mb-4">
<div class="card-header bg-white">
<h6 class="mb-0 fw-bold">Eksempel: API Endpoint</h6>
</div>
<div class="card-body">
<pre class="bg-light p-3 rounded mb-0" style="font-size: 0.85rem;"><code>from fastapi import APIRouter, HTTPException
from app.core.database import execute_query, execute_insert
from app.core.config import get_module_config
router = APIRouter()
@router.post("/my_module/scan")
async def scan_document(file_path: str):
"""Scan et dokument"""
# Safety check
read_only = get_module_config("my_module", "READ_ONLY", "true")
if read_only == "true":
return {"error": "READ_ONLY mode enabled"}
# Process document
result = process_file(file_path)
# Gem i database (bemærk table prefix!)
doc_id = execute_insert(
"INSERT INTO mymod_documents (path, result) VALUES (%s, %s)",
(file_path, result)
)
return {"success": True, "doc_id": doc_id}</code></pre>
</div>
</div>
<!-- Documentation Links -->
<div class="card border-primary">
<div class="card-header bg-primary text-white">
<h6 class="mb-0 fw-bold"><i class="bi bi-book me-2"></i>Dokumentation</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
<h6 class="fw-bold">Quick Start</h6>
<p class="small text-muted mb-2">5 minutter guide til at komme i gang</p>
<code class="d-block small">docs/MODULE_QUICKSTART.md</code>
</div>
<div class="col-md-4 mb-3">
<h6 class="fw-bold">Full Guide</h6>
<p class="small text-muted mb-2">Komplet reference (6000+ ord)</p>
<code class="d-block small">docs/MODULE_SYSTEM.md</code>
</div>
<div class="col-md-4 mb-3">
<h6 class="fw-bold">Template</h6>
<p class="small text-muted mb-2">Working example modul</p>
<code class="d-block small">app/modules/_template/</code>
</div>
</div>
</div>
</div>
<!-- Best Practices -->
<div class="row mt-4">
<div class="col-md-6">
<div class="card border-success">
<div class="card-header bg-success text-white">
<h6 class="mb-0 fw-bold">✅ DO</h6>
</div>
<div class="card-body">
<ul class="mb-0 small">
<li>Brug <code>create_module.py</code> CLI tool</li>
<li>Brug table prefix konsistent</li>
<li>Enable safety switches i development</li>
<li>Test isoleret før enable i production</li>
<li>Log med emoji prefix (🔄 ✅ ❌)</li>
<li>Dokumenter API endpoints</li>
<li>Version moduler semantisk</li>
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h6 class="mb-0 fw-bold">❌ DON'T</h6>
</div>
<div class="card-body">
<ul class="mb-0 small">
<li>Skip table prefix</li>
<li>Hardcode credentials</li>
<li>Disable safety uden grund</li>
<li>Tilgå andre modulers tabeller direkte</li>
<li>Glem at køre migrations</li>
<li>Commit <code>.env</code> files</li>
<li>Enable direkte i production</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- System Settings -->
<div class="tab-pane fade" id="system">
<div class="card p-4 mb-4">
<h5 class="mb-3 fw-bold">Standard Dashboard</h5>
<p class="text-muted mb-3">Dashboard vises altid fra roden af sitet via <code>/</code>. Vælg her hvilken side der skal åbnes som dit standard-dashboard.</p>
<form method="post" action="/dashboard/default" class="row g-2 align-items-end">
<div class="col-lg-8">
<label class="form-label small text-muted" for="defaultDashboardPathInput">Dashboard</label>
<select id="defaultDashboardPathInput" name="dashboard_path" class="form-select" required>
<option value="/ticket/dashboard/technician/v1" {% if (default_dashboard_path or '/ticket/dashboard/technician/v1') == '/ticket/dashboard/technician/v1' %}selected{% endif %}>Tekniker Dashboard V1</option>
<option value="/ticket/dashboard/technician/v2" {% if default_dashboard_path == '/ticket/dashboard/technician/v2' %}selected{% endif %}>Tekniker Dashboard V2</option>
<option value="/ticket/dashboard/technician/v3" {% if default_dashboard_path == '/ticket/dashboard/technician/v3' %}selected{% endif %}>Tekniker Dashboard V3</option>
<option value="/dashboard/sales" {% if default_dashboard_path == '/dashboard/sales' %}selected{% endif %}>Salg Dashboard</option>
<option value="/dashboard/mission-control" {% if default_dashboard_path == '/dashboard/mission-control' %}selected{% endif %}>Mission Control</option>
{% if default_dashboard_path and default_dashboard_path not in ['/ticket/dashboard/technician/v1', '/ticket/dashboard/technician/v2', '/ticket/dashboard/technician/v3', '/dashboard/sales', '/dashboard/mission-control'] %}
<option value="{{ default_dashboard_path }}" selected>Nuværende (tilpasset): {{ default_dashboard_path }}</option>
{% endif %}
</select>
<div class="form-text">Vælg et gyldigt dashboard fra listen.</div>
</div>
<div class="col-lg-4 d-flex gap-2">
<input type="hidden" name="redirect_to" value="/settings#system">
<button class="btn btn-primary" type="submit">
<i class="bi bi-save me-2"></i>Gem standard
</button>
</div>
</form>
<div class="d-flex gap-2 mt-3 flex-wrap">
<form method="post" action="/dashboard/default/clear" class="d-inline">
<input type="hidden" name="redirect_to" value="/settings#system">
<button class="btn btn-sm btn-outline-secondary" type="submit">Ryd standard</button>
</form>
</div>
</div>
<div class="card p-4">
<h5 class="mb-4 fw-bold">System Indstillinger</h5>
<div id="systemSettings">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1 fw-bold">Sags-typer</h5>
<p class="text-muted mb-0">Administrer tilladte typer for sager</p>
</div>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="caseTypeInput" placeholder="F.eks. ticket" style="max-width: 220px;">
<button class="btn btn-primary" onclick="addCaseType()"><i class="bi bi-plus-lg me-1"></i>Tilføj</button>
</div>
</div>
<div id="caseTypesList" class="d-flex flex-wrap gap-2">
<div class="text-muted">Indlæser...</div>
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1 fw-bold">Sagsstatus</h5>
<p class="text-muted mb-0">Styr hvilke status-værdier der kan vælges, og marker hvilke der er lukkede.</p>
</div>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="caseStatusInput" placeholder="F.eks. afventer kunde" style="max-width: 260px;">
<button class="btn btn-primary" onclick="addCaseStatus()"><i class="bi bi-plus-lg me-1"></i>Tilføj</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Status</th>
<th class="text-center" style="width: 150px;">Lukket værdi</th>
<th class="text-end" style="width: 100px;">Handling</th>
</tr>
</thead>
<tbody id="caseStatusesTableBody">
<tr><td colspan="3" class="text-muted">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1 fw-bold">Standardmoduler pr. sagstype</h5>
<p class="text-muted mb-0">Vælg hvilke moduler der vises som standard for hver sagstype. Moduler med indhold vises altid.</p>
</div>
</div>
<div class="row g-3 align-items-end mb-3">
<div class="col-md-4">
<label class="form-label">Sagstype</label>
<select id="caseTypeModulesTypeSelect" class="form-select" onchange="renderCaseTypeModuleChecklist()">
<option value="">Vælg sagstype...</option>
</select>
</div>
<div class="col-md-8 text-md-end">
<button class="btn btn-outline-secondary me-2" onclick="resetCaseTypeModuleDefaults()">
<i class="bi bi-arrow-counterclockwise me-1"></i>Nulstil til standard
</button>
<button class="btn btn-primary" onclick="saveCaseTypeModuleDefaults()">
<i class="bi bi-save me-1"></i>Gem standardmoduler
</button>
</div>
</div>
<div id="caseTypeModuleChecklist" class="row g-2">
<div class="text-muted">Indlæser...</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create User Modal -->
<div class="modal fade" id="createUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret Ny Bruger</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createUserForm">
<div class="mb-3">
<label class="form-label">Brugernavn *</label>
<input type="text" class="form-control" id="newUsername" required>
</div>
<div class="mb-3">
<label class="form-label">Email *</label>
<input type="email" class="form-control" id="newEmail" required>
</div>
<div class="mb-3">
<label class="form-label">Fulde Navn</label>
<input type="text" class="form-control" id="newFullName">
</div>
<div class="mb-3">
<label class="form-label">Adgangskode *</label>
<input type="password" class="form-control" id="newPassword" required autocomplete="new-password">
</div>
<div class="mb-3">
<label class="form-label">Grupper</label>
<div id="createUserGroups" class="border rounded p-2" style="max-height: 180px; overflow-y: auto;">
<div class="text-muted small">Indlæser grupper...</div>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="newIsSuperadmin">
<label class="form-check-label" for="newIsSuperadmin">Superadmin</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="newIsActive" checked>
<label class="form-check-label" for="newIsActive">Aktiv</label>
</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="createUser()">Opret Bruger</button>
</div>
</div>
</div>
</div>
<!-- User Groups Modal -->
<div class="modal fade" id="userGroupsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="userGroupsModalTitle">Tildel Grupper</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="userGroupAssignments" class="border rounded p-2" style="max-height: 240px; overflow-y: auto;">
<div class="text-muted small">Indlæser grupper...</div>
</div>
</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="saveUserGroups()">Gem</button>
</div>
</div>
</div>
</div>
<!-- Create Group Modal -->
<div class="modal fade" id="createGroupModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret Ny Gruppe</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createGroupForm">
<div class="mb-3">
<label class="form-label">Navn *</label>
<input type="text" class="form-control" id="newGroupName" required>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="newGroupDescription" rows="3"></textarea>
</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="createGroup()">Opret Gruppe</button>
</div>
</div>
</div>
</div>
<!-- Group Permissions Modal -->
<div class="modal fade" id="groupPermissionsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="groupPermissionsModalTitle">Rediger Rettigheder</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="groupPermissionsList" class="border rounded p-2" style="max-height: 360px; overflow-y: auto;">
<div class="text-muted small">Indlæser rettigheder...</div>
</div>
</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="saveGroupPermissions()">Gem rettigheder</button>
</div>
</div>
</div>
</div>
<!-- Pipeline Stage Modal -->
<div class="modal fade" id="stageModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="stageModalTitle">Opret stage</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="stageForm">
<input type="hidden" id="stageId">
<div class="mb-3">
<label class="form-label">Navn *</label>
<input type="text" class="form-control" id="stageName" required>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="stageDescription" rows="2"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Sortering</label>
<input type="number" class="form-control" id="stageSortOrder" value="0">
</div>
<div class="mb-3">
<label class="form-label">Standard sandsynlighed (%)</label>
<input type="number" class="form-control" id="stageProbability" value="0" min="0" max="100">
</div>
<div class="mb-3">
<label class="form-label">Farve</label>
<input type="color" class="form-control form-control-color" id="stageColor" value="#0f4c75">
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="stageIsWon">
<label class="form-check-label" for="stageIsWon">Vundet</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="stageIsLost">
<label class="form-check-label" for="stageIsLost">Tabt</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="stageIsActive" checked>
<label class="form-check-label" for="stageIsActive">Aktiv</label>
</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="saveStage()">Gem</button>
</div>
</div>
</div>
</div>
<!-- Nextcloud Instance Modal -->
<div class="modal fade" id="nextcloudInstanceModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title"><i class="bi bi-cloud me-2"></i>Opret Nextcloud instans</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="nextcloudInstanceForm" class="row g-3">
<div class="col-12">
<label class="form-label">Kunde</label>
<select class="form-select" id="nextcloudCustomerSelect"></select>
</div>
<div class="col-md-6">
<label class="form-label">Base URL</label>
<input type="url" class="form-control" id="nextcloudBaseUrl" placeholder="https://cloud.example.com" required>
</div>
<div class="col-md-6">
<label class="form-label">Auth type</label>
<select class="form-select" id="nextcloudAuthType">
<option value="basic">Basic / App Password</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Brugernavn</label>
<input type="text" class="form-control" id="nextcloudUsername" required>
</div>
<div class="col-md-6">
<label class="form-label">Password</label>
<input type="password" class="form-control" id="nextcloudPassword" required>
</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="createNextcloudInstance()">Gem</button>
</div>
</div>
</div>
</div>
<!-- Nextcloud Rotate Credentials Modal -->
<div class="modal fade" id="nextcloudRotateModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title"><i class="bi bi-arrow-repeat me-2"></i>Rotér credentials</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="nextcloudRotateForm">
<input type="hidden" id="nextcloudRotateInstanceId">
<label class="form-label">Nyt password</label>
<input type="password" class="form-control" id="nextcloudRotatePassword" required>
</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="rotateNextcloudCredentials()">Opdater</button>
</div>
</div>
</div>
</div>
<!-- Nextcloud Audit Purge Modal -->
<div class="modal fade" id="nextcloudPurgeModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title"><i class="bi bi-trash me-2"></i>Slet auditlog</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="nextcloudPurgeForm" class="row g-3">
<div class="col-12">
<label class="form-label">Kunde</label>
<select class="form-select" id="nextcloudPurgeCustomerSelect"></select>
</div>
<div class="col-12">
<label class="form-label">Slet events før dato</label>
<input type="date" class="form-control" id="nextcloudPurgeBefore" required>
</div>
<div class="col-12">
<div class="alert alert-warning mb-0">
Dette kan ikke fortrydes.
</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-danger" onclick="purgeNextcloudAudit()">Slet</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let allSettings = [];
let pipelineStagesCache = [];
let nextcloudInstancesCache = [];
let customersCache = [];
function getSettingValue(key, fallback = '') {
const found = allSettings.find(s => s.key === key);
if (!found || found.value === null || found.value === undefined) return fallback;
return String(found.value);
}
function renderTelefoniSettings() {
const enabledEl = document.getElementById('telefoniClickEnabled');
const extEl = document.getElementById('telefoniDefaultExtension');
const templateEl = document.getElementById('telefoniActionTemplate');
const sharedSecretEl = document.getElementById('telefoniSharedSecret');
if (!enabledEl || !extEl || !templateEl || !sharedSecretEl) return;
enabledEl.checked = getSettingValue('telefoni_click_to_call_enabled', 'false') === 'true';
extEl.value = getSettingValue('telefoni_default_extension', '');
templateEl.value = getSettingValue('telefoni_action_url_template', '');
sharedSecretEl.value = getSettingValue('telefoni_shared_secret', '');
if (!document.getElementById('telefoniTestExtension').value) {
document.getElementById('telefoniTestExtension').value = extEl.value;
}
populateTelefoniTestUsers(usersCache || []);
const baseUrlEl = document.getElementById('yealinkBuilderBaseUrl');
if (baseUrlEl && !baseUrlEl.value) {
baseUrlEl.value = window.location.origin;
}
updateTelefoniActionPreview();
buildYealinkActionUrls();
}
function updateTelefoniActionPreview() {
const previewEl = document.getElementById('telefoniActionPreview');
const template = (document.getElementById('telefoniActionTemplate')?.value || '').trim();
const number = (document.getElementById('telefoniTestNumber')?.value || '22334455').trim();
const extension = (document.getElementById('telefoniTestExtension')?.value || document.getElementById('telefoniDefaultExtension')?.value || '').trim();
const userSelect = document.getElementById('telefoniTestUserId');
const selected = userSelect ? userSelect.options[userSelect.selectedIndex] : null;
const phoneIp = (selected?.dataset?.phoneIp || '').trim();
const phoneUsername = (selected?.dataset?.phoneUsername || '').trim();
const phonePassword = (selected?.dataset?.phonePassword || '').trim();
if (!previewEl) return;
if (!template) {
previewEl.textContent = '-';
return;
}
const resolved = template
.replaceAll('{number}', number)
.replaceAll('{raw_number}', number)
.replaceAll('{extension}', extension)
.replaceAll('{phone_ip}', phoneIp)
.replaceAll('{phone_username}', phoneUsername)
.replaceAll('{phone_password}', phonePassword);
previewEl.textContent = resolved;
}
function applyTelefoniTemplatePreset(preset) {
const templateEl = document.getElementById('telefoniActionTemplate');
if (!templateEl) return;
const presets = {
generic: 'http://{phone_ip}/servlet?number={number}&ext={extension}',
'yealink-basic-auth': 'http://{phone_username}:{phone_password}@{phone_ip}/servlet?key=number={raw_number}',
'yealink-open': 'http://{phone_ip}/servlet?key=number={raw_number}'
};
if (!presets[preset]) return;
templateEl.value = presets[preset];
updateTelefoniActionPreview();
}
function buildYealinkActionUrls() {
const baseRaw = (document.getElementById('yealinkBuilderBaseUrl')?.value || '').trim();
const manualToken = (document.getElementById('yealinkBuilderToken')?.value || '').trim();
const sharedToken = (document.getElementById('telefoniSharedSecret')?.value || '').trim();
const token = manualToken || sharedToken;
const estEl = document.getElementById('yealinkEstablishedUrl');
const termEl = document.getElementById('yealinkTerminatedUrl');
if (!estEl || !termEl) return;
if (!baseRaw) {
estEl.value = '';
termEl.value = '';
return;
}
const base = baseRaw.replace(/\/$/, '');
const tokenPart = token ? `token=${encodeURIComponent(token)}&` : '';
estEl.value = `${base}/api/v1/telefoni/established?${tokenPart}callid=$call_id&remote=$remote&local=$local&active_user=$active_user&called_number=$calledNumber`;
termEl.value = `${base}/api/v1/telefoni/terminated?${tokenPart}callid=$call_id&duration=$call_duration`;
}
async function copyYealinkUrl(inputId) {
const input = document.getElementById(inputId);
if (!input || !input.value) return;
try {
await navigator.clipboard.writeText(input.value);
showNotification('URL kopieret', 'success');
} catch (error) {
console.error('Copy failed:', error);
showNotification('Kunne ikke kopiere URL', 'error');
}
}
async function saveTelefoniSettings() {
const enabled = document.getElementById('telefoniClickEnabled')?.checked ? 'true' : 'false';
const extension = (document.getElementById('telefoniDefaultExtension')?.value || '').trim();
const template = (document.getElementById('telefoniActionTemplate')?.value || '').trim();
const sharedSecret = (document.getElementById('telefoniSharedSecret')?.value || '').trim();
if (!template.includes('{number}') && !template.includes('{raw_number}')) {
showNotification('Template skal indeholde {number} eller {raw_number}', 'error');
return;
}
await updateSetting('telefoni_click_to_call_enabled', enabled);
await updateSetting('telefoni_default_extension', extension);
await updateSetting('telefoni_action_url_template', template);
await updateSetting('telefoni_shared_secret', sharedSecret);
await loadSettings();
showNotification('Telefoni-indstillinger gemt', 'success');
}
function generateTelefoniToken() {
const input = document.getElementById('telefoniSharedSecret');
if (!input) return;
let token = '';
if (window.crypto && window.crypto.getRandomValues) {
const bytes = new Uint8Array(24);
window.crypto.getRandomValues(bytes);
token = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
} else {
token = `${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
}
input.value = token;
buildYealinkActionUrls();
showNotification('Nyt token genereret', 'success');
}
async function testTelefoniCall() {
const btn = document.getElementById('telefoniTestBtn');
const resultEl = document.getElementById('telefoniTestResult');
const number = (document.getElementById('telefoniTestNumber')?.value || '').trim();
const extension = (document.getElementById('telefoniTestExtension')?.value || '').trim();
const userIdRaw = (document.getElementById('telefoniTestUserId')?.value || '').trim();
const user_id = userIdRaw ? parseInt(userIdRaw, 10) : null;
if (!number) {
showNotification('Angiv et nummer til test', 'error');
return;
}
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Ringer...';
resultEl.textContent = 'Sender Action URL...';
try {
const response = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ number, extension: extension || null, user_id })
});
if (!response.ok) {
resultEl.textContent = await getErrorMessage(response, 'Testopkald fejlede');
showNotification('Testopkald fejlede', 'error');
return;
}
const data = await response.json();
resultEl.textContent = `✅ Kald sendt. HTTP ${data.http_status}. URL: ${data.action_url}`;
showNotification('Testopkald sendt', 'success');
} catch (error) {
console.error('Telefoni test call failed:', error);
resultEl.textContent = '❌ Kunne ikke sende testopkald';
showNotification('Kunne ikke sende testopkald', 'error');
} finally {
btn.disabled = false;
btn.innerHTML = original;
updateTelefoniActionPreview();
}
}
async function loadSettings() {
try {
const response = await fetch('/api/v1/settings');
allSettings = await response.json();
displaySettingsByCategory();
renderTelefoniSettings();
await loadCaseTypesSetting();
await loadCaseStatusesSetting();
await loadTagsManagement();
await loadNextcloudInstances();
} catch (error) {
console.error('Error loading settings:', error);
}
}
function displaySettingsByCategory() {
const categories = {
company: ['company_name', 'company_cvr', 'company_email', 'company_phone', 'company_address'],
integrations: ['vtiger_enabled', 'vtiger_url', 'vtiger_username', 'economic_enabled', 'economic_app_secret', 'economic_agreement_token'],
notifications: ['email_notifications'],
system: ['system_timezone']
};
// Company settings
displaySettings('companySettings', categories.company);
// vTiger settings
displaySettings('vtigerSettings', ['vtiger_enabled', 'vtiger_url', 'vtiger_username']);
// Economic settings
displaySettings('economicSettings', ['economic_enabled', 'economic_app_secret', 'economic_agreement_token']);
// Notification settings
displaySettings('notificationSettings', categories.notifications);
// Email templates
displaySettings('emailTemplatesInternal', [
'email_template_internal_subject',
'email_template_internal_body'
]);
displaySettings('emailTemplatesExternal', [
'email_template_external_subject',
'email_template_external_body',
'nextcloud_user_welcome_subject',
'nextcloud_user_welcome_body'
]);
// System settings
displaySettings('systemSettings', categories.system);
}
async function loadNextcloudInstances() {
try {
const response = await fetch('/api/v1/nextcloud/instances');
if (!response.ok) {
throw new Error('Failed to fetch instances');
}
nextcloudInstancesCache = await response.json();
if (!customersCache.length) {
const customersResponse = await fetch('/api/v1/customers?limit=1000&offset=0');
if (customersResponse.ok) {
const payload = await customersResponse.json();
customersCache = Array.isArray(payload) ? payload : (payload.customers || []);
} else {
customersCache = [];
}
}
renderNextcloudInstances();
} catch (error) {
console.error('Error loading Nextcloud instances:', error);
const table = document.getElementById('nextcloudInstancesTable');
if (table) {
table.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Kunne ikke hente instanser</td></tr>';
}
}
}
function renderNextcloudInstances() {
const table = document.getElementById('nextcloudInstancesTable');
if (!table) return;
if (!nextcloudInstancesCache.length) {
table.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Ingen instanser oprettet</td></tr>';
return;
}
const customerMap = new Map(customersCache.map(c => [c.id, c]));
table.innerHTML = nextcloudInstancesCache.map(instance => {
const customer = customerMap.get(instance.customer_id);
return `
<tr>
<td>${customer ? escapeHtml(customer.name) : 'Ukendt'}</td>
<td>${escapeHtml(instance.base_url || '-')}</td>
<td>${escapeHtml(instance.username || '-')}</td>
<td>
<span class="badge ${instance.is_enabled ? 'bg-success' : 'bg-secondary'}">
${instance.is_enabled ? 'Aktiv' : 'Deaktiveret'}
</span>
</td>
<td>${instance.updated_at ? formatDate(instance.updated_at) : '-'}</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-light" onclick="toggleNextcloudInstance(${instance.id}, ${!instance.is_enabled})" title="${instance.is_enabled ? 'Deaktiver' : 'Aktiver'}">
<i class="bi bi-${instance.is_enabled ? 'pause' : 'play'}-circle"></i>
</button>
<button class="btn btn-light" onclick="openNextcloudRotateModal(${instance.id})" title="Rotér credentials">
<i class="bi bi-arrow-repeat"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
function openNextcloudInstanceModal() {
const modal = new bootstrap.Modal(document.getElementById('nextcloudInstanceModal'));
populateNextcloudCustomerSelect('nextcloudCustomerSelect');
modal.show();
}
async function populateNextcloudCustomerSelect(selectId) {
if (!customersCache.length) {
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
if (response.ok) {
const payload = await response.json();
customersCache = Array.isArray(payload) ? payload : (payload.customers || []);
} else {
customersCache = [];
}
}
const select = document.getElementById(selectId);
if (!select) return;
if (!customersCache.length) {
select.innerHTML = '<option value="">Ingen kunder fundet</option>';
return;
}
select.innerHTML = customersCache.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
}
async function createNextcloudInstance() {
const payload = {
customer_id: parseInt(document.getElementById('nextcloudCustomerSelect').value || '0', 10),
base_url: document.getElementById('nextcloudBaseUrl').value.trim(),
auth_type: document.getElementById('nextcloudAuthType').value,
username: document.getElementById('nextcloudUsername').value.trim(),
password: document.getElementById('nextcloudPassword').value
};
if (!payload.customer_id || !payload.base_url || !payload.username || !payload.password) {
alert('Udfyld alle felter');
return;
}
try {
const response = await fetch('/api/v1/nextcloud/instances', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json();
alert(error.detail || 'Kunne ikke oprette instans');
return;
}
bootstrap.Modal.getInstance(document.getElementById('nextcloudInstanceModal')).hide();
document.getElementById('nextcloudInstanceForm').reset();
await loadNextcloudInstances();
} catch (error) {
console.error('Error creating Nextcloud instance:', error);
alert('Kunne ikke oprette instans');
}
}
async function toggleNextcloudInstance(instanceId, enable) {
const endpoint = enable ? 'enable' : 'disable';
try {
const response = await fetch(`/api/v1/nextcloud/instances/${instanceId}/${endpoint}`, { method: 'POST' });
if (!response.ok) {
alert('Kunne ikke opdatere instans');
return;
}
await loadNextcloudInstances();
} catch (error) {
console.error('Error toggling Nextcloud instance:', error);
}
}
function openNextcloudRotateModal(instanceId) {
document.getElementById('nextcloudRotateInstanceId').value = instanceId;
const modal = new bootstrap.Modal(document.getElementById('nextcloudRotateModal'));
modal.show();
}
async function rotateNextcloudCredentials() {
const instanceId = document.getElementById('nextcloudRotateInstanceId').value;
const password = document.getElementById('nextcloudRotatePassword').value;
if (!password) {
alert('Angiv nyt password');
return;
}
try {
const response = await fetch(`/api/v1/nextcloud/instances/${instanceId}/rotate-credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (!response.ok) {
alert('Kunne ikke rotere credentials');
return;
}
bootstrap.Modal.getInstance(document.getElementById('nextcloudRotateModal')).hide();
document.getElementById('nextcloudRotateForm').reset();
await loadNextcloudInstances();
} catch (error) {
console.error('Error rotating credentials:', error);
}
}
function openNextcloudPurgeModal() {
const modal = new bootstrap.Modal(document.getElementById('nextcloudPurgeModal'));
populateNextcloudCustomerSelect('nextcloudPurgeCustomerSelect');
modal.show();
}
async function purgeNextcloudAudit() {
const customerId = parseInt(document.getElementById('nextcloudPurgeCustomerSelect').value || '0', 10);
const beforeDate = document.getElementById('nextcloudPurgeBefore').value;
if (!customerId || !beforeDate) {
alert('Vælg kunde og dato');
return;
}
try {
const response = await fetch('/api/v1/nextcloud/audit/purge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customerId, before_date: beforeDate })
});
if (!response.ok) {
const error = await response.json();
alert(error.detail || 'Kunne ikke slette auditlog');
return;
}
const result = await response.json();
alert(`Slettet ${result.deleted || 0} events`);
bootstrap.Modal.getInstance(document.getElementById('nextcloudPurgeModal')).hide();
document.getElementById('nextcloudPurgeForm').reset();
} catch (error) {
console.error('Error purging audit log:', error);
}
}
function displaySettings(containerId, keys) {
const container = document.getElementById(containerId);
if (!container) {
return;
}
const settings = allSettings.filter(s => keys.includes(s.key));
if (settings.length === 0) {
container.innerHTML = '<p class="text-muted">Ingen indstillinger tilgængelige</p>';
return;
}
container.innerHTML = settings.map(setting => {
const inputId = `setting_${setting.key}`;
let inputHtml = '';
if (setting.value_type === 'boolean') {
inputHtml = `
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="${inputId}"
${setting.value === 'true' ? 'checked' : ''}
onchange="updateSetting('${setting.key}', this.checked ? 'true' : 'false')">
</div>
`;
} else if (setting.value_type === 'text' || setting.key.includes('template')) {
inputHtml = `
<textarea class="form-control" id="${inputId}" rows="6"
onblur="updateSetting('${setting.key}', this.value)"
style="max-width: 100%;"></textarea>
`;
} else if (setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('token')) {
inputHtml = `
<input type="password" class="form-control" id="${inputId}"
value="${setting.value || ''}"
onblur="updateSetting('${setting.key}', this.value)"
style="max-width: 300px;">
`;
} else {
inputHtml = `
<input type="text" class="form-control" id="${inputId}"
value="${setting.value || ''}"
onblur="updateSetting('${setting.key}', this.value)"
style="max-width: 300px;">
`;
}
return `
<div class="setting-item">
<div class="setting-info">
<h6>${setting.description || setting.key}</h6>
<small><code>${setting.key}</code></small>
</div>
<div>${inputHtml}</div>
</div>
`;
}).join('');
settings.forEach(setting => {
if (setting.value_type === 'text' || setting.key.includes('template')) {
const textarea = document.getElementById(`setting_${setting.key}`);
if (textarea) textarea.value = setting.value || '';
}
});
}
async function updateSetting(key, value) {
try {
const response = await fetch(`/api/v1/settings/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value })
});
if (response.ok) {
// Show success toast
console.log(`✅ Updated ${key}`);
}
} catch (error) {
console.error('Error updating setting:', error);
alert('Kunne ikke opdatere indstilling');
}
}
function getCaseTypesSetting() {
return allSettings.find(setting => setting.key === 'case_types');
}
const CASE_MODULE_OPTIONS = [
'relations', 'call-history', 'files', 'emails', 'hardware', 'locations',
'contacts', 'customers', 'wiki', 'todo-steps', 'time', 'solution',
'sales', 'subscription', 'reminders', 'calendar'
];
const CASE_MODULE_LABELS = {
'relations': 'Relationer',
'call-history': 'Opkaldshistorik',
'files': 'Filer',
'emails': 'E-mails',
'hardware': 'Hardware',
'locations': 'Lokationer',
'contacts': 'Kontakter',
'customers': 'Kunder',
'wiki': 'Wiki',
'todo-steps': 'Todo-opgaver',
'time': 'Tid',
'solution': 'Løsning',
'sales': 'Varekøb & salg',
'subscription': 'Abonnement',
'reminders': 'Påmindelser',
'calendar': 'Kalender'
};
let caseTypeModuleDefaultsCache = {};
let caseStatusesCache = [];
function normalizeCaseStatuses(raw) {
const normalized = [];
const seen = new Set();
const source = Array.isArray(raw) ? raw : [];
source.forEach((item) => {
const row = typeof item === 'string'
? { value: item, is_closed: false }
: (item && typeof item === 'object' ? item : null);
if (!row) return;
const value = String(row.value || '').trim();
if (!value) return;
const key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
normalized.push({
value,
is_closed: Boolean(row.is_closed)
});
});
const defaults = [
{ value: 'åben', is_closed: false },
{ value: 'under behandling', is_closed: false },
{ value: 'afventer', is_closed: false },
{ value: 'løst', is_closed: true },
{ value: 'lukket', is_closed: true }
];
defaults.forEach((item) => {
const key = item.value.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
normalized.push(item);
}
});
return normalized;
}
function renderCaseStatuses(rows) {
const tbody = document.getElementById('caseStatusesTableBody');
if (!tbody) return;
if (!Array.isArray(rows) || !rows.length) {
tbody.innerHTML = '<tr><td colspan="3" class="text-muted">Ingen statusværdier defineret</td></tr>';
return;
}
tbody.innerHTML = rows.map((row, index) => `
<tr>
<td><span class="fw-semibold">${escapeHtml(row.value)}</span></td>
<td class="text-center">
<div class="form-check form-switch d-inline-flex">
<input class="form-check-input" type="checkbox" id="caseStatusClosed_${index}" ${row.is_closed ? 'checked' : ''}
onchange="toggleCaseStatusClosed(${index}, this.checked)">
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeCaseStatus(${index})" title="Slet status">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
async function loadCaseStatusesSetting() {
try {
const response = await fetch('/api/v1/settings/case_statuses');
if (!response.ok) {
caseStatusesCache = normalizeCaseStatuses([]);
renderCaseStatuses(caseStatusesCache);
return;
}
const setting = await response.json();
const parsed = JSON.parse(setting.value || '[]');
caseStatusesCache = normalizeCaseStatuses(parsed);
renderCaseStatuses(caseStatusesCache);
} catch (error) {
console.error('Error loading case statuses:', error);
caseStatusesCache = normalizeCaseStatuses([]);
renderCaseStatuses(caseStatusesCache);
}
}
async function saveCaseStatuses() {
await updateSetting('case_statuses', JSON.stringify(caseStatusesCache));
renderCaseStatuses(caseStatusesCache);
}
async function addCaseStatus() {
const input = document.getElementById('caseStatusInput');
if (!input) return;
const value = input.value.trim();
if (!value) return;
const exists = caseStatusesCache.some((row) => String(row.value || '').toLowerCase() === value.toLowerCase());
if (!exists) {
caseStatusesCache.push({ value, is_closed: false });
await saveCaseStatuses();
}
input.value = '';
}
async function removeCaseStatus(index) {
caseStatusesCache = caseStatusesCache.filter((_, i) => i !== index);
if (!caseStatusesCache.length) {
caseStatusesCache = normalizeCaseStatuses([]);
}
await saveCaseStatuses();
}
async function toggleCaseStatusClosed(index, checked) {
if (!caseStatusesCache[index]) return;
caseStatusesCache[index].is_closed = Boolean(checked);
await saveCaseStatuses();
}
function normalizeCaseTypeModuleDefaults(raw, caseTypes) {
const normalized = {};
const rawObj = raw && typeof raw === 'object' ? raw : {};
const validTypes = Array.isArray(caseTypes) ? caseTypes : [];
validTypes.forEach(type => {
const existing = rawObj[type];
const asList = Array.isArray(existing) ? existing : CASE_MODULE_OPTIONS;
normalized[type] = asList.filter(m => CASE_MODULE_OPTIONS.includes(m));
});
return normalized;
}
async function loadCaseTypeModuleDefaultsSetting(caseTypes) {
try {
const response = await fetch('/api/v1/settings/case_type_module_defaults');
if (!response.ok) {
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults({}, caseTypes);
} else {
const setting = await response.json();
const parsed = JSON.parse(setting.value || '{}');
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults(parsed, caseTypes);
}
} catch (error) {
console.error('Error loading case type module defaults:', error);
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults({}, caseTypes);
}
renderCaseTypeModuleTypeOptions(caseTypes);
renderCaseTypeModuleChecklist();
}
function renderCaseTypeModuleTypeOptions(caseTypes) {
const select = document.getElementById('caseTypeModulesTypeSelect');
if (!select) return;
const previous = select.value;
select.innerHTML = '<option value="">Vælg sagstype...</option>' +
(caseTypes || []).map(type => `<option value="${type}">${type}</option>`).join('');
if (previous && (caseTypes || []).includes(previous)) {
select.value = previous;
} else if ((caseTypes || []).length > 0) {
select.value = caseTypes[0];
}
}
function renderCaseTypeModuleChecklist() {
const container = document.getElementById('caseTypeModuleChecklist');
const select = document.getElementById('caseTypeModulesTypeSelect');
if (!container || !select) return;
const type = select.value;
if (!type) {
container.innerHTML = '<div class="text-muted">Vælg en sagstype for at redigere standardmoduler.</div>';
return;
}
const enabledModules = new Set(caseTypeModuleDefaultsCache[type] || CASE_MODULE_OPTIONS);
container.innerHTML = CASE_MODULE_OPTIONS.map(moduleKey => `
<div class="col-md-4 col-sm-6">
<div class="form-check border rounded p-2">
<input class="form-check-input" type="checkbox" id="ctmod_${moduleKey}" ${enabledModules.has(moduleKey) ? 'checked' : ''}>
<label class="form-check-label" for="ctmod_${moduleKey}">
${CASE_MODULE_LABELS[moduleKey] || moduleKey}
</label>
</div>
</div>
`).join('');
}
async function loadCaseTypesSetting() {
try {
const response = await fetch('/api/v1/settings/case_types');
if (!response.ok) {
renderCaseTypes([]);
await loadCaseTypeModuleDefaultsSetting([]);
return;
}
const setting = await response.json();
const rawValue = setting.value || '[]';
const parsed = JSON.parse(rawValue);
const types = Array.isArray(parsed) ? parsed : [];
renderCaseTypes(types);
await loadCaseTypeModuleDefaultsSetting(types);
} catch (error) {
console.error('Error loading case types:', error);
renderCaseTypes([]);
await loadCaseTypeModuleDefaultsSetting([]);
}
}
function renderCaseTypes(types) {
const container = document.getElementById('caseTypesList');
if (!container) return;
if (!types.length) {
container.innerHTML = '<span class="text-muted">Ingen typer defineret</span>';
return;
}
container.innerHTML = types.map(type => `
<span class="badge bg-light text-dark border d-inline-flex align-items-center gap-2">
${type}
<button type="button" class="btn btn-sm p-0" onclick="removeCaseType('${type.replace(/'/g, "&#39;")}')">
<i class="bi bi-x"></i>
</button>
</span>
`).join('');
}
async function saveCaseTypes(types) {
await updateSetting('case_types', JSON.stringify(types));
renderCaseTypes(types);
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults(caseTypeModuleDefaultsCache, types);
renderCaseTypeModuleTypeOptions(types);
renderCaseTypeModuleChecklist();
await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache));
}
async function addCaseType() {
const input = document.getElementById('caseTypeInput');
if (!input) return;
const value = input.value.trim().toLowerCase();
if (!value) return;
const response = await fetch('/api/v1/settings/case_types');
if (!response.ok) return;
const setting = await response.json();
const current = JSON.parse(setting.value || '[]');
const types = Array.isArray(current) ? current : [];
if (!types.includes(value)) {
types.push(value);
await saveCaseTypes(types);
}
input.value = '';
}
async function removeCaseType(type) {
const response = await fetch('/api/v1/settings/case_types');
if (!response.ok) return;
const setting = await response.json();
const current = JSON.parse(setting.value || '[]');
const types = Array.isArray(current) ? current : [];
const filtered = types.filter(t => t !== type);
await saveCaseTypes(filtered);
}
async function saveCaseTypeModuleDefaults() {
const select = document.getElementById('caseTypeModulesTypeSelect');
const type = select ? select.value : '';
if (!type) {
alert('Vælg en sagstype først');
return;
}
const enabled = CASE_MODULE_OPTIONS.filter(moduleKey => {
const checkbox = document.getElementById(`ctmod_${moduleKey}`);
return checkbox ? checkbox.checked : false;
});
caseTypeModuleDefaultsCache[type] = enabled;
await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache));
if (typeof showNotification === 'function') {
showNotification('Standardmoduler gemt', 'success');
}
}
async function resetCaseTypeModuleDefaults() {
const select = document.getElementById('caseTypeModulesTypeSelect');
const type = select ? select.value : '';
if (!type) {
alert('Vælg en sagstype først');
return;
}
caseTypeModuleDefaultsCache[type] = [...CASE_MODULE_OPTIONS];
renderCaseTypeModuleChecklist();
try {
await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache));
if (typeof showNotification === 'function') {
showNotification('Standardmoduler nulstillet', 'success');
}
} catch (error) {
if (typeof showNotification === 'function') {
showNotification('Kunne ikke nulstille standardmoduler', 'error');
}
}
}
let usersCache = [];
let groupsCache = [];
let permissionsCache = [];
let selectedUserId = null;
let selectedGroupId = null;
async function loadUsers() {
await Promise.all([
loadAdminUsers(),
loadGroups(),
loadPermissions()
]);
}
async function loadAdminUsers() {
try {
const response = await fetch('/api/v1/admin/users');
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese brugere'));
}
usersCache = await response.json();
displayUsers(usersCache);
populateTelefoniTestUsers(usersCache);
} catch (error) {
console.error('Error loading users:', error);
const tbody = document.getElementById('usersTableBody');
tbody.innerHTML = `<tr><td colspan="11" class="text-center text-muted py-5">${escapeHtml(error.message || 'Kunne ikke indlaese brugere')}</td></tr>`;
}
}
async function loadGroups() {
try {
const response = await fetch('/api/v1/admin/groups');
if (!response.ok) throw new Error('Failed to load groups');
groupsCache = await response.json();
displayGroups(groupsCache);
renderGroupCheckboxes('createUserGroups', []);
} catch (error) {
console.error('Error loading groups:', error);
const tbody = document.getElementById('groupsTableBody');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted py-5">Kunne ikke indlæse grupper</td></tr>';
}
}
}
async function loadPermissions() {
try {
const response = await fetch('/api/v1/admin/permissions');
if (!response.ok) throw new Error('Failed to load permissions');
permissionsCache = await response.json();
} catch (error) {
console.error('Error loading permissions:', error);
}
}
function displayUsers(users) {
const tbody = document.getElementById('usersTableBody');
if (!users || users.length === 0) {
tbody.innerHTML = '<tr><td colspan="11" class="text-center text-muted py-5">Ingen brugere fundet</td></tr>';
return;
}
tbody.innerHTML = users.map(user => {
const groupBadges = (user.groups || []).map(name =>
`<span class="badge bg-light text-dark border me-1">${escapeHtml(name)}</span>`
).join('') || '<span class="text-muted">-</span>';
return `
<tr>
<td>
<div class="d-flex align-items-center gap-3">
<div class="user-avatar">${getInitials(user.username)}</div>
<div>
<div class="fw-semibold">
${escapeHtml(user.username)}
${user.is_superadmin ? '<span class="badge bg-warning text-dark ms-2">Superadmin</span>' : ''}
</div>
${user.full_name ? `<small class="text-muted">${escapeHtml(user.full_name)}</small>` : ''}
</div>
</div>
</td>
<td>${user.email ? escapeHtml(user.email) : '<span class="text-muted">-</span>'}</td>
<td>${groupBadges}</td>
<td>
<span class="badge ${user.is_active ? 'bg-success' : 'bg-secondary'}">
${user.is_active ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td style="min-width: 130px;">
<input
type="text"
class="form-control form-control-sm"
id="telefoni-extension-${user.user_id}"
value="${escapeHtml(user.telefoni_extension || '')}"
placeholder="fx 101"
maxlength="16"
>
</td>
<td style="min-width: 160px;">
<input
type="text"
class="form-control form-control-sm"
id="telefoni-phone-ip-${user.user_id}"
value="${escapeHtml(user.telefoni_phone_ip || '')}"
placeholder="fx 192.168.1.45"
maxlength="64"
>
</td>
<td style="min-width: 160px;">
<input
type="text"
class="form-control form-control-sm"
id="telefoni-phone-username-${user.user_id}"
value="${escapeHtml(user.telefoni_phone_username || '')}"
placeholder="fx admin"
maxlength="128"
>
</td>
<td style="min-width: 160px;">
<input
type="password"
class="form-control form-control-sm"
id="telefoni-phone-password-${user.user_id}"
value=""
placeholder="Lad tom for at beholde"
maxlength="255"
>
</td>
<td>
<div class="form-check d-flex justify-content-center">
<input
class="form-check-input"
type="checkbox"
id="telefoni-active-${user.user_id}"
${user.telefoni_aktiv ? 'checked' : ''}
>
</div>
</td>
<td>${user.created_at ? formatDate(user.created_at) : '<span class="text-muted">-</span>'}</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-light" onclick="saveUserTelefoni(${user.user_id})" title="Gem telefoni">
<i class="bi bi-telephone"></i>
</button>
<button class="btn btn-light" onclick="openUserGroupsModal(${user.user_id})" title="Tildel grupper">
<i class="bi bi-people"></i>
</button>
<button class="btn btn-light" onclick="resetPassword(${user.user_id})" title="Nulstil adgangskode">
<i class="bi bi-key"></i>
</button>
<button class="btn btn-light" onclick="resetTwoFactor(${user.user_id})" title="Nulstil 2FA">
<i class="bi bi-shield-lock"></i>
</button>
<button class="btn btn-light" onclick="toggleUserActive(${user.user_id}, ${!user.is_active})"
title="${user.is_active ? 'Deaktiver' : 'Aktiver'}">
<i class="bi bi-${user.is_active ? 'pause' : 'play'}-circle"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function saveUserTelefoni(userId) {
const extInput = document.getElementById(`telefoni-extension-${userId}`);
const ipInput = document.getElementById(`telefoni-phone-ip-${userId}`);
const phoneUsernameInput = document.getElementById(`telefoni-phone-username-${userId}`);
const phonePasswordInput = document.getElementById(`telefoni-phone-password-${userId}`);
const activeInput = document.getElementById(`telefoni-active-${userId}`);
if (!extInput || !ipInput || !phoneUsernameInput || !phonePasswordInput || !activeInput) return;
const telefoni_extension = (extInput.value || '').trim() || null;
const telefoni_phone_ip = (ipInput.value || '').trim() || null;
const telefoni_phone_username = (phoneUsernameInput.value || '').trim() || null;
const telefoni_phone_password = (phonePasswordInput.value || '').trim() || null;
const telefoni_aktiv = !!activeInput.checked;
try {
const response = await fetch(`/api/v1/telefoni/admin/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
telefoni_extension,
telefoni_phone_ip,
telefoni_phone_username,
telefoni_phone_password,
telefoni_aktiv
})
});
if (!response.ok) {
alert(await getErrorMessage(response, 'Kunne ikke gemme telefoni-indstillinger'));
return;
}
await loadAdminUsers();
} catch (error) {
console.error('Error saving telefoni mapping:', error);
alert('Kunne ikke gemme telefoni-indstillinger');
}
}
function populateTelefoniTestUsers(users = []) {
const select = document.getElementById('telefoniTestUserId');
if (!select) return;
const current = select.value;
select.innerHTML = '<option value="">Ingen valgt</option>';
users.forEach(u => {
const option = document.createElement('option');
option.value = String(u.user_id);
const label = u.full_name || u.username || `User ${u.user_id}`;
const ext = u.telefoni_extension ? ` ext:${u.telefoni_extension}` : '';
const ip = u.telefoni_phone_ip ? ` ip:${u.telefoni_phone_ip}` : '';
const phoneUser = u.telefoni_phone_username ? ` user:${u.telefoni_phone_username}` : '';
option.textContent = `${label}${ext}${ip}${phoneUser}`;
option.dataset.extension = u.telefoni_extension || '';
option.dataset.phoneIp = u.telefoni_phone_ip || '';
option.dataset.phoneUsername = u.telefoni_phone_username || '';
select.appendChild(option);
});
if (current) select.value = current;
}
function onTelefoniTestUserChange() {
const select = document.getElementById('telefoniTestUserId');
const extInput = document.getElementById('telefoniTestExtension');
if (!select || !extInput) return;
const selected = select.options[select.selectedIndex];
if (selected && selected.dataset.extension && !extInput.value.trim()) {
extInput.value = selected.dataset.extension;
}
updateTelefoniActionPreview();
}
function displayGroups(groups) {
const tbody = document.getElementById('groupsTableBody');
if (!groups || groups.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted py-5">Ingen grupper fundet</td></tr>';
return;
}
tbody.innerHTML = groups.map(group => `
<tr>
<td>
<div class="fw-semibold">${escapeHtml(group.name)}</div>
${group.description ? `<small class="text-muted">${escapeHtml(group.description)}</small>` : ''}
</td>
<td>
<span class="badge bg-light text-dark border">
${(group.permissions || []).length} rettigheder
</span>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick="openGroupPermissionsModal(${group.id})">
<i class="bi bi-shield-check me-1"></i>Rediger
</button>
</td>
</tr>
`).join('');
}
function showCreateUserModal() {
renderGroupCheckboxes('createUserGroups', []);
const modal = new bootstrap.Modal(document.getElementById('createUserModal'));
modal.show();
}
async function createUser() {
const selectedGroups = getSelectedGroupIds('createUserGroups');
const passwordValue = document.getElementById('newPassword').value;
const user = {
username: document.getElementById('newUsername').value,
email: document.getElementById('newEmail').value,
full_name: document.getElementById('newFullName').value || null,
password: passwordValue,
is_superadmin: document.getElementById('newIsSuperadmin').checked,
is_active: document.getElementById('newIsActive').checked,
group_ids: selectedGroups
};
try {
const response = await fetch('/api/v1/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
});
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('createUserModal')).hide();
document.getElementById('createUserForm').reset();
renderGroupCheckboxes('createUserGroups', []);
loadUsers();
} else {
alert(await getErrorMessage(response, 'Kunne ikke oprette bruger'));
}
} catch (error) {
console.error('Error creating user:', error);
alert('Kunne ikke oprette bruger');
}
}
async function toggleUserActive(userId, isActive) {
try {
const response = await fetch(`/api/v1/admin/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: isActive })
});
if (!response.ok) {
alert(await getErrorMessage(response, 'Kunne ikke opdatere brugerstatus'));
return;
}
loadUsers();
} catch (error) {
console.error('Error toggling user:', error);
alert('Kunne ikke opdatere brugerstatus');
}
}
function showCreateGroupModal() {
const modal = new bootstrap.Modal(document.getElementById('createGroupModal'));
modal.show();
}
async function createGroup() {
const payload = {
name: document.getElementById('newGroupName').value,
description: document.getElementById('newGroupDescription').value || null
};
if (!payload.name) {
alert('Navn er påkrævet');
return;
}
try {
const response = await fetch('/api/v1/admin/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('createGroupModal')).hide();
document.getElementById('createGroupForm').reset();
loadGroups();
} else {
alert(await getErrorMessage(response, 'Kunne ikke oprette gruppe'));
}
} catch (error) {
console.error('Error creating group:', error);
alert('Kunne ikke oprette gruppe');
}
}
function openUserGroupsModal(userId) {
const user = usersCache.find(u => u.user_id === userId);
if (!user) return;
selectedUserId = userId;
document.getElementById('userGroupsModalTitle').textContent = `Tildel grupper • ${user.username}`;
const selectedIds = (user.groups || [])
.map(name => {
const group = groupsCache.find(g => g.name === name);
return group ? group.id : null;
})
.filter(Boolean);
renderGroupCheckboxes('userGroupAssignments', selectedIds);
const modal = new bootstrap.Modal(document.getElementById('userGroupsModal'));
modal.show();
}
async function saveUserGroups() {
if (!selectedUserId) return;
const groupIds = getSelectedGroupIds('userGroupAssignments');
try {
const response = await fetch(`/api/v1/admin/users/${selectedUserId}/groups`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_ids: groupIds })
});
if (!response.ok) {
alert(await getErrorMessage(response, 'Kunne ikke opdatere grupper'));
return;
}
bootstrap.Modal.getInstance(document.getElementById('userGroupsModal')).hide();
loadAdminUsers();
} catch (error) {
console.error('Error updating user groups:', error);
alert('Kunne ikke opdatere grupper');
}
}
function openGroupPermissionsModal(groupId) {
const group = groupsCache.find(g => g.id === groupId);
if (!group) return;
selectedGroupId = groupId;
document.getElementById('groupPermissionsModalTitle').textContent = `Rettigheder • ${group.name}`;
renderPermissionsChecklist(group.permissions || []);
const modal = new bootstrap.Modal(document.getElementById('groupPermissionsModal'));
modal.show();
}
async function saveGroupPermissions() {
if (!selectedGroupId) return;
const permissionIds = Array.from(document.querySelectorAll('#groupPermissionsList input[type="checkbox"]:checked'))
.map(input => parseInt(input.dataset.permissionId));
try {
const response = await fetch(`/api/v1/admin/groups/${selectedGroupId}/permissions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permission_ids: permissionIds })
});
if (!response.ok) {
alert(await getErrorMessage(response, 'Kunne ikke opdatere rettigheder'));
return;
}
bootstrap.Modal.getInstance(document.getElementById('groupPermissionsModal')).hide();
loadGroups();
} catch (error) {
console.error('Error updating group permissions:', error);
alert('Kunne ikke opdatere rettigheder');
}
}
function renderGroupCheckboxes(containerId, selectedIds = []) {
const container = document.getElementById(containerId);
if (!container) return;
if (!groupsCache || groupsCache.length === 0) {
container.innerHTML = '<div class="text-muted small">Ingen grupper fundet</div>';
return;
}
container.innerHTML = groupsCache.map(group => `
<div class="form-check">
<input class="form-check-input" type="checkbox" id="${containerId}_${group.id}" data-group-id="${group.id}"
${selectedIds.includes(group.id) ? 'checked' : ''}>
<label class="form-check-label" for="${containerId}_${group.id}">
${escapeHtml(group.name)}
</label>
</div>
`).join('');
}
function renderPermissionsChecklist(selectedCodes = []) {
const container = document.getElementById('groupPermissionsList');
if (!container) return;
if (!permissionsCache || permissionsCache.length === 0) {
container.innerHTML = '<div class="text-muted small">Ingen rettigheder fundet</div>';
return;
}
const grouped = permissionsCache.reduce((acc, perm) => {
const key = perm.category || 'andet';
acc[key] = acc[key] || [];
acc[key].push(perm);
return acc;
}, {});
container.innerHTML = Object.entries(grouped).map(([category, perms]) => `
<div class="mb-3">
<div class="fw-semibold text-uppercase small text-muted mb-2">${escapeHtml(category)}</div>
${perms.map(perm => `
<div class="form-check">
<input class="form-check-input" type="checkbox" id="perm_${perm.id}" data-permission-id="${perm.id}"
${selectedCodes.includes(perm.code) ? 'checked' : ''}>
<label class="form-check-label" for="perm_${perm.id}">
<span class="fw-semibold">${escapeHtml(perm.code)}</span>
${perm.description ? `<small class="text-muted d-block">${escapeHtml(perm.description)}</small>` : ''}
</label>
</div>
`).join('')}
</div>
`).join('');
}
function getSelectedGroupIds(containerId) {
return Array.from(document.querySelectorAll(`#${containerId} input[type="checkbox"]:checked`))
.map(input => parseInt(input.dataset.groupId));
}
async function resetPassword(userId) {
const newPassword = prompt('Indtast ny adgangskode:');
if (!newPassword) return;
try {
const response = await fetch(`/api/v1/admin/users/${userId}/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_password: newPassword })
});
if (!response.ok) {
alert(await getErrorMessage(response, 'Kunne ikke nulstille adgangskode'));
return;
}
alert('Adgangskode nulstillet!');
} catch (error) {
console.error('Error resetting password:', error);
alert('Kunne ikke nulstille adgangskode');
}
}
async function resetTwoFactor(userId) {
const confirmed = confirm('Nulstil 2FA for denne bruger?');
if (!confirmed) return;
const reasonRaw = prompt('Begrundelse (valgfri):') || '';
const reason = reasonRaw.trim();
const payload = reason ? { reason } : {};
try {
const response = await fetch(`/api/v1/admin/users/${userId}/2fa/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
alert('2FA nulstillet!');
await loadAdminUsers();
return;
}
alert(await getErrorMessage(response, 'Kunne ikke nulstille 2FA'));
} catch (error) {
console.error('Error resetting 2FA:', error);
alert('Kunne ikke nulstille 2FA');
}
}
function getInitials(name) {
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
}
// Load AI Prompts
async function loadAIPrompts() {
try {
const response = await fetch('/api/v1/ai-prompts');
const prompts = await response.json();
const container = document.getElementById('aiPromptsContent');
const accordionHtml = `
<div class="accordion" id="aiPromptsAccordion">
${Object.entries(prompts).map(([key, prompt], index) => `
<div class="accordion-item">
<h2 class="accordion-header" id="heading_${key}">
<button class="accordion-button ${index !== 0 ? 'collapsed' : ''}" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse_${key}"
aria-expanded="${index === 0 ? 'true' : 'false'}" aria-controls="collapse_${key}">
<div class="d-flex w-100 justify-content-between align-items-center pe-3">
<div class="d-flex flex-column align-items-start">
<span class="fw-bold">
${escapeHtml(prompt.name)}
${prompt.is_custom ? '<span class="badge bg-warning text-dark ms-2" style="font-size: 0.65rem;">Ændret</span>' : ''}
</span>
<small class="text-muted fw-normal">${escapeHtml(prompt.description)}</small>
</div>
</div>
</button>
</h2>
<div id="collapse_${key}" class="accordion-collapse collapse ${index === 0 ? 'show' : ''}"
aria-labelledby="heading_${key}" data-bs-parent="#aiPromptsAccordion">
<div class="accordion-body bg-light">
<div class="row mb-3">
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body py-2">
<small class="text-uppercase text-muted fw-bold" style="font-size: 0.7rem;">Model</small>
<div class="font-monospace text-primary">${escapeHtml(prompt.model)}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body py-2">
<small class="text-uppercase text-muted fw-bold" style="font-size: 0.7rem;">Endpoint</small>
<div class="font-monospace text-truncate" title="${escapeHtml(prompt.endpoint)}">${escapeHtml(prompt.endpoint)}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body py-2">
<small class="text-uppercase text-muted fw-bold" style="font-size: 0.7rem;">Parametre</small>
<div class="font-monospace small text-truncate" title='${JSON.stringify(prompt.parameters)}'>${JSON.stringify(prompt.parameters)}</div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center py-2">
<span class="fw-bold small text-uppercase text-muted"><i class="bi bi-terminal me-2"></i>System Client Prompt</span>
<div class="btn-group btn-group-sm">
${prompt.is_custom ? `
<button class="btn btn-outline-danger" onclick="resetPrompt('${key}')" title="Nulstil til standard">
<i class="bi bi-arrow-counterclockwise"></i> Nulstil
</button>` : ''}
<button class="btn btn-outline-success" onclick="testPrompt('${key}')" id="testBtn_${key}" title="Test AI prompt">
<i class="bi bi-play-circle"></i> Test
</button>
<button class="btn btn-outline-primary" onclick="editPrompt('${key}')" id="editBtn_${key}" title="Rediger Prompt">
<i class="bi bi-pencil"></i> Rediger
</button>
<button class="btn btn-outline-secondary" onclick="copyPrompt('${key}')" title="Kopier til udklipsholder">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div class="card-body p-0 position-relative">
<pre id="prompt_${key}" class="m-0 p-3 bg-dark text-light rounded-bottom"
style="max-height: 400px; overflow-y: auto; font-size: 0.85rem; white-space: pre-wrap; border-radius: 0;">${escapeHtml(prompt.prompt)}</pre>
<textarea id="edit_prompt_${key}" class="form-control d-none p-3 bg-white text-dark rounded-bottom"
style="height: 300px; font-family: monospace; font-size: 0.85rem; border-radius: 0;">${escapeHtml(prompt.prompt)}</textarea>
<div id="testResult_${key}" class="alert alert-secondary m-3 py-2 px-3 d-none" style="white-space: pre-wrap; font-size: 0.85rem;"></div>
<div id="editActions_${key}" class="position-absolute bottom-0 end-0 p-3 d-none">
<button class="btn btn-sm btn-secondary me-1" onclick="cancelEdit('${key}')">Annuller</button>
<button class="btn btn-sm btn-success" onclick="savePrompt('${key}')"><i class="bi bi-check-lg"></i> Gem</button>
</div>
</div>
</div>
</div>
</div>
</div>
`).join('')}
</div>
`;
container.innerHTML = accordionHtml;
} catch (error) {
console.error('Error loading AI prompts:', error);
document.getElementById('aiPromptsContent').innerHTML =
'<div class="alert alert-danger">Kunne ikke indlæse AI prompts</div>';
}
}
function editPrompt(key) {
document.getElementById(`prompt_${key}`).classList.add('d-none');
document.getElementById(`edit_prompt_${key}`).classList.remove('d-none');
document.getElementById(`editActions_${key}`).classList.remove('d-none');
document.getElementById(`editBtn_${key}`).disabled = true;
}
function cancelEdit(key) {
document.getElementById(`prompt_${key}`).classList.remove('d-none');
document.getElementById(`edit_prompt_${key}`).classList.add('d-none');
document.getElementById(`editActions_${key}`).classList.add('d-none');
document.getElementById(`editBtn_${key}`).disabled = false;
// Reset value
document.getElementById(`edit_prompt_${key}`).value = document.getElementById(`prompt_${key}`).textContent;
}
async function savePrompt(key) {
const newText = document.getElementById(`edit_prompt_${key}`).value;
try {
const response = await fetch(`/api/v1/ai-prompts/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt_text: newText })
});
if (!response.ok) throw new Error('Failed to update prompt');
// Reload to show update
await loadAIPrompts();
// Re-open accordion
setTimeout(() => {
const collapse = document.getElementById(`collapse_${key}`);
if (collapse) {
new bootstrap.Collapse(collapse, { toggle: false }).show();
}
}, 100);
} catch (error) {
console.error('Error saving prompt:', error);
alert('Kunne ikke gemme prompt');
}
}
async function resetPrompt(key) {
if (!confirm('Er du sikker på at du vil nulstille denne prompt til standard?')) return;
try {
const response = await fetch(`/api/v1/ai-prompts/${key}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to reset prompt');
// Reload to show update
await loadAIPrompts();
// Re-open accordion
setTimeout(() => {
const collapse = document.getElementById(`collapse_${key}`);
if (collapse) {
new bootstrap.Collapse(collapse, { toggle: false }).show();
}
}, 100);
} catch (error) {
console.error('Error resetting prompt:', error);
alert('Kunne ikke nulstille prompt');
}
}
async function testPrompt(key) {
const btn = document.getElementById(`testBtn_${key}`);
const resultElement = document.getElementById(`testResult_${key}`);
const editElement = document.getElementById(`edit_prompt_${key}`);
const promptText = editElement ? editElement.value : '';
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Tester';
resultElement.className = 'alert alert-secondary m-3 py-2 px-3';
resultElement.classList.remove('d-none');
resultElement.textContent = 'Tester AI...';
try {
const response = await fetch(`/api/v1/ai-prompts/${key}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt_text: promptText })
});
if (!response.ok) {
const message = await getErrorMessage(response, 'Kunne ikke teste AI prompt');
throw new Error(message);
}
const result = await response.json();
const fullResponse = (result.ai_response || '').trim();
const preview = fullResponse.length > 1200 ? `${fullResponse.slice(0, 1200)}\n...` : fullResponse;
resultElement.className = 'alert alert-success m-3 py-2 px-3';
resultElement.textContent =
`✅ AI svar modtaget (${result.latency_ms} ms)\n` +
`Model: ${result.model}\n\n` +
`${preview || '[Tomt svar]'}`;
} catch (error) {
console.error('Error testing AI prompt:', error);
resultElement.className = 'alert alert-danger m-3 py-2 px-3';
resultElement.textContent = `${error.message || 'Kunne ikke teste AI prompt'}`;
} finally {
btn.disabled = false;
btn.innerHTML = originalHtml;
}
}
function copyPrompt(key) {
const promptElement = document.getElementById(`prompt_${key}`);
const text = promptElement.textContent;
navigator.clipboard.writeText(text).then(() => {
// Show success feedback
const btn = event.target.closest('button');
const originalHtml = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check me-1"></i>Kopieret!';
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-success');
setTimeout(() => {
btn.innerHTML = originalHtml;
btn.classList.remove('btn-success');
btn.classList.add('btn-outline-primary');
}, 2000);
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function getErrorMessage(response, fallback) {
try {
const text = await response.text();
if (text) {
try {
const data = JSON.parse(text);
if (data && data.detail) return data.detail;
if (data && data.message) return data.message;
} catch (error) {
return text;
}
}
} catch (error) {
// ignore body read errors
}
const statusLabel = response.status ? ` (${response.status} ${response.statusText || ''})` : '';
return `${fallback}${statusLabel}`.trim();
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('da-DK', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Tab navigation
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
link.addEventListener('click', (e) => {
const tab = link.dataset.tab;
// Skip external links (those without data-tab)
if (!tab) {
return; // Allow normal navigation
}
e.preventDefault();
// Update nav
document.querySelectorAll('.settings-nav .nav-link').forEach(l => l.classList.remove('active'));
link.classList.add('active');
// Update content
document.querySelectorAll('.tab-pane').forEach(pane => {
pane.classList.remove('show', 'active');
});
document.getElementById(tab).classList.add('show', 'active');
// Load data for tab
if (tab === 'users') {
loadUsers();
} else if (tab === 'tags') {
loadTagsManagement();
} else if (tab === 'telefoni') {
renderTelefoniSettings();
} else if (tab === 'ai-prompts') {
loadAIPrompts();
} else if (tab === 'modules') {
loadModules();
}
});
});
// Load modules function
async function loadModules() {
try {
const response = await fetch('/api/v1/modules');
const data = await response.json();
const modulesContainer = document.getElementById('activeModules');
if (!data.modules || Object.keys(data.modules).length === 0) {
modulesContainer.innerHTML = `
<div class="text-center py-4">
<i class="bi bi-inbox display-4 text-muted"></i>
<p class="text-muted mt-3 mb-0">Ingen aktive moduler fundet</p>
<small class="text-muted">Opret dit første modul med <code>python3 scripts/create_module.py</code></small>
</div>
`;
return;
}
const modulesList = Object.values(data.modules).map(module => `
<div class="card mb-2">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1 fw-bold">
${module.enabled ? '<i class="bi bi-check-circle-fill text-success me-2"></i>' : '<i class="bi bi-x-circle-fill text-danger me-2"></i>'}
${module.name}
<small class="text-muted fw-normal">v${module.version}</small>
</h6>
<p class="text-muted small mb-2">${module.description}</p>
<div class="d-flex gap-3 small">
<span><i class="bi bi-person me-1"></i>${module.author}</span>
<span><i class="bi bi-database me-1"></i>Prefix: <code>${module.table_prefix}</code></span>
${module.has_api ? '<span class="badge bg-primary">API</span>' : ''}
${module.has_frontend ? '<span class="badge bg-info">Frontend</span>' : ''}
</div>
</div>
<div>
<a href="${module.api_prefix}/health" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-heart-pulse me-1"></i>Health
</a>
</div>
</div>
</div>
</div>
`).join('');
modulesContainer.innerHTML = modulesList;
} catch (error) {
console.error('Error loading modules:', error);
document.getElementById('activeModules').innerHTML =
'<div class="alert alert-danger mb-0">Kunne ikke indlæse moduler</div>';
}
}
// Tags Management
let allTagsData = [];
let currentTagFilter = 'all';
let showInactive = false;
async function loadTagsManagement() {
try {
const response = await fetch('/api/v1/tags');
if (!response.ok) {
const msg = await getErrorMessage(response, 'Kunne ikke indlæse tags');
throw new Error(msg);
}
allTagsData = await response.json();
updateTagsStats();
renderTagsGrid();
} catch (error) {
console.error('Error loading tags:', error);
allTagsData = [];
updateTagsStats();
renderTagsGrid();
showNotification('Fejl ved indlæsning af tags: ' + (error.message || 'ukendt fejl'), 'error');
}
}
function updateTagsStats() {
const stats = {
total: allTagsData.length,
workflow: allTagsData.filter(t => t.type === 'workflow').length,
status: allTagsData.filter(t => t.type === 'status').length,
category: allTagsData.filter(t => t.type === 'category').length,
priority: allTagsData.filter(t => t.type === 'priority').length,
billing: allTagsData.filter(t => t.type === 'billing').length
};
document.getElementById('totalTagsCount').textContent = stats.total;
document.getElementById('workflowTagsCount').textContent = stats.workflow;
document.getElementById('statusTagsCount').textContent = stats.status;
document.getElementById('categoryTagsCount').textContent = stats.category;
document.getElementById('priorityTagsCount').textContent = stats.priority;
document.getElementById('billingTagsCount').textContent = stats.billing;
}
function renderTagsGrid() {
const container = document.getElementById('tagsGrid');
let tags = currentTagFilter === 'all'
? allTagsData
: allTagsData.filter(t => t.type === currentTagFilter);
if (!showInactive) {
tags = tags.filter(t => t.is_active);
}
if (tags.length === 0) {
container.innerHTML = `
<div class="col-12 text-center py-5">
<i class="bi bi-inbox display-1 text-muted"></i>
<p class="text-muted mt-3">Ingen tags fundet</p>
</div>
`;
return;
}
container.innerHTML = tags.map(tag => `
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm position-relative" style="border-left: 4px solid ${tag.color} !important;">
${!tag.is_active ? '<div class="position-absolute top-0 end-0 m-2"><span class="badge bg-secondary">Inaktiv</span></div>' : ''}
<div class="card-body">
<div class="d-flex align-items-start mb-3">
<div class="flex-shrink-0">
<div class="rounded" style="width: 48px; height: 48px; background-color: ${tag.color}; display: flex; align-items: center; justify-content: center;">
${tag.icon ? `<i class="bi ${tag.icon} text-white" style="font-size: 1.5rem;"></i>` : `<span class="text-white fw-bold">${tag.name.charAt(0)}</span>`}
</div>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="card-title mb-1 fw-bold">${tag.name}</h6>
<span class="badge" style="background-color: ${tag.color}20; color: ${tag.color};">${tag.type}</span>
</div>
</div>
${tag.description ? `<p class="card-text small text-muted mb-3">${tag.description}</p>` : '<p class="card-text small text-muted mb-3"><em>Ingen beskrivelse</em></p>'}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary flex-grow-1" onclick="editTag(${tag.id})">
<i class="bi bi-pencil me-1"></i>Rediger
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTag(${tag.id}, '${tag.name.replace(/'/g, "\\'")}')">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
`).join('');
}
function editTag(tagId) {
const tag = allTagsData.find(t => t.id === tagId);
if (!tag) return;
document.getElementById('tagId').value = tag.id;
document.getElementById('tagName').value = tag.name;
document.getElementById('tagType').value = tag.type;
document.getElementById('tagDescription').value = tag.description || '';
document.getElementById('tagColor').value = tag.color;
document.getElementById('tagColorHex').value = tag.color;
document.getElementById('tagIcon').value = tag.icon || '';
document.getElementById('tagActive').checked = tag.is_active;
document.querySelector('#tagModal .modal-title').textContent = 'Rediger Tag';
new bootstrap.Modal(document.getElementById('tagModal')).show();
}
async function deleteTag(tagId, tagName) {
if (!confirm(`Slet tag "${tagName}"?\n\nDette vil også fjerne tagget fra alle steder det er brugt.`)) {
return;
}
try {
const response = await fetch(`/api/v1/tags/${tagId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete tag');
showNotification(`Tag "${tagName}" slettet`, 'success');
await loadTagsManagement();
} catch (error) {
showNotification('Fejl ved sletning: ' + error.message, 'error');
}
}
async function saveTag() {
const tagId = document.getElementById('tagId').value;
const tagData = {
name: document.getElementById('tagName').value,
type: document.getElementById('tagType').value,
description: document.getElementById('tagDescription').value || null,
color: document.getElementById('tagColorHex').value,
icon: document.getElementById('tagIcon').value || null,
is_active: document.getElementById('tagActive').checked
};
try {
const url = tagId ? `/api/v1/tags/${tagId}` : '/api/v1/tags';
const method = tagId ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tagData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to save tag');
}
bootstrap.Modal.getInstance(document.getElementById('tagModal')).hide();
showNotification(tagId ? 'Tag opdateret' : 'Tag oprettet', 'success');
await loadTagsManagement();
} catch (error) {
showNotification('Fejl: ' + error.message, 'error');
}
}
// Tag filter event listeners
document.querySelectorAll('#tagTypeFilter input[type="radio"]').forEach(radio => {
radio.addEventListener('change', (e) => {
currentTagFilter = e.target.value;
renderTagsGrid();
});
});
document.getElementById('showInactiveToggle').addEventListener('change', (e) => {
showInactive = e.target.checked;
renderTagsGrid();
});
// Color picker sync
function setupTagModalListeners() {
const colorPicker = document.getElementById('tagColor');
const colorHex = document.getElementById('tagColorHex');
if (colorPicker && colorHex) {
colorPicker.addEventListener('input', (e) => {
colorHex.value = e.target.value;
});
colorHex.addEventListener('input', (e) => {
const color = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
colorPicker.value = color;
}
});
}
// Type change updates color
const tagType = document.getElementById('tagType');
if (tagType) {
tagType.addEventListener('change', (e) => {
const type = e.target.value;
const colorMap = {
'workflow': '#ff6b35',
'status': '#ffd700',
'category': '#0f4c75',
'priority': '#dc3545',
'billing': '#2d6a4f'
};
if (colorMap[type] && colorPicker && colorHex) {
colorPicker.value = colorMap[type];
colorHex.value = colorMap[type];
}
});
}
// Modal reset on close
const tagModal = document.getElementById('tagModal');
if (tagModal) {
tagModal.addEventListener('hidden.bs.modal', () => {
document.getElementById('tagForm').reset();
document.getElementById('tagId').value = '';
document.querySelector('#tagModal .modal-title').textContent = 'Opret Tag';
});
}
}
// Load tags when tags tab is activated
const tagsNavLink = document.querySelector('a[data-tab="tags"]');
if (tagsNavLink) {
tagsNavLink.addEventListener('click', () => {
if (allTagsData.length === 0) {
loadTagsManagement();
}
});
}
// ====== SYNC MANAGEMENT ======
let syncLog = [];
let archivedSyncPollInterval = null;
async function loadSyncStats() {
try {
const response = await fetch('/api/v1/customers?limit=1000');
if (!response.ok) throw new Error('Failed to load customers');
const data = await response.json();
const customers = data.customers || [];
const stats = {
total: customers.length,
withVtiger: customers.filter(c => c.vtiger_id).length,
withEconomic: customers.filter(c => c.economic_customer_number).length
};
document.getElementById('syncStatsCustomers').textContent = stats.total;
document.getElementById('syncStatsVtiger').textContent = stats.withVtiger;
document.getElementById('syncStatsEconomic').textContent = stats.withEconomic;
} catch (error) {
console.error('Error loading sync stats:', error);
}
}
async function loadSyncLog() {
const container = document.getElementById('syncLogContainer');
if (syncLog.length === 0) {
container.innerHTML = `
<div class="text-center py-5 text-muted">
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
<p class="mt-2 mb-0">Ingen synkroniseringer endnu</p>
<small>Klik på en af sync knapperne ovenfor for at starte</small>
</div>
`;
return;
}
container.innerHTML = syncLog.map(log => `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center mb-1">
<i class="bi ${log.status === 'success' ? 'bi-check-circle text-success' : log.status === 'error' ? 'bi-x-circle text-danger' : 'bi-info-circle text-primary'} me-2"></i>
<strong>${log.title}</strong>
</div>
<small class="text-muted">${log.message}</small>
</div>
<small class="text-muted">${new Date(log.timestamp).toLocaleString('da-DK')}</small>
</div>
</div>
`).join('');
}
function addSyncLogEntry(title, message, status = 'info') {
syncLog.unshift({
title,
message,
status,
timestamp: new Date().toISOString()
});
// Keep last 50 entries
if (syncLog.length > 50) {
syncLog = syncLog.slice(0, 50);
}
loadSyncLog();
}
async function parseApiError(response, fallbackMessage) {
let detailMessage = fallbackMessage;
try {
const errorPayload = await response.json();
if (errorPayload && errorPayload.detail) {
detailMessage = errorPayload.detail;
}
} catch (parseError) {
// Keep fallback message
}
if (response.status === 403 && detailMessage === '2FA required') {
return '2FA kræves for sync API. Aktivér 2FA på din bruger og log ind igen.';
}
if (response.status === 403) {
if (String(detailMessage).includes('Missing required permission') || String(detailMessage).includes('Superadmin access required')) {
return 'Kun admin/superadmin må starte eller overvåge sync.';
}
}
return detailMessage;
}
function updateArchivedSourceBadge(sourceKey, isSynced, hasError) {
const badgeId = sourceKey === 'simplycrm' ? 'archivedSimplyBadge' : 'archivedVtigerBadge';
const badge = document.getElementById(badgeId);
if (!badge) return;
if (hasError) {
badge.className = 'badge bg-danger';
badge.textContent = 'Fejl';
return;
}
if (isSynced === true) {
badge.className = 'badge bg-success';
badge.textContent = 'Synket';
return;
}
badge.className = 'badge bg-warning text-dark';
badge.textContent = 'Mangler';
}
function startArchivedSyncPolling() {
if (archivedSyncPollInterval) return;
archivedSyncPollInterval = setInterval(() => {
loadArchivedSyncStatus();
}, 15000);
}
function stopArchivedSyncPolling() {
if (!archivedSyncPollInterval) return;
clearInterval(archivedSyncPollInterval);
archivedSyncPollInterval = null;
}
async function loadArchivedSyncStatus() {
const overallBadge = document.getElementById('archivedOverallBadge');
const lastChecked = document.getElementById('archivedLastChecked');
const hint = document.getElementById('archivedStatusHint');
try {
const response = await fetch('/api/v1/ticket/archived/status');
if (!response.ok) {
const errorMessage = await parseApiError(response, 'Kunne ikke hente archived status');
throw new Error(errorMessage);
}
const status = await response.json();
const simply = (status.sources || {}).simplycrm || {};
const vtiger = (status.sources || {}).vtiger || {};
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value === null || value === undefined ? '-' : value;
};
setText('archivedSimplyRemoteCount', simply.remote_total_tickets);
setText('archivedSimplyLocalCount', simply.local_total_tickets);
setText('archivedSimplyDiff', simply.diff);
setText('archivedSimplyMessagesCount', simply.local_total_messages);
setText('archivedVtigerRemoteCount', vtiger.remote_total_tickets);
setText('archivedVtigerLocalCount', vtiger.local_total_tickets);
setText('archivedVtigerDiff', vtiger.diff);
setText('archivedVtigerMessagesCount', vtiger.local_total_messages);
updateArchivedSourceBadge('simplycrm', simply.is_synced, !!simply.error);
updateArchivedSourceBadge('vtiger', vtiger.is_synced, !!vtiger.error);
if (overallBadge) {
if (status.overall_synced === true) {
overallBadge.className = 'badge bg-success';
overallBadge.textContent = 'Alt synced';
} else {
overallBadge.className = 'badge bg-warning text-dark';
overallBadge.textContent = 'Ikke fuldt synced';
}
}
if (lastChecked) {
lastChecked.textContent = new Date().toLocaleString('da-DK');
}
if (hint) {
const errors = [simply.error, vtiger.error].filter(Boolean);
hint.textContent = errors.length > 0
? `Statusfejl: ${errors.join(' | ')}`
: 'Polling aktiv mens Sync-fanen er åben.';
}
} catch (error) {
if (overallBadge) {
overallBadge.className = 'badge bg-danger';
overallBadge.textContent = 'Statusfejl';
}
if (hint) {
hint.textContent = error.message;
}
console.error('Error loading archived sync status:', error);
}
}
async function syncArchivedSimply() {
const btn = document.getElementById('btnSyncArchivedSimply');
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
try {
addSyncLogEntry('Simply Archived Sync Startet', 'Importerer archived tickets fra Simply...', 'info');
const response = await fetch('/api/v1/ticket/archived/simply/import?limit=5000&include_messages=true&force=false', {
method: 'POST'
});
if (!response.ok) {
const errorMessage = await parseApiError(response, 'Simply archived sync fejlede');
throw new Error(errorMessage);
}
const result = await response.json();
const details = [
`Importeret: ${result.imported || 0}`,
`Opdateret: ${result.updated || 0}`,
`Sprunget over: ${result.skipped || 0}`,
`Fejl: ${result.errors || 0}`,
`Beskeder: ${result.messages_imported || 0}`
].join(' | ');
addSyncLogEntry('Simply Archived Sync Fuldført', details, 'success');
await loadArchivedSyncStatus();
showNotification('Simply archived sync fuldført!', 'success');
} catch (error) {
addSyncLogEntry('Simply Archived Sync Fejl', error.message, 'error');
showNotification('Fejl: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Sync Simply Archived';
}
}
async function syncArchivedVtiger() {
const btn = document.getElementById('btnSyncArchivedVtiger');
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
try {
addSyncLogEntry('vTiger Archived Sync Startet', 'Importerer archived tickets fra vTiger Cases...', 'info');
const response = await fetch('/api/v1/ticket/archived/vtiger/import?limit=5000&include_messages=true&force=false', {
method: 'POST'
});
if (!response.ok) {
const errorMessage = await parseApiError(response, 'vTiger archived sync fejlede');
throw new Error(errorMessage);
}
const result = await response.json();
const details = [
`Importeret: ${result.imported || 0}`,
`Opdateret: ${result.updated || 0}`,
`Sprunget over: ${result.skipped || 0}`,
`Fejl: ${result.errors || 0}`,
`Beskeder: ${result.messages_imported || 0}`
].join(' | ');
addSyncLogEntry('vTiger Archived Sync Fuldført', details, 'success');
await loadArchivedSyncStatus();
showNotification('vTiger archived sync fuldført!', 'success');
} catch (error) {
addSyncLogEntry('vTiger Archived Sync Fejl', error.message, 'error');
showNotification('Fejl: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Sync vTiger Archived';
}
}
async function syncFromVtiger() {
const btn = document.getElementById('btnSyncVtiger');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
try {
addSyncLogEntry('vTiger Sync Startet', 'Henter firmaer fra vTiger...', 'info');
const response = await fetch('/api/v1/system/sync/vtiger', {
method: 'POST'
});
if (!response.ok) {
const errorMessage = await parseApiError(response, 'Sync fejlede');
throw new Error(errorMessage);
}
const result = await response.json();
const details = [
`Behandlet: ${result.total_processed || 0}`,
`Linket: ${result.linked || 0}`,
`Opdateret: ${result.updated || 0}`,
`Ikke fundet/sprunget over: ${result.not_found || 0}`
].join(' | ');
addSyncLogEntry(
'vTiger Sync Fuldført',
details,
'success'
);
document.getElementById('lastSyncVtiger').textContent = new Date().toLocaleString('da-DK');
await loadSyncStats();
showNotification('vTiger sync fuldført!', 'success');
} catch (error) {
addSyncLogEntry('vTiger Sync Fejl', error.message, 'error');
showNotification('Fejl: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-download me-2"></i>Sync Firmaer fra vTiger';
}
}
async function syncVtigerContacts() {
const btn = document.getElementById('btnSyncVtigerContacts');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
try {
addSyncLogEntry('vTiger Kontakt Sync Startet', 'Henter kontakter fra vTiger...', 'info');
const response = await fetch('/api/v1/system/sync/vtiger-contacts', {
method: 'POST'
});
if (!response.ok) {
const errorMessage = await parseApiError(response, 'Sync fejlede');
throw new Error(errorMessage);
}
const result = await response.json();
const details = [
`Behandlet: ${result.total_processed || 0}`,
`Oprettet: ${result.created || 0}`,
`Opdateret: ${result.updated || 0}`,
`Sprunget over: ${result.skipped || 0}`
].join(' | ');
addSyncLogEntry(
'vTiger Kontakt Sync Fuldført',
details,
'success'
);
showNotification('vTiger kontakt sync fuldført!', 'success');
} catch (error) {
addSyncLogEntry('vTiger Kontakt Sync Fejl', error.message, 'error');
showNotification('Fejl: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-people me-2"></i>Sync Kontakter fra vTiger';
}
}
async function syncFromEconomic() {
const btn = document.getElementById('btnSyncEconomic');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
try {
addSyncLogEntry('e-conomic Sync Startet', 'Henter kundenumre fra e-conomic...', 'info');
const response = await fetch('/api/v1/system/sync/economic', {
method: 'POST'
});
if (!response.ok) {
const errorMessage = await parseApiError(response, 'Sync fejlede');
throw new Error(errorMessage);
}
const result = await response.json();
const details = [
`Behandlet: ${result.total_processed || 0}`,
`Oprettet: ${result.created || 0}`,
`Opdateret: ${result.updated || 0}`,
`Konflikter: ${result.conflicts || 0}`,
`Sprunget over: ${result.skipped || 0}`
].join(' | ');
addSyncLogEntry(
'e-conomic Sync Fuldført',
details,
'success'
);
document.getElementById('lastSyncEconomic').textContent = new Date().toLocaleString('da-DK');
await loadSyncStats();
showNotification('e-conomic sync fuldført!', 'success');
} catch (error) {
addSyncLogEntry('e-conomic Sync Fejl', error.message, 'error');
showNotification('Fejl: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-download me-2"></i>Sync Firmaer fra e-conomic';
}
}
async function syncCvrToEconomic() {
showNotification('CVR→e-conomic sync er midlertidigt deaktiveret.', 'info');
return;
const btn = document.getElementById('btnSyncCvrEconomic');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Søger...';
try {
addSyncLogEntry('CVR Søgning Startet', 'Tjekker CVR numre i e-conomic...', 'info');
const response = await fetch('/api/v1/system/sync/cvr-to-economic', {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Søgning fejlede');
}
const result = await response.json();
const details = [
`Kontrolleret: ${result.checked || 0}`,
`Fundet i e-conomic: ${result.found || 0}`,
`Linket: ${result.linked || 0}`
].join(' | ');
addSyncLogEntry(
'CVR Søgning Fuldført',
details,
'success'
);
await loadSyncStats();
showNotification('CVR søgning fuldført!', 'success');
} catch (error) {
addSyncLogEntry('CVR Søgning Fejl', error.message, 'error');
showNotification('Fejl: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-search me-2"></i>Find Manglende CVR i e-conomic';
}
}
// Load sync data when sync tab is activated
const syncNavLink = document.querySelector('a[data-tab="sync"]');
if (syncNavLink) {
syncNavLink.addEventListener('click', () => {
loadSyncStats();
loadSyncLog();
loadArchivedSyncStatus();
startArchivedSyncPolling();
});
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopArchivedSyncPolling();
}
});
// Notification helper
function showNotification(message, type = 'info') {
// Create toast notification
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
const toastId = 'toast-' + Date.now();
const bgClass = type === 'success' ? 'bg-success' : type === 'error' ? 'bg-danger' : 'bg-info';
const icon = type === 'success' ? 'check-circle' : type === 'error' ? 'x-circle' : 'info-circle';
const toastHTML = `
<div id="${toastId}" class="toast align-items-center text-white ${bgClass} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-${icon} me-2"></i>${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHTML);
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, { delay: 3000 });
toast.show();
// Remove from DOM after hiding
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
function createToastContainer() {
const container = document.createElement('div');
container.id = 'toastContainer';
container.className = 'toast-container position-fixed top-0 end-0 p-3';
container.style.zIndex = '9999';
document.body.appendChild(container);
return container;
}
// Pipeline stages
async function loadPipelineStages() {
try {
const response = await fetch('/api/v1/pipeline/stages');
const stages = await response.json();
pipelineStagesCache = stages || [];
renderPipelineStages(pipelineStagesCache);
} catch (error) {
console.error('Error loading pipeline stages:', error);
}
}
function renderPipelineStages(stages) {
const tbody = document.getElementById('stagesTableBody');
if (!stages || stages.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Ingen stages</td></tr>';
return;
}
tbody.innerHTML = stages.map(stage => `
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<span class="badge" style="background:${stage.color}; color: white;">${stage.name}</span>
<span class="text-muted small">${stage.description || ''}</span>
</div>
</td>
<td>${stage.sort_order}</td>
<td>${stage.default_probability}%</td>
<td>
${stage.is_won ? '<span class="badge bg-success">Vundet</span>' : ''}
${stage.is_lost ? '<span class="badge bg-danger">Tabt</span>' : ''}
${(!stage.is_won && !stage.is_lost) ? '<span class="badge bg-secondary">Åben</span>' : ''}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-light" onclick="editStageById(${stage.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-light text-danger" onclick="deactivateStage(${stage.id})">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`).join('');
}
function openStageModal() {
document.getElementById('stageModalTitle').textContent = 'Opret stage';
document.getElementById('stageForm').reset();
document.getElementById('stageIsActive').checked = true;
document.getElementById('stageId').value = '';
const modal = new bootstrap.Modal(document.getElementById('stageModal'));
modal.show();
}
function editStageById(stageId) {
const stage = pipelineStagesCache.find(s => s.id === stageId);
if (!stage) return;
document.getElementById('stageModalTitle').textContent = 'Rediger stage';
document.getElementById('stageId').value = stage.id;
document.getElementById('stageName').value = stage.name;
document.getElementById('stageDescription').value = stage.description || '';
document.getElementById('stageSortOrder').value = stage.sort_order || 0;
document.getElementById('stageProbability').value = stage.default_probability || 0;
document.getElementById('stageColor').value = stage.color || '#0f4c75';
document.getElementById('stageIsWon').checked = !!stage.is_won;
document.getElementById('stageIsLost').checked = !!stage.is_lost;
document.getElementById('stageIsActive').checked = !!stage.is_active;
const modal = new bootstrap.Modal(document.getElementById('stageModal'));
modal.show();
}
async function saveStage() {
const stageId = document.getElementById('stageId').value;
const payload = {
name: document.getElementById('stageName').value,
description: document.getElementById('stageDescription').value || null,
sort_order: parseInt(document.getElementById('stageSortOrder').value || 0),
default_probability: parseInt(document.getElementById('stageProbability').value || 0),
color: document.getElementById('stageColor').value,
is_won: document.getElementById('stageIsWon').checked,
is_lost: document.getElementById('stageIsLost').checked,
is_active: document.getElementById('stageIsActive').checked
};
if (!payload.name) {
alert('Navn er påkrævet');
return;
}
const url = stageId ? `/api/v1/pipeline/stages/${stageId}` : '/api/v1/pipeline/stages';
const method = stageId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
alert('Kunne ikke gemme stage');
return;
}
bootstrap.Modal.getInstance(document.getElementById('stageModal')).hide();
loadPipelineStages();
}
async function deactivateStage(stageId) {
if (!confirm('Er du sikker på at du vil deaktivere denne stage?')) return;
const response = await fetch(`/api/v1/pipeline/stages/${stageId}`, { method: 'DELETE' });
if (!response.ok) {
alert('Kunne ikke deaktivere stage');
return;
}
loadPipelineStages();
}
// Load on page ready
document.addEventListener('DOMContentLoaded', () => {
loadSettings();
loadUsers();
setupTagModalListeners();
loadPipelineStages();
const telefoniTemplate = document.getElementById('telefoniActionTemplate');
const telefoniDefaultExt = document.getElementById('telefoniDefaultExtension');
const telefoniTestNumber = document.getElementById('telefoniTestNumber');
const telefoniTestExt = document.getElementById('telefoniTestExtension');
[telefoniTemplate, telefoniDefaultExt, telefoniTestNumber, telefoniTestExt].forEach(el => {
if (el) el.addEventListener('input', updateTelefoniActionPreview);
});
const yealinkBase = document.getElementById('yealinkBuilderBaseUrl');
const yealinkToken = document.getElementById('yealinkBuilderToken');
[yealinkBase, yealinkToken].forEach(el => {
if (el) el.addEventListener('input', buildYealinkActionUrls);
});
});
</script>
<!-- Tag Create/Edit Modal -->
<div class="modal fade" id="tagModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret Tag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="tagForm">
<input type="hidden" id="tagId">
<div class="mb-3">
<label for="tagName" class="form-label">Navn *</label>
<input type="text" class="form-control" id="tagName" required>
</div>
<div class="mb-3">
<label for="tagType" class="form-label">Type *</label>
<select class="form-select" id="tagType" required>
<option value="">Vælg type...</option>
<option value="workflow">Workflow - Trigger automatisering</option>
<option value="status">Status - Tilstand/fase</option>
<option value="category">Category - Emne/område</option>
<option value="priority">Priority - Hastighed</option>
<option value="billing">Billing - Økonomi</option>
</select>
</div>
<div class="mb-3">
<label for="tagDescription" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="tagDescription" rows="3"></textarea>
<small class="text-muted">Forklaring af hvad tagget gør eller betyder</small>
</div>
<div class="mb-3">
<label for="tagColor" class="form-label">Farve *</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="tagColor" value="#0f4c75">
<input type="text" class="form-control" id="tagColorHex" value="#0f4c75" pattern="^#[0-9A-Fa-f]{6}$">
</div>
<small class="text-muted">Hex color code (fx #0f4c75)</small>
</div>
<div class="mb-3">
<label for="tagIcon" class="form-label">Ikon (valgfrit)</label>
<input type="text" class="form-control" id="tagIcon" placeholder="bi-star">
<small class="text-muted">Bootstrap Icons navn (fx: bi-star, bi-flag, bi-check-circle)</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="tagActive" checked>
<label class="form-check-label" for="tagActive">
Aktiv
</label>
</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="saveTag()">Gem</button>
</div>
</div>
</div>
</div>
<!-- Email Template Modal -->
<div class="modal fade" id="emailTemplateModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="emailTemplateModalTitle">Opret Email Skabelon</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<!-- Editor Column -->
<div class="col-md-8">
<form id="emailTemplateForm">
<input type="hidden" id="emailTemplateId">
<div class="mb-3">
<label class="form-label">Skabelon Navn *</label>
<input type="text" class="form-control" id="emailTemplateName" required>
<small class="text-muted">Internt navn til identifikation</small>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Slug (Unik ID) *</label>
<input type="text" class="form-control font-monospace" id="emailTemplateSlug" required>
<small class="text-muted">Bruges i koden (f.eks. 'nextcloud_welcome')</small>
</div>
<div class="col-md-6">
<label class="form-label">Kategori</label>
<select class="form-select" id="emailTemplateCategory">
<option value="general">Generelt</option>
<option value="internal">Internt</option>
<option value="nextcloud">Nextcloud</option>
<option value="billing">Fakturering</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Kunde (Valgfri)</label>
<select class="form-select" id="emailTemplateCustomer">
<option value="">Alle kunder (Global skabelon)</option>
<!-- Populated via JS -->
</select>
<small class="text-muted">Vælg en kunde for at lave en specifik override</small>
</div>
<hr>
<div class="mb-3">
<label class="form-label">Emne *</label>
<input type="text" class="form-control" id="emailTemplateSubject" required>
</div>
<div class="mb-3">
<label class="form-label">Indhold *</label>
<textarea class="form-control font-monospace" id="emailTemplateBody" rows="12" required></textarea>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<input type="text" class="form-control" id="emailTemplateDescription">
</div>
</form>
</div>
<!-- Variables Column -->
<div class="col-md-4 bg-light p-3 rounded">
<h6 class="fw-bold mb-3">Tilgængelige Variabler</h6>
<p class="small text-muted mb-3">Klik på en variabel for at kopiere den til udklipsholderen.</p>
<div id="emailTemplateVariablesList" class="d-grid gap-2">
<!-- Populated via JS -->
</div>
<div class="mt-4">
<h6 class="fw-bold mb-2">Tilføj Variabel info</h6>
<p class="small text-muted mb-2">Definer hvilke variabler systemet understøtter for denne slug.</p>
<textarea class="form-control font-monospace small" id="emailTemplateVariablesJson" rows="5" placeholder='{"name": "Navn", "url": "Login URL"}'></textarea>
<small class="text-muted">JSON format</small>
</div>
</div>
</div>
</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="saveEmailTemplate()">Gem Skabelon</button>
</div>
</div>
</div>
</div>
<script>
// --- Email Template Management ---
async function loadEmailTemplates() {
const category = document.getElementById('emailTemplateCategoryFilter').value;
const customerId = document.getElementById('emailTemplateCustomerFilter').value;
let url = '/api/v1/email-templates/';
const params = new URLSearchParams();
if (category) params.append('category', category);
if (customerId) params.append('customer_id', customerId);
if (params.toString()) url += '?' + params.toString();
const tbody = document.getElementById('emailTemplatesTableBody');
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
try {
const response = await fetch(url);
const templates = await response.json();
tbody.innerHTML = '';
if (templates.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">Ingen skabeloner fundet</td></tr>';
return;
}
templates.forEach(tpl => {
const customerLabel = tpl.customer_id ? '<span class="badge bg-info text-dark">Kunde-specifik</span>' : '<span class="badge bg-secondary">Global</span>';
const updatedAt = new Date(tpl.updated_at).toLocaleDateString('da-DK');
tbody.innerHTML += `
<tr>
<td>
<div class="fw-bold">${tpl.name}</div>
<small class="text-muted font-monospace">${tpl.slug}</small>
</td>
<td>${tpl.subject}</td>
<td><span class="badge bg-light text-dark border">${tpl.category}</span></td>
<td>${customerLabel}</td>
<td>${updatedAt}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary me-1" onclick="editEmailTemplate(${tpl.id})">
<i class="bi bi-pencil"></i>
</button>
${!tpl.is_system ? `
<button class="btn btn-sm btn-outline-danger" onclick="deleteEmailTemplate(${tpl.id})">
<i class="bi bi-trash"></i>
</button>` : ''}
</td>
</tr>
`;
});
} catch (e) {
console.error("Error loading templates:", e);
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-danger">Fejl ved indlæsning af skabeloner</td></tr>';
}
}
async function loadEmailTemplateCustomers() {
// Populate customer dropdowns (Filter and Modal)
try {
const response = await fetch('/api/v1/customers');
const payload = await response.json();
const customers = Array.isArray(payload) ? payload : (payload.customers || []);
const filterSelect = document.getElementById('emailTemplateCustomerFilter');
const modalSelect = document.getElementById('emailTemplateCustomer');
// Reset (keep first option)
filterSelect.length = 1;
modalSelect.length = 1;
customers.forEach(c => {
const opt1 = new Option(`${c.name} (#${c.id})`, c.id);
const opt2 = new Option(`${c.name} (#${c.id})`, c.id);
filterSelect.add(opt1);
modalSelect.add(opt2);
});
} catch (e) {
console.error("Error loading customers:", e);
}
}
async function openEmailTemplateModal() {
// Reset form
document.getElementById('emailTemplateForm').reset();
document.getElementById('emailTemplateId').value = '';
document.getElementById('emailTemplateModalTitle').textContent = 'Opret Email Skabelon';
document.getElementById('emailTemplateSlug').readOnly = false;
document.getElementById('emailTemplateVariablesList').innerHTML = '<p class="text-muted small">Ingen variabler defineret</p>';
const modal = new bootstrap.Modal(document.getElementById('emailTemplateModal'));
modal.show();
}
async function editEmailTemplate(id) {
try {
const response = await fetch(`/api/v1/email-templates/${id}`);
if(!response.ok) throw new Error("Load failed");
const tpl = await response.json();
document.getElementById('emailTemplateId').value = tpl.id;
document.getElementById('emailTemplateName').value = tpl.name;
document.getElementById('emailTemplateSlug').value = tpl.slug;
document.getElementById('emailTemplateSlug').readOnly = tpl.is_system; // Cannot change slug of system template
document.getElementById('emailTemplateCategory').value = tpl.category;
document.getElementById('emailTemplateCustomer').value = tpl.customer_id || "";
document.getElementById('emailTemplateSubject').value = tpl.subject;
document.getElementById('emailTemplateBody').value = tpl.body;
document.getElementById('emailTemplateDescription').value = tpl.description || "";
document.getElementById('emailTemplateVariablesJson').value = JSON.stringify(tpl.variables || {}, null, 2);
document.getElementById('emailTemplateModalTitle').textContent = 'Rediger Email Skabelon';
updateVariablesList(tpl.variables);
const modal = new bootstrap.Modal(document.getElementById('emailTemplateModal'));
modal.show();
} catch (e) {
alert("Kunne ikke hente skabelon data");
}
}
function updateVariablesList(variables) {
const list = document.getElementById('emailTemplateVariablesList');
list.innerHTML = '';
if (!variables || Object.keys(variables).length === 0) {
list.innerHTML = '<p class="text-muted small">Ingen variabler defineret</p>';
return;
}
for (const [key, desc] of Object.entries(variables)) {
const btn = document.createElement('button');
btn.className = 'btn btn-outline-secondary btn-sm text-start';
// Escape Jinja2 curly braces to prevent conflict with JS template literals
btn.innerHTML = `<span class="fw-bold">{{ '{{' }}${key}{{ '}}' }}</span> <br><small>${desc}</small>`;
btn.onclick = () => {
navigator.clipboard.writeText(`{{ '{{' }}${key}{{ '}}' }}`);
// Flash button
const originalClass = btn.className;
btn.className = 'btn btn-success btn-sm text-start';
setTimeout(() => btn.className = originalClass, 500);
};
list.appendChild(btn);
}
}
// Update variables preview when JSON changes
document.getElementById('emailTemplateVariablesJson').addEventListener('input', function(e) {
try {
const vars = JSON.parse(e.target.value);
updateVariablesList(vars);
e.target.classList.remove('is-invalid');
} catch {
// e.target.classList.add('is-invalid');
}
});
async function saveEmailTemplate() {
const id = document.getElementById('emailTemplateId').value;
const isNew = !id;
// Parse JSON
let variables = {};
try {
const jsonStr = document.getElementById('emailTemplateVariablesJson').value;
if (jsonStr.trim()) variables = JSON.parse(jsonStr);
} catch (e) {
alert("Ugyldigt JSON format i variabler");
return;
}
const payload = {
name: document.getElementById('emailTemplateName').value,
slug: document.getElementById('emailTemplateSlug').value,
category: document.getElementById('emailTemplateCategory').value,
subject: document.getElementById('emailTemplateSubject').value,
body: document.getElementById('emailTemplateBody').value,
description: document.getElementById('emailTemplateDescription').value,
customer_id: document.getElementById('emailTemplateCustomer').value || null,
variables: variables
};
if (!payload.name || !payload.slug || !payload.subject || !payload.body) {
alert("Udfyld venligst alle obligatoriske felter (*)");
return;
}
const url = isNew ? '/api/v1/email-templates/' : `/api/v1/email-templates/${id}`;
const method = isNew ? 'POST' : 'PUT';
try {
const response = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "Fejl ved gemning");
}
bootstrap.Modal.getInstance(document.getElementById('emailTemplateModal')).hide();
loadEmailTemplates();
} catch (e) {
alert(e.message);
}
}
async function deleteEmailTemplate(id) {
if(!confirm("Er du sikker på at du vil slette denne skabelon?")) return;
try {
const response = await fetch(`/api/v1/email-templates/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error("Fejl ved sletning");
loadEmailTemplates();
} catch (e) {
alert(e.message);
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
// Other loaders are called at bottom of file in existing script
loadEmailTemplates();
loadEmailTemplateCustomers();
});
</script>
{% endblock %}