- Implement SmsService class for sending SMS via CPSMS API. - Add SMS sending functionality in the frontend with validation and user feedback. - Create database migrations for SMS message storage and telephony features. - Introduce telephony settings and user-specific configurations for click-to-call functionality. - Enhance user experience with toast notifications for incoming calls and actions.
2329 lines
96 KiB
HTML
2329 lines
96 KiB
HTML
{% 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;
|
|
}
|
|
</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="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>
|
|
|
|
<!-- 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="#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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
const contactId = parseInt(window.location.pathname.split('/').pop());
|
|
let contactData = null;
|
|
let primaryCustomerId = null;
|
|
let kontaktHistoryItems = [];
|
|
let kontaktHistoryFilter = 'all';
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadContact();
|
|
loadCompaniesForSelect();
|
|
|
|
// 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();
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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="/opportunities/${o.id}">
|
|
<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 opkald eller SMS 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>'
|
|
: '<span class="badge bg-success-subtle text-success-emphasis">Opkald</span>';
|
|
|
|
const dirOrStatus = item.type === 'sms'
|
|
? (item.sms_status || '-')
|
|
: (item.direction === 'outbound' ? 'Udgående' : 'Indgående');
|
|
|
|
const message = item.type === 'sms'
|
|
? 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 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 %}
|