2025-12-06 02:22:01 +01:00
{% extends "shared/frontend/base.html" %}
{% block title %}Kontakt Detaljer - BMC Hub{% endblock %}
{% block extra_css %}
< style >
.contact-header {
background: var(--accent);
color: white;
padding: 3rem 2rem;
border-radius: 12px;
margin-bottom: 2rem;
}
.contact-avatar-large {
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 2rem;
border: 3px solid rgba(255, 255, 255, 0.3);
}
.nav-pills-vertical {
border-right: 1px solid rgba(0, 0, 0, 0.1);
padding-right: 0;
}
.nav-pills-vertical .nav-link {
color: var(--text-secondary);
border-radius: 8px 0 0 8px;
padding: 1rem 1.5rem;
font-weight: 500;
margin-bottom: 0.5rem;
transition: all 0.2s;
text-align: left;
}
.nav-pills-vertical .nav-link:hover {
background: var(--accent-light);
color: var(--accent);
}
.nav-pills-vertical .nav-link.active {
background: var(--accent);
color: white;
}
.nav-pills-vertical .nav-link i {
width: 20px;
margin-right: 0.75rem;
}
.info-card {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 1.5rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-weight: 500;
color: var(--text-secondary);
}
.info-value {
color: var(--text-primary);
font-weight: 500;
}
.company-card {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 1.5rem;
transition: all 0.2s;
position: relative;
}
.company-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
2026-02-10 14:40:38 +01:00
.session-card {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 1rem;
}
.session-meta {
font-size: 0.9rem;
color: var(--text-secondary);
}
2025-12-06 02:22:01 +01:00
.remove-company-btn {
position: absolute;
top: 1rem;
right: 1rem;
}
< / style >
{% endblock %}
{% block content %}
<!-- Contact Header -->
< div class = "contact-header" >
< div class = "d-flex justify-content-between align-items-start" >
< div class = "d-flex align-items-center" >
< div class = "contact-avatar-large me-4" id = "contactAvatar" > ?< / div >
< div >
< h1 class = "fw-bold mb-2" id = "contactName" > Loading...< / h1 >
< div class = "d-flex gap-3 align-items-center" >
< span id = "contactTitle" > < / span >
< span class = "badge bg-white bg-opacity-20" id = "contactStatus" > < / span >
< / div >
< / div >
< / div >
< div class = "d-flex gap-2" >
2026-08-17 19:27:43 +02:00
< button class = "btn btn-light btn-sm" onclick = "openMergeContactModal()" title = "Flet en dubletkontakt ind i denne kontakt" >
< i class = "bi bi-intersect me-2" > < / i > Flet dublet
< / button >
2026-02-17 12:49:11 +01:00
< button class = "btn btn-warning btn-sm" onclick = "openAlertNoteForm('contact', contactId)" title = "Opret vigtig information/advarsel om denne kontakt" >
< i class = "bi bi-exclamation-triangle-fill me-2" > < / i > Alert Note
< / button >
2025-12-06 02:22:01 +01:00
< button class = "btn btn-light btn-sm" onclick = "editContact()" >
< i class = "bi bi-pencil me-2" > < / i > Rediger
< / button >
< button class = "btn btn-light btn-sm" onclick = "window.location.href='/contacts'" >
< i class = "bi bi-arrow-left me-2" > < / i > Tilbage
< / button >
< / div >
< / div >
< / div >
2026-02-17 12:49:11 +01:00
<!-- Alert Notes Container -->
< div id = "alert-notes-container" > < / div >
2025-12-06 02:22:01 +01:00
<!-- Content Layout with Sidebar Navigation -->
< div class = "row" >
< div class = "col-lg-3 col-md-4" >
<!-- Vertical Navigation -->
< ul class = "nav nav-pills nav-pills-vertical flex-column" role = "tablist" >
< li class = "nav-item" >
< a class = "nav-link active" data-bs-toggle = "tab" href = "#overview" >
< i class = "bi bi-info-circle" > < / i > Oversigt
< / a >
< / li >
< li class = "nav-item" >
< a class = "nav-link" data-bs-toggle = "tab" href = "#companies" >
< i class = "bi bi-building" > < / i > Firmaer
< / a >
< / li >
2026-02-11 23:51:21 +01:00
< li class = "nav-item" >
< a class = "nav-link" data-bs-toggle = "tab" href = "#contacts" >
< i class = "bi bi-people" > < / i > Kontakter
< / a >
< / li >
< li class = "nav-item" >
< a class = "nav-link" data-bs-toggle = "tab" href = "#invoices" >
< i class = "bi bi-receipt" > < / i > Fakturaer
< / a >
< / li >
< li class = "nav-item" >
< a class = "nav-link" data-bs-toggle = "tab" href = "#subscriptions" >
< i class = "bi bi-arrow-repeat" > < / i > Abonnnents tjek
< / a >
< / li >
< li class = "nav-item" >
< a class = "nav-link" data-bs-toggle = "tab" href = "#pipeline" >
< i class = "bi bi-diagram-3" > < / i > Kunde pipeline
< / a >
< / li >
< li class = "nav-item" >
< a class = "nav-link" data-bs-toggle = "tab" href = "#billing-matrix" >
< i class = "bi bi-table" > < / i > Abonnements Matrix
< / a >
< / li >
< li class = "nav-item" >
< a class = "nav-link" data-bs-toggle = "tab" href = "#locations" >
< i class = "bi bi-geo-alt" > < / i > Lokationer
< / a >
< / li >
< li class = "nav-item" >
< a class = "nav-link" data-bs-toggle = "tab" href = "#hardware" >
< i class = "bi bi-hdd" > < / i > Hardware
< / a >
< / li >
< li class = "nav-item d-none" id = "nextcloudTabNav" >
< a class = "nav-link" data-bs-toggle = "tab" href = "#nextcloud" >
< i class = "bi bi-cloud" > < / i > Nextcloud
< / a >
< / li >
2026-02-10 14:40:38 +01:00
< li class = "nav-item" >
< a class = "nav-link" data-bs-toggle = "tab" href = "#remote-sessions" >
< i class = "bi bi-display" > < / i > Remote Sessions
< / a >
< / li >
2026-02-11 23:51:21 +01:00
< li class = "nav-item" >
2026-02-14 02:26:29 +01:00
< a class = "nav-link" data-bs-toggle = "tab" href = "#kontakt" >
< i class = "bi bi-chat-left-text" > < / i > Kontakt
2026-02-11 23:51:21 +01:00
< / a >
< / li >
< li class = "nav-item" >
< a class = "nav-link" data-bs-toggle = "tab" href = "#conversations" >
< i class = "bi bi-mic" > < / i > Samtaler
< / a >
< / li >
2025-12-06 02:22:01 +01:00
< / ul >
< / div >
< div class = "col-lg-9 col-md-8" >
<!-- Tab Content -->
< div class = "tab-content" >
<!-- Overview Tab -->
< div class = "tab-pane fade show active" id = "overview" >
< div class = "row g-4" >
< div class = "col-lg-6" >
< div class = "info-card" >
< h5 class = "fw-bold mb-4" > Kontakt Oplysninger< / h5 >
< div class = "info-row" >
< span class = "info-label" > Navn< / span >
< span class = "info-value" id = "fullName" > -< / span >
< / div >
< div class = "info-row" >
< span class = "info-label" > Email< / span >
< span class = "info-value" id = "email" > -< / span >
< / div >
< div class = "info-row" >
< span class = "info-label" > Telefon< / span >
< span class = "info-value" id = "phone" > -< / span >
< / div >
< div class = "info-row" >
< span class = "info-label" > Mobil< / span >
< span class = "info-value" id = "mobile" > -< / span >
< / div >
< / div >
< / div >
< div class = "col-lg-6" >
< div class = "info-card" >
< h5 class = "fw-bold mb-4" > Rolle & Stilling< / h5 >
< div class = "info-row" >
< span class = "info-label" > Titel< / span >
< span class = "info-value" id = "title" > -< / span >
< / div >
< div class = "info-row" >
< span class = "info-label" > Afdeling< / span >
< span class = "info-value" id = "department" > -< / span >
< / div >
< div class = "info-row" >
< span class = "info-label" > Status< / span >
< span class = "info-value" id = "activeStatus" > -< / span >
< / div >
< div class = "info-row" >
< span class = "info-label" > Antal Firmaer< / span >
< span class = "info-value" id = "companyCount" > -< / span >
< / div >
< / div >
< / div >
< div class = "col-12" >
< div class = "info-card" >
< h5 class = "fw-bold mb-3" > System Info< / h5 >
< div class = "row" >
< div class = "col-md-6" >
< div class = "info-row" >
< span class = "info-label" > vTiger ID< / span >
< span class = "info-value" id = "vtigerId" > -< / span >
< / div >
< / div >
< div class = "col-md-6" >
< div class = "info-row" >
< span class = "info-label" > Oprettet< / span >
< span class = "info-value" id = "createdAt" > -< / span >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
<!-- Companies Tab -->
< div class = "tab-pane fade" id = "companies" >
< div class = "d-flex justify-content-between align-items-center mb-4" >
< h5 class = "fw-bold mb-0" > Tilknyttede Firmaer< / h5 >
< button class = "btn btn-primary btn-sm" onclick = "showAddCompanyModal()" >
< i class = "bi bi-plus-lg me-2" > < / i > Tilføj Firma
< / button >
< / div >
< div class = "row g-4" id = "companiesContainer" >
< div class = "col-12 text-center py-5" >
< div class = "spinner-border text-primary" > < / div >
< / div >
< / div >
< / div >
2026-02-10 14:40:38 +01:00
2026-02-11 23:51:21 +01:00
<!-- Contacts Tab -->
< div class = "tab-pane fade" id = "contacts" >
< div class = "d-flex justify-content-between align-items-center mb-4" >
< h5 class = "fw-bold mb-0" > Kontakter i samme firmaer< / h5 >
< a class = "btn btn-outline-secondary btn-sm" href = "/contacts" >
< i class = "bi bi-people me-2" > < / i > Se alle
< / a >
< / div >
< div class = "table-responsive" id = "relatedContactsContainer" >
< table class = "table table-hover align-middle mb-0" >
< thead class = "table-light" >
< tr >
< th > Navn< / th >
< th > Titel< / th >
< th > Email< / th >
< th > Telefon< / th >
< th > Firmaer< / th >
< / tr >
< / thead >
< tbody id = "relatedContactsTable" >
< tr >
< td colspan = "5" class = "text-center py-4" >
< div class = "spinner-border text-primary" > < / div >
< / td >
< / tr >
< / tbody >
< / table >
< / div >
< / div >
<!-- Invoices Tab -->
< div class = "tab-pane fade" id = "invoices" >
< h5 class = "fw-bold mb-4" > Fakturaer< / h5 >
< div class = "text-muted text-center py-5" >
Fakturamodul kommer snart...
< / div >
< / div >
<!-- Subscriptions Tab -->
< div class = "tab-pane fade" id = "subscriptions" >
< div class = "d-flex justify-content-between align-items-center mb-4" >
< h5 class = "fw-bold mb-0" > Abonnnents tjek< / h5 >
< a class = "btn btn-outline-secondary btn-sm" id = "subscriptionsCustomerLink" href = "#" >
< i class = "bi bi-box-arrow-up-right me-2" > < / i > Åbn kundedetalje
< / a >
< / div >
< div class = "card mb-4" style = "border-left: 4px solid var(--accent);" >
< div class = "card-body" >
< h6 class = "fw-bold mb-3" >
< i class = "bi bi-shield-lock me-2" > < / i > Intern Kommentar
< small class = "text-muted fw-normal" > (kun synlig for medarbejdere)< / small >
< / h6 >
< div id = "internalCommentDisplay" class = "mb-3" style = "display: none;" >
< div class = "alert alert-light mb-2" >
< div style = "white-space: pre-wrap;" id = "commentText" > < / div >
< / div >
< div class = "d-flex justify-content-between align-items-center" >
< small class = "text-muted" id = "commentMeta" > < / small >
< button type = "button" class = "btn btn-sm btn-outline-primary" onclick = "editInternalComment()" title = "Rediger kommentar" >
< i class = "bi bi-pencil me-1" > < / i > Rediger
< / button >
< / div >
< / div >
< div id = "internalCommentEdit" >
< textarea class = "form-control mb-2" id = "internalCommentInput" rows = "3" placeholder = "Skriv intern note om kundens abonnementer..." > < / textarea >
< div class = "d-flex justify-content-end gap-2" >
< button class = "btn btn-sm btn-primary" onclick = "saveInternalComment()" >
< i class = "bi bi-save me-1" > < / i > Gem Kommentar
< / button >
< / div >
< / div >
< / div >
< / div >
< div id = "subscriptionsContainer" >
< div class = "text-center py-5" >
< div class = "spinner-border text-primary" > < / div >
< p class = "text-muted mt-3" > Henter data...< / p >
< / div >
< / div >
< / div >
<!-- Pipeline Tab -->
< 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-0" > Kunde pipeline< / h5 >
< small class = "text-muted" > Muligheder knyttet til kontakten< / small >
< / div >
< a class = "btn btn-outline-secondary btn-sm" id = "pipelineCustomerLink" href = "#" >
< i class = "bi bi-box-arrow-up-right me-2" > < / i > Åbn kundedetalje
< / a >
< / div >
< div class = "card" >
< div class = "table-responsive" >
< table class = "table table-hover align-middle mb-0" >
< thead class = "table-light" >
< tr >
< th > Titel< / th >
< th > Beløb< / th >
< th > Stage< / th >
< th > Sandsynlighed< / th >
< th class = "text-end" > Handling< / th >
< / tr >
< / thead >
< tbody id = "contactOpportunitiesTable" >
< tr >
< td colspan = "5" class = "text-center py-4" >
< div class = "spinner-border text-primary" > < / div >
< / td >
< / tr >
< / tbody >
< / table >
< / div >
< / div >
< / div >
<!-- Billing Matrix Tab -->
< div class = "tab-pane fade" id = "billing-matrix" >
< div class = "d-flex justify-content-between align-items-center mb-4" >
< h5 class = "fw-bold mb-0" >
< i class = "bi bi-table me-2" > < / i > Abonnements-matrix
< small class = "text-muted fw-normal" > (fra e-conomic)< / small >
< / h5 >
< button class = "btn btn-sm btn-outline-primary" onclick = "loadBillingMatrix()" title = "Hent fakturaer fra e-conomic" >
< i class = "bi bi-arrow-repeat me-1" > < / i > Opdater
< / button >
< / div >
< div class = "mb-3" id = "matrixSearchContainer" style = "display: none;" >
< div class = "input-group" >
< span class = "input-group-text" >
< i class = "bi bi-search" > < / i >
< / span >
< input type = "text" class = "form-control" id = "matrixSearchInput" placeholder = "Søg efter produkt..." onkeyup = "filterMatrixProducts()" >
< button class = "btn btn-outline-secondary" type = "button" onclick = "clearMatrixSearch()" >
< i class = "bi bi-x" > < / i >
< / button >
< / div >
< / div >
< div id = "billingMatrixContainer" style = "display: none;" >
< div class = "table-responsive" >
< table class = "table table-sm table-hover mb-0" id = "billingMatrixTable" >
< thead class = "table-light" >
< tr id = "matrixHeaderRow" >
< th style = "min-width: 200px;" > Vare< / th >
< / tr >
< / thead >
< tbody id = "matrixBodyRows" > < / tbody >
< / table >
< / div >
< / div >
< div id = "billingMatrixLoading" class = "text-center py-5" >
< div class = "spinner-border spinner-border-sm text-primary" > < / div >
< p class = "text-muted mt-2" > Henter fakturamatrix fra e-conomic...< / p >
< / div >
< div id = "billingMatrixEmpty" style = "display: none;" class = "text-center py-5" >
< p class = "text-muted" > Ingen fakturaer fundet for denne kontakt< / p >
< / div >
< / div >
<!-- Locations Tab -->
< div class = "tab-pane fade" id = "locations" >
< div class = "d-flex justify-content-between align-items-center mb-4" >
< div >
< h5 class = "fw-bold mb-0" > Lokationer< / h5 >
< small class = "text-muted" > Lokationer knyttet til kontaktens firmaer< / small >
< / div >
< a class = "btn btn-outline-secondary btn-sm" id = "locationsCustomerLink" href = "#" >
< i class = "bi bi-box-arrow-up-right me-2" > < / i > Åbn kundedetalje
< / a >
< / div >
< div id = "contactLocationsList" class = "list-group mb-3" > < / div >
< div id = "contactLocationsEmpty" class = "text-center py-5 text-muted" >
Ingen lokationer fundet for denne kontakt
< / div >
< / div >
<!-- Hardware Tab -->
< div class = "tab-pane fade" id = "hardware" >
< div class = "d-flex justify-content-between align-items-center mb-4" >
< div >
< h5 class = "fw-bold mb-0" > Hardware< / h5 >
2026-02-14 02:26:29 +01:00
< small class = "text-muted" > Kun hardware knyttet direkte til denne kontakt< / small >
< / div >
< div class = "d-flex gap-2" >
< a class = "btn btn-outline-secondary btn-sm" href = "/hardware/new" >
< i class = "bi bi-plus-lg me-2" > < / i > Nyt hardware
< / a >
< button class = "btn btn-outline-primary btn-sm" onclick = "loadUnassignedHardware()" >
< i class = "bi bi-search me-2" > < / i > Gennemgå hardware uden ejer
< / button >
2026-02-11 23:51:21 +01:00
< / div >
< / div >
< div class = "table-responsive" id = "contactHardwareContainer" >
< table class = "table table-hover align-middle mb-0" >
< thead class = "table-light" >
< tr >
< th > Hardware< / th >
< th > Type< / th >
< th > Serienr.< / th >
< th > Lokation< / th >
< th > Status< / th >
< th class = "text-end" > Handling< / th >
< / tr >
< / thead >
< tbody id = "contactHardwareTable" >
< tr >
< td colspan = "6" class = "text-center py-4" >
< div class = "spinner-border text-primary" > < / div >
< / td >
< / tr >
< / tbody >
< / table >
< / div >
< div id = "contactHardwareEmpty" class = "text-center py-5 text-muted d-none" >
Ingen hardware fundet for denne kontakt
< / div >
2026-02-14 02:26:29 +01:00
< div class = "mt-4" >
< h6 class = "fw-bold mb-2" > Hardware uden ejer< / h6 >
< div class = "small text-muted mb-3" > Gennemgå og tilknyt hardware til denne kontakt.< / div >
< div class = "table-responsive d-none" id = "unassignedHardwareContainer" >
< table class = "table table-sm table-hover align-middle mb-0" >
< thead class = "table-light" >
< tr >
< th > Hardware< / th >
< th > Type< / th >
< th > Serienr.< / th >
< th > Status< / th >
< th class = "text-end" > Handling< / th >
< / tr >
< / thead >
< tbody id = "unassignedHardwareTable" > < / tbody >
< / table >
< / div >
< div id = "unassignedHardwareEmpty" class = "text-center py-3 text-muted d-none" >
Ingen hardware uden ejer fundet
< / div >
< / div >
2026-02-11 23:51:21 +01:00
< / div >
<!-- Nextcloud Tab -->
< div class = "tab-pane fade d-none" id = "nextcloud" >
< div class = "row g-4" id = "nextcloudTabContent" >
< div class = "col-lg-6" >
< div class = "info-card" >
< div class = "d-flex justify-content-between align-items-start" >
< h5 class = "fw-bold mb-3" > Systemstatus< / h5 >
< a class = "btn btn-sm btn-outline-secondary" id = "nextcloudCustomerLink" href = "#" >
< i class = "bi bi-box-arrow-up-right" > < / i >
< / a >
< / div >
< div id = "ncStatusBadge" class = "badge bg-secondary" > Ukendt< / div >
< div class = "mt-2 small text-muted" id = "ncLastUpdated" > -< / div >
< div class = "mt-3" >
< div class = "info-row" > < span class = "info-label" > CPU load< / span > < span class = "info-value" id = "ncCpuLoad" > -< / span > < / div >
< div class = "info-row" > < span class = "info-label" > Free disk< / span > < span class = "info-value" id = "ncFreeDisk" > -< / span > < / div >
< div class = "info-row" > < span class = "info-label" > RAM usage< / span > < span class = "info-value" id = "ncRamUsage" > -< / span > < / div >
< div class = "info-row" > < span class = "info-label" > OPCache hit rate< / span > < span class = "info-value" id = "ncOpcache" > -< / span > < / div >
< / div >
< div class = "mt-3 d-flex flex-wrap gap-2" id = "ncAlerts" > < / div >
< / div >
< / div >
< div class = "col-lg-6" >
< div class = "info-card" >
< h5 class = "fw-bold mb-3" > Nøgletal< / h5 >
< div class = "info-row" > < span class = "info-label" > File count growth< / span > < span class = "info-value" id = "ncFileGrowth" > -< / span > < / div >
< div class = "info-row" > < span class = "info-label" > Public shares uden password< / span > < span class = "info-value" id = "ncPublicShares" > -< / span > < / div >
< div class = "info-row" > < span class = "info-label" > Active users< / span > < span class = "info-value" id = "ncActiveUsers" > -< / span > < / div >
< / div >
< / div >
< div class = "col-12" >
< div class = "info-card" >
< h5 class = "fw-bold mb-3" > Historik< / h5 >
< div id = "ncHistory" > Ingen events endnu.< / div >
< / div >
< / div >
< / div >
< / div >
2026-02-10 14:40:38 +01:00
<!-- Remote Sessions Tab -->
< div class = "tab-pane fade" id = "remote-sessions" >
< div class = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4" >
< h5 class = "fw-bold mb-0" > Remote Sessions< / h5 >
< div class = "d-flex flex-wrap gap-2" >
< select class = "form-select form-select-sm" id = "sessionCompanySelect" style = "min-width: 200px;" > < / select >
< input type = "number" class = "form-control form-control-sm" id = "sessionSagIdInput" placeholder = "Sag ID (valgfri)" style = "max-width: 140px;" >
< button class = "btn btn-primary btn-sm" onclick = "startRemoteSession()" >
< i class = "bi bi-play-circle me-2" > < / i > Start Session
< / button >
< button class = "btn btn-outline-secondary btn-sm" onclick = "loadRemoteSessions()" >
< i class = "bi bi-arrow-repeat me-2" > < / i > Opdater
< / button >
< / div >
< / div >
< div id = "remoteSessionAlert" class = "alert alert-info d-none" > < / div >
< div class = "row g-3" id = "remoteSessionsContainer" >
< div class = "col-12 text-center py-5" >
< div class = "spinner-border text-primary" > < / div >
< / div >
< / div >
< / div >
2026-02-11 23:51:21 +01:00
2026-02-14 02:26:29 +01:00
<!-- Kontakt Tab -->
< div class = "tab-pane fade" id = "kontakt" >
< div class = "d-flex justify-content-between align-items-center mb-4" >
< h5 class = "fw-bold mb-0" > Kontakt historik< / h5 >
< div class = "btn-group btn-group-sm" role = "group" aria-label = "Kontakt filter" >
< button type = "button" class = "btn btn-outline-secondary active" id = "kontaktFilterAll" onclick = "setKontaktFilter('all')" > Alle< / button >
< button type = "button" class = "btn btn-outline-secondary" id = "kontaktFilterSms" onclick = "setKontaktFilter('sms')" > SMS< / button >
< button type = "button" class = "btn btn-outline-secondary" id = "kontaktFilterCall" onclick = "setKontaktFilter('call')" > Opkald< / button >
< / div >
< / div >
< div id = "kontaktContainer" >
2026-02-11 23:51:21 +01:00
< div class = "text-center py-5" >
< div class = "spinner-border text-primary" > < / div >
< / div >
< / div >
< / div >
<!-- Conversations Tab -->
< div class = "tab-pane fade" id = "conversations" >
< div class = "d-flex justify-content-between align-items-center mb-4" >
< h5 class = "fw-bold mb-0" > Samtaler< / h5 >
< a class = "btn btn-outline-secondary btn-sm" id = "conversationsCustomerLink" href = "#" >
< i class = "bi bi-box-arrow-up-right me-2" > < / i > Åbn kundedetalje
< / a >
< / div >
< div class = "input-group mb-4" >
< span class = "input-group-text" > < i class = "bi bi-search" > < / i > < / span >
< input type = "text" class = "form-control" id = "conversationSearch" placeholder = "Søg i transskriberinger..." onkeyup = "filterConversations()" >
< / div >
< div id = "conversationsContainer" >
< div class = "text-center py-5" >
< span class = "text-muted" > Henter samtaler...< / span >
< / div >
< / div >
< / div >
2025-12-06 02:22:01 +01:00
< / div >
< / div >
< / div >
<!-- Add Company Modal -->
< div class = "modal fade" id = "addCompanyModal" tabindex = "-1" >
< div class = "modal-dialog" >
< div class = "modal-content" >
< div class = "modal-header" >
< h5 class = "modal-title" > Tilføj Firma til Kontakt< / h5 >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
< form id = "addCompanyForm" >
< div class = "mb-3" >
< label class = "form-label" > Vælg Firma< / label >
< select class = "form-select" id = "companySelectModal" required >
< option value = "" > Vælg et firma...< / option >
<!-- Populated dynamically -->
< / select >
< / div >
< div class = "mb-3" >
< label class = "form-label" > Rolle< / label >
< input type = "text" class = "form-control" id = "roleInputModal" placeholder = "Primær kontakt, Fakturering..." >
< / div >
< div class = "mb-3" >
< label class = "form-label" > Noter< / label >
< textarea class = "form-control" id = "notesInputModal" rows = "3" > < / textarea >
< / div >
< div class = "mb-3" >
< div class = "form-check" >
< input class = "form-check-input" type = "checkbox" id = "isPrimaryInputModal" >
< label class = "form-check-label" for = "isPrimaryInputModal" >
Primær kontakt for dette firma
< / label >
< / div >
< / div >
< / form >
< / div >
< div class = "modal-footer" >
< button type = "button" class = "btn btn-secondary" data-bs-dismiss = "modal" > Annuller< / button >
< button type = "button" class = "btn btn-primary" onclick = "addCompanyToContact()" >
< i class = "bi bi-plus-lg me-2" > < / i > Tilføj
< / button >
< / div >
< / div >
< / div >
< / div >
2026-02-06 10:47:14 +01:00
2026-08-17 19:27:43 +02:00
<!-- Merge duplicate contact modal -->
< div class = "modal fade" id = "mergeContactModal" tabindex = "-1" aria-hidden = "true" >
< div class = "modal-dialog modal-lg modal-dialog-scrollable" >
< div class = "modal-content" >
< div class = "modal-header" >
< div >
< h5 class = "modal-title" > < i class = "bi bi-intersect me-2" > < / i > Flet dubletkontakt< / h5 >
< div class = "small text-muted" > Denne kontakt beholdes som hovedkontakt. Den valgte dublet slettes efter flytning.< / div >
< / div >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
< div class = "alert alert-primary py-2" >
< strong > Hovedkontakt:< / strong > < span id = "mergeTargetName" > -< / span >
< / div >
< label class = "form-label fw-semibold" for = "mergeContactSearch" > Find dublet< / label >
< div class = "input-group mb-3" >
< span class = "input-group-text" > < i class = "bi bi-search" > < / i > < / span >
< input id = "mergeContactSearch" class = "form-control" type = "search" placeholder = "Søg på navn, e-mail, telefon eller firma" autocomplete = "off" >
< / div >
< div id = "mergeContactResults" class = "list-group mb-3" > < / div >
< div id = "mergeContactPreview" class = "d-none" >
< div class = "border rounded p-3" >
< div class = "d-flex justify-content-between align-items-start gap-3 mb-3" >
< div > < div class = "small text-muted" > Dublet der fjernes< / div > < strong id = "mergeSourceName" > < / strong > < div class = "small text-muted" id = "mergeSourceDetails" > < / div > < / div >
< span class = "badge text-bg-warning" > Kan ikke fortrydes direkte< / span >
< / div >
< div class = "small fw-semibold mb-2" > Relateret information der flyttes< / div >
< div id = "mergeRelationCounts" class = "d-flex flex-wrap gap-2" > < / div >
< / div >
< / div >
< / div >
< div class = "modal-footer" >
< button type = "button" class = "btn btn-secondary" data-bs-dismiss = "modal" > Annuller< / button >
< button id = "mergeContactSubmit" type = "button" class = "btn btn-danger" onclick = "submitContactMerge()" disabled >
< i class = "bi bi-intersect me-2" > < / i > Flet kontakter
< / button >
< / div >
< / div >
< / div >
< / div >
2026-02-06 10:47:14 +01:00
<!-- Edit Contact Modal -->
< div class = "modal fade" id = "editContactModal" tabindex = "-1" >
< div class = "modal-dialog modal-lg" >
< div class = "modal-content" >
< div class = "modal-header" >
< h5 class = "modal-title" > Rediger Kontakt< / h5 >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
< form id = "editContactForm" >
< div class = "row g-3" >
< div class = "col-md-6" >
< label class = "form-label" > Fornavn < span class = "text-danger" > *< / span > < / label >
< input type = "text" class = "form-control" id = "editFirstNameInput" required >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Efternavn < span class = "text-danger" > *< / span > < / label >
< input type = "text" class = "form-control" id = "editLastNameInput" required >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Email< / label >
< input type = "email" class = "form-control" id = "editEmailInput" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Telefon< / label >
< input type = "text" class = "form-control" id = "editPhoneInput" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Mobil< / label >
< input type = "text" class = "form-control" id = "editMobileInput" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Titel< / label >
< input type = "text" class = "form-control" id = "editTitleInput" placeholder = "CEO, CTO, Manager..." >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Afdeling< / label >
< input type = "text" class = "form-control" id = "editDepartmentInput" >
< / div >
< div class = "col-12" >
< div class = "form-check" >
< input class = "form-check-input" type = "checkbox" id = "editIsActiveInput" >
< label class = "form-check-label" for = "editIsActiveInput" >
Aktiv kontakt
< / label >
< / div >
< / div >
< / div >
< / form >
< / div >
< div class = "modal-footer" >
< button type = "button" class = "btn btn-secondary" data-bs-dismiss = "modal" > Annuller< / button >
< button type = "button" class = "btn btn-primary" onclick = "saveEditContact()" >
< i class = "bi bi-check-lg me-2" > < / i > Gem Ændringer
< / button >
< / div >
< / div >
< / div >
< / div >
2026-02-10 14:40:38 +01:00
<!-- Worklog Suggestion Modal -->
< div class = "modal fade" id = "worklogSuggestionModal" tabindex = "-1" >
< div class = "modal-dialog" >
< div class = "modal-content" >
< div class = "modal-header" >
< h5 class = "modal-title" > Forslag til tidsregistrering< / h5 >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" id = "worklogSuggestionBody" > < / div >
< div class = "modal-footer" >
< button type = "button" class = "btn btn-secondary" data-bs-dismiss = "modal" > Luk< / button >
< / div >
< / div >
< / div >
< / div >
2025-12-06 02:22:01 +01:00
{% endblock %}
{% block extra_js %}
2026-02-17 12:49:11 +01:00
<!-- Alert Notes Components -->
< script src = "/static/alert_notes/alert_check.js" > < / script >
{% include "alert_notes/frontend/alert_box.html" %}
{% include "alert_notes/frontend/alert_modal.html" %}
{% include "alert_notes/frontend/alert_form_modal.html" %}
2025-12-06 02:22:01 +01:00
< script >
const contactId = parseInt(window.location.pathname.split('/').pop());
let contactData = null;
2026-02-11 23:51:21 +01:00
let primaryCustomerId = null;
2026-02-14 02:26:29 +01:00
let kontaktHistoryItems = [];
let kontaktHistoryFilter = 'all';
2026-08-17 19:27:43 +02:00
let mergeSourceContactId = null;
let mergeSearchTimer = null;
2025-12-06 02:22:01 +01:00
document.addEventListener('DOMContentLoaded', () => {
loadContact();
loadCompaniesForSelect();
2026-08-17 19:27:43 +02:00
const mergeSearch = document.getElementById('mergeContactSearch');
if (mergeSearch) {
mergeSearch.addEventListener('input', () => {
clearTimeout(mergeSearchTimer);
mergeSearchTimer = setTimeout(() => searchMergeContacts(mergeSearch.value), 250);
});
}
2025-12-06 02:22:01 +01:00
// Load companies when tab is shown
document.querySelector('a[href="#companies"]').addEventListener('shown.bs.tab', () => {
loadCompanies();
});
2026-02-10 14:40:38 +01:00
const remoteSessionsTab = document.querySelector('a[href="#remote-sessions"]');
if (remoteSessionsTab) {
remoteSessionsTab.addEventListener('shown.bs.tab', () => {
loadRemoteSessions();
});
}
2026-02-11 23:51:21 +01:00
const contactsTab = document.querySelector('a[href="#contacts"]');
if (contactsTab) {
contactsTab.addEventListener('shown.bs.tab', () => {
loadRelatedContacts();
});
}
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
if (subscriptionsTab) {
subscriptionsTab.addEventListener('shown.bs.tab', () => {
loadContactSubscriptions();
});
}
const pipelineTab = document.querySelector('a[href="#pipeline"]');
if (pipelineTab) {
pipelineTab.addEventListener('shown.bs.tab', () => {
loadContactOpportunities();
});
}
const billingMatrixTab = document.querySelector('a[href="#billing-matrix"]');
if (billingMatrixTab) {
billingMatrixTab.addEventListener('shown.bs.tab', () => {
loadBillingMatrix();
});
}
const locationsTab = document.querySelector('a[href="#locations"]');
if (locationsTab) {
locationsTab.addEventListener('shown.bs.tab', () => {
loadContactLocations();
});
}
const hardwareTab = document.querySelector('a[href="#hardware"]');
if (hardwareTab) {
hardwareTab.addEventListener('shown.bs.tab', () => {
loadContactHardware();
});
}
const nextcloudTab = document.querySelector('a[href="#nextcloud"]');
if (nextcloudTab) {
nextcloudTab.addEventListener('shown.bs.tab', () => {
loadNextcloudStatus();
});
}
2026-02-14 02:26:29 +01:00
const kontaktTab = document.querySelector('a[href="#kontakt"]');
if (kontaktTab) {
kontaktTab.addEventListener('shown.bs.tab', () => {
loadKontaktHistory();
2026-02-11 23:51:21 +01:00
});
}
const conversationsTab = document.querySelector('a[href="#conversations"]');
if (conversationsTab) {
conversationsTab.addEventListener('shown.bs.tab', () => {
loadConversations();
});
}
2025-12-06 02:22:01 +01:00
});
async function loadContact() {
try {
const response = await fetch(`/api/v1/contacts/${contactId}`);
if (!response.ok) {
throw new Error('Contact not found');
}
contactData = await response.json();
displayContact(contactData);
} catch (error) {
console.error('Failed to load contact:', error);
alert('Kunne ikke indlæse kontakt');
window.location.href = '/contacts';
}
}
function displayContact(contact) {
// Update page title
document.title = `${contact.first_name} ${contact.last_name} - BMC Hub`;
// Header
const initials = getInitials(contact.first_name, contact.last_name);
document.getElementById('contactAvatar').textContent = initials;
document.getElementById('contactName').textContent = `${contact.first_name} ${contact.last_name}`;
document.getElementById('contactTitle').textContent = contact.title || '';
const statusBadge = contact.is_active
? '< i class = "bi bi-check-circle me-1" > < / i > Aktiv'
: '< i class = "bi bi-x-circle me-1" > < / i > Inaktiv';
document.getElementById('contactStatus').innerHTML = statusBadge;
// Contact Information
document.getElementById('fullName').textContent = `${contact.first_name} ${contact.last_name}`;
document.getElementById('email').textContent = contact.email || '-';
2026-02-14 02:26:29 +01:00
document.getElementById('phone').innerHTML = renderNumberActions(contact.phone, false, contact);
document.getElementById('mobile').innerHTML = renderNumberActions(contact.mobile, true, contact);
2025-12-06 02:22:01 +01:00
// Role & Position
document.getElementById('title').textContent = contact.title || '-';
document.getElementById('department').textContent = contact.department || '-';
document.getElementById('activeStatus').innerHTML = contact.is_active
? '< span class = "badge bg-success" > Aktiv< / span > '
: '< span class = "badge bg-secondary" > Inaktiv< / span > ';
document.getElementById('companyCount').textContent = contact.companies ? contact.companies.length : 0;
2026-02-10 14:40:38 +01:00
populateSessionCompanySelect(contact);
2026-02-11 23:51:21 +01:00
const primaryCompany = getPrimaryCompany(contact);
primaryCustomerId = primaryCompany ? primaryCompany.id : null;
updateCustomerLinks(primaryCustomerId);
toggleNextcloudTab(primaryCustomerId);
2025-12-06 02:22:01 +01:00
// System Info
document.getElementById('vtigerId').textContent = contact.vtiger_id || '-';
document.getElementById('createdAt').textContent = new Date(contact.created_at).toLocaleString('da-DK');
// Load companies if tab is active
if (document.querySelector('a[href="#companies"]').classList.contains('active')) {
displayCompanies(contact.companies);
}
2026-02-17 12:49:11 +01:00
// Load Alert Notes for this contact
loadAndDisplayAlerts('contact', contact.id, 'inline', 'alert-notes-container');
2025-12-06 02:22:01 +01:00
}
2026-02-14 02:26:29 +01:00
function renderNumberActions(number, allowSms = false, contact = null) {
const clean = String(number || '').trim();
if (!clean) return '-';
const contactLabel = contact ? `${contact.first_name || ''} ${contact.last_name || ''}`.trim() : '';
return `
< div class = "d-flex gap-2 align-items-center justify-content-end flex-wrap" >
< span > ${escapeHtml(clean)}< / span >
< button type = "button" class = "btn btn-sm btn-outline-success" onclick = "contactDetailCallViaYealink('${escapeHtml(clean)}')" > Ring op< / button >
${allowSms ? `< button type = "button" class = "btn btn-sm btn-outline-primary" onclick = "openSmsPrompt('${escapeHtml(clean)}', '${escapeHtml(contactLabel)}', ${contact?.id || 'null'})" > SMS< / button > ` : ''}
< / div >
`;
}
let contactDetailCurrentUserId = null;
async function ensureContactDetailCurrentUserId() {
if (contactDetailCurrentUserId !== null) return contactDetailCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
contactDetailCurrentUserId = Number(me?.id) || null;
return contactDetailCurrentUserId;
} catch (e) {
return null;
}
}
async function contactDetailCallViaYealink(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureContactDetailCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
2026-02-10 14:40:38 +01:00
function populateSessionCompanySelect(contact) {
const select = document.getElementById('sessionCompanySelect');
if (!select) return;
const companies = contact.companies || [];
if (companies.length === 0) {
select.innerHTML = '< option value = "" > Ingen firmaer tilknyttet< / option > ';
select.disabled = true;
return;
}
const primary = companies.find(c => c.is_primary) || companies[0];
select.innerHTML = companies.map(c => {
const label = c.is_primary ? `${c.name} (Primær)` : c.name;
return `< option value = "${c.id}" > ${escapeHtml(label)}< / option > `;
}).join('');
select.value = String(primary.id);
select.disabled = false;
}
2026-02-11 23:51:21 +01:00
function getPrimaryCompany(contact) {
const companies = contact.companies || [];
return companies.find(c => c.is_primary) || companies[0] || null;
}
function updateCustomerLinks(customerId) {
const linkIds = [
'subscriptionsCustomerLink',
'pipelineCustomerLink',
'locationsCustomerLink',
'hardwareCustomerLink',
'nextcloudCustomerLink',
'conversationsCustomerLink'
];
linkIds.forEach(id => {
const link = document.getElementById(id);
if (!link) return;
if (customerId) {
link.href = `/customers/${customerId}`;
link.classList.remove('d-none');
} else {
link.href = '#';
link.classList.add('d-none');
}
});
}
function toggleNextcloudTab(customerId) {
const nav = document.getElementById('nextcloudTabNav');
const pane = document.getElementById('nextcloud');
if (!nav || !pane) return;
if (customerId) {
nav.classList.remove('d-none');
pane.classList.remove('d-none');
} else {
nav.classList.add('d-none');
pane.classList.add('d-none');
}
}
2025-12-06 02:22:01 +01:00
async function loadCompanies() {
if (!contactData) return;
displayCompanies(contactData.companies);
}
function displayCompanies(companies) {
const container = document.getElementById('companiesContainer');
if (!companies || companies.length === 0) {
container.innerHTML = '< div class = "col-12 text-center py-5 text-muted" > Ingen firmaer tilknyttet< / div > ';
return;
}
container.innerHTML = companies.map(company => `
< div class = "col-md-6" >
< div class = "company-card" >
< button class = "btn btn-sm btn-danger remove-company-btn" onclick = "removeCompany(${company.id})" title = "Fjern firma" >
< i class = "bi bi-x-lg" > < / i >
< / button >
< div class = "d-flex align-items-start mb-3" >
< div class = "flex-grow-1" >
< h6 class = "fw-bold mb-1" >
< a href = "/customers/${company.id}" class = "text-decoration-none" > ${escapeHtml(company.name)}< / a >
< / h6 >
${company.is_primary ? '< span class = "badge bg-primary" > Primær Kontakt< / span > ' : ''}
< / div >
< / div >
${company.role ? `
< div class = "mb-2" >
< small class = "text-muted" > Rolle:< / small >
< div > ${escapeHtml(company.role)}< / div >
< / div >
` : ''}
${company.notes ? `
< div >
< small class = "text-muted" > Noter:< / small >
< div class = "small" > ${escapeHtml(company.notes)}< / div >
< / div >
` : ''}
< / div >
< / div >
`).join('');
}
2026-02-11 23:51:21 +01:00
async function loadRelatedContacts() {
const tableBody = document.getElementById('relatedContactsTable');
if (!tableBody) return;
tableBody.innerHTML = `
< tr >
< td colspan = "5" class = "text-center py-4" >
< div class = "spinner-border text-primary" > < / div >
< / td >
< / tr >
`;
try {
const response = await fetch(`/api/v1/contacts/${contactId}/related-contacts`);
if (!response.ok) {
throw new Error('Kunne ikke hente kontakter');
}
const data = await response.json();
const contacts = data.contacts || [];
if (!contacts.length) {
tableBody.innerHTML = `
< tr >
< td colspan = "5" class = "text-center text-muted py-4" > Ingen andre kontakter fundet.< / td >
< / tr >
`;
return;
}
tableBody.innerHTML = contacts.map(c => {
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
const companies = (c.company_names || []).join(', ');
2026-02-14 02:26:29 +01:00
const phoneOrMobile = c.mobile
? `< div class = "d-flex align-items-center gap-2 flex-wrap" > < span > ${escapeHtml(c.mobile)}< / span > < button type = "button" class = "btn btn-sm btn-outline-primary" onclick = "openSmsPrompt('${escapeHtml(c.mobile)}', '${escapeHtml(name || '')}', ${c.id || 'null'})" > SMS< / button > < / div > `
: escapeHtml(c.phone || '—');
2026-02-11 23:51:21 +01:00
return `
< tr >
< td > < a href = "/contacts/${c.id}" class = "text-decoration-none" > ${escapeHtml(name || 'Ukendt')}< / a > < / td >
< td > ${escapeHtml(c.title || '—')}< / td >
< td > ${escapeHtml(c.email || '—')}< / td >
2026-02-14 02:26:29 +01:00
< td > ${phoneOrMobile}< / td >
2026-02-11 23:51:21 +01:00
< td > ${escapeHtml(companies || '—')}< / td >
< / tr >
`;
}).join('');
} catch (error) {
console.error('Failed to load related contacts:', error);
tableBody.innerHTML = `
< tr >
< td colspan = "5" class = "text-center text-danger py-4" > ${error.message}< / td >
< / tr >
`;
}
}
async function loadContactSubscriptions() {
const container = document.getElementById('subscriptionsContainer');
if (!container) return;
container.innerHTML = `
< div class = "text-center py-5" >
< div class = "spinner-border text-primary" > < / div >
< p class = "text-muted mt-3" > Henter data...< / p >
< / div >
`;
await loadInternalComment();
try {
const response = await fetch(`/api/v1/contacts/${contactId}/subscriptions`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Kunne ikke hente abonnementer');
}
if (data.status === 'no_linked_customer') {
container.innerHTML = `< div class = "text-muted text-center py-5" > ${data.message}< / div > `;
return;
}
const sections = [
{
title: 'Aktive abonnementer',
items: data.subscriptions || [],
empty: 'Ingen aktive abonnementer',
render: renderSubscriptionItem
},
{
title: 'Udlobne abonnementer',
items: data.expired_subscriptions || [],
empty: 'Ingen udlobne abonnementer',
render: renderSubscriptionItem
},
{
title: 'Recurring ordrer',
items: data.recurring_orders || [],
empty: 'Ingen recurring ordrer',
render: renderSalesOrderItem
},
{
title: 'Simply-CRM ordrer',
items: data.sales_orders || [],
empty: 'Ingen ordrer fundet',
render: renderSalesOrderItem
},
{
title: 'BMC Office abonnementer',
items: data.bmc_office_subscriptions || [],
empty: 'Ingen BMC Office abonnementer',
render: renderOfficeSubscriptionItem
}
];
container.innerHTML = sections.map(section => renderSubscriptionSection(section)).join('');
} catch (error) {
console.error('Failed to load subscriptions:', error);
container.innerHTML = `< div class = "alert alert-danger" > ${error.message}< / div > `;
}
}
function renderSubscriptionSection({ title, items, empty, render }) {
const count = items.length;
const content = count ? items.map(render).join('') : `< div class = "text-muted" > ${empty}< / div > `;
return `
< div class = "mb-4" >
< div class = "d-flex justify-content-between align-items-center mb-2" >
< h6 class = "fw-bold mb-0" > ${title}< / h6 >
< span class = "badge bg-light text-dark" > ${count}< / span >
< / div >
< div class = "list-group" >
${content}
< / div >
< / div >
`;
}
function renderSubscriptionItem(item) {
const name = getField(item, ['subject', 'subscriptionname', 'name'], 'Abonnement');
const status = getField(item, ['sub_status', 'subscriptionstatus', 'status'], '—');
const start = formatDate(getField(item, ['start_date', 'startdate']));
const end = formatDate(getField(item, ['end_date', 'enddate']));
return `
< div class = "list-group-item" >
< div class = "fw-semibold" > ${escapeHtml(name)}< / div >
< div class = "small text-muted" > Status: ${escapeHtml(status)}${start ? ` • Start: ${start}` : ''}${end ? ` • Slut: ${end}` : ''}< / div >
< / div >
`;
}
function renderSalesOrderItem(item) {
const name = getField(item, ['subject', 'title', 'name'], 'Ordre');
const status = getField(item, ['sostatus', 'status'], '—');
const frequency = getField(item, ['recurring_frequency', 'frequency'], '');
const total = getField(item, ['total', 'total_amount', 'netprice', 'amount'], '');
const totalLabel = total ? ` • Total: ${formatCurrency(total)}` : '';
const freqLabel = frequency ? ` • ${escapeHtml(frequency)}` : '';
return `
< div class = "list-group-item" >
< div class = "fw-semibold" > ${escapeHtml(name)}< / div >
< div class = "small text-muted" > Status: ${escapeHtml(status)}${freqLabel}${totalLabel}< / div >
< / div >
`;
}
function renderOfficeSubscriptionItem(item) {
const name = getField(item, ['product_name', 'name', 'title'], 'Abonnement');
const start = formatDate(getField(item, ['start_date', 'startdate']));
const end = formatDate(getField(item, ['end_date', 'enddate']));
const amount = getField(item, ['amount', 'total_amount', 'monthly_total'], '');
const amountLabel = amount ? ` • ${formatCurrency(amount)}` : '';
return `
< div class = "list-group-item" >
< div class = "fw-semibold" > ${escapeHtml(name)}< / div >
< div class = "small text-muted" > ${start ? `Start: ${start}` : 'Start: —'}${end ? ` • Slut: ${end}` : ''}${amountLabel}< / div >
< / div >
`;
}
async function loadInternalComment() {
const displayDiv = document.getElementById('internalCommentDisplay');
const editDiv = document.getElementById('internalCommentEdit');
const commentText = document.getElementById('commentText');
const commentMeta = document.getElementById('commentMeta');
if (!displayDiv || !editDiv || !commentText || !commentMeta) return;
try {
const response = await fetch(`/api/v1/contacts/${contactId}/subscription-comment`);
if (!response.ok) {
displayDiv.style.display = 'none';
editDiv.style.display = 'block';
return;
}
const data = await response.json();
commentText.textContent = data.comment || '';
const timestamp = data.created_at ? new Date(data.created_at).toLocaleString('da-DK') : '';
commentMeta.textContent = `Oprettet af ${data.created_by || 'System'} • ${timestamp}`;
displayDiv.style.display = 'block';
editDiv.style.display = 'none';
} catch (error) {
displayDiv.style.display = 'none';
editDiv.style.display = 'block';
}
}
async function saveInternalComment() {
const commentInput = document.getElementById('internalCommentInput');
if (!commentInput) return;
try {
const response = await fetch(`/api/v1/contacts/${contactId}/subscription-comment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment: commentInput.value || '' })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke gemme kommentar');
}
await loadInternalComment();
} catch (error) {
alert('Fejl: ' + error.message);
}
}
function editInternalComment() {
const commentText = document.getElementById('commentText').textContent;
const commentInput = document.getElementById('internalCommentInput');
const displayDiv = document.getElementById('internalCommentDisplay');
const editDiv = document.getElementById('internalCommentEdit');
commentInput.value = commentText;
editDiv.style.display = 'block';
displayDiv.style.display = 'none';
}
async function loadContactOpportunities() {
const tableBody = document.getElementById('contactOpportunitiesTable');
if (!tableBody) return;
tableBody.innerHTML = `
< tr >
< td colspan = "5" class = "text-center py-4" >
< div class = "spinner-border text-primary" > < / div >
< / td >
< / tr >
`;
try {
const response = await fetch(`/api/v1/opportunities?contact_id=${contactId}`);
if (!response.ok) {
throw new Error('Kunne ikke hente pipeline');
}
const opportunities = await response.json();
if (!Array.isArray(opportunities) || opportunities.length === 0) {
tableBody.innerHTML = `
< tr >
< td colspan = "5" class = "text-center text-muted py-4" > Ingen muligheder fundet.< / td >
< / tr >
`;
return;
}
tableBody.innerHTML = opportunities.map(o => {
const amount = o.amount ? formatCurrency(o.amount, o.currency) : '—';
const stage = o.stage_name || '—';
const probability = o.probability ? `${o.probability}%` : '—';
return `
< tr >
< td > ${escapeHtml(o.title || '—')}< / td >
< td > ${amount}< / td >
< td > ${escapeHtml(stage)}< / td >
< td > ${probability}< / td >
< td class = "text-end" >
2026-07-17 01:58:02 +02:00
< a class = "btn btn-sm btn-outline-primary" href = "/sag/${o.id}/v3" >
2026-02-11 23:51:21 +01:00
< i class = "bi bi-eye" > < / i >
< / a >
< / td >
< / tr >
`;
}).join('');
} catch (error) {
console.error('Failed to load opportunities:', error);
tableBody.innerHTML = `
< tr >
< td colspan = "5" class = "text-center text-danger py-4" > ${error.message}< / td >
< / tr >
`;
}
}
async function loadContactLocations() {
const list = document.getElementById('contactLocationsList');
const empty = document.getElementById('contactLocationsEmpty');
if (!list || !empty) return;
list.innerHTML = `
< div class = "text-center py-4" >
< div class = "spinner-border text-primary" > < / div >
< / div >
`;
empty.classList.add('d-none');
try {
const response = await fetch(`/api/v1/locations/by-contact/${contactId}`);
if (!response.ok) {
throw new Error('Kunne ikke hente lokationer');
}
const locations = await response.json();
if (!Array.isArray(locations) || locations.length === 0) {
list.innerHTML = '';
empty.classList.remove('d-none');
return;
}
const typeLabels = {
kompleks: 'Kompleks',
bygning: 'Bygning',
etage: 'Etage',
customer_site: 'Kundesite',
rum: 'Rum',
kantine: 'Kantine',
moedelokale: 'Mødelokale',
vehicle: 'Køretøj'
};
const typeColors = {
kompleks: '#0f4c75',
bygning: '#1abc9c',
etage: '#3498db',
customer_site: '#9b59b6',
rum: '#e67e22',
kantine: '#d35400',
moedelokale: '#16a085',
vehicle: '#8e44ad'
};
list.innerHTML = locations.map(loc => {
const typeLabel = typeLabels[loc.location_type] || loc.location_type || '-';
const typeColor = typeColors[loc.location_type] || '#6c757d';
const city = loc.address_city ? escapeHtml(loc.address_city) : '—';
const parent = loc.parent_location_name ? ` · ${escapeHtml(loc.parent_location_name)}` : '';
return `
< a href = "/app/locations/${loc.id}" class = "list-group-item list-group-item-action" >
< div class = "d-flex justify-content-between align-items-center" >
< div >
< div class = "fw-semibold" > ${escapeHtml(loc.name)}${parent}< / div >
< div class = "text-muted small" > ${city}< / div >
< / div >
< span class = "badge" style = "background-color: ${typeColor}; color: white;" >
${typeLabel}
< / span >
< / div >
< / a >
`;
}).join('');
} catch (error) {
console.error('Failed to load locations:', error);
list.innerHTML = '';
empty.classList.remove('d-none');
}
}
async function loadContactHardware() {
const tableBody = document.getElementById('contactHardwareTable');
const empty = document.getElementById('contactHardwareEmpty');
const container = document.getElementById('contactHardwareContainer');
if (!tableBody || !empty || !container) return;
tableBody.innerHTML = `
< tr >
< td colspan = "6" class = "text-center py-4" >
< div class = "spinner-border text-primary" > < / div >
< / td >
< / tr >
`;
empty.classList.add('d-none');
container.classList.remove('d-none');
try {
const response = await fetch(`/api/v1/hardware/by-contact/${contactId}`);
if (!response.ok) {
throw new Error('Kunne ikke hente hardware');
}
const hardware = await response.json();
if (!Array.isArray(hardware) || hardware.length === 0) {
container.classList.add('d-none');
empty.classList.remove('d-none');
return;
}
const locationIds = hardware
.map(item => item.current_location_id)
.filter(id => id !== null & & id !== undefined);
let locationsById = {};
if (locationIds.length > 0) {
const uniqueIds = Array.from(new Set(locationIds));
const locationsResponse = await fetch(`/api/v1/locations/by-ids?ids=${uniqueIds.join(',')}`);
if (locationsResponse.ok) {
const locations = await locationsResponse.json();
(locations || []).forEach(loc => {
locationsById[loc.id] = loc.name;
});
}
}
tableBody.innerHTML = hardware.map(item => {
const assetLabel = [item.brand, item.model].filter(Boolean).join(' ') || item.asset_type || '—';
const serial = item.serial_number || item.customer_asset_id || '—';
const location = locationsById[item.current_location_id] || item.location_name || '—';
const status = item.status || '—';
return `
< tr >
< td class = "fw-semibold" > ${escapeHtml(assetLabel)}< / td >
< td > ${escapeHtml(item.asset_type || '—')}< / td >
< td > ${escapeHtml(serial)}< / td >
< td > ${escapeHtml(location)}< / td >
< td > ${escapeHtml(status)}< / td >
< td class = "text-end" >
< a class = "btn btn-sm btn-outline-primary" href = "/hardware/${item.id}" >
< i class = "bi bi-eye" > < / i >
< / a >
< / td >
< / tr >
`;
}).join('');
} catch (error) {
console.error('Failed to load hardware:', error);
container.classList.add('d-none');
empty.classList.remove('d-none');
}
}
2026-02-14 02:26:29 +01:00
async function loadUnassignedHardware() {
const tableBody = document.getElementById('unassignedHardwareTable');
const empty = document.getElementById('unassignedHardwareEmpty');
const container = document.getElementById('unassignedHardwareContainer');
if (!tableBody || !empty || !container) return;
container.classList.remove('d-none');
empty.classList.add('d-none');
tableBody.innerHTML = `
< tr >
< td colspan = "5" class = "text-center py-4" >
< div class = "spinner-border text-primary" > < / div >
< / td >
< / tr >
`;
try {
const response = await fetch('/api/v1/hardware/unassigned?limit=200');
if (!response.ok) throw new Error('Kunne ikke hente hardware uden ejer');
const items = await response.json();
if (!Array.isArray(items) || items.length === 0) {
container.classList.add('d-none');
empty.classList.remove('d-none');
return;
}
tableBody.innerHTML = items.map(item => {
const label = [item.brand, item.model].filter(Boolean).join(' ') || item.asset_type || '—';
const serial = item.serial_number || '—';
const status = item.status || '—';
return `
< tr >
< td class = "fw-semibold" > ${escapeHtml(label)}< / td >
< td > ${escapeHtml(item.asset_type || '—')}< / td >
< td > ${escapeHtml(serial)}< / td >
< td > ${escapeHtml(status)}< / td >
< td class = "text-end" >
< button class = "btn btn-sm btn-outline-success" onclick = "relateUnassignedHardware(${item.id})" >
< i class = "bi bi-link-45deg me-1" > < / i > Tilknyt
< / button >
< / td >
< / tr >
`;
}).join('');
} catch (error) {
console.error('Failed to load unassigned hardware:', error);
container.classList.add('d-none');
empty.classList.remove('d-none');
empty.textContent = 'Kunne ikke hente hardware uden ejer';
}
}
async function relateUnassignedHardware(hardwareId) {
try {
const response = await fetch(`/api/v1/hardware/${hardwareId}/assign-contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contact_id: contactId })
});
if (!response.ok) {
const err = await response.text();
alert('Kunne ikke tilknytte hardware: ' + err);
return;
}
await loadContactHardware();
await loadUnassignedHardware();
} catch (error) {
console.error('Failed to relate hardware:', error);
alert('Kunne ikke tilknytte hardware');
}
}
2026-02-11 23:51:21 +01:00
async function loadBillingMatrix() {
const loading = document.getElementById('billingMatrixLoading');
const container = document.getElementById('billingMatrixContainer');
const empty = document.getElementById('billingMatrixEmpty');
if (!loading || !container || !empty) return;
loading.style.display = 'block';
container.style.display = 'none';
empty.style.display = 'none';
try {
const response = await fetch(`/api/v1/contacts/${contactId}/subscriptions/billing-matrix`);
const matrix = await response.json();
if (!response.ok) {
throw new Error(matrix.detail || 'Failed to load billing matrix');
}
if (!matrix.products || matrix.products.length === 0) {
empty.style.display = 'block';
loading.style.display = 'none';
return;
}
renderBillingMatrix(matrix);
} catch (error) {
console.error('Failed to load billing matrix:', error);
loading.innerHTML = `< div class = "alert alert-danger" > < i class = "bi bi-exclamation-circle me-2" > < / i > ${error.message}< / div > `;
}
}
function renderBillingMatrix(matrix) {
const headerRow = document.getElementById('matrixHeaderRow');
const bodyRows = document.getElementById('matrixBodyRows');
const monthsSet = new Set();
matrix.products.forEach(product => {
product.rows.forEach(row => {
monthsSet.add(row.year_month);
});
});
const months = Array.from(monthsSet).sort();
if (months.length === 0) {
document.getElementById('billingMatrixEmpty').style.display = 'block';
document.getElementById('billingMatrixLoading').style.display = 'none';
return;
}
headerRow.innerHTML = '< th style = "min-width: 200px;" > Vare< / th > ' +
months.map(month => {
const date = new Date(month + '-01');
const label = date.toLocaleDateString('da-DK', { month: 'short', year: '2-digit' });
return `< th style = "min-width: 100px; text-align: center; font-size: 0.9rem;" > ${label}< / th > `;
}).join('') +
'< th style = "min-width: 80px; text-align: right;" > I alt< / th > ';
bodyRows.innerHTML = matrix.products.map(product => {
const monthCells = months.map(month => {
const cell = product.rows.find(r => r.year_month === month);
if (!cell) {
return '< td class = "text-muted text-center" style = "font-size: 0.85rem;" > -< / td > ';
}
const amount = cell.amount || 0;
const statusBadge = getStatusBadge(cell.status);
const tooltip = cell.period_label ? ` title="${cell.period_label}${cell.invoice_number ? ' • ' + cell.invoice_number : ''}"` : '';
return `< td class = "text-center" style = "font-size: 0.9rem;" $ { tooltip } >
< div class = "d-flex flex-column align-items-center" >
< div class = "fw-500" > ${formatDKK(amount)}< / div >
< div > ${statusBadge}< / div >
< / div >
< / td > `;
}).join('');
return `< tr >
< td class = "fw-500" > ${escapeHtml(product.product_name)}< / td >
${monthCells}
< td class = "text-right fw-bold" style = "text-align: right;" > ${formatDKK(product.total_amount)}< / td >
< / tr > `;
}).join('');
const searchContainer = document.getElementById('matrixSearchContainer');
if (matrix.products.length > 0) {
searchContainer.style.display = 'block';
} else {
searchContainer.style.display = 'none';
}
document.getElementById('billingMatrixContainer').style.display = 'block';
document.getElementById('billingMatrixLoading').style.display = 'none';
}
function getStatusBadge(status) {
const badgeMap = {
paid: { color: 'success', icon: 'check-circle', label: 'Betalt' },
invoiced: { color: 'warning', icon: 'file-text', label: 'Faktureret' },
draft: { color: 'secondary', icon: 'file-earmark', label: 'Kladde' },
missing: { color: 'danger', icon: 'exclamation-triangle', label: 'Manglende' },
credited: { color: 'info', icon: 'arrow-counterclockwise', label: 'Krediteret' }
};
const badge = badgeMap[status] || { color: 'secondary', icon: 'question-circle', label: status || 'Ukendt' };
return `< span class = "badge bg-${badge.color}" style = "font-size: 0.75rem; margin-top: 2px;" >
< i class = "bi bi-${badge.icon}" style = "font-size: 0.65rem;" > < / i > ${badge.label}
< / span > `;
}
function formatDKK(amount) {
const num = parseFloat(amount || 0);
if (!num || Number.isNaN(num)) return '0 kr';
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 }).format(num);
}
function filterMatrixProducts() {
const searchInput = document.getElementById('matrixSearchInput');
const searchTerm = searchInput.value.toLowerCase();
const tableBody = document.getElementById('matrixBodyRows');
const rows = tableBody.getElementsByTagName('tr');
let visibleCount = 0;
for (let i = 0; i < rows.length ; i + + ) {
const row = rows[i];
const productName = row.cells[0].textContent.toLowerCase();
if (productName.includes(searchTerm)) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
}
if (visibleCount === 0 & & searchTerm.length > 0) {
if (!document.getElementById('matrixNoResults')) {
const noResultsRow = document.createElement('tr');
noResultsRow.id = 'matrixNoResults';
noResultsRow.innerHTML = '< td colspan = "100" class = "text-center text-muted py-3" > < i class = "bi bi-search me-2" > < / i > Ingen produkter matcher søgningen< / td > ';
tableBody.appendChild(noResultsRow);
}
} else {
const noResultsRow = document.getElementById('matrixNoResults');
if (noResultsRow) {
noResultsRow.remove();
}
}
}
function clearMatrixSearch() {
const searchInput = document.getElementById('matrixSearchInput');
searchInput.value = '';
filterMatrixProducts();
searchInput.focus();
}
async function loadNextcloudStatus() {
const statusBadge = document.getElementById('ncStatusBadge');
const lastUpdated = document.getElementById('ncLastUpdated');
if (!statusBadge || !lastUpdated) return;
statusBadge.textContent = 'Henter...';
statusBadge.className = 'badge bg-secondary';
lastUpdated.textContent = '-';
try {
const instanceResponse = await fetch(`/api/v1/nextcloud/contacts/${contactId}/instance`);
if (!instanceResponse.ok) {
throw new Error('Kunne ikke hente Nextcloud instance');
}
const instance = await instanceResponse.json();
if (!instance || !instance.id) {
statusBadge.textContent = 'Ingen instance';
statusBadge.className = 'badge bg-secondary';
return;
}
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/status?contact_id=${contactId}`);
if (!response.ok) {
throw new Error('Kunne ikke hente status');
}
const info = await response.json();
const system = info?.nextcloud?.system || {};
const sharesInfo = info?.nextcloud?.shares || {};
const storageInfo = info?.nextcloud?.storage || {};
const alerts = info?.alerts || [];
statusBadge.textContent = info?.nextcloud?.status || 'Ukendt';
statusBadge.className = info?.nextcloud?.status === 'ok' ? 'badge bg-success' : 'badge bg-warning';
lastUpdated.textContent = new Date().toLocaleString('da-DK');
document.getElementById('ncCpuLoad').textContent = system.cpu_load || '-';
document.getElementById('ncFreeDisk').textContent = storageInfo.free || '-';
document.getElementById('ncRamUsage').textContent = system.memory || '-';
document.getElementById('ncOpcache').textContent = system.opcache_hit_rate || '-';
document.getElementById('ncFileGrowth').textContent = storageInfo.file_count_growth || '-';
document.getElementById('ncPublicShares').textContent = sharesInfo.public_without_password || '-';
document.getElementById('ncActiveUsers').textContent = system.active_users || '-';
const alertsContainer = document.getElementById('ncAlerts');
if (alertsContainer) {
alertsContainer.innerHTML = (alerts || []).map(alert => `
< span class = "badge bg-warning text-dark" > ${escapeHtml(alert)}< / span >
`).join('') || '< span class = "text-muted" > Ingen alerts< / span > ';
}
const history = info?.nextcloud?.history || [];
const historyContainer = document.getElementById('ncHistory');
if (historyContainer) {
historyContainer.innerHTML = history.length
? history.map(item => `< div class = "small text-muted" > ${escapeHtml(item)}< / div > `).join('')
: 'Ingen events endnu.';
}
} catch (error) {
console.error('Failed to load Nextcloud status:', error);
statusBadge.textContent = 'Fejl';
statusBadge.className = 'badge bg-danger';
}
}
2026-02-14 02:26:29 +01:00
async function loadKontaktHistory() {
const container = document.getElementById('kontaktContainer');
2026-02-11 23:51:21 +01:00
if (!container) return;
container.innerHTML = '< div class = "text-center py-5" > < div class = "spinner-border text-primary" > < / div > < / div > ';
2026-02-14 02:26:29 +01:00
try {
const response = await fetch(`/api/v1/contacts/${contactId}/kontakt?limit=200`);
if (!response.ok) throw new Error('Kunne ikke hente kontakt historik');
const data = await response.json();
kontaktHistoryItems = data.items || [];
renderKontaktHistoryTable();
} catch (error) {
console.error('Failed to load kontakt history:', error);
container.innerHTML = '< div class = "alert alert-danger" > Kunne ikke hente kontakt historik< / div > ';
}
}
function setKontaktFilter(filter) {
kontaktHistoryFilter = filter;
document.getElementById('kontaktFilterAll')?.classList.toggle('active', filter === 'all');
document.getElementById('kontaktFilterSms')?.classList.toggle('active', filter === 'sms');
document.getElementById('kontaktFilterCall')?.classList.toggle('active', filter === 'call');
renderKontaktHistoryTable();
}
function renderKontaktHistoryTable() {
const container = document.getElementById('kontaktContainer');
if (!container) return;
const filteredItems = (kontaktHistoryItems || []).filter(item => {
if (kontaktHistoryFilter === 'all') return true;
return item.type === kontaktHistoryFilter;
});
if (!filteredItems.length) {
const msg = kontaktHistoryItems.length
? 'Ingen hændelser matcher filteret'
2026-08-17 19:27:43 +02:00
: 'Ingen kontakthistorik fundet';
2026-02-14 02:26:29 +01:00
container.innerHTML = `< div class = "text-muted text-center py-5" > ${msg}< / div > `;
return;
}
container.innerHTML = `
< div class = "table-responsive" >
< table class = "table table-hover align-middle mb-0" >
< thead class = "table-light" >
< tr >
< th > Tid< / th >
< th > Type< / th >
< th > Retning/Status< / th >
< th > Nummer< / th >
< th > Indhold< / th >
< th > Varighed< / th >
< th > Bruger< / th >
< / tr >
< / thead >
< tbody >
${filteredItems.map(renderKontaktHistoryRow).join('')}
< / tbody >
< / table >
< / div >
`;
}
function renderKontaktHistoryRow(item) {
const ts = item.happened_at ? new Date(item.happened_at).toLocaleString('da-DK') : '-';
const typeBadge = item.type === 'sms'
? '< span class = "badge bg-primary-subtle text-primary-emphasis" > SMS< / span > '
2026-08-17 19:27:43 +02:00
: item.type === 'merge'
? '< span class = "badge bg-info-subtle text-info-emphasis" > Fletning< / span > '
: '< span class = "badge bg-success-subtle text-success-emphasis" > Opkald< / span > ';
2026-02-14 02:26:29 +01:00
const dirOrStatus = item.type === 'sms'
? (item.sms_status || '-')
2026-08-17 19:27:43 +02:00
: item.type === 'merge'
? 'Gennemført'
: (item.direction === 'outbound' ? 'Udgående' : 'Indgående');
2026-02-14 02:26:29 +01:00
2026-08-17 19:27:43 +02:00
const message = item.type === 'sms' || item.type === 'merge'
2026-02-14 02:26:29 +01:00
? escapeHtml(item.message || '-')
: '-';
const duration = item.duration_sec & & Number(item.duration_sec) > 0
? `${Math.floor(Number(item.duration_sec) / 60)}:${String(Number(item.duration_sec) % 60).padStart(2, '0')}`
: '-';
return `
< tr >
< td > ${ts}< / td >
< td > ${typeBadge}< / td >
< td > ${escapeHtml(dirOrStatus || '-')}< / td >
< td > ${escapeHtml(item.number || '-')}< / td >
< td class = "text-break" style = "max-width: 420px;" > ${message}< / td >
< td > ${duration}< / td >
< td > ${escapeHtml(item.user_name || '-')}< / td >
< / tr >
`;
2026-02-11 23:51:21 +01:00
}
async function loadConversations() {
const container = document.getElementById('conversationsContainer');
if (!container) return;
container.innerHTML = '< div class = "text-center py-5" > < div class = "spinner-border text-primary" > < / div > < / div > ';
try {
const response = await fetch(`/api/v1/conversations?contact_id=${contactId}`);
if (!response.ok) throw new Error('Failed to load conversations');
const conversations = await response.json();
if (conversations.length === 0) {
container.innerHTML = '< div class = "text-muted text-center py-5" > Ingen samtaler fundet< / div > ';
return;
}
container.innerHTML = conversations.map(c => renderConversationCard(c)).join('');
} catch (error) {
console.error('Error loading conversations:', error);
container.innerHTML = '< div class = "alert alert-danger" > Kunne ikke hente samtaler< / div > ';
}
}
function renderConversationCard(c) {
const date = new Date(c.created_at).toLocaleString();
const duration = c.duration_seconds ? `${Math.floor(c.duration_seconds / 60)}:${(c.duration_seconds % 60).toString().padStart(2, '0')}` : '';
return `
< div class = "card mb-3 shadow-sm conversation-item ${c.is_private ? 'border-warning' : ''}" data-text = "${(c.transcript || '') + ' ' + (c.title || '')}" >
< div class = "card-body" >
< div class = "d-flex justify-content-between align-items-start mb-2" >
< div >
< h6 class = "fw-bold mb-1" > ${escapeHtml(c.title || 'Samtale')}< / h6 >
< div class = "text-muted small" > ${date}${duration ? ` • ${duration}` : ''}< / div >
< / div >
< span class = "badge ${c.is_private ? 'bg-warning text-dark' : 'bg-secondary'}" > ${c.is_private ? 'Privat' : 'Offentlig'}< / span >
< / div >
< div class = "small text-muted" style = "white-space: pre-wrap;" > ${escapeHtml(c.transcript || 'Ingen transskribering')}< / div >
< / div >
< / div >
`;
}
function filterConversations() {
const searchInput = document.getElementById('conversationSearch');
const searchTerm = searchInput.value.toLowerCase();
const items = document.querySelectorAll('.conversation-item');
items.forEach(item => {
const text = (item.dataset.text || '').toLowerCase();
item.style.display = text.includes(searchTerm) ? '' : 'none';
});
}
function getField(item, keys, fallback = '') {
for (const key of keys) {
if (item & & item[key]) return item[key];
}
return fallback;
}
function formatDate(dateStr) {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch {
return dateStr;
}
}
function formatCurrency(value, currency) {
const num = parseFloat(value || 0);
if (Number.isNaN(num)) return value || '';
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
}
2026-02-10 14:40:38 +01:00
async function loadRemoteSessions() {
const container = document.getElementById('remoteSessionsContainer');
if (!container) return;
container.innerHTML = `
< div class = "col-12 text-center py-5" >
< div class = "spinner-border text-primary" > < / div >
< / div >
`;
try {
const response = await fetch(`/api/v1/anydesk/sessions?contact_id=${contactId}`);
if (!response.ok) {
throw new Error('Kunne ikke hente sessioner');
}
const data = await response.json();
displayRemoteSessions(data.sessions || []);
} catch (error) {
console.error('Failed to load sessions:', error);
container.innerHTML = '< div class = "col-12 text-center py-5 text-muted" > Kunne ikke hente sessioner< / div > ';
}
}
function displayRemoteSessions(sessions) {
const container = document.getElementById('remoteSessionsContainer');
if (!container) return;
if (!sessions || sessions.length === 0) {
container.innerHTML = '< div class = "col-12 text-center py-5 text-muted" > Ingen remote sessions endnu< / div > ';
return;
}
container.innerHTML = sessions.map(session => {
const badgeClass = getSessionBadgeClass(session.status);
const duration = session.duration_minutes ? `${session.duration_minutes} min` : '—';
const startedAt = session.started_at ? new Date(session.started_at).toLocaleString('da-DK') : '-';
const endedAt = session.ended_at ? new Date(session.ended_at).toLocaleString('da-DK') : '-';
const linkButton = session.session_link
? `< a class = "btn btn-sm btn-outline-primary" href = "${session.session_link}" target = "_blank" > Åbn Link< / a > `
: '';
const worklogButton = session.status === 'completed'
? `< button class = "btn btn-sm btn-outline-secondary" onclick = "showWorklogSuggestion(${session.id})" > Vis forslag< / button > `
: '';
return `
< div class = "col-12" >
< div class = "session-card" >
< div class = "d-flex flex-wrap justify-content-between align-items-start gap-3" >
< div >
< div class = "d-flex align-items-center gap-2 mb-2" >
< span class = "badge ${badgeClass}" > ${escapeHtml(session.status || 'ukendt')}< / span >
< strong > Session #${session.id}< / strong >
< / div >
< div class = "session-meta" > Start: ${startedAt} | Slut: ${endedAt} | Varighed: ${duration}< / div >
${session.sag_id ? `< div class = "session-meta" > Sag ID: ${session.sag_id}< / div > ` : ''}
< / div >
< div class = "d-flex gap-2" >
${linkButton}
${worklogButton}
< / div >
< / div >
< / div >
< / div >
`;
}).join('');
}
async function startRemoteSession() {
const alertBox = document.getElementById('remoteSessionAlert');
const select = document.getElementById('sessionCompanySelect');
if (!contactData || !select || select.disabled) {
alert('Kontakt skal have mindst et firma for at starte en session');
return;
}
const customerId = parseInt(select.value);
const sagIdValue = document.getElementById('sessionSagIdInput').value;
const sagId = sagIdValue ? parseInt(sagIdValue) : null;
try {
const response = await fetch('/api/v1/anydesk/start-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: customerId,
contact_id: contactId,
sag_id: sagId,
description: `Remote support for ${contactData.first_name} ${contactData.last_name}`
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke starte session');
}
const data = await response.json();
if (alertBox) {
alertBox.classList.remove('d-none');
alertBox.innerHTML = `Session startet. ${data.session_link ? `Link: < a href = "${data.session_link}" target = "_blank" > ${data.session_link}< / a > ` : ''}`;
}
if (data.session_link) {
window.open(data.session_link, '_blank');
}
await loadRemoteSessions();
} catch (error) {
console.error('Failed to start session:', error);
alert('Fejl: ' + error.message);
}
}
async function showWorklogSuggestion(sessionId) {
try {
const response = await fetch(`/api/v1/anydesk/sessions/${sessionId}/worklog-suggestion`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke hente forslag');
}
const suggestion = await response.json();
const body = document.getElementById('worklogSuggestionBody');
if (body) {
body.innerHTML = `
< div class = "mb-2" > < strong > Varighed:< / strong > ${suggestion.duration_hours} timer< / div >
< div class = "mb-2" > < strong > Start:< / strong > ${new Date(suggestion.start_time).toLocaleString('da-DK')}< / div >
< div class = "mb-2" > < strong > Slut:< / strong > ${new Date(suggestion.end_time).toLocaleString('da-DK')}< / div >
< div class = "mb-2" > < strong > Beskrivelse:< / strong > ${escapeHtml(suggestion.description || '')}< / div >
< div class = "small text-muted" > Forslaget kan bruges til manuel tidsregistrering< / div >
`;
}
const modal = new bootstrap.Modal(document.getElementById('worklogSuggestionModal'));
modal.show();
} catch (error) {
console.error('Failed to load suggestion:', error);
alert('Fejl: ' + error.message);
}
}
function getSessionBadgeClass(status) {
switch ((status || '').toLowerCase()) {
case 'active':
return 'bg-success';
case 'completed':
return 'bg-primary';
case 'failed':
return 'bg-danger';
case 'cancelled':
return 'bg-secondary';
default:
return 'bg-light text-dark';
}
}
2025-12-06 02:22:01 +01:00
async function loadCompaniesForSelect() {
try {
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
const select = document.getElementById('companySelectModal');
select.innerHTML = '< option value = "" > Vælg et firma...< / option > ' +
data.customers.map(c => `< option value = "${c.id}" > ${escapeHtml(c.name)}< / option > `).join('');
} catch (error) {
console.error('Failed to load companies:', error);
}
}
function showAddCompanyModal() {
// Reset form
document.getElementById('addCompanyForm').reset();
// Show modal
const modal = new bootstrap.Modal(document.getElementById('addCompanyModal'));
modal.show();
}
async function addCompanyToContact() {
const customerId = parseInt(document.getElementById('companySelectModal').value);
if (!customerId) {
alert('Vælg venligst et firma');
return;
}
const linkData = {
customer_id: customerId,
is_primary: document.getElementById('isPrimaryInputModal').checked,
role: document.getElementById('roleInputModal').value.trim() || null,
notes: document.getElementById('notesInputModal').value.trim() || null
};
try {
const response = await fetch(`/api/v1/contacts/${contactId}/companies`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(linkData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke tilføje firma');
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('addCompanyModal'));
modal.hide();
// Reload contact
await loadContact();
// Switch to companies tab
const companiesTab = new bootstrap.Tab(document.querySelector('a[href="#companies"]'));
companiesTab.show();
} catch (error) {
console.error('Failed to add company:', error);
alert('Fejl: ' + error.message);
}
}
async function removeCompany(customerId) {
if (!confirm('Er du sikker på at du vil fjerne dette firma fra kontakten?')) {
return;
}
try {
const response = await fetch(`/api/v1/contacts/${contactId}/companies/${customerId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke fjerne firma');
}
// Reload contact
await loadContact();
} catch (error) {
console.error('Failed to remove company:', error);
alert('Fejl: ' + error.message);
}
}
function editContact() {
2026-02-06 10:47:14 +01:00
// Fill form with current contact data
if (contactData) {
document.getElementById('editFirstNameInput').value = contactData.first_name || '';
document.getElementById('editLastNameInput').value = contactData.last_name || '';
document.getElementById('editEmailInput').value = contactData.email || '';
document.getElementById('editPhoneInput').value = contactData.phone || '';
document.getElementById('editMobileInput').value = contactData.mobile || '';
document.getElementById('editTitleInput').value = contactData.title || '';
document.getElementById('editDepartmentInput').value = contactData.department || '';
document.getElementById('editIsActiveInput').checked = contactData.is_active || false;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('editContactModal'));
modal.show();
}
}
async function saveEditContact() {
const firstName = document.getElementById('editFirstNameInput').value.trim();
const lastName = document.getElementById('editLastNameInput').value.trim();
if (!firstName || !lastName) {
alert('Fornavn og efternavn er påkrævet');
return;
}
try {
const response = await fetch(`/api/v1/contacts/${contactId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: firstName,
last_name: lastName,
email: document.getElementById('editEmailInput').value || null,
phone: document.getElementById('editPhoneInput').value || null,
mobile: document.getElementById('editMobileInput').value || null,
title: document.getElementById('editTitleInput').value || null,
department: document.getElementById('editDepartmentInput').value || null,
is_active: document.getElementById('editIsActiveInput').checked
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke gemme kontakt');
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('editContactModal'));
modal.hide();
// Reload contact
await loadContact();
} catch (error) {
console.error('Failed to save contact:', error);
alert('Fejl: ' + error.message);
}
2025-12-06 02:22:01 +01:00
}
2026-08-17 19:27:43 +02:00
function contactMergeName(contact) {
if (!contact) return 'Ukendt kontakt';
return `${contact.first_name || ''} ${contact.last_name || ''}`.trim()
|| contact.email
|| `Kontakt #${contact.id}`;
}
function openMergeContactModal() {
mergeSourceContactId = null;
const targetName = contactMergeName(contactData);
const automaticSearch = `${contactData?.first_name || ''} ${contactData?.last_name || ''}`.trim();
document.getElementById('mergeTargetName').textContent = targetName;
document.getElementById('mergeContactSearch').value = automaticSearch;
document.getElementById('mergeContactResults').innerHTML = '< div class = "text-muted small" > Søger efter mulige dubletter…< / div > ';
document.getElementById('mergeContactPreview').classList.add('d-none');
document.getElementById('mergeContactSubmit').disabled = true;
const modalElement = document.getElementById('mergeContactModal');
bootstrap.Modal.getOrCreateInstance(modalElement).show();
modalElement.addEventListener('shown.bs.modal', () => document.getElementById('mergeContactSearch').focus(), { once: true });
if (automaticSearch.length >= 2) {
searchMergeContacts(automaticSearch);
}
}
async function searchMergeContacts(query) {
const results = document.getElementById('mergeContactResults');
const value = query.trim();
if (value.length < 2 ) {
results.innerHTML = '< div class = "text-muted small" > Skriv mindst 2 tegn.< / div > ';
return;
}
results.innerHTML = '< div class = "text-muted small" > < span class = "spinner-border spinner-border-sm me-2" > < / span > Søger…< / div > ';
try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(value)}&limit=20`);
if (!response.ok) throw new Error('Søgningen fejlede');
const data = await response.json();
const contacts = (data.contacts || []).filter(contact => Number(contact.id) !== contactId);
if (!contacts.length) {
results.innerHTML = '< div class = "text-muted small" > Ingen andre kontakter fundet.< / div > ';
return;
}
results.innerHTML = contacts.map(contact => {
const companies = (contact.company_names || []).join(', ') || 'Intet firma';
const details = [contact.email, contact.mobile || contact.phone, companies].filter(Boolean).join(' · ');
return `< button type = "button" class = "list-group-item list-group-item-action" onclick = "selectMergeContact(${Number(contact.id)})" >
< div class = "fw-semibold" > ${escapeHtml(contactMergeName(contact))}< / div >
< div class = "small text-muted" > #${Number(contact.id)} · ${escapeHtml(details)}< / div >
< / button > `;
}).join('');
} catch (error) {
results.innerHTML = `< div class = "text-danger small" > ${escapeHtml(error.message)}< / div > `;
}
}
async function selectMergeContact(sourceId) {
try {
const response = await fetch(`/api/v1/contacts/${contactId}/merge-preview?source_contact_id=${sourceId}`);
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kunne ikke hente fletteoversigt');
mergeSourceContactId = sourceId;
document.getElementById('mergeSourceName').textContent = contactMergeName(data.source);
document.getElementById('mergeSourceDetails').textContent = [
`Kontakt #${data.source.id}`,
data.source.email,
data.source.mobile || data.source.phone,
].filter(Boolean).join(' · ');
const relevant = (data.relations || []).filter(item => Number(item.count) > 0);
document.getElementById('mergeRelationCounts').innerHTML = relevant.length
? relevant.map(item => `< span class = "badge text-bg-light border" > ${escapeHtml(item.label)}: ${Number(item.count)}< / span > `).join('')
: '< span class = "text-muted small" > Ingen direkte relationer fundet.< / span > ';
document.getElementById('mergeContactPreview').classList.remove('d-none');
document.getElementById('mergeContactSubmit').disabled = false;
} catch (error) {
alert(`Fejl: ${error.message}`);
}
}
async function submitContactMerge() {
if (!mergeSourceContactId) return;
const sourceName = document.getElementById('mergeSourceName').textContent;
if (!confirm(`Flet ${sourceName} ind i ${contactMergeName(contactData)}?\n\nSager, firmaer, e-mails, opkald og øvrige relationer flyttes. Dubletkontakten slettes.`)) return;
const button = document.getElementById('mergeContactSubmit');
button.disabled = true;
try {
const response = await fetch(`/api/v1/contacts/${contactId}/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_contact_id: mergeSourceContactId }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kontakterne kunne ikke flettes');
bootstrap.Modal.getInstance(document.getElementById('mergeContactModal'))?.hide();
alert('Kontakterne er flettet. Alle relationer er samlet på hovedkontakten.');
window.location.reload();
} catch (error) {
button.disabled = false;
alert(`Fejl: ${error.message}`);
}
}
2025-12-06 02:22:01 +01:00
function getInitials(firstName, lastName) {
if (!firstName & & !lastName) return '?';
const first = firstName ? firstName[0] : '';
const last = lastName ? lastName[0] : '';
return (first + last).toUpperCase();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
< / script >
{% endblock %}