Files

370 lines
26 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>