bmc_hub/app/admin/hub_impact.html
Christian 1cfe5aee76 feat(migrations): add AI benchmark and vTiger archive tables
- Created tables for AI benchmark runs and results to facilitate model evaluation.
- Added expected answers column to benchmark results.
- Introduced tables for internet connection change cases and vTiger archive management, including records, relations, and checkpoints.
- Implemented triggers to enforce append-only behavior for vTiger archive records and files.
- Enhanced solution management with soft delete capabilities for sag_solutions and knowledge_articles.

feat(scripts): add CRM benchmarking script for Ollama models

- Developed a Python script to benchmark CRM models exposed through Ollama, including various test cases and scoring mechanisms.

test(tests): add comprehensive tests for new features

- Implemented tests for internet change case service, vTiger archive functionality, and sag solution knowledge management.
- Ensured coverage for edge cases and error handling in the new features.
2026-08-31 13:01:35 +02:00

61 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends "shared/frontend/base.html" %}
{% block title %}Project CT · Hub Impact{% endblock %}
{% block extra_css %}
<style>
.ct-shell{max-width:1500px;margin:auto}.ct-hero{border-radius:24px;padding:2rem;background:linear-gradient(120deg,#082f49,#0f4c75 55%,#2563eb);color:#fff;box-shadow:0 22px 55px rgba(8,47,73,.22)}
.ct-panel{background:var(--bs-body-bg);border:1px solid rgba(15,76,117,.14);border-radius:18px;box-shadow:0 10px 28px rgba(15,76,117,.06)}
.ct-kpi{border-radius:16px;padding:1.1rem;background:linear-gradient(145deg,rgba(15,76,117,.08),rgba(37,99,235,.03));height:100%}.ct-kpi strong{font-size:1.7rem}.ct-delta.positive{color:#16803c}.ct-delta.negative{color:#dc3545}
.ct-status{display:inline-flex;align-items:center;gap:.45rem;padding:.38rem .75rem;border-radius:999px;font-weight:700}.ct-status.ready{background:#dcfce7;color:#166534}.ct-status.blocked{background:#fee2e2;color:#991b1b}
.ct-table th{font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:#64748b}.ct-empty{padding:3rem;text-align:center;color:#64748b}
</style>
{% endblock %}
{% block content %}
<main class="container-fluid px-4 py-4 ct-shell">
<section class="ct-hero mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
<div><div class="text-uppercase small fw-bold opacity-75 mb-2">Fortrolig · kun superadmin</div><h1 class="h2 mb-2">Project CT · Hub Impact</h1><p class="mb-0 opacity-75">Dokumentér effekten af Hub mod et permanent vTiger-arkiv.</p></div>
<div class="text-end"><div id="readinessBadge" class="ct-status blocked">Indlæser arkivstatus…</div><div class="small opacity-75 mt-2" id="readinessReasons"></div></div>
</section>
<section class="ct-panel p-3 p-lg-4 mb-4">
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3"><div><h2 class="h5 mb-1">Ny reproducerbar sammenligning</h2><div class="small text-muted">Perioderne skal have præcis samme antal kalenderdage.</div></div><div class="d-flex flex-wrap gap-2"><button class="btn btn-outline-secondary" id="syncFull">Kør fuld sync</button><button class="btn btn-outline-primary" id="syncIncremental">Kør inkrementel sync</button><button class="btn btn-outline-danger" id="syncFinal">Kør final sync</button><button class="btn btn-outline-success" id="approveFinal">Godkend final kontrol</button><a class="btn btn-dark" id="bundleExport">Download pakke til prod</a><button class="btn btn-outline-dark" id="bundleImportBtn">Importér arkivpakke</button><input type="file" id="bundleImportFile" accept=".zip" hidden></div></div>
<div class="row g-3 align-items-end">
<div class="col-lg-3"><label class="form-label fw-semibold">Arkivversion</label><select id="archiveVersion" class="form-select"></select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">vTiger fra</label><input id="vtFrom" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">vTiger til</label><input id="vtTo" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">Hub fra</label><input id="hubFrom" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">Hub til</label><input id="hubTo" type="date" class="form-control"></div>
<div class="col-lg-1 d-grid gap-2"><button id="runReport" class="btn btn-primary">Kør</button><a id="rawExport" class="btn btn-sm btn-outline-secondary">Rådata</a></div>
</div><div id="feedback" class="small mt-3 text-muted"></div>
</section>
<section id="resultArea" class="d-none">
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h4 mb-0">Resultat</h2><a id="excelExport" class="btn btn-success">Eksportér Excel</a></div>
<div class="row g-3 mb-4" id="kpis"></div>
<div class="ct-panel p-4 mb-4"><div class="d-flex align-items-center gap-2 mb-3"><i class="bi bi-speedometer2 text-primary fs-4"></i><h3 class="h5 mb-0">Hvad Hub effektiviserer og optimerer</h3></div><div id="impactEvidence" class="row g-3"></div><div class="small text-muted mt-3">Positive og negative ændringer vises ensartet. Tallene sammenligner lige lange perioder og er dokumentation—ikke en skjult vægtet score.</div></div>
<div class="ct-panel p-3 p-lg-4 mb-4"><h3 class="h5 mb-3">Pr. medarbejder</h3><div class="table-responsive"><table class="table align-middle ct-table"><thead><tr><th>Kilde</th><th>Medarbejder</th><th>E-mail</th><th>Timer</th><th>Registreringer</th><th>Sager</th><th>Ordrer</th><th>Timer/dag</th></tr></thead><tbody id="employeeRows"></tbody></table></div></div>
<div class="row g-4"><div class="col-lg-7"><div class="ct-panel p-4 h-100"><h3 class="h5">Afvigelser udeladt fra KPI</h3><div id="anomalies"></div></div></div><div class="col-lg-5"><div class="ct-panel p-4 h-100"><h3 class="h5">Datakvalitet</h3><div id="quality"></div></div></div></div>
</section>
<section class="ct-panel p-4 mt-4"><h2 class="h5 mb-3">Gemte rapporter</h2><div id="savedReports" class="list-group list-group-flush"></div></section>
<section class="ct-panel p-4 mt-4"><div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="h5 mb-1">Flytning mellem test og produktion</h2><div class="small text-muted">Checksummede arkivpakker. Import læser kun ZIP-filen og kontakter aldrig vTiger.</div><div class="small fw-semibold mt-1" id="fileArchiveStatus"></div></div><button class="btn btn-sm btn-outline-secondary" id="refreshTransfers">Opdatér</button></div><div id="transferRows" class="table-responsive ct-empty">Ingen overførsler endnu.</div></section>
</main>
{% endblock %}
{% block extra_js %}
<script>
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const iso=d=>d.toISOString().slice(0,10); let optionsData={};
function defaults(){const now=new Date(),hTo=new Date(now),hFrom=new Date(now),vTo=new Date(now),vFrom=new Date(now);hFrom.setDate(hFrom.getDate()-29);vTo.setDate(hFrom.getDate()-1);vFrom.setDate(vTo.getDate()-29);hubTo.value=iso(hTo);hubFrom.value=iso(hFrom);vtTo.value=iso(vTo);vtFrom.value=iso(vFrom)}
async function api(url,opt={}){const r=await fetch(url,{credentials:'include',...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});if(!r.ok){let e={};try{e=await r.json()}catch{}throw new Error(e.detail||`HTTP ${r.status}`)}return r.json()}
async function loadOptions(){optionsData=await api('/api/v1/admin/hub-impact/options');archiveVersion.innerHTML=optionsData.versions.map(v=>`<option value="${v.id}">Version ${v.id} · ${esc(v.sync_kind)} · ${new Date(v.completed_at).toLocaleString('da-DK')}${v.control_approved_at?' · godkendt':''}</option>`).join('');setRawLink();const ready=optionsData.readiness.ready;readinessBadge.className=`ct-status ${ready?'ready':'blocked'}`;readinessBadge.textContent=ready?'Klar til opsigelse':'Ikke klar til opsigelse';readinessReasons.textContent=(optionsData.readiness.reasons||[]).join(' · ');savedReports.innerHTML=optionsData.reports.length?optionsData.reports.map(r=>`<button class="list-group-item list-group-item-action d-flex justify-content-between" onclick="openReport(${r.id})"><span>Rapport #${r.id} · arkiv v${r.archive_version_id}</span><small>${esc(r.vtiger_from)}${esc(r.vtiger_to)} / ${esc(r.hub_from)}${esc(r.hub_to)}</small></button>`).join(''):'<div class="text-muted">Ingen gemte rapporter endnu.</div>'}
async function loadTransfers(){const data=await api('/api/v1/admin/vtiger-archive/status');const files=data.file_archive||[];fileArchiveStatus.textContent=files.length?`Originale filer i arkivet: ${files.map(x=>`${x.source_module} ${x.files} (${(Number(x.size_bytes)/1048576).toFixed(1)} MB)`).join(' · ')}`:'Ingen originale filer arkiveret endnu.';const rows=data.transfers||[];transferRows.className='table-responsive';transferRows.innerHTML=rows.length?`<table class="table align-middle ct-table mb-0"><thead><tr><th>Tid</th><th>Retning</th><th>Status</th><th>Til version</th><th>Checksum</th><th>Indhold</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${new Date(x.completed_at||x.started_at).toLocaleString('da-DK')}</td><td>${x.direction==='export'?'Test → prod':'Pakke → denne server'}</td><td><span class="badge ${x.status==='completed'?'bg-success':x.status==='failed'?'bg-danger':'bg-warning text-dark'}">${esc(x.status)}</span></td><td>${x.through_version_id||'—'}</td><td><code>${esc((x.bundle_sha256||'').slice(0,12))}${x.bundle_sha256?'…':'—'}</code></td><td>${Object.entries(x.counts||{}).map(([k,v])=>`${esc(k)}: ${v}`).join(' · ')||esc(x.error_message||'—')}</td></tr>`).join('')}</tbody></table>`:'<div class="ct-empty">Ingen overførsler endnu.</div>'}
function setRawLink(){rawExport.href=archiveVersion.value?`/api/v1/admin/vtiger-archive/versions/${archiveVersion.value}/raw-export`:'#';bundleExport.href=archiveVersion.value?`/api/v1/admin/vtiger-archive/bundle.zip?through_version_id=${archiveVersion.value}`:'#'}archiveVersion.onchange=setRawLink;
function render(id,r){resultArea.classList.remove('d-none');excelExport.href=`/api/v1/admin/hub-impact/reports/${id}/export.xlsx`;const labels={hours:'Timer',time_entries:'Tidsregistreringer',cases:'Sager',orders:'Ordrer'};kpis.innerHTML=Object.entries(labels).map(([k,l])=>{const d=r.comparison[k],pct=r.improvement?.percent?.[k],cls=d>0?'positive':d<0?'negative':'';return `<div class="col-sm-6 col-xl-3"><div class="ct-kpi"><div class="text-muted small fw-bold">${l}</div><div class="d-flex justify-content-between mt-2"><span>vTiger <strong>${r.vtiger.totals[k]}</strong></span><span>Hub <strong>${r.hub.totals[k]}</strong></span></div><div class="ct-delta ${cls} mt-2 fw-bold">Forskel ${d>0?'+':''}${d}${pct==null?'':` · ${pct>0?'+':''}${pct}%`}</div></div></div>`}).join('');impactEvidence.innerHTML=(r.improvement?.evidence||[]).map((text,i)=>`<div class="col-md-6"><div class="ct-kpi d-flex gap-3 align-items-center"><span class="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style="width:34px;height:34px;flex:0 0 34px">${i+1}</span><strong class="fs-6">${esc(text)}</strong></div></div>`).join('')||'<div class="text-muted">Ikke nok vTiger-baseline til at beregne procentvis effekt.</div>';employeeRows.innerHTML=['vtiger','hub'].flatMap(src=>r[src].employees.map(x=>`<tr><td><span class="badge ${src==='hub'?'bg-primary':'bg-secondary'}">${src}</span></td><td>${esc(x.name)}</td><td>${esc(x.email||'—')}</td><td>${x.hours}</td><td>${x.time_entries}</td><td>${x.cases}</td><td>${x.orders}</td><td>${x.productivity.hours_per_workday}</td></tr>`)).join('');const aa=[...r.vtiger.anomalies,...r.hub.anomalies];anomalies.innerHTML=aa.length?aa.map(a=>`<div class="border-bottom py-2"><strong>${esc(a.source)}</strong> · ${esc(a.type)} · #${esc(a.id)} · ${a.hours} timer</div>`).join(''):'<div class="text-success">Ingen afvigelser i perioderne.</div>';quality.innerHTML=['vtiger','hub'].map(src=>`<div class="mb-3"><strong>${src}</strong><div>Manglende datoer: ${r[src].data_quality.missing_dates}</div><div>Ikke matchede medarbejdere: ${r[src].data_quality.unmatched_employees.length}</div></div>`).join('')}
async function openReport(id){feedback.textContent='Indlæser rapport…';try{const row=await api(`/api/v1/admin/hub-impact/reports/${id}`);render(id,row.result);feedback.textContent=`Rapport #${id} indlæst.`}catch(e){feedback.textContent=e.message}}
runReport.onclick=async()=>{runReport.disabled=true;feedback.textContent='Beregner og gemmer rapport…';try{const x=await api('/api/v1/admin/hub-impact/reports',{method:'POST',body:JSON.stringify({archive_version_id:Number(archiveVersion.value),vtiger_from:vtFrom.value,vtiger_to:vtTo.value,hub_from:hubFrom.value,hub_to:hubTo.value})});render(x.id,x.result);feedback.textContent=`Rapport #${x.id} er gemt reproducerbart.`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{runReport.disabled=false}};
async function sync(kind,button){if(kind==='final'&&!confirm('Final sync bruges som grundlag for opsigelsesgodkendelsen. Fortsæt?'))return;button.disabled=true;feedback.textContent=`Kører ${kind} sync…`;try{const r=await api(`/api/v1/admin/vtiger-archive/sync/${kind}`,{method:'POST'});feedback.textContent=`Arkivversion ${r.version_id}: ${r.status}`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{button.disabled=false}}
approveFinal.onclick=async()=>{const final=optionsData.versions.find(v=>v.sync_kind==='final');if(!final){feedback.textContent='Der findes endnu ingen fuldført final sync.';return}approveFinal.disabled=true;try{await api(`/api/v1/admin/vtiger-archive/versions/${final.id}/approve-control`,{method:'POST'});feedback.textContent=`Kontrolrapport for final version ${final.id} er godkendt.`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{approveFinal.disabled=false}};
bundleImportBtn.onclick=()=>bundleImportFile.click();bundleImportFile.onchange=async()=>{const file=bundleImportFile.files[0];if(!file)return;if(!confirm(`Importér ${file.name}? Importen bruger kun filen og kontakter aldrig vTiger.`))return;bundleImportBtn.disabled=true;feedback.textContent='Validerer checksums og importerer hele arkivpakken…';try{const form=new FormData();form.append('file',file);const response=await fetch('/api/v1/admin/vtiger-archive/import-bundle',{method:'POST',body:form,credentials:'include'});const data=await response.json();if(!response.ok)throw new Error(data.detail||`HTTP ${response.status}`);feedback.textContent=data.already_imported?'Denne præcise pakke var allerede importeret; intet blev duplikeret.':`Import færdig: ${data.counts.records} revisioner, ${data.counts.relations} relationer. vTiger kontaktet: nej.`;await Promise.all([loadOptions(),loadTransfers()])}catch(e){feedback.textContent=e.message}finally{bundleImportBtn.disabled=false;bundleImportFile.value=''}};
refreshTransfers.onclick=()=>loadTransfers().catch(e=>feedback.textContent=e.message);syncFull.onclick=()=>sync('full',syncFull);syncIncremental.onclick=()=>sync('incremental',syncIncremental);syncFinal.onclick=()=>sync('final',syncFinal);defaults();Promise.all([loadOptions(),loadTransfers()]).catch(e=>feedback.textContent=e.message);
</script>
{% endblock %}