2026-02-08 12:42:19 +01:00
|
|
|
{% 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>
|
2026-02-17 08:29:05 +01:00
|
|
|
<div class="col-auto d-flex gap-2 align-items-start">
|
|
|
|
|
<a href="/subscriptions/simply-imports" class="btn btn-outline-primary">
|
|
|
|
|
<i class="bi bi-cloud-arrow-down me-1"></i>Simply Import Oversigt
|
|
|
|
|
</a>
|
2026-02-09 15:30:07 +01:00
|
|
|
<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>
|
2026-02-08 12:42:19 +01:00
|
|
|
</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">
|
2026-02-09 15:30:07 +01:00
|
|
|
<h5 class="mb-0" id="subscriptionsTitle">Abonnementer</h5>
|
2026-02-08 12:42:19 +01:00
|
|
|
</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>
|
2026-02-17 08:29:05 +01:00
|
|
|
<th width="150">Handlinger</th>
|
2026-02-08 12:42:19 +01:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="subscriptionsBody">
|
|
|
|
|
<tr>
|
2026-02-17 08:29:05 +01:00
|
|
|
<td colspan="9" class="text-center text-muted py-5">
|
2026-02-08 12:42:19 +01:00
|
|
|
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
<!-- Edit Subscription Modal -->
|
|
|
|
|
<div class="modal fade" id="editModal" tabindex="-1">
|
|
|
|
|
<div class="modal-dialog modal-xl">
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
<h5 class="modal-title">Rediger Abonnement</h5>
|
|
|
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
<input type="hidden" id="editSubId">
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Produkt navn</label>
|
|
|
|
|
<input type="text" class="form-control" id="editProductName">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Pris (DKK)</label>
|
|
|
|
|
<input type="number" class="form-control" id="editPrice" step="0.01" min="0">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Billing interval</label>
|
|
|
|
|
<select class="form-select" id="editInterval">
|
|
|
|
|
<option value="daily">Daglig</option>
|
|
|
|
|
<option value="biweekly">Hver 14. dag</option>
|
|
|
|
|
<option value="monthly">Månedlig</option>
|
|
|
|
|
<option value="quarterly">Kvartalsvis</option>
|
|
|
|
|
<option value="yearly">Årlig</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Billing dag (1-31)</label>
|
|
|
|
|
<input type="number" class="form-control" id="editBillingDay" min="1" max="31">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Start dato</label>
|
|
|
|
|
<input type="date" class="form-control" id="editStartDate">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Slut dato (valgfri)</label>
|
|
|
|
|
<input type="date" class="form-control" id="editEndDate">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Periode start <i class="bi bi-info-circle" title="Startdato for nuværende faktureringsperiode"></i></label>
|
|
|
|
|
<input type="date" class="form-control" id="editPeriodStart">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Næste faktura dato <i class="bi bi-info-circle" title="Dato for næste automatiske faktura"></i></label>
|
|
|
|
|
<input type="date" class="form-control" id="editNextInvoiceDate">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Opsigelsesfrist (dage)</label>
|
|
|
|
|
<input type="number" class="form-control" id="editNoticePeriod" min="0" value="30">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Status</label>
|
|
|
|
|
<select class="form-select" id="editStatus">
|
|
|
|
|
<option value="draft">Kladde</option>
|
|
|
|
|
<option value="active">Aktiv</option>
|
|
|
|
|
<option value="paused">Pauset</option>
|
|
|
|
|
<option value="cancelled">Opsagt</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label class="form-label">Noter</label>
|
|
|
|
|
<textarea class="form-control" id="editNotes" rows="3"></textarea>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<hr class="my-4">
|
|
|
|
|
|
|
|
|
|
<!-- Line Items Section -->
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
|
|
|
<h6 class="mb-0">📦 Abonnementsvarer</h6>
|
|
|
|
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addLineItem()">
|
|
|
|
|
<i class="bi bi-plus-circle"></i> Tilføj vare
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="table-responsive">
|
|
|
|
|
<table class="table table-sm table-bordered" id="lineItemsTable">
|
|
|
|
|
<thead class="table-light">
|
|
|
|
|
<tr>
|
|
|
|
|
<th width="40%">Beskrivelse</th>
|
|
|
|
|
<th width="15%">Antal</th>
|
|
|
|
|
<th width="20%">Pris/stk</th>
|
|
|
|
|
<th width="20%">Total</th>
|
|
|
|
|
<th width="5%"></th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="lineItemsBody">
|
|
|
|
|
<!-- Line items will be inserted here -->
|
|
|
|
|
</tbody>
|
|
|
|
|
<tfoot>
|
|
|
|
|
<tr class="table-light">
|
|
|
|
|
<td colspan="3" class="text-end"><strong>Total:</strong></td>
|
|
|
|
|
<td><strong id="lineItemsTotal">0,00 kr</strong></td>
|
|
|
|
|
<td></td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tfoot>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|
|
|
|
<button type="button" class="btn btn-primary" onclick="saveEdit()">Gem ændringer</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Cancel Subscription Modal -->
|
|
|
|
|
<div class="modal fade" id="cancelModal" tabindex="-1">
|
|
|
|
|
<div class="modal-dialog">
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
<div class="modal-header bg-danger text-white">
|
|
|
|
|
<h5 class="modal-title">Opsig Abonnement</h5>
|
|
|
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
<input type="hidden" id="cancelSubId">
|
|
|
|
|
<div class="alert alert-warning">
|
|
|
|
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
|
|
|
Dette opretter en opsigelsessag og beregner slutdato baseret på opsigelsesfrist.
|
|
|
|
|
</div>
|
|
|
|
|
<p><strong>Abonnement:</strong> <span id="cancelSubName"></span></p>
|
|
|
|
|
<p><strong>Kunde:</strong> <span id="cancelCustomerName"></span></p>
|
|
|
|
|
<p><strong>Opsigelsesfrist:</strong> <span id="cancelNoticeDays"></span> dage</p>
|
|
|
|
|
<p><strong>Beregnet slutdato:</strong> <span id="cancelEndDate"></span></p>
|
|
|
|
|
<div class="mb-3 mt-4">
|
|
|
|
|
<label class="form-label">Årsag til opsigelse</label>
|
|
|
|
|
<textarea class="form-control" id="cancelReason" rows="3" placeholder="Angiv årsag..."></textarea>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|
|
|
|
<button type="button" class="btn btn-danger" onclick="confirmCancel()">Opsig abonnement</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-08 12:42:19 +01:00
|
|
|
<script>
|
2026-02-17 08:29:05 +01:00
|
|
|
let currentSubscriptions = [];
|
|
|
|
|
let currentStagingCustomerKey = null;
|
|
|
|
|
|
|
|
|
|
function stagingStatusBadge(status) {
|
|
|
|
|
const badges = {
|
|
|
|
|
pending: '<span class="badge bg-light text-dark">Pending</span>',
|
|
|
|
|
mapped: '<span class="badge bg-info text-dark">Mapped</span>',
|
|
|
|
|
approved: '<span class="badge bg-success">Approved</span>',
|
|
|
|
|
error: '<span class="badge bg-danger">Fejl</span>'
|
|
|
|
|
};
|
|
|
|
|
return badges[status] || `<span class="badge bg-light text-dark">${status || '-'}</span>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHtml(value) {
|
|
|
|
|
return String(value || '')
|
|
|
|
|
.replaceAll('&', '&')
|
|
|
|
|
.replaceAll('<', '<')
|
|
|
|
|
.replaceAll('>', '>')
|
|
|
|
|
.replaceAll('"', '"')
|
|
|
|
|
.replaceAll("'", ''');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function importSimplyStaging() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/v1/simply-subscription-staging/import', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' }
|
|
|
|
|
});
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(data.detail || 'Import fejlede');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
alert(`✅ Import færdig\nHentet: ${data.fetched}\nUpserted: ${data.upserted}\nAuto-mapped: ${data.auto_mapped}`);
|
|
|
|
|
await loadStagingCustomers();
|
|
|
|
|
await loadStagingOverview();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert(`❌ Import fejl: ${err.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadStagingOverview() {
|
|
|
|
|
const tbody = document.getElementById('stagingOverviewBody');
|
|
|
|
|
if (!tbody) return;
|
|
|
|
|
|
|
|
|
|
const status = document.getElementById('stagingOverviewStatusFilter')?.value || 'all';
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-3">Indlæser...</td></tr>';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/simply-subscription-staging/rows?status=${encodeURIComponent(status)}&limit=500`);
|
|
|
|
|
const rows = await response.json();
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(rows.detail || 'Kunne ikke hente oversigt');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!rows || rows.length === 0) {
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-3">Ingen importerede rækker</td></tr>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = rows.map(row => {
|
|
|
|
|
const amount = formatCurrency(row.source_total_amount || 0);
|
|
|
|
|
const updated = row.updated_at ? formatDate(row.updated_at) : '-';
|
|
|
|
|
const hubCustomer = row.hub_customer_name
|
|
|
|
|
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
|
|
|
|
|
: (row.hub_customer_id ? `#${row.hub_customer_id}` : '-');
|
|
|
|
|
const sag = row.hub_sag_id ? `<a href="/sag/${row.hub_sag_id}">#${row.hub_sag_id}</a>` : '-';
|
|
|
|
|
return `
|
|
|
|
|
<tr>
|
|
|
|
|
<td>${row.id}</td>
|
|
|
|
|
<td>${escapeHtml(row.source_salesorder_no || '-')}</td>
|
|
|
|
|
<td>${escapeHtml(row.source_customer_name || '-')}</td>
|
|
|
|
|
<td>${hubCustomer}</td>
|
|
|
|
|
<td>${sag}</td>
|
|
|
|
|
<td>${escapeHtml(row.source_subject || '-')}</td>
|
|
|
|
|
<td>${amount}</td>
|
|
|
|
|
<td>${stagingStatusBadge(row.approval_status)}</td>
|
|
|
|
|
<td>${updated}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
tbody.innerHTML = `<tr><td colspan="9" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadStagingCustomers() {
|
|
|
|
|
const tbody = document.getElementById('stagingCustomersBody');
|
|
|
|
|
if (!tbody) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/v1/simply-subscription-staging/customers?status=all');
|
|
|
|
|
const rows = await response.json();
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(rows.detail || 'Kunne ikke hente kø');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!rows || rows.length === 0) {
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="4" class="text-muted text-center py-3">Ingen rækker i parkeringsplads</td></tr>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = rows.map(item => {
|
|
|
|
|
const encodedKey = encodeURIComponent(item.customer_key || '');
|
|
|
|
|
const safeName = escapeHtml(item.source_customer_name || 'Ukendt');
|
|
|
|
|
return `
|
|
|
|
|
<tr>
|
|
|
|
|
<td>${safeName}</td>
|
|
|
|
|
<td>${item.row_count || 0}</td>
|
|
|
|
|
<td>${item.mapped_count || 0}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openStagingCustomerEncoded('${encodedKey}', '${safeName}')">Åbn</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
tbody.innerHTML = `<tr><td colspan="4" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openStagingCustomerEncoded(encodedKey, safeName) {
|
|
|
|
|
const key = decodeURIComponent(encodedKey || '');
|
|
|
|
|
openStagingCustomer(key, safeName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openStagingCustomer(customerKey, customerName) {
|
|
|
|
|
currentStagingCustomerKey = customerKey;
|
|
|
|
|
document.getElementById('selectedStagingCustomerName').textContent = customerName || 'Ukendt';
|
|
|
|
|
document.getElementById('approveSelectedBtn').disabled = false;
|
|
|
|
|
|
|
|
|
|
const tbody = document.getElementById('stagingRowsBody');
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-muted text-center py-3">Indlæser...</td></tr>';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/simply-subscription-staging/customers/${encodeURIComponent(customerKey)}/rows`);
|
|
|
|
|
const rows = await response.json();
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(rows.detail || 'Kunne ikke hente rækker');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!rows || rows.length === 0) {
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-muted text-center py-3">Ingen rækker fundet</td></tr>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = rows.map(row => {
|
|
|
|
|
const approved = row.approval_status === 'approved';
|
|
|
|
|
const amount = formatCurrency(row.source_total_amount || 0);
|
|
|
|
|
const title = row.source_subject || row.source_salesorder_no || row.source_record_id || `#${row.id}`;
|
|
|
|
|
return `
|
|
|
|
|
<tr>
|
|
|
|
|
<td>
|
|
|
|
|
<input type="checkbox" class="form-check-input staging-row-check" data-row-id="${row.id}" ${approved ? 'disabled' : 'checked'}>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<div class="fw-semibold">${escapeHtml(title)}</div>
|
|
|
|
|
<div class="small text-muted">${escapeHtml(row.source_billing_frequency || '-')}</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td>${amount}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<input type="number" class="form-control form-control-sm" id="mapCustomer-${row.id}" value="${row.hub_customer_id || ''}" placeholder="kunde id">
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<input type="number" class="form-control form-control-sm" id="mapSag-${row.id}" value="${row.hub_sag_id || ''}" placeholder="auto">
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
${stagingStatusBadge(row.approval_status)}
|
|
|
|
|
${row.approval_error ? `<div class="small text-danger mt-1">${escapeHtml(row.approval_error)}</div>` : ''}
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="saveStagingMap(${row.id})" ${approved ? 'disabled' : ''}>Gem map</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
tbody.innerHTML = `<tr><td colspan="7" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveStagingMap(rowId) {
|
|
|
|
|
const customerValue = document.getElementById(`mapCustomer-${rowId}`)?.value;
|
|
|
|
|
const sagValue = document.getElementById(`mapSag-${rowId}`)?.value;
|
|
|
|
|
|
|
|
|
|
if (!customerValue) {
|
|
|
|
|
alert('Angiv Hub kunde-ID før mapping');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/simply-subscription-staging/${rowId}/map`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
hub_customer_id: parseInt(customerValue, 10),
|
|
|
|
|
hub_sag_id: sagValue ? parseInt(sagValue, 10) : null,
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(result.detail || 'Kunne ikke gemme mapping');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (currentStagingCustomerKey) {
|
|
|
|
|
await openStagingCustomer(currentStagingCustomerKey, document.getElementById('selectedStagingCustomerName').textContent);
|
|
|
|
|
}
|
|
|
|
|
await loadStagingCustomers();
|
|
|
|
|
await loadStagingOverview();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert(`❌ Mapping fejl: ${err.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function approveSelectedStagingRows() {
|
|
|
|
|
if (!currentStagingCustomerKey) {
|
|
|
|
|
alert('Vælg en kunde først');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selectedRowIds = Array.from(document.querySelectorAll('.staging-row-check:checked'))
|
|
|
|
|
.map(el => parseInt(el.getAttribute('data-row-id'), 10))
|
|
|
|
|
.filter(Number.isInteger);
|
|
|
|
|
|
|
|
|
|
if (selectedRowIds.length === 0) {
|
|
|
|
|
alert('Vælg mindst én række');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/simply-subscription-staging/customers/${encodeURIComponent(currentStagingCustomerKey)}/approve`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ row_ids: selectedRowIds })
|
|
|
|
|
});
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(result.detail || 'Godkendelse fejlede');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
alert(`✅ Godkendelse færdig\nApproved: ${result.approved_count}\nErrors: ${result.error_count}`);
|
|
|
|
|
await openStagingCustomer(currentStagingCustomerKey, document.getElementById('selectedStagingCustomerName').textContent);
|
|
|
|
|
await loadStagingCustomers();
|
|
|
|
|
await loadStagingOverview();
|
|
|
|
|
await loadSubscriptions();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert(`❌ Godkendelsesfejl: ${err.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 12:42:19 +01:00
|
|
|
async function loadSubscriptions() {
|
|
|
|
|
try {
|
2026-02-09 15:30:07 +01:00
|
|
|
const status = document.getElementById('subscriptionStatusFilter')?.value || 'all';
|
2026-02-17 08:29:05 +01:00
|
|
|
const stats = await fetch(`/api/v1/sag-subscriptions/stats/summary?status=${encodeURIComponent(status)}`).then(r => r.json());
|
2026-02-08 12:42:19 +01:00
|
|
|
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);
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
const subscriptions = await fetch(`/api/v1/sag-subscriptions?status=${encodeURIComponent(status)}`).then(r => r.json());
|
|
|
|
|
currentSubscriptions = subscriptions;
|
2026-02-08 12:42:19 +01:00
|
|
|
renderSubscriptions(subscriptions);
|
2026-02-09 15:30:07 +01:00
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
}
|
2026-02-08 12:42:19 +01:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Error loading subscriptions:', e);
|
|
|
|
|
document.getElementById('subscriptionsBody').innerHTML = `
|
2026-02-17 08:29:05 +01:00
|
|
|
<tr><td colspan="9" class="text-center text-danger py-5">
|
2026-02-08 12:42:19 +01:00
|
|
|
<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 = `
|
2026-02-17 08:29:05 +01:00
|
|
|
<tr><td colspan="9" class="text-center text-muted py-5">
|
2026-02-08 12:42:19 +01:00
|
|
|
<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}`;
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
// Show product name with item count if available
|
|
|
|
|
let productDisplay = sub.product_name || '-';
|
|
|
|
|
if (sub.line_items && sub.line_items.length > 0) {
|
|
|
|
|
productDisplay = `${sub.product_name} <span class="badge bg-light text-dark">${sub.line_items.length} varer</span>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const canEdit = sub.status !== 'cancelled';
|
|
|
|
|
const canCancel = sub.status === 'active' || sub.status === 'paused';
|
|
|
|
|
|
|
|
|
|
const actions = `
|
|
|
|
|
<div class="btn-group btn-group-sm">
|
|
|
|
|
${canEdit ? `<button class="btn btn-outline-primary" onclick="openEditModal(${sub.id})" title="Rediger">
|
|
|
|
|
<i class="bi bi-pencil"></i>
|
|
|
|
|
</button>` : ''}
|
|
|
|
|
${canCancel ? `<button class="btn btn-outline-danger" onclick="openCancelModal(${sub.id})" title="Opsig">
|
|
|
|
|
<i class="bi bi-x-circle"></i>
|
|
|
|
|
</button>` : ''}
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
2026-02-08 12:42:19 +01:00
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<tr>
|
|
|
|
|
<td><strong>${subNumber}</strong></td>
|
|
|
|
|
<td>${sub.customer_name || '-'}</td>
|
|
|
|
|
<td>${sagLink}</td>
|
2026-02-17 08:29:05 +01:00
|
|
|
<td>${productDisplay}</td>
|
2026-02-08 12:42:19 +01:00
|
|
|
<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>
|
2026-02-17 08:29:05 +01:00
|
|
|
<td>${actions}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openEditModal(subId) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/sag-subscriptions/${subId}`);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
const sub = await response.json();
|
|
|
|
|
|
|
|
|
|
console.log('Loaded subscription:', sub);
|
|
|
|
|
|
|
|
|
|
document.getElementById('editSubId').value = sub.id || '';
|
|
|
|
|
document.getElementById('editProductName').value = sub.product_name || '';
|
|
|
|
|
document.getElementById('editPrice').value = sub.price || 0;
|
|
|
|
|
document.getElementById('editInterval').value = sub.billing_interval || 'monthly';
|
|
|
|
|
document.getElementById('editBillingDay').value = sub.billing_day || 1;
|
|
|
|
|
document.getElementById('editStartDate').value = sub.start_date || '';
|
|
|
|
|
document.getElementById('editEndDate').value = sub.end_date || '';
|
|
|
|
|
document.getElementById('editPeriodStart').value = sub.period_start || '';
|
|
|
|
|
document.getElementById('editNextInvoiceDate').value = sub.next_invoice_date || '';
|
|
|
|
|
document.getElementById('editNoticePeriod').value = sub.notice_period_days || 30;
|
|
|
|
|
document.getElementById('editStatus').value = sub.status || 'draft';
|
|
|
|
|
document.getElementById('editNotes').value = sub.notes || '';
|
|
|
|
|
|
|
|
|
|
// Load line items
|
|
|
|
|
renderLineItems(sub.line_items || []);
|
|
|
|
|
|
|
|
|
|
new bootstrap.Modal(document.getElementById('editModal')).show();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Error loading subscription:', e);
|
|
|
|
|
alert('Fejl ved indlæsning af abonnement: ' + e.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let lineItemsData = [];
|
|
|
|
|
|
|
|
|
|
function renderLineItems(items) {
|
|
|
|
|
lineItemsData = items.map((item, idx) => ({
|
|
|
|
|
id: item.id || null,
|
|
|
|
|
description: item.description || '',
|
|
|
|
|
quantity: item.quantity || 1,
|
|
|
|
|
unit_price: item.unit_price || 0,
|
|
|
|
|
product_id: item.product_id || null
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
updateLineItemsTable();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateLineItemsTable() {
|
|
|
|
|
const tbody = document.getElementById('lineItemsBody');
|
|
|
|
|
|
|
|
|
|
if (lineItemsData.length === 0) {
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen varer - klik "Tilføj vare" for at tilføje</td></tr>';
|
|
|
|
|
document.getElementById('lineItemsTotal').textContent = '0,00 kr';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = lineItemsData.map((item, idx) => {
|
|
|
|
|
const total = (parseFloat(item.quantity) || 0) * (parseFloat(item.unit_price) || 0);
|
|
|
|
|
return `
|
|
|
|
|
<tr>
|
|
|
|
|
<td>
|
|
|
|
|
<input type="text" class="form-control form-control-sm"
|
|
|
|
|
value="${item.description}"
|
|
|
|
|
onchange="updateLineItem(${idx}, 'description', this.value)"
|
|
|
|
|
placeholder="Beskrivelse">
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<input type="number" class="form-control form-control-sm"
|
|
|
|
|
value="${item.quantity}"
|
|
|
|
|
step="0.01" min="0"
|
|
|
|
|
onchange="updateLineItem(${idx}, 'quantity', this.value)">
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<input type="number" class="form-control form-control-sm"
|
|
|
|
|
value="${item.unit_price}"
|
|
|
|
|
step="0.01" min="0"
|
|
|
|
|
onchange="updateLineItem(${idx}, 'unit_price', this.value)">
|
|
|
|
|
</td>
|
|
|
|
|
<td class="align-middle">${formatCurrency(total)}</td>
|
|
|
|
|
<td class="text-center">
|
|
|
|
|
<button type="button" class="btn btn-sm btn-outline-danger"
|
|
|
|
|
onclick="removeLineItem(${idx})" title="Fjern">
|
|
|
|
|
<i class="bi bi-trash"></i>
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
2026-02-08 12:42:19 +01:00
|
|
|
</tr>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
calculateTotal();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addLineItem() {
|
|
|
|
|
lineItemsData.push({
|
|
|
|
|
id: null,
|
|
|
|
|
description: '',
|
|
|
|
|
quantity: 1,
|
|
|
|
|
unit_price: 0,
|
|
|
|
|
product_id: null
|
|
|
|
|
});
|
|
|
|
|
updateLineItemsTable();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeLineItem(idx) {
|
|
|
|
|
if (confirm('Er du sikker på at du vil fjerne denne vare?')) {
|
|
|
|
|
lineItemsData.splice(idx, 1);
|
|
|
|
|
updateLineItemsTable();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateLineItem(idx, field, value) {
|
|
|
|
|
lineItemsData[idx][field] = value;
|
|
|
|
|
updateLineItemsTable();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function calculateTotal() {
|
|
|
|
|
const total = lineItemsData.reduce((sum, item) => {
|
|
|
|
|
return sum + ((parseFloat(item.quantity) || 0) * (parseFloat(item.unit_price) || 0));
|
|
|
|
|
}, 0);
|
|
|
|
|
document.getElementById('lineItemsTotal').textContent = formatCurrency(total);
|
|
|
|
|
// Also update the main price field
|
|
|
|
|
document.getElementById('editPrice').value = total.toFixed(2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveEdit() {
|
|
|
|
|
try {
|
|
|
|
|
const subId = document.getElementById('editSubId').value;
|
|
|
|
|
|
|
|
|
|
// Validate line items
|
|
|
|
|
const validLineItems = lineItemsData.filter(item => {
|
|
|
|
|
return item.description && item.description.trim() !== '' &&
|
|
|
|
|
parseFloat(item.quantity) > 0;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (validLineItems.length === 0) {
|
|
|
|
|
alert('⚠️ Du skal tilføje mindst én vare');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
product_name: document.getElementById('editProductName').value,
|
|
|
|
|
price: parseFloat(document.getElementById('editPrice').value),
|
|
|
|
|
billing_interval: document.getElementById('editInterval').value,
|
|
|
|
|
billing_day: parseInt(document.getElementById('editBillingDay').value),
|
|
|
|
|
start_date: document.getElementById('editStartDate').value || null,
|
|
|
|
|
end_date: document.getElementById('editEndDate').value || null,
|
|
|
|
|
period_start: document.getElementById('editPeriodStart').value || null,
|
|
|
|
|
next_invoice_date: document.getElementById('editNextInvoiceDate').value || null,
|
|
|
|
|
notice_period_days: parseInt(document.getElementById('editNoticePeriod').value),
|
|
|
|
|
status: document.getElementById('editStatus').value,
|
|
|
|
|
notes: document.getElementById('editNotes').value,
|
|
|
|
|
line_items: validLineItems
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`/api/v1/sag-subscriptions/${subId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
throw new Error(error.detail || 'Failed to update');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
|
|
|
|
loadSubscriptions();
|
|
|
|
|
alert('✅ Abonnement opdateret');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
alert('❌ Fejl ved opdatering: ' + e.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openCancelModal(subId) {
|
|
|
|
|
try {
|
|
|
|
|
const sub = await fetch(`/api/v1/sag-subscriptions/${subId}`).then(r => r.json());
|
|
|
|
|
const noticeDays = sub.notice_period_days || 30;
|
|
|
|
|
const endDate = new Date();
|
|
|
|
|
endDate.setDate(endDate.getDate() + noticeDays);
|
|
|
|
|
|
|
|
|
|
document.getElementById('cancelSubId').value = sub.id;
|
|
|
|
|
document.getElementById('cancelSubName').textContent = sub.product_name || 'Ukendt';
|
|
|
|
|
document.getElementById('cancelCustomerName').textContent = sub.customer_name || 'Ukendt';
|
|
|
|
|
document.getElementById('cancelNoticeDays').textContent = noticeDays;
|
|
|
|
|
document.getElementById('cancelEndDate').textContent = endDate.toLocaleDateString('da-DK');
|
|
|
|
|
document.getElementById('cancelReason').value = '';
|
|
|
|
|
|
|
|
|
|
new bootstrap.Modal(document.getElementById('cancelModal')).show();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
alert('Fejl ved indlæsning af abonnement: ' + e.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function confirmCancel() {
|
|
|
|
|
try {
|
|
|
|
|
const subId = document.getElementById('cancelSubId').value;
|
|
|
|
|
const reason = document.getElementById('cancelReason').value;
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`/api/v1/subscriptions/${subId}/cancel`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ reason, user_id: 1 })
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) throw new Error('Failed to cancel');
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
bootstrap.Modal.getInstance(document.getElementById('cancelModal')).hide();
|
|
|
|
|
loadSubscriptions();
|
|
|
|
|
|
|
|
|
|
alert(`✅ Abonnement opsagt\nSlutdato: ${new Date(result.end_date).toLocaleDateString('da-DK')}\nSag oprettet: #${result.cancellation_case_id}`);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
alert('❌ Fejl ved opsigelse: ' + e.message);
|
|
|
|
|
}
|
2026-02-08 12:42:19 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatInterval(interval) {
|
|
|
|
|
const map = {
|
2026-02-17 08:29:05 +01:00
|
|
|
'daily': 'Daglig',
|
|
|
|
|
'biweekly': '14-dage',
|
2026-02-08 12:42:19 +01:00
|
|
|
'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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 15:30:07 +01:00
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
const filter = document.getElementById('subscriptionStatusFilter');
|
|
|
|
|
if (filter) {
|
|
|
|
|
filter.addEventListener('change', loadSubscriptions);
|
|
|
|
|
}
|
|
|
|
|
loadSubscriptions();
|
|
|
|
|
});
|
2026-02-08 12:42:19 +01:00
|
|
|
</script>
|
|
|
|
|
{% endblock %}
|