bmc_hub/app/subscriptions/frontend/list.html

1038 lines
66 KiB
HTML
Raw Permalink Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Abonnementer - BMC Hub{% endblock %}
{% block content %}
2026-08-28 20:49:55 +02:00
<style>
.sub-shell{--ink:#14263b;--muted:#64748b;--line:#dbe5ef;--blue:#0e5d91;--cyan:#19a7b8;--mint:#20a36a;background:radial-gradient(circle at 8% 0%,rgba(25,167,184,.12),transparent 28%),linear-gradient(180deg,#f6fbff 0,#f8fafc 420px);min-height:calc(100vh - 64px);margin:-1.5rem;padding:1.5rem}
.sub-hero{position:relative;overflow:hidden;color:#fff;border-radius:26px;padding:2rem 2.2rem;background:linear-gradient(125deg,#0a3557 0%,#0d5f91 52%,#1598a8 100%);box-shadow:0 24px 60px rgba(14,73,112,.22)}
.sub-hero:after{content:"";position:absolute;width:340px;height:340px;border:70px solid rgba(255,255,255,.08);border-radius:50%;right:-90px;top:-150px}
.sub-kicker{font-size:.72rem;letter-spacing:.15em;text-transform:uppercase;font-weight:800;color:#9de8ef}.sub-title{font-size:clamp(1.8rem,4vw,3rem);font-weight:800;letter-spacing:-.04em}.sub-hero-copy{max-width:680px;color:rgba(255,255,255,.76)}
.sub-hero .btn{border-radius:12px;padding:.68rem 1rem;font-weight:650}.sub-hero .btn-light{color:#0d5f91}.sub-hero .btn-outline-light{border-color:rgba(255,255,255,.4)}
.sub-metric{height:100%;border:1px solid rgba(219,229,239,.85);border-radius:20px;background:rgba(255,255,255,.9);box-shadow:0 12px 34px rgba(30,64,99,.08);padding:1.15rem 1.25rem;backdrop-filter:blur(12px)}
.sub-metric-icon{display:grid;place-items:center;width:42px;height:42px;border-radius:14px;background:#e8f6fb;color:var(--blue);font-size:1.15rem}.sub-metric-value{font-size:1.65rem;font-weight:800;letter-spacing:-.03em;color:var(--ink)}.sub-metric-label{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:700}
.sub-toolbar{border:1px solid var(--line);border-radius:18px;background:#fff;padding:.8rem;box-shadow:0 8px 26px rgba(30,64,99,.06)}.sub-search{border:0;background:#f1f6fa;border-radius:12px;padding:.7rem 1rem}.sub-search:focus{background:#fff;box-shadow:0 0 0 3px rgba(25,167,184,.13)}
.sub-filter-pills{display:flex;gap:.4rem;overflow:auto;padding-bottom:2px}.sub-filter-pill{white-space:nowrap;border:0;border-radius:999px;background:#edf3f7;color:#526476;padding:.55rem .85rem;font-size:.82rem;font-weight:700}.sub-filter-pill.active{background:var(--ink);color:#fff;box-shadow:0 6px 16px rgba(20,38,59,.18)}
.subscription-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(350px,1fr));gap:1rem}.sub-card{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:20px;background:#fff;box-shadow:0 8px 26px rgba(30,64,99,.07);transition:.2s ease}.sub-card:hover{transform:translateY(-3px);box-shadow:0 18px 42px rgba(30,64,99,.13);border-color:#b9d6e5}.sub-card-accent{height:5px;background:linear-gradient(90deg,var(--blue),var(--cyan))}.sub-card.is-blocked .sub-card-accent{background:#dc3545}.sub-card.is-ending .sub-card-accent{background:#e8a317}.sub-card-body{padding:1.2rem}.sub-avatar{display:grid;place-items:center;width:46px;height:46px;border-radius:15px;background:linear-gradient(135deg,#e6f5fb,#dcecff);color:var(--blue);font-weight:800}.sub-number{font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);font-weight:750}.sub-product{color:var(--ink);font-size:1.06rem;font-weight:800}.sub-price{font-size:1.7rem;line-height:1;font-weight:850;letter-spacing:-.04em;color:var(--ink)}
.sub-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:999px;padding:.38rem .62rem;background:#f1f5f9;color:#526476;font-size:.75rem;font-weight:700}.sub-chip.change{background:#fff4d6;color:#875c00}.sub-card-grid{display:grid;grid-template-columns:1fr 1fr;gap:.7rem;margin-top:1rem}.sub-data{border-radius:13px;background:#f7fafc;padding:.7rem}.sub-data-label{font-size:.68rem;text-transform:uppercase;letter-spacing:.07em;color:#8291a3;font-weight:750}.sub-data-value{color:#26384d;font-weight:700;margin-top:.12rem}.sub-card-actions{display:flex;gap:.5rem;border-top:1px solid #edf2f7;padding:.85rem 1.2rem}.sub-card-actions .btn{border-radius:10px;font-weight:650}.sub-empty{grid-column:1/-1;text-align:center;padding:5rem 1rem;border:1px dashed #bdd0dc;border-radius:22px;background:rgba(255,255,255,.72)}
.sub-section-title{font-weight:800;color:var(--ink);letter-spacing:-.02em}.modal-content{border:0;border-radius:22px;overflow:hidden;box-shadow:0 28px 80px rgba(15,37,61,.22)}#editModal .modal-header{background:linear-gradient(120deg,#0a3557,#0d7191);color:#fff;padding:1.25rem 1.5rem}#editModal .btn-close{filter:invert(1)}#editModal .modal-body{background:#f7fafc;padding:1.5rem}#editModal .form-control,#editModal .form-select{border-radius:11px;border-color:#d8e3eb}#editModal .modal-footer{background:#fff}
.sub-table-shell{border:1px solid var(--line);border-radius:18px;background:#fff;box-shadow:0 10px 30px rgba(30,64,99,.07);overflow:hidden}.sub-table{margin:0;min-width:1180px}.sub-table thead th{position:sticky;top:0;z-index:2;border:0;border-bottom:1px solid var(--line);background:#f3f7fa;color:#637589;padding:.8rem .9rem;font-size:.68rem;letter-spacing:.07em;text-transform:uppercase;white-space:nowrap}.sub-table tbody td{border-color:#edf2f6;padding:.8rem .9rem;vertical-align:middle;color:#26384d}.sub-table tbody tr{transition:background .15s ease}.sub-table tbody tr:hover{background:#f5fbfe}.sub-table tbody tr.is-blocked{box-shadow:inset 4px 0 #dc3545}.sub-table tbody tr.is-ending{box-shadow:inset 4px 0 #e8a317}.sub-table-primary{font-weight:800;color:var(--ink)}.sub-table-secondary{font-size:.75rem;color:var(--muted);margin-top:.15rem}.sub-product-lines{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.3rem}.sub-product-line{max-width:190px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-radius:6px;background:#edf4f8;color:#526779;padding:.18rem .38rem;font-size:.67rem}.sub-table-actions{display:flex;justify-content:flex-end;gap:.35rem;white-space:nowrap}.sub-table-actions .btn{border-radius:8px}.sub-table-empty{text-align:center!important;padding:4rem 1rem!important}.sub-sort-note{display:inline-flex;align-items:center;gap:.3rem;color:#8191a1;font-size:.7rem}
.sub-view-tabs{display:flex;gap:.4rem;padding:.35rem;border:1px solid var(--line);border-radius:15px;background:rgba(255,255,255,.85);width:max-content;max-width:100%;box-shadow:0 8px 24px rgba(30,64,99,.06)}.sub-view-tabs .nav-link{border:0;border-radius:11px;color:#526779;font-weight:750;padding:.65rem 1rem}.sub-view-tabs .nav-link.active{background:var(--ink);color:#fff;box-shadow:0 6px 15px rgba(20,38,59,.18)}
.billing-calendar-shell{border:1px solid var(--line);border-radius:18px;background:#fff;box-shadow:0 10px 30px rgba(30,64,99,.07);padding:.85rem}.billing-calendar-toolbar{position:sticky;top:0;z-index:4;border:1px solid #dce7ed;border-radius:14px;background:rgba(255,255,255,.96);padding:.65rem;box-shadow:0 6px 18px rgba(30,64,99,.06);backdrop-filter:blur(10px)}.billing-calendar-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.65rem}.billing-month{border:1px solid #dce6ed;border-radius:13px;overflow:hidden;background:#fbfdfe}.billing-month-header{display:flex;justify-content:space-between;align-items:center;padding:.55rem .7rem;background:#edf5f8;color:#23465d;font-size:.84rem;font-weight:800}.billing-event{display:block;border-bottom:1px solid #e8eff3;padding:.48rem .6rem;color:inherit;text-decoration:none}.billing-event:last-child{border-bottom:0}.billing-event:hover{background:#f0f8fb;color:inherit}.billing-event-date{display:grid;place-items:center;flex:0 0 auto;width:30px;height:30px;border-radius:8px;background:#fff;border:1px solid #d4e1e8;color:#175e81;font-size:.8rem;font-weight:850}.billing-event.overdue .billing-event-date{background:#fff0f0;border-color:#f3c5c5;color:#b4232c}.billing-event.blocked .billing-event-date{background:#fff6dd;border-color:#ead493;color:#805b00}.billing-event-meta{font-size:.66rem;color:#6e8190}.billing-calendar-empty{grid-column:1/-1;text-align:center;padding:3rem;color:#718493}.calendar-legend{display:flex;flex-wrap:wrap;gap:.7rem;font-size:.72rem;color:#667b8b}.calendar-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:.25rem;background:#1686aa}.calendar-dot.overdue{background:#dc3545}.calendar-dot.blocked{background:#d29b00}.generated-order-row{border:1px solid #dce7ed;border-radius:10px;padding:.55rem .7rem;background:#f8fbfc}
@media(max-width:767px){.sub-shell{margin:-1rem;padding:1rem}.sub-hero{padding:1.4rem}.subscription-grid{grid-template-columns:1fr}.sub-toolbar>.row{gap:.6rem}.sub-card-grid{grid-template-columns:1fr}}
</style>
<div class="sub-shell">
<div class="sub-hero mb-4">
<div class="position-relative" style="z-index:1">
<div class="sub-kicker mb-2">Recurring revenue cockpit</div>
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-lg-end">
<div><h1 class="sub-title mb-2">Abonnementer, helt under kontrol.</h1><p class="sub-hero-copy mb-0">Se økonomi, fakturerytme og ændringer i ét levende overblik — fra første bankdag til sidste godkendelse.</p></div>
<div class="d-flex flex-wrap gap-2">
<a href="/subscriptions/simply-imports" class="btn btn-outline-light"><i class="bi bi-cloud-arrow-down me-2"></i>Importcenter</a>
<button type="button" class="btn btn-outline-light" onclick="openSubscriptionView('calendar')"><i class="bi bi-calendar3 me-2"></i>Ordrekalender</button>
<button class="btn btn-light" onclick="document.getElementById('subscriptionSearch').focus()"><i class="bi bi-search me-2"></i>Find abonnement</button>
</div>
</div>
</div>
</div>
2026-08-28 20:49:55 +02:00
<ul class="nav sub-view-tabs mb-4" id="subscriptionViewTabs" role="tablist">
<li class="nav-item" role="presentation"><button class="nav-link active" id="subscriptions-overview-tab" data-bs-toggle="tab" data-bs-target="#subscriptionsOverviewPane" type="button" role="tab"><i class="bi bi-table me-2"></i>Abonnementer</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="subscriptions-calendar-tab" data-bs-toggle="tab" data-bs-target="#subscriptionsCalendarPane" type="button" role="tab"><i class="bi bi-calendar3 me-2"></i>Ordrekalender</button></li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="subscriptionsOverviewPane" role="tabpanel">
<div class="row g-3 mb-4" id="statsCards">
2026-08-28 20:49:55 +02:00
<div class="col-6 col-xl-3"><div class="sub-metric d-flex align-items-center gap-3"><div class="sub-metric-icon"><i class="bi bi-lightning-charge"></i></div><div><div class="sub-metric-label">Aktive aftaler</div><div class="sub-metric-value" id="activeCount"></div></div></div></div>
<div class="col-6 col-xl-3"><div class="sub-metric d-flex align-items-center gap-3"><div class="sub-metric-icon"><i class="bi bi-graph-up-arrow"></i></div><div><div class="sub-metric-label">Månedlig værdi</div><div class="sub-metric-value" id="totalAmount"></div></div></div></div>
<div class="col-6 col-xl-3"><div class="sub-metric d-flex align-items-center gap-3"><div class="sub-metric-icon"><i class="bi bi-calendar2-check"></i></div><div><div class="sub-metric-label">Faktura ≤ 7 dage</div><div class="sub-metric-value" id="dueSoonCount"></div></div></div></div>
<div class="col-6 col-xl-3"><div class="sub-metric d-flex align-items-center gap-3"><div class="sub-metric-icon"><i class="bi bi-hourglass-split"></i></div><div><div class="sub-metric-label">Åbne ændringer</div><div class="sub-metric-value" id="changeCount"></div></div></div></div>
</div>
2026-08-28 20:49:55 +02:00
<div class="sub-toolbar mb-3">
<div class="row align-items-center">
<div class="col-lg-4"><div class="input-group"><span class="input-group-text border-0 bg-transparent"><i class="bi bi-search text-muted"></i></span><input class="form-control sub-search" id="subscriptionSearch" placeholder="Søg kunde, produkt, sag eller nummer…" autocomplete="off"></div></div>
<div class="col-lg-8"><div class="sub-filter-pills justify-content-lg-end" id="subscriptionFilterPills">
<button class="sub-filter-pill active" data-status="all">Alle</button><button class="sub-filter-pill" data-status="active">Aktive</button><button class="sub-filter-pill" data-status="scheduled">Planlagte</button><button class="sub-filter-pill" data-status="paused">Pausede</button><button class="sub-filter-pill" data-status="terminating">Under opsigelse</button><button class="sub-filter-pill" data-status="blocked">Kræver handling</button><button class="sub-filter-pill" data-status="ended">Afsluttede</button>
</div></div>
</div>
2026-08-28 20:49:55 +02:00
</div>
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-end gap-2 mb-3"><div><div class="sub-kicker text-primary">Portfolio · skrivebeskyttet overblik</div><h2 class="h4 sub-section-title mb-0" id="subscriptionsTitle">Alle abonnementer</h2><div class="small text-muted mt-1"><i class="bi bi-shield-lock me-1"></i>Ændringer og opsigelser bestilles på abonnementfanen i hovedsagen.</div></div><div class="small text-muted" id="subscriptionResultCount"></div></div>
<div class="sub-table-shell table-responsive">
<table class="table sub-table align-middle">
<thead><tr><th>Abonnement</th><th>Kunde / hovedsag</th><th>Produkt</th><th>Status</th><th>Periode</th><th>Fakturering</th><th>Næste faktura</th><th class="text-end">Pris</th><th>Ændring</th><th class="text-end">Hovedsag</th></tr></thead>
<tbody id="subscriptionsGrid"><tr><td colspan="10" class="sub-table-empty"><span class="spinner-border text-primary mb-3"></span><div>Indlæser abonnementer…</div></td></tr></tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="subscriptionsCalendarPane" role="tabpanel">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-2 mb-3"><div><div class="sub-kicker text-primary">Fakturering og ordrekladder</div><h2 class="h4 sub-section-title mb-1">Ordrekalender</h2><div class="small text-muted">Forventede ordrekladder beregnet med præcis samme regler som fakturajobbet.</div></div><div class="calendar-legend"><span><i class="calendar-dot"></i>Planlagt</span><span><i class="calendar-dot overdue"></i>Forfalden</span><span><i class="calendar-dot blocked"></i>Blokeret</span></div></div>
<div class="billing-calendar-shell">
<div class="billing-calendar-toolbar mb-3"><div class="row g-2 align-items-center">
<div class="col-lg-4"><div class="input-group input-group-sm"><span class="input-group-text bg-white"><i class="bi bi-search"></i></span><input class="form-control" id="billingCalendarSearch" placeholder="Søg kunde, abonnement, produkt eller sag…"></div></div>
<div class="col-6 col-lg-2"><select class="form-select form-select-sm" id="billingCalendarState"><option value="all">Alle statusser</option><option value="planned">Planlagte</option><option value="overdue">Forfaldne</option><option value="blocked">Blokerede</option></select></div>
<div class="col-6 col-lg-2"><select class="form-select form-select-sm" id="billingCalendarCustomer"><option value="all">Alle kunder</option></select></div>
<div class="col-6 col-lg-1"><select class="form-select form-select-sm" id="billingCalendarMonths" title="Tidshorisont"><option value="6">6 mdr.</option><option value="12" selected>12 mdr.</option><option value="18">18 mdr.</option><option value="24">24 mdr.</option></select></div>
<div class="col-6 col-lg-2"><select class="form-select form-select-sm" id="billingCalendarLimit" title="Maksimalt antal viste ordrer"><option value="100">Vis 100</option><option value="250" selected>Vis 250</option><option value="500">Vis 500</option><option value="all">Vis alle</option></select></div>
<div class="col-12 col-lg-1 d-grid"><button class="btn btn-sm btn-outline-primary" onclick="loadBillingCalendar()" title="Genberegn"><i class="bi bi-arrow-clockwise"></i></button></div>
</div></div>
<div class="row g-2 mb-3" id="billingCalendarStats"></div><div class="billing-calendar-grid" id="billingCalendarGrid"><div class="billing-calendar-empty text-muted"><i class="bi bi-calendar3 fs-2 d-block mb-2"></i>Åbn kalenderen for at beregne forecast.</div></div><details class="mt-3"><summary class="fw-semibold text-primary">Senest genererede ordrekladder</summary><div class="d-grid gap-2 mt-2" id="generatedOrdersList"></div></details>
</div>
</div>
2026-08-28 20:49:55 +02:00
</div>
</div>
<!-- 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">
2026-08-28 20:49:55 +02:00
<div><div class="small text-white-50 text-uppercase fw-bold" style="letter-spacing:.1em">Kontrolleret ændring</div><h5 class="modal-title">Foreslå abonnementsændring</h5></div>
<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">
2026-08-28 20:49:55 +02:00
<label class="form-label">Ny total <span class="text-muted small">(beregnes fra linjer)</span></label>
<input type="number" class="form-control bg-light" id="editPrice" step="0.01" min="0" readonly>
</div>
</div>
</div>
<div class="row">
2026-08-28 20:49:55 +02:00
<div class="col-md-4">
<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>
2026-08-28 20:49:55 +02:00
<div class="col-md-4">
<div class="mb-3">
2026-08-28 20:49:55 +02:00
<label class="form-label">Faktureringsregel</label>
<select class="form-select" id="editScheduleType">
<option value="fixed_day">Fast dag</option><option value="first_business_day">Første bankdag</option><option value="last_business_day">Sidste bankdag</option><option value="interval_anchor">Fast interval</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Dag (128)</label>
<input type="number" class="form-control" id="editBillingDay" min="1" max="28">
</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>
2026-08-28 20:49:55 +02:00
<input type="date" class="form-control bg-light" id="editPeriodStart" readonly>
</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>
2026-08-28 20:49:55 +02:00
<input type="date" class="form-control bg-light" id="editNextInvoiceDate" readonly>
</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>
2026-08-28 20:49:55 +02:00
<option value="scheduled">Planlagt</option>
<option value="active">Aktiv</option>
<option value="paused">Pauset</option>
2026-08-28 20:49:55 +02:00
<option value="terminating">Under opsigelse</option>
<option value="cancelled">Opsagt</option>
2026-08-28 20:49:55 +02:00
<option value="expired">Udløbet</option>
<option value="blocked">Blokeret</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>
2026-08-28 20:49:55 +02:00
<div class="card border-0 shadow-sm mb-3" style="border-radius:16px"><div class="card-body">
<div class="d-flex gap-3 align-items-start"><div class="sub-metric-icon flex-shrink-0"><i class="bi bi-shield-check"></i></div><div class="flex-grow-1"><h6 class="mb-1">Godkendelsespakke</h6><p class="small text-muted mb-3">Ændringen oprettes som undersag og skal godkendes af en anden bruger.</p><div class="row g-3"><div class="col-md-8"><label class="form-label">Begrundelse *</label><textarea class="form-control" id="editChangeReason" rows="2" placeholder="Hvad ændres — og hvorfor?"></textarea></div><div class="col-md-4"><label class="form-label">Ikrafttrædelse *</label><input type="date" class="form-control" id="editEffectiveDate"></div></div></div></div>
</div></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>
2026-08-28 20:49:55 +02:00
<button type="button" class="btn btn-primary" onclick="saveEdit()"><i class="bi bi-send-check me-1"></i>Opret ændringsundersag</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>
<script>
let currentSubscriptions = [];
let currentStagingCustomerKey = null;
2026-08-28 20:49:55 +02:00
let currentSubscriptionStatus = 'all';
let currentBillingCalendarData = null;
let billingCalendarLoaded = false;
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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}/v3">#${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}`);
}
}
async function loadSubscriptions() {
try {
2026-08-28 20:49:55 +02:00
const response = await fetch('/api/v1/sag-subscriptions?status=all');
const subscriptions = await response.json();
if (!response.ok) throw new Error(subscriptions.detail || 'Kunne ikke hente abonnementer');
currentSubscriptions = subscriptions;
2026-08-28 20:49:55 +02:00
updateSubscriptionMetrics(subscriptions);
applySubscriptionFilters();
} catch (e) {
console.error('Error loading subscriptions:', e);
2026-08-28 20:49:55 +02:00
document.getElementById('subscriptionsGrid').innerHTML = `<tr><td colspan="10" class="sub-table-empty text-danger"><i class="bi bi-exclamation-triangle fs-1 d-block mb-3"></i><strong>Abonnementerne kunne ikke indlæses</strong><div class="small mt-1">${escapeHtml(e.message)}</div><button class="btn btn-outline-danger mt-3" onclick="loadSubscriptions()">Prøv igen</button></td></tr>`;
}
}
async function loadBillingCalendar() {
const grid = document.getElementById('billingCalendarGrid');
const months = document.getElementById('billingCalendarMonths')?.value || 12;
if (!grid) return;
grid.innerHTML = '<div class="billing-calendar-empty"><span class="spinner-border text-primary mb-2"></span><div>Beregner ordrekalender…</div></div>';
try {
const response = await fetch(`/api/v1/subscription-billing-forecast?months=${encodeURIComponent(months)}`);
const responseText = await response.text();
let data = {};
try {
data = responseText ? JSON.parse(responseText) : {};
} catch (_error) {
data = {detail: responseText || `HTTP ${response.status}`};
}
if (!response.ok) {
const detail = Array.isArray(data.detail)
? data.detail.map(item => item.msg || JSON.stringify(item)).join(' · ')
: (typeof data.detail === 'object' ? JSON.stringify(data.detail) : data.detail);
throw new Error(detail || 'Kunne ikke beregne ordrekalender');
}
currentBillingCalendarData = data;
billingCalendarLoaded = true;
populateBillingCalendarCustomers(data.forecast || []);
applyBillingCalendarFilters();
} catch (error) {
grid.innerHTML = `<div class="billing-calendar-empty text-danger"><i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>${escapeHtml(error.message)}<br><button class="btn btn-sm btn-outline-danger mt-2" onclick="loadBillingCalendar()">Prøv igen</button></div>`;
}
}
2026-08-28 20:49:55 +02:00
function populateBillingCalendarCustomers(forecast) {
const select = document.getElementById('billingCalendarCustomer');
if (!select) return;
const selected = select.value;
const customers = new Map();
forecast.forEach(item => customers.set(String(item.customer_id), item.customer_name || `Kunde #${item.customer_id}`));
select.innerHTML = '<option value="all">Alle kunder</option>' + Array.from(customers.entries())
.sort((a, b) => a[1].localeCompare(b[1], 'da'))
.map(([id, name]) => `<option value="${escapeHtml(id)}">${escapeHtml(name)}</option>`).join('');
if ([...select.options].some(option => option.value === selected)) select.value = selected;
}
function applyBillingCalendarFilters() {
if (!currentBillingCalendarData) return;
const query = (document.getElementById('billingCalendarSearch')?.value || '').trim().toLocaleLowerCase('da-DK');
const state = document.getElementById('billingCalendarState')?.value || 'all';
const customer = document.getElementById('billingCalendarCustomer')?.value || 'all';
const forecast = (currentBillingCalendarData.forecast || []).filter(item => {
const subscriptions = item.subscriptions || [];
const haystack = [item.customer_name, item.invoice_merge_key, ...subscriptions.flatMap(sub => [sub.subscription_number, sub.product_name, sub.sag_title, sub.sag_id])]
.filter(Boolean).join(' ').toLocaleLowerCase('da-DK');
return (state === 'all' || item.state === state)
&& (customer === 'all' || String(item.customer_id) === customer)
&& (!query || haystack.includes(query));
});
renderBillingCalendar({...currentBillingCalendarData, forecast});
}
function openSubscriptionView(view) {
const tabId = view === 'calendar' ? 'subscriptions-calendar-tab' : 'subscriptions-overview-tab';
const tab = document.getElementById(tabId);
if (tab && window.bootstrap?.Tab) bootstrap.Tab.getOrCreateInstance(tab).show();
tab?.scrollIntoView({behavior:'smooth', block:'start'});
}
function renderBillingCalendar(data) {
const forecast = data.forecast || [];
const limitValue = document.getElementById('billingCalendarLimit')?.value || '250';
const visibleForecast = limitValue === 'all' ? forecast : forecast.slice(0, Number(limitValue));
const months = new Map();
visibleForecast.forEach(item => {
const key = (item.display_date || item.invoice_date).slice(0, 7);
if (!months.has(key)) months.set(key, []);
months.get(key).push(item);
});
const grid = document.getElementById('billingCalendarGrid');
const stats = document.getElementById('billingCalendarStats');
const total = forecast.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const overdue = forecast.filter(item => item.state === 'overdue');
const blocked = forecast.filter(item => item.state === 'blocked');
if (stats) stats.innerHTML = `
<div class="col-6 col-lg-3"><div class="sub-data h-100 py-2"><div class="sub-data-label">Viste ordrer</div><div class="h5 fw-bold mb-0">${visibleForecast.length}${visibleForecast.length < forecast.length ? ` <span class="fs-6 text-muted">af ${forecast.length}</span>` : ''}</div></div></div>
<div class="col-6 col-lg-3"><div class="sub-data h-100 py-2"><div class="sub-data-label">Forventet værdi</div><div class="h5 fw-bold mb-0">${formatCurrency(total)}</div></div></div>
<div class="col-6 col-lg-3"><div class="sub-data h-100 py-2"><div class="sub-data-label">Forfaldne</div><div class="h5 fw-bold mb-0 text-danger">${overdue.length}</div></div></div>
<div class="col-6 col-lg-3"><div class="sub-data h-100 py-2"><div class="sub-data-label">Blokerede</div><div class="h5 fw-bold mb-0 text-warning">${blocked.length}</div></div></div>`;
if (grid) grid.innerHTML = months.size ? Array.from(months.entries()).map(([key, items]) => {
const monthDate = new Date(`${key}-01T12:00:00`);
const monthLabel = monthDate.toLocaleDateString('da-DK', {month:'long', year:'numeric'});
const monthTotal = items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
return `<div class="billing-month"><div class="billing-month-header"><span class="text-capitalize">${escapeHtml(monthLabel)}</span><span>${formatCurrency(monthTotal)}</span></div>${items.map(item => {
const day = Number((item.display_date || item.invoice_date).slice(8,10));
const firstSub = item.subscriptions?.[0];
const href = firstSub?.sag_id ? `/sag/${firstSub.sag_id}/v3?tab=subscription` : '#';
const coverage = firstSub ? `${formatDate(firstSub.coverage_start)}${formatDate(firstSub.coverage_end)}` : '';
return `<a class="billing-event ${item.state}" href="${href}"><div class="d-flex gap-2 align-items-start"><div class="billing-event-date">${day}</div><div class="min-w-0 flex-grow-1"><div class="d-flex justify-content-between gap-2"><strong class="text-truncate">${escapeHtml(item.customer_name)}</strong><strong>${formatCurrency(item.amount)}</strong></div><div class="billing-event-meta">${item.subscription_count} abonnement${item.subscription_count === 1 ? '' : 'er'} · ${escapeHtml(coverage)}</div>${item.state === 'overdue' ? '<div class="small text-danger fw-semibold">Skulle allerede være genereret</div>' : item.state === 'blocked' ? '<div class="small text-warning fw-semibold">Blokeret — kræver handling</div>' : ''}</div></div></a>`;
}).join('')}</div>`;
}).join('') : '<div class="billing-calendar-empty"><i class="bi bi-calendar2-check fs-2 d-block mb-2"></i>Ingen planlagte ordrekladder i perioden.</div>';
const generatedList = document.getElementById('generatedOrdersList');
if (generatedList) generatedList.innerHTML = (data.generated || []).length ? data.generated.map(order => {
const lines = Array.isArray(order.lines_json) ? order.lines_json : [];
const amount = lines.reduce((sum, line) => sum + Number(line.totalNetAmount || 0), 0);
return `<div class="generated-order-row d-flex flex-wrap justify-content-between gap-2"><div><strong>Ordrekladde #${order.ordre_draft_id || '—'}</strong><div class="small text-muted">Abonnement #${order.subscription_id} · periode ${formatDate(order.period_start)} · genereret ${formatDate(order.created_at)}</div></div><div class="text-end"><strong>${formatCurrency(amount)}</strong><div class="small text-muted">${escapeHtml(order.sync_status || 'oprettet')}</div></div></div>`;
}).join('') : '<div class="text-muted small py-2">Ingen genererede ordrekladder i den viste historik.</div>';
}
function monthlyValue(subscription) {
const value = Number(subscription.price || 0);
return {daily: value * 30.4375, biweekly: value * 2.171, monthly: value, quarterly: value / 3, yearly: value / 12}[subscription.billing_interval] || value;
}
function updateSubscriptionMetrics(subscriptions) {
const active = subscriptions.filter(sub => sub.status === 'active');
const now = new Date();
const inSevenDays = new Date(now); inSevenDays.setDate(inSevenDays.getDate() + 7);
const dueSoon = active.filter(sub => sub.next_invoice_date && new Date(sub.next_invoice_date) >= new Date(now.toDateString()) && new Date(sub.next_invoice_date) <= inSevenDays);
document.getElementById('activeCount').textContent = active.length.toLocaleString('da-DK');
document.getElementById('totalAmount').textContent = compactCurrency(active.reduce((sum, sub) => sum + monthlyValue(sub), 0));
document.getElementById('dueSoonCount').textContent = dueSoon.length.toLocaleString('da-DK');
document.getElementById('changeCount').textContent = subscriptions.filter(sub => sub.change_status).length.toLocaleString('da-DK');
}
function applySubscriptionFilters() {
const query = (document.getElementById('subscriptionSearch')?.value || '').trim().toLocaleLowerCase('da-DK');
const filtered = currentSubscriptions.filter(sub => {
const matchesStatus = currentSubscriptionStatus === 'all'
|| (currentSubscriptionStatus === 'ended' ? ['cancelled','expired'].includes(sub.status) : sub.status === currentSubscriptionStatus);
const productLines = (sub.product_lines || []).flatMap(line => [line.product_name, line.sku_internal, line.er_number, line.ean, line.supplier_sku, line.description]);
const haystack = [sub.subscription_number, sub.customer_name, sub.sag_title, sub.product_name, sub.product_search_text, sub.status, ...productLines].filter(Boolean).join(' ').toLocaleLowerCase('da-DK');
return matchesStatus && (!query || haystack.includes(query));
});
renderSubscriptions(filtered);
const labels = {all:'Alle abonnementer',active:'Aktive abonnementer',scheduled:'Planlagte abonnementer',paused:'Pausede abonnementer',terminating:'Under opsigelse',blocked:'Kræver handling',ended:'Afsluttede abonnementer'};
document.getElementById('subscriptionsTitle').textContent = labels[currentSubscriptionStatus] || 'Abonnementer';
document.getElementById('subscriptionResultCount').textContent = `${filtered.length} af ${currentSubscriptions.length}`;
}
function renderSubscriptions(subscriptions) {
2026-08-28 20:49:55 +02:00
const grid = document.getElementById('subscriptionsGrid');
if (!subscriptions || subscriptions.length === 0) {
2026-08-28 20:49:55 +02:00
grid.innerHTML = `<tr><td colspan="10" class="sub-table-empty"><i class="bi bi-search fs-1 text-primary d-block mb-3"></i><h3 class="h5">Ingen abonnementer matcher</h3><p class="text-muted mb-0">Prøv en anden status eller ryd søgningen.</p></td></tr>`;
return;
}
2026-08-28 20:49:55 +02:00
grid.innerHTML = subscriptions.map(sub => {
const intervalLabel = formatInterval(sub.billing_interval);
const statusBadge = getStatusBadge(sub.status);
2026-08-28 20:49:55 +02:00
const sagLink = sub.sag_id ? `/sag/${sub.sag_id}/v3?tab=subscription` : '#';
const subNumber = escapeHtml(sub.subscription_number || `#${sub.id}`);
const schedule = billingScheduleLabel(sub);
const nextInvoice = sub.next_invoice_date ? formatDate(sub.next_invoice_date) : 'Ikke planlagt';
const rowClass = sub.billing_blocked || sub.status === 'blocked' ? 'is-blocked' : ['terminating','cancelled','expired'].includes(sub.status) ? 'is-ending' : '';
const period = [sub.period_start || sub.start_date, sub.end_date].filter(Boolean).map(formatDate);
const productLines = (sub.product_lines || []).slice(0, 3);
const remainingLines = Math.max(0, Number(sub.item_count || 0) - productLines.length);
return `
2026-08-28 20:49:55 +02:00
<tr class="${rowClass}">
<td><a href="${sagLink}" class="sub-table-primary text-decoration-none">${subNumber}</a><div class="sub-table-secondary">ID ${sub.id}</div></td>
<td><div class="sub-table-primary">${escapeHtml(sub.customer_name || 'Ukendt kunde')}</div><a href="${sagLink}" class="sub-table-secondary text-decoration-none">${escapeHtml(sub.sag_title || 'Sag #' + sub.sag_id)}</a></td>
<td><div class="sub-table-primary">${escapeHtml(sub.product_name || 'Abonnement')}</div><div class="sub-product-lines">${productLines.map(line => `<span class="sub-product-line" title="${escapeHtml(line.product_name || line.description || '')}">${escapeHtml(line.product_name || line.description || 'Varelinje')}</span>`).join('')}${remainingLines ? `<span class="sub-product-line">+${remainingLines}</span>` : ''}</div></td>
<td>${statusBadge}${sub.billing_blocked ? `<div class="sub-table-secondary text-danger" title="${escapeHtml(sub.billing_block_reason || '')}"><i class="bi bi-exclamation-octagon me-1"></i>Blokeret</div>` : ''}</td>
<td><div>${escapeHtml(period[0] || '')}</div><div class="sub-table-secondary">${period[1] ? `til ${escapeHtml(period[1])}` : 'Ingen slutdato'}</div></td>
<td><div class="sub-table-primary">${escapeHtml(intervalLabel)}</div><div class="sub-table-secondary">${escapeHtml(schedule)}</div></td>
<td><div class="sub-table-primary">${escapeHtml(nextInvoice)}</div><div class="sub-table-secondary">${sub.billing_day ? `Dag ${sub.billing_day}` : ''}</div></td>
<td class="text-end"><div class="sub-table-primary">${formatCurrency(sub.price || 0)}</div><div class="sub-table-secondary">${formatCurrency(monthlyValue(sub))}/md.</div></td>
<td>${sub.change_status ? `<a href="/sag/${sub.change_sag_id}/v3" class="sub-chip change text-decoration-none"><i class="bi bi-hourglass-split"></i>${changeStatusLabel(sub.change_status)}</a>` : '<span class="text-muted"></span>'}</td>
<td><div class="sub-table-actions"><a class="btn btn-sm btn-primary" href="${sagLink}" title="Åbn abonnementet på hovedsagen"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag</a></div></td>
</tr>
`;
}).join('');
}
2026-08-28 20:49:55 +02:00
function billingScheduleLabel(sub) {
if (sub.billing_interval === 'daily' || sub.billing_interval === 'biweekly' || sub.billing_schedule_type === 'interval_anchor') return 'Fast interval';
if (sub.billing_schedule_type === 'first_business_day') return 'Første bankdag';
if (sub.billing_schedule_type === 'last_business_day') return 'Sidste bankdag';
return `Dag ${sub.billing_day || 1}`;
}
function changeStatusLabel(status) {
return {draft:'Ændringskladde',pending:'Afventer godkendelse',approved_scheduled:'Planlagt ændring',applying:'Anvendes nu',partially_applied:'Delvist gennemført',failed:'Kræver handling',cancellation_pending:'Annullering afventer'}[status] || 'Åben ændring';
}
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';
2026-08-28 20:49:55 +02:00
document.getElementById('editScheduleType').value = sub.billing_schedule_type || 'fixed_day';
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 || '';
2026-08-28 20:49:55 +02:00
document.getElementById('editChangeReason').value = '';
document.getElementById('editEffectiveDate').value = new Date().toISOString().slice(0, 10);
syncEditScheduleControls();
// 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>
</tr>
`;
}).join('');
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;
2026-08-28 20:49:55 +02:00
const currentSub = await fetch(`/api/v1/sag-subscriptions/${subId}`).then(r => r.json());
// 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,
billing_interval: document.getElementById('editInterval').value,
2026-08-28 20:49:55 +02:00
billing_schedule_type: document.getElementById('editScheduleType').value,
billing_day: parseInt(document.getElementById('editBillingDay').value),
start_date: document.getElementById('editStartDate').value || null,
end_date: document.getElementById('editEndDate').value || null,
notice_period_days: parseInt(document.getElementById('editNoticePeriod').value),
status: document.getElementById('editStatus').value,
notes: document.getElementById('editNotes').value,
line_items: validLineItems
};
2026-08-28 20:49:55 +02:00
const reason = document.getElementById('editChangeReason').value.trim();
const effectiveDate = document.getElementById('editEffectiveDate').value;
if (!reason || !effectiveDate) return alert('Angiv begrundelse og ikrafttrædelsesdato');
const response = await fetch('/api/v1/sag-subscriptions/change-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
2026-08-28 20:49:55 +02:00
body: JSON.stringify({
main_sag_id: currentSub.sag_id,
reason,
effective_date: effectiveDate,
proposals: [{subscription_id: Number(subId), changes: payload}]
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to update');
}
2026-08-28 20:49:55 +02:00
const result = await response.json();
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
2026-08-28 20:49:55 +02:00
window.location.href = result.case_url;
} 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;
2026-08-28 20:49:55 +02:00
const sub = await fetch(`/api/v1/sag-subscriptions/${subId}`).then(r => r.json());
const noticeDays = Number(sub.notice_period_days || 30);
const effective = new Date();
effective.setDate(effective.getDate() + noticeDays);
const effectiveDate = effective.toISOString().slice(0, 10);
const response = await fetch('/api/v1/sag-subscriptions/change-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
2026-08-28 20:49:55 +02:00
body: JSON.stringify({
main_sag_id: sub.sag_id,
reason: reason || 'Opsigelse',
effective_date: effectiveDate,
proposals: [{subscription_id: Number(subId), changes: {status: 'terminating', end_date: effectiveDate}}]
})
});
if (!response.ok) throw new Error('Failed to cancel');
const result = await response.json();
bootstrap.Modal.getInstance(document.getElementById('cancelModal')).hide();
2026-08-28 20:49:55 +02:00
window.location.href = result.case_url;
} catch (e) {
alert('❌ Fejl ved opsigelse: ' + e.message);
}
}
function formatInterval(interval) {
const map = {
'daily': 'Daglig',
'biweekly': '14-dage',
'monthly': 'Måned',
'quarterly': 'Kvartal',
'yearly': 'År'
};
return map[interval] || interval || '-';
}
function getStatusBadge(status) {
const badges = {
2026-08-28 20:49:55 +02:00
'active': '<span class="badge rounded-pill bg-success-subtle text-success border border-success-subtle">● Aktiv</span>',
'scheduled': '<span class="badge rounded-pill bg-info-subtle text-info-emphasis">Planlagt</span>',
'paused': '<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis">Pauset</span>',
'terminating': '<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis">Under opsigelse</span>',
'cancelled': '<span class="badge rounded-pill bg-secondary-subtle text-secondary">Opsagt</span>',
'expired': '<span class="badge rounded-pill bg-secondary-subtle text-secondary">Udløbet</span>',
'blocked': '<span class="badge rounded-pill bg-danger-subtle text-danger">Kræver handling</span>',
'draft': '<span class="badge rounded-pill bg-light text-dark border">Kladde</span>'
};
return badges[status] || status || '-';
}
2026-08-28 20:49:55 +02:00
function compactCurrency(amount) {
return new Intl.NumberFormat('da-DK', {style:'currency',currency:'DKK',notation:'compact',maximumFractionDigits:1}).format(Number(amount || 0));
}
function syncEditScheduleControls() {
const interval = document.getElementById('editInterval')?.value;
const schedule = document.getElementById('editScheduleType');
const day = document.getElementById('editBillingDay');
const anchored = ['daily','biweekly'].includes(interval);
if (anchored) schedule.value = 'interval_anchor';
schedule.disabled = anchored;
day.disabled = anchored || schedule.value !== 'fixed_day';
}
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', () => {
2026-08-28 20:49:55 +02:00
document.getElementById('subscriptionSearch')?.addEventListener('input', applySubscriptionFilters);
document.querySelectorAll('.sub-filter-pill').forEach(button => button.addEventListener('click', () => {
currentSubscriptionStatus = button.dataset.status;
document.querySelectorAll('.sub-filter-pill').forEach(item => item.classList.toggle('active', item === button));
applySubscriptionFilters();
}));
document.getElementById('editInterval')?.addEventListener('change', syncEditScheduleControls);
document.getElementById('editScheduleType')?.addEventListener('change', syncEditScheduleControls);
document.getElementById('billingCalendarMonths')?.addEventListener('change', loadBillingCalendar);
document.getElementById('billingCalendarSearch')?.addEventListener('input', applyBillingCalendarFilters);
document.getElementById('billingCalendarState')?.addEventListener('change', applyBillingCalendarFilters);
document.getElementById('billingCalendarCustomer')?.addEventListener('change', applyBillingCalendarFilters);
document.getElementById('billingCalendarLimit')?.addEventListener('change', applyBillingCalendarFilters);
document.getElementById('subscriptions-calendar-tab')?.addEventListener('shown.bs.tab', () => {
if (!billingCalendarLoaded) loadBillingCalendar();
});
loadSubscriptions();
});
</script>
{% endblock %}