bmc_hub/app/contacts/frontend/contact_detail.html
2026-08-28 20:49:55 +02:00

2728 lines
119 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends "shared/frontend/base.html" %}
{% block title %}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);
}
.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);
}
.remove-company-btn {
position: absolute;
top: 1rem;
right: 1rem;
}
.contact-email-dropzone {
border: 1px dashed rgba(15, 76, 117, 0.45);
border-radius: 12px;
background: rgba(15, 76, 117, 0.04);
padding: 0.8rem 1rem;
cursor: pointer;
transition: background-color .2s, border-color .2s, transform .2s;
}
.contact-email-dropzone.is-dragging {
background: rgba(15, 76, 117, 0.12);
border-color: var(--accent);
transform: translateY(-1px);
}
</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">
<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>
<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>
<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>
<div id="contactEmailDropzone" class="contact-email-dropzone mb-3" role="button" tabindex="0" aria-label="Analysér Outlook-mail for kontaktopdateringer">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-envelope-arrow-down fs-4 text-primary"></i>
<div class="flex-grow-1">
<div class="fw-semibold">Opdatér kontakt fra Outlook-mail</div>
<div class="small text-muted">Træk en .msg- eller .eml-fil hertil, eller klik for at vælge. Intet ændres uden din godkendelse.</div>
</div>
<div id="contactEmailAnalyzeStatus" class="small text-muted"></div>
</div>
<input id="contactEmailFileInput" type="file" accept=".msg,.eml,message/rfc822,application/vnd.ms-outlook" hidden>
</div>
<!-- Alert Notes Container -->
<div id="alert-notes-container"></div>
<!-- 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>
<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="#cases">
<i class="bi bi-list-check"></i>Sager
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#emails">
<i class="bi bi-envelope"></i>E-mails
</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>
<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>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
<i class="bi bi-chat-left-text"></i>Kontakt
</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>
</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>
<!-- 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>
<div class="tab-pane fade" id="cases">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Kontaktens sager</h5>
<span id="contactCasesCount" class="badge bg-secondary">0</span>
</div>
<div id="contactCasesContainer" class="table-responsive"></div>
<div id="contactCasesPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
</div>
<div class="tab-pane fade" id="emails">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Kontaktens e-mails</h5>
<span id="contactEmailsCount" class="badge bg-secondary">0</span>
</div>
<div id="contactEmailsContainer" class="table-responsive"></div>
<div id="contactEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></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>
<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>
</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>
<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>
</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>
<!-- 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>
<!-- 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">
<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>
</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>
<!-- 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>
<!-- 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>
<!-- 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>
<div class="modal fade" id="contactEmailSuggestionsModal" 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">Foreslåede kontaktopdateringer</h5>
<div id="contactEmailSuggestionMeta" class="small text-muted"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="contactEmailSuggestionsBody"></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" id="applyContactEmailSuggestionsBtn" onclick="applyContactEmailSuggestions()">
<i class="bi bi-check-lg me-1"></i>Godkend valgte
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- 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" %}
<script>
const contactId = parseInt(window.location.pathname.split('/').pop());
let contactData = null;
let primaryCustomerId = null;
let kontaktHistoryItems = [];
let kontaktHistoryFilter = 'all';
let mergeSourceContactId = null;
let mergeSearchTimer = null;
let pendingContactEmailSuggestions = [];
document.addEventListener('DOMContentLoaded', () => {
loadContact();
loadCompaniesForSelect();
initializeContactEmailDropzone();
const mergeSearch = document.getElementById('mergeContactSearch');
if (mergeSearch) {
mergeSearch.addEventListener('input', () => {
clearTimeout(mergeSearchTimer);
mergeSearchTimer = setTimeout(() => searchMergeContacts(mergeSearch.value), 250);
});
}
// Load companies when tab is shown
document.querySelector('a[href="#companies"]').addEventListener('shown.bs.tab', () => {
loadCompanies();
});
const remoteSessionsTab = document.querySelector('a[href="#remote-sessions"]');
if (remoteSessionsTab) {
remoteSessionsTab.addEventListener('shown.bs.tab', () => {
loadRemoteSessions();
});
}
const contactsTab = document.querySelector('a[href="#contacts"]');
if (contactsTab) {
contactsTab.addEventListener('shown.bs.tab', () => {
loadRelatedContacts();
});
}
document.querySelector('a[href="#cases"]')?.addEventListener('shown.bs.tab', () => loadContactCases());
document.querySelector('a[href="#emails"]')?.addEventListener('shown.bs.tab', () => loadContactEmails());
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();
});
}
const kontaktTab = document.querySelector('a[href="#kontakt"]');
if (kontaktTab) {
kontaktTab.addEventListener('shown.bs.tab', () => {
loadKontaktHistory();
});
}
const conversationsTab = document.querySelector('a[href="#conversations"]');
if (conversationsTab) {
conversationsTab.addEventListener('shown.bs.tab', () => {
loadConversations();
});
}
});
function initializeContactEmailDropzone() {
const zone = document.getElementById('contactEmailDropzone');
const input = document.getElementById('contactEmailFileInput');
if (!zone || !input) return;
const choose = () => input.click();
zone.addEventListener('click', choose);
zone.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(); }
});
input.addEventListener('change', () => {
if (input.files?.[0]) analyzeContactEmailFile(input.files[0]);
input.value = '';
});
['dragenter', 'dragover'].forEach(name => zone.addEventListener(name, event => {
event.preventDefault();
zone.classList.add('is-dragging');
}));
['dragleave', 'drop'].forEach(name => zone.addEventListener(name, event => {
event.preventDefault();
zone.classList.remove('is-dragging');
}));
zone.addEventListener('drop', event => {
const file = event.dataTransfer?.files?.[0];
if (file) analyzeContactEmailFile(file);
});
}
async function analyzeContactEmailFile(file) {
const status = document.getElementById('contactEmailAnalyzeStatus');
const lower = String(file?.name || '').toLowerCase();
if (!lower.endsWith('.msg') && !lower.endsWith('.eml')) {
status.className = 'small text-danger';
status.textContent = 'Vælg en .msg- eller .eml-fil';
return;
}
status.className = 'small text-muted';
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyserer…';
const form = new FormData();
form.append('file', file);
try {
const response = await fetch(`/api/v1/contacts/${contactId}/analyze-email`, { method: 'POST', body: form });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Mailen kunne ikke analyseres');
pendingContactEmailSuggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
renderContactEmailSuggestions(data);
bootstrap.Modal.getOrCreateInstance(document.getElementById('contactEmailSuggestionsModal')).show();
status.className = 'small text-success';
status.textContent = `${pendingContactEmailSuggestions.length} forslag fundet`;
} catch (error) {
status.className = 'small text-danger';
status.textContent = error.message;
}
}
function renderContactEmailSuggestions(data) {
const mail = data.mail || {};
document.getElementById('contactEmailSuggestionMeta').textContent = [data.filename, mail.subject, mail.sender_email].filter(Boolean).join(' · ');
const body = document.getElementById('contactEmailSuggestionsBody');
const button = document.getElementById('applyContactEmailSuggestionsBtn');
if (!pendingContactEmailSuggestions.length) {
body.innerHTML = '<div class="text-center text-muted py-4"><i class="bi bi-check-circle fs-2 d-block mb-2"></i>Ingen nye kontaktoplysninger fundet.</div>';
button.disabled = true;
return;
}
button.disabled = false;
body.innerHTML = `<div class="table-responsive"><table class="table align-middle"><thead><tr><th></th><th>Felt</th><th>Nuværende</th><th>Foreslået</th><th>Kilde</th></tr></thead><tbody>${pendingContactEmailSuggestions.map((item, index) => `
<tr><td><input class="form-check-input contact-email-suggestion-check" type="checkbox" data-index="${index}" checked></td><td class="fw-semibold">${escapeHtml(item.label)}</td><td class="text-muted">${escapeHtml(item.current || '—')}</td><td>${escapeHtml(item.suggested)}</td><td><span class="badge bg-light text-dark border">${escapeHtml(item.source)}</span></td></tr>`).join('')}</tbody></table></div>`;
}
async function applyContactEmailSuggestions() {
const selected = Array.from(document.querySelectorAll('.contact-email-suggestion-check:checked'))
.map(input => pendingContactEmailSuggestions[Number(input.dataset.index)])
.filter(Boolean);
if (!selected.length) return;
const payload = Object.fromEntries(selected.map(item => [item.field, item.suggested]));
const button = document.getElementById('applyContactEmailSuggestionsBtn');
button.disabled = true;
try {
const response = await fetch(`/api/v1/contacts/${contactId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Opdateringen kunne ikke gemmes');
bootstrap.Modal.getInstance(document.getElementById('contactEmailSuggestionsModal'))?.hide();
await loadContact();
const status = document.getElementById('contactEmailAnalyzeStatus');
status.className = 'small text-success';
status.textContent = `${selected.length} felt(er) opdateret`;
} catch (error) {
alert(error.message);
button.disabled = false;
}
}
const contactRelatedPageSize = 25;
let contactCasesOffset = 0;
let contactEmailsOffset = 0;
function contactPagination(hostId, total, offset, loaderName) {
const next = offset + contactRelatedPageSize;
document.getElementById(hostId).innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${offset === 0 ? 'disabled' : ''} onclick="${loaderName}(${Math.max(0, offset - contactRelatedPageSize)})">Forrige</button>
<span class="small text-muted">${total ? offset + 1 : 0}${Math.min(next, total)} af ${total}</span>
<button class="btn btn-sm btn-outline-secondary" ${next >= total ? 'disabled' : ''} onclick="${loaderName}(${next})">Næste</button>`;
}
async function loadContactCases(offset = contactCasesOffset) {
contactCasesOffset = Math.max(0, offset);
const host = document.getElementById('contactCasesContainer');
host.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/contacts/${contactId}/cases?limit=${contactRelatedPageSize}&offset=${contactCasesOffset}`);
if (!response.ok) throw new Error('Kunne ikke hente sager');
const data = await response.json();
document.getElementById('contactCasesCount').textContent = data.total;
host.innerHTML = data.items.length ? `<table class="table table-hover align-middle"><thead><tr><th>Sag</th><th>Status</th><th>Kunde</th><th>Ansvarlig</th><th>Opdateret</th></tr></thead><tbody>${data.items.map(item => `
<tr role="button" onclick="window.location.href='/sag/${item.id}'"><td><strong>#${item.id}</strong> ${escapeHtml(item.titel || '')}</td><td>${escapeHtml(item.status || '-')}</td><td>${escapeHtml(item.customer_name || '-')}</td><td>${escapeHtml(item.responsible_name || 'Ikke tildelt')}</td><td>${escapeHtml(item.updated_at ? new Date(item.updated_at).toLocaleString('da-DK') : '-')}</td></tr>`).join('')}</tbody></table>` : '<div class="text-center text-muted py-5">Ingen direkte tilknyttede sager</div>';
contactPagination('contactCasesPagination', data.total, contactCasesOffset, 'loadContactCases');
} catch (error) {
host.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
document.getElementById('contactCasesPagination').innerHTML = '';
}
}
async function loadContactEmails(offset = contactEmailsOffset) {
contactEmailsOffset = Math.max(0, offset);
const host = document.getElementById('contactEmailsContainer');
host.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/contacts/${contactId}/emails?limit=${contactRelatedPageSize}&offset=${contactEmailsOffset}`);
if (!response.ok) throw new Error('Kunne ikke hente e-mails');
const data = await response.json();
document.getElementById('contactEmailsCount').textContent = data.total;
host.innerHTML = data.items.length ? `<table class="table table-hover align-middle"><thead><tr><th>Dato</th><th>Emne</th><th>Fra / til</th><th>Sag</th><th></th></tr></thead><tbody>${data.items.map(email => `
<tr role="button" onclick="window.location.href='/emails?open=${email.id}'"><td>${escapeHtml(email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-')}</td><td>${email.is_read ? '' : '<span class="badge bg-primary me-1">Ny</span>'}${escapeHtml(email.subject || '(Intet emne)')}</td><td><div>${escapeHtml(email.sender_name || email.sender_email || '-')}</div><small class="text-muted">Til: ${escapeHtml(email.recipient_email || '-')}</small></td><td>${email.linked_case_id ? `<a href="/sag/${email.linked_case_id}" onclick="event.stopPropagation()">#${email.linked_case_id} ${escapeHtml(email.linked_case_title || '')}</a>` : '-'}</td><td>${email.has_attachments ? '<i class="bi bi-paperclip"></i>' : ''}</td></tr>`).join('')}</tbody></table>` : '<div class="text-center text-muted py-5">Ingen e-mails fundet for kontaktens adresse</div>';
contactPagination('contactEmailsPagination', data.total, contactEmailsOffset, 'loadContactEmails');
} catch (error) {
host.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
document.getElementById('contactEmailsPagination').innerHTML = '';
}
}
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 || '-';
document.getElementById('phone').innerHTML = renderNumberActions(contact.phone, false, contact);
document.getElementById('mobile').innerHTML = renderNumberActions(contact.mobile, true, contact);
// 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;
populateSessionCompanySelect(contact);
const primaryCompany = getPrimaryCompany(contact);
primaryCustomerId = primaryCompany ? primaryCompany.id : null;
updateCustomerLinks(primaryCustomerId);
toggleNextcloudTab(primaryCustomerId);
// 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);
}
// Load Alert Notes for this contact
loadAndDisplayAlerts('contact', contact.id, 'inline', 'alert-notes-container');
}
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');
}
}
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;
}
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');
}
}
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('');
}
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(', ');
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 || '—');
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>
<td>${phoneOrMobile}</td>
<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">
<a class="btn btn-sm btn-outline-primary" href="/sag/${o.id}/v3">
<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');
}
}
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');
}
}
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';
}
}
async function loadKontaktHistory() {
const container = document.getElementById('kontaktContainer');
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/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'
: 'Ingen kontakthistorik fundet';
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>'
: 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>';
const dirOrStatus = item.type === 'sms'
? (item.sms_status || '-')
: item.type === 'merge'
? 'Gennemført'
: (item.direction === 'outbound' ? 'Udgående' : 'Indgående');
const message = item.type === 'sms' || item.type === 'merge'
? 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>
`;
}
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);
}
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';
}
}
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() {
// 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);
}
}
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}`);
}
}
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 %}