feat: Update frontend navigation and links for support and CRM sections fix: Modify subscription listing and stats endpoints to support 'all' status feat: Implement subscription status filter in the subscriptions list view feat: Redirect ticket routes to the new sag path feat: Integrate devportal routes into the main application feat: Create a wizard for location creation with nested floors and rooms feat: Add product suppliers table to track multiple suppliers per product feat: Implement product audit log to track changes in products feat: Extend location types to include kantine and moedelokale feat: Add last_2fa_at column to users table for 2FA grace period tracking
193 lines
7.0 KiB
HTML
193 lines
7.0 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Abonnementer - BMC Hub{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-4">
|
|
<div class="row mb-4">
|
|
<div class="col">
|
|
<h1 class="h3 mb-0">🔁 Abonnementer</h1>
|
|
<p class="text-muted">Alle solgte, aktive abonnementer</p>
|
|
</div>
|
|
<div class="col-auto">
|
|
<select class="form-select" id="subscriptionStatusFilter" style="min-width: 180px;">
|
|
<option value="all" selected>Alle statuser</option>
|
|
<option value="active">Aktiv</option>
|
|
<option value="paused">Pauset</option>
|
|
<option value="cancelled">Opsagt</option>
|
|
<option value="draft">Kladde</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3 mb-4" id="statsCards">
|
|
<div class="col-md-4">
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body">
|
|
<p class="text-muted small mb-1">Aktive Abonnementer</p>
|
|
<h3 class="mb-0" id="activeCount">-</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body">
|
|
<p class="text-muted small mb-1">Total Pris (aktive)</p>
|
|
<h3 class="mb-0" id="totalAmount">-</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body">
|
|
<p class="text-muted small mb-1">Gns. Pris</p>
|
|
<h3 class="mb-0" id="avgAmount">-</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0" id="subscriptionsTitle">Abonnementer</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle mb-0">
|
|
<thead class="bg-light">
|
|
<tr>
|
|
<th>Abonnement</th>
|
|
<th>Kunde</th>
|
|
<th>Sag</th>
|
|
<th>Produkt</th>
|
|
<th>Interval</th>
|
|
<th>Pris</th>
|
|
<th>Start</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="subscriptionsBody">
|
|
<tr>
|
|
<td colspan="8" class="text-center text-muted py-5">
|
|
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
async function loadSubscriptions() {
|
|
try {
|
|
const status = document.getElementById('subscriptionStatusFilter')?.value || 'all';
|
|
const stats = await fetch(`/api/v1/subscriptions/stats/summary?status=${encodeURIComponent(status)}`).then(r => r.json());
|
|
document.getElementById('activeCount').textContent = stats.subscription_count || 0;
|
|
document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0);
|
|
document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0);
|
|
|
|
const subscriptions = await fetch(`/api/v1/subscriptions?status=${encodeURIComponent(status)}`).then(r => r.json());
|
|
renderSubscriptions(subscriptions);
|
|
|
|
const title = document.getElementById('subscriptionsTitle');
|
|
if (title) {
|
|
const labelMap = {
|
|
all: 'Alle abonnementer',
|
|
active: 'Aktive abonnementer',
|
|
paused: 'Pausede abonnementer',
|
|
cancelled: 'Opsagte abonnementer',
|
|
draft: 'Kladder'
|
|
};
|
|
title.textContent = labelMap[status] || 'Abonnementer';
|
|
}
|
|
} catch (e) {
|
|
console.error('Error loading subscriptions:', e);
|
|
document.getElementById('subscriptionsBody').innerHTML = `
|
|
<tr><td colspan="8" class="text-center text-danger py-5">
|
|
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
|
|
<p>Fejl ved indlaesning</p>
|
|
</td></tr>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function renderSubscriptions(subscriptions) {
|
|
const tbody = document.getElementById('subscriptionsBody');
|
|
|
|
if (!subscriptions || subscriptions.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr><td colspan="8" class="text-center text-muted py-5">
|
|
<i class="bi bi-inbox fs-1 mb-3"></i>
|
|
<p>Ingen aktive abonnementer</p>
|
|
</td></tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = subscriptions.map(sub => {
|
|
const intervalLabel = formatInterval(sub.billing_interval);
|
|
const statusBadge = getStatusBadge(sub.status);
|
|
const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-';
|
|
const subNumber = sub.subscription_number || `#${sub.id}`;
|
|
|
|
return `
|
|
<tr>
|
|
<td><strong>${subNumber}</strong></td>
|
|
<td>${sub.customer_name || '-'}</td>
|
|
<td>${sagLink}</td>
|
|
<td>${sub.product_name || '-'}</td>
|
|
<td>${intervalLabel}${sub.billing_day ? ' (dag ' + sub.billing_day + ')' : ''}</td>
|
|
<td>${formatCurrency(sub.price || 0)}</td>
|
|
<td>${formatDate(sub.start_date)}</td>
|
|
<td>${statusBadge}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function formatInterval(interval) {
|
|
const map = {
|
|
'monthly': 'Maaned',
|
|
'quarterly': 'Kvartal',
|
|
'yearly': 'Aar'
|
|
};
|
|
return map[interval] || interval || '-';
|
|
}
|
|
|
|
function getStatusBadge(status) {
|
|
const badges = {
|
|
'active': '<span class="badge bg-success">Aktiv</span>',
|
|
'paused': '<span class="badge bg-warning">Pauset</span>',
|
|
'cancelled': '<span class="badge bg-secondary">Opsagt</span>',
|
|
'draft': '<span class="badge bg-light text-dark">Kladde</span>'
|
|
};
|
|
return badges[status] || status || '-';
|
|
}
|
|
|
|
function formatCurrency(amount) {
|
|
return new Intl.NumberFormat('da-DK', {
|
|
style: 'currency',
|
|
currency: 'DKK',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0
|
|
}).format(amount);
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('da-DK');
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const filter = document.getElementById('subscriptionStatusFilter');
|
|
if (filter) {
|
|
filter.addEventListener('change', loadSubscriptions);
|
|
}
|
|
loadSubscriptions();
|
|
});
|
|
</script>
|
|
{% endblock %}
|