2025-12-06 11:04:19 +01:00
{% 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 >
2026-02-14 02:26:29 +01:00
< a class = "nav-link" href = "#telefoni" data-tab = "telefoni" >
< i class = "bi bi-telephone me-2" > < / i > Telefoni
< / a >
2025-12-06 11:04:19 +01:00
< 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 >
2025-12-19 08:06:56 +01:00
< a class = "nav-link" href = "#tags" data-tab = "tags" >
< i class = "bi bi-tags me-2" > < / i > Tags
< / a >
2026-01-28 07:48:10 +01:00
< a class = "nav-link" href = "#pipeline" data-tab = "pipeline" >
< i class = "bi bi-diagram-3 me-2" > < / i > Pipeline
< / a >
2025-12-19 13:09:42 +01:00
< a class = "nav-link" href = "#sync" data-tab = "sync" >
< i class = "bi bi-arrow-repeat me-2" > < / i > Sync
< / a >
2025-12-08 09:15:52 +01:00
< a class = "nav-link" href = "#ai-prompts" data-tab = "ai-prompts" >
< i class = "bi bi-robot me-2" > < / i > AI Prompts
< / a >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< a class = "nav-link" href = "#email-templates" data-tab = "email-templates" >
< i class = "bi bi-envelope-paper me-2" > < / i > Email skabeloner
< / a >
2026-01-06 08:43:30 +01:00
< a class = "nav-link" href = "/admin/bmc-office-upload" >
< i class = "bi bi-cloud-upload me-2" > < / i > BMC Office Import
< / a >
2025-12-13 12:06:28 +01:00
< a class = "nav-link" href = "#modules" data-tab = "modules" >
< i class = "bi bi-box-seam me-2" > < / i > Moduler
< / a >
2025-12-06 11:04:19 +01:00
< a class = "nav-link" href = "#system" data-tab = "system" >
< i class = "bi bi-gear me-2" > < / i > System
< / a >
2026-01-28 08:03:17 +01:00
< a class = "nav-link" href = "/settings/migrations" >
< i class = "bi bi-database me-2" > < / i > DB Migrationer
< / a >
2025-12-06 11:04:19 +01:00
< / 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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 >
2025-12-06 11:04:19 +01:00
< / div >
2026-02-14 02:26:29 +01:00
<!-- 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 >
2025-12-06 11:04:19 +01:00
<!-- 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
<!-- 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 >
2026-02-03 15:37:16 +01:00
<!-- Users & Groups -->
2025-12-06 11:04:19 +01:00
< div class = "tab-pane fade" id = "users" >
2026-02-03 15:37:16 +01:00
< 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 >
2026-02-14 02:26:29 +01:00
< th > Telefoni ext.< / th >
< th > Telefoni IP< / th >
< th > Telefoni bruger< / th >
< th > Telefoni kode< / th >
< th > Telefoni aktiv< / th >
2026-02-03 15:37:16 +01:00
< th > Oprettet< / th >
< th class = "text-end" > Handlinger< / th >
< / tr >
< / thead >
< tbody id = "usersTableBody" >
< tr >
2026-02-14 02:26:29 +01:00
< td colspan = "11" class = "text-center py-5" >
2026-02-03 15:37:16 +01:00
< div class = "spinner-border text-primary" role = "status" > < / div >
< / td >
< / tr >
< / tbody >
< / table >
< / div >
< / div >
2025-12-06 11:04:19 +01:00
< / div >
2026-02-03 15:37:16 +01:00
< div class = "col-xl-5" >
< div class = "card p-4" >
< div class = "d-flex justify-content-between align-items-center mb-4" >
< div >
2026-02-14 02:26:29 +01:00
< h5 class = "mb-1 fw-bold" > Grupper & Rettigheder< / h5 >
2026-02-03 15:37:16 +01:00
< 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 >
2025-12-06 11:04:19 +01:00
< / div >
< / div >
< / div >
2025-12-19 08:06:56 +01:00
<!-- 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 >
2025-12-19 13:12:33 +01:00
< / div >
< / div >
2026-01-28 07:48:10 +01:00
<!-- 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 >
2025-12-19 13:12:33 +01:00
<!-- Sync Integration -->
2025-12-19 13:09:42 +01:00
< 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 >
2026-02-22 03:27:40 +01:00
< 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 >
2025-12-19 13:09:42 +01:00
< / 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" >
2026-02-22 03:27:40 +01:00
< 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 >
2025-12-19 13:09:42 +01:00
< / 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 >
2026-02-22 03:27:40 +01:00
< 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
2025-12-19 13:09:42 +01:00
< / 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 >
2026-02-22 03:27:40 +01:00
< 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 >
2025-12-19 13:09:42 +01:00
< / div >
< / div >
< / 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 >
2025-12-19 08:06:56 +01:00
< / div >
< / div >
2025-12-19 13:09:42 +01:00
<!-- /div>
< / div >
2025-12-08 09:15:52 +01:00
<!-- 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 >
2025-12-13 12:06:28 +01:00
<!-- 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 >
2025-12-16 15:36:11 +01:00
<!-- <a href="/api/v1/modules" target="_blank" class="btn btn - sm btn - outline - primary">
2025-12-13 12:06:28 +01:00
< i class = "bi bi-box-arrow-up-right me-1" > < / i > API
2025-12-16 15:36:11 +01:00
< / a > -->
< span class = "badge bg-secondary" > API ikke implementeret endnu< / span >
2025-12-13 12:06:28 +01:00
< / 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 >
2025-12-06 11:04:19 +01:00
<!-- System Settings -->
< div class = "tab-pane fade" id = "system" >
2026-02-17 08:29:05 +01:00
< 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 >
2026-03-04 07:11:06 +01:00
< 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'] %}
2026-02-17 08:29:05 +01:00
< 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 >
2025-12-06 11:04:19 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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 >
2026-02-15 11:12:58 +01:00
< 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 >
2025-12-06 11:04:19 +01:00
< / 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 >
2026-02-03 15:37:16 +01:00
< 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 >
2025-12-06 11:04:19 +01:00
< / 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 >
2026-02-03 15:37:16 +01:00
<!-- 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 >
2026-01-28 07:48:10 +01:00
<!-- 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
<!-- 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 >
2025-12-06 11:04:19 +01:00
{% endblock %}
{% block extra_js %}
< script >
let allSettings = [];
2026-01-28 07:48:10 +01:00
let pipelineStagesCache = [];
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
let nextcloudInstancesCache = [];
let customersCache = [];
2025-12-06 11:04:19 +01:00
2026-02-14 02:26:29 +01:00
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();
}
}
2025-12-06 11:04:19 +01:00
async function loadSettings() {
try {
const response = await fetch('/api/v1/settings');
allSettings = await response.json();
displaySettingsByCategory();
2026-02-14 02:26:29 +01:00
renderTelefoniSettings();
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
await loadCaseTypesSetting();
await loadNextcloudInstances();
2025-12-06 11:04:19 +01:00
} 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);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
// 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'
]);
2025-12-06 11:04:19 +01:00
// System settings
displaySettings('systemSettings', categories.system);
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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);
}
}
2025-12-06 11:04:19 +01:00
function displaySettings(containerId, keys) {
const container = document.getElementById(containerId);
2026-02-03 15:37:16 +01:00
if (!container) {
return;
}
2025-12-06 11:04:19 +01:00
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 >
`;
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
} 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 >
`;
2025-12-06 11:04:19 +01:00
} 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('');
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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 || '';
}
});
2025-12-06 11:04:19 +01:00
}
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');
}
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
function getCaseTypesSetting() {
return allSettings.find(setting => setting.key === 'case_types');
}
2026-02-15 11:12:58 +01:00
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('');
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
async function loadCaseTypesSetting() {
try {
const response = await fetch('/api/v1/settings/case_types');
if (!response.ok) {
renderCaseTypes([]);
2026-02-15 11:12:58 +01:00
await loadCaseTypeModuleDefaultsSetting([]);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
return;
}
const setting = await response.json();
const rawValue = setting.value || '[]';
const parsed = JSON.parse(rawValue);
2026-02-15 11:12:58 +01:00
const types = Array.isArray(parsed) ? parsed : [];
renderCaseTypes(types);
await loadCaseTypeModuleDefaultsSetting(types);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
} catch (error) {
console.error('Error loading case types:', error);
renderCaseTypes([]);
2026-02-15 11:12:58 +01:00
await loadCaseTypeModuleDefaultsSetting([]);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
}
}
function renderCaseTypes(types) {
const container = document.getElementById('caseTypesList');
if (!container) return;
if (!types.length) {
container.innerHTML = '< span class = "text-muted" > Ingen typer defineret< / span > ';
return;
}
container.innerHTML = types.map(type => `
< span class = "badge bg-light text-dark border d-inline-flex align-items-center gap-2" >
${type}
< button type = "button" class = "btn btn-sm p-0" onclick = "removeCaseType('${type.replace(/'/g, " & # 39 ; " ) } ' ) " >
< i class = "bi bi-x" > < / i >
< / button >
< / span >
`).join('');
}
async function saveCaseTypes(types) {
await updateSetting('case_types', JSON.stringify(types));
renderCaseTypes(types);
2026-02-15 11:12:58 +01:00
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults(caseTypeModuleDefaultsCache, types);
renderCaseTypeModuleTypeOptions(types);
renderCaseTypeModuleChecklist();
await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache));
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
}
async function addCaseType() {
const input = document.getElementById('caseTypeInput');
if (!input) return;
2026-02-15 11:12:58 +01:00
const value = input.value.trim().toLowerCase();
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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);
}
2026-02-15 11:12:58 +01:00
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');
}
}
}
2026-02-03 15:37:16 +01:00
let usersCache = [];
let groupsCache = [];
let permissionsCache = [];
let selectedUserId = null;
let selectedGroupId = null;
2025-12-06 11:04:19 +01:00
async function loadUsers() {
2026-02-03 15:37:16 +01:00
await Promise.all([
loadAdminUsers(),
loadGroups(),
loadPermissions()
]);
}
async function loadAdminUsers() {
2025-12-06 11:04:19 +01:00
try {
2026-02-03 15:37:16 +01:00
const response = await fetch('/api/v1/admin/users');
if (!response.ok) throw new Error('Failed to load users');
usersCache = await response.json();
displayUsers(usersCache);
2026-02-14 02:26:29 +01:00
populateTelefoniTestUsers(usersCache);
2025-12-06 11:04:19 +01:00
} catch (error) {
console.error('Error loading users:', error);
2026-02-03 15:37:16 +01:00
const tbody = document.getElementById('usersTableBody');
2026-02-14 02:26:29 +01:00
tbody.innerHTML = '< tr > < td colspan = "11" class = "text-center text-muted py-5" > Kunne ikke indlæse brugere< / td > < / tr > ';
2026-02-03 15:37:16 +01:00
}
}
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);
2025-12-06 11:04:19 +01:00
}
}
function displayUsers(users) {
const tbody = document.getElementById('usersTableBody');
2026-02-03 15:37:16 +01:00
if (!users || users.length === 0) {
2026-02-14 02:26:29 +01:00
tbody.innerHTML = '< tr > < td colspan = "11" class = "text-center text-muted py-5" > Ingen brugere fundet< / td > < / tr > ';
2025-12-06 11:04:19 +01:00
return;
}
2026-02-03 15:37:16 +01:00
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 `
2025-12-06 11:04:19 +01:00
< tr >
< td >
< div class = "d-flex align-items-center gap-3" >
< div class = "user-avatar" > ${getInitials(user.username)}< / div >
< div >
2026-02-03 15:37:16 +01:00
< div class = "fw-semibold" >
${escapeHtml(user.username)}
${user.is_superadmin ? '< span class = "badge bg-warning text-dark ms-2" > Superadmin< / span > ' : ''}
< / div >
2025-12-06 11:04:19 +01:00
${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 >
2026-02-03 15:37:16 +01:00
< td > ${groupBadges}< / td >
2025-12-06 11:04:19 +01:00
< td >
< span class = "badge ${user.is_active ? 'bg-success' : 'bg-secondary'}" >
${user.is_active ? 'Aktiv' : 'Inaktiv'}
< / span >
< / td >
2026-02-14 02:26:29 +01:00
< 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 >
2026-02-03 15:37:16 +01:00
< td > ${user.created_at ? formatDate(user.created_at) : '< span class = "text-muted" > -< / span > '}< / td >
2025-12-06 11:04:19 +01:00
< td class = "text-end" >
< div class = "btn-group btn-group-sm" >
2026-02-14 02:26:29 +01:00
< button class = "btn btn-light" onclick = "saveUserTelefoni(${user.user_id})" title = "Gem telefoni" >
< i class = "bi bi-telephone" > < / i >
< / button >
2026-02-03 15:37:16 +01:00
< 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" >
2025-12-06 11:04:19 +01:00
< i class = "bi bi-key" > < / i >
< / button >
2026-02-14 02:26:29 +01:00
< button class = "btn btn-light" onclick = "resetTwoFactor(${user.user_id})" title = "Nulstil 2FA" >
< i class = "bi bi-shield-lock" > < / i >
< / button >
2026-02-03 15:37:16 +01:00
< button class = "btn btn-light" onclick = "toggleUserActive(${user.user_id}, ${!user.is_active})"
2025-12-06 11:04:19 +01:00
title="${user.is_active ? 'Deaktiver' : 'Aktiver'}">
< i class = "bi bi-${user.is_active ? 'pause' : 'play'}-circle" > < / i >
< / button >
< / div >
< / td >
< / tr >
2026-02-03 15:37:16 +01:00
`;
}).join('');
}
2026-02-14 02:26:29 +01:00
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();
}
2026-02-03 15:37:16 +01:00
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 >
2025-12-06 11:04:19 +01:00
`).join('');
}
function showCreateUserModal() {
2026-02-03 15:37:16 +01:00
renderGroupCheckboxes('createUserGroups', []);
2025-12-06 11:04:19 +01:00
const modal = new bootstrap.Modal(document.getElementById('createUserModal'));
modal.show();
}
async function createUser() {
2026-02-03 15:37:16 +01:00
const selectedGroups = getSelectedGroupIds('createUserGroups');
const passwordValue = document.getElementById('newPassword').value;
2025-12-06 11:04:19 +01:00
const user = {
username: document.getElementById('newUsername').value,
email: document.getElementById('newEmail').value,
full_name: document.getElementById('newFullName').value || null,
2026-02-03 15:37:16 +01:00
password: passwordValue,
is_superadmin: document.getElementById('newIsSuperadmin').checked,
is_active: document.getElementById('newIsActive').checked,
group_ids: selectedGroups
2025-12-06 11:04:19 +01:00
};
2026-02-03 15:37:16 +01:00
2025-12-06 11:04:19 +01:00
try {
2026-02-03 15:37:16 +01:00
const response = await fetch('/api/v1/admin/users', {
2025-12-06 11:04:19 +01:00
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
});
2026-02-03 15:37:16 +01:00
2025-12-06 11:04:19 +01:00
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('createUserModal')).hide();
document.getElementById('createUserForm').reset();
2026-02-03 15:37:16 +01:00
renderGroupCheckboxes('createUserGroups', []);
2025-12-06 11:04:19 +01:00
loadUsers();
} else {
2026-02-03 15:37:16 +01:00
alert(await getErrorMessage(response, 'Kunne ikke oprette bruger'));
2025-12-06 11:04:19 +01:00
}
} 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/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: isActive })
});
2026-02-03 15:37:16 +01:00
2025-12-06 11:04:19 +01:00
if (response.ok) {
loadUsers();
}
} catch (error) {
console.error('Error toggling user:', error);
}
}
2026-02-03 15:37:16 +01:00
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));
}
2025-12-06 11:04:19 +01:00
async function resetPassword(userId) {
const newPassword = prompt('Indtast ny adgangskode:');
if (!newPassword) return;
try {
const response = await fetch(`/api/v1/users/${userId}/reset-password?new_password=${newPassword}`, {
method: 'POST'
});
if (response.ok) {
alert('Adgangskode nulstillet!');
}
} catch (error) {
console.error('Error resetting password:', error);
alert('Kunne ikke nulstille adgangskode');
}
}
2026-02-14 02:26:29 +01:00
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');
}
}
2025-12-06 11:04:19 +01:00
function getInitials(name) {
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
}
2025-12-08 09:15:52 +01:00
// 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');
2026-01-11 19:23:21 +01:00
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 > ` : ''}
2026-03-01 02:56:38 +01:00
< 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 >
2026-01-11 19:23:21 +01:00
< 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 >
2026-03-01 02:56:38 +01:00
< 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 >
2026-01-11 19:23:21 +01:00
< 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 >
2025-12-08 09:15:52 +01:00
< / div >
< / div >
2026-01-11 19:23:21 +01:00
`).join('')}
2025-12-08 09:15:52 +01:00
< / div >
2026-01-11 19:23:21 +01:00
`;
container.innerHTML = accordionHtml;
2025-12-08 09:15:52 +01:00
} 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 > ';
}
}
2026-01-11 19:23:21 +01:00
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');
}
}
2026-03-01 02:56:38 +01:00
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;
}
}
2026-01-11 19:23:21 +01:00
2025-12-08 09:15:52 +01:00
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);
});
}
2025-12-06 11:04:19 +01:00
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
2026-02-03 15:37:16 +01:00
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();
}
2025-12-06 11:04:19 +01:00
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;
2026-01-06 08:45:36 +01:00
// Skip external links (those without data-tab)
if (!tab) {
return; // Allow normal navigation
}
e.preventDefault();
2025-12-06 11:04:19 +01:00
// 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();
2026-02-14 02:26:29 +01:00
} else if (tab === 'telefoni') {
renderTelefoniSettings();
2025-12-08 09:15:52 +01:00
} else if (tab === 'ai-prompts') {
loadAIPrompts();
2025-12-13 12:06:28 +01:00
} else if (tab === 'modules') {
loadModules();
2025-12-06 11:04:19 +01:00
}
});
});
2025-12-13 12:06:28 +01:00
// 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 > ';
}
}
2025-12-19 08:06:56 +01:00
// 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();
}
});
}
2025-12-19 13:09:42 +01:00
// ====== SYNC MANAGEMENT ======
let syncLog = [];
async function loadSyncStats() {
try {
2026-02-22 03:27:40 +01:00
const response = await fetch('/api/v1/customers?limit=1000');
2025-12-19 13:09:42 +01:00
if (!response.ok) throw new Error('Failed to load customers');
2025-12-23 15:50:20 +01:00
const data = await response.json();
const customers = data.customers || [];
2025-12-19 13:09:42 +01:00
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();
}
2026-02-22 03:27:40 +01:00
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.';
}
return detailMessage;
}
2025-12-19 13:09:42 +01:00
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) {
2026-02-22 03:27:40 +01:00
const errorMessage = await parseApiError(response, 'Sync fejlede');
throw new Error(errorMessage);
2025-12-19 13:09:42 +01:00
}
const result = await response.json();
2025-12-19 15:19:36 +01:00
const details = [
`Behandlet: ${result.total_processed || 0}`,
2026-02-22 03:27:40 +01:00
`Linket: ${result.linked || 0}`,
2025-12-19 15:19:36 +01:00
`Opdateret: ${result.updated || 0}`,
2026-02-22 03:27:40 +01:00
`Ikke fundet/sprunget over: ${result.not_found || 0}`
2025-12-19 15:19:36 +01:00
].join(' | ');
2025-12-19 13:09:42 +01:00
addSyncLogEntry(
'vTiger Sync Fuldført',
2025-12-19 15:19:36 +01:00
details,
2025-12-19 13:09:42 +01:00
'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) {
2026-02-22 03:27:40 +01:00
const errorMessage = await parseApiError(response, 'Sync fejlede');
throw new Error(errorMessage);
2025-12-19 13:09:42 +01:00
}
const result = await response.json();
2025-12-19 15:19:36 +01:00
const details = [
`Behandlet: ${result.total_processed || 0}`,
`Oprettet: ${result.created || 0}`,
`Opdateret: ${result.updated || 0}`,
`Sprunget over: ${result.skipped || 0}`
].join(' | ');
2025-12-19 13:09:42 +01:00
addSyncLogEntry(
'vTiger Kontakt Sync Fuldført',
2025-12-19 15:19:36 +01:00
details,
2025-12-19 13:09:42 +01:00
'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) {
2026-02-22 03:27:40 +01:00
const errorMessage = await parseApiError(response, 'Sync fejlede');
throw new Error(errorMessage);
2025-12-19 13:09:42 +01:00
}
const result = await response.json();
2025-12-19 15:19:36 +01:00
const details = [
`Behandlet: ${result.total_processed || 0}`,
2026-02-22 03:27:40 +01:00
`Oprettet: ${result.created || 0}`,
`Opdateret: ${result.updated || 0}`,
`Konflikter: ${result.conflicts || 0}`,
`Sprunget over: ${result.skipped || 0}`
2025-12-19 15:19:36 +01:00
].join(' | ');
2025-12-19 13:09:42 +01:00
addSyncLogEntry(
'e-conomic Sync Fuldført',
2025-12-19 15:19:36 +01:00
details,
2025-12-19 13:09:42 +01:00
'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() {
2026-02-22 03:27:40 +01:00
showNotification('CVR→e-conomic sync er midlertidigt deaktiveret.', 'info');
return;
2025-12-19 13:09:42 +01:00
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();
2025-12-19 15:19:36 +01:00
const details = [
`Kontrolleret: ${result.checked || 0}`,
`Fundet i e-conomic: ${result.found || 0}`,
`Linket: ${result.linked || 0}`
].join(' | ');
2025-12-19 13:09:42 +01:00
addSyncLogEntry(
'CVR Søgning Fuldført',
2025-12-19 15:19:36 +01:00
details,
2025-12-19 13:09:42 +01:00
'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();
});
}
2025-12-19 13:24:16 +01:00
// 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;
}
2026-01-28 07:48:10 +01:00
// 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();
}
2025-12-06 11:04:19 +01:00
// Load on page ready
document.addEventListener('DOMContentLoaded', () => {
loadSettings();
loadUsers();
2025-12-19 08:06:56 +01:00
setupTagModalListeners();
2026-01-28 07:48:10 +01:00
loadPipelineStages();
2026-02-14 02:26:29 +01:00
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);
});
2025-12-06 11:04:19 +01:00
});
< / script >
2025-12-19 08:06:56 +01:00
<!-- 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
<!-- 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');
2026-02-03 15:37:16 +01:00
const payload = await response.json();
const customers = Array.isArray(payload) ? payload : (payload.customers || []);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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 >
2025-12-06 11:04:19 +01:00
{% endblock %}