bmc_hub/app/customers/frontend/customer_detail.html
Christian 361f2fad5d feat: Implement vTiger integration for subscriptions and sales orders
- Added a new VTigerService class for handling API interactions with vTiger CRM.
- Implemented methods to fetch customer subscriptions and sales orders.
- Created a new database migration for BMC Office subscriptions, including table structure and view for totals.
- Enhanced customer detail frontend to display subscriptions and sales orders with improved UI/UX.
- Added JavaScript functions for loading and displaying subscription data dynamically.
- Created tests for vTiger API queries and field inspections to ensure data integrity and functionality.
2025-12-11 23:14:20 +01:00

1090 lines
46 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);
}
</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">
<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>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-light btn-sm" onclick="editCustomer()">
<i class="bi bi-pencil me-2"></i>Rediger
</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>
<!-- 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>Abonnementer
</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">Abonnementer & Salgsordre</h5>
<button class="btn btn-primary btn-sm" onclick="loadSubscriptions()">
<i class="bi bi-arrow-repeat me-2"></i>Opdater fra vTiger
</button>
</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>
{% 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();
}, { 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);
} 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;
// 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;
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">';
// 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">
<h5 class="fw-bold mb-1">
<i class="bi bi-arrow-repeat text-primary me-2"></i>
vTiger Abonnementer
</h5>
<small class="text-muted">Fra Simply-CRM</small>
</div>
${renderSubscriptionsList(subscriptions || [])}
</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);
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" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
<div class="flex-grow-1">
<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="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 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>` : ''}
</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) {
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 lineItems = sub.lineItems || [];
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
const total = parseFloat(sub.hdnGrandTotal || 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" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
<div class="flex-grow-1">
<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')}
</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="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 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.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>
${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(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>
</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);
}
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-primary';
if (item) item.classList.add('expanded');
} else {
linesDiv.style.display = 'none';
if (icon) {
const isSubscription = itemId.includes('subscription');
icon.className = `bi bi-chevron-right me-2 ${isSubscription ? 'text-primary' : 'text-success'}`;
}
if (item) item.classList.remove('expanded');
}
}
function editCustomer() {
// TODO: Open edit modal with pre-filled data
console.log('Edit customer:', customerId);
}
function showAddContactModal() {
// TODO: Open add contact modal
console.log('Add contact for customer:', customerId);
}
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;
}
</script>
{% endblock %}