bmc_hub/app/subscriptions/frontend/list.html
2026-08-28 20:49:55 +02:00

1038 lines
66 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "shared/frontend/base.html" %}
{% block title %}Abonnementer - BMC Hub{% endblock %}
{% block content %}
<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>
<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">
<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>
<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>
</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>
</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">
<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">
<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">
<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>
<div class="col-md-4">
<div class="mb-3">
<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>
<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>
<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>
<option value="scheduled">Planlagt</option>
<option value="active">Aktiv</option>
<option value="paused">Pauset</option>
<option value="terminating">Under opsigelse</option>
<option value="cancelled">Opsagt</option>
<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>
<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>
<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;
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 {
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;
updateSubscriptionMetrics(subscriptions);
applySubscriptionFilters();
} catch (e) {
console.error('Error loading subscriptions:', e);
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>`;
}
}
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) {
const grid = document.getElementById('subscriptionsGrid');
if (!subscriptions || subscriptions.length === 0) {
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;
}
grid.innerHTML = subscriptions.map(sub => {
const intervalLabel = formatInterval(sub.billing_interval);
const statusBadge = getStatusBadge(sub.status);
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 `
<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('');
}
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';
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 || '';
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;
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,
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
};
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' },
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');
}
const result = await response.json();
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
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;
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' },
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();
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 = {
'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 || '-';
}
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', () => {
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 %}