- 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.
4320 lines
180 KiB
HTML
4320 lines
180 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
||
|
||
{% block title %}Kunde Detaljer - BMC Hub{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.customer-header {
|
||
background: var(--accent);
|
||
color: white;
|
||
padding: 3rem 2rem;
|
||
border-radius: 12px;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.customer-avatar-large {
|
||
width: 80px;
|
||
height: 80px;
|
||
border-radius: 16px;
|
||
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;
|
||
}
|
||
|
||
.contact-card {
|
||
background: var(--bg-card);
|
||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||
border-radius: 12px;
|
||
padding: 1.5rem;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.contact-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);
|
||
}
|
||
|
||
.activity-item {
|
||
padding: 1.5rem;
|
||
border-left: 3px solid var(--accent-light);
|
||
margin-left: 1rem;
|
||
position: relative;
|
||
}
|
||
|
||
.subscription-item {
|
||
transition: all 0.3s ease;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.subscription-item:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
|
||
}
|
||
|
||
.subscription-item.expanded {
|
||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15) !important;
|
||
border-color: var(--accent) !important;
|
||
}
|
||
|
||
.subscription-column {
|
||
min-height: 200px;
|
||
}
|
||
|
||
.column-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
background: white;
|
||
padding: 1rem;
|
||
margin: -1rem -1rem 1rem -1rem;
|
||
border-radius: 8px 8px 0 0;
|
||
}
|
||
|
||
.line-item-details {
|
||
background: rgba(0, 0, 0, 0.02);
|
||
border-radius: 6px;
|
||
padding: 0.5rem;
|
||
}
|
||
|
||
.expandable-item {
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.expandable-item:hover {
|
||
background: rgba(0, 0, 0, 0.02);
|
||
}
|
||
|
||
.chevron-icon {
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.activity-item::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: -8px;
|
||
top: 1.5rem;
|
||
width: 12px;
|
||
height: 12px;
|
||
background: var(--accent);
|
||
border-radius: 50%;
|
||
border: 3px solid var(--bg-body);
|
||
}
|
||
|
||
/* Enhanced Edit Button */
|
||
.btn-edit-customer {
|
||
background: linear-gradient(135deg, #0f4c75 0%, #1a5f8e 100%);
|
||
color: white;
|
||
border: none;
|
||
padding: 0.75rem 1.75rem;
|
||
border-radius: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.3px;
|
||
box-shadow: 0 4px 15px rgba(15, 76, 117, 0.3);
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.btn-edit-customer::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||
transition: left 0.5s;
|
||
}
|
||
|
||
.btn-edit-customer:hover {
|
||
background: linear-gradient(135deg, #1a5f8e 0%, #0f4c75 100%);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 20px rgba(15, 76, 117, 0.4);
|
||
color: white;
|
||
}
|
||
|
||
.btn-edit-customer:hover::before {
|
||
left: 100%;
|
||
}
|
||
|
||
.btn-edit-customer:active {
|
||
transform: translateY(0);
|
||
box-shadow: 0 2px 8px rgba(15, 76, 117, 0.3);
|
||
}
|
||
|
||
.btn-edit-customer i {
|
||
transition: transform 0.3s;
|
||
}
|
||
|
||
.btn-edit-customer:hover i {
|
||
transform: rotate(-15deg) scale(1.1);
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<!-- Customer Header -->
|
||
<div class="customer-header">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div class="d-flex align-items-center">
|
||
<div class="customer-avatar-large me-4" id="customerAvatar">?</div>
|
||
<div>
|
||
<h1 class="fw-bold mb-2" id="customerName">Loading...</h1>
|
||
<div class="d-flex gap-3 align-items-center flex-wrap">
|
||
<span id="customerCity"></span>
|
||
<span class="badge bg-white bg-opacity-20" id="customerStatus"></span>
|
||
<span class="badge bg-white bg-opacity-20" id="customerSource"></span>
|
||
<span id="bmcLockedBadge"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="d-flex gap-2">
|
||
<button class="btn btn-edit-customer" onclick="editCustomer()">
|
||
<i class="bi bi-pencil-square me-2"></i>Rediger Kunde
|
||
</button>
|
||
<button class="btn btn-light btn-sm" onclick="window.location.href='/customers'">
|
||
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bankruptcy Alert -->
|
||
<div id="bankruptcyAlert" class="alert alert-danger d-flex align-items-center mb-4 d-none border-0 shadow-sm" role="alert" style="background-color: #ffeaea; color: #842029;">
|
||
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2 animate__animated animate__pulse animate__infinite"></i>
|
||
<div class="flex-grow-1">
|
||
<h5 class="alert-heading mb-1 fw-bold">⚠️ KONKURS ALARM</h5>
|
||
<div>Der er registreret en aktiv konkurs-notifikation på denne kunde. Stop levering og kontakt bogholderiet.</div>
|
||
<div class="mt-2 small">
|
||
<strong>Emne:</strong> <span id="bankruptcySubject" class="fst-italic"></span><br>
|
||
<strong>Modtaget:</strong> <span id="bankruptcyDate"></span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<a id="bankruptcyLink" href="#" class="btn btn-sm btn-danger px-3">Se Email <i class="bi bi-arrow-right ms-1"></i></a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Data Consistency Alert -->
|
||
<div id="consistencyAlert" class="alert alert-warning alert-dismissible fade show d-none mt-4" role="alert">
|
||
<div class="d-flex align-items-center">
|
||
<i class="bi bi-exclamation-triangle-fill fs-4 me-3"></i>
|
||
<div class="flex-grow-1">
|
||
<strong>Data Uoverensstemmelser Fundet!</strong>
|
||
<p class="mb-0">
|
||
Der er <span id="discrepancyCount" class="fw-bold">0</span> felter med forskellige værdier mellem BMC Hub, vTiger og e-conomic.
|
||
</p>
|
||
</div>
|
||
<button type="button" class="btn btn-warning ms-3" onclick="showConsistencyModal()">
|
||
<i class="bi bi-search me-2"></i>Sammenlign
|
||
</button>
|
||
</div>
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||
</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="#contacts">
|
||
<i class="bi bi-people"></i>Kontakter
|
||
</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="#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="#activity">
|
||
<i class="bi bi-clock-history"></i>Aktivitet
|
||
</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">Virksomhedsoplysninger</h5>
|
||
<div class="info-row">
|
||
<span class="info-label">CVR-nummer</span>
|
||
<span class="info-value" id="cvrNumber">-</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-label">Adresse</span>
|
||
<span class="info-value" id="address">-</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-label">Postnummer & By</span>
|
||
<span class="info-value" id="postalCity">-</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">Hjemmeside</span>
|
||
<span class="info-value" id="website">-</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-label">Kunde Wiki</span>
|
||
<span class="info-value" id="wikiLink">-</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-lg-6">
|
||
<div class="info-card">
|
||
<h5 class="fw-bold mb-4">Økonomiske Oplysninger</h5>
|
||
<div class="info-row">
|
||
<span class="info-label">e-conomic Kundenr.</span>
|
||
<span class="info-value" id="economicNumber">-</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-label">Betalingsbetingelser</span>
|
||
<span class="info-value" id="paymentTerms">-</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-label">Moms Zone</span>
|
||
<span class="info-value" id="vatZone">-</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-label">Valuta</span>
|
||
<span class="info-value" id="currency">-</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-label">EAN-nummer</span>
|
||
<span class="info-value" id="ean">-</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-label">Spærret</span>
|
||
<span class="info-value" id="barred">-</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-12">
|
||
<div class="info-card">
|
||
<h5 class="fw-bold mb-3">Integration</h5>
|
||
<div class="info-row">
|
||
<span class="info-label">El-selskab</span>
|
||
<span class="info-value" id="utilityCompanyName">-</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-label">Kontakt</span>
|
||
<span class="info-value text-muted small" id="utilityCompanyContact">-</span>
|
||
</div>
|
||
<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 class="info-row">
|
||
<span class="info-label">vTiger Sidst Synkroniseret</span>
|
||
<span class="info-value" id="vtigerSyncAt">-</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="info-row">
|
||
<span class="info-label">e-conomic Sidst Synkroniseret</span>
|
||
<span class="info-value" id="economicSyncAt">-</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-label">Oprettet</span>
|
||
<span class="info-value" id="createdAt">-</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-12">
|
||
<div class="info-card">
|
||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||
<h5 class="fw-bold mb-0">Tags</h5>
|
||
<button class="btn btn-sm btn-outline-primary" onclick="openCustomerTagModal()">
|
||
<i class="bi bi-tag me-1"></i>Tilføj tag
|
||
</button>
|
||
</div>
|
||
<div id="customerTagsContainer" class="d-flex flex-wrap gap-2"></div>
|
||
<div id="customerTagsEmpty" class="text-muted small">Ingen tags tilføjet endnu.</div>
|
||
</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">Kontaktpersoner</h5>
|
||
<button class="btn btn-primary btn-sm" onclick="showAddContactModal()">
|
||
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
|
||
</button>
|
||
</div>
|
||
<div class="table-responsive" id="contactsContainer">
|
||
<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>Mobil</th>
|
||
<th>Primær</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td colspan="6" class="text-center py-4">
|
||
<div class="spinner-border text-primary"></div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</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="customerKontaktFilterAll" onclick="setCustomerKontaktFilter('all')">Alle</button>
|
||
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterSms" onclick="setCustomerKontaktFilter('sms')">SMS</button>
|
||
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterCall" onclick="setCustomerKontaktFilter('call')">Opkald</button>
|
||
</div>
|
||
</div>
|
||
<div id="customerKontaktContainer">
|
||
<div class="text-center py-5">
|
||
<div class="spinner-border text-primary"></div>
|
||
</div>
|
||
</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>
|
||
<div class="btn-group">
|
||
<button class="btn btn-success btn-sm" onclick="showCreateSubscriptionModal()">
|
||
<i class="bi bi-plus-circle me-2"></i>Opret Abonnement
|
||
</button>
|
||
<button class="btn btn-primary btn-sm" onclick="loadSubscriptions()">
|
||
<i class="bi bi-arrow-repeat me-2"></i>Opdater fra vTiger
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Internal Comment Box -->
|
||
<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 fra vTiger...</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 kunden</small>
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" onclick="openCustomerOpportunityModal()">
|
||
<i class="bi bi-plus-lg me-2"></i>Opret mulighed
|
||
</button>
|
||
</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="customerOpportunitiesTable">
|
||
<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>
|
||
|
||
<!-- Search field -->
|
||
<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>
|
||
<!-- Months will be added dynamically -->
|
||
</tr>
|
||
</thead>
|
||
<tbody id="matrixBodyRows">
|
||
<!-- Rows will be added dynamically -->
|
||
</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 kunde</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 kunden</small>
|
||
</div>
|
||
<div class="d-flex gap-2">
|
||
<a href="/app/locations/wizard" class="btn btn-sm btn-primary">
|
||
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
||
</a>
|
||
<a href="/app/locations" class="btn btn-sm btn-outline-secondary">
|
||
<i class="bi bi-list-ul me-2"></i>Se alle
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<div id="customerLocationsList" class="list-group mb-3"></div>
|
||
<div id="customerLocationsEmpty" class="text-center py-5 text-muted">
|
||
Ingen lokationer fundet for denne kunde
|
||
</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">Hardware knyttet til kunden</small>
|
||
</div>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<a class="btn btn-sm btn-primary" href="/hardware/new">
|
||
<i class="bi bi-plus-lg me-2"></i>Tilføj hardware
|
||
</a>
|
||
<label for="hardwareGroupBy" class="form-label mb-0 small text-muted">Gruppér efter</label>
|
||
<select class="form-select form-select-sm" id="hardwareGroupBy" style="min-width: 180px;">
|
||
<option value="location">Lokation</option>
|
||
<option value="type">Type</option>
|
||
<option value="model">Model/Version</option>
|
||
<option value="vendor">Leverandør</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="table-responsive" id="customerHardwareContainer">
|
||
<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>
|
||
<tr>
|
||
<td colspan="6" class="text-center py-4">
|
||
<div class="spinner-border text-primary"></div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div id="customerHardwareEmpty" class="text-center py-5 text-muted d-none">
|
||
Ingen hardware fundet for denne kunde
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Nextcloud Tab -->
|
||
<div class="tab-pane fade d-none" id="nextcloud">
|
||
{% include "modules/nextcloud/templates/tab.html" %}
|
||
</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">
|
||
<input type="text" class="form-control form-control-sm" id="customerSessionDescription" placeholder="Beskrivelse (valgfri)" style="min-width: 240px;">
|
||
<button class="btn btn-primary btn-sm" onclick="startCustomerSession()">
|
||
<i class="bi bi-play-circle me-2"></i>Start Session
|
||
</button>
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="loadCustomerSessions()">
|
||
<i class="bi bi-arrow-repeat me-2"></i>Opdater
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="customerSessionAlert" class="alert alert-info d-none"></div>
|
||
<div class="row g-3" id="customerSessionsContainer">
|
||
<div class="col-12 text-center py-5">
|
||
<div class="spinner-border text-primary"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Activity Tab -->
|
||
<div class="tab-pane fade" id="activity">
|
||
<h5 class="fw-bold mb-4">Aktivitet</h5>
|
||
<div id="activityContainer">
|
||
<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>
|
||
<!--
|
||
<button class="btn btn-primary btn-sm" onclick="showUploadConversationModal()">
|
||
<i class="bi bi-upload me-2"></i>Upload Samtale
|
||
</button>
|
||
-->
|
||
</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">
|
||
<!-- Loaded via JS -->
|
||
<div class="text-center py-5">
|
||
<span class="text-muted">Henter samtaler...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Edit Customer Modal -->
|
||
<div class="modal fade" id="editCustomerModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-primary text-white">
|
||
<h5 class="modal-title">
|
||
<i class="bi bi-pencil-square me-2"></i>Rediger Kunde
|
||
</h5>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="editCustomerForm">
|
||
<div class="row g-3">
|
||
<!-- Basic Info -->
|
||
<div class="col-12">
|
||
<h6 class="text-muted text-uppercase small fw-bold mb-3">
|
||
<i class="bi bi-building me-2"></i>Grundlæggende Oplysninger
|
||
</h6>
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<label for="editName" class="form-label">Virksomhedsnavn *</label>
|
||
<input type="text" class="form-control" id="editName" required>
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<label for="editCvrNumber" class="form-label">CVR-nummer</label>
|
||
<input type="text" class="form-control" id="editCvrNumber" maxlength="20">
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<label for="editEmail" class="form-label">Email</label>
|
||
<input type="email" class="form-control" id="editEmail">
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<label for="editInvoiceEmail" class="form-label">Faktura Email</label>
|
||
<input type="email" class="form-control" id="editInvoiceEmail">
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<label for="editPhone" class="form-label">Telefon</label>
|
||
<input type="tel" class="form-control" id="editPhone">
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<label for="editMobilePhone" class="form-label">Mobil</label>
|
||
<input type="tel" class="form-control" id="editMobilePhone">
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<label for="editWebsite" class="form-label">Hjemmeside</label>
|
||
<input type="url" class="form-control" id="editWebsite" placeholder="https://">
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<label for="editWikiSlug" class="form-label">Wiki slug</label>
|
||
<input type="text" class="form-control" id="editWikiSlug" placeholder="norva24">
|
||
<div class="form-text">Bygger https://wiki.bmcnetworks.dk/en/Kunder/{slug}</div>
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<label for="editCountry" class="form-label">Land</label>
|
||
<select class="form-select" id="editCountry">
|
||
<option value="DK">Danmark</option>
|
||
<option value="NO">Norge</option>
|
||
<option value="SE">Sverige</option>
|
||
<option value="DE">Tyskland</option>
|
||
<option value="GB">Storbritannien</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Address -->
|
||
<div class="col-12 mt-4">
|
||
<h6 class="text-muted text-uppercase small fw-bold mb-3">
|
||
<i class="bi bi-geo-alt me-2"></i>Adresse
|
||
</h6>
|
||
</div>
|
||
|
||
<div class="col-12">
|
||
<label for="editAddress" class="form-label">Adresse</label>
|
||
<input type="text" class="form-control" id="editAddress">
|
||
</div>
|
||
|
||
<div class="col-md-4">
|
||
<label for="editPostalCode" class="form-label">Postnummer</label>
|
||
<input type="text" class="form-control" id="editPostalCode" maxlength="10">
|
||
</div>
|
||
|
||
<div class="col-md-8">
|
||
<label for="editCity" class="form-label">By</label>
|
||
<input type="text" class="form-control" id="editCity">
|
||
</div>
|
||
|
||
<!-- Status -->
|
||
<div class="col-12 mt-4">
|
||
<div class="form-check form-switch">
|
||
<input class="form-check-input" type="checkbox" id="editIsActive" checked>
|
||
<label class="form-check-label" for="editIsActive">
|
||
<strong>Aktiv kunde</strong>
|
||
<div class="small text-muted">Deaktiver for at skjule kunden fra lister</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||
<i class="bi bi-x-lg me-2"></i>Annuller
|
||
</button>
|
||
<button type="button" class="btn btn-primary" onclick="saveCustomerEdit()">
|
||
<i class="bi bi-check-lg me-2"></i>Gem Ændringer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Customer Tag Modal -->
|
||
<div class="modal fade" id="customerTagModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-primary text-white">
|
||
<h5 class="modal-title"><i class="bi bi-tag me-2"></i>Tilføj tag</h5>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">Vælg tag</label>
|
||
<select class="form-select" id="customerTagSelect"></select>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||
<button type="button" class="btn btn-primary" onclick="addCustomerTag()">Tilføj</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Nextcloud Create User Modal -->
|
||
<div class="modal fade" id="nextcloudCreateUserModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-primary text-white">
|
||
<h5 class="modal-title"><i class="bi bi-person-plus me-2"></i>Opret Nextcloud bruger</h5>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">Søg eksisterende bruger</label>
|
||
<input type="text" class="form-control" id="ncCreateSearch" placeholder="Skriv for at søge">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Vælg bruger (til grupper)</label>
|
||
<select class="form-select" id="ncCreateUserSelect"></select>
|
||
<div class="form-text">Vælg en eksisterende bruger for at kopiere grupper.</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Brugernavn *</label>
|
||
<input type="text" class="form-control" id="ncCreateUid" placeholder="f.eks. fornavn.efternavn" required>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Navn (display)</label>
|
||
<input type="text" class="form-control" id="ncCreateDisplayName" placeholder="Visningsnavn">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Email</label>
|
||
<input type="email" class="form-control" id="ncCreateEmail" placeholder="mail@firma.dk">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Grupper</label>
|
||
<select class="form-select" id="ncCreateGroups" multiple size="6"></select>
|
||
<div class="form-text">Hold Cmd/Ctrl nede for at vælge flere.</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||
<button type="button" class="btn btn-primary" onclick="createNextcloudUser()">Opret bruger</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Nextcloud Reset Password Modal -->
|
||
<div class="modal fade" id="nextcloudResetPasswordModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-secondary text-white">
|
||
<h5 class="modal-title"><i class="bi bi-key me-2"></i>Reset Nextcloud password</h5>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">Søg bruger</label>
|
||
<input type="text" class="form-control" id="ncResetSearch" placeholder="Skriv for at søge">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Vælg bruger</label>
|
||
<select class="form-select" id="ncResetUserSelect"></select>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Brugernavn *</label>
|
||
<input type="text" class="form-control" id="ncResetUid" placeholder="f.eks. fornavn.efternavn" required readonly>
|
||
</div>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="checkbox" id="ncResetSendEmail" checked>
|
||
<label class="form-check-label" for="ncResetSendEmail">Send email med nyt password</label>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||
<button type="button" class="btn btn-outline-secondary" onclick="resetNextcloudPassword()">Reset</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Nextcloud Disable User Modal -->
|
||
<div class="modal fade" id="nextcloudDisableUserModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-danger text-white">
|
||
<h5 class="modal-title"><i class="bi bi-person-x me-2"></i>Luk Nextcloud bruger</h5>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">Søg bruger</label>
|
||
<input type="text" class="form-control" id="ncDisableSearch" placeholder="Skriv for at søge">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Vælg bruger</label>
|
||
<select class="form-select" id="ncDisableUserSelect"></select>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Brugernavn *</label>
|
||
<input type="text" class="form-control" id="ncDisableUid" placeholder="f.eks. fornavn.efternavn" required readonly>
|
||
</div>
|
||
<div class="alert alert-warning mb-0">
|
||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||
Brugeren bliver deaktiveret i Nextcloud.
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||
<button type="button" class="btn btn-danger" onclick="disableNextcloudUser()">Luk bruger</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subscription Modal -->
|
||
<div class="modal fade" id="subscriptionModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="subscriptionModalLabel">Opret Abonnement</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="subscriptionForm">
|
||
<input type="hidden" id="subscriptionId">
|
||
|
||
<div class="mb-3">
|
||
<label for="subjectInput" class="form-label">Emne/Navn *</label>
|
||
<input type="text" class="form-control" id="subjectInput" required>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6 mb-3">
|
||
<label for="startdateInput" class="form-label">Startdato *</label>
|
||
<input type="date" class="form-control" id="startdateInput" required>
|
||
</div>
|
||
<div class="col-md-6 mb-3">
|
||
<label for="enddateInput" class="form-label">Slutdato</label>
|
||
<input type="date" class="form-control" id="enddateInput">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label for="frequencyInput" class="form-label">Frekvens *</label>
|
||
<select class="form-select" id="frequencyInput" required>
|
||
<option value="Monthly">Månedlig</option>
|
||
<option value="Quarterly">Kvartalsvis</option>
|
||
<option value="Half-Yearly">Halvårlig</option>
|
||
<option value="Yearly">Årlig</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label for="statusInput" class="form-label">Status *</label>
|
||
<select class="form-select" id="statusInput" required>
|
||
<option value="Active">Aktiv</option>
|
||
<option value="Stopped">Stoppet</option>
|
||
<option value="Cancelled">Annulleret</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="alert alert-info">
|
||
<i class="bi bi-info-circle me-2"></i>
|
||
Produkter skal tilføjes i vTiger efter oprettelse
|
||
</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="saveSubscription()">Gem</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Customer Opportunity Modal -->
|
||
<div class="modal fade" id="customerOpportunityModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">Opret mulighed</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="customerOpportunityForm">
|
||
<div class="row g-3">
|
||
<div class="col-md-6">
|
||
<label class="form-label">Titel *</label>
|
||
<input type="text" class="form-control" id="customerOpportunityTitle" required>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Stage</label>
|
||
<select class="form-select" id="customerOpportunityStage"></select>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Beløb</label>
|
||
<input type="number" step="0.01" class="form-control" id="customerOpportunityAmount">
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Valuta</label>
|
||
<select class="form-select" id="customerOpportunityCurrency">
|
||
<option value="DKK">DKK</option>
|
||
<option value="EUR">EUR</option>
|
||
<option value="USD">USD</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Forventet lukning</label>
|
||
<input type="date" class="form-control" id="customerOpportunityCloseDate">
|
||
</div>
|
||
<div class="col-12">
|
||
<label class="form-label">Beskrivelse</label>
|
||
<textarea class="form-control" id="customerOpportunityDescription" rows="3"></textarea>
|
||
</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="createCustomerOpportunity()">Gem</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Worklog Suggestion Modal -->
|
||
<div class="modal fade" id="customerWorklogSuggestionModal" 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="customerWorklogSuggestionBody"></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 customerId = parseInt(window.location.pathname.split('/').pop());
|
||
let customerData = null;
|
||
let pipelineStages = [];
|
||
let allTagsCache = [];
|
||
let customerKontaktItems = [];
|
||
let customerKontaktFilter = 'all';
|
||
|
||
let eventListenersAdded = false;
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
if (eventListenersAdded) {
|
||
console.log('Event listeners already added, skipping...');
|
||
return;
|
||
}
|
||
|
||
loadCustomer();
|
||
|
||
// Load contacts when tab is shown
|
||
const contactsTab = document.querySelector('a[href="#contacts"]');
|
||
if (contactsTab) {
|
||
contactsTab.addEventListener('shown.bs.tab', () => {
|
||
loadContacts();
|
||
}, { once: false });
|
||
}
|
||
|
||
const kontaktTab = document.querySelector('a[href="#kontakt"]');
|
||
if (kontaktTab) {
|
||
kontaktTab.addEventListener('shown.bs.tab', () => {
|
||
loadCustomerKontakt();
|
||
}, { once: false });
|
||
}
|
||
|
||
// Load subscriptions when tab is shown
|
||
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
|
||
if (subscriptionsTab) {
|
||
subscriptionsTab.addEventListener('shown.bs.tab', () => {
|
||
loadSubscriptions();
|
||
loadInternalComment();
|
||
}, { once: false });
|
||
}
|
||
|
||
// Load pipeline when tab is shown
|
||
const pipelineTab = document.querySelector('a[href="#pipeline"]');
|
||
if (pipelineTab) {
|
||
pipelineTab.addEventListener('shown.bs.tab', () => {
|
||
loadCustomerPipeline();
|
||
}, { once: false });
|
||
}
|
||
|
||
// Load locations when tab is shown
|
||
const locationsTab = document.querySelector('a[href="#locations"]');
|
||
if (locationsTab) {
|
||
locationsTab.addEventListener('shown.bs.tab', () => {
|
||
loadCustomerLocations();
|
||
}, { once: false });
|
||
}
|
||
|
||
// Load hardware when tab is shown
|
||
const hardwareTab = document.querySelector('a[href="#hardware"]');
|
||
if (hardwareTab) {
|
||
hardwareTab.addEventListener('shown.bs.tab', () => {
|
||
loadCustomerHardware();
|
||
}, { once: false });
|
||
}
|
||
|
||
// Load activity when tab is shown
|
||
const activityTab = document.querySelector('a[href="#activity"]');
|
||
if (activityTab) {
|
||
activityTab.addEventListener('shown.bs.tab', () => {
|
||
loadActivity();
|
||
}, { once: false });
|
||
}
|
||
|
||
// Load conversations when tab is shown
|
||
const conversationsTab = document.querySelector('a[href="#conversations"]');
|
||
if (conversationsTab) {
|
||
conversationsTab.addEventListener('shown.bs.tab', () => {
|
||
loadConversations();
|
||
}, { once: false });
|
||
}
|
||
|
||
// Load Nextcloud status when tab is shown
|
||
const nextcloudTab = document.querySelector('a[href="#nextcloud"]');
|
||
if (nextcloudTab) {
|
||
nextcloudTab.addEventListener('shown.bs.tab', () => {
|
||
loadNextcloudStatus();
|
||
}, { once: false });
|
||
}
|
||
|
||
// Load remote sessions when tab is shown
|
||
const remoteSessionsTab = document.querySelector('a[href="#remote-sessions"]');
|
||
if (remoteSessionsTab) {
|
||
remoteSessionsTab.addEventListener('shown.bs.tab', () => {
|
||
loadCustomerSessions();
|
||
}, { once: false });
|
||
}
|
||
|
||
eventListenersAdded = true;
|
||
});
|
||
|
||
async function loadCustomer() {
|
||
try {
|
||
const response = await fetch(`/api/v1/customers/${customerId}`);
|
||
if (!response.ok) {
|
||
throw new Error('Customer not found');
|
||
}
|
||
|
||
customerData = await response.json();
|
||
displayCustomer(customerData);
|
||
|
||
await loadUtilityCompany();
|
||
await loadCustomerTags();
|
||
|
||
// Check data consistency
|
||
await checkDataConsistency();
|
||
} catch (error) {
|
||
console.error('Failed to load customer:', error);
|
||
alert('Kunne ikke indlæse kunde');
|
||
window.location.href = '/customers';
|
||
}
|
||
}
|
||
|
||
function displayCustomer(customer) {
|
||
// Update page title
|
||
document.title = `${customer.name} - BMC Hub`;
|
||
|
||
// Bankruptcy Alert
|
||
const bankruptcyAlert = document.getElementById('bankruptcyAlert');
|
||
if (customer.bankruptcy_alert) {
|
||
document.getElementById('bankruptcySubject').textContent = customer.bankruptcy_alert.subject;
|
||
document.getElementById('bankruptcyDate').textContent = new Date(customer.bankruptcy_alert.received_date).toLocaleString('da-DK');
|
||
document.getElementById('bankruptcyLink').href = `/emails?id=${customer.bankruptcy_alert.id}`;
|
||
bankruptcyAlert.classList.remove('d-none');
|
||
|
||
// Also add a badge to the header
|
||
const extraBadge = document.createElement('span');
|
||
extraBadge.className = 'badge bg-danger animate__animated animate__pulse animate__infinite ms-2';
|
||
extraBadge.innerHTML = '<i class="bi bi-shield-exclamation me-1"></i>KONKURS';
|
||
document.getElementById('customerStatus').parentNode.appendChild(extraBadge);
|
||
} else {
|
||
bankruptcyAlert.classList.add('d-none');
|
||
}
|
||
|
||
// Header
|
||
document.getElementById('customerAvatar').textContent = getInitials(customer.name);
|
||
document.getElementById('customerName').textContent = customer.name;
|
||
document.getElementById('customerCity').textContent = customer.city || '';
|
||
|
||
const statusBadge = customer.is_active
|
||
? '<i class="bi bi-check-circle me-1"></i>Aktiv'
|
||
: '<i class="bi bi-x-circle me-1"></i>Inaktiv';
|
||
document.getElementById('customerStatus').innerHTML = statusBadge;
|
||
|
||
const sourceBadge = customer.vtiger_id
|
||
? '<i class="bi bi-cloud me-1"></i>vTiger'
|
||
: '<i class="bi bi-hdd me-1"></i>Lokal';
|
||
document.getElementById('customerSource').innerHTML = sourceBadge;
|
||
|
||
// BMC Låst badge
|
||
const bmcLockedBadge = document.getElementById('bmcLockedBadge');
|
||
if (customer.bmc_locked) {
|
||
bmcLockedBadge.innerHTML = `
|
||
<span class="badge bg-danger bg-opacity-90 px-3 py-2" style="font-size: 0.9rem;" title="Dette firma skal faktureres fra BMC">
|
||
<i class="bi bi-lock-fill me-2"></i>
|
||
<strong>BMC LÅST</strong>
|
||
</span>
|
||
`;
|
||
} else {
|
||
bmcLockedBadge.innerHTML = '';
|
||
}
|
||
|
||
// Company Information
|
||
document.getElementById('cvrNumber').textContent = customer.cvr_number || '-';
|
||
document.getElementById('address').textContent = customer.address || '-';
|
||
document.getElementById('postalCity').textContent = customer.postal_code && customer.city
|
||
? `${customer.postal_code} ${customer.city}`
|
||
: customer.city || '-';
|
||
document.getElementById('email').textContent = customer.email || '-';
|
||
document.getElementById('phone').innerHTML = renderCustomerCallNumber(customer.phone);
|
||
document.getElementById('website').textContent = customer.website || '-';
|
||
|
||
const wikiEl = document.getElementById('wikiLink');
|
||
if (wikiEl) {
|
||
const slug = (customer.wiki_slug || '').trim();
|
||
if (slug) {
|
||
const encoded = encodeURIComponent(slug);
|
||
const url = `https://wiki.bmcnetworks.dk/en/Kunder/${encoded}/`;
|
||
wikiEl.innerHTML = `<a href="${url}" target="_blank" rel="noopener">/en/Kunder/${escapeHtml(slug)}</a>`;
|
||
} else {
|
||
wikiEl.textContent = '-';
|
||
}
|
||
}
|
||
|
||
// Economic Information
|
||
document.getElementById('economicNumber').textContent = customer.economic_customer_number || '-';
|
||
document.getElementById('paymentTerms').textContent = customer.payment_terms_days
|
||
? `${customer.payment_terms_days} dage netto`
|
||
: '-';
|
||
document.getElementById('vatZone').textContent = customer.vat_zone || '-';
|
||
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
|
||
document.getElementById('ean').textContent = customer.ean || '-';
|
||
document.getElementById('barred').innerHTML = customer.barred
|
||
? '<span class="badge bg-danger">Ja</span>'
|
||
: '<span class="badge bg-success">Nej</span>';
|
||
|
||
// Integration
|
||
document.getElementById('vtigerId').textContent = customer.vtiger_id || '-';
|
||
document.getElementById('vtigerSyncAt').textContent = customer.vtiger_last_sync_at
|
||
? new Date(customer.vtiger_last_sync_at).toLocaleString('da-DK')
|
||
: '-';
|
||
document.getElementById('economicSyncAt').textContent = customer.economic_last_sync_at
|
||
? new Date(customer.economic_last_sync_at).toLocaleString('da-DK')
|
||
: '-';
|
||
document.getElementById('createdAt').textContent = new Date(customer.created_at).toLocaleString('da-DK');
|
||
}
|
||
|
||
function renderCustomerCallNumber(number) {
|
||
const clean = String(number || '').trim();
|
||
if (!clean) return '-';
|
||
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="customerDetailCallViaYealink('${escapeHtml(clean)}')">Ring op</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
let customerDetailCurrentUserId = null;
|
||
|
||
async function ensureCustomerDetailCurrentUserId() {
|
||
if (customerDetailCurrentUserId !== null) return customerDetailCurrentUserId;
|
||
try {
|
||
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
||
if (!res.ok) return null;
|
||
const me = await res.json();
|
||
customerDetailCurrentUserId = Number(me?.id) || null;
|
||
return customerDetailCurrentUserId;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function customerDetailCallViaYealink(number) {
|
||
const clean = String(number || '').trim();
|
||
if (!clean || clean === '-') {
|
||
alert('Intet gyldigt nummer at ringe til');
|
||
return;
|
||
}
|
||
|
||
const userId = await ensureCustomerDetailCurrentUserId();
|
||
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');
|
||
}
|
||
}
|
||
|
||
async function loadCustomerTags() {
|
||
try {
|
||
const response = await fetch(`/api/v1/tags/entity/customer/${customerId}`);
|
||
if (!response.ok) return;
|
||
|
||
const tags = await response.json();
|
||
const hasNextcloud = (tags || []).some(tag => (tag.name || '').toLowerCase() === 'nextcloud');
|
||
|
||
const nextcloudNav = document.getElementById('nextcloudTabNav');
|
||
const nextcloudPane = document.getElementById('nextcloud');
|
||
|
||
if (hasNextcloud) {
|
||
nextcloudNav?.classList.remove('d-none');
|
||
nextcloudPane?.classList.remove('d-none');
|
||
} else {
|
||
nextcloudNav?.classList.add('d-none');
|
||
nextcloudPane?.classList.add('d-none');
|
||
}
|
||
|
||
renderCustomerTags(tags || []);
|
||
} catch (error) {
|
||
console.error('Failed to load customer tags:', error);
|
||
}
|
||
}
|
||
|
||
async function loadCustomerLocations() {
|
||
const list = document.getElementById('customerLocationsList');
|
||
const empty = document.getElementById('customerLocationsEmpty');
|
||
|
||
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-customer/${customerId}`);
|
||
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');
|
||
}
|
||
}
|
||
|
||
function renderCustomerTags(tags) {
|
||
const container = document.getElementById('customerTagsContainer');
|
||
const emptyState = document.getElementById('customerTagsEmpty');
|
||
|
||
if (!container || !emptyState) return;
|
||
|
||
if (!tags.length) {
|
||
container.innerHTML = '';
|
||
emptyState.classList.remove('d-none');
|
||
return;
|
||
}
|
||
|
||
emptyState.classList.add('d-none');
|
||
container.innerHTML = tags.map(tag => `
|
||
<span class="badge" style="background:${tag.color || '#0f4c75'}; color: white;">
|
||
${escapeHtml(tag.name)}
|
||
<button class="btn btn-sm btn-link text-white p-0 ms-2" onclick="removeCustomerTag(${tag.id})" title="Fjern tag">
|
||
<i class="bi bi-x"></i>
|
||
</button>
|
||
</span>
|
||
`).join('');
|
||
}
|
||
|
||
async function openCustomerTagModal() {
|
||
const modal = new bootstrap.Modal(document.getElementById('customerTagModal'));
|
||
await loadAllTags();
|
||
modal.show();
|
||
}
|
||
|
||
async function loadAllTags() {
|
||
try {
|
||
const response = await fetch('/api/v1/tags?is_active=true');
|
||
if (!response.ok) return;
|
||
|
||
allTagsCache = await response.json();
|
||
const currentTagsResponse = await fetch(`/api/v1/tags/entity/customer/${customerId}`);
|
||
const currentTags = currentTagsResponse.ok ? await currentTagsResponse.json() : [];
|
||
const currentTagIds = new Set(currentTags.map(tag => tag.id));
|
||
|
||
const select = document.getElementById('customerTagSelect');
|
||
if (!select) return;
|
||
|
||
const options = allTagsCache
|
||
.filter(tag => !currentTagIds.has(tag.id))
|
||
.map(tag => `<option value="${tag.id}">${escapeHtml(tag.name)}</option>`)
|
||
.join('');
|
||
|
||
select.innerHTML = options || '<option value="">Ingen flere tags</option>';
|
||
} catch (error) {
|
||
console.error('Failed to load tags:', error);
|
||
}
|
||
}
|
||
|
||
async function addCustomerTag() {
|
||
const select = document.getElementById('customerTagSelect');
|
||
if (!select || !select.value) return;
|
||
|
||
const payload = {
|
||
entity_type: 'customer',
|
||
entity_id: customerId,
|
||
tag_id: parseInt(select.value, 10)
|
||
};
|
||
|
||
try {
|
||
const response = await fetch('/api/v1/tags/entity', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
alert(error.detail || 'Kunne ikke tilføje tag');
|
||
return;
|
||
}
|
||
|
||
bootstrap.Modal.getInstance(document.getElementById('customerTagModal')).hide();
|
||
await loadCustomerTags();
|
||
} catch (error) {
|
||
console.error('Failed to add tag:', error);
|
||
}
|
||
}
|
||
|
||
async function removeCustomerTag(tagId) {
|
||
try {
|
||
const response = await fetch(`/api/v1/tags/entity?entity_type=customer&entity_id=${customerId}&tag_id=${tagId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
alert(error.detail || 'Kunne ikke fjerne tag');
|
||
return;
|
||
}
|
||
|
||
await loadCustomerTags();
|
||
} catch (error) {
|
||
console.error('Failed to remove tag:', error);
|
||
}
|
||
}
|
||
|
||
async function loadNextcloudStatus() {
|
||
const statusBadge = document.getElementById('ncStatusBadge');
|
||
const lastUpdated = document.getElementById('ncLastUpdated');
|
||
const cpuLoad = document.getElementById('ncCpuLoad');
|
||
const freeDisk = document.getElementById('ncFreeDisk');
|
||
const ramUsage = document.getElementById('ncRamUsage');
|
||
const opcache = document.getElementById('ncOpcache');
|
||
const fileGrowth = document.getElementById('ncFileGrowth');
|
||
const publicShares = document.getElementById('ncPublicShares');
|
||
const activeUsers = document.getElementById('ncActiveUsers');
|
||
const alerts = document.getElementById('ncAlerts');
|
||
|
||
if (!statusBadge || !lastUpdated) return;
|
||
|
||
statusBadge.className = 'badge bg-secondary';
|
||
statusBadge.textContent = 'Henter...';
|
||
lastUpdated.textContent = '-';
|
||
|
||
try {
|
||
const instanceResponse = await fetch(`/api/v1/nextcloud/customers/${customerId}/instance`);
|
||
if (!instanceResponse.ok) {
|
||
statusBadge.textContent = 'Ukendt';
|
||
return;
|
||
}
|
||
|
||
const instance = await instanceResponse.json();
|
||
if (!instance?.id) {
|
||
statusBadge.textContent = 'Ikke konfigureret';
|
||
return;
|
||
}
|
||
|
||
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/status?customer_id=${customerId}`);
|
||
if (!response.ok) {
|
||
statusBadge.textContent = 'Ukendt';
|
||
return;
|
||
}
|
||
|
||
const payload = await response.json();
|
||
const isOnline = payload.status === 'online';
|
||
|
||
statusBadge.className = `badge ${isOnline ? 'bg-success' : 'bg-warning text-dark'}`;
|
||
statusBadge.textContent = isOnline ? 'Online' : 'Ukendt';
|
||
lastUpdated.textContent = new Date().toLocaleString('da-DK');
|
||
|
||
const info = payload.raw?.payload?.ocs?.data || {};
|
||
const system = info?.nextcloud?.system || {};
|
||
const sharesInfo = info?.nextcloud?.shares || {};
|
||
const storageInfo = info?.nextcloud?.storage || {};
|
||
const activeUsersInfo = info?.activeUsers || {};
|
||
const phpInfo = info?.server?.php || {};
|
||
const opcacheStats = phpInfo?.opcache?.opcache_statistics || {};
|
||
|
||
const loadAvg = Array.isArray(system?.cpuload) ? system.cpuload : [];
|
||
const cpuCores = system?.cpucount || system?.cpu_count || system?.num_cores || 0;
|
||
const loadValue = loadAvg.length ? loadAvg[0] : null;
|
||
const loadText = loadAvg.length ? loadAvg.map(v => Number(v).toFixed(2)).join(' / ') : '-';
|
||
|
||
const memTotal = system?.mem_total || null;
|
||
const memFree = system?.mem_free || null;
|
||
const freeRamPct = memTotal && memFree ? (memFree / memTotal) * 100 : null;
|
||
const ramUsageText = freeRamPct !== null ? `${(100 - freeRamPct).toFixed(1)} %` : '-';
|
||
|
||
const freeDiskBytes = system?.freespace ?? null;
|
||
const freeDiskText = freeDiskBytes !== null ? formatBytes(freeDiskBytes) : '-';
|
||
|
||
const opcacheHitRate = opcacheStats?.opcache_hit_rate ?? null;
|
||
const opcacheText = opcacheHitRate !== null ? `${Number(opcacheHitRate).toFixed(2)} %` : '-';
|
||
|
||
if (cpuLoad) cpuLoad.textContent = loadText;
|
||
if (freeDisk) freeDisk.textContent = freeDiskText;
|
||
if (ramUsage) ramUsage.textContent = ramUsageText;
|
||
if (opcache) opcache.textContent = opcacheText;
|
||
if (activeUsers) activeUsers.textContent = activeUsersInfo?.last24hours ?? '-';
|
||
|
||
// File count growth (localStorage diff)
|
||
const fileCount = storageInfo?.num_files ?? null;
|
||
if (fileGrowth) {
|
||
if (fileCount === null) {
|
||
fileGrowth.textContent = '-';
|
||
} else {
|
||
const key = `nextcloud_file_count_${instance.id}`;
|
||
const prev = parseInt(localStorage.getItem(key) || '0', 10);
|
||
const diff = prev ? fileCount - prev : 0;
|
||
fileGrowth.textContent = prev ? `${fileCount} (${diff >= 0 ? '+' : ''}${diff})` : `${fileCount}`;
|
||
localStorage.setItem(key, `${fileCount}`);
|
||
}
|
||
}
|
||
|
||
// Public shares without password
|
||
if (publicShares) {
|
||
if (typeof sharesInfo?.num_shares_link_no_password !== 'undefined') {
|
||
publicShares.textContent = `${sharesInfo.num_shares_link_no_password}`;
|
||
} else {
|
||
const sharesResponse = await fetch(`/api/v1/nextcloud/instances/${instance.id}/shares?customer_id=${customerId}`);
|
||
if (sharesResponse.ok) {
|
||
const sharesPayload = await sharesResponse.json();
|
||
const list = sharesPayload?.payload?.ocs?.data || [];
|
||
const withoutPassword = list.filter(s => s.share_type === 3 && (!s.password || s.password === '')).length;
|
||
publicShares.textContent = `${withoutPassword}`;
|
||
} else {
|
||
publicShares.textContent = '-';
|
||
}
|
||
}
|
||
}
|
||
|
||
if (alerts) {
|
||
const items = [];
|
||
if (freeDiskBytes !== null && freeDiskBytes <= 0) items.push('⚠️ Free disk kritisk');
|
||
if (loadValue !== null && cpuCores && loadValue > cpuCores) items.push('⚠️ CPU load > cores');
|
||
if (freeRamPct !== null && freeRamPct < 10) items.push('⚠️ Free RAM < 10%');
|
||
if (opcacheHitRate !== null && opcacheHitRate < 95) items.push('⚠️ OPCache hit rate < 95%');
|
||
const sharesText = publicShares?.textContent;
|
||
if (sharesText && parseInt(sharesText, 10) > 0) items.push('⚠️ Public shares uden password');
|
||
|
||
alerts.innerHTML = items.length
|
||
? items.map(text => `<span class="badge bg-warning text-dark">${text}</span>`).join('')
|
||
: '<span class="badge bg-success">✅ Ingen alarmer</span>';
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load Nextcloud status:', error);
|
||
statusBadge.textContent = 'Ukendt';
|
||
}
|
||
}
|
||
|
||
function formatBytes(value) {
|
||
if (value === null || typeof value === 'undefined') return '-';
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
let size = Number(value);
|
||
let unitIndex = 0;
|
||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
size /= 1024;
|
||
unitIndex += 1;
|
||
}
|
||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||
}
|
||
|
||
let nextcloudInstanceCache = null;
|
||
|
||
async function resolveNextcloudInstance() {
|
||
if (nextcloudInstanceCache?.id) return nextcloudInstanceCache;
|
||
const response = await fetch(`/api/v1/nextcloud/customers/${customerId}/instance`);
|
||
if (!response.ok) return null;
|
||
const instance = await response.json();
|
||
if (!instance?.id) return null;
|
||
nextcloudInstanceCache = instance;
|
||
return instance;
|
||
}
|
||
|
||
async function loadNextcloudGroups() {
|
||
const select = document.getElementById('ncCreateGroups');
|
||
if (!select) return;
|
||
|
||
select.innerHTML = '<option value="">Henter grupper...</option>';
|
||
|
||
const instance = await resolveNextcloudInstance();
|
||
if (!instance) {
|
||
select.innerHTML = '<option value="">Ingen Nextcloud instance</option>';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/groups?customer_id=${customerId}`);
|
||
if (!response.ok) {
|
||
select.innerHTML = '<option value="">Kunne ikke hente grupper</option>';
|
||
return;
|
||
}
|
||
|
||
const payload = await response.json();
|
||
const groups = payload?.payload?.ocs?.data?.groups || payload?.groups || [];
|
||
if (!Array.isArray(groups) || groups.length === 0) {
|
||
select.innerHTML = '<option value="">Ingen grupper fundet</option>';
|
||
return;
|
||
}
|
||
|
||
select.innerHTML = groups.map(group => `<option value="${escapeHtml(group)}">${escapeHtml(group)}</option>`).join('');
|
||
} catch (error) {
|
||
console.error('Failed to load Nextcloud groups:', error);
|
||
select.innerHTML = '<option value="">Kunne ikke hente grupper</option>';
|
||
}
|
||
}
|
||
|
||
async function loadNextcloudUsers(selectId, inputId, searchValue = '', requireSearch = false) {
|
||
const select = document.getElementById(selectId);
|
||
if (!select) return;
|
||
|
||
if (requireSearch && !searchValue.trim()) {
|
||
select.innerHTML = '<option value="">Skriv for at søge</option>';
|
||
return;
|
||
}
|
||
|
||
select.innerHTML = '<option value="">Henter brugere...</option>';
|
||
|
||
const instance = await resolveNextcloudInstance();
|
||
if (!instance) {
|
||
select.innerHTML = '<option value="">Ingen Nextcloud instance</option>';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const search = encodeURIComponent(searchValue.trim());
|
||
const url = new URL(`/api/v1/nextcloud/instances/${instance.id}/users`, window.location.origin);
|
||
url.searchParams.set('customer_id', customerId);
|
||
url.searchParams.set('include_details', 'true');
|
||
url.searchParams.set('limit', '200');
|
||
if (search) url.searchParams.set('search', search);
|
||
const response = await fetch(url.toString());
|
||
if (!response.ok) {
|
||
select.innerHTML = '<option value="">Kunne ikke hente brugere</option>';
|
||
return;
|
||
}
|
||
|
||
const payload = await response.json();
|
||
const users = payload?.users || [];
|
||
if (!Array.isArray(users) || users.length === 0) {
|
||
select.innerHTML = '<option value="">Ingen brugere fundet</option>';
|
||
return;
|
||
}
|
||
|
||
const options = users
|
||
.map(user => {
|
||
const uid = escapeHtml(user.uid || '');
|
||
const display = escapeHtml(user.display_name || '-');
|
||
const email = escapeHtml(user.email || '-');
|
||
const label = `${uid} - ${display} (${email})`;
|
||
return `<option value="${uid}">${label}</option>`;
|
||
})
|
||
.join('');
|
||
select.innerHTML = options;
|
||
|
||
if (inputId) {
|
||
const input = document.getElementById(inputId);
|
||
if (input) {
|
||
input.value = select.value || '';
|
||
}
|
||
|
||
if (!select.dataset.bound) {
|
||
select.addEventListener('change', () => {
|
||
const target = document.getElementById(inputId);
|
||
if (target) target.value = select.value;
|
||
});
|
||
select.dataset.bound = 'true';
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load Nextcloud users:', error);
|
||
select.innerHTML = '<option value="">Kunne ikke hente brugere</option>';
|
||
}
|
||
}
|
||
|
||
async function applyNextcloudUserGroups(uid) {
|
||
if (!uid) return;
|
||
const instance = await resolveNextcloudInstance();
|
||
if (!instance) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users/${encodeURIComponent(uid)}?customer_id=${customerId}`);
|
||
if (!response.ok) return;
|
||
const payload = await response.json();
|
||
const data = payload?.payload?.ocs?.data || {};
|
||
const groups = Array.isArray(data.groups) ? data.groups : [];
|
||
|
||
const groupSelect = document.getElementById('ncCreateGroups');
|
||
if (!groupSelect) return;
|
||
|
||
Array.from(groupSelect.options).forEach(option => {
|
||
option.selected = groups.includes(option.value);
|
||
});
|
||
|
||
const displayInput = document.getElementById('ncCreateDisplayName');
|
||
const emailInput = document.getElementById('ncCreateEmail');
|
||
if (displayInput && !displayInput.value && data.displayname) {
|
||
displayInput.value = data.displayname;
|
||
}
|
||
if (emailInput && !emailInput.value && data.email) {
|
||
emailInput.value = data.email;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to apply Nextcloud groups:', error);
|
||
}
|
||
}
|
||
|
||
async function openNextcloudCreateUser() {
|
||
document.getElementById('ncCreateUid').value = '';
|
||
document.getElementById('ncCreateDisplayName').value = '';
|
||
document.getElementById('ncCreateEmail').value = '';
|
||
const searchInput = document.getElementById('ncCreateSearch');
|
||
const userSelect = document.getElementById('ncCreateUserSelect');
|
||
|
||
if (searchInput) {
|
||
searchInput.value = '';
|
||
if (!searchInput.dataset.bound) {
|
||
let createTimer = null;
|
||
searchInput.addEventListener('input', () => {
|
||
clearTimeout(createTimer);
|
||
createTimer = setTimeout(() => {
|
||
loadNextcloudUsers('ncCreateUserSelect', null, searchInput.value, false);
|
||
}, 300);
|
||
});
|
||
searchInput.dataset.bound = 'true';
|
||
}
|
||
}
|
||
|
||
if (userSelect && !userSelect.dataset.bound) {
|
||
userSelect.addEventListener('change', () => {
|
||
const uid = userSelect.value;
|
||
if (uid) applyNextcloudUserGroups(uid);
|
||
});
|
||
userSelect.dataset.bound = 'true';
|
||
}
|
||
|
||
await loadNextcloudGroups();
|
||
await loadNextcloudUsers('ncCreateUserSelect', null, searchInput ? searchInput.value : '', false);
|
||
const modal = new bootstrap.Modal(document.getElementById('nextcloudCreateUserModal'));
|
||
modal.show();
|
||
}
|
||
|
||
function openNextcloudResetPassword() {
|
||
document.getElementById('ncResetUid').value = '';
|
||
document.getElementById('ncResetSendEmail').checked = true;
|
||
const searchInput = document.getElementById('ncResetSearch');
|
||
if (searchInput) {
|
||
searchInput.value = '';
|
||
if (!searchInput.dataset.bound) {
|
||
let resetTimer = null;
|
||
searchInput.addEventListener('input', () => {
|
||
clearTimeout(resetTimer);
|
||
resetTimer = setTimeout(() => {
|
||
loadNextcloudUsers('ncResetUserSelect', 'ncResetUid', searchInput.value, true);
|
||
}, 300);
|
||
});
|
||
searchInput.dataset.bound = 'true';
|
||
}
|
||
}
|
||
loadNextcloudUsers('ncResetUserSelect', 'ncResetUid', searchInput ? searchInput.value : '', true);
|
||
const modal = new bootstrap.Modal(document.getElementById('nextcloudResetPasswordModal'));
|
||
modal.show();
|
||
}
|
||
|
||
function openNextcloudDisableUser() {
|
||
document.getElementById('ncDisableUid').value = '';
|
||
const searchInput = document.getElementById('ncDisableSearch');
|
||
if (searchInput) {
|
||
searchInput.value = '';
|
||
if (!searchInput.dataset.bound) {
|
||
let disableTimer = null;
|
||
searchInput.addEventListener('input', () => {
|
||
clearTimeout(disableTimer);
|
||
disableTimer = setTimeout(() => {
|
||
loadNextcloudUsers('ncDisableUserSelect', 'ncDisableUid', searchInput.value, true);
|
||
}, 300);
|
||
});
|
||
searchInput.dataset.bound = 'true';
|
||
}
|
||
}
|
||
loadNextcloudUsers('ncDisableUserSelect', 'ncDisableUid', searchInput ? searchInput.value : '', true);
|
||
const modal = new bootstrap.Modal(document.getElementById('nextcloudDisableUserModal'));
|
||
modal.show();
|
||
}
|
||
|
||
async function createNextcloudUser() {
|
||
const uid = document.getElementById('ncCreateUid').value.trim();
|
||
if (!uid) {
|
||
alert('Brugernavn er påkrævet.');
|
||
return;
|
||
}
|
||
|
||
const instance = await resolveNextcloudInstance();
|
||
if (!instance) {
|
||
alert('Ingen Nextcloud instance konfigureret for denne kunde.');
|
||
return;
|
||
}
|
||
|
||
const groupsSelect = document.getElementById('ncCreateGroups');
|
||
const groups = Array.from(groupsSelect.selectedOptions || []).map(opt => opt.value).filter(Boolean);
|
||
|
||
const payload = {
|
||
uid,
|
||
display_name: document.getElementById('ncCreateDisplayName').value.trim() || null,
|
||
email: document.getElementById('ncCreateEmail').value.trim() || null,
|
||
groups,
|
||
send_welcome: true
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users?customer_id=${customerId}`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
}
|
||
);
|
||
|
||
const result = await response.json();
|
||
if (!response.ok) {
|
||
alert(result.detail || 'Kunne ikke oprette bruger.');
|
||
return;
|
||
}
|
||
|
||
if (result.generated_password) {
|
||
alert(`Bruger oprettet. Genereret password: ${result.generated_password}`);
|
||
} else {
|
||
alert('Bruger oprettet.');
|
||
}
|
||
|
||
bootstrap.Modal.getInstance(document.getElementById('nextcloudCreateUserModal')).hide();
|
||
} catch (error) {
|
||
console.error('Failed to create Nextcloud user:', error);
|
||
alert('Kunne ikke oprette bruger.');
|
||
}
|
||
}
|
||
|
||
async function resetNextcloudPassword() {
|
||
const uid = document.getElementById('ncResetUid').value.trim();
|
||
if (!uid) {
|
||
alert('Brugernavn er påkrævet.');
|
||
return;
|
||
}
|
||
|
||
const instance = await resolveNextcloudInstance();
|
||
if (!instance) {
|
||
alert('Ingen Nextcloud instance konfigureret for denne kunde.');
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
send_email: document.getElementById('ncResetSendEmail').checked
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users/${encodeURIComponent(uid)}/reset-password?customer_id=${customerId}`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
}
|
||
);
|
||
|
||
const result = await response.json();
|
||
if (!response.ok) {
|
||
alert(result.detail || 'Kunne ikke reset password.');
|
||
return;
|
||
}
|
||
|
||
if (result.generated_password) {
|
||
alert(`Password nulstillet. Genereret password: ${result.generated_password}`);
|
||
} else {
|
||
alert('Password nulstillet.');
|
||
}
|
||
|
||
bootstrap.Modal.getInstance(document.getElementById('nextcloudResetPasswordModal')).hide();
|
||
} catch (error) {
|
||
console.error('Failed to reset Nextcloud password:', error);
|
||
alert('Kunne ikke reset password.');
|
||
}
|
||
}
|
||
|
||
async function disableNextcloudUser() {
|
||
const uid = document.getElementById('ncDisableUid').value.trim();
|
||
if (!uid) {
|
||
alert('Brugernavn er påkrævet.');
|
||
return;
|
||
}
|
||
|
||
if (!confirm(`Er du sikker på, at du vil deaktivere brugeren ${uid}?`)) {
|
||
return;
|
||
}
|
||
|
||
const instance = await resolveNextcloudInstance();
|
||
if (!instance) {
|
||
alert('Ingen Nextcloud instance konfigureret for denne kunde.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users/${encodeURIComponent(uid)}/disable?customer_id=${customerId}`,
|
||
{ method: 'POST' }
|
||
);
|
||
|
||
const result = await response.json();
|
||
if (!response.ok) {
|
||
alert(result.detail || 'Kunne ikke deaktivere bruger.');
|
||
return;
|
||
}
|
||
|
||
alert('Bruger deaktiveret.');
|
||
bootstrap.Modal.getInstance(document.getElementById('nextcloudDisableUserModal')).hide();
|
||
} catch (error) {
|
||
console.error('Failed to disable Nextcloud user:', error);
|
||
alert('Kunne ikke deaktivere bruger.');
|
||
}
|
||
}
|
||
|
||
async function loadUtilityCompany() {
|
||
const nameEl = document.getElementById('utilityCompanyName');
|
||
const contactEl = document.getElementById('utilityCompanyContact');
|
||
if (!nameEl || !contactEl) return;
|
||
|
||
if (!customerData?.address) {
|
||
nameEl.textContent = 'Ingen adresse angivet';
|
||
contactEl.textContent = 'Tilføj adresse for at hente netselskab';
|
||
return;
|
||
}
|
||
|
||
nameEl.innerHTML = '<span class="spinner-border spinner-border-sm text-primary me-2" role="status" aria-hidden="true"></span>Henter netselskab...';
|
||
contactEl.textContent = '';
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/customers/${customerId}/utility-company`);
|
||
let payload = {};
|
||
try {
|
||
payload = await response.json();
|
||
} catch (err) {
|
||
console.warn('Utility payload could not be parsed', err);
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error(payload.detail || 'Fejl ved netselskabsopslag');
|
||
}
|
||
|
||
if (!payload.found) {
|
||
nameEl.textContent = 'Netselskab ikke fundet';
|
||
contactEl.textContent = payload.message || 'Kontroller adressen og prøv igen';
|
||
return;
|
||
}
|
||
|
||
displayUtilityCompany(payload);
|
||
} catch (error) {
|
||
console.error('Error fetching utility company:', error);
|
||
nameEl.textContent = 'Kunne ikke hente netselskab';
|
||
contactEl.textContent = 'Prøv igen senere';
|
||
}
|
||
}
|
||
|
||
function displayUtilityCompany(payload) {
|
||
const nameEl = document.getElementById('utilityCompanyName');
|
||
const contactEl = document.getElementById('utilityCompanyContact');
|
||
if (!nameEl || !contactEl) return;
|
||
|
||
const supplier = payload?.supplier;
|
||
if (!supplier) {
|
||
nameEl.textContent = 'Netselskab ikke fundet';
|
||
contactEl.textContent = payload?.message || 'Ingen data fra API';
|
||
return;
|
||
}
|
||
|
||
nameEl.textContent = supplier.name || 'Ukendt netselskab';
|
||
|
||
const contactPieces = [];
|
||
if (supplier.phone) {
|
||
contactPieces.push(`Tlf. ${escapeHtml(supplier.phone)}`);
|
||
}
|
||
|
||
if (supplier.website) {
|
||
const normalized = supplier.website.toLowerCase().startsWith('http')
|
||
? supplier.website
|
||
: `https://${supplier.website}`;
|
||
const href = escapeHtml(normalized);
|
||
const label = escapeHtml(supplier.website);
|
||
contactPieces.push(`<a href="${href}" target="_blank" rel="noreferrer noopener">${label}</a>`);
|
||
}
|
||
|
||
contactEl.innerHTML = contactPieces.length > 0 ? contactPieces.join(' • ') : 'Ingen kontaktinfo';
|
||
}
|
||
|
||
async function loadContacts() {
|
||
const container = document.getElementById('contactsContainer');
|
||
container.innerHTML = `
|
||
<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>Mobil</th>
|
||
<th>Primær</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td colspan="6" class="text-center py-4">
|
||
<div class="spinner-border text-primary"></div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
|
||
const contacts = await response.json();
|
||
|
||
if (!contacts || contacts.length === 0) {
|
||
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter endnu</div>';
|
||
return;
|
||
}
|
||
|
||
const rows = contacts.map(contact => {
|
||
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
|
||
const phone = contact.phone ? `<a href="tel:${contact.phone}">${escapeHtml(contact.phone)}</a>` : '—';
|
||
const mobile = contact.mobile
|
||
? `<div class="d-flex align-items-center gap-2 flex-wrap"><a href="tel:${contact.mobile}">${escapeHtml(contact.mobile)}</a><button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(contact.mobile)}', '${escapeHtml(contact.name || '')}', ${contact.id || 'null'})">SMS</button></div>`
|
||
: '—';
|
||
const title = contact.title ? escapeHtml(contact.title) : '—';
|
||
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : '—';
|
||
|
||
return `
|
||
<tr>
|
||
<td class="fw-semibold">${escapeHtml(contact.name || '-') }</td>
|
||
<td>${title}</td>
|
||
<td>${email}</td>
|
||
<td>${phone}</td>
|
||
<td>${mobile}</td>
|
||
<td>${primaryBadge}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = `
|
||
<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>Mobil</th>
|
||
<th>Primær</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rows}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
} catch (error) {
|
||
console.error('Failed to load contacts:', error);
|
||
container.innerHTML = '<div class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</div>';
|
||
}
|
||
}
|
||
|
||
let subscriptionsLoaded = false;
|
||
|
||
async function loadSubscriptions() {
|
||
const container = document.getElementById('subscriptionsContainer');
|
||
|
||
// Prevent duplicate loads
|
||
if (subscriptionsLoaded && container.innerHTML.includes('row g-4')) {
|
||
console.log('Subscriptions already loaded, skipping...');
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div><p class="text-muted mt-3">Henter data fra vTiger...</p></div>';
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/customers/${customerId}/subscriptions`);
|
||
const data = await response.json();
|
||
|
||
console.log('Loaded subscriptions:', data);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.detail || 'Failed to load subscriptions');
|
||
}
|
||
|
||
if (data.status === 'no_vtiger_link') {
|
||
container.innerHTML = `
|
||
<div class="alert alert-warning">
|
||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||
${data.message}
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
displaySubscriptions(data);
|
||
subscriptionsLoaded = true;
|
||
} catch (error) {
|
||
console.error('Failed to load subscriptions:', error);
|
||
container.innerHTML = '<div class="alert alert-danger">Kunne ikke indlæse abonnementer</div>';
|
||
}
|
||
}
|
||
|
||
async function loadPipelineStages() {
|
||
if (pipelineStages.length > 0) return;
|
||
const response = await fetch('/api/v1/pipeline/stages');
|
||
pipelineStages = await response.json();
|
||
|
||
const select = document.getElementById('customerOpportunityStage');
|
||
if (select) {
|
||
select.innerHTML = pipelineStages.map(stage => `<option value="${stage.id}">${stage.name}</option>`).join('');
|
||
}
|
||
}
|
||
|
||
async function loadCustomerPipeline() {
|
||
await loadPipelineStages();
|
||
const response = await fetch(`/api/v1/opportunities?customer_id=${customerId}`);
|
||
const opportunities = await response.json();
|
||
renderCustomerPipeline(opportunities);
|
||
}
|
||
|
||
let customerHardware = [];
|
||
let hardwareLocationsById = {};
|
||
|
||
function getHardwareGroupLabel(item, groupBy) {
|
||
if (groupBy === 'location') {
|
||
return item.location_name || 'Ukendt lokation';
|
||
}
|
||
if (groupBy === 'type') {
|
||
return item.asset_type ? item.asset_type.replace('_', ' ') : 'Ukendt type';
|
||
}
|
||
if (groupBy === 'model') {
|
||
const brand = item.brand || '';
|
||
const model = item.model || '';
|
||
const label = `${brand} ${model}`.trim();
|
||
return label || 'Ukendt model';
|
||
}
|
||
if (groupBy === 'vendor') {
|
||
return item.brand || 'Ukendt leverandør';
|
||
}
|
||
return 'Ukendt';
|
||
}
|
||
|
||
function renderHardwareTable(groupBy) {
|
||
const container = document.getElementById('customerHardwareContainer');
|
||
const empty = document.getElementById('customerHardwareEmpty');
|
||
|
||
if (!container || !empty) return;
|
||
|
||
if (!customerHardware.length) {
|
||
container.classList.add('d-none');
|
||
empty.classList.remove('d-none');
|
||
return;
|
||
}
|
||
|
||
container.classList.remove('d-none');
|
||
empty.classList.add('d-none');
|
||
|
||
const grouped = {};
|
||
customerHardware.forEach(item => {
|
||
const label = getHardwareGroupLabel(item, groupBy);
|
||
if (!grouped[label]) {
|
||
grouped[label] = [];
|
||
}
|
||
grouped[label].push(item);
|
||
});
|
||
|
||
const sortedGroups = Object.keys(grouped).sort((a, b) => a.localeCompare(b, 'da-DK'));
|
||
|
||
const rows = sortedGroups.map(group => {
|
||
const items = grouped[group];
|
||
const groupRow = `
|
||
<tr class="table-secondary">
|
||
<td colspan="6" class="fw-semibold">${escapeHtml(group)} (${items.length})</td>
|
||
</tr>
|
||
`;
|
||
|
||
const itemRows = items.map(item => {
|
||
const assetLabel = `${item.brand || ''} ${item.model || ''}`.trim() || 'Ukendt hardware';
|
||
const serial = item.serial_number || '—';
|
||
const location = 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('');
|
||
|
||
return groupRow + itemRows;
|
||
}).join('');
|
||
|
||
container.innerHTML = `
|
||
<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>
|
||
${rows}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
async function loadCustomerHardware() {
|
||
const container = document.getElementById('customerHardwareContainer');
|
||
const empty = document.getElementById('customerHardwareEmpty');
|
||
const groupBySelect = document.getElementById('hardwareGroupBy');
|
||
|
||
if (!container || !empty || !groupBySelect) return;
|
||
|
||
container.classList.remove('d-none');
|
||
empty.classList.add('d-none');
|
||
container.innerHTML = `
|
||
<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>
|
||
<tr>
|
||
<td colspan="6" class="text-center py-4">
|
||
<div class="spinner-border text-primary"></div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/hardware?customer_id=${customerId}`);
|
||
if (!response.ok) {
|
||
throw new Error('Kunne ikke hente hardware');
|
||
}
|
||
const hardware = await response.json();
|
||
customerHardware = Array.isArray(hardware) ? hardware : [];
|
||
|
||
const locationIds = customerHardware
|
||
.map(item => item.current_location_id)
|
||
.filter(id => id !== null && id !== undefined);
|
||
|
||
hardwareLocationsById = {};
|
||
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 => {
|
||
hardwareLocationsById[loc.id] = loc.name;
|
||
});
|
||
}
|
||
}
|
||
|
||
customerHardware = customerHardware.map(item => ({
|
||
...item,
|
||
location_name: hardwareLocationsById[item.current_location_id] || item.location_name || null
|
||
}));
|
||
|
||
renderHardwareTable(groupBySelect.value || 'location');
|
||
} catch (error) {
|
||
console.error('Failed to load hardware:', error);
|
||
container.classList.add('d-none');
|
||
empty.classList.remove('d-none');
|
||
}
|
||
}
|
||
|
||
document.addEventListener('change', (event) => {
|
||
if (event.target && event.target.id === 'hardwareGroupBy') {
|
||
renderHardwareTable(event.target.value);
|
||
}
|
||
});
|
||
|
||
function renderCustomerPipeline(opportunities) {
|
||
const tbody = document.getElementById('customerOpportunitiesTable');
|
||
if (!opportunities || opportunities.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Ingen muligheder endnu</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = opportunities.map(o => `
|
||
<tr>
|
||
<td class="fw-semibold">${escapeHtml(o.title)}</td>
|
||
<td>${formatCurrency(o.amount, o.currency)}</td>
|
||
<td>
|
||
<span class="badge" style="background:${o.stage_color || '#0f4c75'}; color: white;">${escapeHtml(o.stage_name || '-')}</span>
|
||
</td>
|
||
<td>${o.probability || 0}%</td>
|
||
<td class="text-end">
|
||
<button class="btn btn-sm btn-outline-primary" onclick="window.location.href='/opportunities/${o.id}'">
|
||
<i class="bi bi-arrow-right"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function openCustomerOpportunityModal() {
|
||
const form = document.getElementById('customerOpportunityForm');
|
||
if (form) form.reset();
|
||
loadPipelineStages();
|
||
const modal = new bootstrap.Modal(document.getElementById('customerOpportunityModal'));
|
||
modal.show();
|
||
}
|
||
|
||
async function createCustomerOpportunity() {
|
||
const payload = {
|
||
customer_id: customerId,
|
||
title: document.getElementById('customerOpportunityTitle').value,
|
||
description: document.getElementById('customerOpportunityDescription').value || null,
|
||
amount: parseFloat(document.getElementById('customerOpportunityAmount').value || 0),
|
||
currency: document.getElementById('customerOpportunityCurrency').value || 'DKK',
|
||
stage_id: parseInt(document.getElementById('customerOpportunityStage').value || 0) || null,
|
||
expected_close_date: document.getElementById('customerOpportunityCloseDate').value || null
|
||
};
|
||
|
||
if (!payload.title) {
|
||
alert('Titel er påkrævet');
|
||
return;
|
||
}
|
||
|
||
const response = await fetch('/api/v1/opportunities', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
alert('Kunne ikke oprette mulighed');
|
||
return;
|
||
}
|
||
|
||
bootstrap.Modal.getInstance(document.getElementById('customerOpportunityModal')).hide();
|
||
loadCustomerPipeline();
|
||
}
|
||
|
||
function displaySubscriptions(data) {
|
||
const container = document.getElementById('subscriptionsContainer');
|
||
const { recurring_orders, sales_orders, subscriptions, expired_subscriptions, bmc_office_subscriptions } = data;
|
||
|
||
// Store subscriptions for editing
|
||
currentSubscriptions = subscriptions || [];
|
||
|
||
const totalItems = (sales_orders?.length || 0) + (subscriptions?.length || 0) + (bmc_office_subscriptions?.length || 0);
|
||
|
||
if (totalItems === 0) {
|
||
container.innerHTML = '<div class="text-center text-muted py-5">Ingen abonnementer eller salgsordre fundet</div>';
|
||
return;
|
||
}
|
||
|
||
// Create 3-column layout
|
||
let html = '<div class="row g-3">';
|
||
|
||
const isLocked = customerData?.subscriptions_locked || false;
|
||
|
||
// Column 1: vTiger Subscriptions
|
||
html += `
|
||
<div class="col-lg-4">
|
||
<div class="subscription-column">
|
||
<div class="column-header bg-primary bg-opacity-10 border-start border-primary border-4 p-3 mb-3 rounded">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<h5 class="fw-bold mb-1">
|
||
<i class="bi bi-arrow-repeat text-primary me-2"></i>
|
||
vTiger Abonnementer
|
||
${isLocked ? '<i class="bi bi-lock-fill text-danger ms-2" title="Abonnementer låst"></i>' : ''}
|
||
</h5>
|
||
<small class="text-muted">Fra vTiger Cloud</small>
|
||
</div>
|
||
<button class="btn btn-sm ${isLocked ? 'btn-danger' : 'btn-outline-secondary'}"
|
||
onclick="toggleSubscriptionsLock()"
|
||
title="${isLocked ? 'Lås op' : 'Lås abonnementer'}">
|
||
<i class="bi bi-${isLocked ? 'unlock' : 'lock'}-fill"></i>
|
||
${isLocked ? 'Låst' : 'Lås'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
${isLocked ? '<div class="alert alert-warning small mb-3"><i class="bi bi-lock-fill me-2"></i>Abonnementer er låst - kan kun redigeres i vTiger</div>' : ''}
|
||
${renderSubscriptionsList(subscriptions || [], isLocked)}
|
||
</div>
|
||
</div>
|
||
|
||
`;
|
||
|
||
// Column 2: Sales Orders
|
||
html += `
|
||
<div class="col-lg-4">
|
||
<div class="subscription-column">
|
||
<div class="column-header bg-success bg-opacity-10 border-start border-success border-4 p-3 mb-3 rounded">
|
||
<h5 class="fw-bold mb-1">
|
||
<i class="bi bi-cart-check text-success me-2"></i>
|
||
Åbne Salgsordre
|
||
</h5>
|
||
<small class="text-muted">Fra Simply-CRM</small>
|
||
</div>
|
||
${renderSalesOrdersList(sales_orders || [])}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Column 3: BMC Office Subscriptions
|
||
html += `
|
||
<div class="col-lg-4">
|
||
<div class="subscription-column">
|
||
<div class="column-header bg-info bg-opacity-10 border-start border-info border-4 p-3 mb-3 rounded">
|
||
<h5 class="fw-bold mb-1">
|
||
<i class="bi bi-database text-info me-2"></i>
|
||
BMC Office Abonnementer
|
||
</h5>
|
||
<small class="text-muted">Fra lokalt system</small>
|
||
</div>
|
||
${renderBmcOfficeSubscriptionsList(bmc_office_subscriptions || [])}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
html += '</div>';
|
||
|
||
// Add comparison stats at bottom
|
||
const subTotal = subscriptions?.reduce((sum, sub) => sum + parseFloat(sub.hdnGrandTotal || 0), 0) || 0;
|
||
const orderTotal = sales_orders?.reduce((sum, order) => sum + parseFloat(order.hdnGrandTotal || 0), 0) || 0;
|
||
const bmcOfficeTotal = bmc_office_subscriptions?.reduce((sum, sub) => sum + parseFloat(sub.total_inkl_moms || 0), 0) || 0;
|
||
|
||
if (totalItems > 0) {
|
||
html += `
|
||
<div class="row g-4 mt-2">
|
||
<div class="col-12">
|
||
<div class="info-card bg-light">
|
||
<h6 class="fw-bold mb-3">Sammenligning</h6>
|
||
<div class="row text-center">
|
||
<div class="col-md-3">
|
||
<div class="stat-item">
|
||
<div class="text-muted small">vTiger Abonnementer</div>
|
||
<div class="fs-4 fw-bold text-primary">${subscriptions?.length || 0}</div>
|
||
<div class="text-muted small">${subTotal.toFixed(2)} DKK</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stat-item">
|
||
<div class="text-muted small">Åbne Salgsordre</div>
|
||
<div class="fs-4 fw-bold text-success">${sales_orders?.length || 0}</div>
|
||
<div class="text-muted small">${orderTotal.toFixed(2)} DKK</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stat-item">
|
||
<div class="text-muted small">BMC Office Abonnementer</div>
|
||
<div class="fs-4 fw-bold text-info">${bmc_office_subscriptions?.length || 0}</div>
|
||
<div class="text-muted small">${bmcOfficeTotal.toFixed(2)} DKK</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stat-item">
|
||
<div class="text-muted small">Total Værdi</div>
|
||
<div class="fs-4 fw-bold text-dark">${(subTotal + orderTotal + bmcOfficeTotal).toFixed(2)} DKK</div>
|
||
<div class="text-muted small">Samlet omsætning</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function renderRecurringOrdersList(orders) {
|
||
if (!orders || orders.length === 0) {
|
||
return '<p class="text-muted small">Ingen tilbagevendende ordrer</p>';
|
||
}
|
||
|
||
return orders.map((order, idx) => {
|
||
const itemId = `recurring-${idx}`;
|
||
const lineItems = order.lineItems || [];
|
||
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
|
||
|
||
return `
|
||
<div class="border-bottom pb-3 mb-3">
|
||
<div class="d-flex justify-content-between align-items-start mb-2" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
|
||
<div class="fw-bold">
|
||
<i class="bi bi-chevron-right me-1" id="${itemId}-icon"></i>
|
||
${escapeHtml(order.subject || order.salesorder_no || 'Unnamed')}
|
||
</div>
|
||
<span class="badge bg-${getStatusColor(order.sostatus)}">${escapeHtml(order.sostatus || 'Open')}</span>
|
||
</div>
|
||
${order.description ? `<p class="text-muted small mb-2">${escapeHtml(order.description).substring(0, 100)}...</p>` : ''}
|
||
<div class="d-flex gap-3 small text-muted flex-wrap">
|
||
${order.recurring_frequency ? `<span><i class="bi bi-arrow-repeat me-1"></i>${escapeHtml(order.recurring_frequency)}</span>` : ''}
|
||
${order.start_period ? `<span><i class="bi bi-calendar-event me-1"></i>${formatDate(order.start_period)}</span>` : ''}
|
||
${order.end_period ? `<span><i class="bi bi-calendar-x me-1"></i>${formatDate(order.end_period)}</span>` : ''}
|
||
${order.hdnGrandTotal ? `<span><i class="bi bi-currency-dollar me-1"></i>${parseFloat(order.hdnGrandTotal).toFixed(2)} DKK</span>` : ''}
|
||
</div>
|
||
${hasLineItems ? `
|
||
<div id="${itemId}-lines" class="mt-3 ps-3" style="display: none;">
|
||
<div class="small">
|
||
<strong>Produktlinjer:</strong>
|
||
${lineItems.map(line => `
|
||
<div class="border-start border-2 border-primary ps-2 py-1 mt-2">
|
||
<div class="fw-bold">${escapeHtml(line.product_name || line.productid)}</div>
|
||
<div class="text-muted">
|
||
Antal: ${line.quantity || 0} × ${parseFloat(line.listprice || 0).toFixed(2)} DKK =
|
||
<strong>${parseFloat(line.netprice || 0).toFixed(2)} DKK</strong>
|
||
</div>
|
||
${line.comment ? `<div class="text-muted small">${escapeHtml(line.comment)}</div>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderSalesOrdersList(orders) {
|
||
if (!orders || orders.length === 0) {
|
||
return '<div class="text-center text-muted py-4"><i class="bi bi-inbox fs-1 d-block mb-2"></i>Ingen salgsordre</div>';
|
||
}
|
||
|
||
return orders.map((order, idx) => {
|
||
const itemId = `salesorder-${idx}`;
|
||
const lineItems = order.lineItems || [];
|
||
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
|
||
const total = parseFloat(order.hdnGrandTotal || 0);
|
||
const recordId = order.id.includes('x') ? order.id.split('x')[1] : order.id;
|
||
const simplycrmUrl = `https://bmcnetworks.simply-crm.dk/index.php?module=SalesOrder&view=Detail&record=${recordId}`;
|
||
|
||
return `
|
||
<div class="subscription-item border rounded p-3 mb-3 bg-white shadow-sm">
|
||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||
<div class="flex-grow-1" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
|
||
<div class="fw-bold d-flex align-items-center">
|
||
<i class="bi bi-chevron-right me-2 text-success" id="${itemId}-icon" style="font-size: 0.8rem;"></i>
|
||
${escapeHtml(order.subject || order.salesorder_no || 'Unnamed')}
|
||
</div>
|
||
<div class="small text-muted mt-1">
|
||
${order.salesorder_no ? `#${escapeHtml(order.salesorder_no)}` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="text-end ms-3">
|
||
<div class="btn-group btn-group-sm mb-2" role="group">
|
||
<a href="${simplycrmUrl}" target="_blank" class="btn btn-outline-success" title="Åbn i Simply-CRM">
|
||
<i class="bi bi-box-arrow-up-right"></i> Simply-CRM
|
||
</a>
|
||
</div>
|
||
<div>
|
||
<div class="badge bg-${getStatusColor(order.sostatus)} mb-1">${escapeHtml(order.sostatus || 'Open')}</div>
|
||
<div class="fw-bold text-success">${total.toFixed(2)} DKK</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="d-flex gap-2 flex-wrap small text-muted mt-2">
|
||
${order.recurring_frequency ? `<span class="badge bg-light text-dark"><i class="bi bi-arrow-repeat me-1"></i>${escapeHtml(order.recurring_frequency)}</span>` : ''}
|
||
${order.last_recurring_date ? `<span class="badge bg-info text-dark"><i class="bi bi-calendar-check me-1"></i>Sidste: ${formatDate(order.last_recurring_date)}</span>` : ''}
|
||
${order.start_period && order.end_period ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-range me-1"></i>${formatDate(order.start_period)} - ${formatDate(order.end_period)}</span>` : ''}
|
||
${order.start_period && !order.end_period ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-check me-1"></i>Start: ${formatDate(order.start_period)}</span>` : ''}
|
||
</div>
|
||
|
||
${hasLineItems ? `
|
||
<div id="${itemId}-lines" class="mt-3 pt-3 border-top" style="display: none;">
|
||
<div class="small">
|
||
${lineItems.map(line => `
|
||
<div class="d-flex justify-content-between align-items-start py-2 border-bottom">
|
||
<div class="flex-grow-1">
|
||
<div class="fw-bold">${escapeHtml(line.product_name || line.productid)}</div>
|
||
<div class="text-muted small">
|
||
${line.quantity || 0} stk × ${parseFloat(line.listprice || 0).toFixed(2)} DKK
|
||
</div>
|
||
</div>
|
||
<div class="text-end fw-bold">
|
||
${parseFloat(line.netprice || 0).toFixed(2)} DKK
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
<div class="d-flex justify-content-between mt-3 pt-2">
|
||
<span class="text-muted">Subtotal:</span>
|
||
<strong>${parseFloat(order.hdnSubTotal || 0).toFixed(2)} DKK</strong>
|
||
</div>
|
||
<div class="d-flex justify-content-between text-success fw-bold fs-5">
|
||
<span>Total inkl. moms:</span>
|
||
<strong>${total.toFixed(2)} DKK</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderBmcOfficeSubscriptionsList(subscriptions) {
|
||
if (!subscriptions || subscriptions.length === 0) {
|
||
return '<div class="text-center text-muted py-4"><i class="bi bi-inbox fs-1 d-block mb-2"></i>Ingen BMC Office abonnementer</div>';
|
||
}
|
||
|
||
return subscriptions.map((sub, idx) => {
|
||
const itemId = `bmcoffice-${idx}`;
|
||
const total = parseFloat(sub.total_inkl_moms || 0);
|
||
const subtotal = parseFloat(sub.subtotal || 0);
|
||
const rabat = parseFloat(sub.rabat || 0);
|
||
|
||
return `
|
||
<div class="subscription-item border rounded p-3 mb-3 bg-white shadow-sm">
|
||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||
<div class="flex-grow-1">
|
||
<div class="fw-bold d-flex align-items-center">
|
||
<i class="bi bi-box-seam text-info me-2" style="font-size: 0.8rem;"></i>
|
||
${escapeHtml(sub.text || 'Unnamed')}
|
||
</div>
|
||
<div class="small text-muted mt-1">
|
||
${sub.firma_name ? `${escapeHtml(sub.firma_name)}` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="text-end ms-3">
|
||
<div class="badge bg-${sub.active ? 'success' : 'secondary'} mb-1">${sub.active ? 'Aktiv' : 'Inaktiv'}</div>
|
||
<div class="fw-bold text-info">${total.toFixed(2)} DKK</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="d-flex gap-2 flex-wrap small text-muted mt-2">
|
||
${sub.start_date ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-check me-1"></i>Start: ${formatDate(sub.start_date)}</span>` : ''}
|
||
${sub.antal ? `<span class="badge bg-light text-dark"><i class="bi bi-stack me-1"></i>Antal: ${sub.antal}</span>` : ''}
|
||
${sub.faktura_firma_name && sub.faktura_firma_name !== sub.firma_name ? `<span class="badge bg-light text-dark"><i class="bi bi-receipt me-1"></i>${escapeHtml(sub.faktura_firma_name)}</span>` : ''}
|
||
</div>
|
||
|
||
<div class="mt-3 pt-3 border-top">
|
||
<div class="small">
|
||
<div class="d-flex justify-content-between py-1">
|
||
<span class="text-muted">Antal:</span>
|
||
<strong>${sub.antal}</strong>
|
||
</div>
|
||
<div class="d-flex justify-content-between py-1">
|
||
<span class="text-muted">Pris pr. stk:</span>
|
||
<strong>${parseFloat(sub.pris || 0).toFixed(2)} DKK</strong>
|
||
</div>
|
||
${rabat > 0 ? `
|
||
<div class="d-flex justify-content-between py-1 text-danger">
|
||
<span>Rabat:</span>
|
||
<strong>-${rabat.toFixed(2)} DKK</strong>
|
||
</div>
|
||
` : ''}
|
||
${sub.beskrivelse ? `
|
||
<div class="py-1 text-muted small">
|
||
<em>${escapeHtml(sub.beskrivelse)}</em>
|
||
</div>
|
||
` : ''}
|
||
<div class="d-flex justify-content-between mt-2 pt-2 border-top">
|
||
<span class="text-muted">Subtotal:</span>
|
||
<strong>${subtotal.toFixed(2)} DKK</strong>
|
||
</div>
|
||
<div class="d-flex justify-content-between text-info fw-bold fs-5">
|
||
<span>Total inkl. moms:</span>
|
||
<strong>${total.toFixed(2)} DKK</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderSubscriptionsList(subscriptions, isLocked = false) {
|
||
if (!subscriptions || subscriptions.length === 0) {
|
||
return '<div class="text-center text-muted py-4"><i class="bi bi-inbox fs-1 d-block mb-2"></i>Ingen abonnementer</div>';
|
||
}
|
||
|
||
return subscriptions.map((sub, idx) => {
|
||
const itemId = `subscription-${idx}`;
|
||
const total = parseFloat(sub.hdnGrandTotal || 0);
|
||
// Extract numeric record ID from vTiger ID (e.g., "72x29932" -> "29932")
|
||
const recordId = sub.id.includes('x') ? sub.id.split('x')[1] : sub.id;
|
||
const vtigerUrl = `https://bmcnetworks.od2.vtiger.com/view/detail?module=Subscription&id=${recordId}&viewtype=summary`;
|
||
|
||
return `
|
||
<div class="subscription-item border rounded p-3 mb-3 bg-white shadow-sm ${sub.cf_subscription_bmclst === '1' ? 'border-danger border-3' : ''}">
|
||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||
<div class="flex-grow-1" style="cursor: pointer;" onclick="toggleSubscriptionDetails('${sub.id}', '${itemId}')">
|
||
<div class="fw-bold d-flex align-items-center">
|
||
<i class="bi bi-chevron-right me-2 text-primary" id="${itemId}-icon" style="font-size: 0.8rem;"></i>
|
||
${escapeHtml(sub.subject || sub.subscription_no || 'Unnamed')}
|
||
${sub.cf_subscription_bmclst === '1' ? '<i class="bi bi-lock-fill text-danger ms-2" title="BMC Låst - Skal faktureres fra BMC"></i>' : ''}
|
||
</div>
|
||
<div class="small text-muted mt-1">
|
||
${sub.subscription_no ? `#${escapeHtml(sub.subscription_no)}` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="text-end ms-3">
|
||
<div class="btn-group btn-group-sm mb-2" role="group">
|
||
<a href="${vtigerUrl}" target="_blank" class="btn btn-outline-success" title="Åbn i vTiger (for at ændre priser)">
|
||
<i class="bi bi-box-arrow-up-right"></i> vTiger
|
||
</a>
|
||
${!isLocked ? `
|
||
<button class="btn btn-outline-danger" onclick="deleteSubscription('${sub.id}', event)" title="Slet">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
<div>
|
||
${sub.cf_subscription_bmclst === '1' ? '<div class="badge bg-danger mb-1"><i class="bi bi-lock-fill me-1"></i>BMC LÅST</div>' : ''}
|
||
<div class="badge bg-${getStatusColor(sub.subscriptionstatus)} mb-1">${escapeHtml(sub.subscriptionstatus || 'Active')}</div>
|
||
<div class="fw-bold text-primary">${total.toFixed(2)} DKK</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="d-flex gap-2 flex-wrap small text-muted mt-2">
|
||
${sub.generateinvoiceevery ? `<span class="badge bg-light text-dark"><i class="bi bi-arrow-repeat me-1"></i>${escapeHtml(sub.generateinvoiceevery)}</span>` : ''}
|
||
${sub.next_subscription_date ? `<span class="badge bg-warning text-dark"><i class="bi bi-calendar-event me-1"></i>Next: ${formatDate(sub.next_subscription_date)}</span>` : ''}
|
||
${sub.startdate && sub.enddate ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-range me-1"></i>${formatDate(sub.startdate)} - ${formatDate(sub.enddate)}</span>` : ''}
|
||
${sub.startdate ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-check me-1"></i>Start: ${formatDate(sub.startdate)}</span>` : ''}
|
||
</div>
|
||
|
||
<div id="${itemId}-lines" class="mt-3 pt-3 border-top" style="display: none;">
|
||
<div class="text-center py-3">
|
||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||
<span class="visually-hidden">Indlæser...</span>
|
||
</div>
|
||
<div class="small text-muted mt-2">Henter produktlinjer...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderInvoicesList(invoices) {
|
||
if (!invoices || invoices.length === 0) {
|
||
return '<p class="text-muted small">Ingen fakturaer</p>';
|
||
}
|
||
|
||
return invoices.map((inv, idx) => {
|
||
const itemId = `regular-invoice-${idx}`;
|
||
const lineItems = inv.lineItems || [];
|
||
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
|
||
|
||
return `
|
||
<div class="border-bottom pb-3 mb-3">
|
||
<div class="d-flex justify-content-between align-items-start mb-2" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
|
||
<div class="fw-bold">
|
||
<i class="bi bi-chevron-right me-1" id="${itemId}-icon"></i>
|
||
${escapeHtml(inv.subject || inv.invoice_no || 'Unnamed')}
|
||
</div>
|
||
<span class="badge bg-${getStatusColor(inv.invoicestatus)}">${escapeHtml(inv.invoicestatus || 'Draft')}</span>
|
||
</div>
|
||
<div class="d-flex gap-3 small text-muted flex-wrap">
|
||
${inv.invoicedate ? `<span><i class="bi bi-calendar me-1"></i>${formatDate(inv.invoicedate)}</span>` : ''}
|
||
${inv.invoice_no ? `<span><i class="bi bi-hash me-1"></i>${escapeHtml(inv.invoice_no)}</span>` : ''}
|
||
${inv.hdnGrandTotal ? `<span><i class="bi bi-currency-dollar me-1"></i>${parseFloat(inv.hdnGrandTotal).toFixed(2)} DKK</span>` : ''}
|
||
</div>
|
||
${hasLineItems ? `
|
||
<div id="${itemId}-lines" class="mt-3 ps-3" style="display: none;">
|
||
<div class="small">
|
||
<strong>Produktlinjer:</strong>
|
||
${lineItems.map(line => `
|
||
<div class="border-start border-2 border-secondary ps-2 py-1 mt-2">
|
||
<div class="fw-bold">${escapeHtml(line.product_name || line.productid)}</div>
|
||
<div class="text-muted">
|
||
Antal: ${line.quantity || 0} × ${parseFloat(line.listprice || 0).toFixed(2)} DKK =
|
||
<strong>${parseFloat(line.netprice || 0).toFixed(2)} DKK</strong>
|
||
</div>
|
||
${line.comment ? `<div class="text-muted small">${escapeHtml(line.comment)}</div>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
<div class="border-top mt-2 pt-2">
|
||
<div class="d-flex justify-content-between">
|
||
<span>Subtotal:</span>
|
||
<strong>${parseFloat(inv.hdnSubTotal || 0).toFixed(2)} DKK</strong>
|
||
</div>
|
||
<div class="d-flex justify-content-between text-info fw-bold">
|
||
<span>Total inkl. moms:</span>
|
||
<strong>${parseFloat(inv.hdnGrandTotal || 0).toFixed(2)} DKK</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function getStatusColor(status) {
|
||
if (!status) return 'secondary';
|
||
const s = status.toLowerCase();
|
||
if (s.includes('active') || s.includes('approved')) return 'success';
|
||
if (s.includes('pending')) return 'warning';
|
||
if (s.includes('cancelled') || s.includes('expired')) return 'danger';
|
||
return 'info';
|
||
}
|
||
|
||
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);
|
||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
|
||
}
|
||
|
||
async function loadActivity() {
|
||
const container = document.getElementById('activityContainer');
|
||
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
||
|
||
// TODO: Implement activity log API endpoint
|
||
setTimeout(() => {
|
||
container.innerHTML = '<div class="text-muted text-center py-5">Ingen aktivitet at vise</div>';
|
||
}, 500);
|
||
}
|
||
|
||
async function loadCustomerKontakt() {
|
||
const container = document.getElementById('customerKontaktContainer');
|
||
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/customers/${customerId}/kontakt?limit=300`);
|
||
if (!response.ok) throw new Error('Kunne ikke hente kontakt historik');
|
||
|
||
const data = await response.json();
|
||
customerKontaktItems = data.items || [];
|
||
renderCustomerKontaktTable();
|
||
} catch (error) {
|
||
console.error('Failed to load customer kontakt history:', error);
|
||
container.innerHTML = '<div class="alert alert-danger">Kunne ikke hente kontakt historik</div>';
|
||
}
|
||
}
|
||
|
||
function setCustomerKontaktFilter(filter) {
|
||
customerKontaktFilter = filter;
|
||
document.getElementById('customerKontaktFilterAll')?.classList.toggle('active', filter === 'all');
|
||
document.getElementById('customerKontaktFilterSms')?.classList.toggle('active', filter === 'sms');
|
||
document.getElementById('customerKontaktFilterCall')?.classList.toggle('active', filter === 'call');
|
||
renderCustomerKontaktTable();
|
||
}
|
||
|
||
function renderCustomerKontaktTable() {
|
||
const container = document.getElementById('customerKontaktContainer');
|
||
if (!container) return;
|
||
|
||
const filtered = (customerKontaktItems || []).filter(item => {
|
||
if (customerKontaktFilter === 'all') return true;
|
||
return item.type === customerKontaktFilter;
|
||
});
|
||
|
||
if (!filtered.length) {
|
||
const msg = customerKontaktItems.length
|
||
? 'Ingen hændelser matcher filteret'
|
||
: 'Ingen opkald eller SMS fundet for virksomhedens kontakter';
|
||
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>Kontakt</th>
|
||
<th>Type</th>
|
||
<th>Retning/Status</th>
|
||
<th>Nummer</th>
|
||
<th>Indhold</th>
|
||
<th>Varighed</th>
|
||
<th>Bruger</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${filtered.map(renderCustomerKontaktRow).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderCustomerKontaktRow(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')}`
|
||
: '-';
|
||
|
||
const contactName = item.contact_id
|
||
? `<a href="/contacts/${item.contact_id}">${escapeHtml(item.contact_name || 'Ukendt')}</a>`
|
||
: escapeHtml(item.contact_name || '-');
|
||
|
||
return `
|
||
<tr>
|
||
<td>${ts}</td>
|
||
<td>${contactName}</td>
|
||
<td>${typeBadge}</td>
|
||
<td>${escapeHtml(dirOrStatus || '-')}</td>
|
||
<td>${escapeHtml(item.number || '-')}</td>
|
||
<td class="text-break" style="max-width: 380px;">${message}</td>
|
||
<td>${duration}</td>
|
||
<td>${escapeHtml(item.user_name || '-')}</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
async function loadConversations() {
|
||
const container = document.getElementById('conversationsContainer');
|
||
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/conversations?customer_id=${customerId}`);
|
||
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="card-title fw-bold mb-1">
|
||
${c.is_private ? '<i class="bi bi-lock-fill text-warning" title="Privat"></i> ' : ''}
|
||
${c.title}
|
||
</h6>
|
||
<div class="small text-muted">
|
||
<i class="bi bi-calendar me-1"></i> ${date}
|
||
${duration ? `• <i class="bi bi-clock me-1"></i> ${duration}` : ''}
|
||
• <span class="badge bg-light text-dark border">${c.source}</span>
|
||
• <span class="badge bg-secondary">${c.category || 'General'}</span>
|
||
</div>
|
||
</div>
|
||
<div class="dropdown">
|
||
<button class="btn btn-link text-muted p-0" data-bs-toggle="dropdown">
|
||
<i class="bi bi-three-dots-vertical"></i>
|
||
</button>
|
||
<ul class="dropdown-menu dropdown-menu-end">
|
||
<li><a class="dropdown-item" href="#" onclick="togglePrivacy(${c.id}, ${!c.is_private})">
|
||
${c.is_private ? 'Gør Offentlig' : 'Gør Privat'}
|
||
</a></li>
|
||
<li><hr class="dropdown-divider"></li>
|
||
<li><a class="dropdown-item text-danger" href="#" onclick="deleteConversation(${c.id})">Slet</a></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<audio controls class="w-100 mb-3 bg-light rounded">
|
||
<source src="/api/v1/conversations/${c.id}/audio" type="audio/mpeg">
|
||
Your browser does not support the audio element.
|
||
</audio>
|
||
|
||
${c.transcript ? `
|
||
<div class="accordion" id="accordion-${c.id}">
|
||
<div class="accordion-item border-0">
|
||
<h2 class="accordion-header">
|
||
<button class="accordion-button collapsed py-2 bg-light rounded" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${c.id}">
|
||
<i class="bi bi-file-text me-2"></i> Vis Transskribering
|
||
</button>
|
||
</h2>
|
||
<div id="collapse-${c.id}" class="accordion-collapse collapse" data-bs-parent="#accordion-${c.id}">
|
||
<div class="accordion-body bg-light rounded mt-2 small font-monospace" style="white-space: pre-wrap;">${c.transcript}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function filterConversations() {
|
||
const query = document.getElementById('conversationSearch').value.toLowerCase();
|
||
const items = document.querySelectorAll('.conversation-item');
|
||
|
||
items.forEach(item => {
|
||
const text = item.getAttribute('data-text').toLowerCase();
|
||
item.style.display = text.includes(query) ? 'block' : 'none';
|
||
});
|
||
}
|
||
|
||
async function togglePrivacy(id, makePrivate) {
|
||
try {
|
||
await fetch(`/api/v1/conversations/${id}`, {
|
||
method: 'PATCH',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({is_private: makePrivate})
|
||
});
|
||
loadConversations(); // Reload
|
||
} catch(e) { alert('Fejl'); }
|
||
}
|
||
|
||
async function deleteConversation(id) {
|
||
if(!confirm('Vil du slette denne samtale?')) return;
|
||
|
||
// User requested "SLET alt" capability.
|
||
// If user confirms again, we do hard delete.
|
||
const hard = confirm('ADVARSEL: Skal dette være en permanent sletning af fil og data? (Kan ikke fortrydes)\n\nTryk OK for Permanent Sletning.\nTryk Cancel for Papirkurv.');
|
||
|
||
try {
|
||
await fetch(`/api/v1/conversations/${id}?hard_delete=${hard}`, { method: 'DELETE' });
|
||
loadConversations();
|
||
} catch(e) { alert('Fejl under sletning'); }
|
||
}
|
||
|
||
async function toggleSubscriptionDetails(subscriptionId, itemId) {
|
||
const linesDiv = document.getElementById(`${itemId}-lines`);
|
||
const icon = document.getElementById(`${itemId}-icon`);
|
||
|
||
if (!linesDiv) return;
|
||
|
||
const item = linesDiv.closest('.subscription-item');
|
||
|
||
if (linesDiv.style.display === 'none') {
|
||
linesDiv.style.display = 'block';
|
||
if (icon) icon.className = 'bi bi-chevron-down me-2 text-primary';
|
||
if (item) item.classList.add('expanded');
|
||
|
||
// Fetch line items if not already loaded
|
||
if (linesDiv.querySelector('.spinner-border')) {
|
||
try {
|
||
const response = await fetch(`/api/v1/subscriptions/${subscriptionId}`);
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'success') {
|
||
const sub = data.subscription;
|
||
const lineItems = sub.LineItems || [];
|
||
const total = parseFloat(sub.hdnGrandTotal || 0);
|
||
|
||
if (lineItems.length > 0) {
|
||
linesDiv.innerHTML = `
|
||
<div class="small">
|
||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||
<strong>Produktlinjer:</strong>
|
||
<span class="text-muted small">
|
||
<i class="bi bi-info-circle me-1"></i>
|
||
For at ændre priser, klik "Åbn i vTiger"
|
||
</span>
|
||
</div>
|
||
${lineItems.map(line => `
|
||
<div class="d-flex justify-content-between align-items-start py-2 border-bottom">
|
||
<div class="flex-grow-1">
|
||
<div class="fw-bold">${escapeHtml(line.product_name || line.productid)}</div>
|
||
<div class="text-muted small">
|
||
${line.quantity} stk × ${parseFloat(line.listprice).toFixed(2)} DKK
|
||
</div>
|
||
${line.comment ? `<div class="text-muted small"><i class="bi bi-chat-left-text me-1"></i>${escapeHtml(line.comment)}</div>` : ''}
|
||
</div>
|
||
<div class="text-end fw-bold">
|
||
${parseFloat(line.netprice || 0).toFixed(2)} DKK
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
<div class="d-flex justify-content-between mt-3 pt-2">
|
||
<span class="text-muted">Subtotal:</span>
|
||
<strong>${parseFloat(sub.hdnSubTotal || 0).toFixed(2)} DKK</strong>
|
||
</div>
|
||
<div class="d-flex justify-content-between text-primary fw-bold fs-5">
|
||
<span>Total inkl. moms:</span>
|
||
<strong>${total.toFixed(2)} DKK</strong>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
linesDiv.innerHTML = '<div class="text-muted small">Ingen produktlinjer</div>';
|
||
}
|
||
} else {
|
||
linesDiv.innerHTML = '<div class="text-danger small">Kunne ikke hente produktlinjer</div>';
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching subscription details:', error);
|
||
linesDiv.innerHTML = '<div class="text-danger small">Fejl ved indlæsning</div>';
|
||
}
|
||
}
|
||
} else {
|
||
linesDiv.style.display = 'none';
|
||
if (icon) icon.className = 'bi bi-chevron-right me-2 text-primary';
|
||
if (item) item.classList.remove('expanded');
|
||
}
|
||
}
|
||
|
||
function toggleLineItems(itemId) {
|
||
const linesDiv = document.getElementById(`${itemId}-lines`);
|
||
const icon = document.getElementById(`${itemId}-icon`);
|
||
|
||
if (!linesDiv) return;
|
||
|
||
const item = linesDiv.closest('.subscription-item');
|
||
|
||
if (linesDiv.style.display === 'none') {
|
||
linesDiv.style.display = 'block';
|
||
if (icon) icon.className = 'bi bi-chevron-down me-2 text-success';
|
||
if (item) item.classList.add('expanded');
|
||
} else {
|
||
linesDiv.style.display = 'none';
|
||
if (icon) icon.className = 'bi bi-chevron-right me-2 text-success';
|
||
if (item) item.classList.remove('expanded');
|
||
}
|
||
}
|
||
|
||
function editCustomer() {
|
||
if (!customerData) {
|
||
alert('Kunde data ikke indlæst endnu');
|
||
return;
|
||
}
|
||
|
||
// Pre-fill form with current data
|
||
document.getElementById('editName').value = customerData.name || '';
|
||
document.getElementById('editCvrNumber').value = customerData.cvr_number || '';
|
||
document.getElementById('editEmail').value = customerData.email || '';
|
||
document.getElementById('editInvoiceEmail').value = customerData.invoice_email || '';
|
||
document.getElementById('editPhone').value = customerData.phone || '';
|
||
document.getElementById('editMobilePhone').value = customerData.mobile_phone || '';
|
||
document.getElementById('editWebsite').value = customerData.website || '';
|
||
document.getElementById('editWikiSlug').value = customerData.wiki_slug || '';
|
||
document.getElementById('editCountry').value = customerData.country || 'DK';
|
||
document.getElementById('editAddress').value = customerData.address || '';
|
||
document.getElementById('editPostalCode').value = customerData.postal_code || '';
|
||
document.getElementById('editCity').value = customerData.city || '';
|
||
document.getElementById('editIsActive').checked = customerData.is_active !== false;
|
||
|
||
// Show modal
|
||
const modal = new bootstrap.Modal(document.getElementById('editCustomerModal'));
|
||
modal.show();
|
||
}
|
||
|
||
async function saveCustomerEdit() {
|
||
const updateData = {
|
||
name: document.getElementById('editName').value,
|
||
cvr_number: document.getElementById('editCvrNumber').value || null,
|
||
email: document.getElementById('editEmail').value || null,
|
||
invoice_email: document.getElementById('editInvoiceEmail').value || null,
|
||
phone: document.getElementById('editPhone').value || null,
|
||
mobile_phone: document.getElementById('editMobilePhone').value || null,
|
||
website: document.getElementById('editWebsite').value || null,
|
||
wiki_slug: document.getElementById('editWikiSlug').value || null,
|
||
country: document.getElementById('editCountry').value || 'DK',
|
||
address: document.getElementById('editAddress').value || null,
|
||
postal_code: document.getElementById('editPostalCode').value || null,
|
||
city: document.getElementById('editCity').value || null,
|
||
is_active: document.getElementById('editIsActive').checked
|
||
};
|
||
|
||
// Validate required fields
|
||
if (!updateData.name || updateData.name.trim() === '') {
|
||
alert('Virksomhedsnavn er påkrævet');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/customers/${customerId}`, {
|
||
method: 'PUT',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(updateData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Kunne ikke opdatere kunde');
|
||
}
|
||
|
||
const updatedCustomer = await response.json();
|
||
|
||
// Close modal
|
||
bootstrap.Modal.getInstance(document.getElementById('editCustomerModal')).hide();
|
||
|
||
// Reload customer data
|
||
await loadCustomer();
|
||
|
||
// Show success message
|
||
alert('✓ Kunde opdateret succesfuldt!');
|
||
|
||
} catch (error) {
|
||
console.error('Error updating customer:', error);
|
||
alert('Fejl ved opdatering: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// Data Consistency Functions
|
||
let consistencyData = null;
|
||
|
||
async function checkDataConsistency() {
|
||
try {
|
||
const response = await fetch(`/api/v1/customers/${customerId}/data-consistency`);
|
||
const data = await response.json();
|
||
|
||
if (!data.enabled) {
|
||
console.log('Data consistency checking is disabled');
|
||
return;
|
||
}
|
||
|
||
consistencyData = data;
|
||
|
||
// Show alert if there are discrepancies
|
||
if (data.discrepancy_count > 0) {
|
||
document.getElementById('discrepancyCount').textContent = data.discrepancy_count;
|
||
document.getElementById('consistencyAlert').classList.remove('d-none');
|
||
} else {
|
||
document.getElementById('consistencyAlert').classList.add('d-none');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error checking data consistency:', error);
|
||
}
|
||
}
|
||
|
||
function showConsistencyModal() {
|
||
if (!consistencyData) {
|
||
alert('Ingen data tilgængelig');
|
||
return;
|
||
}
|
||
|
||
const tbody = document.getElementById('consistencyTableBody');
|
||
tbody.innerHTML = '';
|
||
|
||
// Field labels in Danish
|
||
const fieldLabels = {
|
||
'name': 'Navn',
|
||
'cvr_number': 'CVR Nummer',
|
||
'address': 'Adresse',
|
||
'city': 'By',
|
||
'postal_code': 'Postnummer',
|
||
'country': 'Land',
|
||
'phone': 'Telefon',
|
||
'mobile_phone': 'Mobil',
|
||
'email': 'Email',
|
||
'website': 'Hjemmeside',
|
||
'invoice_email': 'Faktura Email'
|
||
};
|
||
|
||
// Only show fields with discrepancies
|
||
for (const [fieldName, fieldData] of Object.entries(consistencyData.discrepancies)) {
|
||
if (!fieldData.discrepancy) continue;
|
||
|
||
const row = document.createElement('tr');
|
||
row.className = 'table-warning';
|
||
|
||
// Field name
|
||
const fieldCell = document.createElement('td');
|
||
fieldCell.innerHTML = `<strong>${fieldLabels[fieldName] || fieldName}</strong>`;
|
||
row.appendChild(fieldCell);
|
||
|
||
// Hub value
|
||
const hubCell = document.createElement('td');
|
||
hubCell.innerHTML = `
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="field_${fieldName}"
|
||
id="hub_${fieldName}" value="hub" data-value="${fieldData.hub || ''}">
|
||
<label class="form-check-label" for="hub_${fieldName}">
|
||
${fieldData.hub || '<em class="text-muted">Tom</em>'}
|
||
</label>
|
||
</div>
|
||
`;
|
||
row.appendChild(hubCell);
|
||
|
||
// vTiger value
|
||
const vtigerCell = document.createElement('td');
|
||
if (consistencyData.systems_available.vtiger) {
|
||
vtigerCell.innerHTML = `
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="field_${fieldName}"
|
||
id="vtiger_${fieldName}" value="vtiger" data-value="${fieldData.vtiger || ''}">
|
||
<label class="form-check-label" for="vtiger_${fieldName}">
|
||
${fieldData.vtiger || '<em class="text-muted">Tom</em>'}
|
||
</label>
|
||
</div>
|
||
`;
|
||
} else {
|
||
vtigerCell.innerHTML = '<em class="text-muted">Ikke tilgængelig</em>';
|
||
}
|
||
row.appendChild(vtigerCell);
|
||
|
||
// e-conomic value
|
||
const economicCell = document.createElement('td');
|
||
if (consistencyData.systems_available.economic) {
|
||
economicCell.innerHTML = `
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="field_${fieldName}"
|
||
id="economic_${fieldName}" value="economic" data-value="${fieldData.economic || ''}">
|
||
<label class="form-check-label" for="economic_${fieldName}">
|
||
${fieldData.economic || '<em class="text-muted">Tom</em>'}
|
||
</label>
|
||
</div>
|
||
`;
|
||
} else {
|
||
economicCell.innerHTML = '<em class="text-muted">Ikke tilgængelig</em>';
|
||
}
|
||
row.appendChild(economicCell);
|
||
|
||
// Action cell (which system to use)
|
||
const actionCell = document.createElement('td');
|
||
actionCell.innerHTML = '<span class="text-muted">← Vælg</span>';
|
||
row.appendChild(actionCell);
|
||
|
||
tbody.appendChild(row);
|
||
}
|
||
|
||
const modal = new bootstrap.Modal(document.getElementById('consistencyModal'));
|
||
modal.show();
|
||
}
|
||
|
||
async function syncSelectedFields() {
|
||
const selections = [];
|
||
|
||
// Gather all selected values
|
||
const radioButtons = document.querySelectorAll('#consistencyTableBody input[type="radio"]:checked');
|
||
|
||
if (radioButtons.length === 0) {
|
||
alert('Vælg venligst mindst ét felt at synkronisere');
|
||
return;
|
||
}
|
||
|
||
radioButtons.forEach(radio => {
|
||
const fieldName = radio.name.replace('field_', '');
|
||
const sourceSystem = radio.value;
|
||
const sourceValue = radio.dataset.value;
|
||
|
||
selections.push({
|
||
field_name: fieldName,
|
||
source_system: sourceSystem,
|
||
source_value: sourceValue
|
||
});
|
||
});
|
||
|
||
// Confirm action
|
||
if (!confirm(`Du er ved at synkronisere ${selections.length} felt(er) på tværs af alle systemer. Fortsæt?`)) {
|
||
return;
|
||
}
|
||
|
||
// Sync each field
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
|
||
for (const selection of selections) {
|
||
try {
|
||
const response = await fetch(
|
||
`/api/v1/customers/${customerId}/sync-field?` +
|
||
new URLSearchParams(selection),
|
||
{ method: 'POST' }
|
||
);
|
||
|
||
if (response.ok) {
|
||
successCount++;
|
||
} else {
|
||
failCount++;
|
||
console.error(`Failed to sync ${selection.field_name}`);
|
||
}
|
||
} catch (error) {
|
||
failCount++;
|
||
console.error(`Error syncing ${selection.field_name}:`, error);
|
||
}
|
||
}
|
||
|
||
// Close modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('consistencyModal'));
|
||
modal.hide();
|
||
|
||
// Show result
|
||
if (failCount === 0) {
|
||
alert(`✓ ${successCount} felt(er) synkroniseret succesfuldt!`);
|
||
} else {
|
||
alert(`⚠️ ${successCount} felt(er) synkroniseret, ${failCount} fejlede`);
|
||
}
|
||
|
||
// Reload customer data and recheck consistency
|
||
await loadCustomer();
|
||
await checkDataConsistency();
|
||
}
|
||
|
||
function showAddContactModal() {
|
||
// TODO: Open add contact modal
|
||
console.log('Add contact for customer:', customerId);
|
||
}
|
||
|
||
// Subscription management functions
|
||
let currentSubscriptions = [];
|
||
|
||
function showCreateSubscriptionModal() {
|
||
if (!customerData || !customerData.vtiger_id) {
|
||
alert('Kunden er ikke linket til vTiger');
|
||
return;
|
||
}
|
||
|
||
const modal = new bootstrap.Modal(document.getElementById('subscriptionModal'));
|
||
document.getElementById('subscriptionModalLabel').textContent = 'Opret Nyt Abonnement';
|
||
document.getElementById('subscriptionForm').reset();
|
||
document.getElementById('subscriptionId').value = '';
|
||
modal.show();
|
||
}
|
||
|
||
async function editSubscription(subscriptionId, event) {
|
||
event.stopPropagation();
|
||
|
||
// Find subscription data
|
||
const sub = currentSubscriptions.find(s => s.id === subscriptionId);
|
||
if (!sub) {
|
||
alert('Abonnement ikke fundet');
|
||
return;
|
||
}
|
||
|
||
// Fill form
|
||
document.getElementById('subscriptionId').value = subscriptionId;
|
||
document.getElementById('subjectInput').value = sub.subject || '';
|
||
document.getElementById('startdateInput').value = sub.startdate || '';
|
||
document.getElementById('enddateInput').value = sub.enddate || '';
|
||
document.getElementById('frequencyInput').value = sub.generateinvoiceevery || 'Monthly';
|
||
document.getElementById('statusInput').value = sub.subscriptionstatus || 'Active';
|
||
|
||
// Show modal
|
||
const modal = new bootstrap.Modal(document.getElementById('subscriptionModal'));
|
||
document.getElementById('subscriptionModalLabel').textContent = 'Rediger Abonnement';
|
||
modal.show();
|
||
}
|
||
|
||
async function deleteSubscription(subscriptionId, event) {
|
||
event.stopPropagation();
|
||
|
||
if (!confirm('Er du sikker på at du vil slette dette abonnement?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/subscriptions/${subscriptionId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to delete subscription');
|
||
}
|
||
|
||
alert('Abonnement slettet');
|
||
loadSubscriptions(); // Reload
|
||
} catch (error) {
|
||
console.error('Error deleting subscription:', error);
|
||
alert('Kunne ikke slette abonnement: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function saveSubscription() {
|
||
const subscriptionId = document.getElementById('subscriptionId').value;
|
||
const isEdit = !!subscriptionId;
|
||
|
||
const data = {
|
||
subject: document.getElementById('subjectInput').value,
|
||
startdate: document.getElementById('startdateInput').value,
|
||
enddate: document.getElementById('enddateInput').value || null,
|
||
generateinvoiceevery: document.getElementById('frequencyInput').value,
|
||
subscriptionstatus: document.getElementById('statusInput').value,
|
||
products: [] // TODO: Add product picker
|
||
};
|
||
|
||
if (!isEdit) {
|
||
data.account_id = customerData.vtiger_id;
|
||
}
|
||
|
||
try {
|
||
let response;
|
||
if (isEdit) {
|
||
response = await fetch(`/api/v1/subscriptions/${subscriptionId}`, {
|
||
method: 'PUT',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(data)
|
||
});
|
||
} else {
|
||
response = await fetch(`/api/v1/customers/${customerId}/subscriptions`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(data)
|
||
});
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to save subscription');
|
||
}
|
||
|
||
alert(isEdit ? 'Abonnement opdateret' : 'Abonnement oprettet');
|
||
bootstrap.Modal.getInstance(document.getElementById('subscriptionModal')).hide();
|
||
loadSubscriptions(); // Reload
|
||
} catch (error) {
|
||
console.error('Error saving subscription:', error);
|
||
alert('Kunne ikke gemme abonnement: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function loadCustomerSessions() {
|
||
const container = document.getElementById('customerSessionsContainer');
|
||
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?customer_id=${customerId}`);
|
||
if (!response.ok) {
|
||
throw new Error('Kunne ikke hente sessioner');
|
||
}
|
||
|
||
const data = await response.json();
|
||
displayCustomerSessions(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 displayCustomerSessions(sessions) {
|
||
const container = document.getElementById('customerSessionsContainer');
|
||
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="showCustomerWorklogSuggestion(${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.contact_name ? `<div class="session-meta">Kontakt: ${escapeHtml(session.contact_name)}</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 startCustomerSession() {
|
||
const alertBox = document.getElementById('customerSessionAlert');
|
||
const description = document.getElementById('customerSessionDescription')?.value?.trim() || null;
|
||
|
||
try {
|
||
const response = await fetch('/api/v1/anydesk/start-session', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
customer_id: customerId,
|
||
description: description || `Remote support for ${customerData?.name || 'customer'}`
|
||
})
|
||
});
|
||
|
||
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 loadCustomerSessions();
|
||
} catch (error) {
|
||
console.error('Failed to start session:', error);
|
||
alert('Fejl: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function showCustomerWorklogSuggestion(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('customerWorklogSuggestionBody');
|
||
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('customerWorklogSuggestionModal'));
|
||
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';
|
||
}
|
||
}
|
||
|
||
function getInitials(name) {
|
||
if (!name) return '?';
|
||
const words = name.trim().split(' ');
|
||
if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
|
||
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
async function toggleSubscriptionsLock() {
|
||
const currentlyLocked = customerData?.subscriptions_locked || false;
|
||
const action = currentlyLocked ? 'låse op' : 'låse';
|
||
|
||
if (!confirm(`Er du sikker på at du vil ${action} abonnementer for denne kunde?\n\n${currentlyLocked ? 'Efter oplåsning kan abonnementer redigeres i BMC Hub.' : 'Efter låsning kan abonnementer kun redigeres direkte i vTiger.'}`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/customers/${customerId}/subscriptions/lock`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ locked: !currentlyLocked })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Kunne ikke opdatere låsestatus');
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
// Reload customer and subscriptions
|
||
await loadCustomer();
|
||
await loadSubscriptions();
|
||
|
||
alert(`✓ ${result.message}\n\n${result.note || ''}`);
|
||
|
||
} catch (error) {
|
||
console.error('Error toggling lock:', error);
|
||
alert('Fejl: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function saveInternalComment() {
|
||
const commentInput = document.getElementById('internalCommentInput');
|
||
const commentText = commentInput.value.trim();
|
||
|
||
if (!commentText) {
|
||
alert('Indtast venligst en kommentar');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/customers/${customerId}/subscription-comment`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ comment: commentText })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Kunne ikke gemme kommentar');
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
// Clear input
|
||
commentInput.value = '';
|
||
|
||
// Show saved comment
|
||
displayInternalComment(result);
|
||
|
||
alert('✓ Kommentar gemt');
|
||
|
||
} catch (error) {
|
||
console.error('Error saving comment:', error);
|
||
alert('Fejl: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function loadInternalComment() {
|
||
try {
|
||
const response = await fetch(`/api/v1/customers/${customerId}/subscription-comment`);
|
||
|
||
if (response.status === 404) {
|
||
// No comment yet, that's fine
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Kunne ikke hente kommentar');
|
||
}
|
||
|
||
const result = await response.json();
|
||
displayInternalComment(result);
|
||
|
||
} catch (error) {
|
||
console.error('Error loading comment:', error);
|
||
}
|
||
}
|
||
|
||
function displayInternalComment(data) {
|
||
const displayDiv = document.getElementById('internalCommentDisplay');
|
||
const editDiv = document.getElementById('internalCommentEdit');
|
||
const commentMeta = document.getElementById('commentMeta');
|
||
const commentText = document.getElementById('commentText');
|
||
|
||
if (data && data.comment) {
|
||
commentText.textContent = data.comment;
|
||
|
||
// Format timestamp
|
||
const timestamp = new Date(data.created_at).toLocaleString('da-DK', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
|
||
commentMeta.textContent = `Oprettet af ${data.created_by || 'System'} • ${timestamp}`;
|
||
displayDiv.style.display = 'block';
|
||
editDiv.style.display = 'none';
|
||
} else {
|
||
displayDiv.style.display = 'none';
|
||
editDiv.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
function editInternalComment() {
|
||
const commentText = document.getElementById('commentText').textContent;
|
||
const commentInput = document.getElementById('internalCommentInput');
|
||
const displayDiv = document.getElementById('internalCommentDisplay');
|
||
const editDiv = document.getElementById('internalCommentEdit');
|
||
|
||
// Show input, populate with existing text
|
||
commentInput.value = commentText;
|
||
editDiv.style.display = 'block';
|
||
displayDiv.style.display = 'none';
|
||
}
|
||
|
||
/**
|
||
* Load and render subscription billing matrix from e-conomic
|
||
*/
|
||
async function loadBillingMatrix() {
|
||
const loading = document.getElementById('billingMatrixLoading');
|
||
const container = document.getElementById('billingMatrixContainer');
|
||
const empty = document.getElementById('billingMatrixEmpty');
|
||
|
||
// Show loading state
|
||
loading.style.display = 'block';
|
||
container.style.display = 'none';
|
||
empty.style.display = 'none';
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/customers/${customerId}/subscriptions/billing-matrix`);
|
||
const matrix = await response.json();
|
||
|
||
console.log('📊 Billing matrix:', matrix);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(matrix.detail || 'Failed to load billing matrix');
|
||
}
|
||
|
||
// Check if matrix has products
|
||
if (!matrix.products || matrix.products.length === 0) {
|
||
empty.style.display = 'block';
|
||
loading.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// Render matrix
|
||
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');
|
||
|
||
// Get all unique months across all products
|
||
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;
|
||
}
|
||
|
||
// Build header with month columns
|
||
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>';
|
||
|
||
// Build body with product rows
|
||
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('');
|
||
|
||
// Show search container if there are products
|
||
const searchContainer = document.getElementById('matrixSearchContainer');
|
||
if (matrix.products.length > 0) {
|
||
searchContainer.style.display = 'block';
|
||
} else {
|
||
searchContainer.style.display = 'none';
|
||
}
|
||
|
||
// Show matrix, hide loading
|
||
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 };
|
||
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) {
|
||
if (!amount || amount === 0) return '0 kr';
|
||
return amount.toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
|
||
}
|
||
|
||
/**
|
||
* Filter products in billing matrix based on search input
|
||
*/
|
||
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';
|
||
}
|
||
}
|
||
|
||
// Show message if no results
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear matrix search filter
|
||
*/
|
||
function clearMatrixSearch() {
|
||
const searchInput = document.getElementById('matrixSearchInput');
|
||
searchInput.value = '';
|
||
filterMatrixProducts();
|
||
searchInput.focus();
|
||
}
|
||
|
||
// Auto-load matrix when subscriptions tab is shown
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
|
||
if (subscriptionsTab) {
|
||
subscriptionsTab.addEventListener('shown.bs.tab', () => {
|
||
// Load matrix if not already loaded
|
||
if (!document.getElementById('billingMatrixContainer').innerHTML.includes('table-responsive')) {
|
||
setTimeout(() => loadBillingMatrix(), 300);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Auto-load matrix when billing-matrix tab is shown
|
||
const billingMatrixTab = document.querySelector('a[href="#billing-matrix"]');
|
||
if (billingMatrixTab) {
|
||
billingMatrixTab.addEventListener('shown.bs.tab', () => {
|
||
const container = document.getElementById('billingMatrixContainer');
|
||
const loading = document.getElementById('billingMatrixLoading');
|
||
|
||
// Check if we need to load
|
||
if (loading.style.display !== 'none' && container.style.display === 'none') {
|
||
loadBillingMatrix();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<!-- Data Consistency Comparison Modal -->
|
||
<div class="modal fade" id="consistencyModal" tabindex="-1" aria-labelledby="consistencyModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-xl">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="consistencyModalLabel">
|
||
<i class="bi bi-diagram-3 me-2"></i>Sammenlign Kundedata
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="alert alert-info">
|
||
<i class="bi bi-info-circle me-2"></i>
|
||
<strong>Vejledning:</strong> Vælg den korrekte værdi for hvert felt med uoverensstemmelser.
|
||
Når du klikker "Synkroniser Valgte", vil de valgte værdier blive opdateret i alle systemer.
|
||
</div>
|
||
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 20%;">Felt</th>
|
||
<th style="width: 20%;">BMC Hub</th>
|
||
<th style="width: 20%;">vTiger</th>
|
||
<th style="width: 20%;">e-conomic</th>
|
||
<th style="width: 20%;">Vælg Korrekt</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="consistencyTableBody">
|
||
<!-- Populated by JavaScript -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||
<i class="bi bi-x-circle me-2"></i>Luk
|
||
</button>
|
||
<button type="button" class="btn btn-primary" onclick="syncSelectedFields()">
|
||
<i class="bi bi-arrow-repeat me-2"></i>Synkroniser Valgte
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|