bmc_hub/app/modules/migration_center/templates/index.html
Christian d81a8f41b4 feat: Add end-to-end tests for Sag module HTTP API
- Implemented a comprehensive end-to-end testing script for the Sag module's HTTP API, covering various functionalities including case creation, updates, and file uploads.
- Introduced a safe HTML sanitizer utility to ensure safe rendering of HTML content in the BMC Hub UI.
- Added database migrations for new features including WAN connection marking for wall outlets, permanent audit trails for supplier invoices, and dedicated permissions for the Sag module.
- Created a migration center for manual subscription and invoice migrations with relevant tables and indexes.
- Added tests for migration center functionalities, ensuring stability and correctness of the new features.
2026-07-28 14:18:24 +02:00

350 lines
35 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 %}Migreringscenter - BMC Hub{% endblock %}
{% block content %}
<style>
.mc-shell { --mc-navy:#14243a; --mc-blue:#2563eb; --mc-bg:#f4f7fb; color:#172033; }
.mc-hero { background:linear-gradient(120deg,#14243a,#203e65); color:white; border-radius:18px; padding:24px; }
.mc-stat { border:0; border-radius:14px; box-shadow:0 5px 20px rgba(20,36,58,.07); }
.mc-label { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#728099; font-weight:700; }
.mc-number { font-size:1.65rem; font-weight:750; }
.mc-table-wrap { border-radius:14px; overflow:hidden; box-shadow:0 5px 20px rgba(20,36,58,.07); background:white; }
.mc-table th { white-space:nowrap; font-size:.76rem; text-transform:uppercase; color:#6b778c; background:#f8fafc; }
.mc-table td { vertical-align:middle; }
.mc-product { max-width:300px; }
.mc-badge { border-radius:999px; padding:.34rem .6rem; font-size:.72rem; font-weight:700; display:inline-block; }
.mc-green { background:#dcfce7; color:#166534; } .mc-yellow { background:#fef3c7; color:#92400e; }
.mc-red { background:#fee2e2; color:#991b1b; } .mc-gray { background:#e5e7eb; color:#374151; }
.mc-blue { background:#dbeafe; color:#1e40af; }
.mc-queue { border-left:4px solid var(--mc-blue); }
.mc-source-card { background:#f8fafc; border:1px solid #e5eaf1; border-radius:12px; padding:16px; }
.mc-explanation { margin:0; padding-left:1.1rem; color:#56647a; font-size:.88rem; }
.mc-actions .btn { margin:2px; }
@media(max-width:768px){ .mc-hero{padding:18px}.mc-product{max-width:180px}.mc-actions{min-width:170px} }
</style>
<div class="container-fluid py-4 mc-shell">
<section class="mc-hero mb-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="text-uppercase small opacity-75 fw-semibold mb-1">Manuel kontrol og overførsel</div>
<h1 class="h3 mb-2">Migreringscenter</h1>
<p class="mb-0 opacity-75">Abonnementer fra CRM og fakturalinjer fra Faktura-fejl-finder. Intet oprettes automatisk.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<select id="sessionSelect" class="form-select" style="min-width:230px" aria-label="Kontrolsession"></select>
<button class="btn btn-light" onclick="createSession()"><i class="bi bi-plus-circle me-1"></i>Ny session</button>
<button class="btn btn-outline-light" onclick="importCrm('vtiger')">Hent Vtiger</button>
<button class="btn btn-outline-light" onclick="importCrm('simply')">Hent Simply</button>
<button class="btn btn-warning" onclick="loadEconomic13Months()">Indlæs 13 mdr. fakturaer</button>
<button class="btn btn-outline-light" onclick="rematchAll()">Match alle igen</button>
<a id="exportLink" class="btn btn-outline-light" href="#"><i class="bi bi-download me-1"></i>CSV</a>
</div>
</div>
<div id="snapshotWarning" class="alert alert-warning mt-3 mb-0 d-none"></div>
</section>
<div id="emptyState" class="card border-0 shadow-sm d-none">
<div class="card-body text-center py-5">
<i class="bi bi-inboxes fs-1 text-muted"></i>
<h2 class="h5 mt-3">Ingen kontrolsession endnu</h2>
<p class="text-muted">Opret en session for at fastlåse seneste e-conomic-grundlag fra Faktura-fejl-finder.</p>
<button class="btn btn-primary" onclick="createSession()">Opret kontrolsession</button>
</div>
</div>
<div id="workspace" class="d-none">
<div class="row g-3 mb-4" id="stats"></div>
<ul class="nav nav-pills mb-3 gap-1" id="mcTabs">
<li class="nav-item"><button class="nav-link active" data-view="all">Dashboard</button></li>
<li class="nav-item"><button class="nav-link" data-view="queue">Arbejdsbakke</button></li>
<li class="nav-item"><button class="nav-link" data-view="subscription">Abonnementer</button></li>
<li class="nav-item"><button class="nav-link" data-view="invoice_line">Fakturaer</button></li>
<li class="nav-item"><button class="nav-link" data-view="invoice_history">13 mdr. historik</button></li>
<li class="nav-item"><button class="nav-link" data-view="subscription_candidates">Mulige abonnementer</button></li>
<li class="nav-item"><button class="nav-link" data-view="deviations">Afvigelser</button></li>
<li class="nav-item"><button class="nav-link" data-view="locked">Låste</button></li>
<li class="nav-item"><button class="nav-link" data-view="audit">Log / historik</button></li>
</ul>
<section id="queuePanel" class="card mc-stat mc-queue mb-4 d-none">
<div class="card-body" id="queueContent"></div>
</section>
<section id="listPanel">
<div class="card mc-stat mb-3">
<div class="card-body">
<div class="row g-2">
<div class="col-12 col-lg-3"><input id="searchInput" class="form-control" placeholder="Søg produkt, faktura eller kilde-id"></div>
<div class="col-12 col-lg-3"><select id="companyFilter" class="form-select"><option value="">Alle virksomheder</option></select></div>
<div class="col-6 col-lg-2"><select id="sourceFilter" class="form-select"><option value="">Alle kilder</option><option value="vtiger">Vtiger</option><option value="simply">Simply CRM</option><option value="economic">e-conomic</option></select></div>
<div class="col-6 col-lg-1"><select id="statusFilter" class="form-select"><option value="">Status</option><option value="no_match">Intet match</option><option value="manual_review">Manuel kontrol</option><option value="source_changed">Kilde ændret</option><option value="match_found">Match fundet</option></select></div>
<div class="col-6 col-lg-1"><select id="lockFilter" class="form-select"><option value="">Lås</option><option value="unlocked">Ulåst</option><option value="locking_pending">Låser</option><option value="lock_failed">Låsefejl</option><option value="locked">Låst</option></select></div>
<div class="col-12 col-lg-2"><button class="btn btn-primary w-100" onclick="loadItems(1)">Filtrér</button></div>
</div>
</div>
</div>
<div class="mc-table-wrap">
<div class="table-responsive">
<table class="table table-hover mc-table mb-0">
<thead><tr><th>Dato</th><th>Kunde</th><th>Kilde</th><th>Produkt</th><th>Beløb</th><th>Match</th><th>Workflow</th><th>Hub-ID</th><th>Handling</th></tr></thead>
<tbody id="itemsBody"></tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center p-3 border-top">
<small class="text-muted" id="pageInfo"></small>
<div class="btn-group"><button class="btn btn-sm btn-outline-secondary" id="prevPage">Forrige</button><button class="btn btn-sm btn-outline-secondary" id="nextPage">Næste</button></div>
</div>
</div>
</section>
<section id="auditPanel" class="mc-table-wrap d-none">
<div class="table-responsive"><table class="table mc-table mb-0"><thead><tr><th>Tidspunkt</th><th>Bruger</th><th>Handling</th><th>Type</th><th>Reference</th><th>Resultat</th></tr></thead><tbody id="auditBody"></tbody></table></div>
</section>
<section id="historyPanel" class="mc-table-wrap d-none">
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between gap-3 align-items-end"><div><h2 class="h5 mb-1" id="historyTitle">Fakturahistorik</h2><p class="text-muted mb-0">Grupperet på kunde og vare for de seneste 13 måneder. Forslag opretter aldrig noget automatisk.</p></div><div style="min-width:300px"><label class="form-label mc-label">Filtrér på virksomhed</label><select id="historyCompanyFilter" class="form-select" onchange="loadInvoiceHistory(view==='subscription_candidates')"><option value="">Alle virksomheder</option></select></div></div>
<div class="table-responsive"><table class="table table-hover mc-table mb-0"><thead><tr><th>Kunde</th><th>Vare</th><th>Fakturaer</th><th>Måneder</th><th>Periode</th><th>Beløb</th><th>Frekvens</th><th>Hub-match</th><th>Handling</th></tr></thead><tbody id="historyBody"></tbody></table></div>
</section>
</div>
</div>
<div class="modal fade" id="actionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content">
<div class="modal-header"><h2 class="modal-title h5" id="modalTitle">Post</h2><button class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer" id="modalFooter"><button class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div>
</div></div>
</div>
<script>
const API='/api/v1/migration-center';
let sessions=[], sessionId=null, page=1, total=0, view='all', currentItem=null, pendingPreflight=null;
const modal=()=>bootstrap.Modal.getOrCreateInstance(document.getElementById('actionModal'));
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));
async function api(path, options={}){
const res=await fetch(API+path,{headers:{'Content-Type':'application/json',...(options.headers||{})},...options});
if(!res.ok){let d={};try{d=await res.json()}catch{};throw new Error(typeof d.detail==='object'?(d.detail.message||JSON.stringify(d.detail)):(d.detail||`HTTP ${res.status}`))}
return res.headers.get('content-type')?.includes('json')?res.json():res.text();
}
function badge(value){
const green=['created_in_hub','linked_to_existing','verified','approved','locked','match_found'];
const red=['conflict','source_changed','lock_failed','no_match','rejected'];
const gray=['locked'];
return `<span class="mc-badge ${gray.includes(value)?'mc-gray':green.includes(value)?'mc-green':red.includes(value)?'mc-red':'mc-yellow'}">${esc((value||'-').replaceAll('_',' '))}</span>`;
}
async function init(){
sessions=await api('/sessions');
const select=document.getElementById('sessionSelect');
select.innerHTML=sessions.map(s=>`<option value="${s.id}">${esc(s.name)} · ${s.treated_count}/${s.item_count}</option>`).join('');
const remembered=Number(localStorage.getItem('migrationCenterSession'));
sessionId=(sessions.find(s=>s.id===remembered)||sessions[0])?.id||null;
if(!sessionId){document.getElementById('emptyState').classList.remove('d-none');return}
select.value=sessionId; select.onchange=()=>{sessionId=Number(select.value);localStorage.setItem('migrationCenterSession',sessionId);refresh()};
document.getElementById('workspace').classList.remove('d-none');
await refresh();
}
async function createSession(){
const name=prompt('Navn på kontrolsession',`Migrering ${new Date().toLocaleDateString('da-DK',{month:'long',year:'numeric'})}`);
if(!name)return;
try{const s=await api('/sessions',{method:'POST',body:JSON.stringify({name})});localStorage.setItem('migrationCenterSession',s.id);location.reload()}catch(e){alert(e.message)}
}
async function importCrm(source){
if(!sessionId)return alert('Opret en session først');
if(!confirm(`Hent et nyt ${source} snapshot ind i denne kontrolsession? Ændrede poster kræver ny kontrol.`))return;
try{const d=await api(`/sessions/${sessionId}/crm-import?source=${source}`,{method:'POST'});alert(`${d.records} poster hentet · ${d.created} nye · ${d.changed} ændrede`);await refresh()}catch(e){alert(e.message)}
}
async function loadEconomic13Months(){
if(!sessionId)return alert('Opret en session først');
if(!confirm('Indlæs de seneste 13 måneders allerede importerede fakturaer fra Faktura-fejl-finder?'))return;
try{const d=await api(`/sessions/${sessionId}/economic-snapshot`,{method:'POST'});alert(`${d.created} fakturalinjer indlæst · ${d.customers_mapped} kundematches`);await refresh()}catch(e){alert(e.message)}
}
async function rematchAll(){
if(!sessionId)return;
try{const d=await api(`/sessions/${sessionId}/rematch`,{method:'POST'});alert(`${d.processed} poster behandlet · ${d.customers_mapped} virksomheder mappet · ${d.matches_found} abonnementmatches`);await refresh()}catch(e){alert(e.message)}
}
async function refresh(){document.getElementById('exportLink').href=`${API}/sessions/${sessionId}/report.csv`;await Promise.all([loadDashboard(),loadCompanyOptions(),loadItems(1)])}
async function loadCompanyOptions(){
const rows=await api(`/sessions/${sessionId}/company-options`);
for(const id of ['companyFilter','historyCompanyFilter']){
const select=document.getElementById(id), selected=select.value;
select.innerHTML='<option value="">Alle virksomheder</option>'+rows.map(r=>`<option value="${esc(r.customer_name)}">${esc(r.customer_name)} (${r.item_count})</option>`).join('');
if([...select.options].some(o=>o.value===selected))select.value=selected;
}
}
async function loadDashboard(){
const d=await api(`/sessions/${sessionId}/dashboard`), c=d.counts;
const defs=[['Poster',c.total,'bi-stack'],['Klar',c.ready,'bi-check2-square'],['Oprettet',c.created,'bi-cloud-check'],['Afvigelser',c.conflicts,'bi-exclamation-triangle'],['Ignoreret',c.ignored,'bi-slash-circle'],['Låst',c.locked,'bi-lock']];
document.getElementById('stats').innerHTML=defs.map(x=>`<div class="col-6 col-lg-2"><div class="card mc-stat h-100"><div class="card-body"><div class="mc-label"><i class="bi ${x[2]} me-1"></i>${x[0]}</div><div class="mc-number">${x[1]||0}</div></div></div></div>`).join('');
const age=(Date.now()-new Date(d.session.economic_snapshot_at))/86400000;
const warn=document.getElementById('snapshotWarning');
if(d.read_only){warn.classList.remove('d-none');warn.textContent='Migreringscenteret er i read-only tilstand. Historik og rapporter er fortsat tilgængelige.'}
else if(age>d.stale_after_days){warn.classList.remove('d-none');warn.innerHTML=`Fakturagrundlaget er ${Math.floor(age)} dage gammelt. <a href="/invoice-error-finder" class="alert-link">Åbn Faktura-fejl-finder</a> før du opretter en ny session.`}else warn.classList.add('d-none');
}
function query(){
const p=new URLSearchParams({page, page_size:50});
const q=document.getElementById('searchInput').value.trim(); if(q)p.set('q',q);
const source=document.getElementById('sourceFilter').value;if(source)p.set('source_system',source);
const company=document.getElementById('companyFilter').value;if(company)p.set('company_name',company);
const status=document.getElementById('statusFilter').value;if(status)p.set('match_status',status);
const lock=document.getElementById('lockFilter').value;if(lock)p.set('lock_status',lock);
if(['subscription','invoice_line'].includes(view))p.set('entity_type',view);
if(view==='deviations')p.set('only_deviations','true');
if(view==='locked')p.set('lock_status','locked');
return p;
}
async function loadItems(p=1){
page=p;
if(view==='queue'){await loadQueue();return}
if(view==='audit'){await loadAudit();return}
if(['invoice_history','subscription_candidates'].includes(view)){await loadInvoiceHistory(view==='subscription_candidates');return}
const d=await api(`/sessions/${sessionId}/items?${query()}`);total=d.total;
document.getElementById('itemsBody').innerHTML=d.items.length?d.items.map(rowHtml).join(''):`<tr><td colspan="9" class="text-center text-muted py-5">Ingen poster matcher filtrene</td></tr>`;
const end=Math.min(page*50,total);document.getElementById('pageInfo').textContent=total?`${(page-1)*50+1}-${end} af ${total}`:'0 poster';
document.getElementById('prevPage').disabled=page===1;document.getElementById('nextPage').disabled=end>=total;
}
async function loadInvoiceHistory(onlyCandidates){
const company=document.getElementById('historyCompanyFilter').value;
const d=await api(`/sessions/${sessionId}/invoice-history?only_subscription_candidates=${onlyCandidates?'true':'false'}${company?`&company_name=${encodeURIComponent(company)}`:''}`);
document.getElementById('historyTitle').textContent=onlyCandidates?'Mulige manglende abonnementer':'Fakturahistorik · seneste 13 måneder';
document.getElementById('historyBody').innerHTML=d.groups.length?d.groups.map(g=>`<tr>
<td><strong>${esc(g.customer_name||'Ukendt')}</strong><br><small class="text-muted">${esc(g.customer_no||'')}</small></td>
<td><strong>${esc(g.product_name)}</strong><br><small class="text-muted">${esc(g.product_code||'')}</small></td>
<td>${g.invoice_count}</td><td>${g.invoiced_months} / 13</td><td class="text-nowrap">${esc(g.first_invoice_date)} ${esc(g.last_invoice_date)}</td>
<td>${Number(g.min_amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}${Number(g.max_amount||0)!==Number(g.min_amount||0)?` ${Number(g.max_amount).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}`:''}</td>
<td>${badge(g.suggested_frequency)}</td><td>${g.has_hub_subscription?badge('match_found'):badge('no_match')}</td>
<td>${g.subscription_candidate?`<button class="btn btn-sm btn-primary" onclick="openItem(${g.representative_item_id})">Vurdér / opret abonnement</button>`:'<span class="text-muted">Kontrol</span>'}</td></tr>`).join(''):`<tr><td colspan="9" class="text-center text-muted py-5">Ingen fakturagrupper fundet</td></tr>`;
}
function rowHtml(i){
const explanations=(i.match_explanation||[]).map(esc).join(' · ');
return `<tr>
<td class="text-nowrap">${esc(i.invoice_date||i.period_from||'-')}</td>
<td><button class="btn btn-link p-0 text-start" onclick="${i.hub_customer_id?`showCustomer(${i.hub_customer_id})`:'void 0'}"><strong>${esc(i.customer_name||'Ukendt')}</strong></button><br><small class="text-muted">${esc(i.customer_no||'')}</small></td>
<td>${badge(i.source_system)}</td><td class="mc-product"><strong>${esc(i.product_name)}</strong><br><small class="text-muted">${esc(i.product_code||i.invoice_no||'')}</small></td>
<td class="text-nowrap">${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</td>
<td>${badge(i.match_status)}<br><small class="text-muted" title="${esc(explanations)}">${i.match_confidence!=null?Math.round(i.match_confidence*100)+' %':'-'}</small></td>
<td>${badge(i.hub_status)} ${badge(i.approval_status)} ${badge(i.lock_status)}</td>
<td>${i.hub_record_id?`<a href="${i.hub_sag_id?`/sag/${i.hub_sag_id}/v3`:`/subscriptions/${i.hub_record_id}`}">#${i.hub_record_id}</a>`:'-'}</td>
<td class="mc-actions"><button class="btn btn-sm btn-outline-primary" onclick="openItem(${i.id})">Vis / behandl</button></td></tr>`;
}
function filterCustomer(name){document.getElementById('searchInput').value=name;loadItems(1)}
async function showCustomer(customerId){
try{
const d=await api(`/sessions/${sessionId}/customers/${customerId}`);
document.getElementById('modalTitle').textContent=`Kundeoverblik · ${d.customer.name}`;
document.getElementById('modalBody').innerHTML=`<div class="row g-3 mb-3"><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">CVR</div>${esc(d.customer.cvr_number||'-')}</div></div><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">Migreringsposter</div>${d.items.length}</div></div><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">Hub-abonnementer</div>${d.hub_subscriptions.length}</div></div></div>
<h3 class="h6">Afvigelser og behandling</h3><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Kilde</th><th>Produkt</th><th>Match</th><th>Workflow</th></tr></thead><tbody>${d.items.map(i=>`<tr><td>${esc(i.source_system)}</td><td>${esc(i.product_name)}</td><td>${badge(i.match_status)}</td><td>${badge(i.approval_status)} ${badge(i.lock_status)}</td></tr>`).join('')}</tbody></table></div>
<h3 class="h6 mt-3">Eksisterende poster i Hubben</h3><ul>${d.hub_subscriptions.map(s=>`<li>#${s.id} · ${esc(s.product_name||s.subscription_number)} · ${esc(s.status)}</li>`).join('')||'<li>Ingen</li>'}</ul>`;
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-secondary" data-bs-dismiss="modal">Luk</button><button class="btn btn-dark" ${d.can_lock_all?'':'disabled'} onclick="lockCustomer(${customerId})">Lås alt for kunde</button>`;
modal().show();
}catch(e){alert(e.message)}
}
async function lockCustomer(customerId){if(confirm('Lås alle færdigbehandlede poster for kunden?'))await mutate(`/sessions/${sessionId}/customers/${customerId}/lock`)}
async function loadQueue(after){
const d=await api(`/sessions/${sessionId}/queue/next${after?`?after_id=${after}`:''}`);
const panel=document.getElementById('queueContent');
if(!d.item){panel.innerHTML='<div class="text-center py-4"><i class="bi bi-check-circle text-success fs-2"></i><h3 class="h5 mt-2">Arbejdsbakken er tom</h3></div>';return}
const i=d.item; currentItem=i;
panel.innerHTML=`<div class="d-flex justify-content-between align-items-start mb-3"><div><div class="mc-label">Næste manuelle beslutning</div><h2 class="h5 mb-0">${esc(i.customer_name||'Ukendt kunde')}</h2></div>${badge(i.match_status)}</div>
<div class="row g-3"><div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Kilde · ${esc(i.source_system)}</div><strong>${esc(i.product_name)}</strong><div>${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</div><small>${esc(i.source_record_id)}</small></div></div>
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Foreslået Hub-match</div><strong>${i.suggested_hub_record_id?'Abonnement #'+i.suggested_hub_record_id:'Intet sikkert match'}</strong><ul class="mc-explanation mt-2">${(i.match_explanation||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul></div></div></div>
<div class="mt-3 d-flex gap-2 flex-wrap"><button class="btn btn-primary" onclick="openItem(${i.id})">Behandl</button><button class="btn btn-outline-secondary" onclick="loadQueue(${i.id})">Spring over</button></div>`;
}
async function loadAudit(){
const rows=await api(`/sessions/${sessionId}/audit`);
document.getElementById('auditBody').innerHTML=rows.map(a=>`<tr><td>${new Date(a.performed_at).toLocaleString('da-DK')}</td><td>${esc(a.performed_by||'System')}</td><td>${esc(a.action)}</td><td>${esc(a.entity_type)}</td><td>${esc(a.entity_id||'')}</td><td>${a.success?badge('verified'):badge('conflict')}</td></tr>`).join('');
}
async function openItem(id){
let context=await api(`/items/${id}/context`).catch(()=>null), i=context?.item;
if(!i)return alert('Posten kunne ikke hentes');
currentItem=i;
document.getElementById('modalTitle').textContent=`${i.customer_name||'Ukendt kunde'} · ${i.product_name}`;
document.getElementById('modalBody').innerHTML=`<div class="row g-3">
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Kildedata</div><dl class="row small mb-0"><dt class="col-5">Kilde</dt><dd class="col-7">${esc(i.source_system)}</dd><dt class="col-5">Reference</dt><dd class="col-7 text-break">${esc(i.source_record_id)}</dd><dt class="col-5">Kunde</dt><dd class="col-7">${esc(i.customer_name||'-')}</dd><dt class="col-5">Produkt</dt><dd class="col-7">${esc(i.product_name)}</dd><dt class="col-5">Beløb</dt><dd class="col-7">${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</dd></dl></div></div>
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Matchforklaring</div><ul class="mc-explanation">${(i.match_explanation||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul><div class="mt-3">${badge(i.match_status)} ${badge(i.hub_status)} ${badge(i.lock_status)}</div></div></div></div>
<div class="col-12"><details><summary class="small fw-semibold">Vis alle rå felter fra den valgte kilde</summary><pre class="small bg-dark text-light rounded p-3 mt-2 overflow-auto" style="max-height:280px">${esc(JSON.stringify(i.source_payload||{},null,2))}</pre></details></div>
<div class="col-12"><hr><div class="d-flex justify-content-between align-items-center mb-2"><h3 class="h5 mb-0">Samlet kundegrundlag</h3><small class="text-muted">e-conomic ${context.counts.economic} · Vtiger ${context.counts.vtiger} · Simply ${context.counts.simply} · Hub ${context.counts.hub_subscriptions}</small></div>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#ctxEconomic">e-conomic (${context.counts.economic})</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxVtiger">Vtiger (${context.counts.vtiger})</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxSimply">Simply (${context.counts.simply})</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxHub">Hubben (${context.counts.hub_subscriptions})</button></li>
</ul>
<div class="tab-content border border-top-0 rounded-bottom p-3">
<div class="tab-pane fade show active" id="ctxEconomic">${sourceContextTable(context.sources.economic,'Fakturadato / nr.')}</div>
<div class="tab-pane fade" id="ctxVtiger">${sourceContextTable(context.sources.vtiger,'Start / slut')}</div>
<div class="tab-pane fade" id="ctxSimply">${sourceContextTable(context.sources.simply,'Start / slut')}</div>
<div class="tab-pane fade" id="ctxHub">${hubContext(context.hub)}</div>
</div>
</div>
<hr><div class="row g-2"><div class="col-md-4"><label class="form-label">Hub-kunde ID</label><div class="input-group"><input id="hubCustomerId" class="form-control" type="number" value="${i.hub_customer_id||''}"><button class="btn btn-outline-primary" onclick="linkCustomer()">Link</button></div><button class="btn btn-link px-0 btn-sm" onclick="createCustomer()">Opret kunde eksplicit</button></div>
<div class="col-md-4"><label class="form-label">Hub-sag ID</label><div class="input-group"><input id="hubCaseId" class="form-control" type="number" value="${i.hub_sag_id||''}"><button class="btn btn-outline-primary" onclick="linkCase()">Link</button></div><button class="btn btn-link px-0 btn-sm" onclick="createCase()">Opret sag eksplicit</button></div>
<div class="col-md-4"><label class="form-label">Eksisterende abonnement ID</label><div class="input-group"><input id="hubSubscriptionId" class="form-control" type="number" value="${i.suggested_hub_record_id||''}"><button class="btn btn-outline-primary" onclick="linkSubscription()">Link</button></div></div></div>
<div class="mt-3"><label class="form-label">Manuel note</label><textarea id="itemNote" class="form-control" rows="2">${esc(i.manual_note||'')}</textarea><button class="btn btn-sm btn-outline-secondary mt-2" onclick="saveNote()">Gem note</button></div>`;
const disabled=['locked','locking_pending'].includes(i.lock_status)?'disabled':'';
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button><button class="btn btn-outline-warning" ${disabled} onclick="ignoreItem()">Ignorér</button><button class="btn btn-outline-success" ${disabled} onclick="verifyItem()">Markér kontrolleret</button><button class="btn btn-outline-dark" ${disabled} onclick="lockItem()">Lås</button><button class="btn btn-primary" ${disabled} onclick="preflightCreate()">Opret i Hubben</button>`;
modal().show();
}
function sourceContextTable(rows,dateLabel){
if(!rows?.length)return '<div class="text-muted py-3">Ingen relevante poster fra denne kilde for kunden.</div>';
return `<div class="table-responsive" style="max-height:420px"><table class="table table-sm align-middle"><thead class="sticky-top bg-white"><tr><th>${dateLabel}</th><th>Vare</th><th>Beløb</th><th>Status</th><th></th></tr></thead><tbody>${rows.map(r=>`<tr class="${r.id===currentItem.id?'table-primary':''}"><td>${esc(r.invoice_date||r.period_from||'-')}<br><small>${esc(r.invoice_no||r.source_record_id)}</small></td><td><strong>${esc(r.product_name)}</strong><br><small>${esc(r.product_code||'')}</small><details><summary class="small text-muted">Alle kildefelter</summary><pre class="small bg-light p-2 mt-1" style="max-width:520px;max-height:220px;overflow:auto">${esc(JSON.stringify(r.source_payload||{},null,2))}</pre></details></td><td class="text-nowrap">${Number(r.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</td><td>${badge(r.hub_status)}</td><td><button class="btn btn-sm btn-outline-primary" onclick="openItem(${r.id})">Brug som grundlag</button></td></tr>`).join('')}</tbody></table></div>`;
}
function hubContext(hub){
const customer=hub.customer;
return `${customer?`<div class="mc-source-card mb-3"><div class="mc-label">Hub-kunde #${customer.id}</div><strong>${esc(customer.name)}</strong><div class="small">CVR ${esc(customer.cvr_number||'-')} · e-conomic ${esc(customer.economic_customer_number||'-')} · Vtiger ${esc(customer.vtiger_id||'-')}</div></div>`:'<div class="alert alert-warning">Kilden er endnu ikke linket til en Hub-kunde.</div>'}
<h4 class="h6">Eksisterende abonnementer</h4>${hub.subscriptions?.length?`<div class="table-responsive"><table class="table table-sm"><thead><tr><th>ID</th><th>Produkt</th><th>Pris/frekvens</th><th>Status</th><th></th></tr></thead><tbody>${hub.subscriptions.map(s=>`<tr><td>#${s.id}</td><td><strong>${esc(s.product_name||s.subscription_number)}</strong>${(s.lines||[]).map(l=>`<br><small>${esc(l.description)} · ${l.quantity} × ${Number(l.unit_price||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</small>`).join('')}</td><td>${Number(s.price||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})} / ${esc(s.billing_interval)}</td><td>${badge(s.status)}</td><td><button class="btn btn-sm btn-outline-primary" onclick="document.getElementById('hubSubscriptionId').value=${s.id}">Vælg til link</button></td></tr>`).join('')}</tbody></table></div>`:'<p class="text-muted">Ingen abonnementer i Hubben.</p>'}
<h4 class="h6 mt-3">Sager</h4><div class="d-flex flex-wrap gap-2">${(hub.cases||[]).map(s=>`<button class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('hubCaseId').value=${s.id}">#${s.id} ${esc(s.titel)}</button>`).join('')||'<span class="text-muted">Ingen sager</span>'}</div>`;
}
async function mutate(path,body){try{await api(path,{method:'POST',body:body?JSON.stringify(body):undefined});modal().hide();await refresh()}catch(e){alert(e.message)}}
async function linkCustomer(){const id=Number(document.getElementById('hubCustomerId').value);if(id)await mutate(`/items/${currentItem.id}/customer-link`,{hub_id:id})}
async function linkCase(){const id=Number(document.getElementById('hubCaseId').value);if(id)await mutate(`/items/${currentItem.id}/case-link`,{hub_id:id})}
async function linkSubscription(){const id=Number(document.getElementById('hubSubscriptionId').value);if(id)await mutate(`/items/${currentItem.id}/hub-link`,{hub_id:id})}
async function createCustomer(){const name=prompt('Kundenavn',currentItem.customer_name||'');if(!name)return;const cvr=prompt('CVR (valgfri)','');if(!confirm(`Opret kunden "${name}" i Hubben?`))return;await mutate(`/items/${currentItem.id}/customers`,{name,cvr_number:cvr||null,customer_no:currentItem.customer_no||null})}
async function createCase(){const title=prompt('Sagens titel',`Migrering · ${currentItem.product_name}`);if(!title||!confirm(`Opret sagen "${title}"?`))return;await mutate(`/items/${currentItem.id}/cases`,{title,description:`Manuelt oprettet fra migreringscenter, kilde ${currentItem.source_record_id}`})}
async function saveNote(){try{await api(`/items/${currentItem.id}/note`,{method:'PUT',body:JSON.stringify({note:document.getElementById('itemNote').value})});alert('Noten er gemt')}catch(e){alert(e.message)}}
async function ignoreItem(){const reason=prompt('Årsag til ignorering');if(reason)await mutate(`/items/${currentItem.id}/ignore`,{reason})}
async function verifyItem(){if(confirm('Markér posten som kontrolleret?'))await mutate(`/items/${currentItem.id}/verify`)}
async function lockItem(){if(confirm('Lås posten endeligt? Den kan ikke låses op i denne version.'))await mutate(`/items/${currentItem.id}/lock`)}
async function preflightCreate(){
try{
const p=await api(`/items/${currentItem.id}/preflight`);
if(!p.ready)return alert(`Oprettelsen kan ikke gennemføres:\n\n${p.blockers.join('\n')}`);
pendingPreflight=p;
const v=p.preview, frequencyLabels={daily:'Dagligt',biweekly:'Hver 14. dag',monthly:'Månedligt',quarterly:'Kvartalsvist',yearly:'Årligt'};
document.getElementById('modalTitle').textContent='Bekræft oprettelse i Hubben';
document.getElementById('modalBody').innerHTML=`<div class="alert alert-primary"><i class="bi bi-shield-check me-2"></i>Der oprettes kun dette ene kladde-abonnement. Intet andet overføres.</div>
<div class="mc-source-card"><div class="mc-label mb-3">Data der sendes til Hubben</div><dl class="row mb-0">
<dt class="col-sm-4">Kunde</dt><dd class="col-sm-8"><strong>${esc(v.customer||'-')}</strong>${v.customer_no?`<br><small class="text-muted">Kundenr. ${esc(v.customer_no)}</small>`:''}</dd>
<dt class="col-sm-4">Hub-kunde</dt><dd class="col-sm-8">#${esc(v.hub_customer_id)}</dd>
<dt class="col-sm-4">Hub-sag</dt><dd class="col-sm-8">#${esc(v.hub_sag_id)}</dd>
<dt class="col-sm-4">Produkt</dt><dd class="col-sm-8"><strong>${esc(v.product)}</strong>${v.product_code?`<br><small class="text-muted">${esc(v.product_code)}</small>`:''}</dd>
<dt class="col-sm-4">Beløb</dt><dd class="col-sm-8"><strong>${Number(v.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</strong></dd>
<dt class="col-sm-4">Antal</dt><dd class="col-sm-8">${esc(v.quantity||1)}</dd>
<dt class="col-sm-4">Fakturering</dt><dd class="col-sm-8">${esc(frequencyLabels[v.frequency]||v.frequency)}</dd>
<dt class="col-sm-4">Periode</dt><dd class="col-sm-8">${esc(v.start_date||'-')} ${esc(v.end_date||'løbende')}</dd>
<dt class="col-sm-4">Kilde</dt><dd class="col-sm-8">${esc(v.source)} · ${esc(v.source_record_id)}</dd>
</dl></div>
<p class="small text-muted mt-3 mb-0">Abonnementet oprettes som kladde med én varelinje og skal fortsat kontrolleres og låses separat.</p>`;
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-outline-secondary" onclick="openItem(${currentItem.id})">Tilbage</button><button class="btn btn-primary" onclick="confirmCreateInHub(this)"><i class="bi bi-check-circle me-1"></i>Bekræft oprettelse</button>`;
}catch(e){alert(e.message)}
}
async function confirmCreateInHub(button){
if(!pendingPreflight)return;
if(button){button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Opretter…'}
try{
await api(`/items/${currentItem.id}/create-in-hub`,{method:'POST',body:JSON.stringify({preflight_token:pendingPreflight.token,idempotency_key:crypto.randomUUID()})});
pendingPreflight=null;modal().hide();await refresh();
}catch(e){if(button)button.disabled=false;alert(e.message)}
}
document.querySelectorAll('#mcTabs .nav-link').forEach(btn=>btn.onclick=async()=>{
document.querySelectorAll('#mcTabs .nav-link').forEach(x=>x.classList.remove('active'));btn.classList.add('active');view=btn.dataset.view;
document.getElementById('queuePanel').classList.toggle('d-none',view!=='queue');
document.getElementById('auditPanel').classList.toggle('d-none',view!=='audit');
document.getElementById('historyPanel').classList.toggle('d-none',!['invoice_history','subscription_candidates'].includes(view));
document.getElementById('listPanel').classList.toggle('d-none',['queue','audit','invoice_history','subscription_candidates'].includes(view));
await loadItems(1);
});
document.getElementById('prevPage').onclick=()=>loadItems(page-1);document.getElementById('nextPage').onclick=()=>loadItems(page+1);
document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')loadItems(1)});
init().catch(e=>{document.getElementById('emptyState').classList.remove('d-none');document.getElementById('emptyState').querySelector('.card-body').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`});
</script>
{% endblock %}