Files

370 lines
26 KiB
Plaintext
Raw Permalink Normal View History

<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Data Quality — Mek-Tech</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"><link rel="stylesheet" href="/css/style.css"><link rel="stylesheet" href="/css/dynamic.css"><link rel="icon" href="/img/favicon.svg"><script src="https://unpkg.com/lucide@latest"></script>
<style>
:root{--glow:0 0 60px rgba(88,166,255,.06)}
body{background:var(--bg)}
.dq-wrap{max-width:1100px;margin:0 auto;padding:1rem 1.5rem 3rem}
/* Premium header */
.dq-hdr{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:1.5rem;flex-wrap:wrap;gap:.75rem}
.dq-hdr h1{font-size:1.5rem;font-weight:800;color:var(--text);display:flex;align-items:center;gap:.6rem;letter-spacing:-.3px}
.dq-hdr h1 .hl{width:4px;height:28px;background:linear-gradient(180deg,var(--accent),var(--purple));border-radius:4px}
.dq-hdr .sub{font-size:.78rem;color:var(--text-muted)}
.dq-badges{display:flex;gap:.35rem;flex-wrap:wrap}
.dq-badge{padding:.2rem .6rem;border-radius:999px;font-size:.65rem;font-weight:500;border:1px solid var(--border);color:var(--text-dim);display:flex;align-items:center;gap:.25rem}
.dq-badge svg{width:10px;height:10px}
/* Tabs */
.dq-tabs{display:flex;gap:0;margin-bottom:1.25rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.dq-tab{padding:.5rem 1rem;font-size:.78rem;font-weight:500;color:var(--text-muted);cursor:pointer;transition:all .12s;border:none;background:none;font-family:inherit;display:flex;align-items:center;gap:.4rem;border-right:1px solid var(--border)}
.dq-tab:last-child{border-right:none}
.dq-tab:hover{color:var(--text);background:var(--border-light)}
.dq-tab.active{color:var(--accent);background:rgba(88,166,255,.06);font-weight:600}
/* Score circle */
.score-circle{width:140px;height:140px;border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;margin:0 auto}
.score-circle::before{content:'';position:absolute;inset:0;border-radius:50%;padding:4px;background:linear-gradient(135deg,var(--accent),var(--purple),var(--green));-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 4px),#000 0);mask:radial-gradient(farthest-side,transparent calc(100% - 4px),#000 0)}
.score-num{font-size:2.5rem;font-weight:800;letter-spacing:-1px}
.score-label{font-size:.65rem;color:var(--text-muted);text-transform:uppercase;margin-top:-2px}
.score-A .score-num{color:var(--green)}.score-B .score-num{color:var(--cyan)}.score-C .score-num{color:var(--orange)}.score-D .score-num{color:var(--orange)}.score-F .score-num{color:var(--red)}
/* Stats row */
.stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.5rem;margin-bottom:1rem}
.stats-box{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:.8rem 1rem;position:relative;overflow:hidden}
.stats-box::after{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--accent),var(--purple));opacity:.3}
.stats-box .sb-num{font-size:1.3rem;font-weight:700;color:var(--text)}
.stats-box .sb-lbl{font-size:.62rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;margin-top:2px}
/* Column grid */
.col-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.5rem}
.col-box{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:.75rem;position:relative;overflow:hidden}
.col-box::before{content:'';position:absolute;top:0;left:0;width:3px;height:100%;background:var(--accent)}
.col-box.warn::before{background:var(--orange)}
.col-box.danger::before{background:var(--red)}
.col-box.good::before{background:var(--green)}
.col-box h4{font-size:.8rem;font-weight:600;color:var(--text);margin-bottom:.4rem}
.cb-row{display:flex;justify-content:space-between;align-items:center;padding:.15rem 0;font-size:.7rem}
.cb-row .cb-l{color:var(--text-muted)}.cb-row .cb-v{color:var(--text);font-weight:500}
.cb-bar{height:3px;background:var(--border-light);border-radius:2px;overflow:hidden;margin:.15rem 0}
.cb-bar .cb-f{height:100%;border-radius:2px;transition:width .4s}
.cb-tags{display:flex;flex-wrap:wrap;gap:2px;margin-top:.3rem;padding-top:.3rem;border-top:1px solid var(--border-light)}
.cb-tag{padding:.1rem .35rem;border-radius:3px;font-size:.58rem;font-weight:500}
.cb-tag.red{background:var(--red-bg);color:var(--red)}.cb-tag.orange{background:var(--orange-bg);color:var(--orange)}.cb-tag.blue{background:var(--cyan-bg);color:var(--cyan)}
/* Mini histogram */
.mini-hist{display:flex;align-items:flex-end;gap:1px;height:32px;margin:.25rem 0}
.mini-hist .mh-bar{flex:1;border-radius:1px 1px 0 0;background:var(--accent);min-height:2px;opacity:.7;transition:opacity .2s}
.mini-hist .mh-bar:hover{opacity:1}
/* Top value list */
.top-vals{font-size:.62rem;color:var(--text-muted);margin-top:.2rem}
.top-vals span{color:var(--text);font-weight:500}
/* Suggestions */
.sug-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:.4rem}
.sug-card{display:flex;align-items:flex-start;gap:.5rem;padding:.6rem .7rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);border-left:3px solid var(--border)}
.sug-card.high{border-left-color:var(--red)}
.sug-card.medium{border-left-color:var(--orange)}
.sug-card.low{border-left-color:var(--accent)}
.sug-card .sug-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-top:.3rem}
.sug-card.high .sug-dot{background:var(--red)}.sug-card.medium .sug-dot{background:var(--orange)}.sug-card.low .sug-dot{background:var(--accent)}
.sug-card .sug-body{flex:1;min-width:0}
.sug-card .sug-body .sug-col{font-size:.75rem;font-weight:600;color:var(--text)}
.sug-card .sug-body .sug-issue{font-size:.7rem;color:var(--text-muted);margin-top:.1rem}
.sug-card .sug-body .sug-action{font-size:.65rem;color:var(--accent);margin-top:.15rem}
/* dbt section */
.dbt-grid{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}
@media(max-width:700px){.dbt-grid{grid-template-columns:1fr}}
.dbt-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:.75rem}
.dbt-card h3{font-size:.78rem;font-weight:600;color:var(--text);margin-bottom:.5rem}
.dbt-model{padding:.35rem .5rem;background:var(--bg);border-radius:4px;margin-bottom:2px;font-size:.68rem;display:flex;justify-content:space-between;align-items:center}
.dbt-model .dm-name{color:var(--text);font-weight:500}
.dbt-model .dm-status{font-size:.6rem;padding:.05rem .3rem;border-radius:3px}
/* Check table */
.chk-table{width:100%;border-collapse:collapse;margin-top:.5rem}
.chk-table th{text-align:left;padding:.3rem .5rem;font-size:.6rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px;border-bottom:1px solid var(--border);background:var(--bg)}
.chk-table td{padding:.3rem .5rem;font-size:.7rem;border-bottom:1px solid var(--border-light);color:var(--text)}
.chk-table .ok{color:var(--green);font-weight:600}.chk-table .nok{color:var(--red);font-weight:600}
/* Correlation */
.corr-list{display:flex;flex-direction:column;gap:2px}
.corr-item{display:flex;align-items:center;gap:.4rem;padding:.25rem .4rem;background:var(--bg);border-radius:3px;font-size:.68rem}
.corr-item .ci-from{color:var(--text);font-weight:500}.corr-item .ci-arrow{color:var(--text-dim)}
.corr-item .ci-to{color:var(--text)}.corr-item .ci-val{font-weight:600;margin-left:auto}
.corr-item .ci-val.pos{color:var(--green)}.corr-item .ci-val.neg{color:var(--red)}
/* Upload zone */
.dz{display:flex;flex-direction:column;align-items:center;gap:.75rem;padding:2.5rem 2rem;border:2px dashed var(--border);border-radius:var(--radius-lg);cursor:pointer;transition:all .2s;margin-bottom:1.5rem;background:rgba(88,166,255,.01)}
.dz:hover,.dz.dragover{border-color:var(--accent);background:rgba(88,166,255,.03)}.dz.dragover{background:rgba(88,166,255,.06)}
.dz-icon{width:56px;height:56px;border-radius:50%;background:rgba(88,166,255,.06);display:flex;align-items:center;justify-content:center}
.dz-icon svg{width:24px;height:24px;color:var(--accent);opacity:.6}
.dz p{font-size:.82rem;color:var(--text-muted)}.dz p strong{color:var(--accent)}
.dz .dz-hint{font-size:.68rem;color:var(--text-dim)}
/* Panel */
.pane{display:none}.pane.active{display:block}
/* No data state */
.no-data{text-align:center;padding:3rem 1rem;color:var(--text-muted)}
.no-data svg{width:48px;height:48px;opacity:.2;margin-bottom:.5rem}
.no-data p{font-size:.85rem}
</style></head><body>
<nav class="navbar"><div class="nav-inner"><a href="/" class="nav-brand"><img src="/img/logo.svg" class="nav-logo" alt="Mek-Tech"></a><div style="display:flex;align-items:center;gap:.4rem"><a href="/" class="nav-icon-btn" title="Dashboard"><i data-lucide="layout-dashboard" style="width:15px;height:15px"></i> Dashboard</a><button class="theme-toggle" id="themeToggle" onclick="toggleTheme()"></button><a href="/auth/logout" class="nav-icon-btn" style="color:var(--red)"><i data-lucide="log-out" style="width:15px;height:15px"></i> Uitloggen</a></div></div></nav>
<div class="dq-wrap">
<a href="javascript:history.back()" class="back-link"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg> Terug</a>
<div class="dq-hdr">
<div><h1><span class="hl"></span> Data Quality Engine</h1><p class="sub">Profileer, valideer en verbeter je data met enterprise-grade tools</p></div>
<div class="dq-badges">
<span class="dq-badge"><i data-lucide="layers" style="width:10px;height:10px"></i> pandas</span>
<span class="dq-badge"><i data-lucide="shield-check" style="width:10px;height:10px"></i> great_expectations</span>
<span class="dq-badge"><i data-lucide="database" style="width:10px;height:10px"></i> dbt</span>
<span class="dq-badge"><i data-lucide="bar-chart-3" style="width:10px;height:10px"></i> scikit-learn</span>
</div>
</div>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<% if (!uploaded && !profile) { %>
<form method="POST" action="/quality/upload" enctype="multipart/form-data" id="uf">
<div class="dz" id="dzone" onclick="document.getElementById('fi').click()">
<div class="dz-icon"><i data-lucide="upload-cloud" style="width:20px;height:20px"></i></div>
<p>Sleep een dataset of <strong>klik om te uploaden</strong></p>
<span class="dz-hint">CSV · Excel · Parquet · JSON · TSV — max 100MB</span>
<input type="file" name="file" id="fi" accept=".csv,.xlsx,.xls,.parquet,.json,.tsv" style="display:none" onchange="document.getElementById('uf').submit()">
</div></form>
<% } %>
<% if (profile) { %>
<% var qScore = profile.overall_quality_score || 0;
var grade = qScore >= 90 ? 'A' : qScore >= 75 ? 'B' : qScore >= 50 ? 'C' : qScore >= 25 ? 'D' : 'F';
%>
<div class="dq-tabs">
<button class="dq-tab active" data-tab="overview"><i data-lucide="layout-dashboard" style="width:13px;height:13px"></i> Overzicht</button>
<button class="dq-tab" data-tab="columns"><i data-lucide="columns" style="width:13px;height:13px"></i> Kolommen</button>
<button class="dq-tab" data-tab="suggestions"><i data-lucide="lightbulb" style="width:13px;height:13px"></i> Verbeterpunten</button>
<button class="dq-tab" data-tab="checks"><i data-lucide="check-circle" style="width:13px;height:13px"></i> Kwaliteit</button>
<button class="dq-tab" data-tab="dbt"><i data-lucide="box" style="width:13px;height:13px"></i> dbt</button>
</div>
<!-- OVERVIEW TAB -->
<div class="pane active" id="pane-overview">
<div class="stats-row">
<div class="stats-box" style="background:linear-gradient(135deg,var(--bg-card),rgba(88,166,255,.03))"><div class="sb-num"><%= profile.rows.toLocaleString() %></div><div class="sb-lbl">Rijen</div></div>
<div class="stats-box"><div class="sb-num"><%= profile.columns %></div><div class="sb-lbl">Kolommen</div></div>
<div class="stats-box"><div class="sb-num"><%= profile.memory_mb %> MB</div><div class="sb-lbl">Geheugen</div></div>
<div class="stats-box" style="grid-row:span 2;display:flex;align-items:center;justify-content:center">
<div class="score-circle score-<%= grade %>"><div class="score-num"><%= qScore %>%</div><div class="score-label"><%= grade %> Grade</div></div>
</div>
</div>
<!-- Column overview -->
<div class="col-grid">
<% profile.columns_profile.slice(0, 8).forEach(function(cp){
var status = cp.null_pct > 20 ? 'danger' : cp.null_pct > 5 ? 'warn' : cp.null_pct === 0 ? 'good' : '';
%>
<div class="col-box <%= status %>">
<h4><%= cp.name %></h4>
<div class="cb-row"><span class="cb-l">Type</span><span class="cb-v"><%= cp.dtype %></span></div>
<div class="cb-row"><span class="cb-l">Compleet</span><span class="cb-v"><%= (100-cp.null_pct).toFixed(1) %>%</span></div>
<div class="cb-bar"><div class="cb-f" style="width:<%= 100-cp.null_pct %>%;background:<%= cp.null_pct>20?'var(--red)':cp.null_pct>5?'var(--orange)':'var(--accent)' %>"></div></div>
<div class="cb-row"><span class="cb-l">Uniek</span><span class="cb-v"><%= cp.unique %> (<%= cp.unique_pct.toFixed(0) %>%)</span></div>
<% if (cp.min !== undefined && cp.min !== null) { %><div class="cb-row"><span class="cb-l">Min → Max</span><span class="cb-v"><%= cp.min %> → <%= cp.max %></span></div><% } %>
<% if (cp.histogram) { %><div class="mini-hist"><% var maxH=Math.max(...cp.histogram.counts,1); cp.histogram.counts.forEach(function(h){ %><div class="mh-bar" style="height:<%= (h/maxH*100) %>%;background:<%= status==='danger'?'var(--red)':'var(--accent)' %>" title="<%= h %>"></div><% }) %></div><% } %>
<% if (cp.top_values) { var tvk=Object.keys(cp.top_values).slice(0,3); %><div class="top-vals">Top: <% tvk.forEach(function(k,i){ %><span><%= k %></span><%= i<tvk.length-1 ? ' · ' : '' %><% }) %></div><% } %>
<% if (cp.null_count>0) { %><div class="cb-tags"><span class="cb-tag red"><%= cp.null_count %> nulls</span></div><% } %>
</div>
<% }) %>
</div>
</div>
<!-- COLUMNS TAB -->
<div class="pane" id="pane-columns">
<div class="col-grid">
<% profile.columns_profile.forEach(function(cp){
var colIssues = [];
if (cp.null_pct>20) colIssues.push({s:'high',t:'Hoge null-rate'});
else if (cp.null_pct>5) colIssues.push({s:'medium',t:'Enkele nulls'});
if (cp.negative>0) colIssues.push({s:'medium',t:'Negatieve waarden'});
if (cp.unique_pct>95) colIssues.push({s:'low',t:'Unieke ID kolom'});
if (cp.unique_pct<1) colIssues.push({s:'low',t:'Constante waarde'});
var colStat = cp.null_pct > 20 ? 'danger' : cp.null_pct > 5 ? 'warn' : 'good';
%>
<div class="col-box <%= colStat %>">
<h4><%= cp.name %></h4>
<div class="cb-row"><span class="cb-l">Dtype</span><span class="cb-v"><%= cp.dtype %></span></div>
<div class="cb-row"><span class="cb-l">Compleet</span><span class="cb-v"><%= (100-cp.null_pct).toFixed(1) %>% · <%= cp.null_count %> null</span></div>
<div class="cb-bar"><div class="cb-f" style="width:<%= 100-cp.null_pct %>%;background:<%= colStat==='danger'?'var(--red)':'var(--accent)' %>"></div></div>
<div class="cb-row"><span class="cb-l">Uniek</span><span class="cb-v"><%= cp.unique %> / <%= cp.non_null %> (<%= cp.unique_pct.toFixed(1) %>%)</span></div>
<% if (cp.min !== undefined && cp.min !== null) { %><div class="cb-row"><span class="cb-l">Range</span><span class="cb-v"><%= cp.min %> — <%= cp.max %></span></div><% } %>
<% if (cp.mean !== undefined && cp.mean !== null) { %><div class="cb-row"><span class="cb-l">μ ± σ</span><span class="cb-v"><%= cp.mean %> ± <%= cp.std %></span></div><% } %>
<% if (cp.q25 !== undefined) { %><div class="cb-row"><span class="cb-l">Quartielen</span><span class="cb-v">Q1:<%= cp.q25 %> M:<%= cp.q50 %> Q3:<%= cp.q75 %></span></div><% } %>
<% if (cp.histogram) { %><div class="mini-hist"><% var hMax=Math.max(...cp.histogram.counts,1); cp.histogram.counts.forEach(function(h){ %><div class="mh-bar" style="height:<%= (h/hMax*100) %>%" title="<%= h %>"></div><% }) %></div><% } %>
<% if (cp.top_values) { var tvk2=Object.keys(cp.top_values).slice(0,5); %><div class="top-vals">Top: <% tvk2.forEach(function(k,i){ %><span><%= k %></span><%= i<tvk2.length-1 ? ' · ' : '' %><% }) %></div><% } %>
<% if (colIssues.length>0) { %><div class="cb-tags"><% colIssues.forEach(function(i){ %><span class="cb-tag <%= i.s==='high'?'red':'orange' %>"><%= i.t %></span><% }) %></div><% } %>
</div>
<% }) %>
</div>
</div>
<!-- SUGGESTIONS TAB -->
<div class="pane" id="pane-suggestions">
<% if (profile.suggestions && profile.suggestions.length > 0) { %>
<div class="sug-list">
<% profile.suggestions.forEach(function(s){ %>
<div class="sug-card <%= s.severity %>">
<div class="sug-dot"></div>
<div class="sug-body">
<div class="sug-col"><%= s.column %></div>
<div class="sug-issue"><%= s.issue %></div>
<div class="sug-action">→ <%= s.action %></div>
</div>
</div>
<% }) %>
</div>
<% } else { %><div class="no-data"><p>Geen verbeterpunten — je data ziet er goed uit!</p></div><% } %>
</div>
<!-- CHECKS TAB -->
<div class="pane" id="pane-checks">
<!-- Correlation -->
<% if (profile.correlation && profile.correlation.length > 0) { %>
<div class="dbt-card" style="margin-bottom:.5rem"><h3><i data-lucide="git-branch" style="width:13px;height:13px"></i> Correlatie matrix</h3>
<div class="corr-list"><% profile.correlation.forEach(function(c){ %><div class="corr-item"><span class="ci-from"><%= c.from %></span><span class="ci-arrow">↔</span><span class="ci-to"><%= c.to %></span><span class="ci-val <%= c.direction==='positive'?'pos':'neg' %>"><%= c.direction==='positive'?'+':'' %><%= c.value %></span></div><% }) %></div>
</div>
<% } %>
<!-- Distribution -->
<% if (profile.distribution && Object.keys(profile.distribution).length > 0) { %>
<div class="dbt-card" style="margin-bottom:.5rem"><h3><i data-lucide="activity" style="width:13px;height:13px"></i> Distributie analyse</h3>
<div class="dbt-grid">
<% Object.entries(profile.distribution).forEach(function(e){ var k=e[0],d=e[1]; %>
<div class="dbt-card" style="background:var(--bg);border-color:var(--border-light)"><h4 style="font-size:.72rem;color:var(--text);margin-bottom:.3rem"><%= k %></h4>
<div class="cb-row"><span class="cb-l">Mean</span><span class="cb-v"><%= d.mean %></span></div>
<div class="cb-row"><span class="cb-l">Median</span><span class="cb-v"><%= d.median %></span></div>
<div class="cb-row"><span class="cb-l">Stdev</span><span class="cb-v"><%= d.std %></span></div>
<div class="cb-row"><span class="cb-l">Skew</span><span class="cb-v"><%= d.skew %></span></div>
<div class="cb-row"><span class="cb-l">Outliers (IQR)</span><span class="cb-v"><%= d.outliers_iqr %></span></div>
</div>
<% }) %>
</div></div><% } %>
<button class="btn btn-primary" style="margin-top:.5rem" onclick="runChecks('<%= uploaded.file_id %>')"><i data-lucide="play" style="width:14px;height:14px"></i> Run kwaliteitschecks</button>
<div id="checkResults"></div>
</div>
<!-- DBT TAB -->
<div class="pane" id="pane-dbt">
<div class="dbt-grid">
<div class="dbt-card">
<h3><i data-lucide="box" style="width:13px;height:13px"></i> dbt Model validator</h3>
<p style="font-size:.68rem;color:var(--text-dim);margin-bottom:.5rem">Valideer SQL modellen op best practices</p>
<form id="dbtForm" onsubmit="validateDbt(event)">
<textarea name="sql" placeholder="SELECT * FROM raw_orders WHERE date > '2024-01-01'&#10;" rows="3" style="width:100%;font-size:.68rem;font-family:monospace;padding:.35rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text)"></textarea>
<div style="display:flex;gap:.35rem;align-items:center;margin:.35rem 0">
<input type="text" name="model_name" placeholder="model naam" style="width:120px;font-size:.68rem;padding:.3rem">
<input type="text" name="columns" placeholder="kolom1, kolom2" style="flex:1;font-size:.68rem;padding:.3rem">
</div>
<button class="btn btn-primary" style="font-size:.7rem;padding:.3rem .6rem">Valideer</button>
</form>
<div id="dbtResult" style="margin-top:.4rem"></div>
</div>
<div class="dbt-card">
<h3><i data-lucide="test-tube" style="width:13px;height:13px"></i> dbt Test runner</h3>
<p style="font-size:.68rem;color:var(--text-dim);margin-bottom:.5rem">Voer data tests uit (unique, not_null, accepted_values)</p>
<% if (profile && profile.columns_profile) { %><form onsubmit="runDbtTests(event)">
<select id="dbtTestCol" style="width:100%;font-size:.68rem;padding:.3rem;margin-bottom:.3rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text)">
<% profile.columns_profile.forEach(function(cp){ %><option value="<%= cp.name %>" data-unique="<%= cp.unique %>" data-rows="<%= profile.rows %>" data-null="<%= cp.null_count %>"><%= cp.name %></option><% }) %>
</select>
<div style="display:flex;gap:.35rem;flex-wrap:wrap;margin-bottom:.35rem">
<label style="font-size:.65rem;color:var(--text-muted)"><input type="checkbox" name="dbtTest" value="unique" checked> unique</label>
<label style="font-size:.65rem;color:var(--text-muted)"><input type="checkbox" name="dbtTest" value="not_null" checked> not_null</label>
</div>
<button class="btn btn-primary" style="font-size:.7rem;padding:.3rem .6rem">Run tests</button>
</form><div id="dbtTestResult" style="margin-top:.4rem"></div><% } %>
</div>
</div>
</div>
<div style="display:flex;gap:.35rem;margin-top:1rem">
<a href="/quality" class="btn btn-secondary" style="font-size:.75rem;padding:.35rem .7rem"><i data-lucide="upload-cloud" style="width:12px;height:12px"></i> Nieuw bestand</a>
</div>
<% } %>
</div>
<script>
lucide.createIcons();
// Tabs
document.querySelectorAll('.dq-tab').forEach(function(b){
b.addEventListener('click',function(){
document.querySelectorAll('.dq-tab').forEach(function(x){x.classList.remove('active')});
document.querySelectorAll('.pane').forEach(function(x){x.classList.remove('active')});
this.classList.add('active');
document.getElementById('pane-'+this.dataset.tab).classList.add('active');
});
});
// Drag n drop
var dz=document.getElementById('dzone'); if(dz){['dragover','dragenter'].forEach(function(e){dz.addEventListener(e,function(ev){ev.preventDefault();dz.classList.add('dragover')})});['dragleave','drop'].forEach(function(e){dz.addEventListener(e,function(){dz.classList.remove('dragover')})});dz.addEventListener('drop',function(e){e.preventDefault();var f=e.dataTransfer.files[0];if(f){var dt=new DataTransfer();dt.items.add(f);document.getElementById('fi').files=dt.files;document.getElementById('uf').submit()}})}
// Quality checks
async function runChecks(fileId) {
document.getElementById('checkResults').innerHTML = '<p style="color:var(--text-muted);font-size:.78rem;padding:.5rem 0">Bezig met analyseren...</p>';
try {
var resp = await fetch('/quality/quality-check/' + fileId, { method: 'POST' });
var data = await resp.json();
var r = data.results || data;
if (!r.checks) { document.getElementById('checkResults').innerHTML = '<div class="alert alert-error">Geen resultaten</div>'; return; }
var g = r.grade || 'C'; var qs = r.quality_score || 0;
var h = '<div class="stats-box" style="margin-bottom:.5rem"><div class="sb-num"><span class="score-'+g+'" style="display:inline"><span class="score-num" style="font-size:1.5rem">'+qs+'%</span></span> ('+g+')</div><div class="sb-lbl">'+(r.summary?r.summary.passed+'/'+r.summary.total+' checks passed':'')+'</div></div>';
h += '<table class="chk-table"><thead><tr><th>Test</th><th>Kolom</th><th>Result</th><th>Waarde</th></tr></thead><tbody>';
(r.checks||[]).forEach(function(c){
h += '<tr><td>'+c.type+'</td><td>'+c.column+'</td><td class="'+(c.passed?'ok':'nok')+'">'+(c.passed?'✓':'✗')+'</td><td>'+c.value+' ('+c.threshold+')</td></tr>';
});
h += '</tbody></table>';
document.getElementById('checkResults').innerHTML = h;
} catch(e) { document.getElementById('checkResults').innerHTML = '<div class="alert alert-error">'+e.message+'</div>'; }
}
// dbt validate
async function validateDbt(e) {
e.preventDefault();
var result = document.getElementById('dbtResult');
result.innerHTML = '<p style="color:var(--text-muted);font-size:.7rem">Valideren...</p>';
var f = e.target;
var name = f.querySelector('[name=model_name]').value || 'model_1';
var cols = (f.querySelector('[name=columns]').value||'').split(',').map(function(s){return s.trim()}).filter(Boolean);
var sql = f.querySelector('[name=sql]').value;
try {
var resp = await fetch('/quality/dbt/validate', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({models:[{name:name,sql:sql,columns:cols}]})});
var data = await resp.json();
var h = '';
(data.issues||[]).forEach(function(i){h+='<div class="dbt-model"><span class="dm-name">'+i.model_name+'</span><span class="dm-status" style="background:'+(i.status==='ok'?'var(--green-bg)':'var(--orange-bg)')+';color:'+(i.status==='ok'?'var(--green)':'var(--orange)')+'">'+(i.status==='ok'?'✓ OK':'⚠ '+i.warnings.length+' issues')+'</span></div>';if(i.warnings.length>0){i.warnings.forEach(function(w){h+='<div style="font-size:.62rem;color:var(--orange);padding:.1rem .5rem">'+w+'</div>'})}});
result.innerHTML = h || '<p style="font-size:.68rem;color:var(--text-dim)">Geen modellen geupload</p>';
} catch(e) { result.innerHTML = '<div class="alert alert-error">'+e.message+'</div>'; }
}
// dbt tests
async function runDbtTests(e) {
e.preventDefault();
var result = document.getElementById('dbtTestResult');
result.innerHTML = '<p style="color:var(--text-muted);font-size:.7rem">Testen...</p>';
var sel = document.getElementById('dbtTestCol');
var opt = sel.options[sel.selectedIndex];
var tests = [];
document.querySelectorAll('[name=dbtTest]:checked').forEach(function(cb){tests.push({type:cb.value,column:opt.value,data:{unique_count:parseInt(opt.dataset.unique)||0,row_count:<%= profile ? profile.rows : 0 %>,null_count:parseInt(opt.dataset.null)||0}})});
try {
var resp = await fetch('/quality/dbt/test', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tests:tests})});
var data = await resp.json();
var h = '<div class="stats-box" style="margin-bottom:.4rem"><div class="sb-num" style="font-size:1rem">'+data.summary.passed+'/'+data.summary.total+' passed</div></div>';
(data.results||[]).forEach(function(r){h+='<div class="dbt-model"><span class="dm-name">'+r.test+' · '+r.column+'</span><span class="dm-status" style="background:'+(r.passed?'var(--green-bg)':'var(--red-bg)')+';color:'+(r.passed?'var(--green)':'var(--red)')+'">'+(r.passed?'✓ OK':'✗ FAIL')+'</span></div><div style="font-size:.62rem;color:var(--text-dim);padding:.1rem .5rem">'+r.message+'</div>'});
result.innerHTML = h;
} catch(e) { result.innerHTML = '<div class="alert alert-error">'+e.message+'</div>'; }
}
</script>
<script src="/js/theme.js"></script></body></html>