bmc_hub/app/customers/frontend/customer_detail.html
2026-01-29 00:36:32 +01:00

2745 lines
117 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends "shared/frontend/base.html" %}
{% block title %}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);
}
.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="#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="#hardware">
<i class="bi bi-hdd"></i>Hardware
</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>
</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>
</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="row g-4" id="contactsContainer">
<div class="col-12 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>
<!-- Hardware Tab -->
<div class="tab-pane fade" id="hardware">
<h5 class="fw-bold mb-4">Hardware</h5>
<div class="text-muted text-center py-5">
Hardwaremodul kommer snart...
</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="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>
<!-- 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>
{% endblock %}
{% block extra_js %}
<script>
const customerId = parseInt(window.location.pathname.split('/').pop());
let customerData = null;
let pipelineStages = [];
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 });
}
// 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 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 });
}
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();
// 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').textContent = customer.phone || '-';
document.getElementById('website').textContent = customer.website || '-';
// 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');
}
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 = '<div class="col-12 text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
const contacts = await response.json();
if (!contacts || contacts.length === 0) {
container.innerHTML = '<div class="col-12 text-center py-5 text-muted">Ingen kontakter endnu</div>';
return;
}
container.innerHTML = contacts.map(contact => `
<div class="col-md-6">
<div class="contact-card">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h6 class="fw-bold mb-1">${escapeHtml(contact.name)}</h6>
<div class="text-muted small">${contact.title || 'Kontakt'}</div>
</div>
${contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : ''}
</div>
<div class="d-flex flex-column gap-2">
${contact.email ? `
<div class="d-flex align-items-center">
<i class="bi bi-envelope me-2 text-muted"></i>
<a href="mailto:${contact.email}">${contact.email}</a>
</div>
` : ''}
${contact.phone ? `
<div class="d-flex align-items-center">
<i class="bi bi-telephone me-2 text-muted"></i>
<a href="tel:${contact.phone}">${contact.phone}</a>
</div>
` : ''}
${contact.mobile ? `
<div class="d-flex align-items-center">
<i class="bi bi-phone me-2 text-muted"></i>
<a href="tel:${contact.mobile}">${contact.mobile}</a>
</div>
` : ''}
</div>
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load contacts:', error);
container.innerHTML = '<div class="col-12 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);
}
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 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('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,
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);
}
}
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 %}