350 lines
35 KiB
HTML
350 lines
35 KiB
HTML
|
|
{% 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|