<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fill · Vendors (Clickable Prototype)</title>
<style>
:root {
--bg: #0b1020;
--panel: #ffffff;
--muted: #f5f6f8;
--text: #1f2937;
--text-weak: #6b7280;
--border: #e5e7eb;
--brand: #111827;
--green: #10b981;
--amber: #f59e0b;
--red: #ef4444;
--blue: #3b82f6;
--purple: #8b5cf6;
--shadow: 0 10px 30px rgba(2, 6, 23, 0.08);
--radius: 14px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
color: var(--text); background: #f3f4f6;
}
.app { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
aside {
background: var(--brand); color: white; padding: 24px 18px; position: sticky; top: 0; height: 100vh;
}
.logo { font-weight: 800; font-size: 28px; letter-spacing: 0.4px; margin-bottom: 24px; }
.nav a { display: flex; align-items: center; gap: 10px; color: #cbd5e1; text-decoration: none; padding: 10px 12px; border-radius: 10px; }
.nav a.active, .nav a:hover { background: rgba(255,255,255,0.08); color: #fff; }
header.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
main { padding: 24px; }
.page { background: var(--panel); border-radius: var(--radius); box-shadow: var(--shadow); padding: 22px; }
.kpis { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; margin-bottom: 14px; }
.kpi { background: var(--muted); border: 1px solid var(--border); border-radius: 14px; padding: 16px; cursor: pointer; }
.kpi h3 { margin: 0 0 6px 0; font-size: 13px; font-weight: 600; color: var(--text-weak); }
.kpi .val { display: flex; align-items: baseline; gap: 10px; font-size: 24px; font-weight: 700; }
.kpi small { color: var(--text-weak); }
.toolbar { display:flex; gap: 8px; align-items: center; margin: 10px 0 12px; }
.search { position: relative; flex: 1; }
.search input { width: 100%; padding: 12px 14px 12px 38px; border-radius: 12px; border: 1px solid var(--border); }
.search svg { position:absolute; left: 12px; top: 50%; transform: translateY(-50%); }
.btn { border:1px solid var(--border); background: white; padding: 10px 12px; border-radius: 12px; cursor:pointer; }
.btn[aria-pressed="true"] { outline: 2px solid var(--blue); }
.btn.primary { background: var(--blue); border-color: var(--blue); color: white; }
.table-wrap { overflow:auto; border:1px solid var(--border); border-radius: 12px; background: white; }
table { width:100%; border-collapse: collapse; font-size: 14px; }
thead th { background: #fbfbfc; position: sticky; top: 0; z-index: 1; text-align:left; font-weight: 600; color: #374151; border-bottom:1px solid var(--border); padding: 12px; }
tbody td { border-top:1px solid var(--border); padding: 12px; white-space: nowrap; }
tbody tr:hover { background: #fafafa; }
.chip { display:inline-flex; align-items:center; gap:6px; padding:4px 8px; border-radius:999px; font-size:12px; border:1px solid var(--border); background:#fff; }
.state.Active { background:#ecfdf5; border-color:#bbf7d0; color:#065f46; }
.state.Probation { background:#fffbeb; border-color:#fde68a; color:#92400e; }
.state.Blocked { background:#fef2f2; border-color:#fecaca; color:#991b1b; }
.risk.ok { background:#ecfdf5; color:#065f46; border-color:#bbf7d0; }
.risk.warn { background:#fff7ed; color:#9a3412; border-color:#fed7aa; }
.risk.block { background:#fef2f2; color:#991b1b; border-color:#fecaca; }
.health { display:inline-flex; align-items:center; gap:6px; font-weight:600; }
.dot { width:10px; height:10px; border-radius:999px; display:inline-block; }
.flags { display:flex; gap:6px; }
.flag { font-size: 11px; background:#eef2ff; color:#3730a3; border:1px solid #c7d2fe; padding:3px 8px; border-radius:999px; }
.dropdown { position: relative; }
.menu { position:absolute; right:0; top:110%; background:white; border:1px solid var(--border); border-radius:12px; padding:8px; box-shadow: var(--shadow); display:none; min-width: 220px; }
.menu.open { display:block; }
.menu label { display:flex; align-items:center; gap:10px; padding:8px; border-radius:8px; }
.menu label:hover { background:#f3f4f6; }
.bulkbar { display:none; align-items:center; justify-content:space-between; background:#111827; color:white; padding:10px 14px; border-radius:12px; margin: 0 0 12px; }
.bulkbar button { background:white; color:#111827; border:0; padding:8px 12px; border-radius:8px; }
.side { position: fixed; top:0; right:-520px; width:520px; height:100%; background:white; box-shadow: -10px 0 30px rgba(2,6,23,.2); transition:right .28s ease; display:flex; flex-direction:column; }
.side.open { right:0; }
.side header { padding:18px 18px 12px; border-bottom: 1px solid var(--border); }
.side .content { padding: 16px 18px; overflow:auto; }
.pill { padding:3px 8px; border-radius:999px; font-size:12px; background:#eef2ff; }
.split { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 8px; }
.split small { color: var(--text-weak); }
@media (max-width: 1200px) { .kpis { grid-template-columns: repeat(2, 1fr);} }
@media (max-width: 900px) { .app { grid-template-columns: 1fr; } aside { position: relative; height:auto; } }
</style>
</head>
<body>
<div class="app">
<aside>
<div class="logo">Fill</div>
<nav class="nav" aria-label="Main">
<a href="#" class="active">Vendors</a>
<a href="#">Projects</a>
<a href="#">Consultants</a>
<a href="#">Candidates</a>
<a href="#">Analytics</a>
<a href="#">Settings</a>
</nav>
<div style="margin-top:24px; font-size:12px; color:#9ca3af">This is a clickable prototype. Data is synthetic.</div>
</aside>
<main>
<div class="page">
<header class="top">
<h1 style="margin:0; font-size: 24px;">Vendors</h1>
<div class="split" id="vendorSplit"></div>
</header>
<section class="kpis" id="kpis"></section>
<div class="toolbar" role="region" aria-label="Filters and actions">
<div class="search" style="max-width: 500px;">
<svg width="18" height="18" fill="none" stroke="#6b7280" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input id="nlq" placeholder="Ask Fill… e.g. ‘Sweden consultancies with expiring docs’" aria-label="Natural language search" />
</div>
<button class="btn" id="structuredBtn" title="Structured filters">Filters</button>
<div class="dropdown">
<button class="btn" id="columnsBtn" aria-haspopup="true" aria-expanded="false">Columns</button>
<div class="menu" id="columnsMenu" role="menu" aria-label="Columns"></div>
</div>
<div class="dropdown">
<button class="btn" id="viewsBtn">Views</button>
<div class="menu" id="viewsMenu">
<button class="btn" id="saveView" style="width:100%;">Save current view</button>
</div>
</div>
<button class="btn primary" id="newVendor">New vendor</button>
</div>
<div class="bulkbar" id="bulkbar">
<div><strong id="selCount">0</strong> selected</div>
<div style="display:flex; gap:8px;">
<button id="bulkState">Change state</button>
<button id="bulkDocs">Request docs</button>
<button id="bulkExport">Export CSV</button>
<button id="bulkClear" title="Clear selection">Clear</button>
</div>
</div>
<div class="table-wrap">
<table id="grid" aria-label="Vendors table">
<thead><tr id="thead"></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
</main>
</div>
<!-- Side sheet (vendor profile) -->
<aside class="side" id="side">
<header>
<div style="display:flex; align-items:center; justify-content: space-between; gap:12px;">
<div>
<div id="sideName" style="font-size:18px; font-weight:700;">Vendor</div>
<div style="display:flex; gap:8px; margin-top:6px; align-items:center;">
<span id="sideState" class="chip state Active">Active</span>
<span id="sideHealth" class="chip">Health: 84</span>
<span id="sideSpend" class="chip">Spend YTD: 0</span>
<span id="sideTasks" class="chip">Open actions: 0</span>
</div>
</div>
<button class="btn" onclick="closeSide()">Close</button>
</div>
</header>
<div class="content" id="sideContent">
<!-- filled dynamically -->
</div>
</aside>
<script>
(function(){
// --- Data ---------------------------------------------------------------
const vendors = [
{id:1, name:'Knowit', state:'Active', health:86, assignments:{active:23,total:68}, spend:23000, contracted:23000, invoiced:23000, risk:'ok', geo:'Sweden', type:'Broker', flags:['Preferred'], created:'2023-04-13', expiryDays:180},
{id:2, name:'Sigma', state:'Active', health:77, assignments:{active:15,total:17}, spend:3200, contracted:2000, invoiced:3200, risk:'ok', geo:'Sweden', type:'Consultancy', flags:['Preferred'], created:'2023-04-15', expiryDays:45},
{id:3, name:'Tingent', state:'Active', health:81, assignments:{active:1,total:1}, spend:15000, contracted:15000, invoiced:15000, risk:'ok', geo:'Sweden', type:'Consultancy', flags:[''], created:'2023-04-12', expiryDays:120},
{id:4, name:'HiQ', state:'Active', health:72, assignments:{active:15,total:16}, spend:6000, contracted:6000, invoiced:6000, risk:'ok', geo:'Sweden', type:'Broker', flags:[''], created:'2023-04-16', expiryDays:14},
{id:5, name:'The Stellar collective', state:'Active', health:74, assignments:{active:3,total:9}, spend:15000, contracted:15000, invoiced:15000, risk:'ok', geo:'Sweden, Norway +3', type:'Consultancy', flags:[''], created:'2023-04-16', expiryDays:75},
{id:6, name:'Curamando', state:'Active', health:69, assignments:{active:23,total:39}, spend:24000, contracted:24000, invoiced:24000, risk:'ok', geo:'Sweden', type:'Broker', flags:['Marketplace'], created:'2023-04-17', expiryDays:200},
{id:7, name:'Wise professionals', state:'Active', health:65, assignments:{active:3,total:6}, spend:500, contracted:1000, invoiced:500, risk:'ok', geo:'Sweden', type:'Staffing agency', flags:[''], created:'2023-04-14', expiryDays:365},
{id:8, name:'Ework', state:'Probation', health:58, assignments:{active:0,total:0}, spend:0, contracted:0, invoiced:0, risk:'warn', geo:'Sweden, Poland', type:'Consultancy', flags:['Marketplace'], created:'2023-04-18', expiryDays:25},
{id:9, name:'Newr', state:'Blocked', health:40, assignments:{active:15,total:69}, spend:6000, contracted:6000, invoiced:6000, risk:'block', geo:'Sweden', type:'Freelancer', flags:[''], created:'2023-04-11', expiryDays:5},
];
// Column configuration (toggle-able)
const columns = [
{key:'sel', label:'', width:28, render: r => `<input type="checkbox" class="rowcheck" data-id="${r.id}">`},
{key:'name', label:'Vendor', sortable:true, render:r=>`<button class="btn" style="padding:6px 10px" data-open="${r.id}">${escapeHtml(r.name)}</button>`},
{key:'state', label:'State', sortable:true, render:r=>`<span class="chip state ${r.state}">${r.state}</span>`},
{key:'health', label:'Health', sortable:true, render:r=>{
const color = r.health>=75? '#10b981' : r.health>=60? '#f59e0b' : '#ef4444';
return `<span class="health"><span class="dot" style="background:${color}"></span>${r.health}</span>`;
}},
{key:'assign', label:'Assignments', sortable:true, render:r=>`${r.assignments.active} | ${r.assignments.total}`},
{key:'spend', label:'Spend (k SEK)', sortable:true, render:r=> formatK(r.spend)},
{key:'variance', label:'Variance %', sortable:true, render:r=> variancePill(r)},
{key:'risk', label:'Risk', sortable:true, render:r=> riskPill(r)},
{key:'geo', label:'Geography', sortable:true, render:r=> escapeHtml(r.geo)},
{key:'type', label:'Type', sortable:true, render:r=> escapeHtml(r.type)},
{key:'flags', label:'Flags', sortable:false, render:r=> flagsCell(r.flags)}
];
const defaultVisible = ['sel','name','state','health','assign','spend','variance','risk','geo','type','flags'];
// --- State --------------------------------------------------------------
let state = {
sortKey: 'name', sortDir: 'asc',
visible: JSON.parse(localStorage.getItem('fill_vendor_cols')||'null') || defaultVisible,
filter: null, // function(row)=>bool
selection: new Set(),
};
// --- Elements -----------------------------------------------------------
const thead = document.getElementById('thead');
const tbody = document.getElementById('tbody');
const menu = document.getElementById('columnsMenu');
const columnsBtn = document.getElementById('columnsBtn');
const bulkbar = document.getElementById('bulkbar');
const selCount = document.getElementById('selCount');
const nlq = document.getElementById('nlq');
// --- Renderers ----------------------------------------------------------
function renderHeader(){
thead.innerHTML = '';
visibleCols().forEach(col => {
const th = document.createElement('th'); th.textContent = col.label; th.style.minWidth = (col.width||120)+ 'px';
if (col.key && col.sortable){
th.style.cursor = 'pointer'; th.title = 'Sort';
th.addEventListener('click', () => sortBy(col.key));
}
thead.appendChild(th);
});
}
function renderRows(){
const data = vendors.filter(r => state.filter? state.filter(r) : true)
.sort((a,b)=> compare(a,b,state.sortKey,state.sortDir));
tbody.innerHTML = data.map(r => `<tr data-id="${r.id}">` + visibleCols().map(col => `<td>${col.render(r)}</td>`).join('') + `</tr>`).join('');
// Wire row checkbox events
document.querySelectorAll('.rowcheck').forEach(cb=>{
cb.addEventListener('change', () => { cb.checked? state.selection.add(+cb.dataset.id) : state.selection.delete(+cb.dataset.id); updateBulkbar(); });
});
// Wire open profile buttons
document.querySelectorAll('[data-open]').forEach(btn=> btn.addEventListener('click', ()=> openSide(+btn.dataset.open)));
// Wire risk pill click -> open compliance section
document.querySelectorAll('[data-compliance]').forEach(el=> el.addEventListener('click', ()=> openSide(+el.dataset.compliance, 'risk')));
}
function renderColumnsMenu(){
menu.innerHTML = '';
columns.forEach(col => {
if(!col.key) return;
const id = 'col_' + col.key;
const row = document.createElement('label');
row.innerHTML = `<input type="checkbox" id="${id}"> <span>${col.label||col.key}</span>`;
const input = row.querySelector('input');
input.checked = state.visible.includes(col.key);
input.addEventListener('change', () => {
if (input.checked) state.visible.push(col.key); else state.visible = state.visible.filter(k=>k!==col.key);
persistCols(); renderHeader(); renderRows(); updateColumnsButton();
});
menu.appendChild(row);
});
}
function renderKPIs(){
const wrap = document.getElementById('kpis');
const goodStanding = vendors.filter(v=> v.health>=75 && v.risk==='ok').length;
const atRisk = vendors.filter(v=> v.risk!=='ok' || v.state!=='Active').length;
const spendYTD = vendors.reduce((s,v)=> s+v.invoiced, 0);
const expiring = vendors.filter(v=> v.expiryDays <= 60).length;
wrap.innerHTML = '' +
kpiCard('Vendors in good standing', goodStanding, '', ()=> setFilter(r=> r.health>=75 && r.risk==='ok')) +
kpiCard('Vendors at risk', atRisk, '', ()=> setFilter(r=> r.risk!=='ok' || r.state!=='Active')) +
kpiCard('Spend YTD', formatK(spendYTD) + ' SEK', 'invoices', ()=> openSpendView()) +
kpiCard('Contracts expiring < 60d', expiring, '', ()=> setFilter(r=> r.expiryDays<=60));
renderVendorSplit();
}
function renderVendorSplit(){
const out = document.getElementById('vendorSplit');
const byType = {};
vendors.forEach(v=> { byType[v.type] = (byType[v.type]||0)+1; })
out.innerHTML = Object.entries(byType).map(([t,c])=>`<div class="kpi" style="padding:8px;" role="button" tabindex="0" onclick="(${setFilter})(r=>r.type==='${t}')"><div class="val" style="font-size:18px;">${c}</div><small>${t}</small></div>`).join('');
}
function kpiCard(title, value, sub, onClick){
const id = 'kpi_' + Math.random().toString(36).slice(2);
setTimeout(()=> {
const el = document.getElementById(id); if (el) el.addEventListener('click', onClick);
}, 0);
return `<div class="kpi" id="${id}" role="button" tabindex="0"><h3>${title}</h3><div class="val">${value}</div>${sub?`<small>${sub}</small>`:''}</div>`;
}
// --- Helpers ------------------------------------------------------------
function visibleCols(){ return columns.filter(c=> !c.key || state.visible.includes(c.key)); }
function persistCols(){ localStorage.setItem('fill_vendor_cols', JSON.stringify(state.visible)); }
function sortBy(key){ state.sortKey=key; state.sortDir = state.sortDir==='asc'?'desc':'asc'; renderHeader(); renderRows(); }
function compare(a,b,key,dir){
let va; switch(key){
case 'assign': va = a.assignments.active; break;
case 'spend': va = a.spend; break;
case 'variance': va = (a.invoiced - a.contracted) / (a.contracted||1); break;
default: va = a[key];
}
let vb; switch(key){
case 'assign': vb = b.assignments.active; break;
case 'spend': vb = b.spend; break;
case 'variance': vb = (b.invoiced - b.contracted) / (b.contracted||1); break;
default: vb = b[key];
}
const s = (va>vb?1:va<vb?-1:0); return dir==='asc'? s : -s;
}
function riskPill(r){
const map = {ok:'ok', warn:'warn', block:'block'}; const label = r.risk==='ok'?'OK': r.risk==='warn'? 'Expiring' : 'Missing';
return `<button class="chip risk ${map[r.risk]}" data-compliance="${r.id}" title="Open compliance">${label}</button>`;
}
function flagsCell(flags){
return `<div class="flags">${flags.filter(Boolean).map(f=>`<span class="flag">${escapeHtml(f)}</span>`).join('')}</div>`;
}
function variancePill(r){
const diff = (r.invoiced - r.contracted) / (r.contracted||1);
const pct = Math.round(diff*100);
const color = diff>0? 'var(--red)' : diff<0? 'var(--green)' : '#6b7280';
return `<span class="pill" style="background:#f3f4f6; color:${color};">${pct>0?'+':''}${pct}%</span>`;
}
function formatK(n){ return Math.round(n/1)/1 >= 1000 ? (Math.round(n/1000)) + ' k' : Math.round(n) + '' }
function escapeHtml(s){ return (s+"").replace(/[&<>]/g, c=> ({'&':'&','<':'<','>':'>'}[c])); }
function updateBulkbar(){
const count = state.selection.size; selCount.textContent = count;
bulkbar.style.display = count? 'flex':'none';
}
function setFilter(fn){ state.filter = fn; state.selection.clear(); updateBulkbar(); renderRows(); }
// --- Interactions -------------------------------------------------------
columnsBtn.addEventListener('click', () => toggleMenu('columnsMenu'));
document.getElementById('viewsBtn').addEventListener('click', () => toggleMenu('viewsMenu'));
function toggleMenu(id){
document.querySelectorAll('.menu').forEach(m=> m.classList.remove('open'));
const el = document.getElementById(id); el.classList.toggle('open');
document.getElementById('columnsBtn').setAttribute('aria-expanded', String(document.getElementById('columnsMenu').classList.contains('open')));
}
document.addEventListener('click', (e)=>{
if(!e.target.closest('.dropdown')){ document.querySelectorAll('.menu').forEach(m=> m.classList.remove('open')); }
});
document.getElementById('bulkClear').addEventListener('click', ()=>{ state.selection.clear(); document.querySelectorAll('.rowcheck').forEach(c=>c.checked=false); updateBulkbar(); });
document.getElementById('bulkDocs').addEventListener('click', ()=> alert('Requested missing/expiring documents from selected vendors.'));
document.getElementById('bulkState').addEventListener('click', ()=> alert('State change wizard would open (Active/Probation/Blocked).'));
document.getElementById('bulkExport').addEventListener('click', ()=> exportCSV());
nlq.addEventListener('keydown', (e)=>{ if(e.key==='Enter') runNLQ(nlq.value); });
function runNLQ(q){
q = (q||'').toLowerCase();
if(!q){ state.filter=null; renderRows(); return; }
const geo = ['sweden','norway','poland'];
const types = ['broker','consultancy','staffing agency','freelancer'];
setFilter(r=>{
const s = q;
if(s.includes('risk')){ if(r.risk==='ok') return false; }
if(s.includes('expir')){ if(r.expiryDays>60) return false; }
if(s.includes('active')){ if(r.state.toLowerCase()!=='active') return false; }
if(s.includes('probation')){ if(r.state.toLowerCase()!=='probation') return false; }
if(s.includes('blocked')){ if(r.state.toLowerCase()!=='blocked') return false; }
for(const g of geo){ if(s.includes(g) && !r.geo.toLowerCase().includes(g)) return false; }
for(const t of types){ if(s.includes(t) && r.type.toLowerCase()!==t) return false; }
if(s.includes('preferred')){ if(!r.flags.map(x=>x.toLowerCase()).includes('preferred')) return false; }
return [r.name,r.type,r.geo,(r.flags||[]).join(' ')].join(' ').toLowerCase().includes(q.replace(/\s+/g,' ').trim().split(' ')[0]||'');
});
}
function openSpendView(){ alert('This would open the spend dashboard with trend and top vendors.'); }
// Side sheet
function openSide(id, section){
const v = vendors.find(x=>x.id===id); if(!v) return;
document.getElementById('sideName').textContent = v.name;
const st = document.getElementById('sideState'); st.textContent = v.state; st.className = 'chip state ' + v.state;
document.getElementById('sideHealth').textContent = 'Health: ' + v.health;
document.getElementById('sideSpend').textContent = 'Spend YTD: ' + v.invoiced + ' SEK';
document.getElementById('sideTasks').textContent = 'Open actions: ' + (v.risk!=='ok'?1:0);
const html = `
<h3>Overview</h3>
<p><strong>Type:</strong> ${escapeHtml(v.type)} · <strong>Geography:</strong> ${escapeHtml(v.geo)}</p>
<div style="display:flex; gap:8px; margin:8px 0;">${flagsCell(v.flags)}</div>
<h3>Performance</h3>
<ul>
<li>Active assignments: <strong>${v.assignments.active}</strong> · Total: ${v.assignments.total}</li>
<li>Health drivers: Compliance 40 · Risk 30 · Invoice accuracy 20 · Dispute rate 10</li>
</ul>
<h3 id="risk">Compliance & Risk</h3>
<p>Risk status: ${riskPill(v)} · Contract expiry in <strong>${v.expiryDays}</strong> days.</p>
<h3>Financials</h3>
<ul>
<li>Contracted: ${v.contracted} SEK</li>
<li>Invoiced: ${v.invoiced} SEK</li>
<li>Variance: ${((v.invoiced - v.contracted)/(v.contracted||1) * 100).toFixed(1)}%</li>
</ul>
<h3>Docs & Notes</h3>
<p>Drag & drop a file here in the real app to auto-extract clauses. (Prototype stub)</p>
`;
document.getElementById('sideContent').innerHTML = html;
const side = document.getElementById('side'); side.classList.add('open');
if(section==='risk'){
setTimeout(()=>{
const el = document.querySelector('#risk'); if(el) el.scrollIntoView({behavior:'smooth'});
}, 80);
}
}
window.openSide = openSide; window.closeSide = function(){ document.getElementById('side').classList.remove('open'); };
// Export CSV
function exportCSV(){
const rows = vendors.filter(r=> state.selection.has(r.id));
if(rows.length===0){ alert('Select rows first.'); return; }
const fields = ['name','state','health','assign_active','assign_total','spend','contracted','invoiced','risk','geo','type','flags'];
const lines = [fields.join(',')].concat(rows.map(r=> [
r.name, r.state, r.health, r.assignments.active, r.assignments.total, r.spend, r.contracted, r.invoiced, r.risk, r.geo, r.type, (r.flags||[]).join('|')
].map(v=>`"${String(v).replace(/"/g,'\"')}"`).join(',')));
const blob = new Blob([lines.join('\n')], {type:'text/csv'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'vendors_export.csv'; a.click();
}
// Column menu button state
function updateColumnsButton(){
columnsBtn.textContent = 'Columns (' + state.visible.filter(k=>k!=='sel').length + ')';
}
// Init
renderHeader(); renderRows(); renderColumnsMenu(); renderKPIs(); updateColumnsButton();
})();
</script>
</body>
</html>