2026-07-28 14:18:24 +02:00
{% 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; }
2026-10-08 08:58:02 +02:00
.mc-table-wrap { border-radius:14px; overflow:hidden; box-shadow:0 5px 20px rgba(20,36,58,.07); background:var(--theme-surface, white); }
.mc-table th { white-space:nowrap; font-size:.76rem; text-transform:uppercase; color:var(--theme-text, #6b778c); background:var(--theme-surface-soft, #f8fafc); }
2026-07-28 14:18:24 +02:00
.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; }
2026-10-08 08:58:02 +02:00
.mc-green { background:var(--theme-success-bg, #dcfce7); color:var(--theme-success-text, #166534); } .mc-yellow { background:var(--theme-warning-bg, #fef3c7); color:var(--theme-warning-text, #92400e); }
.mc-red { background:var(--theme-danger-bg, #fee2e2); color:var(--theme-danger-text, #991b1b); } .mc-gray { background:var(--theme-surface-soft, #e5e7eb); color:var(--theme-text, #374151); }
.mc-blue { background:var(--theme-primary-bg, #dbeafe); color:var(--theme-primary-text, #1e40af); }
2026-07-28 14:18:24 +02:00
.mc-queue { border-left:4px solid var(--mc-blue); }
2026-10-08 08:58:02 +02:00
.mc-source-card { background:var(--theme-surface-soft, #f8fafc); border:1px solid #e5eaf1; border-radius:12px; padding:16px; }
2026-07-28 14:18:24 +02:00
.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 >
2026-09-24 09:12:25 +02:00
< li class = "nav-item" > < button class = "nav-link" data-view = "case_import" > < i class = "bi bi-arrow-left-right me-1" > < / i > Overfør VTiger-sag< / button > < / li >
< li class = "nav-item" > < button class = "nav-link" data-view = "hub_export" > < i class = "bi bi-box-arrow-up-right me-1" > < / i > Overfør Hub-sag til VTiger< / button > < / li >
2026-07-28 14:18:24 +02:00
< / 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 >
2026-09-24 09:12:25 +02:00
< section id = "caseImportPanel" class = "card mc-stat d-none" >
< div class = "card-body p-4 p-lg-5" >
< div class = "row g-4 align-items-start" >
< div class = "col-12 col-xl-5" >
< div class = "mc-label mb-2" > Enkeltvis, kontrolleret overførsel< / div >
< h2 class = "h4" > Overfør vTiger-sag< / h2 >
< p class = "text-muted" > Indtast sagsnummeret fra vTiger, fx < strong > CC12345< / strong > . Først vises en forhåndsvisning; intet ændres, før du bekræfter overførslen.< / p >
< label class = "form-label fw-semibold" for = "vtigerTicketNumber" > vTiger-sagsnummer< / label >
< div class = "input-group input-group-lg" >
< input id = "vtigerTicketNumber" class = "form-control" maxlength = "80" placeholder = "CC12345" autocomplete = "off" >
< button id = "vtigerPreviewButton" class = "btn btn-primary" type = "button" onclick = "previewVtigerCase()" > < i class = "bi bi-search me-1" > < / i > Find sag< / button >
< / div >
< div class = "form-text" > Sagen skal allerede være knyttet til en kunde, som findes og er linket i Hub.< / div >
< div id = "vtigerImportAlert" class = "alert d-none mt-3 mb-0" role = "status" aria-live = "polite" > < / div >
< / div >
< div class = "col-12 col-xl-7" >
< div id = "vtigerPreviewEmpty" class = "mc-source-card h-100 d-flex align-items-center justify-content-center text-center text-muted py-5" > Sagens kunde, status og registrerede tid vises her, inden du overfører.< / div >
< div id = "vtigerPreviewCard" class = "mc-source-card d-none" >
< div class = "d-flex flex-wrap justify-content-between gap-2 align-items-start" >
< div > < div class = "mc-label" > Forhåndsvisning< / div > < h3 class = "h5 mb-1" id = "vtigerPreviewTitle" > < / h3 > < div class = "text-muted" id = "vtigerPreviewMeta" > < / div > < / div >
< div id = "vtigerPreviewState" > < / div >
< / div >
< p class = "mt-3 mb-3" id = "vtigerPreviewDescription" > < / p >
< div class = "row g-2 mb-3" >
< div class = "col-6 col-md-3" > < div class = "mc-label" > Kunde< / div > < div class = "fw-semibold" id = "vtigerPreviewCustomer" > < / div > < / div >
< div class = "col-6 col-md-3" > < div class = "mc-label" > Kontakt< / div > < div class = "fw-semibold" id = "vtigerPreviewContact" > < / div > < / div >
< div class = "col-6 col-md-3" > < div class = "mc-label" > Tidsregistreringer< / div > < div class = "fw-semibold" id = "vtigerPreviewTimes" > < / div > < / div >
< div class = "col-6 col-md-3" > < div class = "mc-label" > Prioritet< / div > < div class = "fw-semibold" id = "vtigerPreviewPriority" > < / div > < / div >
< / div >
< div class = "alert alert-info py-2 mb-3 small" > Overførslen opretter Hub-sagen og knytter tidsregistreringerne. vTiger-sagen markeres først som “Hubbet”, når Hub-overførslen er gemt. Kommentarer vises kun som antal og kopieres ikke, da vTiger ikke fortæller sikkert, om de er interne.< / div >
< button id = "vtigerImportButton" class = "btn btn-success" type = "button" onclick = "importVtigerCase()" > < i class = "bi bi-check2-circle me-1" > < / i > Overfør sag til Hub< / button >
< / div >
< / div >
< / div >
< / div >
< / section >
< section id = "hubExportPanel" class = "card mc-stat d-none" >
< div class = "card-body p-4 p-lg-5" >
< div class = "row g-4 align-items-start" >
< div class = "col-12 col-xl-4" >
< div class = "mc-label mb-2" > Hub → VTiger< / div >
< h2 class = "h4" > Overfør Hub-sag til VTiger< / h2 >
< p class = "text-muted" > Sagen og godkendte Hub-timer overføres efter forhåndsvisning. VTiger forbliver stedet, hvor faktureringen håndteres.< / p >
< label class = "form-label fw-semibold" for = "hubExportSagId" > Hub-sagsnummer< / label >
< div class = "input-group input-group-lg" >
< span class = "input-group-text" > #< / span > < input id = "hubExportSagId" class = "form-control" type = "number" min = "1" step = "1" placeholder = "173" >
< button id = "hubExportPreviewButton" class = "btn btn-primary" type = "button" onclick = "previewHubCaseExport()" > < i class = "bi bi-search me-1" > < / i > Find sag< / button >
< / div >
< div class = "form-text" > Kunden og en VTiger-linket primær kontakt skal være knyttet til sagen.< / div >
< div id = "hubExportAlert" class = "alert d-none mt-3 mb-0" role = "status" aria-live = "polite" > < / div >
< / div >
< div class = "col-12 col-xl-8" >
< div id = "hubExportPreviewEmpty" class = "mc-source-card h-100 d-flex align-items-center justify-content-center text-center text-muted py-5" > Sagsfelter og godkendte tidsregistreringer vises her, inden du overfører.< / div >
< div id = "hubExportPreviewCard" class = "mc-source-card d-none" >
< div class = "d-flex flex-wrap justify-content-between gap-2 align-items-start" >
< div > < div class = "mc-label" > Forhåndsvisning< / div > < h3 class = "h5 mb-1" id = "hubExportTitle" > < / h3 > < div class = "text-muted" id = "hubExportMeta" > < / div > < / div >
< div id = "hubExportState" > < / div >
< / div >
< p class = "mt-3 mb-3" id = "hubExportDescription" > < / p >
< div class = "row g-2 mb-3" >
< div class = "col-6 col-md-3" > < div class = "mc-label" > Kunde< / div > < div class = "fw-semibold" id = "hubExportCustomer" > < / div > < / div >
< div class = "col-6 col-md-3" > < div class = "mc-label" > Kontakt< / div > < div class = "fw-semibold" id = "hubExportContact" > < / div > < / div >
< div class = "col-6 col-md-3" > < div class = "mc-label" > VTiger-status< / div > < div class = "fw-semibold" id = "hubExportStatus" > < / div > < / div >
< div class = "col-6 col-md-3" > < div class = "mc-label" > Prioritet< / div > < div class = "fw-semibold" id = "hubExportPriority" > < / div > < / div >
< / div >
< div class = "d-flex justify-content-between align-items-center mb-2" > < strong > Tider til VTiger< / strong > < span class = "small text-muted" id = "hubExportTimesSummary" > < / span > < / div >
< div class = "table-responsive mb-3" > < table class = "table table-sm align-middle" > < thead > < tr > < th > Dato< / th > < th > Tekniker< / th > < th > Beskrivelse< / th > < th class = "text-end" > Timer< / th > < th > Fakturerbar< / th > < / tr > < / thead > < tbody id = "hubExportTimesBody" > < / tbody > < / table > < / div >
< div id = "hubExportBlockers" class = "alert alert-warning d-none py-2 small" > < / div >
< div class = "alert alert-info py-2 mb-3 small" > Kun godkendte, ikke fakturerede Hub-timer overføres. Timer markeret som faktura-afregning oprettes fakturerbare i VTiger; øvrige timer oprettes ikke-fakturerbare. Eksisterende VTiger-timer og faktureringsflag ændres ikke.< / div >
< button id = "hubExportButton" class = "btn btn-success" type = "button" onclick = "exportHubCaseToVtiger()" > < i class = "bi bi-check2-circle me-1" > < / i > Overfør sag til VTiger< / button >
< / div >
< / div >
< / div >
< / div >
< / section >
2026-07-28 14:18:24 +02:00
< / 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;
2026-09-24 09:12:25 +02:00
let vtigerPreview=null;
let hubExportPreviewData=null;
2026-07-28 14:18:24 +02:00
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();
}
2026-09-24 09:12:25 +02:00
function setVtigerImportAlert(message, type='info'){
const el=document.getElementById('vtigerImportAlert');el.className=`alert alert-${type} mt-3 mb-0`;el.textContent=message;
}
async function previewVtigerCase(){
const input=document.getElementById('vtigerTicketNumber'),button=document.getElementById('vtigerPreviewButton'),number=input.value.trim().toUpperCase();
if(!/^CC[0-9]{1,12}$/.test(number)){setVtigerImportAlert('Indtast sagsnummer i formatet CC12345.','warning');input.focus();return}
button.disabled=true;button.innerHTML='< span class = "spinner-border spinner-border-sm me-1" > < / span > Henter…';vtigerPreview=null;
document.getElementById('vtigerPreviewCard').classList.add('d-none');document.getElementById('vtigerPreviewEmpty').classList.remove('d-none');
try{
const d=await api('/vtiger-cases/preview',{method:'POST',body:JSON.stringify({ticket_number:number})});vtigerPreview=d;
document.getElementById('vtigerPreviewEmpty').classList.add('d-none');document.getElementById('vtigerPreviewCard').classList.remove('d-none');
document.getElementById('vtigerPreviewTitle').textContent=d.title;document.getElementById('vtigerPreviewMeta').textContent=`${d.ticket_number} · ${d.status}`;
document.getElementById('vtigerPreviewDescription').textContent=d.description||'Ingen beskrivelse';document.getElementById('vtigerPreviewCustomer').textContent=d.customer?.name||'—';
document.getElementById('vtigerPreviewContact').textContent=d.contact?[d.contact.first_name,d.contact.last_name].filter(Boolean).join(' ')||'—':'—';
document.getElementById('vtigerPreviewTimes').textContent=`${d.time_entries} · ${Number(d.time_hours).toFixed(2)} timer`;document.getElementById('vtigerPreviewPriority').textContent=d.priority;
document.getElementById('vtigerPreviewState').innerHTML=d.already_imported?`< span class = "mc-badge mc-green" > Allerede overført · Hub-sag #${esc(d.hub_sag_id)}< / span > `:`< span class = "mc-badge mc-blue" > Klar til gennemgang< / span > `;
const importButton=document.getElementById('vtigerImportButton');importButton.disabled=d.already_imported&&d.vtiger_marked; importButton.innerHTML=d.already_imported?(d.vtiger_marked?'Allerede overført og markeret':'Prøv VTiger-markering igen'):'Overfør sag til Hub';
setVtigerImportAlert(d.comments_available_not_imported?`${d.comments_available_not_imported} vTiger-kommentarer kopieres ikke, fordi deres synlighedstype ikke kan fastslås sikkert.`:'Forhåndsvisning klar. Ingen data er ændret.','info');
}catch(e){setVtigerImportAlert(e.message,'danger')}
finally{button.disabled=false;button.innerHTML='< i class = "bi bi-search me-1" > < / i > Find sag'}
}
async function importVtigerCase(){
if(!vtigerPreview)return;const button=document.getElementById('vtigerImportButton');
const promptText=vtigerPreview.already_imported?'Forsøg at markere den eksisterende Hub-sag som Hubbet i vTiger?':'Overfør '+vtigerPreview.ticket_number+' til Hub med de viste tidsregistreringer og markér derefter vTiger-sagen som Hubbet?';
if(!window.confirm(promptText))return;
button.disabled=true;button.innerHTML='< span class = "spinner-border spinner-border-sm me-1" > < / span > Overfører…';
try{
const d=await api('/vtiger-cases/import',{method:'POST',body:JSON.stringify({ticket_number:vtigerPreview.ticket_number})});
if(d.warning){await previewVtigerCase();setVtigerImportAlert(`${d.warning} Du kan trykke “Prøv VTiger-markering igen”; Hub-sagen bliver ikke oprettet på ny.`,'warning')}
else{await previewVtigerCase();setVtigerImportAlert(d.imported?`Sagen er overført til Hub som sag #${d.hub_sag_id}; ${d.time_entries} tidsregistreringer blev tilknyttet. vTiger er markeret Hubbet.`:`Hub-sag #${d.hub_sag_id} var allerede oprettet. vTiger er nu markeret Hubbet.`,'success')}
}catch(e){setVtigerImportAlert(e.message,'danger')}
finally{button.disabled=Boolean(vtigerPreview?.already_imported&&vtigerPreview?.vtiger_marked); if(!button.disabled)button.innerHTML=vtigerPreview?.already_imported?'Prøv VTiger-markering igen':'< i class = "bi bi-check2-circle me-1" > < / i > Overfør sag til Hub'}
}
function setHubExportAlert(message,type='info'){
const el=document.getElementById('hubExportAlert');el.className=`alert alert-${type} mt-3 mb-0`;el.textContent=message;
}
async function previewHubCaseExport(){
const input=document.getElementById('hubExportSagId'),button=document.getElementById('hubExportPreviewButton'),sagId=Number(input.value);
if(!Number.isInteger(sagId)||sagId< 1 ) { setHubExportAlert ( ' Indtast et gyldigt Hub-sagsnummer . ' , ' warning ' ) ; input . focus ( ) ; return }
button.disabled=true;button.innerHTML='< span class = "spinner-border spinner-border-sm me-1" > < / span > Henter…';hubExportPreviewData=null;
document.getElementById('hubExportPreviewCard').classList.add('d-none');document.getElementById('hubExportPreviewEmpty').classList.remove('d-none');
try{
const d=await api('/hub-cases/preview',{method:'POST',body:JSON.stringify({sag_id:sagId})});hubExportPreviewData=d;
document.getElementById('hubExportPreviewEmpty').classList.add('d-none');document.getElementById('hubExportPreviewCard').classList.remove('d-none');
document.getElementById('hubExportTitle').textContent=d.title;document.getElementById('hubExportMeta').textContent=`Hub sag #${d.sag_id} · ${d.status||'—'} · VTiger: ${d.vtiger_case_id?d.ticket_number||d.vtiger_case_id:'ny sag'}`;
document.getElementById('hubExportDescription').textContent=d.description||'Ingen beskrivelse';document.getElementById('hubExportCustomer').textContent=d.customer_name||'—';document.getElementById('hubExportContact').textContent=d.contact_name||'—';document.getElementById('hubExportStatus').textContent=d.vtiger_status||'—';document.getElementById('hubExportPriority').textContent=d.vtiger_priority||'—';
document.getElementById('hubExportTimesSummary').textContent=`${d.time_entries} godkendte · ${Number(d.time_hours).toFixed(2)} timer${d.pending_time_count?` · ${d.pending_time_count} ikke-godkendte udeladt`:''}`;
document.getElementById('hubExportTimesBody').innerHTML=d.time_rows.length?d.time_rows.map(t=>`< tr > < td > ${esc(t.date||'—')}< / td > < td > ${esc(t.technician)}< / td > < td > ${esc(t.description||'Ingen beskrivelse')}< / td > < td class = "text-end" > ${Number(t.hours).toFixed(2)}< / td > < td > ${t.billable?'Ja':'Nej'}< / td > < / tr > `).join(''):'< tr > < td colspan = "5" class = "text-muted" > Ingen nye, godkendte Hub-timer at overføre.< / td > < / tr > ';
const blockers=document.getElementById('hubExportBlockers');blockers.classList.toggle('d-none',!d.blockers.length);blockers.textContent=d.blockers.join(' ');
const buttonEl=document.getElementById('hubExportButton');buttonEl.disabled=d.blockers.length>0||d.already_exported&&d.time_entries===0; buttonEl.innerHTML=d.already_exported?(d.time_entries?'Fortsæt overførsel af manglende timer':'Allerede overført'):'< i class = "bi bi-check2-circle me-1" > < / i > Overfør sag til VTiger';
setHubExportAlert(d.blockers.length?'Ret punkterne ovenfor før overførslen.':d.already_exported?'Sagen findes allerede i VTiger. Kun eventuelle manglende timer kan fortsættes.':'Forhåndsvisning klar. Ingen data er ændret.','info');
}catch(e){setHubExportAlert(e.message,'danger')}
finally{button.disabled=false;button.innerHTML='< i class = "bi bi-search me-1" > < / i > Find sag'}
}
async function exportHubCaseToVtiger(){
const d=hubExportPreviewData;if(!d)return;
if(!window.confirm(`Overfør Hub-sag #${d.sag_id} og ${d.time_entries} godkendte tidsregistreringer til VTiger? Fakturering fortsætter i VTiger.`))return;
const button=document.getElementById('hubExportButton');button.disabled=true;button.innerHTML='< span class = "spinner-border spinner-border-sm me-1" > < / span > Overfører…';
try{
const result=await api('/hub-cases/export',{method:'POST',body:JSON.stringify({sag_id:d.sag_id,source_hash:d.source_hash})});
await previewHubCaseExport();
setHubExportAlert(result.warning?`Sagen er ${result.created_case?'oprettet':'allerede linket'} i VTiger, men nogle timer mangler: ${result.warning} Du kan fortsætte uden at oprette sagen igen.`:`Sagen er ${result.created_case?'oprettet':'allerede linket'} i VTiger${result.ticket_number?` som ${result.ticket_number}`:''}; ${result.created_time_entries} tidsregistreringer overført.` ,result.warning?'warning':'success');
}catch(e){setHubExportAlert(e.message,'danger')}
finally{if(hubExportPreviewData)button.disabled=Boolean(hubExportPreviewData.blockers.length)||hubExportPreviewData.time_entries===0;button.innerHTML='Overfør sag til VTiger'}
}
2026-07-28 14:18:24 +02:00
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;
2026-09-24 09:12:25 +02:00
if(!sessionId){
document.getElementById('workspace').classList.remove('d-none');document.getElementById('stats').classList.add('d-none');
document.querySelectorAll('#mcTabs .nav-link').forEach(btn=>{if(!['case_import','hub_export'].includes(btn.dataset.view))btn.closest('.nav-item').classList.add('d-none')});
document.querySelector('#sessionSelect').closest('.d-flex').classList.add('d-none');
document.querySelector('[data-view="case_import"]').click();return
}
2026-07-28 14:18:24 +02:00
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));
2026-09-24 09:12:25 +02:00
document.getElementById('caseImportPanel').classList.toggle('d-none',view!=='case_import');
document.getElementById('hubExportPanel').classList.toggle('d-none',view!=='hub_export');
document.getElementById('listPanel').classList.toggle('d-none',['queue','audit','invoice_history','subscription_candidates','case_import','hub_export'].includes(view));
if(['case_import','hub_export'].includes(view)){document.getElementById('queuePanel').classList.add('d-none');document.getElementById('auditPanel').classList.add('d-none');document.getElementById('historyPanel').classList.add('d-none');return}
2026-07-28 14:18:24 +02:00
await loadItems(1);
});
2026-09-24 09:12:25 +02:00
document.getElementById('vtigerTicketNumber').addEventListener('keydown',e=>{if(e.key==='Enter')previewVtigerCase()});
document.getElementById('hubExportSagId').addEventListener('keydown',e=>{if(e.key==='Enter')previewHubCaseExport()});
2026-07-28 14:18:24 +02:00
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 %}