bmc_hub/app/customers/frontend/customer_detail.html
Christian cbcd0fe4e7 feat: Implement data consistency checking system for customer data across BMC Hub, vTiger, and e-conomic
- Added CustomerConsistencyService to compare and sync customer data.
- Introduced new API endpoints for data consistency checks and field synchronization.
- Enhanced customer detail page with alert for discrepancies and modal for manual syncing.
- Updated vTiger and e-conomic services to support fetching and updating customer data.
- Added configuration options for enabling/disabling sync operations and automatic checks.
- Implemented data normalization and error handling for robust comparisons.
- Documented the new system and its features in DATA_CONSISTENCY_SYSTEM.md.
2026-01-08 18:28:00 +01:00

2034 lines
87 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>
<!-- 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="#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>
</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="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>
<!-- 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>
</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>
{% endblock %}
{% block extra_js %}
<script>
const customerId = parseInt(window.location.pathname.split('/').pop());
let customerData = null;
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 activity when tab is shown
const activityTab = document.querySelector('a[href="#activity"]');
if (activityTab) {
activityTab.addEventListener('shown.bs.tab', () => {
loadActivity();
}, { 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);
// 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`;
// 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 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>';
}
}
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;
}
}
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 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';
}
</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 %}