2026-02-08 12:42:19 +01:00
{% 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 >
2026-02-09 15:30:07 +01:00
< / div >
2026-02-08 12:42:19 +01:00
< / 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" >
2026-02-08 12:42:19 +01:00
< 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 >
2026-02-08 12:42:19 +01:00
< / 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 >
2026-02-08 12:42:19 +01:00
< / 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 >
2026-02-08 12:42:19 +01:00
< / div >
< / div >
2026-08-28 20:49:55 +02:00
< / div >
2026-02-08 12:42:19 +01:00
< / div >
2026-02-17 08:29:05 +01:00
<!-- Edit Subscription Modal -->
< div class = "modal fade" id = "editModal" tabindex = "-1" >
< div class = "modal-dialog modal-xl" >
< div class = "modal-content" >
< div class = "modal-header" >
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 >
2026-02-17 08:29:05 +01:00
< 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 >
2026-02-17 08:29:05 +01:00
< / div >
< / div >
< / div >
< div class = "row" >
2026-08-28 20:49:55 +02:00
< div class = "col-md-4" >
2026-02-17 08:29:05 +01:00
< 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" >
2026-02-17 08:29:05 +01:00
< 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 (1– 28)< / label >
< input type = "number" class = "form-control" id = "editBillingDay" min = "1" max = "28" >
2026-02-17 08:29:05 +01:00
< / 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 >
2026-02-17 08:29:05 +01:00
< / 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 >
2026-02-17 08:29:05 +01:00
< / 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 >
2026-02-17 08:29:05 +01:00
< option value = "active" > Aktiv< / option >
< option value = "paused" > Pauset< / option >
2026-08-28 20:49:55 +02:00
< option value = "terminating" > Under opsigelse< / option >
2026-02-17 08:29:05 +01:00
< option value = "cancelled" > Opsagt< / option >
2026-08-28 20:49:55 +02:00
< option value = "expired" > Udløbet< / option >
< option value = "blocked" > Blokeret< / option >
2026-02-17 08:29:05 +01:00
< / 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 >
2026-02-17 08:29:05 +01:00
< 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 >
2026-02-17 08:29:05 +01:00
< / div >
< / div >
< / div >
< / div >
<!-- Cancel Subscription Modal -->
< div class = "modal fade" id = "cancelModal" tabindex = "-1" >
< div class = "modal-dialog" >
< div class = "modal-content" >
< div class = "modal-header bg-danger text-white" >
< h5 class = "modal-title" > Opsig Abonnement< / h5 >
< button type = "button" class = "btn-close btn-close-white" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
< input type = "hidden" id = "cancelSubId" >
< div class = "alert alert-warning" >
< i class = "bi bi-exclamation-triangle me-2" > < / i >
Dette opretter en opsigelsessag og beregner slutdato baseret på opsigelsesfrist.
< / div >
< p > < strong > Abonnement:< / strong > < span id = "cancelSubName" > < / span > < / p >
< p > < strong > Kunde:< / strong > < span id = "cancelCustomerName" > < / span > < / p >
< p > < strong > Opsigelsesfrist:< / strong > < span id = "cancelNoticeDays" > < / span > dage< / p >
< p > < strong > Beregnet slutdato:< / strong > < span id = "cancelEndDate" > < / span > < / p >
< div class = "mb-3 mt-4" >
< label class = "form-label" > Årsag til opsigelse< / label >
< textarea class = "form-control" id = "cancelReason" rows = "3" placeholder = "Angiv årsag..." > < / textarea >
< / div >
< / div >
< div class = "modal-footer" >
< button type = "button" class = "btn btn-secondary" data-bs-dismiss = "modal" > Annuller< / button >
< button type = "button" class = "btn btn-danger" onclick = "confirmCancel()" > Opsig abonnement< / button >
< / div >
< / div >
< / div >
< / div >
2026-02-08 12:42:19 +01:00
< script >
2026-02-17 08:29:05 +01:00
let currentSubscriptions = [];
let currentStagingCustomerKey = null;
2026-08-28 20:49:55 +02:00
let currentSubscriptionStatus = 'all';
let currentBillingCalendarData = null;
let billingCalendarLoaded = false;
2026-02-17 08:29:05 +01:00
function stagingStatusBadge(status) {
const badges = {
pending: '< span class = "badge bg-light text-dark" > Pending< / span > ',
mapped: '< span class = "badge bg-info text-dark" > Mapped< / span > ',
approved: '< span class = "badge bg-success" > Approved< / span > ',
error: '< span class = "badge bg-danger" > Fejl< / span > '
};
return badges[status] || `< span class = "badge bg-light text-dark" > ${status || '-'}< / span > `;
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('& ', '& ')
.replaceAll('< ', '< ')
.replaceAll('>', '> ')
.replaceAll('"', '" ')
.replaceAll("'", '' ');
}
async function importSimplyStaging() {
try {
const response = await fetch('/api/v1/simply-subscription-staging/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Import fejlede');
}
alert(`✅ Import færdig\nHentet: ${data.fetched}\nUpserted: ${data.upserted}\nAuto-mapped: ${data.auto_mapped}`);
await loadStagingCustomers();
await loadStagingOverview();
} catch (err) {
alert(`❌ Import fejl: ${err.message}`);
}
}
async function loadStagingOverview() {
const tbody = document.getElementById('stagingOverviewBody');
if (!tbody) return;
const status = document.getElementById('stagingOverviewStatusFilter')?.value || 'all';
tbody.innerHTML = '< tr > < td colspan = "9" class = "text-muted text-center py-3" > Indlæser...< / td > < / tr > ';
try {
const response = await fetch(`/api/v1/simply-subscription-staging/rows?status=${encodeURIComponent(status)}&limit=500`);
const rows = await response.json();
if (!response.ok) {
throw new Error(rows.detail || 'Kunne ikke hente oversigt');
}
if (!rows || rows.length === 0) {
tbody.innerHTML = '< tr > < td colspan = "9" class = "text-muted text-center py-3" > Ingen importerede rækker< / td > < / tr > ';
return;
}
tbody.innerHTML = rows.map(row => {
const amount = formatCurrency(row.source_total_amount || 0);
const updated = row.updated_at ? formatDate(row.updated_at) : '-';
const hubCustomer = row.hub_customer_name
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
: (row.hub_customer_id ? `#${row.hub_customer_id}` : '-');
feat: Update sag links to include versioning in URLs across multiple templates and services
- Updated links in index_old.html, varekob_salg.html, log.html, opportunities.html, detail.html, and various frontend files to point to the new versioned sag URLs.
- Modified reminder_notification_service.py to reflect the new sag URL structure in notifications.
- Added FedEx shipment management functionality, including API client, service layer, and router for handling FedEx bookings, tracking, and cancellations.
- Created database migration for FedEx shipments, including tables for shipments, packages, and tracking events.
2026-04-30 23:06:00 +02:00
const sag = row.hub_sag_id ? `< a href = "/sag/${row.hub_sag_id}/v3" > #${row.hub_sag_id}< / a > ` : '-';
2026-02-17 08:29:05 +01:00
return `
< tr >
< td > ${row.id}< / td >
< td > ${escapeHtml(row.source_salesorder_no || '-')}< / td >
< td > ${escapeHtml(row.source_customer_name || '-')}< / td >
< td > ${hubCustomer}< / td >
< td > ${sag}< / td >
< td > ${escapeHtml(row.source_subject || '-')}< / td >
< td > ${amount}< / td >
< td > ${stagingStatusBadge(row.approval_status)}< / td >
< td > ${updated}< / td >
< / tr >
`;
}).join('');
} catch (err) {
tbody.innerHTML = `< tr > < td colspan = "9" class = "text-danger text-center py-3" > ${escapeHtml(err.message)}< / td > < / tr > `;
}
}
async function loadStagingCustomers() {
const tbody = document.getElementById('stagingCustomersBody');
if (!tbody) return;
try {
const response = await fetch('/api/v1/simply-subscription-staging/customers?status=all');
const rows = await response.json();
if (!response.ok) {
throw new Error(rows.detail || 'Kunne ikke hente kø');
}
if (!rows || rows.length === 0) {
tbody.innerHTML = '< tr > < td colspan = "4" class = "text-muted text-center py-3" > Ingen rækker i parkeringsplads< / td > < / tr > ';
return;
}
tbody.innerHTML = rows.map(item => {
const encodedKey = encodeURIComponent(item.customer_key || '');
const safeName = escapeHtml(item.source_customer_name || 'Ukendt');
return `
< tr >
< td > ${safeName}< / td >
< td > ${item.row_count || 0}< / td >
< td > ${item.mapped_count || 0}< / td >
< td >
< button class = "btn btn-sm btn-outline-primary" onclick = "openStagingCustomerEncoded('${encodedKey}', '${safeName}')" > Åbn< / button >
< / td >
< / tr >
`;
}).join('');
} catch (err) {
tbody.innerHTML = `< tr > < td colspan = "4" class = "text-danger text-center py-3" > ${escapeHtml(err.message)}< / td > < / tr > `;
}
}
function openStagingCustomerEncoded(encodedKey, safeName) {
const key = decodeURIComponent(encodedKey || '');
openStagingCustomer(key, safeName);
}
async function openStagingCustomer(customerKey, customerName) {
currentStagingCustomerKey = customerKey;
document.getElementById('selectedStagingCustomerName').textContent = customerName || 'Ukendt';
document.getElementById('approveSelectedBtn').disabled = false;
const tbody = document.getElementById('stagingRowsBody');
tbody.innerHTML = '< tr > < td colspan = "7" class = "text-muted text-center py-3" > Indlæser...< / td > < / tr > ';
try {
const response = await fetch(`/api/v1/simply-subscription-staging/customers/${encodeURIComponent(customerKey)}/rows`);
const rows = await response.json();
if (!response.ok) {
throw new Error(rows.detail || 'Kunne ikke hente rækker');
}
if (!rows || rows.length === 0) {
tbody.innerHTML = '< tr > < td colspan = "7" class = "text-muted text-center py-3" > Ingen rækker fundet< / td > < / tr > ';
return;
}
tbody.innerHTML = rows.map(row => {
const approved = row.approval_status === 'approved';
const amount = formatCurrency(row.source_total_amount || 0);
const title = row.source_subject || row.source_salesorder_no || row.source_record_id || `#${row.id}`;
return `
< tr >
< td >
< input type = "checkbox" class = "form-check-input staging-row-check" data-row-id = "${row.id}" $ { approved ? ' disabled ' : ' checked ' } >
< / td >
< td >
< div class = "fw-semibold" > ${escapeHtml(title)}< / div >
< div class = "small text-muted" > ${escapeHtml(row.source_billing_frequency || '-')}< / div >
< / td >
< td > ${amount}< / td >
< td >
< input type = "number" class = "form-control form-control-sm" id = "mapCustomer-${row.id}" value = "${row.hub_customer_id || ''}" placeholder = "kunde id" >
< / td >
< td >
< input type = "number" class = "form-control form-control-sm" id = "mapSag-${row.id}" value = "${row.hub_sag_id || ''}" placeholder = "auto" >
< / td >
< td >
${stagingStatusBadge(row.approval_status)}
${row.approval_error ? `< div class = "small text-danger mt-1" > ${escapeHtml(row.approval_error)}< / div > ` : ''}
< / td >
< td >
< button class = "btn btn-sm btn-outline-secondary" onclick = "saveStagingMap(${row.id})" $ { approved ? ' disabled ' : ' ' } > Gem map< / button >
< / td >
< / tr >
`;
}).join('');
} catch (err) {
tbody.innerHTML = `< tr > < td colspan = "7" class = "text-danger text-center py-3" > ${escapeHtml(err.message)}< / td > < / tr > `;
}
}
async function saveStagingMap(rowId) {
const customerValue = document.getElementById(`mapCustomer-${rowId}`)?.value;
const sagValue = document.getElementById(`mapSag-${rowId}`)?.value;
if (!customerValue) {
alert('Angiv Hub kunde-ID før mapping');
return;
}
try {
const response = await fetch(`/api/v1/simply-subscription-staging/${rowId}/map`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hub_customer_id: parseInt(customerValue, 10),
hub_sag_id: sagValue ? parseInt(sagValue, 10) : null,
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Kunne ikke gemme mapping');
}
if (currentStagingCustomerKey) {
await openStagingCustomer(currentStagingCustomerKey, document.getElementById('selectedStagingCustomerName').textContent);
}
await loadStagingCustomers();
await loadStagingOverview();
} catch (err) {
alert(`❌ Mapping fejl: ${err.message}`);
}
}
async function approveSelectedStagingRows() {
if (!currentStagingCustomerKey) {
alert('Vælg en kunde først');
return;
}
const selectedRowIds = Array.from(document.querySelectorAll('.staging-row-check:checked'))
.map(el => parseInt(el.getAttribute('data-row-id'), 10))
.filter(Number.isInteger);
if (selectedRowIds.length === 0) {
alert('Vælg mindst én række');
return;
}
try {
const response = await fetch(`/api/v1/simply-subscription-staging/customers/${encodeURIComponent(currentStagingCustomerKey)}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_ids: selectedRowIds })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Godkendelse fejlede');
}
alert(`✅ Godkendelse færdig\nApproved: ${result.approved_count}\nErrors: ${result.error_count}`);
await openStagingCustomer(currentStagingCustomerKey, document.getElementById('selectedStagingCustomerName').textContent);
await loadStagingCustomers();
await loadStagingOverview();
await loadSubscriptions();
} catch (err) {
alert(`❌ Godkendelsesfejl: ${err.message}`);
}
}
2026-02-08 12:42:19 +01:00
async function loadSubscriptions() {
try {
2026-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');
2026-02-17 08:29:05 +01:00
currentSubscriptions = subscriptions;
2026-08-28 20:49:55 +02:00
updateSubscriptionMetrics(subscriptions);
applySubscriptionFilters();
2026-02-08 12:42:19 +01:00
} 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-02-08 12:42:19 +01:00
}
}
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}`;
}
2026-02-08 12:42:19 +01:00
function renderSubscriptions(subscriptions) {
2026-08-28 20:49:55 +02:00
const grid = document.getElementById('subscriptionsGrid');
2026-02-08 12:42:19 +01:00
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 > `;
2026-02-08 12:42:19 +01:00
return;
}
2026-08-28 20:49:55 +02:00
grid.innerHTML = subscriptions.map(sub => {
2026-02-08 12:42:19 +01:00
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);
2026-02-08 12:42:19 +01:00
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 >
2026-02-17 08:29:05 +01:00
< / 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';
}
2026-02-17 08:29:05 +01:00
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';
2026-02-17 08:29:05 +01:00
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();
2026-02-17 08:29:05 +01:00
// Load line items
renderLineItems(sub.line_items || []);
new bootstrap.Modal(document.getElementById('editModal')).show();
} catch (e) {
console.error('Error loading subscription:', e);
alert('Fejl ved indlæsning af abonnement: ' + e.message);
}
}
let lineItemsData = [];
function renderLineItems(items) {
lineItemsData = items.map((item, idx) => ({
id: item.id || null,
description: item.description || '',
quantity: item.quantity || 1,
unit_price: item.unit_price || 0,
product_id: item.product_id || null
}));
updateLineItemsTable();
}
function updateLineItemsTable() {
const tbody = document.getElementById('lineItemsBody');
if (lineItemsData.length === 0) {
tbody.innerHTML = '< tr > < td colspan = "5" class = "text-center text-muted" > Ingen varer - klik "Tilføj vare" for at tilføje< / td > < / tr > ';
document.getElementById('lineItemsTotal').textContent = '0,00 kr';
return;
}
tbody.innerHTML = lineItemsData.map((item, idx) => {
const total = (parseFloat(item.quantity) || 0) * (parseFloat(item.unit_price) || 0);
return `
< tr >
< td >
< input type = "text" class = "form-control form-control-sm"
value="${item.description}"
onchange="updateLineItem(${idx}, 'description', this.value)"
placeholder="Beskrivelse">
< / td >
< td >
< input type = "number" class = "form-control form-control-sm"
value="${item.quantity}"
step="0.01" min="0"
onchange="updateLineItem(${idx}, 'quantity', this.value)">
< / td >
< td >
< input type = "number" class = "form-control form-control-sm"
value="${item.unit_price}"
step="0.01" min="0"
onchange="updateLineItem(${idx}, 'unit_price', this.value)">
< / td >
< td class = "align-middle" > ${formatCurrency(total)}< / td >
< td class = "text-center" >
< button type = "button" class = "btn btn-sm btn-outline-danger"
onclick="removeLineItem(${idx})" title="Fjern">
< i class = "bi bi-trash" > < / i >
< / button >
< / td >
2026-02-08 12:42:19 +01:00
< / tr >
`;
}).join('');
2026-02-17 08:29:05 +01:00
calculateTotal();
}
function addLineItem() {
lineItemsData.push({
id: null,
description: '',
quantity: 1,
unit_price: 0,
product_id: null
});
updateLineItemsTable();
}
function removeLineItem(idx) {
if (confirm('Er du sikker på at du vil fjerne denne vare?')) {
lineItemsData.splice(idx, 1);
updateLineItemsTable();
}
}
function updateLineItem(idx, field, value) {
lineItemsData[idx][field] = value;
updateLineItemsTable();
}
function calculateTotal() {
const total = lineItemsData.reduce((sum, item) => {
return sum + ((parseFloat(item.quantity) || 0) * (parseFloat(item.unit_price) || 0));
}, 0);
document.getElementById('lineItemsTotal').textContent = formatCurrency(total);
// Also update the main price field
document.getElementById('editPrice').value = total.toFixed(2);
}
async function saveEdit() {
try {
const subId = document.getElementById('editSubId').value;
2026-08-28 20:49:55 +02:00
const currentSub = await fetch(`/api/v1/sag-subscriptions/${subId}`).then(r => r.json());
2026-02-17 08:29:05 +01:00
// 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,
2026-02-17 08:29:05 +01:00
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',
2026-02-17 08:29:05 +01:00
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}]
})
2026-02-17 08:29:05 +01:00
});
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();
2026-02-17 08:29:05 +01:00
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
2026-08-28 20:49:55 +02:00
window.location.href = result.case_url;
2026-02-17 08:29:05 +01:00
} 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', {
2026-02-17 08:29:05 +01:00
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}}]
})
2026-02-17 08:29:05 +01:00
});
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;
2026-02-17 08:29:05 +01:00
} catch (e) {
alert('❌ Fejl ved opsigelse: ' + e.message);
}
2026-02-08 12:42:19 +01:00
}
function formatInterval(interval) {
const map = {
2026-02-17 08:29:05 +01:00
'daily': 'Daglig',
'biweekly': '14-dage',
2026-08-17 19:27:43 +02:00
'monthly': 'Måned',
2026-02-08 12:42:19 +01:00
'quarterly': 'Kvartal',
2026-08-17 19:27:43 +02:00
'yearly': 'År'
2026-02-08 12:42:19 +01:00
};
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 > '
2026-02-08 12:42:19 +01:00
};
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';
}
2026-02-08 12:42:19 +01:00
function formatCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK');
}
2026-02-09 15:30:07 +01:00
document.addEventListener('DOMContentLoaded', () => {
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();
});
2026-02-09 15:30:07 +01:00
loadSubscriptions();
});
2026-02-08 12:42:19 +01:00
< / script >
{% endblock %}