4421 lines
193 KiB
HTML
4421 lines
193 KiB
HTML
{% 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 kunde‑instanser, credentials og audit‑log</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">Audit‑log 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">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 audit‑log</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 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 audit‑log');
|
||
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 = {};
|
||
|
||
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, "'")}')">
|
||
<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 === '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) throw new Error('Failed to load tags');
|
||
allTagsData = await response.json();
|
||
updateTagsStats();
|
||
renderTagsGrid();
|
||
} catch (error) {
|
||
console.error('Error loading tags:', error);
|
||
showNotification('Fejl ved indlæsning af tags', '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 %}
|