Initial commit — Mek-Tech AI Consultancy Framework v2.0

This commit is contained in:
mo
2026-07-12 21:54:22 +00:00
commit 18921c021b
180 changed files with 28518 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pagina niet gevonden — Mek-Tech</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/neon.css">
<link rel="stylesheet" href="/css/dynamic.css">
<style>
.e404{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;text-align:center;padding:2rem}
.e404 h1{font-size:4rem;font-weight:800;color:var(--accent);margin:0;line-height:1}
.e404 p{color:var(--text-muted);margin:1rem 0 1.5rem;max-width:360px}
</style>
</head>
<body>
<div class="e404">
<h1>404</h1>
<p>Deze pagina bestaat niet of is verplaatst.</p>
<a href="/" class="btn btn-accent">Terug naar dashboard</a>
</div>
<script src="/js/theme.js"></script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="nl">
<head><%- include('partials/head', { pageTitle: 'AI Providers' }) %></head>
<body style="margin:0;font-family:'Inter',sans-serif;background:var(--bg);color:var(--text)">
<%- include('partials/navbar') %>
<div class="app-layout" style="display:flex;min-height:100vh;padding-top:var(--hdr-h)">
<%- include('partials/sidebar') %>
<main class="main-area" style="flex:1;margin-left:var(--sb-w);padding:1.5rem 2rem 3rem;max-width:1200px">
<div class="page-hdr fade-in-up" style="margin-bottom:1.5rem">
<h1 style="font-size:1.5rem;font-weight:800;letter-spacing:-.4px;margin:0">AI Providers</h1>
<p style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem">Configureer OpenRouter, OpenAI, Anthropic, DeepSeek, Groq, Ollama en custom providers</p>
</div>
<% if (saved) { %><div class="alert alert-success" style="background:var(--green-bg);border:1px solid var(--green);color:var(--green);padding:.5rem .75rem;border-radius:8px;margin-bottom:1rem;font-size:.78rem">Opgeslagen — config gesynchroniseerd naar alle Python services</div><% } %>
<% if (tested) { %><div class="alert alert-success" style="background:var(--green-bg);border:1px solid var(--green);color:var(--green);padding:.5rem .75rem;border-radius:8px;margin-bottom:1rem;font-size:.78rem">Test OK: <%= tested %></div><% } %>
<% if (error) { %><div class="alert alert-error" style="background:var(--red-bg);border:1px solid var(--red);color:var(--red);padding:.5rem .75rem;border-radius:8px;margin-bottom:1rem;font-size:.78rem"><%= error %></div><% } %>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem;margin-bottom:2rem">
<% providers.forEach(function(p) { %>
<div class="provider-card<%= p.is_default ? ' default' : '' %> fade-in-up">
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:.75rem">
<div>
<div style="font-weight:700;font-size:.95rem"><%= p.name %></div>
<div style="font-size:.68rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.4px;margin-top:.15rem"><%= p.provider_type %></div>
</div>
<span class="status-dot<%= p.is_active ? '' : ' offline' %>"></span>
</div>
<div style="font-size:.72rem;color:var(--text-muted);margin-bottom:.35rem">Model: <code style="color:var(--accent)"><%= p.default_model || '—' %></code></div>
<div style="font-size:.68rem;color:var(--text-dim);margin-bottom:1rem;word-break:break-all"><%= p.base_url %></div>
<div style="display:flex;gap:.35rem;flex-wrap:wrap">
<% if (p.is_default) { %><span style="font-size:.62rem;padding:.15rem .45rem;border-radius:4px;background:rgba(0,212,255,.12);color:var(--accent);border:1px solid rgba(0,212,255,.25)">Default</span><% } %>
<form action="/ai/test/<%= p.id %>" method="POST" style="display:inline"><button type="submit" class="btn btn-small" style="padding:.25rem .55rem;font-size:.65rem;border-radius:6px;border:1px solid var(--border);background:var(--bg-card);color:var(--text);cursor:pointer">Test</button></form>
<form action="/ai/delete/<%= p.id %>" method="POST" style="display:inline" onsubmit="return confirm('Provider verwijderen?')"><button type="submit" class="btn btn-small" style="padding:.25rem .55rem;font-size:.65rem;border-radius:6px;border:1px solid var(--red);background:transparent;color:var(--red);cursor:pointer">Verwijder</button></form>
</div>
</div>
<% }); %>
</div>
<div class="card neon-card fade-in-up" style="background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:1.5rem;position:relative;overflow:hidden">
<div class="neon-border-top" style="position:absolute;top:0;left:0;right:0;height:3px"></div>
<h3 style="font-size:.85rem;font-weight:700;margin-bottom:1rem">Nieuwe provider toevoegen</h3>
<form action="/ai/save" method="POST">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
<div><label style="font-size:.68rem;color:var(--text-muted);display:block;margin-bottom:.25rem">Naam</label><input name="name" required placeholder="Mijn OpenRouter" style="width:100%;padding:.45rem .6rem;background:var(--bg);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:.78rem"></div>
<div><label style="font-size:.68rem;color:var(--text-muted);display:block;margin-bottom:.25rem">Provider type</label>
<select name="provider_type" id="providerType" onchange="updatePreset()" style="width:100%;padding:.45rem .6rem;background:var(--bg);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:.78rem">
<% Object.keys(presets).forEach(function(k) { %><option value="<%= k %>"><%= presets[k].label %></option><% }); %>
</select>
</div>
<div style="grid-column:1/-1"><label style="font-size:.68rem;color:var(--text-muted);display:block;margin-bottom:.25rem">API Key</label><input name="api_key" type="password" placeholder="sk-..." style="width:100%;padding:.45rem .6rem;background:var(--bg);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:.78rem"></div>
<div><label style="font-size:.68rem;color:var(--text-muted);display:block;margin-bottom:.25rem">Base URL</label><input name="base_url" id="baseUrl" placeholder="https://openrouter.ai/api/v1" style="width:100%;padding:.45rem .6rem;background:var(--bg);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:.78rem"></div>
<div><label style="font-size:.68rem;color:var(--text-muted);display:block;margin-bottom:.25rem">Default model</label><input name="default_model" id="defaultModel" placeholder="qwen/qwen3-coder:free" style="width:100%;padding:.45rem .6rem;background:var(--bg);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:.78rem"></div>
</div>
<div style="display:flex;gap:1rem;margin-top:1rem;align-items:center">
<label style="font-size:.72rem;display:flex;align-items:center;gap:.35rem;color:var(--text-muted)"><input type="checkbox" name="is_active" value="1" checked> Actief</label>
<label style="font-size:.72rem;display:flex;align-items:center;gap:.35rem;color:var(--text-muted)"><input type="checkbox" name="is_default" value="1"> Standaard provider</label>
<button type="submit" class="btn btn-primary" style="margin-left:auto;padding:.45rem 1.2rem;border:none;border-radius:8px;color:#fff;font-weight:600;cursor:pointer">Opslaan</button>
</div>
</form>
</div>
</main>
</div>
<script>
const presets = <%- JSON.stringify(presets) %>;
function updatePreset() {
const t = document.getElementById('providerType').value;
const p = presets[t];
if (p) {
document.getElementById('baseUrl').value = p.base_url || '';
document.getElementById('defaultModel').value = (p.models && p.models[0]) || '';
}
}
updatePreset();
</script>
<%- include('partials/scripts') %>
</body>
</html>
+942
View File
@@ -0,0 +1,942 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= diagram ? diagram.name : 'Nieuw Diagram' %> — Mek-Tech Architectuur</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/neon.css">
<link rel="stylesheet" href="/css/dynamic.css">
<link rel="stylesheet" href="/css/arch-editor.css">
<link rel="icon" href="/img/favicon.svg" type="image/svg+xml">
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body class="arch-editor">
<header class="arch-topbar">
<a href="/architecture" class="brand">
<img src="/img/logo.svg" alt="Mek-Tech">
<span>Architectuur Designer<em>v2</em></span>
</a>
<div class="arch-topbar-actions">
<a href="/architecture"><i data-lucide="layers" style="width:14px;height:14px"></i> Diagrammen</a>
<a href="/"><i data-lucide="layout-dashboard" style="width:14px;height:14px"></i> Dashboard</a>
<a href="/auth/logout" style="color:#ff6b8a"><i data-lucide="log-out" style="width:14px;height:14px"></i></a>
</div>
</header>
<% if (typeof rack !== 'undefined' && rack) { %>
<div class="rack-context-bar" style="background:linear-gradient(90deg,rgba(0,212,255,.12),rgba(168,85,247,.08));border-bottom:1px solid rgba(0,212,255,.25);padding:.55rem 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap">
<div style="font-size:.75rem;color:#fff">
<strong style="color:var(--accent)">Rack:</strong> <%= rack.name %> · <%= rack.client_name %>
<span style="color:rgba(255,255,255,.7);margin-left:.5rem"><%= (typeof rackDevices !== 'undefined' ? rackDevices.length : 0) %> devices gekoppeld</span>
</div>
<div style="display:flex;gap:.35rem;flex-wrap:wrap">
<a href="/racks/<%= rack.id %>" class="btn btn-sec" style="font-size:.65rem;padding:.3rem .6rem">← Rack Designer</a>
<a href="/racks/<%= rack.id %>/report" class="btn btn-sec" style="font-size:.65rem;padding:.3rem .6rem">Gecombineerd Rapport</a>
</div>
</div>
<% } %>
<div class="editor-layout">
<div class="editor-sidebar">
<div class="palette-header">
<h3>Componenten</h3>
<div class="palette-search"><input id="paletteSearch" placeholder="Zoek component..." oninput="filterPalette(this.value)"></div>
<div class="palette-cats" id="paletteCats">
<button class="pc-btn active" data-cat="" onclick="filterPaletteCat('')">Alles</button>
<button class="pc-btn" data-cat="Data Bronnen" onclick="filterPaletteCat(this.dataset.cat)">Bron</button>
<button class="pc-btn" data-cat="Ingest" onclick="filterPaletteCat(this.dataset.cat)">Pipeline</button>
<button class="pc-btn" data-cat="Opslag" onclick="filterPaletteCat(this.dataset.cat)">Storage</button>
<button class="pc-btn" data-cat="Microservices" onclick="filterPaletteCat(this.dataset.cat)">Apps</button>
<button class="pc-btn" data-cat="API" onclick="filterPaletteCat(this.dataset.cat)">API</button>
<button class="pc-btn" data-cat="Databases" onclick="filterPaletteCat(this.dataset.cat)">DB</button>
<button class="pc-btn" data-cat="CI/CD" onclick="filterPaletteCat(this.dataset.cat)">DevOps</button>
<button class="pc-btn" data-cat="Observability" onclick="filterPaletteCat(this.dataset.cat)">Observe</button>
</div>
<% if (typeof rack !== 'undefined' && rack) { %>
<div class="rack-flow-filters" style="margin-top:.55rem;padding-top:.55rem;border-top:1px solid var(--border)">
<div style="font-size:.58rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:.35rem;font-weight:700">Flow filters</div>
<div class="palette-cats" style="margin-bottom:.4rem">
<button type="button" class="pc-btn layer-btn active" data-layer="" onclick="filterPaletteLayer('')">Alle lagen</button>
<button type="button" class="pc-btn layer-btn" data-layer="data" onclick="filterPaletteLayer('data')">Data</button>
<button type="button" class="pc-btn layer-btn" data-layer="app" onclick="filterPaletteLayer('app')">App</button>
<button type="button" class="pc-btn layer-btn" data-layer="integration" onclick="filterPaletteLayer('integration')">Integratie</button>
<button type="button" class="pc-btn layer-btn" data-layer="infra" onclick="filterPaletteLayer('infra')">Infra</button>
</div>
<select id="flowProtocolDefault" style="width:100%;padding:.3rem .45rem;font-size:.68rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);margin-bottom:.4rem" onchange="setDefaultFlowProtocol(this.value)">
<option value="">Standaard protocol: geen</option>
<option value="sync">Sync (REST/RPC)</option>
<option value="async">Async (message)</option>
<option value="batch">Batch / ETL</option>
<option value="stream">Stream / realtime</option>
<option value="event">Event-driven</option>
</select>
<button type="button" class="btn btn-sec" style="width:100%;font-size:.65rem;padding:.35rem" onclick="importRackDevices()">
<i data-lucide="download" style="width:12px;height:12px"></i> Import rack devices (<%= typeof rackDevices !== 'undefined' ? rackDevices.length : 0 %>)
</button>
</div>
<% } %>
</div>
<div class="palette-scroll" id="paletteScroll">
<div class="cat-label">Data Bronnen</div>
<div class="palette-item" draggable="true" data-type="source-db"><span class="p-icon" style="background:#d2992233;color:#d29922">DB</span> Database</div>
<div class="palette-item" draggable="true" data-type="source-api"><span class="p-icon" style="background:#d2992233;color:#d29922">API</span> API Service</div>
<div class="palette-item" draggable="true" data-type="source-iot"><span class="p-icon" style="background:#d2992233;color:#d29922">IoT</span> IoT Sensor</div>
<div class="palette-item" draggable="true" data-type="source-stream"><span class="p-icon" style="background:#d2992233;color:#d29922">ST</span> Streaming</div>
<div class="palette-item" draggable="true" data-type="source-sap"><span class="p-icon" style="background:#0070d133;color:#0070d1">SAP</span> SAP HANA</div>
<div class="palette-item" draggable="true" data-type="source-oracle"><span class="p-icon" style="background:#f8000033;color:#f80000">OR</span> Oracle DB</div>
<div class="cat-label">Ingest & Pipeline</div>
<div class="palette-item" draggable="true" data-type="ingest-kafka"><span class="p-icon" style="background:#58a6ff33;color:#00d4ff">K</span> Kafka</div>
<div class="palette-item" draggable="true" data-type="ingest-airbyte"><span class="p-icon" style="background:#58a6ff33;color:#00d4ff">AB</span> Airbyte</div>
<div class="palette-item" draggable="true" data-type="ingest-flink"><span class="p-icon" style="background:#58a6ff33;color:#00d4ff">SF</span> Spark/Flink</div>
<div class="palette-item" draggable="true" data-type="ingest-cdc"><span class="p-icon" style="background:#58a6ff33;color:#00d4ff">CDC</span> Debezium/CDC</div>
<div class="palette-item" draggable="true" data-type="ingest-nifi"><span class="p-icon" style="background:#58a6ff33;color:#00d4ff">NF</span> NiFi</div>
<div class="palette-item" draggable="true" data-type="ingest-dms"><span class="p-icon" style="background:#58a6ff33;color:#00d4ff">DMS</span> AWS DMS</div>
<div class="cat-label">Opslag & Storage</div>
<div class="palette-item" draggable="true" data-type="storage-minio"><span class="p-icon" style="background:#3fb95033;color:#3fb950">M</span> MinIO / S3</div>
<div class="palette-item" draggable="true" data-type="storage-iceberg"><span class="p-icon" style="background:#3fb95033;color:#3fb950">IC</span> Iceberg</div>
<div class="palette-item" draggable="true" data-type="storage-warehouse"><span class="p-icon" style="background:#3fb95033;color:#3fb950">DW</span> Data Warehouse</div>
<div class="palette-item" draggable="true" data-type="storage-lake"><span class="p-icon" style="background:#3fb95033;color:#3fb950">LH</span> Data Lakehouse</div>
<div class="palette-item" draggable="true" data-type="storage-nimble"><span class="p-icon" style="background:#ff7b0033;color:#ff7b00">HN</span> HPE Nimble</div>
<div class="palette-item" draggable="true" data-type="storage-3par"><span class="p-icon" style="background:#ff7b0033;color:#ff7b00">3P</span> HPE 3PAR</div>
<div class="palette-item" draggable="true" data-type="storage-powerstore"><span class="p-icon" style="background:#0076a833;color:#0076a8">PS</span> Dell PowerStore</div>
<div class="palette-item" draggable="true" data-type="storage-powerscale"><span class="p-icon" style="background:#0076a833;color:#0076a8">PSC</span> Dell PowerScale</div>
<div class="palette-item" draggable="true" data-type="storage-netapp"><span class="p-icon" style="background:#00a1df33;color:#00a1df">NA</span> NetApp AFF/FAS</div>
<div class="palette-item" draggable="true" data-type="storage-pure"><span class="p-icon" style="background:#4a90d933;color:#4a90d9">PU</span> Pure Storage</div>
<div class="cat-label">Compute / Servers</div>
<div class="palette-item" draggable="true" data-type="hw-hpe-dl"><span class="p-icon" style="background:#ff7b0033;color:#ff7b00">HP</span> HPE ProLiant DL</div>
<div class="palette-item" draggable="true" data-type="hw-hpe-ml"><span class="p-icon" style="background:#ff7b0033;color:#ff7b00">HPM</span> HPE ProLiant ML</div>
<div class="palette-item" draggable="true" data-type="hw-hpe-synergy"><span class="p-icon" style="background:#ff7b0033;color:#ff7b00">SYN</span> HPE Synergy</div>
<div class="palette-item" draggable="true" data-type="hw-dell-pe"><span class="p-icon" style="background:#0076a833;color:#0076a8">DE</span> Dell PowerEdge</div>
<div class="palette-item" draggable="true" data-type="hw-dell-mx"><span class="p-icon" style="background:#0076a833;color:#0076a8">MX</span> Dell PowerEdge MX</div>
<div class="palette-item" draggable="true" data-type="hw-lenovo-ts"><span class="p-icon" style="background:#e41f3333;color:#e41f33">LE</span> Lenovo ThinkSystem</div>
<div class="palette-item" draggable="true" data-type="hw-lenovo-ta"><span class="p-icon" style="background:#e41f3333;color:#e41f33">TA</span> Lenovo ThinkAgile</div>
<div class="palette-item" draggable="true" data-type="hw-supermicro"><span class="p-icon" style="background:#e6a81733;color:#e6a817">SM</span> Supermicro SYS</div>
<div class="palette-item" draggable="true" data-type="hw-cisco-ucs"><span class="p-icon" style="background:#00529a33;color:#00529a">UCS</span> Cisco UCS</div>
<div class="palette-item" draggable="true" data-type="compute-vmware"><span class="p-icon" style="background:#a855f733;color:#a855f7">VM</span> VMware vSphere</div>
<div class="cat-label">Netwerk</div>
<div class="palette-item" draggable="true" data-type="net-catalyst"><span class="p-icon" style="background:#34d39933;color:#34d399">CAT</span> Cisco Catalyst</div>
<div class="palette-item" draggable="true" data-type="net-nexus"><span class="p-icon" style="background:#34d39933;color:#34d399">NX</span> Cisco Nexus</div>
<div class="palette-item" draggable="true" data-type="net-junper-ex"><span class="p-icon" style="background:#34d39933;color:#34d399">JEX</span> Juniper EX</div>
<div class="palette-item" draggable="true" data-type="net-junper-qfx"><span class="p-icon" style="background:#34d39933;color:#34d399">JQ</span> Juniper QFX</div>
<div class="palette-item" draggable="true" data-type="net-junper-srx"><span class="p-icon" style="background:#34d39933;color:#34d399">SRX</span> Juniper SRX</div>
<div class="palette-item" draggable="true" data-type="net-arista"><span class="p-icon" style="background:#34d39933;color:#34d399">AR</span> Arista EOS</div>
<div class="palette-item" draggable="true" data-type="net-fortinet"><span class="p-icon" style="background:#34d39933;color:#34d399">FG</span> Fortinet FortiGate</div>
<div class="palette-item" draggable="true" data-type="net-f5"><span class="p-icon" style="background:#34d39933;color:#34d399">F5</span> F5 BIG-IP</div>
<div class="palette-item" draggable="true" data-type="net-paloalto"><span class="p-icon" style="background:#34d39933;color:#34d399">PA</span> Palo Alto</div>
<div class="cat-label">Compute / Data Platform</div>
<div class="palette-item" draggable="true" data-type="compute-spark"><span class="p-icon" style="background:#a855f733;color:#a855f7">SP</span> Apache Spark</div>
<div class="palette-item" draggable="true" data-type="compute-trino"><span class="p-icon" style="background:#a855f733;color:#a855f7">TR</span> Trino / Presto</div>
<div class="palette-item" draggable="true" data-type="compute-k8s"><span class="p-icon" style="background:#a855f733;color:#a855f7">K8</span> Kubernetes</div>
<div class="palette-item" draggable="true" data-type="compute-dbt"><span class="p-icon" style="background:#a855f733;color:#a855f7">DBT</span> dbt</div>
<div class="palette-item" draggable="true" data-type="compute-hadoop"><span class="p-icon" style="background:#a855f733;color:#a855f7">HD</span> Hadoop Cluster</div>
<div class="palette-item" draggable="true" data-type="compute-sap-hana"><span class="p-icon" style="background:#0070d133;color:#0070d1">H</span> SAP HANA Platform</div>
<div class="cat-label">AI / ML</div>
<div class="palette-item" draggable="true" data-type="ai-ollama"><span class="p-icon" style="background:#fb718533;color:#fb7185">O</span> Ollama / vLLM</div>
<div class="palette-item" draggable="true" data-type="ai-mlflow"><span class="p-icon" style="background:#fb718533;color:#fb7185">MF</span> MLflow</div>
<div class="palette-item" draggable="true" data-type="ai-vector"><span class="p-icon" style="background:#fb718533;color:#fb7185">VS</span> Vector Store</div>
<div class="palette-item" draggable="true" data-type="ai-model"><span class="p-icon" style="background:#fb718533;color:#fb7185">MS</span> Model Serving</div>
<div class="palette-item" draggable="true" data-type="ai-nvidia"><span class="p-icon" style="background:#76b90033;color:#76b900">NV</span> NVIDIA DGX</div>
<div class="cat-label">Microservices & Apps</div>
<div class="palette-item" draggable="true" data-type="app-nodejs"><span class="p-icon" style="background:#22c55e33;color:#22c55e">NJS</span> Node.js App</div>
<div class="palette-item" draggable="true" data-type="app-python"><span class="p-icon" style="background:#22c55e33;color:#22c55e">PY</span> Python App</div>
<div class="palette-item" draggable="true" data-type="app-java"><span class="p-icon" style="background:#22c55e33;color:#22c55e">JV</span> Java / Spring Boot</div>
<div class="palette-item" draggable="true" data-type="app-go"><span class="p-icon" style="background:#22c55e33;color:#22c55e">GO</span> Golang App</div>
<div class="palette-item" draggable="true" data-type="app-dotnet"><span class="p-icon" style="background:#22c55e33;color:#22c55e">DN</span> .NET App</div>
<div class="palette-item" draggable="true" data-type="app-react"><span class="p-icon" style="background:#22c55e33;color:#22c55e">RE</span> React / Frontend</div>
<div class="palette-item" draggable="true" data-type="app-microservice"><span class="p-icon" style="background:#22c55e33;color:#22c55e">μS</span> Microservice</div>
<div class="palette-item" draggable="true" data-type="app-container"><span class="p-icon" style="background:#22c55e33;color:#22c55e">CT</span> Container / Docker</div>
<div class="palette-item" draggable="true" data-type="app-serverless"><span class="p-icon" style="background:#22c55e33;color:#22c55e">λ</span> Serverless Function</div>
<div class="cat-label">API & Gateway</div>
<div class="palette-item" draggable="true" data-type="api-gateway"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">GW</span> API Gateway</div>
<div class="palette-item" draggable="true" data-type="api-rest"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">REST</span> REST API</div>
<div class="palette-item" draggable="true" data-type="api-graphql"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">GQL</span> GraphQL API</div>
<div class="palette-item" draggable="true" data-type="api-grpc"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">gRPC</span> gRPC Service</div>
<div class="palette-item" draggable="true" data-type="api-ingress"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">IN</span> K8s Ingress</div>
<div class="palette-item" draggable="true" data-type="api-reverse-proxy"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">RP</span> Reverse Proxy</div>
<div class="palette-item" draggable="true" data-type="api-service-mesh"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">SM</span> Service Mesh</div>
<div class="cat-label">Messaging & Queue</div>
<div class="palette-item" draggable="true" data-type="queue-kafka"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">KQ</span> Kafka / Confluent</div>
<div class="palette-item" draggable="true" data-type="queue-rabbit"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">RM</span> RabbitMQ</div>
<div class="palette-item" draggable="true" data-type="queue-nats"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">N</span> NATS / Pulsar</div>
<div class="palette-item" draggable="true" data-type="queue-redis"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">RD</span> Redis Pub/Sub</div>
<div class="palette-item" draggable="true" data-type="queue-mqtt"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">MQ</span> MQTT Broker</div>
<div class="palette-item" draggable="true" data-type="queue-sqs"><span class="p-icon" style="background:#38bdf833;color:#38bdf8">SQS</span> AWS SQS / SNS</div>
<div class="cat-label">Databases & Cache</div>
<div class="palette-item" draggable="true" data-type="db-postgres"><span class="p-icon" style="background:#d2992233;color:#d29922">PG</span> PostgreSQL</div>
<div class="palette-item" draggable="true" data-type="db-mysql"><span class="p-icon" style="background:#d2992233;color:#d29922">MY</span> MySQL / MariaDB</div>
<div class="palette-item" draggable="true" data-type="db-mongodb"><span class="p-icon" style="background:#d2992233;color:#d29922">MD</span> MongoDB</div>
<div class="palette-item" draggable="true" data-type="db-redis"><span class="p-icon" style="background:#d2992233;color:#d29922">RE</span> Redis Cache</div>
<div class="palette-item" draggable="true" data-type="db-elastic"><span class="p-icon" style="background:#d2992233;color:#d29922">ES</span> Elasticsearch</div>
<div class="palette-item" draggable="true" data-type="db-cassandra"><span class="p-icon" style="background:#d2992233;color:#d29922">CA</span> Cassandra / Scylla</div>
<div class="palette-item" draggable="true" data-type="db-clickhouse"><span class="p-icon" style="background:#d2992233;color:#d29922">CH</span> ClickHouse</div>
<div class="palette-item" draggable="true" data-type="db-dynamodb"><span class="p-icon" style="background:#d2992233;color:#d29922">DY</span> DynamoDB / Cosmos</div>
<div class="cat-label">CI/CD & DevOps</div>
<div class="palette-item" draggable="true" data-type="devops-github"><span class="p-icon" style="background:#a855f733;color:#a855f7">GH</span> GitHub Actions</div>
<div class="palette-item" draggable="true" data-type="devops-gitlab"><span class="p-icon" style="background:#a855f733;color:#a855f7">GL</span> GitLab CI/CD</div>
<div class="palette-item" draggable="true" data-type="devops-jenkins"><span class="p-icon" style="background:#a855f733;color:#a855f7">JK</span> Jenkins</div>
<div class="palette-item" draggable="true" data-type="devops-argocd"><span class="p-icon" style="background:#a855f733;color:#a855f7">AR</span> ArgoCD</div>
<div class="palette-item" draggable="true" data-type="devops-terraform"><span class="p-icon" style="background:#a855f733;color:#a855f7">TF</span> Terraform</div>
<div class="palette-item" draggable="true" data-type="devops-docker"><span class="p-icon" style="background:#a855f733;color:#a855f7">DK</span> Docker Registry</div>
<div class="palette-item" draggable="true" data-type="devops-helm"><span class="p-icon" style="background:#a855f733;color:#a855f7">HL</span> Helm Charts</div>
<div class="cat-label">Observability</div>
<div class="palette-item" draggable="true" data-type="obs-prometheus"><span class="p-icon" style="background:#f472b633;color:#f472b6">PR</span> Prometheus</div>
<div class="palette-item" draggable="true" data-type="obs-grafana"><span class="p-icon" style="background:#f472b633;color:#f472b6">GF</span> Grafana</div>
<div class="palette-item" draggable="true" data-type="obs-loki"><span class="p-icon" style="background:#f472b633;color:#f472b6">LO</span> Loki / Logs</div>
<div class="palette-item" draggable="true" data-type="obs-tempo"><span class="p-icon" style="background:#f472b633;color:#f472b6">TP</span> Tempo / Tracing</div>
<div class="palette-item" draggable="true" data-type="obs-datadog"><span class="p-icon" style="background:#f472b633;color:#f472b6">DD</span> Datadog</div>
<div class="palette-item" draggable="true" data-type="obs-opentelemetry"><span class="p-icon" style="background:#f472b633;color:#f472b6">OT</span> OpenTelemetry</div>
<div class="palette-item" draggable="true" data-type="obs-elk"><span class="p-icon" style="background:#f472b633;color:#f472b6">ELK</span> ELK Stack</div>
<div class="cat-label">Security</div>
<div class="palette-item" draggable="true" data-type="security-iam"><span class="p-icon" style="background:#f472b633;color:#f472b6">IAM</span> IAM / Auth</div>
<div class="palette-item" draggable="true" data-type="security-vault"><span class="p-icon" style="background:#f472b633;color:#f472b6">V</span> HashiCorp Vault</div>
<div class="palette-item" draggable="true" data-type="security-grafana"><span class="p-icon" style="background:#f472b633;color:#f472b6">G</span> Grafana</div>
<div class="cat-label">Infrastructure</div>
<div class="palette-item" draggable="true" data-type="infra-rack"><span class="p-icon" style="background:#6e768133;color:#6e7681">RK</span> Server Rack</div>
<div class="palette-item" draggable="true" data-type="infra-apc-ups"><span class="p-icon" style="background:#6e768133;color:#6e7681">UPS</span> APC UPS</div>
<div class="palette-item" draggable="true" data-type="infra-pdu"><span class="p-icon" style="background:#6e768133;color:#6e7681">PDU</span> PDU / Stroom</div>
<div class="palette-item" draggable="true" data-type="infra-cooling"><span class="p-icon" style="background:#6e768133;color:#6e7681">AC</span> Koeling / AC</div>
<div class="palette-item" draggable="true" data-type="infra-patch-panel"><span class="p-icon" style="background:#6e768133;color:#6e7681">PP</span> Patch Panel</div>
</div>
<div class="node-count" id="nodeCount">0 componenten · 0 connecties</div>
</div>
<div class="editor-main">
<div class="editor-toolbar">
<div class="toolbar-group meta">
<input class="ed-name" id="diagramName" placeholder="Diagram naam..." value="<%= diagram ? diagram.name : '' %>">
<select id="diagramType">
<option value="data_architecture" <%= (diagram && diagram.diagram_type === 'data_architecture') || (!diagram && defaultDiagramType === 'data_architecture') ? 'selected' : '' %>>Data Architectuur</option>
<option value="network_topology" <%= (diagram && diagram.diagram_type === 'network_topology') || (!diagram && defaultDiagramType === 'network_topology') ? 'selected' : '' %>>Netwerk Topologie</option>
<option value="system_architecture" <%= (diagram && diagram.diagram_type === 'system_architecture') || (!diagram && defaultDiagramType === 'system_architecture') ? 'selected' : '' %>>Systeem Architectuur</option>
<option value="application_flow" <%= (diagram && diagram.diagram_type === 'application_flow') || (!diagram && defaultDiagramType === 'application_flow') ? 'selected' : '' %>>Applicatie Flow</option>
<option value="application_landscape" <%= (diagram && diagram.diagram_type === 'application_landscape') || (!diagram && defaultDiagramType === 'application_landscape') ? 'selected' : '' %>>Applicatie Landschap</option>
</select>
<select id="diagramClient">
<option value="">Cliënt...</option>
<% clients.forEach(function(c) { %>
<option value="<%= c.id %>" <%= (diagram && diagram.client_id == c.id) || clientId == c.id ? 'selected' : '' %>><%= c.name %></option>
<% }) %>
</select>
</div>
<div class="tb-sep"></div>
<div class="toolbar-group">
<button class="btn btn-sec btn-icon" onclick="undoAction()" title="Ongedaan maken">↩</button>
<button class="btn btn-sec btn-icon" onclick="redoAction()" title="Opnieuw">↪</button>
</div>
<div class="tb-sep"></div>
<div class="toolbar-group flow-toolbar" id="defaultFlowToolbar">
<span class="flow-label">Nieuwe lijnen:</span>
<button type="button" class="flow-chip" data-flow="off" onclick="setDefaultFlowMode('off')" title="Statische lijnen">Uit</button>
<button type="button" class="flow-chip" data-flow="flow" onclick="setDefaultFlowMode('flow')" title="Stromende stippellijn">Flow</button>
<button type="button" class="flow-chip" data-flow="pulse" onclick="setDefaultFlowMode('pulse')" title="Pulserende gloed">Puls</button>
<button type="button" class="flow-chip active" data-flow="both" onclick="setDefaultFlowMode('both')" title="Flow + puls">Beide</button>
</div>
<div class="tb-sep"></div>
<div class="toolbar-group">
<button class="btn btn-pri" onclick="saveDiagram()">Opslaan</button>
<button class="btn btn-sec" onclick="exportJSON()">JSON</button>
<button class="btn btn-sec btn-icon" onclick="fitToScreen()" title="Passend maken">⊞</button>
<button class="btn btn-sec btn-icon" onclick="toggleGrid()" title="Raster">⊟</button>
<button class="btn btn-sec btn-danger" onclick="clearCanvas()" title="Wissen">× Wis</button>
</div>
<div class="tb-sep"></div>
<a href="<%= (typeof rack !== 'undefined' && rack) ? '/racks/' + rack.id + '/report' : '/architecture/report/' + (diagram ? diagram.id : 0) %>" class="btn btn-sec" title="Rapport genereren">📄 Rapport</a>
<span class="status-msg" id="statusMsg"></span>
</div>
<div class="canvas-wrap" id="canvasWrap">
<div class="canvas-hint" id="canvasHint">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M17.5 14v7M14 17.5h7"/></svg>
<p>Sleep componenten hierheen</p>
<small>Verbind nodes via de poorten aan de zijkanten</small>
</div>
<div class="viewport" id="viewport">
<svg class="connections" id="svgConnections">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0,10 3.5,0 7" fill="var(--accent)"/></marker>
<marker id="arrowhead-glow" markerWidth="14" markerHeight="10" refX="12" refY="5" orient="auto"><polygon points="0 0,12 5,0 10" fill="#00d4ff"/></marker>
</defs>
</svg>
<div id="canvas" style="position:absolute;top:0;left:0;width:100%;height:100%"></div>
</div>
<div class="zoom-indicator" id="zoomIndicator">100%</div>
</div>
</div>
<div class="props-panel" id="propsPanel">
<h3 id="propsTitle">Eigenschappen</h3>
<div class="props-body">
<div id="propsNode">
<div class="pg"><label>Label</label><input id="pLabel" onchange="updateProp('label',this.value)"></div>
<div class="pg"><label>Subtitel</label><input id="pSub" onchange="updateProp('sub',this.value)"></div>
<div class="pg"><label>IP Adres</label><input id="pIp" onchange="updateProp('ip',this.value)" placeholder="10.0.0.1/24"></div>
<div class="props-divider"></div>
<div class="pg"><label>Vendor</label><select id="pVendor" onchange="updateProp('vendor',this.value)"></select></div>
<div class="pg"><label>Model</label><input id="pModel" onchange="updateProp('model',this.value)" placeholder="ProLiant DL380 Gen11"></div>
<div class="pg"><label>Type</label><select id="pType" onchange="updateProp('type',this.value)"></select></div>
<div class="pg"><label>Kleur</label><select id="pColor" onchange="updateProp('css',this.value)">
<option value="node-source">Oranje (Bron)</option><option value="node-ingest">Blauw (Ingest)</option>
<option value="node-storage">Groen (Opslag)</option><option value="node-compute">Paars (Compute)</option>
<option value="node-ai">Rood (AI/ML)</option><option value="node-queue">Cyaan (Queue)</option>
<option value="node-network">Mint (Netwerk)</option><option value="node-security">Roze (Security)</option>
<option value="node-vendor">Oranje (Vendor)</option><option value="node-sap">Blauw (SAP)</option>
<option value="node-db">Rood (Database)</option><option value="node-hw">Oranje (Hardware)</option>
<option value="node-hybrid">Cyaan (Hybrid)</option>
</select></div>
<div class="pg"><label>Breedte</label><input id="pWidth" type="number" min="100" max="500" onchange="updateProp('w',+this.value)"></div>
<div class="props-divider"></div>
<div class="pg"><label>Specs / Config</label><textarea id="pSpecs" onchange="updateProp('specs',this.value)" placeholder="CPU: Xeon 16C&#10;RAM: 512GB&#10;Opslag: 4x 3.84TB NVMe&#10;Poorten: 4x 25GbE"></textarea></div>
<div class="pg"><label>Notities</label><textarea id="pNotes" onchange="updateProp('notes',this.value)" placeholder="Extra informatie..."></textarea></div>
<div class="props-divider"></div>
<button class="btn-delete-node" onclick="deleteSelected()">Verwijder node</button>
</div>
<div id="propsEdge" style="display:none">
<div class="pg"><label>Connectie</label><div id="pEdgeRoute" class="edge-route-text">—</div></div>
<div class="pg"><label>Flow protocol</label>
<select id="pEdgeProtocol" onchange="setEdgeProtocol(this.value)">
<option value="">—</option>
<option value="sync">Sync</option>
<option value="async">Async</option>
<option value="batch">Batch</option>
<option value="stream">Stream</option>
<option value="event">Event</option>
</select>
</div>
<div class="pg"><label>Animatie</label>
<div class="flow-chips" id="edgeFlowChips">
<button type="button" class="flow-chip" data-flow="off" onclick="setEdgeFlow('off')">Uit</button>
<button type="button" class="flow-chip" data-flow="flow" onclick="setEdgeFlow('flow')">Flow</button>
<button type="button" class="flow-chip" data-flow="pulse" onclick="setEdgeFlow('pulse')">Puls</button>
<button type="button" class="flow-chip" data-flow="both" onclick="setEdgeFlow('both')">Beide</button>
</div>
<p class="flow-hint">Klik een lijn op het canvas om de animatie per connectie aan te passen.</p>
</div>
<div class="props-divider"></div>
<button class="btn-delete-node" onclick="deleteSelectedEdge()">Verwijder connectie</button>
</div>
</div>
</div>
</div>
<div id="diagramMeta" data-id="<%= diagram ? diagram.id : '' %>" data-rack-id="<%= (typeof rack !== 'undefined' && rack) ? rack.id : (diagram && diagram.rack_id ? diagram.rack_id : '') %>" style="display:none"></div>
<script>
const DIAGRAM_ID = '<%= diagram ? diagram.id : '' %>';
const RACK_ID = '<%= (typeof rack !== "undefined" && rack) ? rack.id : (diagram && diagram.rack_id ? diagram.rack_id : "") %>';
const RACK_DEVICES = <%- JSON.stringify(typeof rackDevices !== 'undefined' ? rackDevices : []) %>;
let defaultFlowProtocol = '';
let _paletteLayer = '';
let nodes = [], edges = [], selectedNodeId = null, selectedEdgeIdx = null;
let defaultFlowMode = 'both';
let dragNodeId = null, dragOffX = 0, dragOffY = 0;
let isPanning = false, panStartX, panStartY, panOffX = 0, panOffY = 0;
let connectingFrom = null, tempLine = null, zoomLevel = 1, showGrid = true;
let nodeIdCounter = 0, undoStack = [], redoStack = [], isDirty = false;
const wrap = document.getElementById('canvasWrap'), viewport = document.getElementById('viewport');
const canvas = document.getElementById('canvas'), svg = document.getElementById('svgConnections');
const propsEl = document.getElementById('propsPanel');
const VENDORS = ['','HPE','Dell','Lenovo','Super micro','Cisco','Juniper','Arista','NetApp','Pure Storage','Fortinet','F5','Palo Alto','NVIDIA','APC','Supermicro','ORACLE','Custom'];
const NT = {
'source-db':{l:'Database',s:'OLTP / Bron',c:'node-source',w:140,h:44},
'source-api':{l:'API Service',s:'REST / gRPC',c:'node-source',w:140,h:44},
'source-iot':{l:'IoT Sensor',s:'Device Data',c:'node-source',w:140,h:44},
'source-stream':{l:'Streaming',s:'Real-time Data',c:'node-source',w:140,h:44},
'source-sap':{l:'SAP HANA',s:'Enterprise Data',c:'node-sap',w:140,h:44},
'source-oracle':{l:'Oracle DB',s:'Enterprise DB',c:'node-db',w:140,h:44},
'ingest-kafka':{l:'Kafka',s:'Event Streaming',c:'node-ingest',w:130,h:44},
'ingest-airbyte':{l:'Airbyte',s:'Data Integration',c:'node-ingest',w:130,h:44},
'ingest-flink':{l:'Spark / Flink',s:'Stream Processing',c:'node-ingest',w:140,h:44},
'ingest-cdc':{l:'Debezium / CDC',s:'Change Data Capture',c:'node-ingest',w:150,h:44},
'ingest-nifi':{l:'Apache NiFi',s:'Data Flow',c:'node-ingest',w:130,h:44},
'ingest-dms':{l:'AWS DMS',s:'Migration Service',c:'node-ingest',w:140,h:44},
'storage-minio':{l:'MinIO / S3',s:'Object Storage',c:'node-storage',w:140,h:44},
'storage-iceberg':{l:'Iceberg',s:'Table Format',c:'node-storage',w:130,h:44},
'storage-warehouse':{l:'Data Warehouse',s:'Analytics',c:'node-storage',w:150,h:44},
'storage-lake':{l:'Data Lakehouse',s:'Unified Analytics',c:'node-storage',w:155,h:44},
'storage-nimble':{l:'HPE Nimble',s:'All-Flash Storage',c:'node-vendor',w:150,h:44},
'storage-3par':{l:'HPE 3PAR',s:'Enterprise Storage',c:'node-vendor',w:140,h:44},
'storage-powerstore':{l:'Dell PowerStore',s:'All-Flash',c:'node-hw',w:155,h:44},
'storage-powerscale':{l:'Dell PowerScale',s:'Scale-Out NAS',c:'node-hw',w:160,h:44},
'storage-netapp':{l:'NetApp AFF/FAS',s:'Hybrid Cloud',c:'node-vendor',w:155,h:44},
'storage-pure':{l:'Pure Storage',s:'All-Flash Array',c:'node-vendor',w:155,h:44},
'hw-hpe-dl':{l:'HPE ProLiant DL',s:'Rack Server',c:'node-hw',w:160,h:48},
'hw-hpe-ml':{l:'HPE ProLiant ML',s:'Tower Server',c:'node-hw',w:160,h:48},
'hw-hpe-synergy':{l:'HPE Synergy',s:'Composable Infra',c:'node-hw',w:160,h:48},
'hw-dell-pe':{l:'Dell PowerEdge',s:'Rack Server',c:'node-hw',w:160,h:48},
'hw-dell-mx':{l:'Dell PowerEdge MX',s:'Modular Infra',c:'node-hw',w:170,h:48},
'hw-lenovo-ts':{l:'Lenovo ThinkSystem',s:'Enterprise Server',c:'node-hw',w:170,h:48},
'hw-lenovo-ta':{l:'Lenovo ThinkAgile',s:'HCI Platform',c:'node-hw',w:170,h:48},
'hw-supermicro':{l:'Supermicro SYS',s:'High-Density Server',c:'node-hw',w:160,h:48},
'hw-cisco-ucs':{l:'Cisco UCS',s:'Unified Computing',c:'node-hw',w:155,h:48},
'compute-vmware':{l:'VMware vSphere',s:'Virtualization',c:'node-compute',w:155,h:44},
'net-catalyst':{l:'Cisco Catalyst',s:'Access / Core Switch',c:'node-network',w:155,h:44},
'net-nexus':{l:'Cisco Nexus',s:'Datacenter Switch',c:'node-network',w:155,h:44},
'net-junper-ex':{l:'Juniper EX',s:'Ethernet Switch',c:'node-network',w:150,h:44},
'net-junper-qfx':{l:'Juniper QFX',s:'Spine / Leaf',c:'node-network',w:150,h:44},
'net-junper-srx':{l:'Juniper SRX',s:'Security Gateway',c:'node-network',w:150,h:44},
'net-arista':{l:'Arista EOS',s:'Datacenter Switch',c:'node-network',w:150,h:44},
'net-fortinet':{l:'Fortinet FortiGate',s:'Next-Gen Firewall',c:'node-network',w:165,h:44},
'net-f5':{l:'F5 BIG-IP',s:'Load Balancer / ADC',c:'node-network',w:160,h:44},
'net-paloalto':{l:'Palo Alto',s:'Next-Gen Firewall',c:'node-network',w:155,h:44},
'compute-spark':{l:'Apache Spark',s:'Distributed Compute',c:'node-compute',w:145,h:44},
'compute-trino':{l:'Trino / Presto',s:'SQL Query Engine',c:'node-compute',w:150,h:44},
'compute-k8s':{l:'Kubernetes',s:'Container Orchestr.',c:'node-compute',w:145,h:44},
'compute-dbt':{l:'dbt',s:'Data Transform',c:'node-compute',w:130,h:44},
'compute-hadoop':{l:'Hadoop Cluster',s:'Big Data Platform',c:'node-compute',w:155,h:44},
'compute-sap-hana':{l:'SAP HANA',s:'In-Memory Platform',c:'node-sap',w:155,h:44},
'ai-ollama':{l:'Ollama / vLLM',s:'LLM Serving',c:'node-ai',w:145,h:44},
'ai-mlflow':{l:'MLflow',s:'ML Lifecycle',c:'node-ai',w:140,h:44},
'ai-vector':{l:'Vector Store',s:'Embeddings',c:'node-ai',w:140,h:44},
'ai-model':{l:'Model Serving',s:'Inference',c:'node-ai',w:145,h:44},
'ai-nvidia':{l:'NVIDIA DGX',s:'AI Supercomputer',c:'node-ai',w:150,h:44},
'queue-kafka':{l:'Kafka / Confluent',s:'Message Queue',c:'node-queue',w:150,h:44},
'queue-rabbit':{l:'RabbitMQ',s:'Message Broker',c:'node-queue',w:140,h:44},
'queue-nats':{l:'NATS / Pulsar',s:'Event Bus',c:'node-queue',w:150,h:44},
'security-iam':{l:'IAM / Auth',s:'Identity & Access',c:'node-security',w:140,h:44},
'security-vault':{l:'HashiCorp Vault',s:'Secrets Mgmt',c:'node-security',w:155,h:44},
'security-grafana':{l:'Grafana',s:'Observability',c:'node-security',w:140,h:44},
'infra-rack':{l:'Server Rack',s:'42U Rack Enclosure',c:'node-vendor',w:160,h:52},
'infra-apc-ups':{l:'APC UPS',s:'Uninterruptible Power',c:'node-vendor',w:150,h:44},
'infra-pdu':{l:'PDU',s:'Power Distribution',c:'node-vendor',w:140,h:42},
'infra-cooling':{l:'Koeling / AC',s:'Rack Cooling',c:'node-vendor',w:140,h:42},
'infra-patch-panel':{l:'Patch Panel',s:'48-port Keystone',c:'node-vendor',w:150,h:40},
// Software & Apps
'app-nodejs':{l:'Node.js App',s:'JavaScript Runtime',c:'node-compute',w:145,h:44},
'app-python':{l:'Python App',s:'Python Runtime',c:'node-compute',w:145,h:44},
'app-java':{l:'Java / Spring Boot',s:'JVM Runtime',c:'node-compute',w:155,h:44},
'app-go':{l:'Golang App',s:'Go Runtime',c:'node-compute',w:140,h:44},
'app-dotnet':{l:'.NET App',s:'.NET Runtime',c:'node-compute',w:140,h:44},
'app-react':{l:'React / Frontend',s:'Web Frontend',c:'node-compute',w:150,h:44},
'app-microservice':{l:'Microservice',s:'Service Component',c:'node-compute',w:150,h:44},
'app-container':{l:'Container / Docker',s:'Container Runtime',c:'node-compute',w:160,h:44},
'app-serverless':{l:'Serverless Function',s:'FaaS / Lambda',c:'node-compute',w:160,h:44},
// API & Gateway
'api-gateway':{l:'API Gateway',s:'Traffic Management',c:'node-queue',w:145,h:44},
'api-rest':{l:'REST API',s:'HTTP / JSON',c:'node-queue',w:135,h:44},
'api-graphql':{l:'GraphQL API',s:'Query Language',c:'node-queue',w:145,h:44},
'api-grpc':{l:'gRPC Service',s:'RPC Framework',c:'node-queue',w:145,h:44},
'api-ingress':{l:'K8s Ingress',s:'External Access',c:'node-queue',w:140,h:44},
'api-reverse-proxy':{l:'Reverse Proxy',s:'Load Balancing',c:'node-queue',w:145,h:44},
'api-service-mesh':{l:'Service Mesh',s:'mTLS / Observability',c:'node-queue',w:155,h:44},
// More messaging
'queue-redis':{l:'Redis Pub/Sub',s:'In-Memory Queue',c:'node-queue',w:145,h:44},
'queue-mqtt':{l:'MQTT Broker',s:'IoT Protocol',c:'node-queue',w:140,h:44},
'queue-sqs':{l:'AWS SQS / SNS',s:'Cloud Queue',c:'node-queue',w:145,h:44},
// Databases & Cache
'db-postgres':{l:'PostgreSQL',s:'Relational DB',c:'node-db',w:145,h:44},
'db-mysql':{l:'MySQL / MariaDB',s:'Relational DB',c:'node-db',w:150,h:44},
'db-mongodb':{l:'MongoDB',s:'Document DB',c:'node-db',w:145,h:44},
'db-redis':{l:'Redis Cache',s:'In-Memory Cache',c:'node-db',w:140,h:44},
'db-elastic':{l:'Elasticsearch',s:'Search & Analytics',c:'node-db',w:155,h:44},
'db-cassandra':{l:'Cassandra / Scylla',s:'Wide-Column DB',c:'node-db',w:160,h:44},
'db-clickhouse':{l:'ClickHouse',s:'OLAP Database',c:'node-db',w:145,h:44},
'db-dynamodb':{l:'DynamoDB / Cosmos',s:'NoSQL Cloud DB',c:'node-db',w:160,h:44},
// CI/CD & DevOps
'devops-github':{l:'GitHub Actions',s:'CI/CD Pipeline',c:'node-queue',w:145,h:44},
'devops-gitlab':{l:'GitLab CI/CD',s:'CI/CD Pipeline',c:'node-queue',w:145,h:44},
'devops-jenkins':{l:'Jenkins',s:'Automation Server',c:'node-queue',w:135,h:44},
'devops-argocd':{l:'ArgoCD',s:'GitOps Deployment',c:'node-queue',w:150,h:44},
'devops-terraform':{l:'Terraform',s:'Infrastructure as Code',c:'node-queue',w:160,h:44},
'devops-docker':{l:'Docker Registry',s:'Container Registry',c:'node-queue',w:155,h:44},
'devops-helm':{l:'Helm Charts',s:'K8s Package Manager',c:'node-queue',w:155,h:44},
// Observability
'obs-prometheus':{l:'Prometheus',s:'Metrics Collection',c:'node-security',w:150,h:44},
'obs-grafana':{l:'Grafana',s:'Dashboard & Viz',c:'node-security',w:145,h:44},
'obs-loki':{l:'Loki / Logs',s:'Log Aggregation',c:'node-security',w:145,h:44},
'obs-tempo':{l:'Tempo / Tracing',s:'Distributed Tracing',c:'node-security',w:155,h:44},
'obs-datadog':{l:'Datadog',s:'Monitoring SaaS',c:'node-security',w:145,h:44},
'obs-opentelemetry':{l:'OpenTelemetry',s:'Telemetry Standard',c:'node-security',w:160,h:44},
'obs-elk':{l:'ELK Stack',s:'Log Analysis',c:'node-security',w:140,h:44}
};
function snap(v){return Math.round(v/20)*20}
function addNode(type,x,y,id,opt){
const d=NT[type];if(!d)return null;
const nid=id||(++nodeIdCounter);
const el=document.createElement('div');
el.className='canvas-node '+(opt&&opt.css?opt.css:d.c);
el.dataset.id=nid;
el.style.left=snap(x)+'px';el.style.top=snap(y)+'px';el.style.width=(opt&&opt.w?opt.w:d.w)+'px';
const label=(opt&&opt.label)||d.l;
const sub=(opt&&opt.sub)||d.s;
const ip=(opt&&opt.ip)||'';
const metaLine=ip?'<div class="node-meta">'+ip+'</div>':'';
el.innerHTML='<div class="node-remove" onclick="removeNode('+nid+')">×</div>'+
'<div class="node-port in" data-node="'+nid+'" data-port="in"></div>'+
'<div class="node-label">'+label+'</div><div class="node-sub">'+sub+'</div>'+metaLine+
'<div class="node-port out" data-node="'+nid+'" data-port="out"></div>';
el.addEventListener('mousedown',e=>{
if(e.target.closest('.node-remove')||e.target.closest('.node-port'))return;
e.stopPropagation();selectNode(nid);
const r=el.getBoundingClientRect();dragNodeId=nid;
dragOffX=e.clientX-r.left;dragOffY=e.clientY-r.top;
el.style.zIndex='20';el.style.cursor='grabbing';saveState();
});
el.querySelectorAll('.node-port').forEach(p=>{
p.addEventListener('mousedown',e=>{
e.stopPropagation();e.preventDefault();
if(p.dataset.port==='out'){
connectingFrom={nodeId:parseInt(p.dataset.node),el:p};
const r=p.getBoundingClientRect(),wr=viewport.getBoundingClientRect();
tempLine=document.createElementNS('http://www.w3.org/2000/svg','line');
tempLine.setAttribute('stroke','#00d4ff');tempLine.setAttribute('stroke-width','2.5');
tempLine.setAttribute('stroke-dasharray','8,6');tempLine.setAttribute('opacity','0.85');
if(defaultFlowMode==='flow'||defaultFlowMode==='both')tempLine.classList.add('conn-anim-flow');
if(defaultFlowMode==='pulse'||defaultFlowMode==='both')tempLine.classList.add('conn-anim-pulse');
tempLine.setAttribute('x1',(r.left-wr.left)/zoomLevel);tempLine.setAttribute('y1',(r.top-wr.top+5)/zoomLevel);
tempLine.setAttribute('x2',(e.clientX-wr.left)/zoomLevel);tempLine.setAttribute('y2',(e.clientY-wr.top)/zoomLevel);
svg.appendChild(tempLine);
}});
});
canvas.appendChild(el);
const nd={id:nid,type,x:snap(x),y:snap(y),label,sub,css:opt&&opt.css?opt.css:d.c,w:opt&&opt.w?opt.w:d.w,h:opt&&opt.h?opt.h:d.h,ip:ip||'',vendor:(opt&&opt.vendor)||'',model:(opt&&opt.model)||'',specs:(opt&&opt.specs)||'',notes:(opt&&opt.notes)||'',rack_device_id:opt&&opt.rack_device_id?opt.rack_device_id:null,layer:opt&&opt.layer?opt.layer:inferLayerFromType(type)};
nodes.push(nd);updateStats();return nid;
}
function inferLayerFromType(type){
if(!type)return 'other';
if(type.startsWith('source-')||type.startsWith('ingest-')||type.startsWith('storage-')||type.startsWith('db-'))return 'data';
if(type.startsWith('app-'))return 'app';
if(type.startsWith('api-')||type.startsWith('queue-'))return 'integration';
if(type.startsWith('hw-')||type.startsWith('net-')||type.startsWith('infra-'))return 'infra';
return 'other';
}
function removeNode(id){
const el=canvas.querySelector('.canvas-node[data-id="'+id+'"]');
if(el)el.remove();
nodes=nodes.filter(n=>n.id!==id);
edges=edges.filter(e=>e.from!==id&&e.to!==id);
renderEdges();
if(selectedNodeId===id){selectedNodeId=null;if(selectedEdgeIdx===null)propsEl.classList.remove('open');}
updateStats();saveState();
}
function normalizeEdge(e){return{from:e.from,to:e.to,flow:e.flow||'both',protocol:e.protocol||''};}
function setDefaultFlowProtocol(p){defaultFlowProtocol=p||'';}
function setEdgeProtocol(protocol){
if(selectedEdgeIdx===null)return;
edges[selectedEdgeIdx].protocol=protocol;
saveState();
}
function setDefaultFlowMode(mode){
defaultFlowMode=mode;
document.querySelectorAll('#defaultFlowToolbar .flow-chip').forEach(b=>b.classList.toggle('active',b.dataset.flow===mode));
}
function setEdgeFlow(mode){
if(selectedEdgeIdx===null)return;
edges[selectedEdgeIdx].flow=mode;
document.querySelectorAll('#edgeFlowChips .flow-chip').forEach(b=>b.classList.toggle('active',b.dataset.flow===mode));
renderEdges();
saveState();
}
function selectEdge(idx){
selectedEdgeIdx=idx;
selectedNodeId=null;
canvas.querySelectorAll('.canvas-node').forEach(n=>n.classList.remove('selected'));
svg.querySelectorAll('.conn-group').forEach(g=>g.classList.remove('selected'));
const g=svg.querySelector('.conn-group[data-idx="'+idx+'"]');
if(g)g.classList.add('selected');
showEdgeProps(idx);
}
function showEdgeProps(idx){
const e=edges[idx];if(!e)return;
const from=nodes.find(n=>n.id===e.from),to=nodes.find(n=>n.id===e.to);
document.getElementById('propsTitle').textContent='Connectie';
document.getElementById('propsNode').style.display='none';
document.getElementById('propsEdge').style.display='block';
document.getElementById('pEdgeRoute').textContent=(from?from.label:'?')+' → '+(to?to.label:'?');
document.getElementById('pEdgeProtocol').value=e.protocol||'';
document.querySelectorAll('#edgeFlowChips .flow-chip').forEach(b=>b.classList.toggle('active',b.dataset.flow===(e.flow||'both')));
propsEl.classList.add('open');
}
function deselectAll(){
selectedNodeId=null;
selectedEdgeIdx=null;
canvas.querySelectorAll('.canvas-node').forEach(n=>n.classList.remove('selected'));
svg.querySelectorAll('.conn-group').forEach(g=>g.classList.remove('selected'));
propsEl.classList.remove('open');
}
function deleteSelectedEdge(){
if(selectedEdgeIdx===null)return;
edges.splice(selectedEdgeIdx,1);
selectedEdgeIdx=null;
propsEl.classList.remove('open');
renderEdges();
updateStats();
saveState();
}
function nodeLabel(id){const n=nodes.find(nn=>nn.id===id);return n?n.label:'Node '+id;}
function selectNode(id){
selectedEdgeIdx=null;
svg.querySelectorAll('.conn-group').forEach(g=>g.classList.remove('selected'));
canvas.querySelectorAll('.canvas-node').forEach(n=>n.classList.remove('selected'));
const el=canvas.querySelector('.canvas-node[data-id="'+id+'"]');
if(el)el.classList.add('selected');
selectedNodeId=id;showProps(id);
}
function showProps(id){
const n=nodes.find(nn=>nn.id===id);if(!n)return;
document.getElementById('propsTitle').textContent='Eigenschappen';
document.getElementById('propsNode').style.display='block';
document.getElementById('propsEdge').style.display='none';
propsEl.classList.add('open');
document.getElementById('pLabel').value=n.label||'';
document.getElementById('pSub').value=n.sub||'';
document.getElementById('pIp').value=n.ip||'';
document.getElementById('pWidth').value=n.w||140;
document.getElementById('pColor').value=n.css||'node-source';
document.getElementById('pSpecs').value=n.specs||'';
document.getElementById('pNotes').value=n.notes||'';
const vSel=document.getElementById('pVendor');
vSel.innerHTML='';VENDORS.forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent=v||'— Vendor —';if(v===(n.vendor||''))o.selected=true;vSel.appendChild(o);});
const mInp=document.getElementById('pModel');mInp.value=n.model||'';
const tSel=document.getElementById('pType');
tSel.innerHTML='';Object.keys(NT).forEach(k=>{const o=document.createElement('option');o.value=k;o.textContent=NT[k].l;if(k===n.type)o.selected=true;tSel.appendChild(o);});
}
function updateProp(field,value){
const n=nodes.find(nn=>nn.id===selectedNodeId);if(!n)return;
const el=canvas.querySelector('.canvas-node[data-id="'+selectedNodeId+'"]');if(!el)return;
if(field==='label'){n.label=value;el.querySelector('.node-label').textContent=value;if(selectedEdgeIdx!==null)showEdgeProps(selectedEdgeIdx);}
else if(field==='sub'){n.sub=value;el.querySelector('.node-sub').textContent=value;}
else if(field==='ip'){n.ip=value;let m=el.querySelector('.node-meta');if(m){m.textContent=value;}else if(value){const d=document.createElement('div');d.className='node-meta';d.textContent=value;el.querySelector('.node-sub').after(d);}}
else if(field==='w'){n.w=value;el.style.width=value+'px';}
else if(field==='css'){n.css=value;el.className='canvas-node '+value;}
else if(field==='vendor'){n.vendor=value;}
else if(field==='model'){n.model=value;}
else if(field==='specs'){n.specs=value;}
else if(field==='notes'){n.notes=value;}
else if(field==='type'){
const d=NT[value];if(d){n.type=value;n.css=d.c;n.w=d.w;n.h=d.h;
el.style.width=d.w+'px';el.className='canvas-node '+d.c;
el.querySelector('.node-label').textContent=d.l;el.querySelector('.node-sub').textContent=d.s;
document.getElementById('pLabel').value=d.l;document.getElementById('pSub').value=d.s;
document.getElementById('pWidth').value=d.w;document.getElementById('pColor').value=d.c;
}}renderEdges();saveState();
}
function deleteSelected(){
if(selectedEdgeIdx!==null){deleteSelectedEdge();return;}
if(selectedNodeId)removeNode(selectedNodeId);
}
function addEdge(from,to){
if(from===to)return;
if(edges.some(e=>e.from===from&&e.to===to))return;
edges.push(normalizeEdge({from,to,flow:defaultFlowMode,protocol:defaultFlowProtocol}));
renderEdges();
selectEdge(edges.length-1);
updateStats();
saveState();
}
function renderEdges(){
svg.querySelectorAll('.conn-group').forEach(g=>g.remove());
const wr=viewport.getBoundingClientRect();
edges.forEach((e,idx)=>{
e=normalizeEdge(e);edges[idx]=e;
const flow=e.flow;
const fEl=canvas.querySelector('.canvas-node[data-id="'+e.from+'"]');
const tEl=canvas.querySelector('.canvas-node[data-id="'+e.to+'"]');
if(!fEl||!tEl)return;
const fr=fEl.getBoundingClientRect(),tr=tEl.getBoundingClientRect();
const x1=(fr.right-wr.left-8)/zoomLevel,y1=(fr.top+fr.height/2-wr.top)/zoomLevel;
const x2=(tr.left-wr.left+8)/zoomLevel,y2=(tr.top+tr.height/2-wr.top)/zoomLevel;
const dx=Math.abs(x2-x1),cp=Math.max(50,dx*0.4);
const d='M '+x1+' '+y1+' C '+(x1+cp)+' '+y1+', '+(x2-cp)+' '+y2+', '+x2+' '+y2;
const g=document.createElementNS('http://www.w3.org/2000/svg','g');
g.classList.add('conn-group');
g.dataset.idx=idx;
if(selectedEdgeIdx===idx)g.classList.add('selected');
const glow=document.createElementNS('http://www.w3.org/2000/svg','path');
glow.setAttribute('d',d);glow.setAttribute('stroke','var(--accent)');glow.setAttribute('stroke-width','6');
glow.setAttribute('fill','none');glow.setAttribute('opacity','0.12');
glow.style.pointerEvents='none';
if(flow==='pulse'||flow==='both')glow.classList.add('conn-glow-pulse');
g.appendChild(glow);
const main=document.createElementNS('http://www.w3.org/2000/svg','path');
main.setAttribute('d',d);main.setAttribute('stroke','var(--accent)');main.setAttribute('stroke-width','2.5');
main.setAttribute('fill','none');main.setAttribute('stroke-linecap','round');
main.classList.add('conn-path');main.dataset.idx=idx;
main.addEventListener('click',ev=>{ev.stopPropagation();selectEdge(idx);});
main.addEventListener('dblclick',ev=>{ev.stopPropagation();edges.splice(idx,1);selectedEdgeIdx=null;propsEl.classList.remove('open');renderEdges();updateStats();saveState();});
main.addEventListener('mouseenter',()=>glow.setAttribute('opacity',flow==='off'?'0.2':'0.35'));
main.addEventListener('mouseleave',()=>glow.setAttribute('opacity',flow==='off'?'0.12':'0.18'));
g.appendChild(main);
if(flow==='flow'||flow==='both'){
const flow1=document.createElementNS('http://www.w3.org/2000/svg','path');
flow1.setAttribute('d',d);flow1.setAttribute('stroke','#00d4ff');flow1.setAttribute('stroke-width','2');
flow1.setAttribute('fill','none');flow1.setAttribute('stroke-dasharray','10,14');
flow1.setAttribute('opacity','0.85');flow1.classList.add('conn-anim-flow');
flow1.style.pointerEvents='none';
g.appendChild(flow1);
const flow2=document.createElementNS('http://www.w3.org/2000/svg','path');
flow2.setAttribute('d',d);flow2.setAttribute('stroke','#66e5ff');flow2.setAttribute('stroke-width','1.5');
flow2.setAttribute('fill','none');flow2.setAttribute('stroke-dasharray','6,18');
flow2.setAttribute('opacity','0.55');flow2.classList.add('conn-anim-flow','conn-anim-flow-delay');
flow2.style.pointerEvents='none';
g.appendChild(flow2);
}
if(flow==='pulse'||flow==='both'){
const pulse=document.createElementNS('http://www.w3.org/2000/svg','path');
pulse.setAttribute('d',d);pulse.setAttribute('stroke','#00d4ff');pulse.setAttribute('stroke-width','3');
pulse.setAttribute('fill','none');pulse.setAttribute('opacity','0.35');
pulse.classList.add('conn-anim-pulse');
pulse.style.pointerEvents='none';
g.appendChild(pulse);
}
const angle=Math.atan2(y2-y1,x2-x1);
const arrow=document.createElementNS('http://www.w3.org/2000/svg','polygon');
arrow.setAttribute('points',(x2-5)+','+(y2-3)+' '+(x2-9*Math.cos(angle-0.35))+','+(y2-9*Math.sin(angle-0.35))+' '+(x2-9*Math.cos(angle+0.35))+','+(y2-9*Math.sin(angle+0.35)));
arrow.setAttribute('fill',flow==='off'?'var(--accent)':'#00d4ff');
if(flow==='pulse'||flow==='both')arrow.classList.add('conn-anim-pulse');
arrow.style.pointerEvents='none';
g.appendChild(arrow);
svg.appendChild(g);
});
}
function saveState(){undoStack.push(JSON.stringify({nodes,edges}));if(undoStack.length>50)undoStack.shift();redoStack=[];isDirty=true;}
function undoAction(){if(undoStack.length<2)return;redoStack.push(undoStack.pop());loadState(JSON.parse(undoStack[undoStack.length-1]));}
function redoAction(){if(redoStack.length===0)return;undoStack.push(redoStack.pop());loadState(JSON.parse(undoStack[undoStack.length-1]));}
function loadState(st){
canvas.querySelectorAll('.canvas-node').forEach(el=>el.remove());svg.querySelectorAll('.conn-group').forEach(g=>g.remove());
nodes=[];edges=(st.edges||[]).map(normalizeEdge);
(st.nodes||[]).forEach(n=>addNode(n.type,n.x,n.y,n.id,n));
renderEdges();updateStats();
}
document.addEventListener('mousemove',e=>{
if(dragNodeId!==null){
const el=canvas.querySelector('.canvas-node[data-id="'+dragNodeId+'"]');if(!el)return;
const wr=viewport.getBoundingClientRect();
let x=(e.clientX-wr.left-dragOffX)/zoomLevel,y=(e.clientY-wr.top-dragOffY)/zoomLevel;
x=snap(Math.max(0,x));y=snap(Math.max(0,y));
el.style.left=x+'px';el.style.top=y+'px';
const n=nodes.find(nn=>nn.id===dragNodeId);if(n){n.x=x;n.y=y;}
renderEdges();
}
if(tempLine){const wr=viewport.getBoundingClientRect();tempLine.setAttribute('x2',(e.clientX-wr.left)/zoomLevel);tempLine.setAttribute('y2',(e.clientY-wr.top)/zoomLevel);}
if(isPanning){const dx=e.clientX-panStartX,dy=e.clientY-panStartY;panOffX+=dx;panOffY+=dy;panStartX=e.clientX;panStartY=e.clientY;viewport.style.transform='translate('+panOffX+'px,'+panOffY+'px) scale('+zoomLevel+')';}
});
document.addEventListener('mouseup',e=>{
if(dragNodeId!==null){const el=canvas.querySelector('.canvas-node[data-id="'+dragNodeId+'"]');if(el)el.style.cursor='move';dragNodeId=null;}
if(tempLine){tempLine.remove();tempLine=null;const target=e.target.closest('.node-port.in');if(target&&connectingFrom)addEdge(connectingFrom.nodeId,parseInt(target.dataset.node));connectingFrom=null;}
if(isPanning){isPanning=false;wrap.style.cursor='grab';}
});
wrap.addEventListener('mousedown',e=>{
if(e.target.closest('.canvas-node'))return;
if(e.target===wrap||e.target===viewport||e.target===canvas||e.target===svg){
deselectAll();
isPanning=true;panStartX=e.clientX;panStartY=e.clientY;wrap.style.cursor='grabbing';
}
});
wrap.addEventListener('wheel',e=>{
e.preventDefault();
const delta=e.deltaY>0?.9:1.1,newZoom=Math.max(.2,Math.min(3,zoomLevel*delta));
const mx=e.clientX,my=e.clientY,wr=wrap.getBoundingClientRect();
const vx=(mx-wr.left-panOffX)/zoomLevel,vy=(my-wr.top-panOffY)/zoomLevel;
zoomLevel=newZoom;panOffX=mx-wr.left-vx*zoomLevel;panOffY=my-wr.top-vy*zoomLevel;
viewport.style.transform='translate('+panOffX+'px,'+panOffY+'px) scale('+zoomLevel+')';
document.getElementById('zoomIndicator').textContent=Math.round(zoomLevel*100)+'%';
},{passive:false});
document.querySelectorAll('.palette-item[draggable]').forEach(item=>{
item.addEventListener('dragstart',e=>{e.dataTransfer.setData('text/plain',item.dataset.type);e.dataTransfer.effectAllowed='copy';});
});
canvas.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='copy';});
canvas.addEventListener('drop',e=>{
e.preventDefault();
const type=e.dataTransfer.getData('text/plain');if(!type||!NT[type])return;
const wr=viewport.getBoundingClientRect();
let x=(e.clientX-wr.left)/zoomLevel-70,y=(e.clientY-wr.top)/zoomLevel-24;
x=snap(Math.max(0,x));y=snap(Math.max(0,y));
addNode(type,x,y);saveState();
});
function updateStats(){
document.getElementById('nodeCount').textContent=nodes.length+' componenten · '+edges.length+' connecties';
const hint=document.getElementById('canvasHint');
if(hint)hint.classList.toggle('hidden',nodes.length>0);
}
function filterPalette(q){
document.getElementById('paletteSearch').value=q;
applyPaletteFilters();
document.querySelectorAll('.pc-btn:not(.layer-btn)').forEach(b=>b.classList.toggle('active',!b.dataset.cat));
}
var _paletteCat='';
function filterPaletteCat(cat){
_paletteCat=cat;
document.querySelectorAll('.pc-btn:not(.layer-btn)').forEach(b=>b.classList.toggle('active',b.dataset.cat===cat));
document.getElementById('paletteSearch').value='';
applyPaletteFilters();
}
const LAYER_CATS={
data:['data bron','ingest','opslag','database','compute / data'],
app:['microservice','apps','ai / ml'],
integration:['api','messaging','queue'],
infra:['compute / server','netwerk','infrastructure','security','observability','ci/cd']
};
function inferLayerFromCat(cat){
const c=(cat||'').toLowerCase();
for(const [layer,keys] of Object.entries(LAYER_CATS)){
if(keys.some(k=>c.includes(k)))return layer;
}
return 'other';
}
function applyPaletteFilters(){
const q=(document.getElementById('paletteSearch').value||'').toLowerCase();
document.querySelectorAll('.cat-label').forEach(el=>{
const layer=inferLayerFromCat(el.textContent);
let showCat=!_paletteLayer||_paletteLayer===layer||layer==='other';
if(_paletteCat&&!el.textContent.trim().toLowerCase().includes(_paletteCat.toLowerCase()))showCat=false;
el.style.display=showCat?'block':'none';
let sib=el.nextElementSibling;
while(sib&&sib.classList.contains('palette-item')){
let showItem=showCat;
if(q&&!sib.textContent.toLowerCase().includes(q))showItem=false;
sib.style.display=showItem?'flex':'none';
sib=sib.nextElementSibling;
}
});
}
function filterPaletteLayer(layer){
_paletteLayer=layer;
document.querySelectorAll('.layer-btn').forEach(b=>b.classList.toggle('active',b.dataset.layer===layer));
applyPaletteFilters();
}
async function importRackDevices(){
if(!RACK_ID){setStatus('Geen rack gekoppeld','err');return;}
let devs=RACK_DEVICES;
if(!devs.length){
try{
const r=await fetch('/racks/'+RACK_ID+'/devices.json');
const d=await r.json();
devs=d.devices||[];
}catch(e){setStatus('Import mislukt','err');return;}
}
if(!devs.length){setStatus('Geen rack devices','err');return;}
const typeMap={server:'hw-dell-pe',switch:'net-catalyst',storage:'storage-netapp',firewall:'net-fortinet',router:'net-junper-srx',loadbalancer:'net-f5',san:'storage-pure',pdu:'infra-pdu',ups:'infra-apc-ups'};
let added=0;
devs.forEach(function(dev,i){
if(nodes.some(n=>n.rack_device_id===dev.id))return;
const type=typeMap[dev.device_type]||'hw-dell-pe';
addNode(type,60+(i%3)*190,60+Math.floor(i/3)*95,null,{
label:dev.name,sub:dev.model||dev.device_type,vendor:dev.manufacturer||'',model:dev.model||'',ip:dev.mgmt_ip||'',specs:dev.specs||'',rack_device_id:dev.id,layer:'infra'
});
added++;
});
saveState();
setStatus(added?added+' rack devices geïmporteerd':'Alle devices al aanwezig','ok');
}
function setStatus(msg,type){const el=document.getElementById('statusMsg');el.textContent=msg;el.className='status-msg '+(type||'');clearTimeout(el._timer);el._timer=setTimeout(()=>{el.textContent='';el.className='status-msg';},3000);}
async function saveDiagram(){
const name=document.getElementById('diagramName').value;
const clientId=document.getElementById('diagramClient').value;
const diagramType=document.getElementById('diagramType').value;
if(!name){setStatus('Naam is verplicht','err');return;}
if(!clientId){setStatus('Selecteer een cliënt','err');return;}
const payload={id:DIAGRAM_ID||null,client_id:clientId,name,description:diagramType,diagram_type:diagramType,nodes:JSON.stringify(nodes),edges:JSON.stringify(edges),rack_id:RACK_ID||null};
try{
const r=await fetch('/architecture/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const d=await r.json();setStatus('Opgeslagen ✓','ok');isDirty=false;
if(d.id&&!DIAGRAM_ID){window.history.replaceState(null,'','/architecture/'+d.id);document.getElementById('diagramMeta').dataset.id=d.id;}
}catch(e){setStatus('Fout bij opslaan','err');}
}
function exportJSON(){
const data={nodes,edges};const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'});
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=(document.getElementById('diagramName').value||'diagram')+'.json';a.click();
}
function fitToScreen(){
if(nodes.length===0)return;
const minX=Math.min(...nodes.map(n=>n.x)),minY=Math.min(...nodes.map(n=>n.y));
const maxX=Math.max(...nodes.map(n=>n.x+(n.w||140))),maxY=Math.max(...nodes.map(n=>n.y+(n.h||48)));
const wr=wrap.getBoundingClientRect();
const w=maxX-minX+100,h=maxY-minY+100,z=Math.min(wr.width/w,(wr.height-60)/h,1.5);
zoomLevel=Math.max(.2,z);panOffX=-minX*zoomLevel+50;panOffY=-minY*zoomLevel+30;
viewport.style.transform='translate('+panOffX+'px,'+panOffY+'px) scale('+zoomLevel+')';
document.getElementById('zoomIndicator').textContent=Math.round(zoomLevel*100)+'%';
}
const GRID_ON='radial-gradient(ellipse 60% 50% at 50% 50%, rgba(0,212,255,0.04) 0%, transparent 70%),linear-gradient(rgba(0,212,255,0.045) 1px,transparent 1px),linear-gradient(90deg,rgba(0,212,255,0.045) 1px,transparent 1px)';
const GRID_OFF='radial-gradient(ellipse 60% 50% at 50% 50%, rgba(0,212,255,0.04) 0%, transparent 70%)';
function toggleGrid(){showGrid=!showGrid;wrap.style.backgroundImage=showGrid?GRID_ON:GRID_OFF;wrap.style.backgroundSize=showGrid?'100% 100%, 24px 24px, 24px 24px':'100% 100%';}
function clearCanvas(){if(nodes.length===0)return;if(!confirm('Wis alle componenten en connecties?'))return;canvas.querySelectorAll('.canvas-node').forEach(el=>el.remove());svg.querySelectorAll('.conn-group').forEach(g=>g.remove());nodes=[];edges=[];selectedNodeId=null;selectedEdgeIdx=null;propsEl.classList.remove('open');updateStats();saveState();}
window.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='SELECT')return;
if(e.key==='Delete'||e.key==='Backspace'){deleteSelected();e.preventDefault();}
if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.shiftKey?redoAction():undoAction();e.preventDefault();}
if((e.ctrlKey||e.metaKey)&&e.key==='s'){saveDiagram();e.preventDefault();}
if((e.ctrlKey||e.metaKey)&&e.key==='f'){fitToScreen();e.preventDefault();}
});
if(DIAGRAM_ID){
fetch('/architecture/render/'+DIAGRAM_ID).then(r=>r.json()).then(data=>{
if(data.nodes)data.nodes.forEach(n=>addNode(n.type,n.x,n.y,n.id,n));
if(data.edges)edges=data.edges.map(normalizeEdge);
renderEdges();updateStats();saveState();setTimeout(fitToScreen,100);
});
}
updateStats();saveState();
setDefaultFlowMode('both');
if(RACK_ID && !document.getElementById('diagramName').value){
const dt=document.getElementById('diagramType').value;
document.getElementById('diagramName').value=(dt==='application_landscape'?'Applicatie Landschap':'Applicatie Flow')+' — Rack '+RACK_ID;
}
if(typeof lucide!=='undefined')lucide.createIcons();
</script>
<script src="/js/theme.js"></script></body>
</html>
+129
View File
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Architectuur Diagrammen — 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" type="image/svg+xml">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.arch-header{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
.arch-header h1{font-size:1.3rem;font-weight:700;display:flex;align-items:center;gap:.5rem}
.filter-bar{display:flex;gap:.4rem;align-items:center;flex-wrap:wrap;margin-bottom:1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:.5rem .65rem}
.filter-bar input,.filter-bar select{background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:.3rem .5rem;font-size:.75rem;color:var(--text);font-family:inherit}
.filter-bar input{flex:1;min-width:120px}
.filter-bar select{width:auto;min-width:100px}
.filter-bar .f-stat{font-size:.68rem;color:var(--text-dim);margin-left:auto;white-space:nowrap}
.btn{display:inline-flex;align-items:center;gap:.3rem;padding:.4rem .75rem;border-radius:var(--radius);font-size:.78rem;font-weight:500;cursor:pointer;text-decoration:none;transition:all .12s;font-family:inherit;border:none}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:var(--accent-hover);opacity:.9}
.btn-ghost{background:var(--bg);color:var(--text-muted);border:1px solid var(--border)}
.btn-ghost:hover{border-color:var(--text-dim);color:var(--text)}
.btn-red{background:var(--red-bg);color:var(--red)}
.btn-red:hover{background:#4d1a1a}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:.65rem}
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:0;overflow:hidden;transition:all .15s;position:relative}
.card:hover{border-color:var(--accent);box-shadow:0 4px 20px rgba(88,166,255,.08)}
.card-top{height:6px}
.card-top.data_architecture{background:var(--accent)}
.card-top.network_topology{background:#34d399}
.card-top.system_architecture{background:var(--purple)}
.card-body{padding:.9rem 1rem}
.card-body h3{font-size:.88rem;font-weight:600;color:var(--text);margin:0 0 .2rem}
.card-body .c-meta{font-size:.68rem;color:var(--text-muted);display:flex;gap:.5rem;flex-wrap:wrap;align-items:center}
.card-body .c-meta span{display:flex;align-items:center;gap:.25rem}
.card-body .c-desc{font-size:.7rem;color:var(--text-dim);margin-top:.3rem;line-height:1.3}
.card-actions{display:flex;gap:.3rem;padding:.5rem 1rem;border-top:1px solid var(--border-light)}
.card-actions .btn{font-size:.65rem;padding:.2rem .5rem}
.empty-state{border:2px dashed var(--border);border-radius:var(--radius-lg);padding:2rem;text-align:center}
.empty-state i{font-size:2rem;color:var(--text-dim);margin-bottom:.5rem;display:block}
.empty-state h3{font-size:1rem;color:var(--text);margin-bottom:.3rem}
.empty-state p{font-size:.78rem;color:var(--text-muted)}
.badge{font-size:.6rem;padding:.1rem .45rem;border-radius:999px;font-weight:500}
.badge-data_architecture{background:rgba(88,166,255,.15);color:var(--accent)}
.badge-network_topology{background:rgba(52,211,153,.15);color:#34d399}
.badge-system_architecture{background:rgba(168,85,247,.15);color:var(--purple)}
.type-label{font-size:.68rem;display:flex;align-items:center;gap:.25rem}
@media(max-width:600px){.filter-bar{flex-direction:column}.filter-bar input{width:100%}}
</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="container" style="padding-top:1rem">
<div class="arch-header">
<h1><i data-lucide="layers" style="width:20px;height:20px"></i> Architectuur Diagrammen</h1>
<a href="/architecture/new" class="btn btn-primary"><i data-lucide="plus" style="width:14px;height:14px"></i> Nieuw Diagram</a>
</div>
<form method="GET" action="/architecture">
<div class="filter-bar">
<input type="text" name="search" placeholder="Zoek op naam of beschrijving..." value="<%= filters.search || '' %>" oninput="this.form.submit()">
<select name="client_id" onchange="this.form.submit()">
<option value="">Alle cliënten</option>
<% clients.forEach(function(c){ %>
<option value="<%= c.id %>" <%= filters.client_id == c.id ? 'selected' : '' %>><%= c.name %></option>
<% }) %>
</select>
<select name="diagram_type" onchange="this.form.submit()">
<option value="">Alle types</option>
<option value="data_architecture" <%= filters.diagram_type === 'data_architecture' ? 'selected' : '' %>>Data Architectuur</option>
<option value="network_topology" <%= filters.diagram_type === 'network_topology' ? 'selected' : '' %>>Netwerk Topologie</option>
<option value="system_architecture" <%= filters.diagram_type === 'system_architecture' ? 'selected' : '' %>>Systeem Architectuur</option>
<option value="application_flow" <%= filters.diagram_type === 'application_flow' ? 'selected' : '' %>>Applicatie Flow</option>
<option value="application_landscape" <%= filters.diagram_type === 'application_landscape' ? 'selected' : '' %>>Applicatie Landschap</option>
</select>
<span class="f-stat"><%= diagrams.length %> diagram<%= diagrams.length !== 1 ? 'men' : '' %></span>
<% if (filters.search || filters.client_id || filters.diagram_type) { %>
<a href="/architecture" class="btn btn-ghost" style="font-size:.65rem;padding:.2rem .5rem"><i data-lucide="x" style="width:12px;height:12px"></i> Wis filters</a>
<% } %>
</div>
</form>
<% if (diagrams.length === 0) { %>
<div class="empty-state">
<i data-lucide="file-text"></i>
<h3>Geen diagrammen gevonden</h3>
<% if (filters.search || filters.client_id || filters.diagram_type) { %>
<p>Probeer andere filters of <a href="/architecture" class="link">wis alle filters</a>.</p>
<% } else { %>
<p>Maak je eerste architectuur-diagram om data pipelines, netwerken en systemen te ontwerpen.</p>
<a href="/architecture/new" class="btn btn-primary" style="margin-top:.75rem"><i data-lucide="plus" style="width:14px;height:14px"></i> Nieuw Diagram</a>
<% } %>
</div>
<% } else { %>
<div class="grid">
<% diagrams.forEach(function(d) { %>
<div class="card">
<div class="card-top <%= d.diagram_type %>"></div>
<a href="/architecture/<%= d.id %>" style="text-decoration:none;color:inherit">
<div class="card-body">
<h3><%= d.name %></h3>
<div class="c-meta">
<span><i data-lucide="building" style="width:12px;height:12px"></i> <%= d.client_name %></span>
<span class="badge-<%= d.diagram_type %> badge"><%= d.diagram_type.replace('_',' ') %></span>
<span><i data-lucide="box" style="width:12px;height:12px"></i> <%= d.node_count || 0 %> comp.</span>
</div>
<% if (d.description) { %>
<div class="c-desc"><%= d.description.length > 100 ? d.description.substring(0,100) + '...' : d.description %></div>
<% } %>
</div>
</a>
<div class="card-actions">
<a href="/architecture/<%= d.id %>" class="btn btn-ghost"><i data-lucide="edit-3" style="width:12px;height:12px"></i> Bewerk</a>
<a href="/architecture/report/<%= d.id %>" class="btn btn-ghost"><i data-lucide="file-text" style="width:12px;height:12px"></i> Rapport</a>
<form method="POST" action="/architecture/<%= d.id %>/delete" style="margin-left:auto" onsubmit="return confirm('Diagram \"<%= d.name %>\" verwijderen?')">
<button class="btn btn-red" type="submit"><i data-lucide="trash-2" style="width:12px;height:12px"></i></button>
</form>
</div>
</div>
<% }) %>
</div>
<% } %>
</div>
<script>lucide.createIcons()</script>
<script src="/js/theme.js"></script></body>
</html>
+137
View File
@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Architectuur Rapport — <%= diagram.name %> — 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" type="image/svg+xml">
<style>
.report-wrap{max-width:900px;margin:0 auto;padding:2rem 1.5rem}
.report-header{text-align:center;padding:1.5rem 0;border-bottom:2px solid var(--accent);margin-bottom:1.5rem}
.report-header h1{font-size:1.4rem;color:var(--text);margin-bottom:.25rem}
.report-header .sub{color:var(--text-muted);font-size:.85rem}
.report-header .meta{color:var(--text-dim);font-size:.75rem;margin-top:.25rem}
.section-title{font-size:1rem;font-weight:600;color:var(--text);border-bottom:1px solid var(--border);padding-bottom:.35rem;margin-bottom:.75rem;display:flex;justify-content:space-between;align-items:center}
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.5rem;margin-bottom:1.5rem}
.summary-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:.6rem .75rem;text-align:center}
.summary-card .num{font-size:1.5rem;font-weight:700;color:var(--accent)}
.summary-card .lbl{font-size:.65rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px}
.node-table{width:100%;border-collapse:collapse}
.node-table th{text-align:left;padding:.4rem .5rem;font-size:.65rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;border-bottom:1px solid var(--border);background:var(--bg-card)}
.node-table td{padding:.35rem .5rem;border-bottom:1px solid var(--border-light);font-size:.75rem;color:var(--text)}
.node-table tr:hover td{background:var(--border-light)}
.node-table .vendor-tag{display:inline-block;padding:.05rem .35rem;border-radius:3px;font-size:.6rem;font-weight:500;color:#fff}
.conn-table{width:100%;border-collapse:collapse}
.conn-table th{text-align:left;padding:.35rem .5rem;font-size:.65rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;border-bottom:1px solid var(--border);background:var(--bg-card)}
.conn-table td{padding:.3rem .5rem;border-bottom:1px solid var(--border-light);font-size:.7rem;color:var(--text)}
.type-chart{display:flex;flex-wrap:wrap;gap:.35rem;margin-bottom:1rem}
.type-chart .tc-item{display:flex;align-items:center;gap:.35rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:.35rem .5rem;font-size:.7rem}
.type-chart .tc-dot{width:8px;height:8px;border-radius:50%}
.action-bar{display:flex;gap:.5rem;justify-content:center;margin-top:1.5rem;padding:1rem 0;border-top:1px solid var(--border)}
@media print{.navbar,.action-bar{display:none!important}.report-wrap{padding:0}.node-table th{background:#f0f0f0}.summary-card{background:#f5f5f5;border-color:#ddd}}
</style>
<script src="https://unpkg.com/lucide@latest"></script></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="report-wrap">
<header class="report-header">
<h1><%= diagram.name %></h1>
<div class="sub"><%= diagram.client_name %></div>
<div class="meta">
<%= diagram.diagram_type.replace(/_/g,' ') %> ·
<%= nodes.length %> componenten ·
<%= edges.length %> connecties ·
<%= new Date(diagram.updated_at).toLocaleDateString('nl-NL',{year:'numeric',month:'long',day:'numeric'}) %>
</div>
</header>
<section style="margin-bottom:1.5rem">
<h3 class="section-title">Samenvatting</h3>
<div class="summary-grid">
<div class="summary-card"><div class="num"><%= nodes.length %></div><div class="lbl">Componenten</div></div>
<div class="summary-card"><div class="num"><%= edges.length %></div><div class="lbl">Connecties</div></div>
<div class="summary-card"><div class="num"><%= Object.keys(typeCount).length %></div><div class="lbl">Type Categorieën</div></div>
<div class="summary-card"><div class="num"><%= Object.keys(vendorCount).length %></div><div class="lbl">Vendoren</div></div>
</div>
<div class="type-chart">
<% var colors={source:'#d29922',ingest:'#58a6ff',storage:'#3fb950',compute:'#a855f7',hw:'#ff7b00',net:'#34d399',ai:'#fb7185',queue:'#38bdf8',security:'#f472b6',infra:'#6e7681',other:'#8b949e'}; %>
<% Object.entries(typeCount).forEach(function(e){ %>
<div class="tc-item"><div class="tc-dot" style="background:<%= colors[e[0]]||'#8b949e' %>"></div><%= e[0] %>: <%= e[1] %></div>
<% }) %>
</div>
</section>
<section style="margin-bottom:1.5rem">
<h3 class="section-title">Componenten Detail</h3>
<% if (nodes.length === 0) { %>
<div class="empty-state"><p>Geen componenten in dit diagram.</p></div>
<% } else { %>
<table class="node-table">
<thead>
<tr><th>#</th><th>Label</th><th>Type</th><th>Vendor</th><th>Model</th><th>IP Adres</th><th>Specs</th></tr>
</thead>
<tbody>
<% nodes.forEach(function(n,i){ %>
<tr>
<td style="color:var(--text-dim)"><%= i+1 %></td>
<td><strong><%= n.label||'—' %></strong></td>
<td><%= (n.type||'').split('-').pop() %></td>
<td>
<% if(n.vendor){ %>
<% var vc='#555d66';var vl=(n.vendor||'').toLowerCase(); %>
<% if(vl.includes('hpe')) vc='#ff7b00';else if(vl.includes('dell')) vc='#0076a8';else if(vl.includes('lenovo')) vc='#e41f33';else if(vl.includes('cisco')) vc='#00529a';else if(vl.includes('juniper')) vc='#44a833';else if(vl.includes('arista')) vc='#cc3333';else if(vl.includes('netapp')) vc='#00a1df';else if(vl.includes('pure')) vc='#4a90d9';else if(vl.includes('fortinet')) vc='#ee3124';else if(vl.includes('apc')) vc='#6e7681';else if(vl.includes('nvidia')) vc='#76b900';%>
<span class="vendor-tag" style="background:<%= vc %>"><%= n.vendor %></span>
<% } else { %>—<% } %>
</td>
<td><%= n.model||'—' %></td>
<td><%= n.ip||'—' %></td>
<td style="font-size:.65rem;color:var(--text-muted);max-width:200px;white-space:pre-wrap"><%= n.specs||'—' %></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</section>
<section style="margin-bottom:1.5rem">
<h3 class="section-title">Connecties (<%= edges.length %>)</h3>
<% if (edges.length === 0) { %>
<div class="empty-state"><p>Geen connecties in dit diagram.</p></div>
<% } else { %>
<table class="conn-table">
<thead><tr><th>#</th><th>Van</th><th>Naar</th></tr></thead>
<tbody>
<% edges.forEach(function(e,i){
var from=nodes.find(function(n){return n.id==e.from});
var to=nodes.find(function(n){return n.id==e.to});
%>
<tr>
<td style="color:var(--text-dim)"><%= i+1 %></td>
<td><%= from?from.label+' ('+from.type.split('-').pop()+')':'ID:'+e.from %></td>
<td>→ <%= to?to.label+' ('+to.type.split('-').pop()+')':'ID:'+e.to %></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</section>
<% if (diagram.description) { %>
<section style="margin-bottom:1.5rem">
<h3 class="section-title">Beschrijving</h3>
<div class="card"><p style="white-space:pre-wrap;font-size:.85rem"><%= diagram.description %></p></div>
</section>
<% } %>
<div class="action-bar">
<button class="btn btn-primary" onclick="window.print()">📄 Download PDF</button>
<button class="btn btn-secondary" onclick="window.location='/architecture/<%= diagram.id %>'">Open Designer</button>
</div>
</div>
<script src="/js/theme.js"></script><script>lucide.createIcons()</script></body>
</html>
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Consultancy Framework — 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" type="image/svg+xml">
</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="container">
<header class="header">
<div class="logo">
<img src="/img/logo.svg" style="height:48px;width:auto;margin-right:.75rem" alt="Mek-Tech">
<div>
<h1>AI Consultancy Framework</h1>
<p class="subtitle">Mek-Tech.nl — Datacenter & AI Architectuur</p>
</div>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill" style="width: 0%"></div>
</div>
</header>
<form id="consultingForm" action="/assess/report" method="POST">
<div class="field" style="margin-bottom:1.5rem">
<label for="_client_id">Koppel aan cliënt (optioneel)</label>
<select name="_client_id" id="_client_id">
<option value="">— Geen cliënt —</option>
<% clients.forEach(function(c) { %>
<option value="<%= c.id %>" <%= clientId == c.id ? 'selected' : '' %>><%= c.name %></option>
<% }) %>
</select>
</div>
<input type="hidden" name="_engagement_id" value="<%= engagementId || '' %>">
<% questions.forEach(function(q, index) { %>
<div class="step" id="step<%= index %>" style="display: <%= index === 0 ? 'block' : 'none' %>">
<div class="step-header">
<span class="step-number">Stap <%= index + 1 %> / <%= questions.length %></span>
<h2><%= q.title %></h2>
</div>
<div class="fields">
<% q.fields.forEach(function(field) { %>
<div class="field">
<label for="<%= field.name %>"><%= field.label %></label>
<% if (field.type === 'textarea') { %>
<textarea name="<%= field.name %>" id="<%= field.name %>" rows="3" <%= field.required ? 'required' : '' %>></textarea>
<% } else { %>
<input type="<%= field.type %>" name="<%= field.name %>" id="<%= field.name %>" <%= field.required ? 'required' : '' %>>
<% } %>
</div>
<% }) %>
</div>
<div class="step-actions">
<% if (index > 0) { %>
<button type="button" class="btn btn-secondary" onclick="prevStep(<%= index %>)">Vorige</button>
<% } %>
<% if (index < questions.length - 1) { %>
<button type="button" class="btn btn-primary" onclick="nextStep(<%= index %>)">Volgende</button>
<% } else { %>
<button type="submit" class="btn btn-accent">Genereer Rapport</button>
<% } %>
</div>
</div>
<% }) %>
</form>
</div>
<script src="/js/main.js"></script>
<script src="/js/theme.js"></script></body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Audit Log — 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></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="container">
<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="page-header"><h1>Audit Log</h1><span style="color:var(--text-muted);font-size:.8rem"><%= total %> entries</span></div>
<table class="data-table"><thead><tr><th>Tijd</th><th>Gebruiker</th><th>Actie</th><th>Entiteit</th><th>Details</th></tr></thead><tbody>
<% entries.forEach(function(e){ %>
<tr><td style="font-size:.7rem"><%= new Date(e.created_at).toLocaleString('nl-NL') %></td><td><%= e.username || '-' %></td><td><code><%= e.action %></code></td><td><%= e.entity_type %><% if (e.entity_id) { %> #<%= e.entity_id %><% } %></td><td style="color:var(--text-muted);font-size:.75rem"><%= e.details || '' %></td></tr>
<% }) %>
</tbody></table>
<% if (pages > 1) { %><div style="display:flex;gap:.35rem;margin-top:1rem;justify-content:center">
<% for (var p = 1; p <= pages; p++) { %><a href="/audit?page=<%= p %>" class="btn <%= page === p ? 'btn-primary' : 'btn-secondary' %>" style="min-width:36px"><%= p %></a><% } %>
</div><% } %>
</div><script src="/js/theme.js"></script><script>lucide.createIcons()</script></body></html>
+129
View File
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Backup & Herstel — Mek-Tech</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/neon.css">
<link rel="stylesheet" href="/css/dynamic.css">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.bk-wrap{max-width:900px;margin:2rem auto;padding:0 1.5rem 3rem}
.bk-wrap h1{font-size:1.4rem;font-weight:800;margin:0 0 .2rem;display:flex;align-items:center;gap:.45rem}
.bk-wrap .sub{color:var(--text-muted);font-size:.78rem;margin-bottom:1.25rem}
.section{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.1rem 1.2rem;margin-bottom:1rem}
.section h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);margin:0 0 .75rem;display:flex;align-items:center;gap:.35rem}
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.45rem;margin-bottom:.5rem}
.stat-card{background:var(--bg);border:1px solid var(--border-light);border-radius:8px;padding:.55rem .65rem;text-align:center}
.stat-card .num{font-size:1.25rem;font-weight:800;color:var(--accent)}
.stat-card .lbl{font-size:.58rem;color:var(--text-muted);text-transform:uppercase;margin-top:.1rem}
.bk-actions{display:flex;gap:.45rem;flex-wrap:wrap;margin-top:.75rem}
.bk-table{width:100%;border-collapse:collapse;font-size:.75rem}
.bk-table th{text-align:left;padding:.45rem .5rem;font-size:.62rem;color:var(--text-muted);text-transform:uppercase;border-bottom:1px solid var(--border)}
.bk-table td{padding:.45rem .5rem;border-bottom:1px solid var(--border-light)}
.bk-table tr:hover td{background:var(--border-light)}
.alert{background:var(--green-bg);border:1px solid var(--green);color:var(--green);padding:.55rem .75rem;border-radius:8px;font-size:.76rem;margin-bottom:1rem}
.alert-warn{background:var(--orange-bg);border-color:var(--orange);color:var(--orange)}
.alert-err{background:var(--red-bg);border-color:var(--red);color:var(--red)}
.restore-box{border:1px dashed var(--border);border-radius:8px;padding:1rem;margin-top:.5rem}
.restore-box p{font-size:.72rem;color:var(--text-muted);margin:0 0 .65rem;line-height:1.45}
.restore-box input[type=file]{font-size:.72rem;margin-bottom:.5rem;width:100%}
.info-list{font-size:.72rem;color:var(--text-muted);margin:.35rem 0 0;padding-left:1.1rem;line-height:1.5}
</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;gap:.4rem;align-items:center">
<a href="/settings" class="nav-icon-btn"><i data-lucide="settings" style="width:15px;height:15px"></i> Instellingen</a>
<a href="/" class="nav-icon-btn"><i data-lucide="layout-dashboard" style="width:15px;height:15px"></i></a>
<a href="/auth/logout" class="nav-icon-btn" style="color:var(--red)"><i data-lucide="log-out" style="width:15px;height:15px"></i></a>
</div></div></nav>
<div class="bk-wrap">
<a href="/settings" 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 naar instellingen</a>
<h1><i data-lucide="database-backup" style="width:22px;height:22px;color:var(--accent)"></i> Backup & Herstel</h1>
<p class="sub">Volledige backup van database, sessies, uploads en alle cliëntdata.</p>
<% if (created) { %><div class="alert"><i data-lucide="check-circle" style="width:14px;height:14px"></i> Backup succesvol aangemaakt op de server.</div><% } %>
<% if (restored) { %><div class="alert"><i data-lucide="check-circle" style="width:14px;height:14px"></i> Herstel voltooid. Vernieuw het dashboard — bij problemen herstart de container.</div><% } %>
<% if (deleted) { %><div class="alert">Backup verwijderd.</div><% } %>
<% if (error) { %><div class="alert alert-err"><%= error %></div><% } %>
<div class="section">
<h2><i data-lucide="bar-chart-2" style="width:14px;height:14px"></i> Huidige data</h2>
<div class="stat-grid">
<div class="stat-card"><div class="num"><%= stats.counts.clients || 0 %></div><div class="lbl">Cliënten</div></div>
<div class="stat-card"><div class="num"><%= stats.counts.engagements || 0 %></div><div class="lbl">Engagements</div></div>
<div class="stat-card"><div class="num"><%= stats.counts.racks || 0 %></div><div class="lbl">Racks</div></div>
<div class="stat-card"><div class="num"><%= stats.counts.data_diagrams || 0 %></div><div class="lbl">Diagrammen</div></div>
<div class="stat-card"><div class="num"><%= stats.counts.projects || 0 %></div><div class="lbl">Projecten</div></div>
<div class="stat-card"><div class="num"><%= stats.counts.invoices || 0 %></div><div class="lbl">Facturen</div></div>
</div>
<p style="font-size:.68rem;color:var(--text-dim);margin:0">
Database: <%= formatBytes(stats.dbSizeBytes) %>
<% if (stats.uploadFiles) { %> · Uploads: <%= stats.uploadFiles %> bestanden (<%= formatBytes(stats.uploadsBytes) %>)<% } %>
<% if (diskFree) { %> · Schijf vrij: <%= diskFree %><% } %>
</p>
</div>
<div class="section">
<h2><i data-lucide="download" style="width:14px;height:14px"></i> Nieuwe backup maken</h2>
<p style="font-size:.74rem;color:var(--text-muted);margin:0 0 .5rem">Maakt een .tar.gz archief met:</p>
<ul class="info-list">
<li><strong>consulting.db</strong> — alle cliënten, racks, diagrammen, facturen, instellingen</li>
<li><strong>sessions.db</strong> — actieve sessies</li>
<li><strong>uploads/</strong> — geüploade data quality bestanden</li>
</ul>
<div class="bk-actions">
<form method="POST" action="/backup/create?download=1" style="display:inline"><button type="submit" class="btn btn-accent btn-small"><i data-lucide="download" style="width:14px;height:14px"></i> Backup maken & downloaden</button></form>
<form method="POST" action="/backup/create" style="display:inline"><button type="submit" class="btn btn-secondary btn-small"><i data-lucide="save" style="width:14px;height:14px"></i> Opslaan op server</button></form>
</div>
</div>
<div class="section">
<h2><i data-lucide="archive" style="width:14px;height:14px"></i> Backups op server (<%= backups.length %>)</h2>
<% if (backups.length === 0) { %>
<p style="font-size:.74rem;color:var(--text-muted)">Nog geen backups opgeslagen op de server.</p>
<% } else { %>
<table class="bk-table">
<thead><tr><th>Bestand</th><th>Grootte</th><th>Datum</th><th></th></tr></thead>
<tbody>
<% backups.forEach(function(b) { %>
<tr>
<td><strong><%= b.filename %></strong></td>
<td><%= b.sizeLabel %></td>
<td><%= new Date(b.created_at).toLocaleString('nl-NL') %></td>
<td style="white-space:nowrap">
<a href="/backup/download/<%= b.filename %>" class="btn btn-secondary btn-small">Download</a>
<form method="POST" action="/backup/restore/<%= b.filename %>" style="display:inline" onsubmit="return confirm('WAARSCHUWING: Alle huidige data wordt overschreven. Er wordt eerst automatisch een veiligheidsbackup gemaakt. Doorgaan?')">
<button type="submit" class="btn btn-secondary btn-small">Herstellen</button>
</form>
<form method="POST" action="/backup/delete/<%= b.filename %>" style="display:inline" onsubmit="return confirm('Backup verwijderen?')">
<button type="submit" class="btn btn-secondary btn-small" style="color:var(--red)">×</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div>
<div class="section">
<h2><i data-lucide="upload" style="width:14px;height:14px"></i> Herstellen vanaf bestand</h2>
<div class="restore-box">
<p><strong>Let op:</strong> Herstellen overschrijft alle huidige data. Vóór herstel wordt automatisch een veiligheidsbackup gemaakt op de server.</p>
<form method="POST" action="/backup/restore" enctype="multipart/form-data" onsubmit="return confirm('Weet je zeker dat je wilt herstellen? Huidige data wordt overschreven.')">
<input type="file" name="backup_file" accept=".tar.gz,.tgz,application/gzip" required>
<button type="submit" class="btn btn-accent btn-small"><i data-lucide="upload" style="width:14px;height:14px"></i> Backup uploaden & herstellen</button>
</form>
</div>
</div>
</div>
<script>lucide.createIcons();</script>
<script src="/js/theme.js"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Agenda — 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></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="container">
<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="page-header"><h1>Calendar</h1><button class="btn btn-primary" onclick="document.getElementById('eventForm').style.display='block'">+ Nieuw event</button></div>
<div class="form-card" id="eventForm" style="display:none;margin-bottom:1rem">
<form method="POST" action="/calendar/new">
<div class="form-grid">
<div class="field"><label>Titel</label><input type="text" name="title" required></div>
<div class="field"><label>Type</label><select name="event_type"><option value="meeting">Meeting</option><option value="call">Call</option><option value="deadline">Deadline</option><option value="workshop">Workshop</option><option value="review">Review</option><option value="other">Anders</option></select></div>
<div class="field"><label>Cliënt</label><select name="client_id"><option value="">Geen</option><% clients.forEach(function(c){ %><option value="<%= c.id %>"><%= c.name %></option><% }) %></select></div>
<div class="field"><label>Engagement</label><select name="engagement_id"><option value="">Geen</option><% engagements.forEach(function(e){ %><option value="<%= e.id %>"><%= e.title %></option><% }) %></select></div>
<div class="field"><label>Start</label><input type="datetime-local" name="start_time" required></div>
<div class="field"><label>Einde</label><input type="datetime-local" name="end_time"></div>
<div class="field"><label>Hele dag</label><input type="checkbox" name="all_day" value="1"></div>
<div class="field"><label>Kleur</label><input type="color" name="color" value="#58a6ff"></div>
<div class="field" style="grid-column:1/-1"><label>Omschrijving</label><textarea name="description" rows="2"></textarea></div>
</div>
<div class="form-actions"><button class="btn btn-primary">Opslaan</button><button type="button" class="btn btn-secondary" onclick="this.closest('.form-card').style.display='none'">Annuleren</button></div>
</form></div>
<div class="card">
<div class="card-title">Komende events</div>
<% if (events.length === 0) { %><div class="empty-state-s">Geen events</div><% } else { %>
<div class="timeline">
<% events.forEach(function(e){ %>
<div class="tl-item">
<div class="tl-dot" style="background:<%= e.color || '#58a6ff' %>"></div>
<div class="tl-body">
<div class="tl-title"><%= e.title %></div>
<div class="tl-date"><%= new Date(e.start_time).toLocaleDateString('nl-NL',{weekday:'long',year:'numeric',month:'long',day:'numeric',hour:'2-digit',minute:'2-digit'}) %><% if (e.client_name) { %> · <%= e.client_name %><% } %></div>
<% if (e.description) { %><div style="font-size:.7rem;color:var(--text-muted);margin-top:.15rem"><%= e.description %></div><% } %>
</div>
<form method="POST" action="/calendar/<%= e.id %>/delete" style="flex-shrink:0"><button class="btn-small danger">✕</button></form>
</div>
<% }) %>
</div>
<% } %>
</div>
</div>
<script>lucide.createIcons()</script><script src="/js/theme.js"></script></body></html>
+403
View File
@@ -0,0 +1,403 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= client.name %> — Mek-Tech Consulting</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" type="image/svg+xml">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 1.25rem; overflow-x: auto; }
.tab-btn {
padding: .5rem 1rem; background: none; border: none; border-bottom: 2px solid transparent;
color: var(--text-muted); font-size: .8rem; font-weight: 500; cursor: pointer;
white-space: nowrap; transition: all .15s; font-family: inherit;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.file-item { display: flex; align-items: center; gap: .5rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .75rem; }
.file-icon { font-size: 1.1rem; flex-shrink: 0; }
.file-info { flex: 1; }
.file-name { font-size: .85rem; color: var(--text); }
.file-meta { font-size: .7rem; color: var(--text-muted); }
.note-item { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; border-left: 3px solid var(--accent); }
.note-meta { font-size: .7rem; color: var(--text-muted); }
.note-content { font-size: .85rem; color: var(--text); white-space: pre-wrap; margin-top: .25rem; }
.diagram-item { display: flex; align-items: center; gap: .5rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .75rem; text-decoration: none; color: inherit; transition: all .15s; }
.diagram-item:hover { border-color: var(--text-dim); }
.diagram-icon { width: 32px; height: 32px; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .65rem; flex-shrink: 0; }
.diagram-icon.data_architecture { background: var(--cyan-bg); color: var(--cyan); }
.diagram-icon.network_topology { background: var(--purple-bg); color: var(--purple); }
.diagram-icon.system_architecture { background: var(--green-bg); color: var(--green); }
.client-email-btn { display: inline-flex; align-items: center; gap: .35rem; }
.files-grid { display: flex; flex-direction: column; gap: .35rem; }
.notes-list { display: flex; flex-direction: column; gap: .5rem; }
.inline-form { display: inline; }
.upload-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 1.5rem; text-align: center; cursor: pointer; transition: all .15s; margin-bottom: .75rem; }
.upload-zone:hover { border-color: var(--accent); background: var(--border-light); }
.upload-zone p { color: var(--text-muted); font-size: .8rem; }
.tab-btn { display: inline-flex; align-items: center; gap: .4rem; }
.tab-btn i { width: 14px; height: 14px; }
</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="container">
<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="page-header">
<div>
<h1><%= client.name %></h1>
</div>
<div class="header-actions">
<a href="/assess/<%= client.id %>" class="btn btn-accent">Nieuw Assessment</a>
<a href="/engagements/new/<%= client.id %>" class="btn btn-primary">+ Engagement</a>
<a href="/clients/<%= client.id %>/edit" class="btn btn-secondary">Bewerk</a>
</div>
</div>
<div class="tabs" id="tabs">
<button class="tab-btn active" data-tab="overview"><i data-lucide="eye"></i> Overzicht</button>
<button class="tab-btn" data-tab="contact"><i data-lucide="phone"></i> Contact</button>
<button class="tab-btn" data-tab="designs"><i data-lucide="file-text"></i> Designs (<%= diagrams.length %>)</button>
<button class="tab-btn" data-tab="notes"><i data-lucide="edit-3"></i> Notities (<%= notes.length %>)</button>
<button class="tab-btn" data-tab="files"><i data-lucide="folder"></i> Bestanden (<%= files.length %>)</button>
<button class="tab-btn" data-tab="engagements"><i data-lucide="briefcase"></i> Engagements (<%= engagements.length %>)</button>
<button class="tab-btn" data-tab="assessments"><i data-lucide="clipboard-list"></i> Assessments (<%= assessments.length %>)</button>
</div>
<!-- OVERVIEW -->
<div class="tab-panel active" id="panel-overview">
<div class="detail-grid">
<div class="detail-card">
<h3>Bedrijfsgegevens</h3>
<table class="info-table">
<tr><td>Status</td><td><span class="status-badge <%= client.status %>"><%= client.status %></span></td></tr>
<% if (client.industry) { %><tr><td>Sector</td><td><%= client.industry %></td></tr><% } %>
<% if (client.website) { %><tr><td>Website</td><td><a href="<%= client.website %>" target="_blank" class="link"><%= client.website %></a></td></tr><% } %>
<% if (client.source) { %><tr><td>Bron</td><td><%= client.source %></td></tr><% } %>
<tr><td>Aangemaakt</td><td><%= new Date(client.created_at).toLocaleDateString('nl-NL') %></td></tr>
<tr><td>Bijgewerkt</td><td><%= new Date(client.updated_at).toLocaleDateString('nl-NL') %></td></tr>
</table>
</div>
<div class="detail-card" style="position:relative">
<h3>Contact</h3>
<table class="info-table">
<% if (client.contact_name) { %><tr><td>Naam</td><td><%= client.contact_name %></td></tr><% } %>
<% if (client.contact_email) { %><tr><td>Email</td><td><a href="#" onclick="openEmailModal();return false" class="link"><%= client.contact_email %></a></td></tr><% } %>
<% if (client.contact_phone) { %><tr><td>Telefoon</td><td><a href="tel:<%= client.contact_phone %>" class="link"><%= client.contact_phone %></a></td></tr><% } %>
</table>
<% if (client.contact_email) { %>
<div style="margin-top:.75rem;display:flex;gap:.4rem;flex-wrap:wrap">
<a href="#" onclick="openEmailModal();return false" class="btn btn-accent" style="display:inline-flex;align-items:center;gap:.3rem;font-size:.75rem"><i data-lucide="send" style="width:14px;height:14px"></i> E-mail sturen</a>
<a href="#" onclick="openEmailModal('template');return false" class="btn btn-secondary" style="display:inline-flex;align-items:center;gap:.3rem;font-size:.75rem"><i data-lucide="file-text" style="width:14px;height:14px"></i> Met template</a>
</div>
<% } %>
</div>
</div>
<% if (diagrams.length > 0) { %>
<section class="section">
<h3 class="section-title">Recente Designs</h3>
<div class="diagram-grid">
<% diagrams.slice(0, 4).forEach(function(d) { %>
<a href="/architecture/<%= d.id %>" class="diagram-card">
<div class="diagram-card-type <%= d.diagram_type %>"><%= d.diagram_type === 'data_architecture' ? 'DA' : d.diagram_type === 'network_topology' ? 'NT' : 'SA' %></div>
<div class="diagram-card-body">
<h4><%= d.name %></h4>
<span style="font-size:.7rem;color:var(--text-muted)"><%= new Date(d.updated_at).toLocaleDateString('nl-NL') %></span>
</div>
<!-- EMAIL MODAL -->
<div class="modal-overlay" id="emailModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.65);z-index:999;display:none;align-items:center;justify-content:center">
<div class="modal-box" style="background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:0;width:95%;max-width:680px;max-height:85vh;display:flex;flex-direction:column;overflow:hidden;animation:fadeInUp .25s ease">
<div style="padding:.8rem 1rem;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;background:var(--bg-nav)">
<h3 style="font-size:.85rem;color:var(--text);font-weight:600;display:flex;align-items:center;gap:.4rem"><i data-lucide="send" style="color:var(--accent);width:16px;height:16px"></i> Email naar <%= client.name %></h3>
<button onclick="closeEmailModal()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:1.1rem;padding:.2rem">✕</button></div>
<div style="overflow-y:auto;padding:1rem;flex:1">
<form id="emailForm" method="POST" action="/clients/<%= client.id %>/email/send" enctype="multipart/form-data">
<div style="margin-bottom:.6rem"><label style="font-size:.62rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px">Aan</label><input type="email" name="to" id="emTo" value="<%= client.contact_email || '' %>" style="width:100%;padding:.4rem .55rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.78rem;font-family:inherit"></div>
<div style="display:flex;gap:.5rem;margin-bottom:.6rem"><div style="flex:1"><label style="font-size:.62rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px">CC</label><input type="text" name="cc" placeholder="cc@email.nl" style="width:100%;padding:.4rem .55rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.78rem;font-family:inherit"></div><div style="flex:1"><label style="font-size:.62rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px">BCC</label><input type="text" name="bcc" placeholder="bcc@email.nl" style="width:100%;padding:.4rem .55rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.78rem;font-family:inherit"></div></div>
<div style="margin-bottom:.6rem"><label style="font-size:.62rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px">Onderwerp</label><input type="text" name="subject" id="emSubject" value="Mek-Tech Consulting — <%= client.name %>" style="width:100%;padding:.4rem .55rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.78rem;font-family:inherit"></div>
<div style="margin-bottom:.6rem">
<div style="display:flex;gap:.3rem;margin-bottom:.3rem;flex-wrap:wrap">
<span style="font-size:.62rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px">Bericht</span>
<button type="button" onclick="formatText('bold')" title="Vet" style="background:var(--border-light);border:none;border-radius:4px;padding:.15rem .35rem;cursor:pointer;color:var(--text-muted);font-size:.65rem;font-weight:700">B</button>
<button type="button" onclick="formatText('italic')" title="Cursief" style="background:var(--border-light);border:none;border-radius:4px;padding:.15rem .35rem;cursor:pointer;color:var(--text-muted);font-size:.65rem;font-style:italic">I</button>
<button type="button" onclick="formatText('list')" title="Lijst" style="background:var(--border-light);border:none;border-radius:4px;padding:.15rem .35rem;cursor:pointer;color:var(--text-muted);font-size:.65rem">≡</button>
<button type="button" onclick="formatText('link')" title="Link" style="background:var(--border-light);border:none;border-radius:4px;padding:.15rem .35rem;cursor:pointer;color:var(--text-muted);font-size:.65rem">🔗</button>
</div>
<textarea name="text" id="emText" rows="10" placeholder="Typ uw bericht..." style="width:100%;padding:.5rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.78rem;font-family:inherit;line-height:1.6;resize:vertical;min-height:180px"></textarea>
</div>
<div style="margin-bottom:.75rem"><label style="font-size:.62rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px">Bijlages</label><input type="file" name="attachments" multiple style="font-size:.72rem;color:var(--text-muted);width:100%"><div style="font-size:.6rem;color:var(--text-dim);margin-top:.15rem">Max 5 bestanden. Kies de bestanden die je wilt meesturen.</div></div>
<div style="display:flex;gap:.4rem"><button type="submit" class="btn btn-accent" style="display:inline-flex;align-items:center;gap:.3rem;font-size:.72rem;padding:.4rem .85rem;border-radius:7px"><i data-lucide="send" style="width:14px;height:14px"></i> Versturen</button><button type="button" onclick="closeEmailModal()" class="btn btn-secondary" style="display:inline-flex;align-items:center;font-size:.72rem;padding:.4rem .85rem;border-radius:7px">Annuleren</button></div>
</form></div></div></div>
<style>.modal-overlay.open{display:flex!important}
.modal-overlay.open .modal-box{animation:fadeInUp .3s ease}
</style>
</a>
<% }) %>
</div>
<% if (diagrams.length > 4) { %>
<p style="margin-top:.5rem"><a href="#" class="link" onclick="switchTab('designs');return false">Alle <%= diagrams.length %> designs bekijken →</a></p>
<% } %>
</section>
<% } %>
<% if (advice.length > 0) { %>
<section class="section">
<h3 class="section-title">Advies</h3>
<div class="advice-list">
<% advice.forEach(function(a) { %>
<div class="advice-card <%= a.priority %>">
<div class="advice-prio <%= a.priority %>"><%= a.priority %></div>
<div class="advice-body"><p><%= a.advice_text %></p></div>
<form action="/clients/<%= client.id %>/advice/<%= a.id %>/dismiss" method="POST" style="margin-left:auto">
<button class="btn-small" type="submit">×</button>
</form>
</div>
<% }) %>
</div>
</section>
<% } %>
<% if (notes.length > 0) { %>
<section class="section">
<h3 class="section-title">Recente Notities</h3>
<div class="notes-list">
<% notes.slice(0, 3).forEach(function(n) { %>
<div class="note-item">
<div class="note-meta"><%= new Date(n.created_at).toLocaleDateString('nl-NL', {hour:'2-digit',minute:'2-digit'}) %><% if (n.created_by) { %> · <%= n.created_by %><% } %></div>
<div class="note-content"><%= n.content %></div>
</div>
<% }) %>
</div>
<% if (notes.length > 3) { %>
<p style="margin-top:.5rem"><a href="#" class="link" onclick="switchTab('notes');return false">Alle <%= notes.length %> notities bekijken →</a></p>
<% } %>
</section>
<% } %>
</div>
<!-- CONTACT -->
<div class="tab-panel" id="panel-contact">
<div class="detail-card">
<h3>Contactgegevens</h3>
<table class="info-table">
<% if (client.contact_name) { %><tr><td>Naam</td><td><%= client.contact_name %></td></tr><% } %>
<% if (client.contact_email) { %>
<tr><td>Email</td><td><a href="mailto:<%= client.contact_email %>" class="link"><%= client.contact_email %></a></td></tr>
<% } %>
<% if (client.contact_phone) { %><tr><td>Telefoon</td><td><a href="tel:<%= client.contact_phone %>" class="link"><%= client.contact_phone %></a></td></tr><% } %>
<% if (client.website) { %><tr><td>Website</td><td><a href="<%= client.website %>" target="_blank" class="link"><%= client.website %></a></td></tr><% } %>
</table>
<% if (client.contact_email) { %>
<div style="margin-top:1rem;display:flex;gap:.5rem;flex-wrap:wrap">
<a href="mailto:<%= client.contact_email %>?subject=Mek-Tech Consulting - <%= client.name %>" class="btn btn-accent">✉ E-mail sturen</a>
<a href="mailto:<%= client.contact_email %>?subject=Mek-Tech Consulting - <%= client.name %>&body=Geachte <%= client.contact_name || '' %>,%0D%0A%0D%0A" class="btn btn-secondary">✉ E-mail met template</a>
</div>
<% } %>
</div>
<% if (client.notes) { %>
<div class="detail-card" style="margin-top:.75rem">
<h3>Opgeslagen Notities bij Cliënt</h3>
<p style="white-space:pre-wrap;font-size:.85rem"><%= client.notes %></p>
</div>
<% } %>
</div>
<!-- DESIGNS -->
<div class="tab-panel" id="panel-designs">
<div class="section-header">
<h3 class="section-title">Designs & Diagrammen</h3>
<div style="display:flex;gap:.35rem">
<a href="/architecture/new/<%= client.id %>" class="btn btn-accent btn-small">+ Nieuw Design</a>
<a href="http://192.168.1.247:3001/python-editor/?client_id=<%= client.id %>" class="btn btn-secondary btn-small" target="_blank">Python Editor</a>
</div>
</div>
<% if (diagrams.length === 0) { %>
<div class="empty-state"><p>Nog geen designs voor deze cliënt.</p></div>
<% } else { %>
<div class="diagram-grid">
<% diagrams.forEach(function(d) { %>
<a href="/architecture/<%= d.id %>" class="diagram-card">
<div class="diagram-card-type <%= d.diagram_type %>"><%= d.diagram_type === 'data_architecture' ? 'DA' : d.diagram_type === 'network_topology' ? 'NT' : 'SA' %></div>
<div class="diagram-card-body">
<h4><%= d.name %></h4>
<% if (d.description) { %><span style="font-size:.75rem;color:var(--text-muted)"><%= d.description %></span><% } %>
<span style="font-size:.7rem;color:var(--text-dim);display:block;margin-top:.15rem"><%= new Date(d.updated_at).toLocaleDateString('nl-NL') %></span>
</div>
<a href="/architecture/report/<%= d.id %>" class="btn-small" style="position:absolute;bottom:.5rem;right:.5rem">Rapport</a>
</a>
<% }) %>
</div>
<% } %>
</div>
<!-- NOTES -->
<div class="tab-panel" id="panel-notes">
<div class="section-header">
<h3 class="section-title">Notities (<%= notes.length %>)</h3>
</div>
<form action="/clients/<%= client.id %>/notes" method="POST" class="form-card" style="margin-bottom:.75rem">
<div class="field">
<label for="note-content">Nieuwe notitie</label>
<textarea name="content" id="note-content" rows="3" placeholder="Typ hier je notitie..." style="margin-top:.25rem"></textarea>
</div>
<button type="submit" class="btn btn-accent" style="margin-top:.5rem">Notitie toevoegen</button>
</form>
<% if (notes.length === 0) { %>
<div class="empty-state"><p>Nog geen notities.</p></div>
<% } else { %>
<div class="notes-list">
<% notes.forEach(function(n) { %>
<div class="note-item">
<div style="display:flex;justify-content:space-between;align-items:center">
<div class="note-meta"><%= new Date(n.created_at).toLocaleDateString('nl-NL', {hour:'2-digit',minute:'2-digit'}) %><% if (n.created_by) { %> · <%= n.created_by %><% } %></div>
<form action="/clients/<%= client.id %>/notes/<%= n.id %>/delete" method="POST" class="inline-form">
<button class="btn-small danger" type="submit">Verwijder</button>
</form>
</div>
<div class="note-content"><%= n.content %></div>
</div>
<% }) %>
</div>
<% } %>
</div>
<!-- FILES -->
<div class="tab-panel" id="panel-files">
<div class="section-header">
<h3 class="section-title">Bestanden (<%= files.length %>)</h3>
</div>
<form action="/clients/<%= client.id %>/upload" method="POST" enctype="multipart/form-data">
<div class="upload-zone" onclick="document.getElementById('file-input').click()">
<p>Klik hier om bestanden te uploaden (max 100MB)</p>
</div>
<input type="file" name="file" id="file-input" style="display:none" onchange="this.form.submit()">
</form>
<% if (files.length === 0) { %>
<div class="empty-state"><p>Nog geen bestanden geüpload.</p></div>
<% } else { %>
<div class="files-grid">
<% files.forEach(function(f) { %>
<div class="file-item">
<span class="file-icon"><%= f.mime_type.startsWith('image/') ? '🖼' : f.mime_type.includes('pdf') ? '📄' : f.mime_type.includes('spreadsheet') || f.mime_type.includes('excel') ? '📊' : f.mime_type.includes('zip') || f.mime_type.includes('tar') || f.mime_type.includes('gzip') ? '📦' : '📎' %></span>
<div class="file-info">
<div class="file-name"><%= f.original_name %></div>
<div class="file-meta"><%= (f.file_size / 1024).toFixed(1) %> KB · <%= new Date(f.created_at).toLocaleDateString('nl-NL') %></div>
</div>
<% if (f.mime_type === 'application/pdf') { %><a href="/clients/<%= client.id %>/files/<%= f.id %>/view" class="btn-small">Bekijk</a><% } %>
<a href="/clients/<%= client.id %>/files/<%= f.id %>/download" class="btn-small">Download</a>
<form action="/clients/<%= client.id %>/files/<%= f.id %>/delete" method="POST" class="inline-form">
<button class="btn-small danger" type="submit">×</button>
</form>
</div>
<% }) %>
</div>
<% } %>
</div>
<!-- ENGAGEMENTS -->
<div class="tab-panel" id="panel-engagements">
<div class="section-header">
<h3 class="section-title">Engagements (<%= engagements.length %>)</h3>
<a href="/engagements/new/<%= client.id %>" class="btn btn-primary btn-small">+ Nieuw</a>
</div>
<% if (engagements.length === 0) { %>
<div class="empty-state"><p>Nog geen engagements. <a href="/engagements/new/<%= client.id %>" class="link">Start een engagement</a>.</p></div>
<% } else { %>
<div class="engagement-list">
<% engagements.forEach(function(e) { %>
<a href="/engagements/<%= e.id %>" class="engagement-row">
<span class="engagement-type <%= e.type %>"><%= e.type %></span>
<span class="engagement-title"><%= e.title %></span>
<span class="status-badge <%= e.status %>"><%= e.status %></span>
<span class="engagement-date"><%= e.start_date ? new Date(e.start_date).toLocaleDateString('nl-NL') : '' %></span>
</a>
<% }) %>
</div>
<% } %>
</div>
<!-- ASSESSMENTS -->
<div class="tab-panel" id="panel-assessments">
<div class="section-header">
<h3 class="section-title">Assessments (<%= assessments.length %>)</h3>
<a href="/assess/<%= client.id %>" class="btn btn-accent btn-small">+ Nieuw</a>
</div>
<% if (assessments.length === 0) { %>
<div class="empty-state"><p>Nog geen assessments uitgevoerd.</p></div>
<% } else { %>
<div class="assess-list">
<% assessments.slice().reverse().forEach(function(a) { %>
<div class="assess-item">
<span class="assess-date"><%= new Date(a.created_at).toLocaleDateString('nl-NL') %></span>
<span class="assess-id">Rapport #<%= a.id %></span>
<a href="/assess/report/<%= a.id %>" class="btn-small">Bekijk</a>
</div>
<% }) %>
</div>
<% } %>
</div>
</div>
<script>
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
var btn = document.querySelector('.tab-btn[data-tab="' + name + '"]');
var panel = document.getElementById('panel-' + name);
if (btn) btn.classList.add('active');
if (panel) panel.classList.add('active');
}
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
switchTab(this.getAttribute('data-tab'));
});
});
function openEmailModal(type) {
var m = document.getElementById('emailModal');
m.classList.add('open');
if (type === 'template') {
var tmpl = 'Geachte ' + '<%= client.contact_name || '' %>'.trim().replace(/'/g,"\\'") + ',\n\nBedankt voor uw interesse in Mek-Tech Consulting.\n\n' +
'Op basis van onze analyse zien wij kansen op het gebied van data-infrastructuur en AI-implementatie.\n\n' +
'Ik stel voor om een kennismakingsgesprek in te plannen om de mogelijkheden te bespreken.\n\n' +
'Met vriendelijke groet,\n<%= user.username %>\nMek-Tech Consulting';
document.getElementById('emText').value = tmpl;
document.getElementById('emSubject').value = 'Mek-Tech Consulting — Samenwerking met <%= client.name %>';
}
}
function closeEmailModal() { document.getElementById('emailModal').classList.remove('open'); }
document.getElementById('emailModal').addEventListener('click', function(e) { if (e.target === this) closeEmailModal(); });
function formatText(cmd) {
var ta = document.getElementById('emText');
var s = ta.selectionStart, e = ta.selectionEnd, t = ta.value;
if (cmd === 'bold') { ta.value = t.slice(0,s) + '**' + t.slice(s,e) + '**' + t.slice(e); ta.selectionStart = s+2; ta.selectionEnd = e+2; }
else if (cmd === 'italic') { ta.value = t.slice(0,s) + '_' + t.slice(s,e) + '_' + t.slice(e); ta.selectionStart = s+1; ta.selectionEnd = e+1; }
else if (cmd === 'list') { ta.value = t.slice(0,s) + '\n- ' + t.slice(s,e).replace(/\n/g,'\n- ') + '\n' + t.slice(e); }
else if (cmd === 'link') { var u = prompt('URL:'); if(u) ta.value = t.slice(0,s) + '[' + t.slice(s,e) + '](' + u + ')' + t.slice(e); }
ta.focus();
}
document.getElementById('emailForm').addEventListener('submit', function(e) {
var to = document.getElementById('emTo').value;
if (!to) { e.preventDefault(); alert('Vul een ontvanger in'); }
});
lucide.createIcons();
var hash = window.location.hash.replace('#', '');
if (hash && document.getElementById('panel-' + hash)) { switchTab(hash); }
<% if (typeof emailSent !== 'undefined' && emailSent) { %>
alert('Email verzonden!');
<% } %>
</script>
<script src="/js/theme.js"></script></body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= client ? 'Bewerk' : 'Nieuwe' %> Cliënt — Mek-Tech Consulting</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" type="image/svg+xml">
</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="container">
<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="page-header">
<div>
<a href="/clients" class="back-link">← Terug naar cliënten</a>
<h1><%= client ? 'Bewerk: ' + client.name : 'Nieuwe Cliënt' %></h1>
</div>
</div>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<form action="/clients/<%= client ? client.id + '/edit' : 'new' %>" method="POST" class="form-card">
<div class="form-grid">
<div class="field">
<label for="name">Bedrijfsnaam *</label>
<input type="text" name="name" id="name" value="<%= client ? client.name : '' %>" required>
</div>
<div class="field">
<label for="industry">Sector/Industrie</label>
<input type="text" name="industry" id="industry" value="<%= client ? client.industry : '' %>">
</div>
<div class="field">
<label for="website">Website</label>
<input type="url" name="website" id="website" value="<%= client ? client.website : '' %>" placeholder="https://">
</div>
<div class="field">
<label for="source">Bron / Hoe gevonden?</label>
<input type="text" name="source" id="source" value="<%= client ? client.source : '' %>" placeholder="Referral, LinkedIn, etc.">
</div>
<div class="field">
<label for="contact_name">Contactpersoon</label>
<input type="text" name="contact_name" id="contact_name" value="<%= client ? client.contact_name : '' %>">
</div>
<div class="field">
<label for="contact_email">Contact Email</label>
<input type="email" name="contact_email" id="contact_email" value="<%= client ? client.contact_email : '' %>">
</div>
<div class="field">
<label for="contact_phone">Contact Telefoon</label>
<input type="text" name="contact_phone" id="contact_phone" value="<%= client ? client.contact_phone : '' %>">
</div>
<div class="field">
<label for="status">Status</label>
<select name="status" id="status">
<option value="lead" <%= client && client.status === 'lead' ? 'selected' : '' %>>Lead</option>
<option value="active" <%= client && client.status === 'active' ? 'selected' : '' %>>Actief</option>
<option value="paused" <%= client && client.status === 'paused' ? 'selected' : '' %>>Gepauzeerd</option>
<option value="archived" <%= client && client.status === 'archived' ? 'selected' : '' %>>Gearchiveerd</option>
</select>
</div>
</div>
<div class="field">
<label for="notes">Notities</label>
<textarea name="notes" id="notes" rows="4"><%= client ? client.notes : '' %></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-accent"><%= client ? 'Opslaan' : 'Cliënt toevoegen' %></button>
<a href="/clients" class="btn btn-secondary">Annuleren</a>
</div>
</form>
</div>
<script src="/js/theme.js"></script></body>
</html>
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cliënten — Mek-Tech Consulting</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" type="image/svg+xml">
</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="container">
<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="page-header">
<h1>Clients</h1>
<a href="/clients/new" class="btn btn-primary">+ Nieuwe cliënt</a>
</div>
<% if (clients.length === 0) { %>
<div class="empty-state">
<p>Nog geen cliënten. <a href="/clients/new" class="link">Voeg je eerste cliënt toe</a>.</p>
</div>
<% } else { %>
<table class="data-table">
<thead>
<tr>
<th>Bedrijf</th>
<th>Sector</th>
<th>Status</th>
<th>Engagements</th>
<th>Laatste Assessment</th>
<th></th>
</tr>
</thead>
<tbody>
<% clients.forEach(function(c) { %>
<tr>
<td><a href="/clients/<%= c.id %>" class="link"><%= c.name %></a></td>
<td><%= c.industry || '-' %></td>
<td><span class="status-badge <%= c.status %>"><%= c.status %></span></td>
<td><%= c.engagement_count %></td>
<td><%= c.last_assessment ? new Date(c.last_assessment).toLocaleDateString('nl-NL') : '-' %></td>
<td><a href="/clients/<%= c.id %>/edit" class="btn-small">Bewerk</a></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div>
<script src="/js/theme.js"></script></body>
</html>
+122
View File
@@ -0,0 +1,122 @@
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Consultancy Framework — Mek-Tech</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&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"><style>
.consult-page{max-width:860px;margin:0 auto;padding:1.5rem 0 3rem}
.consult-page h1{font-size:1.5rem;font-weight:800;letter-spacing:-.4px;margin-bottom:4px;background:linear-gradient(135deg,var(--text),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.consult-page .sub{font-size:.75rem;color:var(--text-muted)}
.cp-bar{display:flex;gap:.5rem;align-items:center;margin:.75rem 0 1rem;padding:.6rem .9rem;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;flex-wrap:wrap}
.cp-bar select{flex:1;min-width:180px;padding:.4rem .55rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.76rem;font-family:inherit}.cp-bar select:focus{outline:none;border-color:var(--accent)}
.cp-bar .cp-link{display:inline-flex;align-items:center;gap:.3rem;padding:.3rem .65rem;border-radius:5px;font-size:.66rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--bg);color:var(--text-muted);text-decoration:none;font-family:inherit}.cp-bar .cp-link:hover{color:var(--text)}
.cp-bar .cp-on{font-size:.62rem;color:var(--green);display:flex;align-items:center;gap:.25rem}.cp-bar .cp-on::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s ease infinite}
.alert{background:var(--green-bg);border:1px solid var(--green);color:var(--green);padding:.4rem .7rem;border-radius:6px;font-size:.7rem;margin-bottom:.75rem}
.prev{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:.6rem .9rem;margin-bottom:.75rem;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;font-size:.7rem}.prev span{color:var(--text-dim);font-size:.6rem}.prev a{font-size:.62rem;text-decoration:none;padding:.25rem .55rem;border-radius:5px;white-space:nowrap}
.steps-row{display:flex;gap:2px;margin-bottom:1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:3px}
.sb{flex:1;display:flex;align-items:center;gap:.35rem;padding:.42rem .45rem;border:none;background:none;color:var(--text-dim);font-family:inherit;font-size:.63rem;font-weight:600;cursor:pointer;border-radius:6px}.sb:hover{color:var(--text);background:var(--border-light)}.sb.active{color:var(--accent);background:rgba(88,166,255,.06)}.sb.done{color:var(--green)}
.sb .si{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:.56rem;font-weight:800;border:2px solid currentColor;flex-shrink:0}.sb.active .si{background:var(--accent);color:#fff;border-color:var(--accent)}.sb.done .si{background:var(--green);color:#fff;border-color:var(--green)}
.qc{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.3rem;animation:fadeIn .4s ease}.qc h2{font-size:1rem;font-weight:700;color:var(--text);margin-bottom:2px}.qc .sub{font-size:.68rem;color:var(--text-muted);margin-bottom:1rem}
.qg{display:grid;grid-template-columns:1fr 1fr;gap:.65rem}@media(max-width:600px){.qg{grid-template-columns:1fr}}
.qf{display:flex;flex-direction:column;gap:2px}.qf.full{grid-column:1/-1}
.qf label{font-size:.6rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px}
.qf input,.qf select,.qf textarea{padding:.4rem .55rem;background:var(--bg);border:1px solid var(--border);border-radius:5px;color:var(--text);font-size:.73rem;font-family:inherit;width:100%}
.qf input:focus,.qf select:focus,.qf textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px rgba(88,166,255,.08)}.qf textarea{resize:vertical;min-height:60px}.qf .hint{font-size:.55rem;color:var(--text-dim)}
.qa{display:flex;justify-content:space-between;align-items:center;margin-top:1.2rem;gap:.4rem}.qa .step-num{font-size:.62rem;color:var(--text-dim)}
.btn{display:inline-flex;align-items:center;gap:.3rem;padding:.4rem .85rem;border-radius:6px;font-size:.7rem;font-weight:600;cursor:pointer;border:none;font-family:inherit;text-decoration:none;transition:all .12s}
.btn-o{background:var(--bg);color:var(--text-muted);border:1px solid var(--border)}.btn-o:hover{color:var(--text)}.btn-p{background:var(--accent);color:#fff}.btn-p:hover{background:var(--accent-hover)}.btn-g{background:var(--green);color:#fff}.btn-g:hover{filter:brightness(1.1)}
.res-h{text-align:center;padding:1.5rem;background:linear-gradient(135deg,var(--bg-card),rgba(88,166,255,.03));border:1px solid var(--border);border-radius:10px;margin-bottom:.75rem}
.res-h h2{font-size:1.2rem;font-weight:800;color:var(--text);margin-bottom:3px}.res-h .rp{font-size:.68rem;color:var(--text-muted)}.res-h .tier{display:inline-flex;gap:.2rem;margin-top:5px}
.tb{padding:.12rem .5rem;border-radius:999px;font-size:.62rem;font-weight:700}.tb.a{background:var(--green-bg);color:var(--green)}.tb.b{background:var(--orange-bg);color:var(--orange)}.tb.c{background:var(--red-bg);color:var(--red)}
.sec{margin-top:1rem}.sec h3{font-size:.78rem;font-weight:700;color:var(--text);margin-bottom:.45rem}
.tg{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:.35rem}
.ti{background:var(--bg);border:1px solid var(--border-light);border-radius:6px;padding:.5rem .65rem}.ti .tl{font-size:.54rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px;font-weight:700;margin-bottom:2px}.ti .tv{font-size:.7rem;color:var(--text);font-weight:600}
.ph{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:.8rem 1rem;margin-bottom:.4rem;border-left:3px solid var(--accent)}.ph h4{font-size:.76rem;font-weight:700;color:var(--text);margin-bottom:.25rem}.ph ul{list-style:none}.ph li{padding:.12rem 0;font-size:.68rem;color:var(--text-muted);display:flex;gap:.3rem}.ph li::before{content:'→';color:var(--accent);font-weight:700;flex-shrink:0}.ph.note{border-left-color:var(--orange)}.ph.note.red{border-left-color:var(--red)}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
/* Agent */
.agent-float{position:fixed;bottom:1.2rem;right:1.2rem;width:46px;height:46px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--purple));color:#fff;border:none;cursor:pointer;font-size:1.1rem;box-shadow:0 4px 16px rgba(88,166,255,.2);z-index:50;transition:transform .15s}
.agent-float:hover{transform:scale(1.06)}
.agent-popup{display:none;position:fixed;bottom:4.5rem;right:1rem;width:350px;max-height:440px;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;z-index:49;flex-direction:column;box-shadow:0 8px 40px rgba(0,0,0,.3)}
.agent-popup.open{display:flex}.agent-popup .ah{border-bottom:1px solid var(--border);padding:.5rem .7rem;display:flex;align-items:center;gap:.35rem;font-size:.68rem;font-weight:700;color:var(--text)}.agent-popup .ah .dot{width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s ease infinite}.agent-popup .ah span{flex:1}.agent-popup .ah button{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:.9rem}
.agent-popup .ab{flex:1;overflow-y:auto;padding:.5rem;display:flex;flex-direction:column;gap:.4rem;max-height:300px}
.agent-popup .af{border-top:1px solid var(--border);padding:.45rem;display:flex;gap:.25rem}.agent-popup .af input{flex:1;padding:.3rem .45rem;background:var(--bg);border:1px solid var(--border);border-radius:5px;color:var(--text);font-size:.66rem;font-family:inherit}.agent-popup .af input:focus{outline:none;border-color:var(--accent)}.agent-popup .af button{background:var(--accent);color:#fff;border:none;border-radius:5px;padding:.3rem .55rem;font-size:.63rem;font-weight:600;cursor:pointer}
.am{padding:.4rem .55rem;border-radius:6px;font-size:.64rem;line-height:1.4;max-width:88%}.am.user{align-self:flex-end;background:var(--accent);color:#fff}.am.agent{align-self:flex-start;background:var(--border-light);color:var(--text)}.am .who{display:block;font-size:.55rem;color:var(--text-dim);margin-bottom:2px;font-weight:600}
</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="main-area" style="max-width:960px;margin:0 auto;padding:1.5rem 2rem 3rem">
<div class="consult-page">
<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> Back</a>
<h1>Enterprise Solution Architect</h1>
<p class="sub">Connect a client, complete the 5-step assessment, generate a full architecture roadmap</p>
<% if (saved) { %><div class="alert">✓ Assessment saved to <strong><%= client ? client.name : 'client' %></strong></div><% } %>
<div class="cp-bar">
<select id="cs" onchange="selectClient(this.value)"><option value="">— Select a client —</option><% clients.forEach(function(c){ %><option value="<%= c.id %>" <%= String(c.id)===String(clientId)?'selected':'' %>><%= c.name %></option><% }) %></select>
<% if(clientId){ %><span class="cp-on">Connected</span><a href="/clients/<%= clientId %>" class="cp-link">View Client</a><% } %>
</div>
<% if (prevAssessment && !loadedAnswers) { %>
<div class="prev"><strong style="color:var(--text)">Previous assessment</strong> <span><%= new Date(prevAssessment.created_at).toLocaleString('en-US') %></span>
<a href="/consulting/report/<%= prevAssessment.id %>" class="cp-link">View</a>
<a href="/consulting?client_id=<%= clientId %>" class="cp-link" style="border-color:var(--accent);color:var(--accent)">New</a></div><% } %>
<div id="stepsRow" class="steps-row"></div>
<div id="qArea"></div>
<div id="results" style="display:none"></div>
</div></div>
<button class="agent-float" onclick="toggleAgent()">🧠</button>
<div class="agent-popup" id="apop"><div class="ah"><span class="dot"></span><span>AI Consultant</span><button onclick="toggleAgent()">×</button></div><div class="ab" id="amsgs"><div class="am agent"><span class="who">Agent</span>Hello! I analyze your assessment in real-time. Ask about industry benchmarks, data lake sizing, AI platforms, budgets, or roadmaps.</div></div><div class="af"><input id="ainp" placeholder="Ask me anything..." onkeydown="if(event.keyCode===13)send()"><button onclick="send()">Send</button></div></div>
<script src="/js/theme.js"></script>
<script>
const STEPS=[{title:"Company Profile",sub:"Organization details & context",flds:[{n:'company',l:'Organization Name',t:'text',r:true,h:'Full legal/brand name'},{n:'industry',l:'Industry',t:'sel',o:['Banking & Finance','Insurance','Healthcare','Retail & E-commerce','Manufacturing','Telecom & Media','Energy & Utilities','Government','Education & Research','Technology & SaaS','Other'],r:true},{n:'employees',l:'Workforce Size',t:'sel',o:['1-50','51-200','201-1000','1001-5000','5001-20000','20000+']},{n:'challenge',l:'Primary Challenge',t:'area',r:true,h:'Key business/technical problem to solve'},{n:'tech',l:'Current Tech Stack',t:'area',h:'E.g. Oracle on-prem, Power BI, manual Excel'}]},{title:"Infrastructure",sub:"Current infrastructure landscape",flds:[{n:'infra',l:'Infrastructure Model',t:'sel',o:['Fully on-premise','Hybrid (on-prem+cloud)','Single cloud','Multi-cloud','Fully cloud-native'],r:true},{n:'servers',l:'Server Footprint',t:'sel',o:['< 10','10-50','50-200','200-1000','1000+']},{n:'cloud',l:'Cloud Provider',t:'sel',o:['None','AWS','Azure','GCP','Multi-cloud']},{n:'virt',l:'Virtualization',t:'sel',o:['VMware vSphere','Proxmox','Hyper-V','Docker/K8s','Bare metal']},{n:'net',l:'Network Fabric',t:'sel',o:['1GbE','10GbE','25GbE','40GbE','100GbE+']}]},{title:"Data & Analytics",sub:"Data landscape & tooling",flds:[{n:'dvol',l:'Data Volume',t:'sel',o:['< 100 GB','100 GB - 1 TB','1-10 TB','10-100 TB','100 TB - 1 PB','> 1 PB'],r:true},{n:'dgrowth',l:'Monthly Growth',t:'sel',o:['< 1%','1-5%','5-15%','15-30%','> 30%']},{n:'dtypes',l:'Data Variety',t:'sel',o:['Structured only','Structured + semi-structured','All types (streaming,IoT)']},{n:'bi',l:'BI/Analytics Tools',t:'sel',o:['Excel only','Power BI','Tableau','Looker','SAP Analytics Cloud','Multiple']},{n:'gov',l:'Data Governance',t:'sel',o:['None','Basic access control','GDPR/AVG','GDPR+sector','Full enterprise']}]},{title:"AI & Machine Learning",sub:"AI maturity & goals",flds:[{n:'aimat',l:'AI Maturity',t:'sel',o:['No AI/ML','Basic reporting','PoC ML','Production ML','Advanced AI (LLMs,CV)'],r:true},{n:'aigoal',l:'AI Goal',t:'sel',o:['Predictive analytics','NLP/chatbots/LLMs','Computer vision','Recommendation engines','Generative AI','Process automation','Exploring']},{n:'aiteam',l:'AI Team Size',t:'sel',o:['None','1-3','4-10','10-30','30+']},{n:'aipref',l:'AI Model Preference',t:'sel',o:['Open-source (Llama,DeepSeek)','Cloud AI services','Proprietary (OpenAI,Anthropic)','Hybrid','Undecided']}]},{title:"Requirements",sub:"Performance, security, budget & timeline",flds:[{n:'perf',l:'Performance',t:'sel',o:['Standard','Low latency','Real-time/streaming','Batch']},{n:'avail',l:'Availability SLA',t:'sel',o:['99%','99.5%','99.9%','99.99%','99.999%']},{n:'sec',l:'Security Level',t:'sel',o:['Standard','Enhanced (Zero Trust)','High (HSM,encryption)','Mission critical']},{n:'budget',l:'Annual IT Budget',t:'sel',o:['< €50K','€50K-€250K','€250K-€1M','€1M-€5M','> €5M']},{n:'timeline',l:'Timeline',t:'sel',o:['ASAP (<3 months)','3-6 months','6-12 months','12-24 months','Flexible/phased']}]}];
let step=0,ans={},cid='<%= clientId||"" %>';
<% if(loadedAnswers){ %>ans=<%- JSON.stringify(loadedAnswers) %>;<% } %>
<% if(loadedReport){ %>
document.getElementById('stepsRow').style.display='none';
document.getElementById('results').innerHTML=buildRes(<%- JSON.stringify(loadedReport) %>,ans)+'<div class="qa" style="margin-top:1rem"><a href="/consulting?client_id='+cid+'" class="btn btn-o">New Assessment</a></div>';
document.getElementById('results').style.display='block';
<% }else{ %>rStep(0);<% } %>
function rStep(i){step=i;const s=STEPS[i];
let h='';STEPS.forEach((x,j)=>{h+='<button class="sb'+(j===step?' active':j<step?' done':'')+'" onclick="goStep('+j+')"><span class="si">'+(j+1)+'</span>'+(window.innerWidth>600?x.title:'')+'</button>'});
document.getElementById('stepsRow').innerHTML=h;
h='<div class="qc"><h2>'+s.title+'</h2><div class="sub">'+s.sub+'</div><div class="qg">';
s.flds.forEach(f=>{const v=ans[f.n]||'';h+='<div class="qf'+(f.t==='area'?' full':'')+'"><label>'+f.l+'</label>';
if(f.t==='sel'){h+='<select name="'+f.n+'" onchange="sv()"><option value="">— Select —</option>';f.o.forEach(o=>h+='<option value="'+o+'"'+(v===o?' selected':'')+'>'+o+'</option>');h+='</select>'}
else if(f.t==='area')h+='<textarea name="'+f.n+'" onchange="sv()" rows="2">'+v+'</textarea>';
else h+='<input type="text" name="'+f.n+'" onchange="sv()" value="'+v+'">';
if(f.h)h+='<span class="hint">'+f.h+'</span>';h+='</div>'});
h+='</div><div class="qa"><span class="step-num">Step '+(i+1)+' of '+STEPS.length+'</span><div style="display:flex;gap:.4rem">';
if(i>0)h+='<button class="btn btn-o" onclick="prev()">Back</button>';
h+=i<STEPS.length-1?'<button class="btn btn-p" onclick="next()">Next</button>':'<button class="btn btn-g" onclick="finish()">Generate Solution</button>';
h+='</div></div></div>';document.getElementById('qArea').innerHTML=h;document.getElementById('qArea').scrollIntoView({behavior:'smooth',block:'start'})}
function sv(){STEPS[step].flds.forEach(f=>{const el=document.querySelector('[name="'+f.n+'"]');if(el)ans[f.n]=el.value})}
function prev(){sv();if(step>0)rStep(step-1)}
function goStep(i){if(i<=step){sv();rStep(i)}}
function next(){sv();const req=STEPS[step].flds.filter(f=>f.r);let ok=true;req.forEach(f=>{if(!ans[f.n]||!ans[f.n].trim()){const el=document.querySelector('[name="'+f.n+'"]');if(el)el.style.borderColor='#f85149';ok=false}});if(!ok)return;if(step<STEPS.length-1)rStep(step+1)}
function selectClient(v){window.location.href=v?'/consulting?client_id='+v:'/consulting'}
function finish(){sv();if(!cid){addMsg('agent','Connect to a client first.');return}const req=STEPS[4].flds.filter(f=>f.r);let ok=true;req.forEach(f=>{if(!ans[f.n]||!ans[f.n].trim())ok=false});if(!ok){addMsg('agent','Fill in all required fields.');return}const rpt=genRpt();fetch('/consulting/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({client_id:parseInt(cid),answers:ans,report:rpt})}).then(r=>r.json()).then(()=>{document.getElementById('qArea').style.display='none';document.getElementById('results').innerHTML=buildRes(rpt,ans)+'<div class="qa" style="margin-top:1rem"><a href="/consulting?client_id='+cid+'" class="btn btn-o">New Assessment</a><a href="/clients/'+cid+'" class="btn btn-p" style="margin-left:auto">View Client →</a></div>';document.getElementById('results').style.display='block';document.getElementById('results').scrollIntoView({behavior:'smooth'});addMsg('agent','✅ Architecture solution generated & saved!')}).catch(e=>addMsg('agent','Error: '+e.message))}
function genRpt(){let dwT='Standard',aiT='Standard',inT='Standard';const dv=(ans.dvol||''),vg=(ans.aigoal||''),am=(ans.aimat||''),it=(ans.infra||''),bg=(ans.budget||'');if(dv.includes('PB')||dv.includes('100 TB'))dwT='Enterprise';else if(dv.includes('10 TB'))dwT='Advanced';if(vg.includes('Generative')||vg.includes('LLM')||am.includes('Advanced'))aiT='Enterprise';else if(vg.includes('NLP')||vg.includes('Computer')||am.includes('Production'))aiT='Advanced';if(bg.includes('5M')||bg.includes('1M')||it.includes('Multi-cloud'))inT='Enterprise';else if(bg.includes('250K')||it.includes('Hybrid'))inT='Advanced';let dwS={},aiS={},inS={};
if(dwT==='Enterprise')dwS={Lakehouse:'MinIO + Iceberg + Kubernetes','Query Engine':'Spark Streaming + Trino + dbt',Catalog:'Hive Metastore + DataHub',Governance:'Apache Atlas + Ranger',Storage:'S3 (MinIO multi-site)'};
else if(dwT==='Advanced')dwS={Lakehouse:'MinIO + Iceberg + K8s','Query Engine':'Spark + Trino + dbt',Catalog:'Hive Metastore',Governance:'Apache Ranger',Storage:'MinIO/S3'};
else dwS={Lakehouse:'MinIO/S3 + PostgreSQL','Query Engine':'Spark batch + dbt',Catalog:'Basic metadata',Governance:'IAM + audit',Storage:'MinIO'};
if(aiT==='Enterprise')aiS={Serving:'vLLM + SGLang (multi-GPU)',Orchestrator:'Kubeflow + MLflow + Ray',Monitoring:'Prometheus + Grafana + Evidently',Frameworks:'PyTorch + LangChain + LlamaIndex',GPU:'NVIDIA H100 / L40S'};
else if(aiT==='Advanced')aiS={Serving:'Ollama + vLLM',Orchestrator:'MLflow + Prefect',Monitoring:'Grafana + MLflow',Frameworks:'PyTorch + scikit-learn + XGBoost',GPU:'NVIDIA L40S / A10'};
else aiS={Serving:'Ollama / HuggingFace',Orchestrator:'MLflow basic',Monitoring:'Streamlit',Frameworks:'scikit-learn + XGBoost',GPU:'CPU + optional GPU'};
if(inT==='Enterprise')inS={Compute:'vSphere + Rancher/K8s + bare metal',Network:'100GbE leaf-spine','Storage':'Pure Storage + NetApp AFF','Sec/DR':'Zero Trust + HSM + Veeam',Obs:'Grafana + Loki + Tempo'};
else if(inT==='Advanced')inS={Compute:'vSphere + Proxmox + Docker',Network:'25GbE','Storage':'NetApp FAS + MinIO','Sec/DR':'Zero Trust + Veeam',Obs:'Grafana + Prometheus'};
else inS={Compute:'Proxmox VE + Docker',Network:'10GbE','Storage':'TrueNAS + MinIO','Sec/DR':'pfSense + PBS',Obs:'Grafana + Netdata'};
let g=dwT==='Standard'&&aiT==='Standard'?'C':(dwT==='Enterprise'||aiT==='Enterprise'?'A':'B');return{dwT,aiT,inT,grade:g,dwS,aiS,inS}}
function buildRes(rpt,a){let h='<div class="res-h"><h2>'+(a.company||'Organization')+'</h2><div class="rp">'+a.industry+' · '+a.employees+' · '+a.budget+' · '+a.timeline+'</div><div class="tier"><span class="tb '+rpt.grade.toLowerCase()+'">Grade: '+rpt.grade+'</span><span class="tb b">Data: '+rpt.dwT+'</span><span class="tb b">AI: '+rpt.aiT+'</span></div></div>';h+='<div class="sec"><h3>Data Lakehouse — '+rpt.dwT+'</h3><div class="tg">';for(const[k,v] of Object.entries(rpt.dwS))h+='<div class="ti"><div class="tl">'+k+'</div><div class="tv">'+v+'</div></div>';h+='</div></div><div class="sec"><h3>AI/ML Platform — '+rpt.aiT+'</h3><div class="tg">';for(const[k,v] of Object.entries(rpt.aiS))h+='<div class="ti"><div class="tl">'+k+'</div><div class="tv">'+v+'</div></div>';h+='</div></div><div class="sec"><h3>Infrastructure — '+rpt.inT+'</h3><div class="tg">';for(const[k,v] of Object.entries(rpt.inS))h+='<div class="ti"><div class="tl">'+k+'</div><div class="tv">'+v+'</div></div>';h+='</div></div><div class="sec"><h3>Implementation Roadmap</h3>';[{t:'Phase 1: Foundation (0-3m)',i:['Infrastructure audit & baseline','PoC: Data lake on MinIO + Spark','AI readiness assessment','Security baseline','Team training: data engineering']},{t:'Phase 2: Build (3-6m)',i:['Lakehouse implementation (Iceberg+Trino+dbt)','Data catalog & governance rollout','CI/CD pipeline (GitOps)','ML platform (MLflow)','Data pipeline migration']},{t:'Phase 3: Scale (6-12m)',i:['AI model serving (vLLM/SGLang)','Automated retraining + monitoring','Real-time streaming (Kafka/Flink)','Self-service analytics','FinOps implementation']},{t:'Phase 4: Optimize (12m+)',i:['Multi-region replication','Advanced AI (RAG, agents)','Data mesh evaluation','Enterprise observability','Continuous optimization']}].forEach(p=>{h+='<div class="ph"><h4>'+p.t+'</h4><ul>';p.i.forEach(it=>h+='<li>'+it+'</li>');h+='</ul></div>'});return h+'</div>'}
function toggleAgent(){document.getElementById('apop').classList.toggle('open')}
function addMsg(role,txt){const c=document.getElementById('amsgs');const d=document.createElement('div');d.className='am '+role;d.innerHTML=(role==='agent'?'<span class="who">Agent</span>':'')+txt;c.appendChild(d);c.scrollTop=c.scrollHeight}
function send(){const i=document.getElementById('ainp');const t=i.value.trim();if(!t)return;addMsg('user',t);i.value='';setTimeout(()=>agentReply(t),300)}
function agentReply(q){const ql=q.toLowerCase();let r='';const ind=(ans.industry||'').toLowerCase();const kb={banking:'Azure Synapse + Databricks + Snowflake (DW), Azure ML + risk models (AI), Cisco/HPE/Oracle (Infra). PSD2/DORA compliance required.',insurance:'Azure Synapse + Teradata (DW), Azure ML + SAS (AI), Cisco/Dell/NetApp (Infra). Solvency II compliance.',healthcare:'Databricks + Snowflake + Azure (DW), PyTorch + MONAI (AI), Dell/HPE/Pure (Infra). HIPAA/NEN 7510 compliance.',retail:'BigQuery + dbt + Databricks (DW), Vertex AI + Python (AI), GCP/Cisco/Dell (Infra). Real-time personalization.',manufacturing:'Azure Synapse + SAP BW + Databricks (DW), Azure ML + CV (AI). IoT & edge computing.',telecom:'Snowflake + Oracle + dbt (DW), Vertex AI + Python (AI), Nokia/Cisco/Dell (Infra). 5G & streaming.',energy:'Azure Synapse + Databricks (DW), TensorFlow + Python (AI). Smart grid & IoT.',government:'Oracle Exadata + Azure Synapse (DW), Azure ML + Python (AI), Cisco/Dell/NetApp (Infra).'};(ql.includes('industry')||ql.includes('sector'))?(Object.entries(kb).find(([k])=>ind.includes(k))?(r='<b>'+ans.industry+'</b>: '+Object.entries(kb).find(([k])=>ind.includes(k))[1]):(r='Tell me the industry (Step 1) to get benchmarks.')):(ql.includes('data')||ql.includes('lake'))?(r=(dv=ans.dvol||'').includes('PB')||dv.includes('100 TB')?'Enterprise lakehouse: MinIO + Iceberg + Spark Streaming + Trino + dbt on K8s. Multi-site S3 storage.':dv.includes('10 TB')?'Advanced: MinIO + Iceberg + Spark + dbt on K8s.':dv?'Standard: MinIO/S3 + Spark batch + dbt.':'What is your data volume? (Step 3)'):(ql.includes('ai')||ql.includes('ml')||ql.includes('model'))?(r=(ans.aigoal||'')?(ans.aigoal.includes('Generative')||ans.aigoal.includes('LLM')?'Enterprise AI: vLLM + SGLang on H100 GPUs, LangChain + LlamaIndex, Kubeflow + MLflow.':ans.aigoal.includes('NLP')||ans.aigoal.includes('Computer')?'Advanced: Ollama/vLLM on L40S, MLflow + PyTorch.':'Standard: scikit-learn + XGBoost + Streamlit. CPU + optional GPU.'):'What is your AI goal? (Step 4)'):(ql.includes('budget')||ql.includes('cost'))?(r=(ans.budget||'')?(ans.budget.includes('5M')||ans.budget.includes('1M')?'Enterprise: 100GbE, multi-GPU, Pure+NetApp, Zero Trust. €1-5M/yr.':ans.budget.includes('250K')?'Advanced: 25GbE, 1-2 GPUs, NetApp+MinIO. €250K-1M/yr.':'Standard: 10GbE, Proxmox+Docker, TrueNAS+MinIO.'):'What is your budget? (Step 5)'):(ql.includes('help')||ql.includes('guide'))?r='Ask about: <b>industry benchmarks</b>, <b>data lake size</b>, <b>AI models</b>, <b>budget planning</b>, or <b>roadmap</b>.':r='Try asking: <b>"data lake"</b>, <b>"AI platform"</b>, <b>"budget"</b>, or <b>"help"</b>.';addMsg('agent',r)}
rStep(0);
</script>
</body></html>
+47
View File
@@ -0,0 +1,47 @@
const express = require('express');
const router = express.Router();
const { getDb, logAudit } = require('../db');
router.get('/', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const clientId = req.query.client_id || null;
const saved = req.query.saved === '1';
let client = null, assessments = [], prevAssessment = null;
if (clientId) {
client = db.prepare('SELECT * FROM clients WHERE id = ?').get(clientId);
assessments = db.prepare('SELECT * FROM assessments WHERE client_id = ? ORDER BY created_at DESC LIMIT 10').all(clientId);
if (assessments.length > 0) prevAssessment = assessments[0];
}
res.render('consultancy-framework', { clients, clientId, client, assessments, prevAssessment, saved, loadedAnswers: null, loadedReport: null });
});
router.post('/save', async (req, res) => {
const db = getDb();
const { client_id, answers, report } = req.body;
if (!client_id) return res.status(400).json({ error: 'Client is required' });
if (!answers) return res.status(400).json({ error: 'Answers required' });
const answersJson = typeof answers === 'string' ? answers : JSON.stringify(answers);
const reportJson = typeof report === 'string' ? report : JSON.stringify(report || {});
const result = db.prepare('INSERT INTO assessments (client_id, answers, report) VALUES (?, ?, ?)').run(client_id, answersJson, reportJson);
logAudit(req.session.userId, req.session.username, 'assessment_created', 'assessment', result.lastInsertRowid, 'Consultancy framework assessment');
const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(client_id);
if (client && client.status === 'lead') {
db.prepare("UPDATE clients SET status='active', updated_at=datetime('now') WHERE id=?").run(client_id);
}
res.json({ id: result.lastInsertRowid, redirect: '/consulting?client_id=' + client_id + '&saved=1' });
});
router.get('/report/:id', (req, res) => {
const db = getDb();
const assessment = db.prepare('SELECT a.*, c.name as client_name FROM assessments a LEFT JOIN clients c ON c.id = a.client_id WHERE a.id = ?').get(req.params.id);
if (!assessment) return res.redirect('/consulting');
try {
const answers = JSON.parse(assessment.answers);
const report = JSON.parse(assessment.report);
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('consultancy-framework', { clients, clientId: assessment.client_id, client: { name: assessment.client_name }, assessments: [], prevAssessment: assessment, saved: false, loadedAnswers: answers, loadedReport: report });
} catch(e) { res.redirect('/consulting?client_id=' + assessment.client_id); }
});
module.exports = router;
+815
View File
@@ -0,0 +1,815 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Dashboard — Mek-Tech Enterprise</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet"><link rel="stylesheet" href="/css/style.css"><link rel="stylesheet" href="/css/neon.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{--sb-w:210px;--hdr-h:50px}
html,body{height:100%;margin:0;padding:0;scroll-behavior:smooth}
body{background:var(--bg);font-family:'Inter',-apple-system,sans-serif}
.app-layout{display:flex;min-height:100vh;padding-top:var(--hdr-h)}
.sidebar{position:fixed;top:var(--hdr-h);left:0;width:var(--sb-w);height:calc(100vh - var(--hdr-h));background:var(--bg-nav);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:50;overflow-y:auto;transition:all .3s ease}
.main-area{flex:1;margin-left:var(--sb-w);padding:1.5rem 2rem 3rem;max-width:1300px}
@media(max-width:768px){.sidebar{width:56px}.sidebar .sb-brand p,.sidebar .sb-sep,.sidebar .sb-item span:not(.sb-icon),.sidebar .sb-badge{display:none}.sidebar .sb-item{justify-content:center;padding:.5rem}.main-area{margin-left:56px;padding:1rem}}@media(max-width:480px){.sidebar{width:48px}.main-area{margin-left:48px;padding:.75rem}}
.sidebar .sb-brand{padding:1rem 1.1rem;border-bottom:1px solid var(--border);background:linear-gradient(135deg,rgba(255,255,255,.04),rgba(0,212,255,.08))}
.sidebar .sb-brand h2{font-size:.82rem;font-weight:700;color:var(--text);letter-spacing:-.2px;display:flex;align-items:center;gap:.4rem}
.sidebar .sb-brand h2 .brand-dot{width:7px;height:7px;border-radius:50%;background:var(--accent);animation:pulse-glow 2s ease-in-out infinite}
.sidebar .sb-brand p{font-size:.6rem;color:var(--text-dim);margin-top:1px}
.sb-nav{flex:1;padding:.4rem 0}
.sb-sep{font-size:.55rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.6px;padding:.6rem 1rem .2rem;font-weight:600}
.sb-item{display:flex;align-items:center;gap:.55rem;padding:.5rem .9rem;margin:1px .45rem;border-radius:8px;cursor:pointer;font-size:.78rem;color:var(--text-muted);transition:all .18s cubic-bezier(.4,0,.2,1);border:none;background:none;width:calc(100% - .9rem);text-align:left;font-family:inherit;position:relative;overflow:hidden;text-decoration:none}
.sb-item::after{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--accent);border-radius:0 4px 4px 0;transform:translateX(-3px);transition:transform .2s ease;opacity:0}
.sb-item:hover{color:var(--text);background:var(--border-light)}
.sb-item.active{color:var(--accent);background:rgba(0,212,255,.08);font-weight:600}
.sb-item.active::after{transform:translateX(0);opacity:1}
.sb-item .sb-icon{width:20px;text-align:center;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.sb-item .sb-icon svg{width:17px;height:17px;transition:transform .2s ease}
.sb-item:hover .sb-icon svg{transform:scale(1.1)}
.sb-item .sb-badge{margin-left:auto;background:var(--border-light);padding:.05rem .5rem;border-radius:999px;font-size:.58rem;font-weight:600}
.sb-item .sb-badge.warn{background:var(--red-bg);color:var(--red);animation:pulse 2s ease-in-out infinite}
.sb-item .sb-badge.ok{background:var(--green-bg);color:var(--green)}
.page-hdr{margin-bottom:1.5rem}.page-hdr h1{font-size:1.5rem;color:var(--text);font-weight:800;letter-spacing:-.4px}.page-hdr p{font-size:.78rem;color:var(--text-muted);margin-top:.1rem}
/* KPIs premium */
.kpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.55rem;margin-bottom:1.25rem}@media(max-width:640px){.kpi-row{grid-template-columns:repeat(2,1fr);gap:.4rem}}
.kpi-card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:.9rem 1.1rem;display:flex;flex-direction:column;gap:.15rem;position:relative;overflow:hidden;transition:all .25s cubic-bezier(.4,0,.2,1);cursor:pointer}
.kpi-card:hover{transform:translateY(-2px);box-shadow:0 6px 25px rgba(0,0,0,.1),0 0 0 1px rgba(0,212,255,.15);border-color:rgba(0,212,255,.3)}
.kpi-card:hover .kpi-icon svg{opacity:.15;transform:scale(1.1)}
.kpi-card:active{transform:translateY(0)}
.kpi-card .kpi-num{font-size:1.7rem;font-weight:800;color:var(--text);line-height:1.1;letter-spacing:-.5px;transition:color .3s}
.kpi-card .kpi-label{font-size:.64rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;font-weight:600}
.kpi-card .kpi-sub{font-size:.62rem;color:var(--text-dim)}
.kpi-card .kpi-icon{position:absolute;right:.75rem;top:50%;transform:translateY(-50%);transition:all .25s ease}
.kpi-card .kpi-icon svg{width:2.2rem;height:2.2rem;opacity:.06;transition:all .25s ease}
.kpi-card.accent .kpi-num{color:var(--accent)}.kpi-card.green .kpi-num{color:var(--green)}.kpi-card.orange .kpi-num{color:var(--orange)}.kpi-card.purple .kpi-num{color:var(--purple)}.kpi-card.red .kpi-num{color:var(--red)}.kpi-card.cyan .kpi-num{color:var(--cyan)}
.kpi-blink{position:relative}
.kpi-blink::after{content:'';position:absolute;top:.5rem;right:.5rem;width:7px;height:7px;border-radius:50%;background:var(--green);animation:pulse-glow 2s ease-in-out infinite}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:.85rem}@media(max-width:960px){.grid-2{grid-template-columns:1fr}}
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1rem 1.1rem;transition:all .2s ease;overflow-x:auto}
.card:hover{border-color:rgba(0,212,255,.15)}
.card-title{font-size:.66rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:.7rem;display:flex;justify-content:space-between;align-items:center;font-weight:600}
.card-title .ct-btn{font-size:.6rem;color:var(--accent);text-decoration:none;border:none;background:none;cursor:pointer;font-family:inherit;text-transform:none;letter-spacing:0;font-weight:500;transition:opacity .2s}
.card-title .ct-btn:hover{opacity:.8}
.tab-panel{display:none}.tab-panel.active{display:block}
.data-table{min-width:400px}
.grid-2 .card:only-child{grid-column:1/-1}
/* Client grid */
.client-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.6rem}@media(max-width:640px){.client-grid{grid-template-columns:1fr}}
.client-card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.1rem;text-decoration:none;color:inherit;transition:all .2s cubic-bezier(.4,0,.2,1);position:relative;overflow:hidden}
.client-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--accent),var(--purple));opacity:0;transition:opacity .25s}
.client-card:hover{border-color:rgba(0,212,255,.3);transform:translateY(-2px);box-shadow:0 4px 20px rgba(0,0,0,.08)}
.client-card:hover::before{opacity:1}
.client-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem}
.client-card-header h4{font-size:.9rem;color:var(--text);font-weight:600}
.client-meta{font-size:.78rem;color:var(--text-muted)}.client-meta.warn{color:var(--orange)}.client-meta.small{font-size:.68rem}
.status-badge{display:inline-block;padding:.15rem .55rem;border-radius:999px;font-size:.62rem;font-weight:600;border:1px solid transparent}
.status-badge.lead{border-color:var(--accent);color:var(--accent)}
.status-badge.active{border-color:var(--green);color:var(--green)}
.status-badge.paused{border-color:var(--orange);color:var(--orange)}
.status-badge.archived{border-color:var(--text-dim);color:var(--text-dim)}
.status-badge.planned,.status-badge.sent{border-color:var(--accent);color:var(--accent)}
.status-badge.in_progress{border-color:var(--green);color:var(--green)}
.status-badge.completed,.status-badge.paid{border-color:var(--purple);color:var(--purple)}
.status-badge.on_hold{border-color:var(--orange);color:var(--orange)}
.status-badge.todo{border-color:var(--text-dim);color:var(--text-dim)}
.status-badge.done{border-color:var(--green);color:var(--green)}
.status-badge.overdue{border-color:var(--red);color:var(--red)}
.status-badge.draft{border-color:var(--text-dim);color:var(--text-dim)}
.status-badge.cancelled{border-color:var(--orange);color:var(--orange)}
.engagement-type{display:inline-block;padding:.1rem .45rem;border-radius:4px;font-size:.62rem;font-weight:600}
.engagement-type.assessment{background:var(--cyan-bg);color:var(--cyan)}
.engagement-type.architecture{background:var(--purple-bg);color:var(--purple)}
.engagement-type.implementation{background:var(--green-bg);color:var(--green)}
.engagement-type.monitoring{background:var(--orange-bg);color:var(--orange)}
.engagement-type.consulting{background:var(--pink-bg);color:var(--pink)}
.engagement-type.training{background:var(--red-bg);color:var(--red)}
.data-table{width:100%;border-collapse:collapse;border-radius:8px;overflow:hidden}
.data-table th.num,.data-table td.num{text-align:right;font-variant-numeric:tabular-nums}
@media(max-width:768px){.data-table{font-size:.65rem}.data-table th,.data-table td{padding:.3rem .4rem}.data-table td{max-width:120px!important}}
.data-table th{text-align:left;padding:.55rem .8rem;font-size:.66rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border);background:var(--bg);font-weight:600}
.data-table td{padding:.55rem .8rem;border-bottom:1px solid var(--border-light);font-size:.78rem}
.data-table tr{transition:background .15s}
.data-table tr:hover td{background:var(--border-light)}
.link{color:var(--accent);text-decoration:none;font-weight:500}.link:hover{text-decoration:underline}
.inline-form{display:flex;gap:.35rem;flex-wrap:wrap;align-items:flex-end;margin-bottom:.85rem}
.inline-form input,.inline-form select{padding:.38rem .55rem;font-size:.74rem;width:auto;min-width:115px}
.inline-form .btn{padding:.38rem .7rem;font-size:.72rem}
.advice-list{display:flex;flex-direction:column;gap:.4rem}
.advice-card{display:flex;align-items:flex-start;gap:.5rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:.6rem .8rem;border-left:4px solid var(--border);transition:all .2s ease}
.advice-card:hover{border-color:rgba(0,212,255,.2)}
.advice-card.critical{border-left-color:var(--red)}.advice-card.high{border-left-color:var(--orange)}.advice-card.medium{border-left-color:var(--accent)}.advice-card.low{border-left-color:var(--green)}
.advice-prio{font-size:.55rem;font-weight:700;text-transform:uppercase;padding:.08rem .38rem;border-radius:4px;white-space:nowrap}
.advice-prio.critical{background:var(--red-bg);color:var(--red)}.advice-prio.high{background:var(--orange-bg);color:var(--orange)}.advice-prio.medium{background:var(--cyan-bg);color:var(--cyan)}.advice-prio.low{background:var(--green-bg);color:var(--green)}
.advice-body{flex:1}.advice-body p{font-size:.78rem;color:var(--text);line-height:1.4}
.advice-action{font-size:.62rem;color:var(--accent);text-decoration:none;font-weight:600;white-space:nowrap;margin-left:auto;align-self:center}
.advice-action:hover{text-decoration:underline}
.advice-meta{font-size:.62rem;color:var(--text-muted);margin-top:.15rem}
.consult-hero{background:linear-gradient(135deg,rgba(0,212,255,.06),rgba(168,85,247,.06));border:1px solid rgba(0,212,255,.15);border-radius:12px;padding:1rem 1.1rem;margin-bottom:1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap}
.consult-hero h2{font-size:.95rem;font-weight:800;margin:0 0 .2rem;background:linear-gradient(135deg,var(--text),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.consult-hero p{font-size:.72rem;color:var(--text-muted);margin:0;max-width:520px;line-height:1.45}
.consult-hero .ch-btns{display:flex;gap:.4rem;flex-wrap:wrap}
.consult-hero .ch-btn{display:inline-flex;align-items:center;gap:.3rem;padding:.45rem .85rem;border-radius:8px;font-size:.7rem;font-weight:600;text-decoration:none;border:1px solid rgba(0,212,255,.35);background:rgba(0,212,255,.1);color:var(--accent);transition:all .15s}
.consult-hero .ch-btn:hover{box-shadow:0 0 20px rgba(0,212,255,.2);transform:translateY(-1px)}
.consult-hero .ch-btn.sec{background:transparent;border-color:var(--border);color:var(--text-muted)}
.consult-hero .ch-btn.sec:hover{color:var(--text);border-color:rgba(0,212,255,.25)}
.toolkit-section{margin-bottom:1rem}
.toolkit-section-hdr{display:flex;align-items:center;gap:.5rem;margin-bottom:.55rem}
.toolkit-section-hdr h3{font-size:.72rem;font-weight:700;color:var(--text);margin:0}
.toolkit-section-hdr span{font-size:.6rem;color:var(--text-muted)}
.toolkit-section-hdr .ts-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.toolkit-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:.4rem}
.tool-card{display:flex;flex-direction:column;gap:.2rem;padding:.55rem .6rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;text-decoration:none;color:inherit;transition:all .18s;min-height:72px}
.tool-card:hover{border-color:rgba(0,212,255,.35);transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,212,255,.08)}
.tool-card .tc-icon{width:26px;height:26px;border-radius:7px;display:flex;align-items:center;justify-content:center;margin-bottom:.1rem}
.tool-card .tc-label{font-size:.68rem;font-weight:600;color:var(--text);line-height:1.2}
.tool-card .tc-desc{font-size:.58rem;color:var(--text-muted);line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.playbook-row{display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,1fr));gap:.45rem;margin-bottom:1rem}
.pb-card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:.65rem .75rem;border-top:3px solid var(--pb-color,var(--accent))}
.pb-card .pb-phase{font-size:.55rem;font-weight:800;color:var(--pb-color);text-transform:uppercase;letter-spacing:.06em}
.pb-card h4{font-size:.72rem;font-weight:700;color:var(--text);margin:.2rem 0 .35rem}
.pb-card ul{list-style:none;margin:0;padding:0}
.pb-card li{font-size:.58rem;color:var(--text-muted);padding:.1rem 0;display:flex;gap:.25rem;line-height:1.3}
.pb-card li::before{content:'';color:var(--pb-color);font-weight:700}
.pb-dur{font-size:.55rem;color:var(--text-dim);margin-top:.35rem;padding-top:.3rem;border-top:1px solid var(--border-light)}
.maturity-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:.4rem}
.mat-col{background:var(--bg);border:1px solid var(--border-light);border-radius:8px;padding:.55rem .65rem}
.mat-col h5{font-size:.62rem;font-weight:700;color:var(--accent);margin:0 0 .35rem;text-transform:uppercase;letter-spacing:.04em}
.mat-col span{display:block;font-size:.58rem;color:var(--text-muted);padding:.12rem 0;border-bottom:1px solid var(--border-light)}
.mat-col span:last-child{border-bottom:none}
.advice-feed-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem}
.advice-feed-header h3{font-size:.75rem;font-weight:700;color:var(--text);margin:0}
.advice-empty{padding:1.5rem;text-align:center;background:var(--bg-card);border:1px dashed var(--border);border-radius:10px}
.advice-empty p{font-size:.75rem;color:var(--text-muted);margin:.35rem 0 0}
.task-list{display:flex;flex-direction:column;gap:.4rem}
.task-item{display:flex;align-items:center;gap:.5rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:.55rem .8rem;transition:all .15s ease}
.task-item:hover{border-color:rgba(0,212,255,.15)}
.task-item.done{opacity:.5}.task-item.done .task-title{text-decoration:line-through}
.task-body{flex:1;display:flex;flex-direction:column;gap:.1rem}.task-title{font-size:.82rem;color:var(--text);font-weight:500}.task-desc{font-size:.72rem;color:var(--text-muted)}.task-meta{font-size:.68rem;color:var(--accent)}
.task-prio{font-size:.55rem;font-weight:700;text-transform:uppercase;padding:.08rem .38rem;border-radius:4px}
.task-prio.critical{background:var(--red-bg);color:var(--red)}.task-prio.high{background:var(--orange-bg);color:var(--orange)}.task-prio.medium{background:var(--cyan-bg);color:var(--cyan)}.task-prio.low{background:var(--green-bg);color:var(--green)}
.task-due{font-size:.68rem;color:var(--text-muted)}.task-due.overdue{color:var(--red);font-weight:600}
.rm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:.4rem}
.rm-card{display:flex;gap:.6rem;padding:.65rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;text-decoration:none;color:inherit;transition:all .2s cubic-bezier(.4,0,.2,1)}
.rm-card:hover{border-color:rgba(0,212,255,.3);transform:translateY(-1px);box-shadow:0 4px 16px rgba(0,0,0,.06)}
.rm-vis{width:14px;display:flex;flex-direction:column;gap:1px;flex-shrink:0}
.rm-u{width:100%;height:4px;border-radius:1px;background:var(--border)}.rm-u.filled{background:var(--accent)}
.nd-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:.4rem}
.nd-card{display:flex;align-items:center;gap:.5rem;padding:.6rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;text-decoration:none;color:inherit;transition:all .2s ease}
.nd-card:hover{border-color:rgba(0,212,255,.3);box-shadow:0 2px 12px rgba(0,0,0,.05)}
.nd-icon{width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:.6rem;font-weight:700;flex-shrink:0}
.quick-actions{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:.4rem}
.qa-btn{display:flex;flex-direction:column;align-items:center;gap:.25rem;padding:.6rem .5rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;color:var(--text-muted);text-decoration:none;font-size:.63rem;font-weight:500;transition:all .2s ease;cursor:pointer}
.qa-btn:hover{border-color:var(--qa,var(--accent));color:var(--qa,var(--accent));background:rgba(0,212,255,.04);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,212,255,.08)}
.qa-btn .qa-icon svg{width:1.2rem;height:1.2rem;transition:transform .2s ease}
.qa-btn:hover .qa-icon svg{transform:scale(1.15)}
.empty-state-s{margin:.75rem 0;text-align:center;color:var(--text-dim);font-size:.75rem}
.progress-mini{height:3px;background:var(--border-light);border-radius:2px;overflow:hidden;margin-bottom:.05rem}
.progress-mini .pf{height:100%;border-radius:2px;transition:width .8s cubic-bezier(.4,0,.2,1)}
.timeline{display:flex;flex-direction:column;gap:.4rem}
.tl-item{display:flex;gap:.5rem;padding:.4rem 0;border-bottom:1px solid var(--border-light);font-size:.7rem;transition:all .15s}
.tl-item:hover{padding-left:.25rem}
.tl-item:last-child{border-bottom:none}.tl-item .tl-dot{width:8px;height:8px;border-radius:50%;margin-top:.3rem;flex-shrink:0}.tl-item .tl-body{flex:1}.tl-item .tl-title{color:var(--text);font-weight:500}.tl-item .tl-date{color:var(--text-dim);font-size:.64rem;margin-top:.05rem}
.alert{background:var(--green-bg);border:1px solid var(--green);color:var(--green);padding:.4rem .7rem;border-radius:8px;font-size:.76rem;margin-bottom:.75rem}
.alert-error{background:var(--red-bg);border:1px solid var(--red);color:var(--red);padding:.4rem .7rem;border-radius:8px;font-size:.76rem;margin-bottom:.75rem}
.time-tools{display:flex;gap:.4rem;margin-bottom:.75rem;flex-wrap:wrap}.time-tools .tt-btn{display:flex;align-items:center;gap:.3rem;padding:.35rem .65rem;border-radius:8px;font-size:.68rem;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--bg-card);color:var(--text-muted);text-decoration:none;transition:all .2s ease;font-family:inherit}
.time-tools .tt-btn:hover{border-color:var(--accent);color:var(--accent);box-shadow:0 2px 10px rgba(0,212,255,.06)}.time-tools .tt-btn svg{width:13px;height:13px}
.nav-right{display:flex;align-items:center;gap:.25rem;flex-shrink:0}
.nav-icon-btn{width:34px;height:34px;border-radius:8px;border:none;background:transparent;color:var(--text-muted);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;position:relative;text-decoration:none}
.nav-icon-btn:hover{background:var(--border-light);color:var(--text)}
.nav-icon-btn svg{width:18px;height:18px}
.nav-icon-btn .nav-dot{position:absolute;top:5px;right:5px;width:7px;height:7px;border-radius:50%;background:var(--red);border:2px solid var(--bg-nav)}
.nav-icon-btn::after{content:attr(data-count);position:absolute;top:2px;right:2px;min-width:15px;height:15px;border-radius:8px;background:var(--red);color:#fff;font-size:.58rem;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 4px;line-height:1}
.nav-icon-btn:not([data-count])::after{display:none}
.nav-logout{color:var(--red)!important}.nav-logout:hover{background:var(--red-bg)!important}
.nav-search{display:flex;align-items:center;gap:.3rem;flex:1;max-width:320px;margin:0 1rem}
.nav-search input{padding:.3rem .6rem;font-size:.7rem;background:var(--bg);border:1px solid var(--border);border-radius:7px;color:var(--text);font-family:inherit;width:100%;transition:all .2s}
.nav-search input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px rgba(0,212,255,.08)}
.nav-search input::placeholder{color:var(--text-dim)}
@media(max-width:600px){.nav-search{display:none}}
@media(max-width:480px){.nav-brand span{display:none}.nav-icon-btn{width:30px;height:30px}}
/* Interactive dashboard */
.welcome-bar{background:linear-gradient(135deg,rgba(0,212,255,.08),rgba(168,85,247,.06));border:1px solid rgba(0,212,255,.2);border-radius:12px;padding:1rem 1.15rem;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap}
.welcome-bar h2{font-size:1rem;font-weight:800;margin:0;color:#fff}
.welcome-bar p{font-size:.72rem;color:rgba(255,255,255,.88);margin:.2rem 0 0}
.welcome-clock{font-size:.68rem;color:rgba(255,255,255,.75);text-align:right}
.svc-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.45rem;margin-bottom:1rem}
.svc-pill{display:flex;align-items:center;gap:.45rem;padding:.5rem .65rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;font-size:.68rem;transition:all .2s;cursor:default}
.svc-pill.online{border-color:rgba(63,185,80,.35)}
.svc-pill.offline{border-color:rgba(248,81,73,.35);opacity:.85}
.svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.svc-dot.on{background:var(--green);box-shadow:0 0 8px var(--green)}
.svc-dot.off{background:var(--red)}
.advice-strip{display:flex;gap:.45rem;overflow-x:auto;padding-bottom:.25rem;margin-bottom:1rem;scrollbar-width:thin}
.advice-chip{flex:0 0 auto;max-width:280px;padding:.55rem .75rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;border-left:3px solid var(--accent);text-decoration:none;color:inherit;font-size:.68rem;transition:all .15s}
.advice-chip:hover{border-color:rgba(0,212,255,.35);transform:translateY(-1px)}
.advice-chip.critical{border-left-color:var(--red)}.advice-chip.high{border-left-color:var(--orange)}
.advice-chip strong{display:block;font-size:.72rem;margin-bottom:.15rem;color:var(--text)}
.tab-panel{animation:fadeInUp .3s ease}
@keyframes countUp{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.kpi-num.anim{animation:countUp .5s ease}
</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 class="nav-search"><input type="text" placeholder="Zoek cliënten, engagements, taken..." onkeydown="if(event.key==='Enter')window.location='/search?q='+encodeURIComponent(this.value)"></div>
<div class="nav-right">
<% if (overdueTasks > 0) { %><a href="/?tab=tasks" class="nav-icon-btn" title="<%= overdueTasks %> taken over tijd"><i data-lucide="alert-triangle"></i><span class="nav-dot"></span></a><% } %>
<a href="/notifications" class="nav-icon-btn" title="Meldingen"><i data-lucide="bell"></i></a>
<a href="/settings" class="nav-icon-btn" title="Instellingen"><i data-lucide="settings"></i></a>
<button class="theme-toggle" id="themeToggle" title="Toggle theme" onclick="toggleTheme()"></button>
<a href="/auth/logout" class="nav-icon-btn nav-logout" title="Logout"><i data-lucide="log-out"></i></a>
</div>
</div></nav>
<div class="app-layout">
<aside class="sidebar"><div class="sb-brand"><h2><span class="brand-dot"></span> Mek-Tech Console</h2><p>Enterprise Platform</p></div><nav class="sb-nav">
<div class="sb-sep">Core</div>
<button class="sb-item active" data-tab="overview"><span class="sb-icon"><i data-lucide="layout-dashboard" style="color:var(--accent)"></i></span>Overzicht</button>
<button class="sb-item" data-tab="clients"><span class="sb-icon"><i data-lucide="users" style="color:var(--green)"></i></span>Cliënten <span class="sb-badge"><%= clients.length %></span></button>
<button class="sb-item" data-tab="engagements"><span class="sb-icon"><i data-lucide="briefcase" style="color:var(--purple)"></i></span>Engagements <span class="sb-badge"><%= stats.totalEngagements %></span></button>
<button class="sb-item" data-tab="finance"><span class="sb-icon"><i data-lucide="wallet" style="color:var(--orange)"></i></span>Financiën</button>
<div class="sb-sep">Operatie</div>
<button class="sb-item" data-tab="infra"><span class="sb-icon"><i data-lucide="server" style="color:var(--cyan)"></i></span>Infrastructuur</button>
<button class="sb-item" data-tab="tasks"><span class="sb-icon"><i data-lucide="check-circle" style="color:var(--pink)"></i></span>Taken <% if (overdueTasks > 0) { %><span class="sb-badge warn"><%= overdueTasks %></span><% } %></button>
<button class="sb-item" data-tab="advice"><span class="sb-icon"><i data-lucide="lightbulb" style="color:var(--orange)"></i></span>Advies <% if (activeAdvice.length > 0) { %><span class="sb-badge ok"><%= activeAdvice.length %></span><% } %></button>
<div class="sb-sep">Intelligence</div>
<button class="sb-item" data-tab="leadgen"><span class="sb-icon"><i data-lucide="zap" style="color:var(--orange)"></i></span>LeadGen AI</button>
<button class="sb-item" data-tab="market"><span class="sb-icon"><i data-lucide="target" style="color:var(--accent)"></i></span>Markt Intel</button>
<button class="sb-item" data-tab="calendar"><span class="sb-icon"><i data-lucide="calendar" style="color:var(--purple)"></i></span>Agenda</button>
<button class="sb-item" data-tab="data"><span class="sb-icon"><i data-lucide="shield-check" style="color:var(--green)"></i></span>Data Kwaliteit</button>
<div class="sb-sep">Tools</div>
<a href="/email" class="sb-item"><span class="sb-icon"><i data-lucide="mail" style="color:var(--accent)"></i></span>E-mail</a>
<button class="sb-item" data-tab="activity"><span class="sb-icon"><i data-lucide="clock" style="color:var(--text-muted)"></i></span>Activiteit</button>
</nav></aside>
<div class="main-area">
<!-- ===== OVERVIEW ===== -->
<div class="tab-panel active" id="panel-overview">
<div class="welcome-bar fade-in-up">
<div>
<h2>Welkom terug<% if (typeof user !== 'undefined' && user && user.username) { %>, <%= user.username %><% } %></h2>
<p>Jouw consultancy command center — live inzicht in cliënten, trajecten en platformstatus.</p>
</div>
<div class="welcome-clock" id="liveClock"></div>
</div>
<% if (activeAdvice.length > 0) { %>
<div class="advice-strip fade-in-up">
<% activeAdvice.slice(0,4).forEach(function(a) { %>
<a href="<%= a.action_url %>" class="advice-chip <%= a.priority %>">
<strong><%= a.client_name %></strong>
<%= a.advice_text.length > 80 ? a.advice_text.substring(0,80) + '…' : a.advice_text %>
</a>
<% }) %>
<a href="#" onclick="switchTab('advice');return false" class="advice-chip" style="border-left-color:var(--purple);display:flex;align-items:center">Alle adviezen →</a>
</div>
<% } %>
<div class="svc-row fade-in-up">
<% var svcList = servicesStatus || {};
Object.keys(svcList).forEach(function(key) {
var s = svcList[key];
var on = s.online !== false;
%>
<div class="svc-pill <%= on ? 'online' : 'offline' %>" title="<%= s.label %> — poort <%= s.port || '—' %>">
<span class="svc-dot <%= on ? 'on' : 'off' %>"></span>
<span><strong><%= s.label %></strong><br><span style="color:var(--text-dim);font-size:.6rem"><%= on ? 'Online' : 'Offline' %></span></span>
</div>
<% }) %>
</div>
<div class="page-hdr fade-in-up"><h1><span class="gradient-text">Enterprise Dashboard</span></h1><p>Realtime consultancy intelligence platform</p></div>
<div class="kpi-row">
<div class="kpi-card accent kpi-blink" onclick="switchTab('clients')" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="building-2"></i></div><div class="kpi-num anim" data-count="<%= stats.total %>"><%= stats.total %></div><div class="kpi-label">Cliënten</div><div class="kpi-sub"><%= stats.active %> actief · <%= stats.leads %> leads</div></div>
<div class="kpi-card green" onclick="switchTab('engagements')" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="briefcase"></i></div><div class="kpi-num anim" data-count="<%= stats.activeEngagements %>"><%= stats.activeEngagements %></div><div class="kpi-label">Actieve Engagements</div><div class="kpi-sub">van <%= stats.totalEngagements %> totaal</div></div>
<div class="kpi-card purple" onclick="switchTab('infra')" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="layers"></i></div><div class="kpi-num anim" data-count="<%= totalRackDevices + totalNetworkDevices %>"><%= totalRackDevices + totalNetworkDevices %></div><div class="kpi-label">Infra Devices</div><div class="kpi-sub"><%= totalRacks %> racks · <%= totalDiagrams %> diagrammen</div></div>
<div class="kpi-card <%= overdueTasks > 0 ? 'red' : 'green' %>" onclick="switchTab('tasks')" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="check-circle"></i></div><div class="kpi-num anim" data-count="<%= overdueTasks %>"><%= overdueTasks %></div><div class="kpi-label">Taken over tijd</div><div class="kpi-sub"><%= completedTasks %>/<%= totalTasks %> voltooid</div></div>
<div class="kpi-card cyan" onclick="window.location='/quality'" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="shield-check"></i></div><div class="kpi-num anim" data-count="<%= assessmentsCount %>"><%= assessmentsCount %></div><div class="kpi-label">Assessments</div><div class="kpi-sub">Data quality engine</div></div>
</div>
<div class="grid-2">
<div class="card card-hover"><div class="card-title">Clients Status</div>
<% var sts={active:'Actief',leads:'Lead',paused:'Paused',archived:'Archived'};var clrs={active:'var(--green)',leads:'var(--accent)',paused:'var(--orange)',archived:'var(--text-dim)'};var maxC=Math.max(stats.active,stats.leads,stats.paused,stats.archived,1);
for(var k in sts){var cnt=stats[k]||0;%><div style="display:flex;justify-content:space-between;font-size:.74rem;margin:.25rem 0"><span style="color:var(--text-muted);font-weight:500"><%= sts[k] %></span><span style="color:var(--text);font-weight:600"><%= cnt %></span></div><div class="progress-mini" style="margin-bottom:.1rem"><div class="pf" style="width:<%= (cnt/maxC*100) %>%;background:<%= clrs[k] %>"></div></div><% } %>
</div>
<div class="card card-hover"><div class="card-title">Engagements per Type</div>
<% var tCol={assessment:'var(--cyan)',architecture:'var(--purple)',implementation:'var(--green)',monitoring:'var(--orange)',consulting:'var(--pink)',training:'var(--red)'};var maxE=Math.max(...engagementByType.map(function(e){return e.cnt}),1);
engagementByType.forEach(function(e){ %><div style="display:flex;justify-content:space-between;font-size:.74rem;margin:.2rem 0"><span style="color:var(--text-muted);font-weight:500"><%= e.type %></span><span style="color:var(--text);font-weight:600"><%= e.cnt %></span></div><div class="progress-mini" style="margin-bottom:.05rem"><div class="pf" style="width:<%= (e.cnt/maxE*100) %>%;background:<%= tCol[e.type]||'var(--accent)' %>"></div></div><% }) %>
</div></div>
<div class="grid-2" style="margin-top:.85rem">
<div class="card card-hover"><div class="card-title">Recent Assessments</div>
<% if(recentAssessments.length===0){ %><div class="empty-state-s">Nog geen assessments</div><% }else{ %><div class="timeline"><% recentAssessments.forEach(function(a){ %><div class="tl-item"><div class="tl-dot" style="background:var(--purple)"></div><div class="tl-body"><div class="tl-title">Assessment voor <strong><%= a.client_name %></strong></div><div class="tl-date"><%= new Date(a.created_at).toLocaleDateString('nl-NL',{hour:'2-digit',minute:'2-digit'}) %></div></div></div><% }) %></div><% } %>
</div>
<div class="card card-hover"><div class="card-title">Quick Actions</div>
<div class="quick-actions">
<a href="/clients/new" class="qa-btn" style="--qa:var(--green)"><span class="qa-icon"><i data-lucide="user-plus" style="color:var(--green)"></i></span>Cliënt</a>
<a href="/engagements/new" class="qa-btn" style="--qa:var(--purple)"><span class="qa-icon"><i data-lucide="briefcase" style="color:var(--purple)"></i></span>Engagement</a>
<a href="/time" class="qa-btn" style="--qa:var(--orange)"><span class="qa-icon"><i data-lucide="clock" style="color:var(--orange)"></i></span>Hours</a>
<a href="/invoices/new" class="qa-btn" style="--qa:var(--accent)"><span class="qa-icon"><i data-lucide="receipt" style="color:var(--accent)"></i></span>Factuur</a>
<a href="/quality" class="qa-btn" style="--qa:var(--cyan)"><span class="qa-icon"><i data-lucide="shield-check" style="color:var(--cyan)"></i></span>Data</a>
<a href="/assess" class="qa-btn" style="--qa:var(--pink)"><span class="qa-icon"><i data-lucide="clipboard-list" style="color:var(--pink)"></i></span>Assess</a>
</div></div></div></div>
<!-- ===== CLIENTS ===== -->
<div class="tab-panel" id="panel-clients"><div class="page-hdr fade-in-up"><h1>Clients</h1><p><%= stats.total %> total · <%= stats.active %> active</p></div>
<% if (clients.length === 0) { %><div class="card card-hover" style="text-align:center;padding:2rem"><p class="empty-state-s" style="margin-bottom:.5rem">Nog geen cliënten</p><a href="/clients/new" class="btn btn-accent btn-small" style="margin-top:.5rem">+ Eerste Cliënt Toevoegen</a></div><% } else { %><div class="client-grid">
<% clients.forEach(function(c){ %><a href="/clients/<%= c.id %>" class="client-card fade-in-up"><div class="client-card-header"><h4><%= c.name %></h4><span class="status-badge <%= c.status %>"><%= c.status %></span></div><% if(c.industry){ %><p class="client-meta">Industry: <%= c.industry %></p><% } %><p class="client-meta"><%= c.engagement_count %> engagement(s) · <%= c.active_engagements %> active</p><% if(c.last_assessment){ %><p class="client-meta small">Assessment: <%= new Date(c.last_assessment).toLocaleDateString('nl-NL') %></p><% }else{ %><p class="client-meta small warn">Geen assessment</p><% } %></a><% }) %>
</div><% } %></div>
<!-- ===== ENGAGEMENTS ===== -->
<div class="tab-panel" id="panel-engagements"><div class="page-hdr fade-in-up"><h1>Engagements</h1><p><%= stats.totalEngagements %> total · <%= stats.activeEngagements %> active</p></div>
<div class="grid-2" style="margin-bottom:.85rem">
<div class="card card-hover"><div class="card-title">By Type</div><% engagementByType.forEach(function(e){ %><div style="display:flex;justify-content:space-between;padding:.25rem 0;font-size:.74rem;border-bottom:1px solid var(--border-light)"><span style="color:var(--text-muted)"><%= e.type %></span><span style="color:var(--text);font-weight:600"><%= e.cnt %></span></div><% }) %></div>
<div class="card card-hover"><div class="card-title">By Status</div><% engagementByStatus.forEach(function(e){ %><div style="display:flex;justify-content:space-between;padding:.25rem 0;font-size:.74rem;border-bottom:1px solid var(--border-light)"><span style="color:var(--text-muted)"><%= e.status.replace('_',' ') %></span><span style="color:var(--text);font-weight:600"><%= e.cnt %></span></div><% }) %></div></div>
<div class="card"><div class="card-title">Alle Engagements</div>
<% if(allEngagements.length===0){ %><div class="empty-state-s">Nog geen engagements</div><% }else{ %>
<table class="data-table"><thead><tr><th>Titel</th><th>Cliënt</th><th>Type</th><th>Status</th><th></th></tr></thead><tbody><% allEngagements.forEach(function(e){ %><tr><td><a href="/engagements/<%= e.id %>" class="link"><%= e.title %></a></td><td><%= e.client_name %></td><td><span class="engagement-type <%= e.type %>"><%= e.type %></span></td><td><span class="status-badge <%= e.status %>"><%= e.status %></span></td><td><a href="/engagements/<%= e.id %>" class="btn-small">Details</a></td></tr><% }) %></tbody></table><% } %>
</div></div>
<!-- ===== FINANCE ===== -->
<div class="tab-panel" id="panel-finance"><div class="page-hdr fade-in-up"><h1>Finance</h1><p>Urenregistratie en facturatie</p></div>
<div class="kpi-row"><div class="kpi-card fade-in-up"><div class="kpi-num"><%= totalHours.toFixed(1) %></div><div class="kpi-label">Total hours</div><div class="kpi-sub"><%= billableHours.toFixed(1) %> billable</div></div><div class="kpi-card green fade-in-up"><div class="kpi-num">€<%= totalInvoiced.toFixed(0) %></div><div class="kpi-label">Invoiced</div><div class="kpi-sub">€<%= totalPaid.toFixed(0) %> paid</div></div><div class="kpi-card orange fade-in-up"><div class="kpi-num">€<%= totalOutstanding.toFixed(0) %></div><div class="kpi-label">Outstanding</div></div></div>
<div class="time-tools"><a href="/time" class="tt-btn"><i data-lucide="plus"></i> Uren registreren</a><a href="/invoices/new" class="tt-btn"><i data-lucide="plus"></i> Factuur maken</a><a href="/finance" class="tt-btn"><i data-lucide="bar-chart-2"></i> Volledig overzicht</a></div>
<div class="card card-hover" style="margin-bottom:.85rem"><div class="card-title">Recente urenregistraties</div>
<% if(timeEntries.length===0){ %><div class="empty-state-s">Nog geen uren geregistreerd</div><% }else{ %><table class="data-table"><thead><tr><th>Datum</th><th class="num">Uren</th><th>Cliënt</th><th>Engagement</th><th>Omschrijving</th></tr></thead><tbody><% timeEntries.forEach(function(t){ %><tr><td style="white-space:nowrap"><%= t.date %></td><td class="num"><strong><%= t.hours %></strong></td><td><%= t.client_name||'-' %></td><td style="max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><%= t.engagement_title||'-' %></td><td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted)"><%= t.description||'' %></td></tr><% }) %></tbody></table><% } %></div>
<div class="card card-hover"><div class="card-title">Recente facturen</div>
<% if(invoices.length===0){ %><div class="empty-state-s">Nog geen facturen</div><% }else{ %><table class="data-table"><thead><tr><th>Factuurnr</th><th>Cliënt</th><th class="num">Bedrag</th><th>Status</th><th></th></tr></thead><tbody><% invoices.forEach(function(i){ %><tr><td><a href="/invoices/<%= i.id %>" class="link"><%= i.number %></a></td><td><%= i.client_name %></td><td class="num">€<%= (i.total||0).toFixed(2) %></td><td><span class="status-badge <%= i.status %>"><%= i.status %></span></td><td><a href="/invoices/<%= i.id %>" class="btn-small">Open</a></td></tr><% }) %></tbody></table><% } %></div></div>
<!-- ===== INFRA ===== -->
<div class="tab-panel" id="panel-infra"><div class="page-hdr fade-in-up"><h1>Infrastructure</h1><p>Racks, netwerkapparatuur, verbindingen & architectuurdiagrammen</p></div>
<div class="kpi-row"><div class="kpi-card cyan" onclick="window.location='/infrastructure?tab=racks'" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="server"></i></div><div class="kpi-num"><%= totalRacks %></div><div class="kpi-label">Server Racks</div><div class="kpi-sub"><%= totalRackDevices %> devices</div></div><div class="kpi-card purple" onclick="window.location='/infrastructure?tab=network'" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="wifi"></i></div><div class="kpi-num"><%= totalNetworkDevices %></div><div class="kpi-label">Netwerk Devices</div><div class="kpi-sub">Switches, routers & firewalls</div></div><div class="kpi-card green" onclick="window.location='/infrastructure?tab=architecture'" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="layers"></i></div><div class="kpi-num"><%= totalDiagrams %></div><div class="kpi-label">Diagrammen</div><div class="kpi-sub">Data, netwerk & systeem</div></div></div>
<div class="time-tools"><a href="/infrastructure" class="tt-btn"><i data-lucide="layout-dashboard"></i> Volledig Overzicht</a><a href="/racks" class="tt-btn" style="--qa:var(--cyan)"><i data-lucide="server"></i> Racks Beheren</a><a href="/networking" class="tt-btn" style="--qa:var(--purple)"><i data-lucide="wifi"></i> Netwerk</a><a href="/architecture" class="tt-btn" style="--qa:var(--green)"><i data-lucide="layers"></i> Architectuur</a></div>
<div class="grid-2">
<div class="card card-hover"><div class="card-title">Server Racks <% if(racks.length>0){ %><a href="/infrastructure?tab=racks" class="ct-btn">Alle</a><% } %></div>
<% if(racks.length===0){ %><div class="empty-state-s">Nog geen racks</div><% }else{ %><div class="rm-grid"><% racks.forEach(function(r){ %><a href="/racks/<%= r.id %>" class="rm-card"><div class="rm-vis"><% for(var i=0;i<Math.min(r.total_units||42,12);i++){%><div class="rm-u <%= i < (r.device_count||0) ? 'filled' : '' %>"></div><% } %></div><div><h4 style="font-size:.78rem;color:var(--text)"><%= r.name %></h4><p style="font-size:.66rem;color:var(--text-muted)"><%= r.client_name %> · <%= r.device_count||0 %>/<%= r.total_units||42 %>U</p></div></a><% }) %></div><% } %></div>
<div class="card card-hover"><div class="card-title">Netwerk Devices <% if(netDevices.length>0){ %><a href="/infrastructure?tab=network" class="ct-btn">Alle</a><% } %></div>
<% if(netDevices.length===0){ %><div class="empty-state-s">Nog geen netwerk apparaten</div><% }else{ %><div class="nd-grid"><% netDevices.forEach(function(d){ %><a href="/networking/devices/<%= d.id %>" class="nd-card"><div class="nd-icon" style="background:<%= d.device_type==='switch'?'rgba(188,140,255,.15)':d.device_type==='router'?'rgba(0,212,255,.15)':d.device_type==='firewall'?'rgba(248,81,73,.15)':'rgba(63,185,80,.15)' %>;color:<%= d.device_type==='switch'?'#bc8cff':d.device_type==='router'?'#58a6ff':d.device_type==='firewall'?'#f85149':'#3fb950' %>"><%= d.device_type.charAt(0).toUpperCase() %></div><div style="font-size:.7rem"><strong><%= d.name %></strong><br><span style="color:var(--text-muted);font-size:.63rem"><%= d.model||'' %> · <%= d.client_name %></span></div></a><% }) %></div><% } %></div>
</div>
<div class="card card-hover" style="margin-top:.85rem"><div class="card-title">Architectuur Diagrammen <% if(diagrams.length>0){ %><a href="/infrastructure?tab=architecture" class="ct-btn">Alle</a><% } %></div>
<% if(diagrams.length===0){ %><div class="empty-state-s">Nog geen diagrammen</div><% }else{ %><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.5rem"><% diagrams.forEach(function(d){ %><a href="/architecture/<%= d.id %>" style="display:flex;flex-direction:column;gap:.15rem;padding:.55rem;background:var(--bg);border:1px solid var(--border-light);border-radius:8px;text-decoration:none;color:inherit;transition:all .15s"><span style="font-size:.6rem;padding:.05rem .35rem;border-radius:3px;background:<%= d.diagram_type==='data_architecture'?'rgba(0,212,255,.1)':'rgba(188,140,255,.1)' %>;color:<%= d.diagram_type==='data_architecture'?'#58a6ff':'#bc8cff' %>;width:fit-content"><%= d.diagram_type.replace(/_/g,' ') %></span><span style="font-size:.75rem;color:var(--text);font-weight:500"><%= d.name %></span><span style="font-size:.64rem;color:var(--text-dim)"><%= d.client_name %></span></a><% }) %></div><% } %></div></div>
<!-- ===== TASKS ===== -->
<div class="tab-panel" id="panel-tasks"><div class="page-hdr fade-in-up"><h1>Tasks</h1><p><%= completedTasks %>/<%= totalTasks %> completed <% if(overdueTasks>0){ %><span style="color:var(--red);font-weight:600"><%= overdueTasks %> overdue</span><% } %></p></div>
<div class="kpi-row"><div class="kpi-card green"><div class="kpi-num"><%= completedTasks %></div><div class="kpi-label">Voltooid</div></div><div class="kpi-card accent"><div class="kpi-num"><%= totalTasks-completedTasks %></div><div class="kpi-label">Open</div></div><div class="kpi-card red"><div class="kpi-num"><%= overdueTasks %></div><div class="kpi-label">Over tijd</div></div></div>
<% if(allTasks.length===0){ %><div class="empty-state-s">Nog geen taken</div><% }else{ %><div class="task-list"><% allTasks.forEach(function(t){ var io=t.status!=='done'&&t.status!=='cancelled'&&t.due_date&&new Date(t.due_date)<new Date(); %><div class="task-item <%= t.status==='done'?'done':'' %> fade-in-up"><span class="task-prio <%= t.priority %>"><%= t.priority %></span><div class="task-body"><span class="task-title"><a href="/engagements/<%= t.eid %>" class="link" style="color:inherit;text-decoration:none"><%= t.title %></a></span><span class="task-desc"><%= t.client_name %> · <%= t.engagement_title %></span></div><span class="status-badge <%= t.status %>"><%= t.status.replace('_',' ') %></span><% if(t.due_date){ %><span class="task-due <%= io?'overdue':'' %>"><%= new Date(t.due_date).toLocaleDateString('nl-NL') %></span><% } %></div><% }) %></div><% } %></div>
<!-- ===== ADVICE ===== -->
<div class="tab-panel" id="panel-advice">
<div class="page-hdr fade-in-up"><h1>Advice & Consulting</h1><p>Jouw complete consultancy werkplaats — van discovery tot delivery</p></div>
<div class="consult-hero fade-in-up">
<div>
<h2>Mek-Tech Consultancy Hub</h2>
<p>Assessments, architectuur, data quality, AI readiness en commerciële follow-up — alles op één plek voor jouw FDE- en enterprise consultancy.</p>
</div>
<div class="ch-btns">
<a href="/consulting" class="ch-btn"><i data-lucide="brain" style="width:14px;height:14px"></i> Start Assessment</a>
<a href="/clients/new" class="ch-btn sec"><i data-lucide="user-plus" style="width:14px;height:14px"></i> Nieuwe Cliënt</a>
</div>
</div>
<div class="kpi-row fade-in-up">
<div class="kpi-card cyan" onclick="window.location='/consulting'" style="cursor:pointer">
<div class="kpi-icon"><i data-lucide="clipboard-check"></i></div>
<div class="kpi-num"><%= consultingStats.assessments %></div>
<div class="kpi-label">Assessments</div>
<div class="kpi-sub"><%= consultingStats.clientsNeedingAssessment %> wachten op intake</div>
</div>
<div class="kpi-card purple" onclick="window.location='/engagements'" style="cursor:pointer">
<div class="kpi-icon"><i data-lucide="briefcase"></i></div>
<div class="kpi-num"><%= consultingStats.activeConsulting %></div>
<div class="kpi-label">Lopende Trajecten</div>
<div class="kpi-sub">Consulting & architectuur</div>
</div>
<div class="kpi-card green" onclick="window.location='/architecture'" style="cursor:pointer">
<div class="kpi-icon"><i data-lucide="layers"></i></div>
<div class="kpi-num"><%= totalDiagrams %></div>
<div class="kpi-label">Designs</div>
<div class="kpi-sub">Architectuurdiagrammen</div>
</div>
<div class="kpi-card orange" onclick="document.getElementById('adviceFeed').scrollIntoView({behavior:'smooth'})" style="cursor:pointer">
<div class="kpi-icon"><i data-lucide="lightbulb"></i></div>
<div class="kpi-num"><%= consultingStats.openAdvice %></div>
<div class="kpi-label">Open Adviezen</div>
<div class="kpi-sub">Actie-items per cliënt</div>
</div>
</div>
<% toolkit.forEach(function(section) { %>
<div class="toolkit-section fade-in-up">
<div class="toolkit-section-hdr">
<span class="ts-dot" style="background:<%= section.color %>;box-shadow:0 0 8px <%= section.color %>55"></span>
<h3><%= section.title %></h3>
<span>— <%= section.desc %></span>
</div>
<div class="toolkit-grid">
<% section.tools.forEach(function(tool) { %>
<a href="<%= tool.href %>" class="tool-card">
<div class="tc-icon" style="background:<%= section.color %>18;color:<%= section.color %>">
<i data-lucide="<%= tool.icon %>" style="width:14px;height:14px"></i>
</div>
<span class="tc-label"><%= tool.label %></span>
<span class="tc-desc"><%= tool.desc %></span>
</a>
<% }) %>
</div>
</div>
<% }) %>
<div class="grid-2" style="margin-bottom:1rem">
<div class="card card-hover fade-in-up">
<div class="card-title">Delivery Playbook</div>
<p style="font-size:.65rem;color:var(--text-muted);margin:-.25rem 0 .55rem">Standaard consultancy traject in 5 fasen</p>
<div class="playbook-row">
<% playbooks.forEach(function(pb) { %>
<div class="pb-card" style="--pb-color:<%= pb.color %>">
<div class="pb-phase">Fase <%= pb.phase %></div>
<h4><%= pb.title %></h4>
<ul><% pb.steps.forEach(function(s) { %><li><%= s %></li><% }) %></ul>
<div class="pb-dur"><i data-lucide="clock" style="width:10px;height:10px;display:inline;vertical-align:-1px"></i> <%= pb.duration %></div>
</div>
<% }) %>
</div>
</div>
<div class="card card-hover fade-in-up">
<div class="card-title">Maturity Model</div>
<p style="font-size:.65rem;color:var(--text-muted);margin:-.25rem 0 .55rem">Consultancy dimensies per engagement</p>
<div class="maturity-grid">
<% maturityLayers.forEach(function(layer) { %>
<div class="mat-col">
<h5><%= layer.name %></h5>
<% layer.items.forEach(function(item) { %><span><%= item %></span><% }) %>
</div>
<% }) %>
</div>
</div>
</div>
<div class="grid-2">
<div class="card card-hover fade-in-up" id="adviceFeed">
<div class="advice-feed-header">
<h3><i data-lucide="sparkles" style="width:14px;height:14px;color:var(--accent);vertical-align:-2px"></i> Slim Advies</h3>
<span style="font-size:.62rem;color:var(--text-muted)">Automatisch gegenereerd uit CRM-data</span>
</div>
<% if(activeAdvice.length===0){ %>
<div class="advice-empty">
<i data-lucide="check-circle-2" style="width:32px;height:32px;color:var(--green);opacity:.7"></i>
<p>Geen open actie-items — voeg cliënten toe om gepersonaliseerd advies te krijgen.</p>
<a href="/clients/new" class="ch-btn" style="display:inline-flex;margin-top:.75rem;font-size:.65rem">Eerste cliënt toevoegen</a>
</div>
<% }else{ %>
<div class="advice-list">
<% activeAdvice.forEach(function(a) { %>
<div class="advice-card <%= a.priority %> fade-in-up">
<div class="advice-prio <%= a.priority %>"><%= a.priority %></div>
<div class="advice-body">
<p><%= a.advice_text %></p>
<div class="advice-meta"><%= a.client_name %> · <%= a.advice_type.replace(/_/g,' ') %></div>
</div>
<a href="<%= a.action_url %>" class="advice-action">Actie →</a>
</div>
<% }) %>
</div>
<% } %>
</div>
<div class="card card-hover fade-in-up">
<div class="card-title">Recente Assessments <% if(recentAssessments.length>0){ %><a href="/consulting" class="ct-btn">Alle</a><% } %></div>
<% if(recentAssessments.length===0){ %>
<div class="empty-state-s">Nog geen assessments — start via Enterprise Framework</div>
<% }else{ %>
<div class="timeline">
<% recentAssessments.forEach(function(a) { %>
<div class="tl-item">
<div class="tl-dot" style="background:var(--purple)"></div>
<div class="tl-body">
<div class="tl-title">Assessment voor <strong><%= a.client_name %></strong></div>
<div class="tl-date"><%= new Date(a.created_at).toLocaleString('nl-NL') %></div>
<a href="/consulting/report/<%= a.id %>" class="link" style="font-size:.65rem;margin-top:.2rem;display:inline-block">Rapport bekijken →</a>
</div>
</div>
<% }) %>
</div>
<% } %>
<div class="props-divider" style="height:1px;background:var(--border);margin:.75rem 0"></div>
<div class="quick-actions" style="grid-template-columns:1fr 1fr">
<a href="/consulting" class="qa-btn" style="padding:.65rem"><span class="qa-icon"><i data-lucide="brain" style="color:var(--purple)"></i></span>Enterprise Framework</a>
<a href="/assess" class="qa-btn" style="padding:.65rem"><span class="qa-icon"><i data-lucide="clipboard-list" style="color:var(--cyan)"></i></span>Quick Assessment</a>
<a href="/architecture" class="qa-btn" style="padding:.65rem"><span class="qa-icon"><i data-lucide="layers" style="color:var(--green)"></i></span>Architectuur</a>
<a href="/quality" class="qa-btn" style="padding:.65rem"><span class="qa-icon"><i data-lucide="shield-check" style="color:var(--accent)"></i></span>Data Quality</a>
</div>
</div>
</div>
</div>
<!-- ===== LEADGEN AI ===== -->
<div class="tab-panel" id="panel-leadgen"><div class="page-hdr fade-in-up"><h1><span class="gradient-text">LeadGen AI</span></h1><p>AI-driven Lead Generation & Tech Scoping — analyseer bedrijfswebsites op technische signalen</p></div>
<div class="grid-2">
<div class="card card-hover"><div class="card-title">URL Analyseren</div>
<div style="display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:.5rem">
<input type="url" id="leadgenUrl" placeholder="https://bedrijf.nl/over-ons" style="flex:1;min-width:200px;padding:.45rem .6rem;background:var(--bg);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:.78rem;font-family:inherit">
<button onclick="analyzeLeadgen()" class="btn btn-accent" style="display:inline-flex;align-items:center;gap:.3rem;padding:.4rem .8rem;font-size:.72rem;border-radius:7px;font-weight:500"><i data-lucide="search" style="width:14px;height:14px"></i> Analyseer</button>
</div>
<button onclick="batchLeadgen()" class="btn btn-secondary" style="width:100%;display:flex;align-items:center;justify-content:center;gap:.3rem;padding:.35rem;font-size:.7rem;border-radius:7px"><i data-lucide="layers" style="width:13px;height:13px"></i> Batch URLs (max 10)</button>
</div>
<div class="card card-hover"><div class="card-title">API Configuratie <button onclick="toggleLeadgenConfig()" id="cfgBtn" style="background:var(--border-light);border:none;border-radius:6px;padding:.2rem .6rem;cursor:pointer;color:var(--text-muted);font-size:.68rem;font-family:inherit;font-weight:500;transition:all .15s">⚙ Instellingen</button></div>
<div id="leadgenConfig" style="display:none">
<div style="margin-bottom:.5rem"><label style="font-size:.6rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px">API Key</label><input type="password" id="lgApiKey" placeholder="sk-or-v1-..." style="width:100%;padding:.35rem .5rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.72rem;font-family:inherit"></div>
<div style="margin-bottom:.5rem"><label style="font-size:.6rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px">Base URL</label><input type="text" id="lgBaseUrl" placeholder="https://openrouter.ai/api/v1" style="width:100%;padding:.35rem .5rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.72rem;font-family:inherit"></div>
<div style="margin-bottom:.5rem"><label style="font-size:.6rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px">Model <button onclick="loadModels()" class="ct-btn" style="font-size:.55rem;margin-left:.3rem">🔄 Laad gratis modellen</button></label><select id="lgModel" style="width:100%;padding:.35rem .5rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.72rem;font-family:inherit"></select></div>
<div style="display:flex;gap:.4rem"><button onclick="saveLeadgenConfig()" class="btn btn-accent btn-small" style="padding:.3rem .6rem;font-size:.65rem">Opslaan</button><span id="lgStatus" style="font-size:.62rem;color:var(--green);padding:.3rem 0;display:none">✓ Opgeslagen</span></div>
</div></div></div>
<div id="leadgenResults" style="display:none"><div class="page-hdr" style="margin-bottom:.5rem"><h3 style="font-size:.85rem;color:var(--text)">Resultaten <span id="leadgenTiming" style="font-size:.65rem;color:var(--text-dim);font-weight:400"></span></h3></div><div id="leadgenCards"></div></div>
<div id="leadgenLoading" style="display:none;text-align:center;padding:2rem"><div class="shimmer" style="width:60%;margin:0 auto .5rem"></div><div class="shimmer" style="width:40%;margin:0 auto .5rem"></div><div class="shimmer" style="width:50%;margin:0 auto"></div><p style="font-size:.7rem;color:var(--text-dim);margin-top:.75rem">AI analyse bezig...</p></div>
<div id="leadgenEmpty"><div class="empty-state-s" style="padding:2rem"><i data-lucide="zap" style="width:36px;height:36px;color:var(--text-dim);margin-bottom:.5rem"></i><p>Plak een bedrijfs-URL en klik Analyseren. De AI scant de pagina op:</p><div style="display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center;margin-top:.5rem"><span style="font-size:.65rem;padding:.2rem .5rem;background:var(--cyan-bg);color:var(--cyan);border-radius:4px">Tech stack detectie</span><span style="font-size:.65rem;padding:.2rem .5rem;background:var(--purple-bg);color:var(--purple);border-radius:4px">Pain points</span><span style="font-size:.65rem;padding:.2rem .5rem;background:var(--green-bg);color:var(--green);border-radius:4px">Data maturity</span><span style="font-size:.65rem;padding:.2rem .5rem;background:var(--orange-bg);color:var(--orange);border-radius:4px">Outreach hook</span></div></div></div>
<div id="leadgenError" style="display:none"><div class="alert alert-error"></div></div>
</div>
<!-- ===== MARKET ===== -->
<div class="tab-panel" id="panel-market"><div class="page-hdr fade-in-up"><h1>Market Intelligence</h1><p>Nederlandse datacenters & data-gedreven bedrijven</p></div>
<div class="kpi-row"><div class="kpi-card cyan" onclick="window.location='/market'" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="database"></i></div><div class="kpi-num"><%= datacenterCount || 0 %></div><div class="kpi-label">Datacenters</div><div class="kpi-sub">Nederlandse markt</div></div><div class="kpi-card purple" onclick="window.location='/market'" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="building-2"></i></div><div class="kpi-num"><%= marketCompaniesCount || 0 %></div><div class="kpi-label">Prospects</div><div class="kpi-sub">Data-gedreven bedrijven</div></div></div>
<div class="grid-2"><div class="card card-hover"><div class="card-title">Quick Tools</div><div class="quick-actions" style="grid-template-columns:1fr 1fr"><a href="/market" class="qa-btn" style="--qa:var(--cyan);padding:.8rem"><span class="qa-icon" style="font-size:1.3rem"><i data-lucide="map" style="color:var(--cyan)"></i></span>Kaart & Datacenters</a><a href="/market" class="qa-btn" style="--qa:var(--purple);padding:.8rem"><span class="qa-icon" style="font-size:1.3rem"><i data-lucide="target" style="color:var(--purple)"></i></span>Prospects & Leads</a></div></div>
<div class="card card-hover"><div class="card-title">Snel Actie</div><div class="quick-actions"><a href="/market" class="qa-btn"><span class="qa-icon"><i data-lucide="search"></i></span>Open Market Explorer</a><a href="/clients/new" class="qa-btn"><span class="qa-icon"><i data-lucide="user-plus"></i></span>Nieuwe Prospect</a></div></div></div></div>
<!-- ===== CALENDAR ===== -->
<div class="tab-panel" id="panel-calendar"><div class="page-hdr fade-in-up"><h1>Calendar</h1><p>Planning, deadlines & afspraken</p></div>
<div class="kpi-row"><div class="kpi-card cyan" onclick="window.location='/calendar'" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="calendar"></i></div><div class="kpi-num"><%= overdueTasks %></div><div class="kpi-label">Open Tasks</div><div class="kpi-sub"><%= completedTasks %> completed</div></div></div>
<div class="card card-hover"><div class="card-title">Acties</div><div class="quick-actions" style="grid-template-columns:1fr 1fr"><a href="/calendar" class="qa-btn" style="padding:.8rem"><span class="qa-icon" style="font-size:1.3rem"><i data-lucide="calendar-days" style="color:var(--accent)"></i></span>Bekijk Agenda</a><a href="/calendar" class="qa-btn" style="padding:.8rem"><span class="qa-icon" style="font-size:1.3rem"><i data-lucide="plus-circle" style="color:var(--green)"></i></span>Nieuwe Afspraak</a></div></div></div>
<!-- ===== DATA ===== -->
<div class="tab-panel" id="panel-data"><div class="page-hdr fade-in-up"><h1>Data Quality</h1><p>Data profiling, kwaliteitschecks & modellen</p></div>
<div class="kpi-row"><div class="kpi-card green" onclick="window.location='/quality'" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="shield-check"></i></div><div class="kpi-num"><%= assessmentsCount %></div><div class="kpi-label">Assessments</div><div class="kpi-sub">Data kwaliteit checks</div></div><div class="kpi-card purple" onclick="window.location='/quality'" style="cursor:pointer"><div class="kpi-icon"><i data-lucide="code-2"></i></div><div class="kpi-num"><%= qualityFileCount || 0 %></div><div class="kpi-label">Data Bestanden</div><div class="kpi-sub">SQL & dbt modellen</div></div></div>
<div class="card card-hover"><div class="card-title">Data Tools</div><div class="quick-actions" style="grid-template-columns:1fr 1fr"><a href="/quality" class="qa-btn" style="padding:.8rem"><span class="qa-icon" style="font-size:1.3rem"><i data-lucide="shield-check" style="color:var(--green)"></i></span>Quality Engine</a><a href="/python-quality/" class="qa-btn" style="padding:.8rem"><span class="qa-icon" style="font-size:1.3rem"><i data-lucide="code-2" style="color:var(--purple)"></i></span>Schema Editor</a><a href="/assess" class="qa-btn" style="padding:.8rem"><span class="qa-icon" style="font-size:1.3rem"><i data-lucide="clipboard-list" style="color:var(--accent)"></i></span>Assessment Starten</a></div></div></div>
<!-- ===== ACTIVITY ===== -->
<div class="tab-panel" id="panel-activity"><div class="page-hdr fade-in-up"><h1>Recente Activiteit</h1></div>
<div class="card card-hover"><div class="card-title">Recent Assessments</div>
<% if(recentAssessments.length===0){ %><div class="empty-state-s">Nog geen recente activiteit</div><% }else{ %><div class="timeline"><% recentAssessments.forEach(function(a){ %><div class="tl-item"><div class="tl-dot" style="background:var(--purple)"></div><div class="tl-body"><div class="tl-title">Rapport voor <strong><%= a.client_name %></strong></div><div class="tl-date"><%= new Date(a.created_at).toLocaleString('nl-NL') %></div></div></div><% }) %></div><% } %></div>
<div style="margin-top:.85rem" class="quick-actions"><a href="/audit" class="qa-btn"><span class="qa-icon"><i data-lucide="file-text"></i></span>Audit Log</a><a href="/notifications" class="qa-btn"><span class="qa-icon"><i data-lucide="bell"></i></span>Notifications</a><a href="/users" class="qa-btn"><span class="qa-icon"><i data-lucide="users"></i></span>Users</a></div></div>
</div></div>
<!-- BATCH MODAL -->
<div id="batchModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:999;display:none;align-items:center;justify-content:center" onclick="if(event.target===this)closeBatchModal()">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:1.25rem;width:95%;max-width:550px;animation:fadeInUp .25s ease">
<h3 style="font-size:.85rem;color:var(--text);margin-bottom:.5rem">Batch URLs (max 10)</h3>
<textarea id="batchTextarea" rows="6" placeholder="https://bedrijf1.nl&#10;https://bedrijf2.nl/about&#10;https://bedrijf3.nl/careers" style="width:100%;padding:.5rem;background:var(--bg);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:.75rem;font-family:inherit;resize:vertical"></textarea>
<div style="display:flex;gap:.4rem;margin-top:.75rem"><button onclick="submitBatch()" class="btn btn-accent btn-small">Analyseren</button><button onclick="closeBatchModal()" class="btn btn-secondary btn-small">Sluiten</button></div>
</div></div>
<!-- DETAIL MODAL -->
<div id="detailModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.65);z-index:999;display:none;align-items:center;justify-content:center" onclick="if(event.target===this)closeDetailModal()">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:12px;width:95%;max-width:650px;max-height:85vh;overflow-y:auto;animation:fadeInUp .25s ease">
<div style="position:sticky;top:0;background:var(--bg-nav);padding:.6rem 1rem;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;z-index:1">
<h3 style="font-size:.82rem;color:var(--text)">Company Details</h3>
<button onclick="closeDetailModal()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:1.1rem">✕</button></div>
<div id="detailContent"></div>
</div></div>
<style> #batchModal.open,#detailModal.open{display:flex!important}</style>
<script>
document.querySelectorAll('.sb-item[data-tab]').forEach(function(b){
b.addEventListener('click',function(e){
if(this.tagName==='A')return;
e.preventDefault();
var t=this.dataset.tab;
document.querySelectorAll('.sb-item[data-tab]').forEach(function(x){x.classList.remove('active')});
document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});
this.classList.add('active');document.getElementById('panel-'+t).classList.add('active');
var u=new URL(window.location);u.searchParams.set('tab',t);window.history.replaceState({},'',u);
});
});
function switchTab(t){
document.querySelectorAll('.sb-item').forEach(function(x){x.classList.remove('active')});
document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});
var b=document.querySelector('.sb-item[data-tab="'+t+'"]');if(b)b.classList.add('active');
document.getElementById('panel-'+t).classList.add('active');
}
var h=(new URL(window.location)).searchParams.get('tab');
if(h){switchTab(h)}else{switchTab('overview')}
function updateClock(){
var el=document.getElementById('liveClock');
if(el)el.textContent=new Date().toLocaleString('nl-NL',{weekday:'long',day:'numeric',month:'long',hour:'2-digit',minute:'2-digit'});
}
updateClock();setInterval(updateClock,60000);
document.querySelectorAll('.kpi-num[data-count]').forEach(function(el){
var target=parseInt(el.dataset.count,10)||0;
if(target<=20){var n=0;var step=Math.max(1,Math.ceil(target/12));var iv=setInterval(function(){n+=step;if(n>=target){n=target;clearInterval(iv)}el.textContent=n},40);}
});
lucide.createIcons();
fetch('/notifications/api/unread').then(r=>r.json()).then(d=>{if(d.unread>0){var nb=document.querySelector('.nav-icon-btn[title="Meldingen"]');if(nb)nb.setAttribute('data-count',d.unread)}}).catch(function(){});
// LeadGen AI
async function loadLeadgenConfig() {
try { var r=await fetch('/api/v1/leadgen-config'); var c=await r.json();
document.getElementById('lgApiKey').value=c.api_key_set?'••••••••••••••':'';
document.getElementById('lgBaseUrl').value=c.base_url||'https://openrouter.ai/api/v1';
document.getElementById('lgModel').innerHTML='<option value="'+c.model+'">'+c.model+'</option>';
if(c.fallback_models) c.fallback_models.forEach(function(m){document.getElementById('lgModel').innerHTML+='<option value="'+m+'">'+m+'</option>'});
} catch(e){}
}
async function loadModels() {
var sel=document.getElementById('lgModel'); sel.innerHTML='<option>Laden...</option>';
try { var r=await fetch('/api/v1/leadgen-models'); var d=await r.json();
sel.innerHTML='';
(d.models||[]).forEach(function(m){sel.innerHTML+='<option value="'+m.id+'">'+m.id.split('/').pop()+' — '+m.name+' ('+(m.context_length/1000).toFixed(0)+'K)</option>'});
} catch(e){ sel.innerHTML='<option>Fout bij laden</option>'; }
}
async function saveLeadgenConfig() {
var key=document.getElementById('lgApiKey').value.trim();
var payload={base_url:document.getElementById('lgBaseUrl').value.trim(),model:document.getElementById('lgModel').value};
if(key && key!=='••••••••••••••') payload.api_key=key;
try { await fetch('/api/v1/leadgen-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
var s=document.getElementById('lgStatus'); s.style.display='inline'; setTimeout(function(){s.style.display='none'},2000);
} catch(e){ alert('Fout: '+e.message); }
}
function toggleLeadgenConfig() { var c=document.getElementById('leadgenConfig'); c.style.display=c.style.display==='none'?'block':'none'; if(c.style.display==='block')loadLeadgenConfig(); }
async function analyzeLeadgen() {
var url = document.getElementById('leadgenUrl').value.trim();
if (!url) return alert('Voer een URL in');
if (!/^https?:\/\//.test(url)) url = 'https://' + url;
var empty = document.getElementById('leadgenEmpty');
var load = document.getElementById('leadgenLoading');
var results = document.getElementById('leadgenResults');
var err = document.getElementById('leadgenError');
empty.style.display = 'none'; err.style.display = 'none'; results.style.display = 'none';
load.style.display = 'block';
try {
var r = await fetch('/api/v1/leadgen-analyze', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:url})});
var d = await r.json();
load.style.display = 'none';
if (!d.success) { err.querySelector('.alert-error').textContent = d.error || 'Onbekende fout'; err.style.display = 'block'; return; }
renderLeadgen([d]);
} catch(e) { load.style.display = 'none'; err.querySelector('.alert-error').textContent = e.message; err.style.display = 'block'; }
}
function batchLeadgen() { document.getElementById('batchModal').classList.add('open'); }
function closeBatchModal() { document.getElementById('batchModal').classList.remove('open'); }
async function submitBatch() {
var t = document.getElementById('batchTextarea').value.trim();
if (!t) return;
var list = t.split('\n').map(function(u){return u.trim()}).filter(Boolean).slice(0,10);
if (!list.length) return alert('Geen geldige URLs');
closeBatchModal();
batchAnalyze(list);
}
async function batchAnalyze(list) {
var empty = document.getElementById('leadgenEmpty');
var load = document.getElementById('leadgenLoading');
var results = document.getElementById('leadgenResults');
var err = document.getElementById('leadgenError');
empty.style.display = 'none'; err.style.display = 'none'; results.style.display = 'none';
load.style.display = 'block';
try {
var r = await fetch('/api/v1/leadgen-batch', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({urls:list})});
var d = await r.json();
load.style.display = 'none';
if (!d.success) { err.querySelector('.alert-error').textContent = d.error || 'Onbekende fout'; err.style.display = 'block'; return; }
renderLeadgen(d.results);
} catch(e) { load.style.display = 'none'; err.querySelector('.alert-error').textContent = e.message; err.style.display = 'block'; }
}
var leadgenHistory = [];
function renderLeadgen(list) {
var results = document.getElementById('leadgenResults');
var cards = document.getElementById('leadgenCards');
var valid = list.filter(function(r){return r.success && r.profile});
var failed = list.filter(function(r){return !r.success});
var t = valid.length > 0 ? valid[0].elapsed_ms : 0;
leadgenHistory = valid;
document.getElementById('leadgenTiming').textContent = valid.length + ' van ' + list.length + ' · ' + (t ? Math.round(t/1000)+'s' : '');
cards.innerHTML = valid.map(function(r,idx){var p=r.profile;
var matScore = p.estimated_data_maturity === 'High' ? 100 : p.estimated_data_maturity === 'Medium' ? 60 : 30;
var matColor = p.estimated_data_maturity === 'High' ? 'var(--green)' : p.estimated_data_maturity === 'Medium' ? 'var(--orange)' : 'var(--red)';
var hasHiring = (p.hiring_roles && p.hiring_roles.length > 0) || (p.desired_skills && p.desired_skills.length > 0);
return '<div class="card card-hover fade-in-up" style="margin-bottom:.6rem;overflow:hidden;border-left:4px solid var(--orange)">'+
'<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;flex-wrap:wrap">'+
'<div style="flex:1;min-width:200px"><h3 style="font-size:.95rem;color:var(--text);font-weight:700;margin-bottom:.1rem">'+esc(p.company_name)+'</h3>'+
'<div style="display:flex;gap:.4rem;flex-wrap:wrap;align-items:center">'+
(p.industry?'<span style="font-size:.62rem;color:var(--text-dim);background:var(--border-light);padding:.12rem .45rem;border-radius:4px;font-weight:500">'+esc(p.industry)+'</span>':'')+
(p.company_size_hint?'<span style="font-size:.62rem;color:var(--text-dim)">'+esc(p.company_size_hint)+'</span>':'')+
(p.source_url?'<a href="'+esc(p.source_url)+'" target="_blank" style="font-size:.58rem;color:var(--text-dim);text-decoration:none">🔗 bron</a>':'')+
'</div></div>'+
'<div style="display:flex;align-items:center;gap:.35rem"><div style="width:50px;text-align:center"><div style="font-size:.7rem;font-weight:800;color:'+matColor+'">'+p.estimated_data_maturity+'</div><div style="height:4px;background:var(--border-light);border-radius:2px;overflow:hidden;margin-top:2px"><div style="height:100%;width:'+matScore+'%;background:'+matColor+';border-radius:2px"></div></div><div style="font-size:.5rem;color:var(--text-dim)">maturity</div></div></div>'+
'</div>'+
// --- TECH TABLE ---
'<table class="data-table" style="margin:.5rem 0;min-width:auto"><thead><tr><th style="width:50%">Tech Stack</th><th style="width:50%">Pain Points</th></tr></thead><tbody><tr><td style="padding:.3rem .5rem">'+
'<div style="display:flex;gap:.25rem;flex-wrap:wrap">'+
(p.detected_technologies||[]).map(function(t,ti){
var colors = ['var(--cyan-bg)','var(--purple-bg)','var(--green-bg)','var(--orange-bg)','var(--pink-bg)'];
var cols = ['var(--cyan)','var(--purple)','var(--green)','var(--orange)','var(--pink)'];
return '<span style="font-size:.6rem;padding:.15rem .45rem;background:'+colors[ti%5]+';color:'+cols[ti%5]+';border-radius:4px;font-weight:500;cursor:pointer;margin-bottom:2px" onclick="searchTech(\''+esc(t)+'\')" title="Zoek '+esc(t)+'">'+esc(t)+'</span>';
}).join('')+'</div>'+
'</td><td style="padding:.3rem .5rem">'+
'<div style="display:flex;gap:.25rem;flex-wrap:wrap">'+
(p.current_pain_points||[]).map(function(pp){return '<span style="font-size:.6rem;padding:.15rem .45rem;background:var(--red-bg);color:var(--red);border-radius:4px;font-weight:500;margin-bottom:2px">'+esc(pp)+'</span>'}).join('')+'</div>'+
'</td></tr></tbody></table>'+
// --- HIRING TABLE ---
(hasHiring ? '<table class="data-table" style="margin-bottom:.4rem;min-width:auto"><thead><tr><th style="width:45%">Vacatures / Rollen</th><th style="width:55%">Gevraagde Skills</th></tr></thead><tbody><tr><td style="padding:.3rem .5rem">'+
'<div style="display:flex;gap:.25rem;flex-wrap:wrap">'+
(p.hiring_roles||[]).map(function(hr){return '<span style="font-size:.6rem;padding:.15rem .45rem;background:var(--green-bg);color:var(--green);border-radius:4px;font-weight:500;margin-bottom:2px">'+esc(hr)+'</span>'}).join('')+'</div>'+
'</td><td style="padding:.3rem .5rem">'+
'<div style="display:flex;gap:.25rem;flex-wrap:wrap">'+
(p.desired_skills||[]).map(function(ds){return '<span style="font-size:.6rem;padding:.15rem .45rem;background:var(--orange-bg);color:var(--orange);border-radius:4px;font-weight:500;margin-bottom:2px">'+esc(ds)+'</span>'}).join('')+'</div>'+
'</td></tr></tbody></table>' : '')+
(p.outreach_hook?'<div style="background:var(--bg);border:1px solid var(--accent);border-radius:8px;padding:.55rem .75rem;margin-top:.35rem;position:relative">'+
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.25rem"><span style="font-size:.6rem;color:var(--accent);text-transform:uppercase;letter-spacing:.3px;font-weight:700">Outreach Hook</span>'+
'<button onclick="copyHook('+idx+')" style="background:var(--border-light);border:none;border-radius:5px;padding:.12rem .45rem;cursor:pointer;color:var(--text-muted);font-size:.6rem;font-family:inherit">📋 Kopieer</button></div>'+
'<p style="font-size:.75rem;color:var(--text);margin-top:.15rem;line-height:1.5">'+esc(p.outreach_hook)+'</p></div>':'')+
'<div style="display:flex;gap:.4rem;margin-top:.65rem;flex-wrap:wrap">'+
'<button onclick="saveAsProspect('+idx+')" class="btn btn-accent btn-small" style="display:inline-flex;align-items:center;gap:.25rem;padding:.3rem .7rem;font-size:.65rem;border-radius:6px"><i data-lucide="user-plus" style="width:12px;height:12px"></i> Save als Prospect</button>'+
'<button onclick="sendOutreach('+idx+')" class="btn btn-secondary btn-small" style="display:inline-flex;align-items:center;gap:.25rem;padding:.3rem .7rem;font-size:.65rem;border-radius:6px"><i data-lucide="send" style="width:12px;height:12px"></i> Outreach email</button>'+
'<button onclick="showDetail('+idx+')" class="btn btn-secondary btn-small" style="display:inline-flex;align-items:center;gap:.25rem;padding:.3rem .7rem;font-size:.65rem;border-radius:6px"><i data-lucide="eye" style="width:12px;height:12px"></i> Details</button>'+
'</div></div>';
}).join('');
if (failed.length) cards.innerHTML += '<div class="alert alert-error" style="margin-top:.5rem">'+failed.length+' URL(s) konden niet geanalyseerd worden</div>';
results.style.display = 'block';
lucide.createIcons();
}
function copyHook(idx) {
var p = leadgenHistory[idx].profile;
navigator.clipboard.writeText(p.outreach_hook).then(function(){alert('Outreach hook gekopieerd!')});
}
function searchTech(tech) {
window.open('/search?q='+encodeURIComponent(tech), '_blank');
}
function saveAsProspect(idx) {
var p = leadgenHistory[idx].profile;
var params = new URLSearchParams();
params.set('name', p.company_name);
if (p.industry) params.set('industry', p.industry);
var tech = (p.detected_technologies||[]).join(', ');
if (tech) params.set('notes', 'Tech stack: '+tech+'\nPain points: '+(p.current_pain_points||[]).join(', ')+'\nOutreach: '+p.outreach_hook);
window.open('/clients/new?'+params.toString(), '_blank');
}
function sendOutreach(idx) {
var p = leadgenHistory[idx].profile;
window.open('/email/compose?subject=Mek-Tech Consulting — '+encodeURIComponent(p.company_name)+'&to=', '_blank');
}
function showDetail(idx) {
var p = leadgenHistory[idx].profile;
var html = '<div style="padding:1rem">'+
'<h2 style="font-size:1.1rem;margin-bottom:.5rem">'+esc(p.company_name)+'</h2>'+
'<table class="info-table" style="margin-bottom:1rem">'+
'<tr><td>Industry</td><td>'+esc(p.industry||'-')+'</td></tr>'+
'<tr><td>Size</td><td>'+esc(p.company_size_hint||'-')+'</td></tr>'+
'<tr><td>Maturity</td><td>'+esc(p.estimated_data_maturity)+'</td></tr>'+
'<tr><td>URL</td><td><a href="'+esc(p.source_url||'')+'" target="_blank" class="link">'+esc(p.source_url||'-')+'</a></td></tr>'+
'<tr><td>Tech Blog</td><td>'+(p.tech_blog_posts?'<a href="'+esc(p.tech_blog_posts)+'" target="_blank" class="link">'+esc(p.tech_blog_posts)+'</a>':'-')+'</td></tr>'+
'</table>'+
'<h3 style="font-size:.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;margin-bottom:.4rem">Alle Technologieën</h3>'+
'<div style="display:flex;gap:.3rem;flex-wrap:wrap;margin-bottom:1rem">'+ (p.detected_technologies||[]).map(function(t){return '<span style="font-size:.65rem;padding:.2rem .55rem;background:var(--cyan-bg);color:var(--cyan);border-radius:5px;font-weight:500">'+esc(t)+'</span>'}).join('')+'</div>'+
'<h3 style="font-size:.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;margin-bottom:.4rem">Pain Points</h3>'+
'<div style="display:flex;gap:.3rem;flex-wrap:wrap;margin-bottom:1rem">'+ (p.current_pain_points||[]).map(function(pp){return '<span style="font-size:.65rem;padding:.2rem .55rem;background:var(--red-bg);color:var(--red);border-radius:5px;font-weight:500">'+esc(pp)+'</span>'}).join('')+'</div>'+
'<h3 style="font-size:.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;margin-bottom:.4rem">Outreach Hook</h3>'+
'<div style="background:var(--bg);border:1px solid var(--accent);border-radius:8px;padding:.6rem .8rem;margin-bottom:1rem"><p style="font-size:.78rem;color:var(--text);line-height:1.5">'+esc(p.outreach_hook)+'</p></div>'+
(p.hiring_roles&&p.hiring_roles.length?'<h3 style="font-size:.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;margin-bottom:.4rem">Vacatures & Gewenste Skills</h3><table class="data-table" style="margin-bottom:1rem"><thead><tr><th>Rol</th><th style="text-align:right">Gevraagde Skills</th></tr></thead><tbody>'+
(p.hiring_roles||[]).map(function(hr,i){var ds = (p.desired_skills||[]).slice(i*3,(i+1)*3).join(', '); return '<tr><td>'+esc(hr)+'</td><td style="text-align:right"><span style="font-size:.65rem;color:var(--orange)">'+esc(ds||'—')+'</span></td></tr>'}).join('')+'</tbody></table>':'')+
(p.job_listings_url?'<a href="'+esc(p.job_listings_url)+'" target="_blank" class="btn btn-accent btn-small" style="margin-top:.25rem">Bekijk vacatures</a>':'')+
'</div>';
var modal = document.getElementById('detailModal');
document.getElementById('detailContent').innerHTML = html;
modal.classList.add('open');
}
function closeDetailModal() { document.getElementById('detailModal').classList.remove('open'); }
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
</script>
<script src="/js/theme.js"></script></body></html>
+98
View File
@@ -0,0 +1,98 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
const { generateAdvice } = require('../advice');
router.get('/', (req, res) => {
const db = getDb();
const tab = req.query.tab || 'overview';
const clients = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id) as engagement_count,
(SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id AND e.status = 'in_progress') as active_engagements,
(SELECT MAX(a.created_at) FROM assessments a WHERE a.client_id = c.id) as last_assessment
FROM clients c ORDER BY c.updated_at DESC
`).all();
const stats = {
total: clients.length,
active: clients.filter(c => c.status === 'active').length,
leads: clients.filter(c => c.status === 'lead').length,
paused: clients.filter(c => c.status === 'paused').length,
archived: clients.filter(c => c.status === 'archived').length,
activeEngagements: clients.reduce((s, c) => s + c.active_engagements, 0),
totalEngagements: clients.reduce((s, c) => s + c.engagement_count, 0)
};
const totalDiagrams = db.prepare('SELECT COUNT(*) as cnt FROM data_diagrams').get().cnt;
const totalRacks = db.prepare('SELECT COUNT(*) as cnt FROM racks').get().cnt;
const totalRackDevices = db.prepare('SELECT COUNT(*) as cnt FROM rack_devices').get().cnt;
const totalNetworkDevices = db.prepare('SELECT COUNT(*) as cnt FROM network_devices').get().cnt;
const overdueTasks = db.prepare('SELECT COUNT(*) as cnt FROM tasks WHERE status IN (\'todo\',\'in_progress\') AND due_date < date(\'now\')').get().cnt;
const totalTasks = db.prepare('SELECT COUNT(*) as cnt FROM tasks').get().cnt;
const completedTasks = db.prepare('SELECT COUNT(*) as cnt FROM tasks WHERE status = \'done\'').get().cnt;
const assessmentsCount = db.prepare('SELECT COUNT(*) as cnt FROM assessments').get().cnt;
const recentEngagements = db.prepare(`
SELECT e.*, c.name as client_name FROM engagements e JOIN clients c ON c.id = e.client_id
WHERE e.status = 'in_progress' ORDER BY e.updated_at DESC LIMIT 8
`).all();
const allEngagements = db.prepare(`
SELECT e.*, c.name as client_name FROM engagements e JOIN clients c ON c.id = e.client_id ORDER BY e.updated_at DESC
`).all();
const recentAssessments = db.prepare(`
SELECT a.id, a.created_at, c.name as client_name FROM assessments a JOIN clients c ON c.id = a.client_id ORDER BY a.created_at DESC LIMIT 5
`).all();
const engagementByType = db.prepare('SELECT type, COUNT(*) as cnt FROM engagements GROUP BY type ORDER BY cnt DESC').all();
const engagementByStatus = db.prepare('SELECT status, COUNT(*) as cnt FROM engagements GROUP BY status ORDER BY cnt DESC').all();
const clientsByIndustry = db.prepare("SELECT industry, COUNT(*) as cnt FROM clients WHERE industry IS NOT NULL AND industry != '' GROUP BY industry ORDER BY cnt DESC LIMIT 6").all();
const allAdvice = [];
for (const client of clients) allAdvice.push(...generateAdvice(client.id));
const activeAdvice = allAdvice.filter(a => !a.dismissed).sort((a, b) => {
const prio = { critical: 0, high: 1, medium: 2, low: 3 };
return (prio[a.priority] || 99) - (prio[b.priority] || 99);
}).slice(0, 8);
// Finance
const timeEntries = db.prepare('SELECT t.*, c.name as client_name, e.title as engagement_title, u.username FROM time_entries t LEFT JOIN clients c ON c.id = t.client_id LEFT JOIN engagements e ON e.id = t.engagement_id LEFT JOIN users u ON u.id = t.user_id ORDER BY t.date DESC LIMIT 20').all();
const totalHours = db.prepare('SELECT COALESCE(SUM(hours),0) as h FROM time_entries').get().h;
const billableHours = db.prepare('SELECT COALESCE(SUM(hours),0) as h FROM time_entries WHERE billable=1').get().h;
const invoices = db.prepare('SELECT i.*, c.name as client_name FROM invoices i JOIN clients c ON c.id = i.client_id ORDER BY i.created_at DESC LIMIT 10').all();
const totalInvoiced = db.prepare("SELECT COALESCE(SUM(total),0) as t FROM invoices WHERE status != 'draft'").get().t;
const totalPaid = db.prepare("SELECT COALESCE(SUM(total),0) as t FROM invoices WHERE status='paid'").get().t;
const totalOutstanding = db.prepare("SELECT COALESCE(SUM(total),0) as t FROM invoices WHERE status IN ('sent','overdue')").get().t;
// Infrastructure
const racks = db.prepare('SELECT r.*, c.name as client_name, (SELECT COUNT(*) FROM rack_devices d WHERE d.rack_id = r.id) as device_count FROM racks r JOIN clients c ON c.id = r.client_id ORDER BY r.updated_at DESC LIMIT 6').all();
const netDevices = db.prepare('SELECT nd.*, c.name as client_name FROM network_devices nd JOIN clients c ON c.id = nd.client_id ORDER BY nd.updated_at DESC LIMIT 10').all();
const diagrams = db.prepare('SELECT d.*, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id ORDER BY d.updated_at DESC LIMIT 6').all();
// Tasks
const allTasks = db.prepare(`
SELECT t.*, e.title as engagement_title, e.id as eid, c.name as client_name
FROM tasks t JOIN engagements e ON e.id = t.engagement_id JOIN clients c ON c.id = e.client_id
ORDER BY CASE t.status WHEN 'todo' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'done' THEN 2 WHEN 'cancelled' THEN 3 END,
CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
t.due_date ASC
`).all();
res.render('dashboard', {
tab, clients, stats,
totalDiagrams, totalRacks, totalRackDevices, totalNetworkDevices,
overdueTasks, totalTasks, completedTasks, assessmentsCount,
recentEngagements, allEngagements, recentAssessments,
engagementByType, engagementByStatus, clientsByIndustry,
activeAdvice,
timeEntries, totalHours, billableHours, invoices, totalInvoiced, totalPaid, totalOutstanding,
racks, netDevices, diagrams,
allTasks
});
});
module.exports = router;
+554
View File
@@ -0,0 +1,554 @@
const Database = require('better-sqlite3');
const path = require('path');
const bcrypt = require('bcryptjs');
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'consulting.db');
let db;
function getDb() {
if (!db) {
const fs = require('fs');
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
initSchema();
migrateSchema();
seedDefaults();
}
return db;
}
function migrateSchema() {
const existingColumns = db.prepare("PRAGMA table_info('users')").all().map(r => r.name);
const newCols = { email: "TEXT DEFAULT ''", role: "TEXT DEFAULT 'admin'", reset_token: "TEXT DEFAULT ''", reset_expires: "TEXT DEFAULT ''" };
for (const [col, def] of Object.entries(newCols)) {
if (!existingColumns.includes(col)) {
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
}
}
db.exec(`
CREATE TABLE IF NOT EXISTS ai_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
provider_type TEXT NOT NULL DEFAULT 'openrouter',
api_key TEXT DEFAULT '',
base_url TEXT DEFAULT '',
default_model TEXT DEFAULT '',
is_active INTEGER DEFAULT 1,
is_default INTEGER DEFAULT 0,
extra_headers TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS integrations (
id TEXT PRIMARY KEY,
name TEXT DEFAULT '',
api_key TEXT DEFAULT '',
webhook_url TEXT DEFAULT '',
config TEXT DEFAULT '{}',
status TEXT DEFAULT 'inactive',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
report_type TEXT DEFAULT 'client_summary',
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
project_id INTEGER,
config TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
start_date TEXT,
end_date TEXT,
status TEXT DEFAULT 'planning',
priority TEXT DEFAULT 'medium',
budget REAL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
`);
const diagramCols = db.prepare("PRAGMA table_info('data_diagrams')").all().map(r => r.name);
if (!diagramCols.includes('engagement_id')) {
db.exec('ALTER TABLE data_diagrams ADD COLUMN engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL');
}
if (!diagramCols.includes('rack_id')) {
db.exec('ALTER TABLE data_diagrams ADD COLUMN rack_id INTEGER REFERENCES racks(id) ON DELETE SET NULL');
}
const tblRow = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='data_diagrams'").get();
const tblSql = tblRow ? tblRow.sql : '';
if (tblSql && tblSql.includes('CHECK') && !tblSql.includes('application_flow')) {
const cols = db.prepare("PRAGMA table_info('data_diagrams')").all();
const colNames = cols.map(c => c.name);
const selectCols = colNames.join(', ');
db.exec(`
CREATE TABLE data_diagrams_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
diagram_type TEXT DEFAULT 'data_architecture',
nodes TEXT NOT NULL DEFAULT '[]',
edges TEXT NOT NULL DEFAULT '[]',
engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL,
rack_id INTEGER REFERENCES racks(id) ON DELETE SET NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
INSERT INTO data_diagrams_v2 (${selectCols}) SELECT ${selectCols} FROM data_diagrams;
DROP TABLE data_diagrams;
ALTER TABLE data_diagrams_v2 RENAME TO data_diagrams;
`);
}
}
function initSchema() {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT DEFAULT '',
role TEXT DEFAULT 'admin' CHECK(role IN ('admin','consultant','viewer')),
reset_token TEXT DEFAULT '',
reset_expires TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
industry TEXT,
website TEXT,
contact_name TEXT,
contact_email TEXT,
contact_phone TEXT,
status TEXT DEFAULT 'lead' CHECK(status IN ('lead','active','paused','archived')),
source TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS engagements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK(type IN ('assessment','architecture','implementation','monitoring','consulting','training')),
status TEXT DEFAULT 'planned' CHECK(status IN ('planned','in_progress','completed','on_hold')),
title TEXT NOT NULL,
description TEXT,
start_date TEXT,
end_date TEXT,
outcome TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS assessments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL,
answers TEXT NOT NULL,
report TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
engagement_id INTEGER NOT NULL REFERENCES engagements(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'todo' CHECK(status IN ('todo','in_progress','done','cancelled')),
priority TEXT DEFAULT 'medium' CHECK(priority IN ('low','medium','high','critical')),
due_date TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS advice_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
advice_type TEXT NOT NULL,
advice_text TEXT NOT NULL,
priority TEXT DEFAULT 'medium',
dismissed INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS racks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
name TEXT NOT NULL,
location TEXT,
datacenter TEXT,
total_units INTEGER DEFAULT 42,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS rack_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rack_id INTEGER NOT NULL REFERENCES racks(id) ON DELETE CASCADE,
name TEXT NOT NULL,
device_type TEXT DEFAULT 'server',
model TEXT,
manufacturer TEXT,
position_u INTEGER NOT NULL,
height_u INTEGER DEFAULT 1,
specs TEXT,
mgmt_ip TEXT,
notes TEXT,
serial TEXT DEFAULT '',
asset_tag TEXT DEFAULT '',
status TEXT DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS network_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
name TEXT NOT NULL,
device_type TEXT NOT NULL CHECK(device_type IN ('switch','router','firewall','loadbalancer','ap','other')),
model TEXT,
manufacturer TEXT,
ip_address TEXT,
mgmt_ip TEXT,
ports_count INTEGER DEFAULT 24,
os_version TEXT,
notes TEXT,
status TEXT DEFAULT 'active' CHECK(status IN ('active','standby','decommissioned')),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS network_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
name TEXT,
from_type TEXT NOT NULL CHECK(from_type IN ('rack_device','network_device','external')),
from_id INTEGER,
from_port TEXT,
to_type TEXT NOT NULL CHECK(to_type IN ('rack_device','network_device','external')),
to_id INTEGER,
to_port TEXT,
media_type TEXT DEFAULT 'copper' CHECK(media_type IN ('copper','sfp','sfp+','qsfp','dac','fiber')),
speed TEXT DEFAULT '1GbE',
vlan TEXT,
status TEXT DEFAULT 'active' CHECK(status IN ('active','inactive','planned')),
notes TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS data_diagrams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
diagram_type TEXT DEFAULT 'data_architecture' CHECK(diagram_type IN ('data_architecture','network_topology','system_architecture')),
nodes TEXT NOT NULL DEFAULT '[]',
edges TEXT NOT NULL DEFAULT '[]',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS client_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_by TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS client_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
stored_name TEXT NOT NULL,
mime_type TEXT DEFAULT '',
file_size INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username TEXT DEFAULT '',
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
details TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS time_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL,
date TEXT NOT NULL,
hours REAL NOT NULL,
description TEXT DEFAULT '',
billable INTEGER DEFAULT 1,
hourly_rate REAL DEFAULT 150.0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
number TEXT UNIQUE NOT NULL,
status TEXT DEFAULT 'draft' CHECK(status IN ('draft','sent','paid','overdue','cancelled')),
date TEXT NOT NULL,
due_date TEXT NOT NULL,
subtotal REAL DEFAULT 0,
tax REAL DEFAULT 0,
total REAL DEFAULT 0,
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS invoice_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL,
quantity REAL DEFAULT 1,
unit_price REAL DEFAULT 0,
total REAL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT DEFAULT '',
channel TEXT DEFAULT 'in-app' CHECK(channel IN ('in-app','email','slack','webhook')),
read INTEGER DEFAULT 0,
sent INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT DEFAULT '',
event_type TEXT DEFAULT 'meeting' CHECK(event_type IN ('meeting','call','deadline','workshop','review','other')),
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
all_day INTEGER DEFAULT 0,
color TEXT DEFAULT '#58a6ff',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
notify_email INTEGER DEFAULT 1,
notify_slack INTEGER DEFAULT 0,
notify_webhook INTEGER DEFAULT 0,
webhook_url TEXT DEFAULT '',
slack_webhook TEXT DEFAULT '',
language TEXT DEFAULT 'nl'
);
CREATE TABLE IF NOT EXISTS email_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email TEXT NOT NULL,
name TEXT DEFAULT '',
imap_host TEXT NOT NULL,
imap_port INTEGER DEFAULT 993,
imap_secure INTEGER DEFAULT 1,
imap_user TEXT NOT NULL,
imap_pass TEXT NOT NULL,
smtp_host TEXT NOT NULL,
smtp_port INTEGER DEFAULT 587,
smtp_secure INTEGER DEFAULT 0,
smtp_user TEXT DEFAULT '',
smtp_pass TEXT DEFAULT '',
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS email_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
uid INTEGER NOT NULL,
folder TEXT DEFAULT 'INBOX',
subject TEXT DEFAULT '',
from_name TEXT DEFAULT '',
from_addr TEXT DEFAULT '',
to_addr TEXT DEFAULT '',
cc TEXT DEFAULT '',
bcc TEXT DEFAULT '',
date TEXT DEFAULT '',
body_text TEXT DEFAULT '',
body_html TEXT DEFAULT '',
attachments INTEGER DEFAULT 0,
seen INTEGER DEFAULT 0,
flagged INTEGER DEFAULT 0,
replied INTEGER DEFAULT 0,
size INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS datacenters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address TEXT DEFAULT '',
city TEXT DEFAULT '',
region TEXT DEFAULT '',
postal_code TEXT DEFAULT '',
phone TEXT DEFAULT '',
website TEXT DEFAULT '',
type TEXT DEFAULT 'colo',
operator TEXT DEFAULT '',
tier TEXT DEFAULT 'III',
power_mw REAL DEFAULT 0,
size_sqm INTEGER DEFAULT 0,
network_vendors TEXT DEFAULT '',
server_vendors TEXT DEFAULT '',
storage_vendors TEXT DEFAULT '',
certifications TEXT DEFAULT '',
notes TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS tech_companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
industry TEXT DEFAULT '',
city TEXT DEFAULT '',
address TEXT DEFAULT '',
postal_code TEXT DEFAULT '',
phone TEXT DEFAULT '',
email TEXT DEFAULT '',
website TEXT DEFAULT '',
linkedin TEXT DEFAULT '',
employees INTEGER DEFAULT 0,
revenue_range TEXT DEFAULT '',
data_warehouse TEXT DEFAULT '',
bi_tools TEXT DEFAULT '',
cloud_provider TEXT DEFAULT '',
data_lake TEXT DEFAULT '',
ai_ml TEXT DEFAULT '',
database_vendor TEXT DEFAULT '',
lead_score INTEGER DEFAULT 0,
lead_status TEXT DEFAULT 'new',
notes TEXT DEFAULT '',
source TEXT DEFAULT ''
);
`);
}
function seedDefaults() {
const existing = db.prepare('SELECT COUNT(*) as cnt FROM users').get();
if (existing.cnt === 0) {
const pw = process.env.ADMIN_PASSWORD || 'admin';
const hash = bcrypt.hashSync(pw, 10);
const result = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run('admin', hash, 'admin');
db.prepare('INSERT INTO user_settings (user_id, language) VALUES (?, ?)').run(result.lastInsertRowid, 'nl');
console.log(`Default user created: admin / ${pw}`);
}
}
const DEFAULTS = {
brand_name: 'Mek-Tech Consulting',
language: 'nl',
theme: 'mek-neon-blue',
default_page: '/',
auto_refresh: '0',
items_per_page: '50',
debug_mode: '0',
icon_colors: JSON.stringify({
'layout-dashboard': '#58a6ff',
users: '#3fb950',
briefcase: '#d29922',
server: '#bc8cff',
'check-circle': '#3fb950',
lightbulb: '#d29922',
clock: '#79c0ff',
eye: '#f778ba',
phone: '#3fb950',
'file-text': '#f778ba',
'edit-3': '#79c0ff',
folder: '#d29922',
'clipboard-list': '#bc8cff'
}),
theme_colors: '{}'
};
function getSettings() {
const d = getDb();
const rows = d.prepare('SELECT key, value FROM app_settings').all();
const s = { ...DEFAULTS };
// Ensure defaults exist in DB
const upsert = d.prepare(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`);
for (const [k, v] of Object.entries(DEFAULTS)) {
const row = rows.find(r => r.key === k);
if (row) {
s[k] = row.value;
} else {
upsert.run(k, v);
}
}
// Also store icon_colors as parsed object
if (typeof s.icon_colors === 'string') {
try { s.icon_colors = JSON.parse(s.icon_colors); } catch (e) { s.icon_colors = JSON.parse(DEFAULTS.icon_colors); }
}
if (typeof s.theme_colors === 'string') {
try { s.theme_colors = JSON.parse(s.theme_colors || '{}'); } catch (e) { s.theme_colors = {}; }
}
if (!s.theme_colors || typeof s.theme_colors !== 'object') s.theme_colors = {};
return s;
}
function updateSetting(key, value) {
const d = getDb();
if (typeof value === 'object') value = JSON.stringify(value);
d.prepare(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`).run(key, value);
}
function logAudit(userId, username, action, entityType, entityId, details) {
const d = getDb();
d.prepare('INSERT INTO audit_log (user_id, username, action, entity_type, entity_id, details) VALUES (?, ?, ?, ?, ?, ?)')
.run(userId || null, username || '', action, entityType, entityId || null, details || '');
}
function getUserSettings(userId) {
const d = getDb();
let s = d.prepare('SELECT * FROM user_settings WHERE user_id = ?').get(userId);
if (!s) {
d.prepare('INSERT INTO user_settings (user_id) VALUES (?)').run(userId);
s = d.prepare('SELECT * FROM user_settings WHERE user_id = ?').get(userId);
}
return s;
}
module.exports = { getDb, getSettings, updateSetting, logAudit, getUserSettings, DEFAULTS };
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="nl">
<head><%- include('partials/head', { pageTitle: 'Pro Designer' }) %></head>
<body style="margin:0;font-family:'Inter',sans-serif;background:var(--bg);color:var(--text)">
<%- include('partials/navbar') %>
<div class="app-layout" style="display:flex;min-height:100vh;padding-top:var(--hdr-h)">
<%- include('partials/sidebar') %>
<main class="main-area" style="flex:1;margin-left:var(--sb-w);padding:1rem 1.5rem 2rem;max-width:100%">
<%- include('partials/crm-bar') %>
<div class="page-hdr fade-in-up" style="margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.75rem">
<div>
<h1 style="font-size:1.35rem;font-weight:800;margin:0">Pro Architecture Designer</h1>
<p style="font-size:.75rem;color:var(--text-muted);margin:.15rem 0 0">Drag-and-drop data architecture met CRM-koppeling</p>
</div>
<div style="display:flex;gap:.5rem;align-items:center">
<select id="clientPick" onchange="syncClient()" style="padding:.35rem .6rem;background:var(--bg);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:.75rem">
<option value="">Cliënt koppelen...</option>
<% clients.forEach(function(c) { %><option value="<%= c.id %>"<%= clientId == c.id ? ' selected' : '' %>><%= c.name %></option><% }); %>
</select>
<a href="/architecture" class="btn btn-small" style="padding:.35rem .7rem;font-size:.72rem;border-radius:7px;border:1px solid var(--border);color:var(--text-muted);text-decoration:none">← Diagrammen</a>
</div>
</div>
<iframe id="designerFrame" class="designer-frame" src="<%= designerUrl %>" allow="clipboard-read; clipboard-write"></iframe>
</main>
</div>
<script>
function syncClient() {
const id = document.getElementById('clientPick').value;
if (id) window.location = '/architecture/designer?client_id=' + id;
}
</script>
<%- include('partials/scripts') %>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Opstellen — Mek-Tech</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&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"><style>
body{background:var(--bg);font-family:'Inter',-apple-system,sans-serif;min-height:100vh}
.em-wrap{max-width:720px;margin:0 auto;padding:1rem 1.5rem 3rem}
.em-wrap h1{font-size:1.2rem;font-weight:800;color:var(--text);margin-bottom:1rem;display:flex;align-items:center;gap:.4rem}
.em-wrap h1 svg{color:var(--accent)}
.cmp-card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.2rem}
.cmp-field{margin-bottom:.85rem}
.cmp-field label{display:block;font-size:.65rem;font-weight:600;color:var(--text-muted);margin-bottom:.2rem;text-transform:uppercase;letter-spacing:.3px}
.cmp-field input,.cmp-field textarea{width:100%;padding:.45rem .6rem;background:var(--bg);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:.8rem;font-family:inherit;line-height:1.5}
.cmp-field textarea{resize:vertical;min-height:140px}
.cmp-field input:focus,.cmp-field textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px rgba(88,166,255,.08)}
.cmp-actions{display:flex;gap:.4rem}
.alert{padding:.4rem .7rem;border-radius:7px;font-size:.72rem;margin-bottom:.75rem;font-weight:500}
.alert-error{background:var(--red-bg);color:var(--red);border:1px solid var(--red)}
.alert-success{background:var(--green-bg);color:var(--green);border:1px solid var(--green)}
.btn{display:inline-flex;align-items:center;gap:.3rem;padding:.38rem .75rem;border-radius:7px;font-size:.7rem;font-weight:500;cursor:pointer;border:none;font-family:inherit;text-decoration:none;transition:all .15s}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}
.btn-secondary{background:var(--border-light);color:var(--text-muted)}.btn-secondary:hover{color:var(--text)}
</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="em-wrap">
<a href="/email" class="back-link">← Terug naar inbox</a>
<h1><i data-lucide="pen-line" style="width:18px;height:18px"></i> Nieuw bericht</h1>
<% if (sent) { %><div class="alert alert-success">Bericht verzonden!</div><% } %>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<% if (!account) { %>
<div class="cmp-card" style="text-align:center"><p style="color:var(--text-muted);font-size:.85rem">Geen email account geconfigureerd.</p><a href="/email/settings" class="btn btn-primary" style="margin-top:.75rem">Account toevoegen</a></div>
<% } else { %>
<div class="cmp-card">
<form method="POST" action="/email/compose">
<div class="cmp-field"><label>Van</label><input type="text" value="<%= account.email %>" disabled></div>
<div class="cmp-field"><label>Aan</label><input type="email" name="to" value="<%= to %>" required placeholder="ontvanger@bedrijf.nl"></div>
<div class="cmp-field"><label>CC</label><input type="text" name="cc" placeholder="cc@bedrijf.nl"></div>
<div class="cmp-field"><label>Onderwerp</label><input type="text" name="subject" value="<%= subject %>" required placeholder="Onderwerp van het bericht"></div>
<div class="cmp-field"><label>Bericht</label><textarea name="text" placeholder="Typ uw bericht hier..."></textarea></div>
<div class="cmp-actions"><button class="btn btn-primary">Versturen</button><a href="/email" class="btn btn-secondary">Annuleren</a></div>
</form></div>
<% } %></div>
<script src="/js/theme.js"></script></body></html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title><%= msg.subject || 'Geen onderwerp' %> — 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"><style>
.email-hdr{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;margin-bottom:.75rem}
.email-hdr h2{font-size:1.1rem;color:var(--text);font-weight:600;margin-bottom:.5rem}
.email-hdr .em-meta{display:grid;grid-template-columns:auto 1fr;gap:.2rem .5rem;font-size:.8rem}
.email-hdr .em-meta .em-l{color:var(--text-muted)}.email-hdr .em-meta .em-v{color:var(--text)}
.email-body{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;min-height:200px;overflow-x:auto}
.email-body iframe{width:100%;min-height:400px;border:none;background:#fff;border-radius:4px}
.email-body pre{white-space:pre-wrap;font-family:inherit;font-size:.85rem;color:var(--text);line-height:1.6}
</style><script src="https://unpkg.com/lucide@latest"></script></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="container" style="max-width:800px">
<a href="/email" class="back-link">← Terug naar inbox</a>
<div class="email-hdr">
<h2><%= msg.subject || '(geen onderwerp)' %></h2>
<div class="em-meta">
<span class="em-l">Van:</span><span class="em-v"><%= msg.from_name || msg.from_addr || 'Onbekend' %> <%= msg.from_addr ? '<' + msg.from_addr + '>' : '' %></span>
<span class="em-l">Aan:</span><span class="em-v"><%= msg.to_addr || '-' %></span>
<% if (msg.cc) { %><span class="em-l">CC:</span><span class="em-v"><%= msg.cc %></span><% } %>
<span class="em-l">Datum:</span><span class="em-v"><%= msg.date ? new Date(msg.date).toLocaleString('nl-NL',{year:'numeric',month:'long',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '-' %></span>
</div>
<div style="display:flex;gap:.35rem;margin-top:.5rem">
<a href="/email/compose?to=<%= msg.from_addr %>&subject=Re: <%= msg.subject %>" class="btn btn-small">↩ Beantwoorden</a>
<form method="POST" action="/email/message/<%= msg.id %>/delete" style="display:inline"><button class="btn-small danger">✕ Verwijderen</button></form>
</div>
</div>
<div class="email-body">
<% if (msg.body_html && msg.body_html.length > 10) { %>
<iframe sandbox="" srcdoc="<%= msg.body_html.replace(/"/g, '&quot;').replace(/'/g, '&#39;') %>"></iframe>
<% } else { %>
<pre><%= msg.body_text || '(geen inhoud)' %></pre>
<% } %>
</div>
</div><script src="/js/theme.js"></script><script>lucide.createIcons()</script></body></html>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Inbox — Mek-Tech</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&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>
body{background:var(--bg);font-family:'Inter',-apple-system,sans-serif;min-height:100vh}
.em-wrap{max-width:900px;margin:0 auto;padding:1rem 1.5rem 3rem}
.em-toolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;gap:.5rem;flex-wrap:wrap}
.em-toolbar h1{font-size:1.3rem;font-weight:800;color:var(--text);display:flex;align-items:center;gap:.4rem}
.em-toolbar .em-actions{display:flex;gap:.35rem;flex-wrap:wrap}
.em-actions .btn{display:inline-flex;align-items:center;gap:.3rem;padding:.35rem .7rem;border-radius:7px;font-size:.68rem;font-weight:500;cursor:pointer;border:none;font-family:inherit;text-decoration:none;transition:all .15s}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}
.btn-secondary{background:var(--border-light);color:var(--text-muted)}.btn-secondary:hover{color:var(--text);background:var(--border)}
.account-badge{font-size:.62rem;color:var(--text-dim);background:var(--bg-card);border:1px solid var(--border);padding:.2rem .5rem;border-radius:6px;display:inline-flex;align-items:center;gap:.25rem}
.em-row{display:flex;align-items:center;gap:.6rem;padding:.6rem .8rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;margin-bottom:2px;text-decoration:none;color:inherit;transition:all .15s;cursor:pointer}
.em-row:hover{border-color:var(--accent);background:rgba(88,166,255,.03)}
.em-row.unseen{border-left:3px solid var(--accent);font-weight:600}
.em-row .em-from{width:160px;font-size:.8rem;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0}
.em-row .em-subj{flex:1;font-size:.8rem;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.em-row .em-date{font-size:.68rem;color:var(--text-dim);flex-shrink:0;white-space:nowrap}
.em-row .em-star{color:var(--orange);flex-shrink:0;font-size:.75rem}
.empty-state{text-align:center;padding:3rem 1rem;color:var(--text-muted)}
.empty-state svg{width:48px;height:48px;color:var(--text-dim);margin-bottom:.75rem}
.empty-state h3{font-size:.9rem;color:var(--text);margin-bottom:.25rem}
.empty-state p{font-size:.72rem;margin-bottom:.75rem}
.alert{padding:.4rem .7rem;border-radius:7px;font-size:.72rem;margin-bottom:.75rem}
.alert-error{background:var(--red-bg);color:var(--red);border:1px solid var(--red)}
@media(max-width:640px){.em-row .em-from{width:100px;font-size:.72rem}.em-row .em-subj{font-size:.72rem}.em-row .em-date{font-size:.62rem}}
</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="em-wrap">
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<% if (!account) { %>
<div class="empty-state">
<i data-lucide="mail" style="width:56px;height:56px;color:var(--text-dim);margin-bottom:1rem"></i>
<h3>Geen email account gekoppeld</h3>
<p>Koppel je IMAP/SMTP account om emails te ontvangen en te versturen</p>
<a href="/email/settings" class="btn-primary" style="text-decoration:none;display:inline-flex;align-items:center;gap:.3rem;padding:.45rem .9rem;border-radius:7px;font-size:.72rem"><i data-lucide="plus" style="width:14px;height:14px"></i> Account configureren</a>
</div>
<% } else { %>
<div class="em-toolbar">
<h1><i data-lucide="inbox" style="color:var(--accent);width:20px;height:20px"></i> Inbox</h1>
<div class="em-actions">
<span class="account-badge"><%= account.email %></span>
<a href="/email/sync" class="btn btn-secondary"><i data-lucide="refresh-cw" style="width:13px;height:13px"></i> Sync</a>
<a href="/email/compose" class="btn btn-primary"><i data-lucide="pen-line" style="width:13px;height:13px"></i> Opstellen</a>
</div>
</div>
<% if (messages.length === 0) { %>
<div class="empty-state"><i data-lucide="inbox" style="width:48px;height:48px;color:var(--text-dim);margin-bottom:.75rem"></i><h3>Inbox is leeg</h3><p>Klik op Sync om je berichten op te halen</p></div>
<% } else { %>
<% messages.forEach(function(m){ %>
<a href="/email/message/<%= m.id %>" class="em-row <%= !m.seen ? 'unseen' : '' %>">
<span class="em-from"><%= m.from_name || m.from_addr || 'Onbekend' %></span>
<span class="em-subj"><%= m.subject || '(geen onderwerp)' %></span>
<% if (m.flagged) { %><span class="em-star">★</span><% } %>
<span class="em-date"><%= m.date ? new Date(m.date).toLocaleDateString('nl-NL',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '' %></span>
</a>
<% }) %>
<% } %>
<% } %>
</div>
<script>lucide.createIcons()</script><script src="/js/theme.js"></script></body></html>
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Email Accounts — Mek-Tech</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&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>
body{background:var(--bg);font-family:'Inter',-apple-system,sans-serif;min-height:100vh}
.em-wrap{max-width:680px;margin:0 auto;padding:1.25rem 1.5rem 3rem}
.em-hdr{margin-bottom:1.5rem}.em-hdr h1{font-size:1.4rem;font-weight:800;color:var(--text);display:flex;align-items:center;gap:.5rem}.em-hdr h1 svg{width:22px;height:22px;color:var(--accent)}.em-hdr p{font-size:.75rem;color:var(--text-muted);margin-top:.2rem}
.em-card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.1rem;margin-bottom:1rem}
.em-card h3{font-size:.72rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.4px;margin-bottom:.75rem;font-weight:600}
.em-account{display:flex;align-items:center;justify-content:space-between;padding:.55rem 0;border-bottom:1px solid var(--border-light);gap:.5rem}
.em-account:last-child{border-bottom:none}
.em-account-info{flex:1;min-width:0}
.em-account-info strong{font-size:.82rem;color:var(--text)}
.em-account-info span{font-size:.65rem;color:var(--text-dim);display:block}
.em-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.65rem}
@media(max-width:600px){.em-form-grid{grid-template-columns:1fr}}
.em-form-grid .field-full{grid-column:1/-1}
.field label{display:block;font-size:.65rem;font-weight:600;color:var(--text-muted);margin-bottom:.2rem;text-transform:uppercase;letter-spacing:.3px}
.field input,.field select{width:100%;padding:.4rem .55rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.78rem;font-family:inherit}
.field input:focus,.field select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px rgba(88,166,255,.08)}
.alert{padding:.4rem .7rem;border-radius:7px;font-size:.72rem;margin-bottom:.75rem;font-weight:500}
.alert-error{background:var(--red-bg);color:var(--red);border:1px solid var(--red)}
.alert-success{background:var(--green-bg);color:var(--green);border:1px solid var(--green)}
.btn{display:inline-flex;align-items:center;gap:.3rem;padding:.4rem .8rem;border-radius:7px;font-size:.72rem;font-weight:500;cursor:pointer;border:none;font-family:inherit;text-decoration:none;transition:all .15s}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}
.btn-secondary{background:var(--border-light);color:var(--text-muted)}.btn-secondary:hover{color:var(--text)}
.btn-danger{background:transparent;color:var(--red);border:1px solid transparent}.btn-danger:hover{background:var(--red-bg);border-color:var(--red)}
</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="em-wrap">
<a href="/email" class="back-link">← Terug naar inbox</a>
<div class="em-hdr"><h1><i data-lucide="at-sign"></i> Email Accounts</h1><p>Configureer IMAP/SMTP voor het ontvangen en versturen van e-mail</p></div>
<% if (saved) { %><div class="alert alert-success">Account succesvol opgeslagen</div><% } %>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<% if (accounts.length > 0) { %>
<div class="em-card"><h3>Bestaande Accounts</h3>
<% accounts.forEach(function(a){ %>
<div class="em-account">
<div class="em-account-info"><strong><%= a.email %></strong><span>IMAP: <%= a.imap_host %> · SMTP: <%= a.smtp_host %></span></div>
<form method="POST" action="/email/settings/<%= a.id %>/delete" style="flex-shrink:0"><button class="btn btn-danger" style="font-size:.65rem">✕</button></form>
</div>
<% }) %>
</div>
<% } %>
<div class="em-card">
<h3><%= accounts.length > 0 ? 'Account Toevoegen' : 'Eerste Account Configureren' %></h3>
<form method="POST" action="/email/settings">
<div class="em-form-grid">
<div class="field field-full"><label>E-mailadres</label><input type="email" name="email" required placeholder="uw@bedrijf.nl"></div>
<div class="field"><label>Weergavenaam</label><input type="text" name="name" placeholder="Uw Naam"></div>
<div class="field"><label>IMAP Server</label><input type="text" name="imap_host" required placeholder="imap.bedrijf.nl"></div>
<div class="field"><label>IMAP Poort</label><input type="number" name="imap_port" value="993"></div>
<div class="field"><label>IMAP SSL</label><select name="imap_secure"><option value="1">SSL (993)</option><option value="0">Geen (143)</option></select></div>
<div class="field"><label>IMAP Gebruiker</label><input type="text" name="imap_user" required placeholder="uw@bedrijf.nl"></div>
<div class="field"><label>IMAP Wachtwoord</label><input type="password" name="imap_pass" required></div>
<div class="field"><label>SMTP Server</label><input type="text" name="smtp_host" placeholder="smtp.bedrijf.nl"></div>
<div class="field"><label>SMTP Poort</label><input type="number" name="smtp_port" value="587"></div>
<div class="field"><label>SMTP SSL</label><select name="smtp_secure"><option value="0">STARTTLS (587)</option><option value="1">SSL (465)</option></select></div>
<div class="field"><label>SMTP Gebruiker</label><input type="text" name="smtp_user" placeholder="Zelfde als IMAP"></div>
<div class="field"><label>SMTP Wachtwoord</label><input type="password" name="smtp_pass" placeholder="Zelfde als IMAP"></div>
</div>
<div style="margin-top:.75rem;display:flex;gap:.5rem"><button class="btn btn-primary">Opslaan & Synchroniseren</button><a href="/email" class="btn btn-secondary">Annuleren</a></div>
</form></div>
</div>
<script>lucide.createIcons()</script><script src="/js/theme.js"></script></body></html>
+121
View File
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= engagement.title %> — Mek-Tech Consulting</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" type="image/svg+xml">
</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="container">
<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="page-header">
<div>
<h1><%= engagement.title %></h1>
</div>
<div class="header-actions">
<a href="/engagements/<%= engagement.id %>/edit" class="btn btn-primary">Bewerk</a>
<a href="/assess/<%= engagement.client_id %>/<%= engagement.id %>" class="btn btn-accent">Assessment koppelen</a>
</div>
</div>
<div class="detail-grid">
<div class="detail-card">
<h3>Details</h3>
<table class="info-table">
<tr><td>Cliënt</td><td><a href="/clients/<%= engagement.client_id %>" class="link"><%= engagement.client_name %></a></td></tr>
<tr><td>Type</td><td><span class="engagement-type <%= engagement.type %>"><%= engagement.type %></span></td></tr>
<tr><td>Status</td><td><span class="status-badge <%= engagement.status %>"><%= engagement.status %></span></td></tr>
<tr><td>Start</td><td><%= engagement.start_date ? new Date(engagement.start_date).toLocaleDateString('nl-NL') : '-' %></td></tr>
<tr><td>Eind</td><td><%= engagement.end_date ? new Date(engagement.end_date).toLocaleDateString('nl-NL') : '-' %></td></tr>
</table>
</div>
<div class="detail-card">
<h3>Status wijzigen</h3>
<form action="/engagements/<%= engagement.id %>/status" method="POST" class="status-form">
<select name="status">
<option value="planned" <%= engagement.status === 'planned' ? 'selected' : '' %>>Gepland</option>
<option value="in_progress" <%= engagement.status === 'in_progress' ? 'selected' : '' %>>Bezig</option>
<option value="completed" <%= engagement.status === 'completed' ? 'selected' : '' %>>Afgerond</option>
<option value="on_hold" <%= engagement.status === 'on_hold' ? 'selected' : '' %>>On Hold</option>
</select>
<button type="submit" class="btn btn-primary">Update</button>
</form>
</div>
</div>
<% if (engagement.description) { %>
<section class="section">
<h3 class="section-title">Omschrijving</h3>
<div class="card"><p style="white-space:pre-wrap"><%= engagement.description %></p></div>
</section>
<% } %>
<% if (engagement.outcome) { %>
<section class="section">
<h3 class="section-title">Resultaat</h3>
<div class="card"><p style="white-space:pre-wrap"><%= engagement.outcome %></p></div>
</section>
<% } %>
<section class="section">
<div class="section-header">
<h3 class="section-title">Taken (<%= tasks.length %>)</h3>
<button class="btn btn-small btn-primary" onclick="document.getElementById('taskForm').style.display='block'">+ Taak</button>
</div>
<form id="taskForm" action="/tasks/new" method="POST" class="task-form" style="display:none">
<input type="hidden" name="engagement_id" value="<%= engagement.id %>">
<div class="task-form-grid">
<input type="text" name="title" placeholder="Taak titel" required>
<input type="text" name="description" placeholder="Omschrijving (optioneel)">
<select name="priority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
<input type="date" name="due_date">
<button type="submit" class="btn btn-accent">Toevoegen</button>
</div>
</form>
<% if (tasks.length === 0) { %>
<div class="empty-state"><p>Nog geen taken.</p></div>
<% } else { %>
<div class="task-list">
<% tasks.forEach(function(t) { %>
<div class="task-item <%= t.status %>">
<form action="/tasks/<%= t.id %>/status" method="POST" class="task-status-form">
<select name="status" onchange="this.form.submit()">
<option value="todo" <%= t.status === 'todo' ? 'selected' : '' %>>Todo</option>
<option value="in_progress" <%= t.status === 'in_progress' ? 'selected' : '' %>>Bezig</option>
<option value="done" <%= t.status === 'done' ? 'selected' : '' %>>Done</option>
</select>
</form>
<div class="task-body">
<span class="task-title"><%= t.title %></span>
<% if (t.description) { %><span class="task-desc"><%= t.description %></span><% } %>
</div>
<span class="task-prio <%= t.priority %>"><%= t.priority %></span>
<% if (t.due_date) { %>
<span class="task-due <%= new Date(t.due_date) < new Date() && t.status !== 'done' ? 'overdue' : '' %>">
<%= new Date(t.due_date).toLocaleDateString('nl-NL') %>
</span>
<% } %>
<form action="/tasks/<%= t.id %>/delete" method="POST" style="display:inline">
<button class="btn-small danger" type="submit">×</button>
</form>
</div>
<% }) %>
</div>
<% } %>
</section>
</div>
<script src="/js/theme.js"></script></body>
</html>
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= engagement ? 'Bewerk' : 'Nieuw' %> Engagement — Mek-Tech Consulting</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" type="image/svg+xml">
</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="container">
<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="page-header">
<div>
<a href="/engagements" class="back-link">← Terug naar engagements</a>
<h1><%= engagement ? 'Bewerk: ' + engagement.title : 'Nieuw Engagement' %></h1>
</div>
</div>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<form action="/engagements/<%= engagement ? engagement.id + '/edit' : 'new' %>" method="POST" class="form-card">
<% if (!engagement) { %>
<div class="field">
<label for="client_id">Cliënt *</label>
<select name="client_id" id="client_id" required>
<option value="">Selecteer een cliënt...</option>
<% clients.forEach(function(c) { %>
<option value="<%= c.id %>" <%= (selected && String(selected) === String(c.id)) ? 'selected' : '' %>><%= c.name %></option>
<% }) %>
</select>
</div>
<% } else { %>
<input type="hidden" name="client_id" value="<%= engagement.client_id %>">
<% } %>
<div class="form-grid">
<div class="field">
<label for="title">Titel *</label>
<input type="text" name="title" id="title" value="<%= engagement ? engagement.title : '' %>" required>
</div>
<div class="field">
<label for="type">Type</label>
<select name="type" id="type">
<option value="assessment" <%= engagement && engagement.type === 'assessment' ? 'selected' : '' %>>Assessment</option>
<option value="architecture" <%= engagement && engagement.type === 'architecture' ? 'selected' : '' %>>Architectuur</option>
<option value="implementation" <%= engagement && engagement.type === 'implementation' ? 'selected' : '' %>>Implementatie</option>
<option value="monitoring" <%= engagement && engagement.type === 'monitoring' ? 'selected' : '' %>>Monitoring</option>
<option value="consulting" <%= engagement && engagement.type === 'consulting' ? 'selected' : '' %>>Consulting</option>
<option value="training" <%= engagement && engagement.type === 'training' ? 'selected' : '' %>>Training</option>
</select>
</div>
<div class="field">
<label for="status">Status</label>
<select name="status" id="status">
<option value="planned" <%= engagement && engagement.status === 'planned' ? 'selected' : '' %>>Gepland</option>
<option value="in_progress" <%= engagement && engagement.status === 'in_progress' ? 'selected' : '' %>>Bezig</option>
<option value="completed" <%= engagement && engagement.status === 'completed' ? 'selected' : '' %>>Afgerond</option>
<option value="on_hold" <%= engagement && engagement.status === 'on_hold' ? 'selected' : '' %>>On Hold</option>
</select>
</div>
<div class="field">
<label for="start_date">Startdatum</label>
<input type="date" name="start_date" id="start_date" value="<%= engagement ? engagement.start_date : '' %>">
</div>
<div class="field">
<label for="end_date">Einddatum</label>
<input type="date" name="end_date" id="end_date" value="<%= engagement ? engagement.end_date : '' %>">
</div>
</div>
<div class="field">
<label for="description">Omschrijving</label>
<textarea name="description" id="description" rows="4"><%= engagement ? engagement.description : '' %></textarea>
</div>
<% if (engagement) { %>
<div class="field">
<label for="outcome">Resultaat / Oplevering</label>
<textarea name="outcome" id="outcome" rows="4"><%= engagement.outcome || '' %></textarea>
</div>
<% } %>
<div class="form-actions">
<button type="submit" class="btn btn-accent"><%= engagement ? 'Opslaan' : 'Aanmaken' %></button>
<a href="/engagements" class="btn btn-secondary">Annuleren</a>
</div>
</form>
</div>
<script src="/js/theme.js"></script></body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Engagements — Mek-Tech Consulting</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" type="image/svg+xml">
</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="container">
<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="page-header">
<h1>Engagements</h1>
<a href="/engagements/new" class="btn btn-primary">+ Nieuw engagement</a>
</div>
<% if (engagements.length === 0) { %>
<div class="empty-state"><p>Nog geen engagements.</p></div>
<% } else { %>
<table class="data-table">
<thead>
<tr>
<th>Titel</th>
<th>Cliënt</th>
<th>Type</th>
<th>Status</th>
<th>Start</th>
<th>Eind</th>
</tr>
</thead>
<tbody>
<% engagements.forEach(function(e) { %>
<tr>
<td><a href="/engagements/<%= e.id %>" class="link"><%= e.title %></a></td>
<td><%= e.client_name %></td>
<td><span class="engagement-type <%= e.type %>"><%= e.type %></span></td>
<td><span class="status-badge <%= e.status %>"><%= e.status %></span></td>
<td><%= e.start_date ? new Date(e.start_date).toLocaleDateString('nl-NL') : '-' %></td>
<td><%= e.end_date ? new Date(e.end_date).toLocaleDateString('nl-NL') : '-' %></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div>
<script src="/js/theme.js"></script></body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Financiën — 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>
.fin-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.5rem;margin-bottom:1rem}
.fin-card{border:1px solid var(--border);border-radius:var(--radius);padding:.75rem;background:var(--bg-card)}
.fin-card .fc-num{font-size:1.3rem;font-weight:700;color:var(--text)}
.fin-card .fc-lbl{font-size:.65rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px}
.tab-bar{display:flex;gap:0;margin-bottom:1rem;background:var(--bg-card);border-radius:var(--radius);border:1px solid var(--border);overflow:hidden}
.tab-btn{padding:.45rem .9rem;font-size:.78rem;font-weight:500;color:var(--text-muted);cursor:pointer;transition:all .12s;border:none;background:none;font-family:inherit;border-right:1px solid var(--border);display:flex;align-items:center;gap:.3rem}
.tab-btn:last-child{border-right:none}
.tab-btn:hover{color:var(--text);background:var(--border-light)}
.tab-btn.active{color:var(--accent);background:rgba(88,166,255,.08);font-weight:600;box-shadow:inset 0 -2px 0 var(--accent)}
.tab-btn svg{width:14px;height:14px}
.pane{display:none}.pane.active{display:block}
.alert-sent{background:var(--green-bg);border:1px solid var(--green);color:var(--green);padding:.4rem .7rem;border-radius:var(--radius);font-size:.78rem;margin-bottom:.75rem;display:flex;align-items:center;gap:.35rem}
.alert-err{background:var(--red-bg);border:1px solid var(--red);color:var(--red);padding:.4rem .7rem;border-radius:var(--radius);font-size:.78rem;margin-bottom:.75rem}
</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="container" style="max-width:960px">
<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="page-header"><h1><i data-lucide="wallet" style="width:20px;height:20px;color:var(--accent)"></i> Financiën</h1></div>
<div class="fin-grid">
<div class="fin-card"><div class="fc-num"><%= totalHours.toFixed(1) %></div><div class="fc-lbl">Totaal uren</div></div>
<div class="fin-card"><div class="fc-num"><%= billableHours.toFixed(1) %></div><div class="fc-lbl">Facturabel</div></div>
<div class="fin-card"><div class="fc-num">€<%= totalInvoiced.toFixed(0) %></div><div class="fc-lbl">Gefactureerd</div></div>
<div class="fin-card"><div class="fc-num">€<%= totalPaid.toFixed(0) %></div><div class="fc-lbl">Betaald</div></div>
<div class="fin-card"><div class="fc-num" style="color:var(--red)">€<%= totalOutstanding.toFixed(0) %></div><div class="fc-lbl">Openstaand</div></div>
</div>
<div class="tab-bar">
<button class="tab-btn active" data-tab="overview"><i data-lucide="layout-dashboard"></i> Overzicht</button>
<button class="tab-btn" data-tab="time"><i data-lucide="clock"></i> Uren</button>
<button class="tab-btn" data-tab="invoices"><i data-lucide="receipt"></i> Facturen</button>
</div>
<div class="pane active" id="pane-overview">
<div class="fin-grid" style="grid-template-columns:1fr 1fr">
<a href="/finance?tab=time" class="fin-card" style="text-decoration:none;color:inherit;cursor:pointer"><div class="fc-num"><%= entries.length %></div><div class="fc-lbl">Recente urenregistraties</div></a>
<a href="/finance?tab=invoices" class="fin-card" style="text-decoration:none;color:inherit;cursor:pointer"><div class="fc-num"><%= invoices.length %></div><div class="fc-lbl">Recente facturen</div></a>
</div>
<div style="display:flex;gap:.35rem;flex-wrap:wrap">
<a href="/time" class="btn btn-secondary" style="font-size:.75rem;padding:.35rem .7rem"><i data-lucide="plus" style="width:12px;height:12px"></i> Uren toevoegen</a>
<a href="/invoices/new" class="btn btn-secondary" style="font-size:.75rem;padding:.35rem .7rem"><i data-lucide="plus" style="width:12px;height:12px"></i> Factuur maken</a>
</div>
</div>
<div class="pane" id="pane-time">
<a href="/time" class="btn btn-primary" style="margin-bottom:.5rem;font-size:.78rem;padding:.35rem .7rem"><i data-lucide="external-link" style="width:12px;height:12px"></i> Open volledige registratie</a>
<table class="data-table"><thead><tr><th>Datum</th><th>Cliënt</th><th>Hours</th><th>Omschrijving</th><th>Fact.</th></tr></thead><tbody>
<% entries.forEach(function(e){ %><tr><td><%= e.date %></td><td><%= e.client_name || '-' %></td><td><strong><%= e.hours %></strong></td><td style="color:var(--text-muted);font-size:.75rem"><%= e.description || '' %></td><td><%= e.billable ? '✓' : '-' %></td></tr><% }) %>
<% if (entries.length === 0) { %><tr><td colspan="5" style="text-align:center;color:var(--text-dim);padding:1rem">Nog geen uren</td></tr><% } %>
</tbody></table>
</div>
<div class="pane" id="pane-invoices">
<a href="/invoices" class="btn btn-primary" style="margin-bottom:.5rem;font-size:.78rem;padding:.35rem .7rem"><i data-lucide="external-link" style="width:12px;height:12px"></i> Alle facturen</a>
<table class="data-table"><thead><tr><th>Nr</th><th>Cliënt</th><th>Datum</th><th>Totaal</th><th>Status</th></tr></thead><tbody>
<% invoices.forEach(function(i){ %><tr><td><a href="/invoices/<%= i.id %>" class="link"><%= i.number %></a></td><td><%= i.client_name %></td><td><%= i.date %></td><td>€<%= (i.total||0).toFixed(2) %></td><td><span class="status-badge <%= i.status %>"><%= i.status %></span></td></tr><% }) %>
<% if (invoices.length === 0) { %><tr><td colspan="5" style="text-align:center;color:var(--text-dim);padding:1rem">Nog geen facturen</td></tr><% } %>
</tbody></table>
</div>
</div>
<script>
document.querySelectorAll('.tab-btn').forEach(function(b){
b.addEventListener('click',function(){
document.querySelectorAll('.tab-btn').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');
var u=new URL(window.location);u.searchParams.set('tab',this.dataset.tab);window.history.replaceState({},'',u);
});
});
var t=new URL(window.location).searchParams.get('tab');if(t){var btn=document.querySelector('.tab-btn[data-tab="'+t+'"]');if(btn)btn.click();}
lucide.createIcons();
</script>
<script src="/js/theme.js"></script></body></html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Wachtwoord vergeten — Mek-Tech</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&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">
<style>
html,body{height:100%;margin:0;padding:0}
body{display:flex;align-items:center;justify-content:center;background:var(--bg);position:relative;overflow:hidden;font-family:'Inter',-apple-system,sans-serif}
body::before{content:'';position:fixed;inset:0;z-index:0;background:radial-gradient(ellipse 80% 60% at 50% -30%,rgba(88,166,255,.06) 0%,transparent 55%),radial-gradient(ellipse 55% 45% at 85% 85%,rgba(130,80,223,.05) 0%,transparent 50%)}
.login-wrap{position:relative;z-index:1;width:100%;max-width:400px;padding:1.5rem}
.login-card{background:var(--bg-card);border:1px solid var(--border);border-radius:14px;padding:2.25rem 2rem;position:relative;overflow:hidden}
.login-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--accent),var(--purple),var(--green));background-size:200% 100%;animation:gradientShift 4s ease infinite}
.login-logo{text-align:center;margin-bottom:1.75rem}
.login-logo img{height:40px;width:auto;margin-bottom:.25rem;filter:drop-shadow(0 0 16px rgba(88,166,255,.12))}
.login-logo .tagline{font-size:.7rem;color:var(--text-dim);letter-spacing:.5px}
.field{margin-bottom:1rem}
.field label{display:block;font-size:.68rem;font-weight:600;color:var(--text-muted);margin-bottom:.3rem;text-transform:uppercase;letter-spacing:.4px}
.field input{width:100%;padding:.55rem .75rem;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.85rem;font-family:inherit;transition:all .2s ease}
.field input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(88,166,255,.1)}
.btn-primary{width:100%;padding:.6rem;background:linear-gradient(135deg,var(--accent),#4d8fd9);color:#fff;border:none;border-radius:8px;font-size:.82rem;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s}
.btn-primary:hover{box-shadow:0 4px 16px rgba(88,166,255,.25);transform:translateY(-1px)}
.alert{font-size:.72rem;padding:.45rem .7rem;border-radius:7px;margin-bottom:1rem;font-weight:500}
.alert-error{background:var(--red-bg);border:1px solid var(--red);color:var(--red)}
.alert-success{background:var(--green-bg);border:1px solid var(--green);color:var(--green)}
.link{color:var(--text-dim);text-decoration:none;font-size:.72rem;transition:color .15s;display:block;text-align:center;margin-top:1rem}
.link:hover{color:var(--accent)}
</style></head><body>
<div class="login-wrap"><div class="login-card">
<div class="login-logo"><img src="/img/logo.svg" alt="Mek-Tech"><p class="tagline">Wachtwoord herstellen</p></div>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
<form method="POST" action="/wachtwoord-vergeten">
<div class="field"><label>E-mailadres</label><input type="email" name="email" placeholder="uw@email.nl" required></div>
<button type="submit" class="btn-primary">Reset-link versturen</button>
</form>
<a href="/login" class="link">Terug naar inloggen</a>
</div></div></body></html>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Consultancy Framework - 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" type="image/svg+xml">
</head>
<body>
<div class="container">
<header class="header">
<div class="logo">
<img src="/img/logo.svg" class="login-logo-img" alt="Mek-Tech">
<div>
<h1>AI Consultancy Framework</h1>
<p class="subtitle">Mek-Tech.nl — Datacenter & AI Architectuur</p>
</div>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill" style="width: 0%"></div>
</div>
</header>
<form id="consultingForm" action="/report" method="POST">
<% questions.forEach(function(q, index) { %>
<div class="step" id="step<%= index %>" style="display: <%= index === 0 ? 'block' : 'none' %>">
<div class="step-header">
<span class="step-number">Stap <%= index + 1 %> / <%= questions.length %></span>
<h2><%= q.title %></h2>
</div>
<div class="fields">
<% q.fields.forEach(function(field) { %>
<div class="field">
<label for="<%= field.name %>"><%= field.label %></label>
<% if (field.type === 'textarea') { %>
<textarea name="<%= field.name %>" id="<%= field.name %>" rows="3" <%= field.required ? 'required' : '' %>></textarea>
<% } else { %>
<input type="<%= field.type %>" name="<%= field.name %>" id="<%= field.name %>" <%= field.required ? 'required' : '' %>>
<% } %>
</div>
<% }) %>
</div>
<div class="step-actions">
<% if (index > 0) { %>
<button type="button" class="btn btn-secondary" onclick="prevStep(<%= index %>)">Vorige</button>
<% } %>
<% if (index < questions.length - 1) { %>
<button type="button" class="btn btn-primary" onclick="nextStep(<%= index %>)">Volgende</button>
<% } else { %>
<button type="submit" class="btn btn-accent">Genereer Rapport</button>
<% } %>
</div>
</div>
<% }) %>
</form>
</div>
<script src="/js/main.js"></script>
</body>
</html>
+237
View File
@@ -0,0 +1,237 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Infrastructuur — 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{--infra-bg:var(--bg);--card-glow:0 0 0 1px var(--border)}
body{background:var(--bg)}
.infra-wrap{max-width:1200px;margin:0 auto;padding:1.25rem 1.5rem 3rem}
/* Header */
.infra-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem;flex-wrap:wrap;gap:.5rem}
.infra-hdr h1{font-size:1.4rem;color:var(--text);font-weight:700;display:flex;align-items:center;gap:.5rem}
.infra-hdr h1 svg{width:24px;height:24px;color:var(--accent)}
.infra-hdr .sub{font-size:.8rem;color:var(--text-muted);margin-top:.1rem}
/* Tabs */
.infra-tabs{display:flex;gap:0;margin-bottom:1.5rem;background:var(--bg-card);border-radius:var(--radius);border:1px solid var(--border);overflow:hidden}
.infra-tab{padding:.55rem 1.1rem;font-size:.8rem;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;white-space:nowrap;border-right:1px solid var(--border)}
.infra-tab:last-child{border-right:none}
.infra-tab:hover{color:var(--text);background:var(--border-light)}
.infra-tab.active{color:var(--accent);background:rgba(88,166,255,.08);font-weight:600;box-shadow:inset 0 -2px 0 var(--accent)}
.infra-tab svg{width:14px;height:14px}
/* KPI row */
.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.6rem;margin-bottom:1.25rem}
.kpi-box{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:.8rem 1rem;display:flex;align-items:center;gap:.75rem;transition:all .15s;position:relative;overflow:hidden}
.kpi-box:hover{border-color:var(--text-dim)}
.kpi-box .kb-icon{width:36px;height:36px;border-radius:var(--radius);display:flex;align-items:center;justify-content:center;font-size:1rem;flex-shrink:0}
.kpi-box .kb-num{font-size:1.4rem;font-weight:700;color:var(--text);line-height:1}
.kpi-box .kb-label{font-size:.65rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;margin-top:2px}
/* Panel content */
.panel{display:none}.panel.active{display:block}
/* Section header */
.sec-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem}
.sec-hdr h2{font-size:.95rem;color:var(--text);font-weight:600;display:flex;align-items:center;gap:.4rem}
.sec-hdr h2 svg{width:16px;height:16px;color:var(--accent)}
/* Grid layouts */
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.6rem}
@media(max-width:900px){.grid-3{grid-template-columns:1fr 1fr}}@media(max-width:600px){.grid-3{grid-template-columns:1fr}}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:.6rem}
@media(max-width:600px){.grid-2{grid-template-columns:1fr}}
/* Infra cards */
.infra-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;transition:all .12s}
.infra-card:hover{border-color:var(--text-dim)}
/* Rack mini visualization */
.rm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.5rem}
.rm-card{display:flex;gap:.6rem;padding:.6rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);text-decoration:none;color:inherit;transition:all .12s}
.rm-card:hover{border-color:var(--text-dim)}
.rm-vis{width:16px;display:flex;flex-direction:column;gap:1px;flex-shrink:0}
.rm-u{width:100%;height:4px;border-radius:1px;background:var(--border)}.rm-u.filled{background:var(--accent)}
.rm-info{flex:1;min-width:0}
.rm-info h4{font-size:.8rem;color:var(--text);font-weight:600}
.rm-info p{font-size:.68rem;color:var(--text-muted)}
/* Network device grid */
.nd-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.5rem}
.nd-card{display:flex;align-items:center;gap:.5rem;padding:.55rem .6rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);text-decoration:none;color:inherit;transition:all .12s}
.nd-card:hover{border-color:var(--text-dim)}
.nd-icon{width:28px;height:28px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:.65rem;font-weight:700;flex-shrink:0}
.nd-info{flex:1;min-width:0}
.nd-info h4{font-size:.75rem;color:var(--text);font-weight:500}
.nd-info p{font-size:.65rem;color:var(--text-muted)}
/* Diagram grid */
.dg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.5rem}
.dg-card{padding:.65rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);text-decoration:none;color:inherit;transition:all .12s;display:flex;flex-direction:column;gap:.2rem}
.dg-card:hover{border-color:var(--text-dim)}
.dg-card h4{font-size:.78rem;color:var(--text);font-weight:500}
.dg-card p{font-size:.65rem;color:var(--text-muted)}
/* Conn list */
.cl-list{display:flex;flex-direction:column;gap:2px}
.cl-item{display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--bg);border-radius:3px;font-size:.72rem;color:var(--text-muted)}
/* Empty state */
.empty-i{padding:2rem 1rem;text-align:center;color:var(--text-dim);font-size:.8rem}
/* Quick actions */
.qa-strip{display:flex;gap:.35rem;flex-wrap:wrap}
.qa-strip a{padding:.35rem .65rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);font-size:.72rem;color:var(--text-muted);text-decoration:none;transition:all .1s;display:flex;align-items:center;gap:.3rem}
.qa-strip a:hover{border-color:var(--accent);color:var(--accent)}
/* Data table compact */
.dt-compact{width:100%;border-collapse:collapse}
.dt-compact th{text-align:left;padding:.35rem .5rem;font-size:.65rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.3px;border-bottom:1px solid var(--border);background:var(--bg)}
.dt-compact td{padding:.35rem .5rem;font-size:.75rem;border-bottom:1px solid var(--border-light);color:var(--text)}
.dt-compact tr:hover td{background:var(--border-light)}
/* Form inline */
.form-inline{display:flex;gap:.35rem;flex-wrap:wrap;align-items:flex-end}
.form-inline input,.form-inline select{padding:.3rem .5rem;font-size:.75rem;width:auto;min-width:100px}
.form-inline .btn{padding:.3rem .6rem;font-size:.72rem}
/* Modal */
.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:1000;display:none;align-items:center;justify-content:center}
.modal-overlay.open{display:flex}
.modal-box{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:1.25rem;width:90%;max-width:600px;max-height:80vh;overflow-y:auto}
.modal-box h3{font-size:.9rem;color:var(--text);margin-bottom:.75rem;display:flex;justify-content:space-between;align-items:center}
.modal-close{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:1.1rem}
</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="infra-wrap">
<div class="infra-hdr">
<div><h1><i data-lucide="server"></i> Infrastructuur Beheer</h1><p class="sub">Racks, netwerkapparatuur en architectuurdiagrammen op één plek</p></div>
<div class="qa-strip">
<a href="#" onclick="showModal('rackModal')"><i data-lucide="plus" style="width:12px;height:12px"></i> Rack</a>
<a href="/networking/devices/new"><i data-lucide="plus" style="width:12px;height:12px"></i> Netwerk</a>
<a href="/architecture/new"><i data-lucide="plus" style="width:12px;height:12px"></i> Diagram</a>
</div>
</div>
<div class="infra-tabs">
<button class="infra-tab <%= tab === 'overview' ? 'active' : '' %>" data-tab="overview"><i data-lucide="layout-dashboard"></i> Overzicht</button>
<button class="infra-tab <%= tab === 'racks' ? 'active' : '' %>" data-tab="racks"><i data-lucide="server"></i> Racks <span class="sb-badge" style="font-size:.6rem;margin-left:.2rem"><%= totalRacks %></span></button>
<button class="infra-tab <%= tab === 'network' ? 'active' : '' %>" data-tab="network"><i data-lucide="wifi"></i> Netwerk <span class="sb-badge" style="font-size:.6rem;margin-left:.2rem"><%= totalNetworkDevices %></span></button>
<button class="infra-tab <%= tab === 'architecture' ? 'active' : '' %>" data-tab="architecture"><i data-lucide="layers"></i> Architectuur <span class="sb-badge" style="font-size:.6rem;margin-left:.2rem"><%= totalDiagrams %></span></button>
</div>
<!-- ===== OVERVIEW TAB ===== -->
<div class="panel <%= tab === 'overview' ? 'active' : '' %>" id="panel-overview">
<div class="kpi-grid">
<a href="/infrastructure?tab=racks" class="kpi-box" style="text-decoration:none;color:inherit"><div class="kb-icon" style="background:rgba(88,166,255,.1);color:var(--accent)"><i data-lucide="server" style="width:18px;height:18px"></i></div><div><div class="kb-num"><%= totalRacks %></div><div class="kb-label">Racks</div><div class="kb-label" style="color:var(--text-dim)"><%= totalRackDevices %> devices</div></div></a>
<a href="/infrastructure?tab=network" class="kpi-box" style="text-decoration:none;color:inherit"><div class="kb-icon" style="background:rgba(188,140,255,.1);color:#bc8cff"><i data-lucide="wifi" style="width:18px;height:18px"></i></div><div><div class="kb-num"><%= totalNetworkDevices %></div><div class="kb-label">Netwerk Devices</div><div class="kb-label" style="color:var(--text-dim)"><%= totalConnections %> connecties</div></div></a>
<a href="/infrastructure?tab=architecture" class="kpi-box" style="text-decoration:none;color:inherit"><div class="kb-icon" style="background:rgba(63,185,80,.1);color:var(--green)"><i data-lucide="layers" style="width:18px;height:18px"></i></div><div><div class="kb-num"><%= totalDiagrams %></div><div class="kb-label">Diagrammen</div><div class="kb-label" style="color:var(--text-dim)">architectuur</div></div></a>
</div>
<div class="grid-3">
<div class="infra-card"><div class="sec-hdr"><h2><i data-lucide="server"></i> Recente Racks</h2></div>
<% if (recentRacks.length === 0) { %><div class="empty-i">Nog geen racks</div><% } else { %><div class="rm-grid" style="grid-template-columns:1fr">
<% recentRacks.forEach(function(r){ %><a href="/racks/<%= r.id %>" class="rm-card" style="padding:.5rem">
<div class="rm-vis"><% for(var i=0;i<Math.min(r.total_units||42,10);i++){%><div class="rm-u <%= i < (r.device_count||0) ? 'filled' : '' %>"></div><% } %></div>
<div class="rm-info"><h4><%= r.name %></h4><p><%= r.client_name %> · <%= r.device_count %> devices</p></div>
</a><% }) %></div><% } %></div>
<div class="infra-card"><div class="sec-hdr"><h2><i data-lucide="wifi"></i> Netwerk Types</h2></div>
<% if (netDeviceTypes.length === 0) { %><div class="empty-i">Geen netwerk devices</div><% } else { %>
<% var maxNd = Math.max(...netDeviceTypes.map(function(e){return e.cnt}), 1); %>
<% netDeviceTypes.forEach(function(d){ %><div style="display:flex;justify-content:space-between;align-items:center;padding:.3rem 0;font-size:.75rem"><span style="color:var(--text-muted);text-transform:capitalize"><%= d.device_type %></span><span style="color:var(--text);font-weight:600"><%= d.cnt %></span></div>
<div style="height:3px;background:var(--border-light);border-radius:2px;margin-bottom:.2rem;overflow:hidden"><div style="height:100%;width:<%= (d.cnt/maxNd*100) %>%background:var(--accent);border-radius:2px"></div></div>
<% }) %><% } %></div>
<div class="infra-card"><div class="sec-hdr"><h2><i data-lucide="layers"></i> Diagram Types</h2></div>
<% if (diagramTypes.length === 0) { %><div class="empty-i">Geen diagrammen</div><% } else { %>
<% var maxDg = Math.max(...diagramTypes.map(function(e){return e.cnt}), 1); %>
<% diagramTypes.forEach(function(d){ %><div style="display:flex;justify-content:space-between;align-items:center;padding:.3rem 0;font-size:.75rem"><span style="color:var(--text-muted);text-transform:capitalize"><%= d.diagram_type.replace(/_/g,' ') %></span><span style="color:var(--text);font-weight:600"><%= d.cnt %></span></div>
<div style="height:3px;background:var(--border-light);border-radius:2px;margin-bottom:.2rem;overflow:hidden"><div style="height:100%;width:<%= (d.cnt/maxDg*100) %>%background:var(--green);border-radius:2px"></div></div>
<% }) %><% } %></div>
</div>
<div class="grid-2" style="margin-top:.6rem">
<div class="infra-card"><div class="sec-hdr"><h2><i data-lucide="activity"></i> Device Types</h2></div>
<% if (deviceTypes.length === 0) { %><div class="empty-i">Geen rack devices</div><% } else { %><div class="cl-list"><% deviceTypes.forEach(function(d){ %><div class="cl-item"><span style="text-transform:capitalize;flex:1"><%= d.device_type %></span><span style="font-weight:600;color:var(--text)"><%= d.cnt %></span></div><% }) %></div><% } %></div>
<div class="infra-card"><div class="sec-hdr"><h2><i data-lucide="trending-up"></i> Per Cliënt</h2></div>
<% if (racksByClient.length === 0) { %><div class="empty-i">Geen data</div><% } else { %><div class="cl-list"><% racksByClient.forEach(function(r){ %><div class="cl-item"><span style="flex:1"><%= r.client_name %></span><span style="font-weight:600;color:var(--text)"><%= r.cnt %> racks</span></div><% }) %></div><% } %></div>
</div>
</div>
<!-- ===== RACKS TAB ===== -->
<div class="panel <%= tab === 'racks' ? 'active' : '' %>" id="panel-racks">
<div class="sec-hdr"><h2><i data-lucide="server"></i> Server Racks</h2><a href="/racks" class="btn btn-primary btn-small">Alle racks</a></div>
<% if (racks.length === 0) { %><div class="empty-i">Nog geen racks. <a href="#" class="link" onclick="showModal('rackModal')">Voeg je eerste rack toe</a>.</div><% } else { %>
<div class="rm-grid">
<% racks.forEach(function(r){ %><a href="/racks/<%= r.id %>" class="rm-card">
<div class="rm-vis"><% var maxU = Math.min(r.total_units||42,20); for(var i=0;i<maxU;i++){%><div class="rm-u <%= i < (r.device_count||0) ? 'filled' : '' %>"></div><% } %></div>
<div class="rm-info"><h4><%= r.name %></h4><p><%= r.client_name %> · <%= r.device_count || 0 %>/<%= r.total_units || 42 %>U</p><p style="font-size:.6rem"><%= r.location || r.datacenter || '' %></p></div>
</a><% }) %>
</div>
<% } %>
</div>
<!-- ===== NETWORK TAB ===== -->
<div class="panel <%= tab === 'network' ? 'active' : '' %>" id="panel-network">
<div class="sec-hdr"><h2><i data-lucide="wifi"></i> Netwerkapparatuur</h2><a href="/networking" class="btn btn-primary btn-small">Alle netwerk</a></div>
<% if (netDevices.length === 0) { %><div class="empty-i">Geen netwerk devices. <a href="/networking/devices/new" class="link">Voeg device toe</a>.</div><% } else { %>
<div class="nd-grid">
<% netDevices.forEach(function(d){ %><a href="/networking/devices/<%= d.id %>" class="nd-card">
<div class="nd-icon" style="background:<%= d.device_type==='switch'?'rgba(188,140,255,.15)':d.device_type==='router'?'rgba(88,166,255,.15)':d.device_type==='firewall'?'rgba(248,81,73,.15)':'rgba(63,185,80,.15)' %>;color:<%= d.device_type==='switch'?'#bc8cff':d.device_type==='router'?'#58a6ff':d.device_type==='firewall'?'#f85149':'#3fb950' %>"><%= d.device_type.charAt(0).toUpperCase() %></div>
<div class="nd-info"><h4><%= d.name %></h4><p><%= d.model || d.device_type %> · <%= d.client_name %></p></div>
</a><% }) %>
</div>
<% } %>
<% if (connections.length > 0) { %><div class="infra-card" style="margin-top:.75rem">
<div class="sec-hdr"><h2><i data-lucide="link"></i> Verbindingen</h2></div>
<div class="cl-list"><% connections.slice(0,10).forEach(function(c){ %><div class="cl-item"><span style="flex:1"><%= c.name || 'Onbekend' %> · <%= c.from_type %> → <%= c.to_type %></span><span style="font-size:.65rem;color:var(--accent)"><%= c.speed %> · <%= c.media_type %></span></div><% }) %></div>
</div><% } %>
</div>
<!-- ===== ARCHITECTURE TAB ===== -->
<div class="panel <%= tab === 'architecture' ? 'active' : '' %>" id="panel-architecture">
<div class="sec-hdr"><h2><i data-lucide="layers"></i> Architectuur Diagrammen</h2><a href="/architecture" class="btn btn-primary btn-small">Alle diagrammen</a></div>
<% if (diagrams.length === 0) { %><div class="empty-i">Geen diagrammen. <a href="/architecture/new" class="link">Maak je eerste diagram</a>.</div><% } else { %>
<div class="dg-grid">
<% diagrams.forEach(function(d){ %><a href="/architecture/<%= d.id %>" class="dg-card">
<div style="display:flex;gap:.35rem;align-items:center;margin-bottom:.2rem"><span style="font-size:.6rem;padding:.05rem .35rem;border-radius:3px;background:<%= d.diagram_type==='data_architecture'?'rgba(88,166,255,.1)':'rgba(188,140,255,.1)' %>;color:<%= d.diagram_type==='data_architecture'?'#58a6ff':'#bc8cff' %>"><%= d.diagram_type.replace(/_/g,' ') %></span><span style="font-size:.6rem;color:var(--text-dim)"><%= d.node_count || 0 %> nodes</span></div>
<h4><%= d.name %></h4>
<p><%= d.client_name %> · <%= new Date(d.updated_at).toLocaleDateString('nl-NL') %></p>
</a><% }) %>
</div>
<% } %>
</div>
</div>
<!-- ===== RACK MODAL ===== -->
<div class="modal-overlay" id="rackModal"><div class="modal-box">
<h3>Nieuw Rack <button class="modal-close" onclick="hideModal('rackModal')">✕</button></h3>
<form method="POST" action="/racks/new">
<select name="client_id" required style="margin-bottom:.5rem"><option value="">Cliënt...</option><% clients.forEach(function(c){ %><option value="<%= c.id %>"><%= c.name %></option><% }) %></select>
<div class="form-inline"><input type="text" name="name" placeholder="Rack naam" required><input type="text" name="location" placeholder="Locatie">
<select name="total_units"><option value="42">42U</option><option value="48">48U</option><option value="24">24U</option><option value="12">12U</option></select>
<button class="btn btn-primary" type="submit">Aanmaken</button></div>
</form></div></div>
<script>
document.querySelectorAll('.infra-tab').forEach(function(tab){
tab.addEventListener('click',function(){
var t = this.dataset.tab;
document.querySelectorAll('.infra-tab').forEach(function(b){b.classList.remove('active')});
document.querySelectorAll('.panel').forEach(function(p){p.classList.remove('active')});
this.classList.add('active');
document.getElementById('panel-'+t).classList.add('active');
var url = new URL(window.location);
url.searchParams.set('tab', t);
window.history.replaceState({}, '', url);
});
});
var t = new URL(window.location).searchParams.get('tab');
if(t){var btn=document.querySelector('.infra-tab[data-tab="'+t+'"]');if(btn)btn.click();}
function showModal(id){document.getElementById(id).classList.add('open')}
function hideModal(id){document.getElementById(id).classList.remove('open')}
document.querySelectorAll('.modal-overlay').forEach(function(m){m.addEventListener('click',function(e){if(e.target===this)this.classList.remove('open')})});
lucide.createIcons();
</script>
<script src="/js/theme.js"></script></body></html>
+116
View File
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Integraties — Mek-Tech</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/neon.css">
<link rel="stylesheet" href="/css/dynamic.css">
<link rel="icon" href="/img/favicon.svg">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.int-wrap{max-width:1100px;margin:0 auto;padding:1.5rem 2rem 3rem}
.int-hdr{margin-bottom:1.25rem}
.int-hdr h1{font-size:1.4rem;font-weight:800;margin:0}
.int-hdr p{font-size:.78rem;color:var(--text-muted);margin-top:.2rem}
.int-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:.85rem}
.int-card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.1rem;transition:all .2s}
.int-card:hover{border-color:rgba(0,212,255,.3);box-shadow:0 4px 20px rgba(0,212,255,.08)}
.int-card.connected{border-color:rgba(63,185,80,.4)}
.int-icon{width:48px;height:48px;border-radius:10px;background:linear-gradient(135deg,rgba(0,212,255,.15),rgba(168,85,247,.15));display:flex;align-items:center;justify-content:center;margin-bottom:.75rem}
.int-icon svg{width:24px;height:24px;color:var(--accent)}
.int-title{font-size:.95rem;font-weight:700;margin-bottom:.25rem}
.int-desc{font-size:.72rem;color:var(--text-muted);margin-bottom:.65rem;line-height:1.4}
.int-status{font-size:.62rem;font-weight:600;padding:.15rem .5rem;border-radius:999px;display:inline-block}
.int-status.connected{background:var(--green-bg);color:var(--green)}
.int-status.available{background:var(--cyan-bg);color:var(--cyan)}
.int-actions{display:flex;gap:.4rem;margin-top:.75rem;flex-wrap:wrap}
.int-modal{position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:200;display:none;align-items:center;justify-content:center}
.int-modal.open{display:flex}
.int-modal-box{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:1.25rem;width:95%;max-width:420px}
.int-modal-box h3{font-size:.9rem;margin:0 0 .75rem}
.int-modal-box label{font-size:.65rem;color:var(--text-muted);display:block;margin-bottom:.2rem}
.int-modal-box input{width:100%;padding:.45rem .6rem;background:var(--bg);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:.78rem;margin-bottom:.65rem;font-family:inherit}
</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"><i data-lucide="layout-dashboard" style="width:15px;height:15px"></i> Dashboard</a>
<a href="/ai/providers" class="nav-icon-btn"><i data-lucide="sparkles" style="width:15px;height:15px"></i> AI</a>
<a href="/settings" class="nav-icon-btn"><i data-lucide="settings" style="width:15px;height:15px"></i></a>
<a href="/auth/logout" class="nav-icon-btn" style="color:var(--red)"><i data-lucide="log-out" style="width:15px;height:15px"></i></a>
</div></div></nav>
<div class="int-wrap">
<a href="/" 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 naar dashboard</a>
<% if (saved) { %><div class="alert" style="margin-top:.75rem">Integratie opgeslagen.</div><% } %>
<div class="int-hdr fade-in-up">
<h1>Integraties</h1>
<p>Verbind externe systemen met je Mek-Tech consultancy platform — AI, calendar, CRM en boekhouding.</p>
</div>
<div class="int-grid">
<% var activeIds = (activeIntegrations || []).map(function(i){ return i.id; });
integrations.forEach(function(integration) {
var isConnected = activeIds.indexOf(integration.id) >= 0;
%>
<div class="int-card <%= isConnected ? 'connected' : '' %> fade-in-up">
<div class="int-icon"><i data-lucide="<%= integration.icon %>"></i></div>
<div class="int-title"><%= integration.name %></div>
<div class="int-desc"><%= integration.description %></div>
<span class="int-status <%= isConnected ? 'connected' : 'available' %>"><%= isConnected ? 'Verbonden' : 'Beschikbaar' %></span>
<div class="int-actions">
<% if (integration.category === 'ai') { %>
<a href="/ai/providers" class="btn btn-accent btn-small">Configureren</a>
<% } else if (isConnected) { %>
<form method="POST" action="/integrations/disconnect/<%= integration.id %>" style="display:inline"><button type="submit" class="btn btn-secondary btn-small">Verbreken</button></form>
<button type="button" class="btn btn-secondary btn-small" onclick="testIntegration('<%= integration.id %>')">Test</button>
<% } else { %>
<button type="button" class="btn btn-accent btn-small" onclick="openConnect('<%= integration.id %>','<%= integration.name %>')">Verbinden</button>
<% } %>
</div>
</div>
<% }) %>
</div>
</div>
<div class="int-modal" id="connectModal" onclick="if(event.target===this)closeConnect()">
<div class="int-modal-box">
<h3 id="connectTitle">Integratie verbinden</h3>
<form id="connectForm" method="POST">
<label>API Key / Token</label>
<input type="password" name="api_key" placeholder="Optioneel — laat leeg voor webhook-only">
<label>Webhook URL</label>
<input type="url" name="webhook_url" placeholder="https://...">
<div style="display:flex;gap:.4rem;margin-top:.25rem">
<button type="submit" class="btn btn-accent btn-small">Opslaan & verbinden</button>
<button type="button" class="btn btn-secondary btn-small" onclick="closeConnect()">Annuleren</button>
</div>
</form>
</div>
</div>
<script>
lucide.createIcons();
function openConnect(id, name) {
document.getElementById('connectTitle').textContent = name + ' verbinden';
document.getElementById('connectForm').action = '/integrations/connect/' + id;
document.getElementById('connectModal').classList.add('open');
}
function closeConnect() { document.getElementById('connectModal').classList.remove('open'); }
async function testIntegration(id) {
try {
var r = await fetch('/integrations/test/' + id, { method: 'POST' });
var d = await r.json();
alert(d.success ? 'Verbinding succesvol getest.' : (d.message || 'Test mislukt'));
} catch (e) { alert('Test mislukt: ' + e.message); }
}
</script>
<script src="/js/theme.js"></script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title><%= invoice.number %> — 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>.invoice-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:2rem;flex-wrap:wrap;gap:.5rem}.invoice-org h2{font-size:1.2rem;color:var(--text)}.invoice-client{text-align:right}.invoice-client h3{font-size:1rem;color:var(--text)}.invoice-meta{color:var(--text-muted);font-size:.8rem}.invoice-total-box{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;text-align:right;margin-top:1rem}.invoice-total-box .amount{font-size:1.8rem;font-weight:700;color:var(--accent)}.alert-sent{background:var(--green-bg);border:1px solid var(--green);color:var(--green);padding:.5rem .75rem;border-radius:var(--radius);font-size:.8rem;margin-bottom:.75rem;display:flex;align-items:center;gap:.4rem}.alert-err{background:var(--red-bg);border:1px solid var(--red);color:var(--red);padding:.5rem .75rem;border-radius:var(--radius);font-size:.8rem;margin-bottom:.75rem}</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="container" style="max-width:800px">
<% if (sent) { %><div class="alert-sent"><i data-lucide="check-circle" style="width:16px;height:16px"></i> Factuur verzonden per email!</div><% } %>
<% if (error) { %><div class="alert-err"><%= error %></div><% } %>
<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="invoice-header">
<div class="invoice-org"><h2>Mek-Tech Consulting</h2><p class="invoice-meta"><%= invoice.contact_email %><br><%= invoice.website || '' %></p></div>
<div class="invoice-client"><h3><%= invoice.client_name %></h3><p class="invoice-meta"><%= invoice.contact_name || '' %><br><%= invoice.contact_email || '' %><br><%= invoice.contact_phone || '' %></p></div>
</div>
<div class="detail-grid">
<div class="detail-card"><h3>Factuur</h3><p style="color:var(--text);font-size:1.1rem;font-weight:600"><%= invoice.number %></p><p style="color:var(--text-muted);font-size:.8rem">Datum: <%= invoice.date %><br>Vervaldatum: <%= invoice.due_date || '-' %></p></div>
<div class="detail-card"><h3>Status</h3><form method="POST" action="/invoices/<%= invoice.id %>/status" class="status-form">
<select name="status">
<option value="draft" <%= invoice.status==='draft'?'selected':'' %>>Concept</option>
<option value="sent" <%= invoice.status==='sent'?'selected':'' %>>Verzonden</option>
<option value="paid" <%= invoice.status==='paid'?'selected':'' %>>Betaald</option>
<option value="overdue" <%= invoice.status==='overdue'?'selected':'' %>>Overdue</option>
<option value="cancelled" <%= invoice.status==='cancelled'?'selected':'' %>>Geannuleerd</option>
</select>
<button class="btn-small">Bijwerken</button>
</form></div>
</div>
<div style="display:flex;gap:.35rem;margin-bottom:1rem;flex-wrap:wrap">
<form method="POST" action="/invoices/<%= invoice.id %>/send-email"><button class="btn btn-primary" style="font-size:.78rem;padding:.35rem .7rem"><i data-lucide="send" style="width:14px;height:14px"></i> Verstuur per email</button></form>
<a href="/invoices" class="btn btn-secondary" style="font-size:.78rem;padding:.35rem .7rem"><i data-lucide="list" style="width:14px;height:14px"></i> Alle facturen</a>
</div>
<table class="data-table"><thead><tr><th>Omschrijving</th><th>Aantal</th><th>Prijs</th><th>Totaal</th></tr></thead><tbody>
<% items.forEach(function(item){ %>
<tr><td><%= item.description %></td><td><%= item.quantity %></td><td>€<%= (item.unit_price || 0).toFixed(2) %></td><td>€<%= (item.total || 0).toFixed(2) %></td></tr>
<% }) %>
<tr style="font-weight:600"><td colspan="3" style="text-align:right">Subtotaal</td><td>€<%= (invoice.subtotal || 0).toFixed(2) %></td></tr>
<tr style="font-weight:600"><td colspan="3" style="text-align:right">BTW</td><td>€<%= (invoice.tax || 0).toFixed(2) %></td></tr>
</tbody></table>
<div class="invoice-total-box"><div class="amount">€<%= (invoice.total || 0).toFixed(2) %></div><div class="invoice-meta">Totaal inclusief BTW</div></div>
<% if (invoice.notes) { %><div class="detail-card" style="margin-top:1rem"><h3>Notities</h3><p style="color:var(--text);font-size:.85rem"><%= invoice.notes %></p></div><% } %>
</div>
<script>lucide.createIcons()</script><script src="/js/theme.js"></script></body></html>
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Nieuwe Factuur — 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></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="container" style="max-width:800px">
<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="page-header"></a>
<h1>Nieuwe Factuur</h1></div>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<form method="POST" action="/invoices/new">
<div class="form-card">
<div class="form-grid">
<div class="field"><label>Cliënt</label><select name="client_id" required><option value="">Kies cliënt...</option><% clients.forEach(function(c){ %><option value="<%= c.id %>" <%= selectedClient == c.id ? 'selected' : '' %>><%= c.name %></option><% }) %></select></div>
<div class="field"><label>Factuurdatum</label><input type="date" name="date" value="<%= new Date().toISOString().split('T')[0] %>"></div>
<div class="field"><label>Vervaldatum</label><input type="date" name="due_date"></div>
</div>
</div>
<div class="form-card" style="margin-top:.75rem">
<h3 style="font-size:.8rem;color:var(--text-muted);margin-bottom:.5rem">Factuurregels</h3>
<div id="items"><div class="item-row" style="display:flex;gap:.35rem;margin-bottom:.35rem"><input type="text" name="items[0][description]" placeholder="Omschrijving" style="flex:1"><input type="number" name="items[0][quantity]" placeholder="Aantal" value="1" style="width:70px" step="0.01"><input type="number" name="items[0][unit_price]" placeholder="Prijs" style="width:100px" step="0.01"><button type="button" class="btn-small" onclick="this.parentElement.remove()">✕</button></div></div>
<button type="button" class="btn btn-secondary" onclick="addItem()" style="margin-top:.35rem">+ Regel toevoegen</button>
</div>
<div class="form-card" style="margin-top:.75rem">
<div class="form-grid">
<div class="field"><label>Subtotaal</label><input type="number" name="subtotal" step="0.01" value="0"></div>
<div class="field"><label>BTW</label><input type="number" name="tax" step="0.01" value="0"></div>
<div class="field" style="grid-column:1/-1"><label>Notities</label><textarea name="notes" rows="3"></textarea></div>
</div>
</div>
<div class="form-actions"><button class="btn btn-primary">Factuur aanmaken</button><a href="/invoices" class="btn btn-secondary">Annuleren</a></div>
</form></div>
<script>function addItem(){var i=document.querySelectorAll('.item-row').length;var d=document.createElement('div');d.className='item-row';d.style.cssText='display:flex;gap:.35rem;margin-bottom:.35rem';d.innerHTML='<input type="text" name="items['+i+'][description]" placeholder="Omschrijving" style="flex:1"><input type="number" name="items['+i+'][quantity]" placeholder="Aantal" value="1" style="width:70px" step="0.01"><input type="number" name="items['+i+'][unit_price]" placeholder="Prijs" style="width:100px" step="0.01"><button type="button" class="btn-small" onclick="this.parentElement.remove()">✕</button>';document.getElementById('items').appendChild(d)}</script>
<script src="/js/theme.js"></script><script>lucide.createIcons()</script></body></html>
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Facturen — 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></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="container">
<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="page-header"><h1>Invoices</h1><a href="/invoices/new" class="btn btn-primary">+ Nieuwe factuur</a></div>
<div class="stats-grid">
<div class="stat-card"><div class="stat-number">€<%= totalOutstanding.toFixed(2) %></div><div class="stat-label">Openstaand</div></div>
<div class="stat-card green"><div class="stat-number">€<%= totalPaid.toFixed(2) %></div><div class="stat-label">Betaald</div></div>
</div>
<table class="data-table"><thead><tr><th>Factuurnr</th><th>Cliënt</th><th>Datum</th><th>Vervaldatum</th><th>Subtotaal</th><th>Totaal</th><th>Status</th><th></th></tr></thead><tbody>
<% invoices.forEach(function(i){ %>
<tr><td><a href="/invoices/<%= i.id %>" class="link"><%= i.number %></a></td><td><%= i.client_name %></td><td><%= i.date %></td><td><%= i.due_date || '-' %></td><td>€<%= (i.subtotal || 0).toFixed(2) %></td><td><strong>€<%= (i.total || 0).toFixed(2) %></strong></td><td><span class="status-badge <%= i.status %>"><%= i.status %></span></td><td><a href="/invoices/<%= i.id %>" class="btn-small">Details</a></td></tr>
<% }) %>
</tbody></table></div>
<script>lucide.createIcons()</script><script src="/js/theme.js"></script></body></html>
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<%- include('partials/head', { pageTitle: 'Login' }) %>
<style>
html,body{height:100%;margin:0;padding:0}
body{display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;font-family:'Inter',sans-serif}
body::before{content:'';position:fixed;inset:0;z-index:0;background:radial-gradient(ellipse 80% 60% at 50% -30%,rgba(0,212,255,.1) 0%,transparent 55%),radial-gradient(ellipse 55% 45% at 85% 85%,rgba(168,85,247,.08) 0%,transparent 50%),radial-gradient(ellipse 45% 35% at 15% 15%,rgba(0,255,136,.05) 0%,transparent 50%);pointer-events:none}
.login-wrap{position:relative;z-index:1;width:100%;max-width:420px;padding:1.5rem}
.login-card{background:var(--bg-card);border:1px solid rgba(0,212,255,.2);border-radius:16px;padding:2.25rem 2rem;animation:fadeInUp .5s ease-out;position:relative;overflow:hidden;box-shadow:0 0 40px rgba(0,212,255,.12),inset 0 1px 0 rgba(255,255,255,.05)}
.login-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--accent),var(--purple),var(--green),var(--accent));background-size:200% 100%;animation:gradientShift 4s ease infinite}
.login-logo{text-align:center;margin-bottom:1.75rem}
.login-logo img{height:56px;width:auto;margin-bottom:.35rem;animation:float 4s ease-in-out infinite;filter:drop-shadow(0 0 20px rgba(0,212,255,.25))}
.login-logo .tagline{font-size:.7rem;color:var(--text-dim);letter-spacing:.6px;font-weight:500;text-transform:uppercase}
.field{margin-bottom:1rem;opacity:0;animation:fadeInUp .45s ease-out forwards}
.field:nth-child(1){animation-delay:.1s}.field:nth-child(2){animation-delay:.2s}
.field label{display:block;font-size:.68rem;font-weight:600;color:var(--text-muted);margin-bottom:.3rem;text-transform:uppercase;letter-spacing:.4px}
.field input{width:100%;padding:.6rem .75rem;background:var(--bg);border:1px solid var(--border);border-radius:9px;color:var(--text);font-size:.85rem;font-family:inherit;transition:all .2s ease;box-sizing:border-box}
.login-btn{width:100%;padding:.65rem;background:linear-gradient(135deg,var(--accent),#0099cc);color:#fff;border:none;border-radius:9px;font-size:.82rem;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s ease;opacity:0;animation:fadeInUp .45s ease-out .3s forwards;box-shadow:0 0 20px rgba(0,212,255,.3)}
.login-footer{text-align:center;margin-top:1.15rem;opacity:0;animation:fadeInUp .45s ease-out .4s forwards}
.login-footer a{color:var(--text-dim);text-decoration:none;font-size:.7rem;transition:color .15s}
.login-footer a:hover{color:var(--accent)}
.alert{font-size:.72rem;padding:.45rem .7rem;border-radius:7px;margin-bottom:1rem;font-weight:500}
.alert-error{background:var(--red-bg);border:1px solid var(--red);color:var(--red)}
.alert-success{background:var(--green-bg);border:1px solid var(--green);color:var(--green)}
</style>
</head>
<body>
<div class="login-wrap">
<div class="login-card">
<div class="login-logo">
<img src="/img/logo.svg" alt="Mek-Tech">
<p class="tagline">AI Consultancy & Data Engineering</p>
</div>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<% if (reset) { %><div class="alert alert-success">Wachtwoord succesvol gewijzigd</div><% } %>
<form action="/auth/login" method="POST" autocomplete="off">
<div class="field">
<label>Gebruikersnaam</label>
<input type="text" name="username" placeholder="Voer uw gebruikersnaam in" required autofocus>
</div>
<div class="field">
<label>Wachtwoord</label>
<input type="password" name="password" placeholder="Voer uw wachtwoord in" required>
</div>
<button type="submit" class="login-btn">Inloggen</button>
</form>
<div class="login-footer">
<a href="/wachtwoord-vergeten">Wachtwoord vergeten?</a>
<span style="margin:0 .5rem;color:var(--border)">·</span>
<span style="font-size:.62rem;color:var(--text-dim)">v2.1 Neon</span>
</div>
</div>
</div>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inloggen — Mek-Tech Consulting</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-v2.css">
<link rel="stylesheet" href="/css/dynamic.css">
<link rel="icon" href="/img/favicon.svg" type="image/svg+xml">
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-logo">
<img src="/img/logo.svg" class="login-logo-img" alt="Mek-Tech">
<h1>Mek-Tech</h1>
<p class="subtitle">AI Consultancy & Forward Engineering</p>
</div>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<% if (reset) { %>
<div class="alert" style="background:var(--green-bg);border-color:var(--green);color:var(--green)">Wachtwoord succesvol gewijzigd! Log in met je nieuwe wachtwoord.</div>
<% } %>
<form action="/auth/login" method="POST" class="login-form">
<div class="field">
<label for="username">Gebruikersnaam</label>
<input type="text" name="username" id="username" required autofocus>
</div>
<div class="field">
<label for="password">Wachtwoord</label>
<input type="password" name="password" id="password" required>
</div>
<button type="submit" class="btn btn-accent btn-full">Inloggen</button>
<div style="text-align:center;margin-top:.75rem"><a href="/wachtwoord-vergeten" class="link">Wachtwoord vergeten?</a></div>
</form>
</div>
</div>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Market Intelligence — Mek-Tech</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&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><script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script><link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"><style>
.mi-wrap{max-width:1200px;margin:0 auto;padding:1.25rem 1.5rem 3rem}
.mi-hdr h1{font-size:1.4rem;font-weight:800;letter-spacing:-.4px}.mi-hdr p{font-size:.72rem;color:var(--text-muted)}
.mi-kpi{display:flex;gap:.4rem;margin:.75rem 0 1rem;flex-wrap:wrap}
.mi-kpi .mk{border:1px solid var(--border);border-radius:8px;padding:.45rem .75rem;background:var(--bg-card);display:flex;align-items:center;gap:.4rem;font-size:.68rem}
.mi-kpi .mk b{font-size:1.05rem;font-weight:700;color:var(--accent)}.mi-kpi .mk span{color:var(--text-muted);font-size:.58rem}
.mi-tabs{display:flex;margin-bottom:.75rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;overflow:hidden}
.mi-tab{flex:1;padding:.4rem .8rem;font-size:.72rem;font-weight:600;color:var(--text-muted);cursor:pointer;border:none;background:none;font-family:inherit;border-right:1px solid var(--border);display:flex;align-items:center;justify-content:center;gap:.3rem}
.mi-tab:last-child{border-right:none}.mi-tab:hover{color:var(--text);background:var(--border-light)}.mi-tab.active{color:#fff;background:var(--accent)}
.mi-tab svg{width:13px;height:13px}
.mi-pane{display:none}.mi-pane.active{display:block}
.mi-fbar{display:flex;gap:.3rem;margin-bottom:.6rem;flex-wrap:wrap}
.mi-fbar input,.mi-fbar select{padding:.32rem .5rem;font-size:.68rem;background:var(--bg);border:1px solid var(--border);border-radius:5px;color:var(--text);font-family:inherit}
.mi-fbar input:focus,.mi-fbar select:focus{outline:none;border-color:var(--accent)}
.dc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.45rem}
.dc-card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:.7rem;position:relative;transition:all .12s}
.dc-card:hover{border-color:var(--accent);transform:translateY(-1px);box-shadow:0 3px 12px rgba(0,0,0,.08)}
.dc-card h3{font-size:.78rem;font-weight:700;color:var(--text)}
.dc-card .dc-loc{font-size:.6rem;color:var(--text-dim);margin:2px 0}
.dc-card .dc-tags{display:flex;gap:3px;flex-wrap:wrap;margin:4px 0}
.dc-card .dc-tag{font-size:.52rem;font-weight:600;padding:2px 6px;border-radius:3px}
.dc-tag.tier{background:rgba(130,80,223,.12);color:#a855f7}.dc-tag.type{background:rgba(88,166,255,.1);color:var(--accent)}.dc-tag.op{background:rgba(63,185,80,.1);color:var(--green)}
.dc-card .dc-spec{font-size:.58rem;color:var(--text-dim);line-height:1.4}
.comp-table{width:100%;border-collapse:collapse;font-size:.68rem}
.comp-table th{text-align:left;padding:.4rem .5rem;font-size:.56rem;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text-muted);background:var(--bg);border-bottom:1px solid var(--border)}
.comp-table td{padding:.4rem .5rem;border-bottom:1px solid var(--border-light);vertical-align:top}
.comp-table tr:hover td{background:var(--border-light)}
.comp-table .c-name{font-weight:700;color:var(--text)}.comp-table .c-sub{font-size:.58rem;color:var(--text-dim)}
.c-chip{display:inline-block;padding:1px 5px;border-radius:3px;font-size:.54rem;font-weight:600;margin:1px}
.c-chip.dw{background:rgba(130,80,223,.12);color:#a855f7}.c-chip.bi{background:rgba(88,166,255,.1);color:var(--accent)}.c-chip.cl{background:rgba(63,185,80,.1);color:var(--green)}.c-chip.ai{background:rgba(210,153,34,.12);color:var(--orange)}.c-chip.db{background:rgba(247,119,186,.1);color:var(--pink)}
.c-score{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;font-weight:800;font-size:.64rem}
.c-score.h90{background:var(--green-bg);color:var(--green)}.c-score.h70{background:rgba(88,166,255,.1);color:var(--accent)}.c-score.h50{background:var(--orange-bg);color:var(--orange)}.c-score.low{background:var(--border-light);color:var(--text-dim)}
#map{height:480px;border-radius:10px;border:1px solid var(--border);z-index:1}
.map-legend{display:flex;gap:.75rem;flex-wrap:wrap;margin-top:.5rem;font-size:.62rem;color:var(--text-muted)}
.map-legend span{display:flex;align-items:center;gap:.25rem}
.map-legend .dot{width:10px;height:10px;border-radius:50%}
.mi-empty{text-align:center;padding:1.5rem;color:var(--text-dim);font-size:.72rem}
.mi-result-count{font-size:.62rem;color:var(--text-dim);margin-bottom:.4rem}
.leaflet-popup-content{font-size:.7rem}.leaflet-popup-content h4{margin:0 0 3px;font-size:.75rem}.leaflet-popup-content p{margin:1px 0;font-size:.62rem;color:#666}
</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="mi-wrap">
<div class="mi-hdr"><h1>Market Intelligence</h1><p>Dutch datacenters & data-driven enterprises — filter, search and explore on the map</p></div>
<div class="mi-kpi">
<div class="mk"><b id="kpiDc">0</b><span>datacenters</span></div>
<div class="mk"><b id="kpiPwr">0 MW</b><span>power</span></div>
<div class="mk"><b id="kpiCo">0</b><span>prospects</span></div>
<div class="mk"><b id="kpiDw">0</b><span>DW vendors</span></div>
<div class="mk"><b id="kpiInd">0</b><span>industries</span></div>
<div class="mk"><b id="kpiReg">0</b><span>regions</span></div>
</div>
<div class="mi-tabs">
<button class="mi-tab active" data-tab="datacenters"><i data-lucide="database"></i> Datacenters</button>
<button class="mi-tab" data-tab="companies"><i data-lucide="building-2"></i> Prospects</button>
<button class="mi-tab" data-tab="map"><i data-lucide="map"></i> Map</button>
</div>
<div class="mi-fbar">
<input type="text" id="searchInput" placeholder="Search..." style="width:160px" oninput="liveFilter()">
<select id="filterType" onchange="liveFilter()"><option value="">All types</option><option value="colo">Colo</option><option value="hyperscale">Hyperscale</option><option value="edge">Edge</option><option value="regional">Regional</option><option value="modular">Modular</option></select>
<select id="filterRegion" onchange="liveFilter()"></select>
<select id="filterVendor" onchange="liveFilter()"></select>
<select id="filterIndustry" onchange="liveFilter()" style="display:none"></select>
<select id="filterDw" onchange="liveFilter()" style="display:none"></select>
<select id="filterCity" onchange="liveFilter()" style="display:none"></select>
<select id="filterSort" onchange="liveFilter()"><option value="power_mw">Power (desc)</option><option value="name">Name (A-Z)</option><option value="lead_score">Lead score</option><option value="employees">Employees</option></select>
</div>
<div class="mi-pane active" id="pane-datacenters">
<div class="mi-result-count" id="dcCount"></div>
<div class="dc-grid" id="dcGrid"></div>
</div>
<div class="mi-pane" id="pane-companies">
<div class="mi-result-count" id="coCount"></div>
<table class="comp-table"><thead><tr><th style="width:36px"></th><th>Company</th><th>Data Warehouse</th><th>BI / Cloud</th><th>AI / Database</th><th>Contact</th></tr></thead><tbody id="compBody"></tbody></table>
</div>
<div class="mi-pane" id="pane-map">
<div id="map"></div>
<div class="map-legend">
<span><span class="dot" style="background:#a855f7"></span>Tier IV</span>
<span><span class="dot" style="background:#58a6ff"></span>Tier III</span>
<span><span class="dot" style="background:#3fb950"></span>Hyperscale</span>
<span><span class="dot" style="background:#f85149"></span>Company HQ</span>
<span><span class="dot" style="background:rgba(130,80,223,.12)"></span>Edge/Regional</span>
</div>
</div>
</div>
<script src="/js/theme.js"></script>
<script>
var currentTab='datacenters', allDc=[], allCo=[];
var map=null, dcLayer=null, coLayer=null, fullDcMap=[], dwVendorsArr=[], industriesArr=[], citiesArr=[], regionsArr=[], vendorsArr=[];
var totalPwr=0;
function loadFilters() {
fetch('/market/filters.json').then(r=>r.json()).then(d=>{
regionsArr=d.regions||[];dwVendorsArr=d.dwVendors||[];industriesArr=d.industries||[];citiesArr=d.cities||[];vendorsArr=d.vendors||[];
var fr=document.getElementById('filterRegion');fr.innerHTML='<option value="">All regions</option>'+regionsArr.map(r=>'<option value="'+r+'">'+r+'</option>').join('');
var fv=document.getElementById('filterVendor');fv.innerHTML='<option value="">All vendors</option>'+vendorsArr.map(v=>'<option value="'+v+'">'+v+'</option>').join('');
var fi=document.getElementById('filterIndustry');fi.innerHTML='<option value="">All industries</option>'+industriesArr.map(i=>'<option value="'+i+'">'+i+'</option>').join('');
var fd=document.getElementById('filterDw');fd.innerHTML='<option value="">DW vendor</option>'+dwVendorsArr.map(v=>'<option value="'+v+'">'+v+'</option>').join('');
var fc=document.getElementById('filterCity');fc.innerHTML='<option value="">City</option>'+citiesArr.map(c=>'<option value="'+c+'">'+c+'</option>').join('');
document.getElementById('kpiDw').textContent=dwVendorsArr.length;document.getElementById('kpiInd').textContent=industriesArr.length;document.getElementById('kpiReg').textContent=regionsArr.length;
});
}
function liveFilter() {
var s=document.getElementById('searchInput').value;
var region=document.getElementById('filterRegion').value;
var vendor=document.getElementById('filterVendor').value;
var industry=document.getElementById('filterIndustry').value;
var dw=document.getElementById('filterDw').value;
var city=document.getElementById('filterCity').value;
var sort=document.getElementById('filterSort').value;
var params=new URLSearchParams();
if(s)params.set('search',s);if(region)params.set('region',region);if(vendor)params.set('vendor',vendor);
if(industry)params.set('industry',industry);if(dw)params.set('dw',dw);if(city)params.set('city',city);if(sort)params.set('sort',sort);
// Datacenters
fetch('/market/search/dc?'+params.toString()).then(r=>r.json()).then(dc=>{
allDc=dc;renderDc(dc);
document.getElementById('kpiDc').textContent=dc.length;
totalPwr=dc.reduce((s,d)=>s+(d.power_mw||0),0);
document.getElementById('kpiPwr').textContent=Math.round(totalPwr)+' MW';
});
// Companies
fetch('/market/search/co?'+params.toString()).then(r=>r.json()).then(co=>{
allCo=co;renderCo(co);document.getElementById('kpiCo').textContent=co.length;
});
// Map
fetch('/market/map.json').then(r=>r.json()).then(d=>{fullDcMap=d;renderMap(d)});
}
function renderDc(dc) {
document.getElementById('dcCount').textContent=dc.length+' datacenters found';
document.getElementById('dcGrid').innerHTML=dc.length===0?'<div class="mi-empty">No datacenters found</div>':dc.map(d=>'<div class="dc-card"><h3>'+d.name+'</h3><div class="dc-loc">'+(d.city||'')+', '+(d.region||'')+(d.address?' · '+d.address:'')+'</div><div class="dc-loc">'+(d.phone?'📞 '+d.phone+' · ':'')+(d.website?'🌐 '+d.website:'')+'</div><div class="dc-tags"><span class="dc-tag tier">T'+d.tier+'</span><span class="dc-tag type">'+d.type+'</span><span class="dc-tag op">'+(d.operator||'')+'</span></div><div class="dc-spec"><strong>'+(d.power_mw||0)+' MW</strong> · '+(d.size_sqm||0).toLocaleString()+' m²</div>'+(d.network_vendors?'<div class="dc-spec"><b>Network:</b> '+d.network_vendors+'</div>':'')+(d.server_vendors?'<div class="dc-spec"><b>Servers:</b> '+d.server_vendors+'</div>':'')+(d.storage_vendors?'<div class="dc-spec"><b>Storage:</b> '+d.storage_vendors+'</div>':'')+(d.certifications?'<div class="dc-spec"><b>Certs:</b> '+d.certifications+'</div>':'')+'</div>').join('');
}
function renderCo(co) {
document.getElementById('coCount').textContent=co.length+' companies found';
document.getElementById('compBody').innerHTML=co.length===0?'<tr><td colspan="6" class="mi-empty">No companies found</td></tr>':co.map(c=>{
var sc=c.lead_score||0,cls=sc>=90?'h90':sc>=70?'h70':sc>=50?'h50':'low';
var dw=(c.data_warehouse||'').split(',').filter(Boolean).slice(0,5).map(v=>'<span class="c-chip dw">'+v.trim()+'</span>').join('');
var bi=(c.bi_tools||'').split(',').filter(Boolean).slice(0,3).map(v=>'<span class="c-chip bi">'+v.trim()+'</span>').join('');
var cl=(c.cloud_provider||'').split(',').filter(Boolean).slice(0,3).map(v=>'<span class="c-chip cl">'+v.trim()+'</span>').join('');
var ai=(c.ai_ml||'').split(',').filter(Boolean).slice(0,2).map(v=>'<span class="c-chip ai">'+v.trim()+'</span>').join('');
var db=(c.database_vendor||'').split(',').filter(Boolean).slice(0,2).map(v=>'<span class="c-chip db">'+v.trim()+'</span>').join('');
return '<tr><td><div class="c-score '+cls+'">'+sc+'</div></td><td><div class="c-name">'+c.name+'</div><div class="c-sub">'+c.industry+' · '+c.city+' · ~'+(c.employees||0).toLocaleString()+'</div>'+(c.revenue_range?'<div class="c-sub">Rev: '+c.revenue_range+'</div>':'')+'</td><td>'+dw+'</td><td>'+bi+cl+'</td><td>'+ai+db+'</td><td style="font-size:.6rem;color:var(--text-dim)">'+(c.phone?'📞 '+c.phone+'<br>':'')+(c.email?'✉ '+c.email+'<br>':'')+(c.website?'🌐 '+c.website:'')+'</td></tr>';
}).join('');
}
function renderMap(d) {
if(map){map.invalidateSize();}else{
map=L.map('map',{zoomControl:true,scrollWheelZoom:false}).setView([52.1,5.3],7);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',{attribution:'&copy; OpenStreetMap, &copy; CARTO',maxZoom:18}).addTo(map);
}
if(dcLayer)dcLayer.remove();if(coLayer)coLayer.remove();
dcLayer=L.layerGroup().addTo(map);coLayer=L.layerGroup().addTo(map);
(d.datacenters||[]).forEach(dc=>{
var color=dc.tier==='IV'?'#a855f7':dc.type==='hyperscale'?'#3fb950':'#58a6ff';
var r=Math.max(8,Math.min(40,(dc.power_mw||1)/5));
L.circleMarker([dc.lat,dc.lng],{radius:r,color:color,fillColor:color,fillOpacity:.4,weight:2}).bindPopup('<h4>'+dc.name+'</h4><p>'+(dc.city||'')+', '+(dc.region||'')+'</p><p>Tier '+dc.tier+' · '+dc.type+'</p><p>'+(dc.power_mw||0)+' MW · '+(dc.operator||'')+'</p>').addTo(dcLayer);
});
(d.companies||[]).forEach(c=>{
L.circleMarker([c.lat,c.lng],{radius:4,color:'#f85149',fillColor:'#f85149',fillOpacity:.3,weight:1}).bindPopup('<h4>'+c.name+'</h4><p>'+c.industry+' · '+c.city+'</p><p>~'+(c.employees||0).toLocaleString()+' employees</p>').addTo(coLayer);
});
}
document.querySelectorAll('.mi-tab').forEach(function(t){
t.addEventListener('click',function(){
document.querySelectorAll('.mi-tab').forEach(x=>x.classList.remove('active'));this.classList.add('active');
document.querySelectorAll('.mi-pane').forEach(x=>x.classList.remove('active'));document.getElementById('pane-'+this.dataset.tab).classList.add('active');currentTab=this.dataset.tab;
if(currentTab==='datacenters'){document.getElementById('filterType').style.display='';document.getElementById('filterRegion').style.display='';document.getElementById('filterVendor').style.display='';document.getElementById('filterIndustry').style.display='none';document.getElementById('filterDw').style.display='none';document.getElementById('filterCity').style.display='none'}
else if(currentTab==='companies'){document.getElementById('filterType').style.display='none';document.getElementById('filterRegion').style.display='none';document.getElementById('filterVendor').style.display='none';document.getElementById('filterIndustry').style.display='';document.getElementById('filterDw').style.display='';document.getElementById('filterCity').style.display=''}
if(currentTab==='map'&&map){setTimeout(()=>map.invalidateSize(),100)}
});
});
document.getElementById('filterType').addEventListener('change',liveFilter);
loadFilters();liveFilter();lucide.createIcons();
</script>
</body></html>
+186
View File
@@ -0,0 +1,186 @@
/* Mek-Tech Neon Theme — Global Visual Layer */
@keyframes neonPulse {
0%, 100% { opacity: 1; filter: drop-shadow(0 0 8px var(--accent)); }
50% { opacity: 0.85; filter: drop-shadow(0 0 18px var(--accent)); }
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 6px var(--accent), 0 0 12px rgba(0,212,255,0.3); opacity: 1; }
50% { box-shadow: 0 0 14px var(--accent), 0 0 28px rgba(0,212,255,0.5); opacity: 0.85; }
}
@keyframes neon-border-flow {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
/* ── Page background — volgt --bg uit instellingen ── */
body {
background-color: var(--bg) !important;
background-image:
radial-gradient(ellipse 90% 55% at 50% -15%, rgba(0, 212, 255, 0.1) 0%, transparent 60%),
radial-gradient(ellipse 50% 35% at 95% 90%, rgba(168, 85, 247, 0.07) 0%, transparent 55%),
radial-gradient(ellipse 40% 30% at 5% 70%, rgba(0, 255, 136, 0.05) 0%, transparent 50%) !important;
background-attachment: fixed !important;
}
/* ── Navbar ── */
.navbar {
backdrop-filter: blur(16px) !important;
background: var(--bg-nav) !important;
border-bottom: 1px solid var(--border) !important;
box-shadow: 0 1px 0 rgba(0, 212, 255, 0.08), 0 4px 24px rgba(0, 0, 0, 0.2) !important;
}
.nav-brand:hover { color: var(--accent) !important; text-shadow: 0 0 12px rgba(0,212,255,0.5); }
/* ── Sidebar ── */
.sidebar {
border-right: 1px solid rgba(0, 212, 255, 0.2) !important;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.12), inset -1px 0 0 rgba(255, 255, 255, 0.04) !important;
}
.sidebar .sb-brand {
background: linear-gradient(135deg, transparent, rgba(0, 212, 255, 0.06)) !important;
border-bottom: 1px solid rgba(0, 212, 255, 0.12) !important;
}
.brand-dot, .sidebar .sb-brand h2 .brand-dot {
background: var(--accent) !important;
box-shadow: 0 0 10px var(--accent), 0 0 20px rgba(0,212,255,0.4) !important;
animation: pulse-glow 2s ease-in-out infinite !important;
}
.sb-item.active {
color: var(--accent) !important;
background: rgba(0, 212, 255, 0.1) !important;
box-shadow: inset 3px 0 0 var(--accent), 0 0 16px rgba(0, 212, 255, 0.08) !important;
}
.sb-item.active .sb-icon svg,
.sb-item:hover .sb-icon svg {
filter: drop-shadow(0 0 5px var(--accent));
}
.sb-item:hover {
background: rgba(0, 212, 255, 0.06) !important;
color: var(--text) !important;
}
/* ── Cards & KPIs ── */
.card, .kpi-card, .client-card, .integration-card, .provider-card,
.stat-card, .advice-card, .task-item, .rm-card, .nd-card, .login-card {
border: 1px solid rgba(0, 212, 255, 0.12) !important;
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.04) !important;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.card:hover, .kpi-card:hover, .client-card:hover, .stat-card:hover,
.advice-card:hover, .task-item:hover, .rm-card:hover, .nd-card:hover {
border-color: rgba(0, 212, 255, 0.45) !important;
box-shadow: 0 0 28px rgba(0, 212, 255, 0.2), 0 8px 24px rgba(0, 0, 0, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.1) !important;
transform: translateY(-2px);
}
.kpi-card .kpi-num {
text-shadow: 0 0 30px rgba(0, 212, 255, 0.25) !important;
}
.kpi-card.accent .kpi-num { color: var(--accent) !important; text-shadow: 0 0 24px rgba(0, 212, 255, 0.55) !important; }
.kpi-card.green .kpi-num { text-shadow: 0 0 24px rgba(0, 255, 136, 0.45) !important; }
.kpi-card.purple .kpi-num { text-shadow: 0 0 24px rgba(168, 85, 247, 0.45) !important; }
.kpi-card.orange .kpi-num { text-shadow: 0 0 24px rgba(255, 179, 71, 0.45) !important; }
/* ── Buttons ── */
.btn-primary, .login-btn, button.btn-primary {
background: linear-gradient(135deg, #00d4ff, #0099cc) !important;
border: 1px solid rgba(0, 212, 255, 0.4) !important;
box-shadow: 0 0 20px rgba(0, 212, 255, 0.3), inset 0 1px 0 rgba(255,255,255,0.15) !important;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.4) !important;
color: #fff !important;
}
.btn-primary:hover, .login-btn:hover {
box-shadow: 0 0 36px rgba(0, 212, 255, 0.5), 0 4px 16px rgba(0, 0, 0, 0.2) !important;
transform: translateY(-1px);
}
/* ── Status badges ── */
.status-badge.lead { border-color: var(--accent) !important; color: var(--accent) !important; box-shadow: 0 0 8px rgba(0,212,255,0.2); }
.status-badge.active { border-color: var(--green) !important; color: var(--green) !important; box-shadow: 0 0 8px rgba(0,255,136,0.2); }
/* ── Form focus ── */
input:focus, select:focus, textarea:focus {
border-color: var(--accent) !important;
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.15), 0 0 20px rgba(0, 212, 255, 0.1) !important;
outline: none !important;
}
/* ── Tables ── */
.data-table th { background: rgba(0, 212, 255, 0.04) !important; }
.data-table tr:hover td { background: rgba(0, 212, 255, 0.04) !important; }
/* ── Links ── */
.link, a.link { color: var(--accent) !important; }
.link:hover { text-shadow: 0 0 8px rgba(0,212,255,0.4); }
/* ── CRM bar ── */
.crm-bar {
background: rgba(0, 212, 255, 0.07) !important;
border: 1px solid rgba(0, 212, 255, 0.2) !important;
box-shadow: 0 0 20px rgba(0, 212, 255, 0.06) !important;
border-radius: 10px !important;
}
/* ── Page headers ── */
.page-hdr h1, .dash-header h1 {
background: linear-gradient(135deg, var(--text) 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ── Neon top accent on cards ── */
.kpi-card::after, .client-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0;
transition: opacity 0.25s;
}
.kpi-card { position: relative; overflow: hidden; }
.kpi-card:hover::after { opacity: 1; }
.fade-in-up { animation: fadeInUp 0.45s ease-out forwards; }
.designer-frame {
border: 1px solid rgba(0, 212, 255, 0.25) !important;
border-radius: 12px !important;
box-shadow: 0 0 40px rgba(0, 212, 255, 0.1) !important;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: rgba(0,212,255,0.25); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(0,212,255,0.45); }
+151
View File
@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= connection ? 'Bewerk' : 'Nieuwe' %> Connectie — 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" type="image/svg+xml">
</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="container">
<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="page-header">
<div>
<a href="/networking" class="back-link">← Netwerk</a>
<h1><%= connection ? 'Bewerk Connectie' : 'Nieuwe Connectie' %></h1>
</div>
</div>
<form action="/networking/connections/<%= connection ? connection.id + '/edit' : 'new' %>" method="POST" class="form-card">
<div class="field">
<label for="client_id">Cliënt *</label>
<select name="client_id" required>
<option value="">Selecteer...</option>
<% clients.forEach(function(c) { %>
<option value="<%= c.id %>" <%= connection && connection.client_id == c.id ? 'selected' : '' %>><%= c.name %></option>
<% }) %>
</select>
</div>
<div class="field">
<label>Connectie naam</label>
<input type="text" name="name" value="<%= connection ? connection.name : '' %>" placeholder="Bv. Link naar core switch">
</div>
<h4 style="color:#fff;margin:1rem 0 0.5rem">Van</h4>
<div class="form-grid">
<div class="field">
<label>Type</label>
<select name="from_type">
<option value="network_device" <%= !connection || connection.from_type === 'network_device' ? 'selected' : '' %>>Netwerk Device</option>
<option value="rack_device" <%= connection && connection.from_type === 'rack_device' ? 'selected' : '' %>>Rack Device</option>
<option value="external" <%= connection && connection.from_type === 'external' ? 'selected' : '' %>>Extern</option>
</select>
</div>
<div class="field">
<label>Device</label>
<select name="from_id">
<option value="">—</option>
<optgroup label="Netwerk Devices">
<% netDevices.forEach(function(d) { %>
<option value="<%= d.id %>" <%= connection && connection.from_type === 'network_device' && connection.from_id == d.id ? 'selected' : '' %>><%= d.name %> (<%= d.device_type %>)</option>
<% }) %>
</optgroup>
<optgroup label="Rack Devices">
<% rackDevices.forEach(function(d) { %>
<option value="<%= d.id %>" <%= connection && connection.from_type === 'rack_device' && connection.from_id == d.id ? 'selected' : '' %>><%= d.name %> — <%= d.rack_name %></option>
<% }) %>
</optgroup>
</select>
</div>
<div class="field">
<label>Poort / Interface</label>
<input type="text" name="from_port" value="<%= connection ? connection.from_port : '' %>" placeholder="Bv. Gi1/0/1">
</div>
</div>
<h4 style="color:#fff;margin:1rem 0 0.5rem">Naar</h4>
<div class="form-grid">
<div class="field">
<label>Type</label>
<select name="to_type">
<option value="network_device" <%= !connection || connection.to_type === 'network_device' ? 'selected' : '' %>>Netwerk Device</option>
<option value="rack_device" <%= connection && connection.to_type === 'rack_device' ? 'selected' : '' %>>Rack Device</option>
<option value="external" <%= connection && connection.to_type === 'external' ? 'selected' : '' %>>Extern</option>
</select>
</div>
<div class="field">
<label>Device</label>
<select name="to_id">
<option value="">—</option>
<optgroup label="Netwerk Devices">
<% netDevices.forEach(function(d) { %>
<option value="<%= d.id %>" <%= connection && connection.to_type === 'network_device' && connection.to_id == d.id ? 'selected' : '' %>><%= d.name %> (<%= d.device_type %>)</option>
<% }) %>
</optgroup>
<optgroup label="Rack Devices">
<% rackDevices.forEach(function(d) { %>
<option value="<%= d.id %>" <%= connection && connection.to_type === 'rack_device' && connection.to_id == d.id ? 'selected' : '' %>><%= d.name %> — <%= d.rack_name %></option>
<% }) %>
</optgroup>
</select>
</div>
<div class="field">
<label>Poort / Interface</label>
<input type="text" name="to_port" value="<%= connection ? connection.to_port : '' %>" placeholder="Bv. Gi1/0/1">
</div>
</div>
<h4 style="color:#fff;margin:1rem 0 0.5rem">Properties</h4>
<div class="form-grid">
<div class="field">
<label>Media Type</label>
<select name="media_type">
<option value="copper" <%= connection && connection.media_type === 'copper' ? 'selected' : '' %>>Copper (CAT6/6A)</option>
<option value="sfp" <%= connection && connection.media_type === 'sfp' ? 'selected' : '' %>>SFP (1GbE)</option>
<option value="sfp+" <%= connection && connection.media_type === 'sfp+' ? 'selected' : '' %>>SFP+ (10GbE)</option>
<option value="qsfp" <%= connection && connection.media_type === 'qsfp' ? 'selected' : '' %>>QSFP (40/100GbE)</option>
<option value="dac" <%= connection && connection.media_type === 'dac' ? 'selected' : '' %>>DAC Kabel</option>
<option value="fiber" <%= connection && connection.media_type === 'fiber' ? 'selected' : '' %>>Fiber (Single/Multi)</option>
</select>
</div>
<div class="field">
<label>Snelheid</label>
<select name="speed">
<option value="100MbE" <%= connection && connection.speed === '100MbE' ? 'selected' : '' %>>100 MbE</option>
<option value="1GbE" <%= !connection || connection.speed === '1GbE' ? 'selected' : '' %>>1 GbE</option>
<option value="10GbE" <%= connection && connection.speed === '10GbE' ? 'selected' : '' %>>10 GbE</option>
<option value="25GbE" <%= connection && connection.speed === '25GbE' ? 'selected' : '' %>>25 GbE</option>
<option value="40GbE" <%= connection && connection.speed === '40GbE' ? 'selected' : '' %>>40 GbE</option>
<option value="100GbE" <%= connection && connection.speed === '100GbE' ? 'selected' : '' %>>100 GbE</option>
</select>
</div>
<div class="field">
<label>VLAN</label>
<input type="text" name="vlan" value="<%= connection ? connection.vlan : '' %>" placeholder="Bv. 100">
</div>
<div class="field">
<label>Status</label>
<select name="status">
<option value="active" <%= !connection || connection.status === 'active' ? 'selected' : '' %>>Active</option>
<option value="inactive" <%= connection && connection.status === 'inactive' ? 'selected' : '' %>>Inactive</option>
<option value="planned" <%= connection && connection.status === 'planned' ? 'selected' : '' %>>Planned</option>
</select>
</div>
</div>
<div class="field">
<label>Notities</label>
<textarea name="notes" rows="2"><%= connection ? connection.notes : '' %></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-accent"><%= connection ? 'Opslaan' : 'Aanmaken' %></button>
<a href="/networking" class="btn btn-secondary">Annuleren</a>
</div>
</form>
</div>
<script src="/js/theme.js"></script></body>
</html>
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= device.name %> — Mek-Tech Consulting</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" type="image/svg+xml">
</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="container">
<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="page-header">
<div>
<a href="/networking" class="back-link">← Netwerk</a>
</a>
<h1><%= device.name %></h1>
<p class="subtitle"><%= device.device_type %> — <%= device.client_name %></p>
</div>
<div class="header-actions">
<a href="/networking/devices/<%= device.id %>/edit" class="btn btn-primary">Bewerk</a>
<a href="/networking/connections/new" class="btn btn-secondary">+ Connectie</a>
</div>
</div>
<div class="detail-grid">
<div class="detail-card">
<h3>Device Info</h3>
<table class="info-table">
<tr><td>Type</td><td><span class="engagement-type <%= device.device_type %>"><%= device.device_type %></span></td></tr>
<tr><td>Model</td><td><%= device.model || '-' %></td></tr>
<tr><td>Manufacturer</td><td><%= device.manufacturer || '-' %></td></tr>
<tr><td>IP Adres</td><td><code><%= device.ip_address || '-' %></code></td></tr>
<tr><td>Mgmt IP</td><td><code><%= device.mgmt_ip || '-' %></code></td></tr>
<tr><td>Poorten</td><td><%= device.ports_count %></td></tr>
<tr><td>OS</td><td><%= device.os_version || '-' %></td></tr>
<tr><td>Status</td><td><span class="status-badge <%= device.status %>"><%= device.status %></span></td></tr>
</table>
</div>
<div class="detail-card">
<h3>Connecties (<%= connections.length %>)</h3>
<% if (connections.length === 0) { %>
<p class="client-meta">Geen connecties</p>
<% } else { %>
<div class="conn-list">
<% connections.forEach(function(c) { %>
<div class="conn-item">
<span class="conn-name"><%= c.name || 'Connectie' %></span>
<span class="conn-path">
<% if (c.from_type === 'network_device' && c.from_id == device.id) { %>
Poort <%= c.from_port || '?' %> → <%= c.to_type %> <%= c.to_id || '?' %> poort <%= c.to_port || '?' %>
<% } else { %>
<%= c.from_type %> <%= c.from_id || '?' %> poort <%= c.from_port || '?' %> → Poort <%= c.to_port || '?' %>
<% } %>
</span>
<span class="conn-meta"><%= c.speed %> · <%= c.media_type %></span>
</div>
<% }) %>
</div>
<% } %>
</div>
</div>
<% if (device.notes) { %>
<section class="section">
<h3 class="section-title">Notities</h3>
<div class="card"><p><%= device.notes %></p></div>
</section>
<% } %>
</div>
<script src="/js/theme.js"></script></body>
</html>
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= device ? 'Bewerk' : 'Nieuw' %> Netwerk Device — 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" type="image/svg+xml">
</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="container">
<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="page-header">
<div>
<a href="/networking" class="back-link">← Terug</a>
<h1><%= device ? 'Bewerk: ' + device.name : 'Nieuw Netwerk Device' %></h1>
</div>
</div>
<form action="/networking/devices/<%= device ? device.id + '/edit' : 'new' %>" method="POST" class="form-card">
<% if (!device) { %>
<div class="field">
<label for="client_id">Cliënt *</label>
<select name="client_id" required>
<option value="">Selecteer...</option>
<% clients.forEach(function(c) { %>
<option value="<%= c.id %>"><%= c.name %></option>
<% }) %>
</select>
</div>
<% } %>
<div class="form-grid">
<div class="field">
<label>Naam *</label>
<input type="text" name="name" value="<%= device ? device.name : '' %>" required>
</div>
<div class="field">
<label>Type</label>
<select name="device_type">
<option value="switch" <%= device && device.device_type === 'switch' ? 'selected' : '' %>>Switch</option>
<option value="router" <%= device && device.device_type === 'router' ? 'selected' : '' %>>Router</option>
<option value="firewall" <%= device && device.device_type === 'firewall' ? 'selected' : '' %>>Firewall</option>
<option value="loadbalancer" <%= device && device.device_type === 'loadbalancer' ? 'selected' : '' %>>Load Balancer</option>
<option value="ap" <%= device && device.device_type === 'ap' ? 'selected' : '' %>>Access Point</option>
<option value="other" <%= device && device.device_type === 'other' ? 'selected' : '' %>>Other</option>
</select>
</div>
<div class="field">
<label>Model</label>
<input type="text" name="model" value="<%= device ? device.model : '' %>" placeholder="Bv. Cisco 9300">
</div>
<div class="field">
<label>Manufacturer</label>
<input type="text" name="manufacturer" value="<%= device ? device.manufacturer : '' %>" placeholder="Cisco, Juniper, etc.">
</div>
<div class="field">
<label>IP Adres</label>
<input type="text" name="ip_address" value="<%= device ? device.ip_address : '' %>">
</div>
<div class="field">
<label>Mgmt IP</label>
<input type="text" name="mgmt_ip" value="<%= device ? device.mgmt_ip : '' %>">
</div>
<div class="field">
<label>Aantal Poorten</label>
<input type="number" name="ports_count" value="<%= device ? device.ports_count : 24 %>" min="1" max="256">
</div>
<div class="field">
<label>OS Versie</label>
<input type="text" name="os_version" value="<%= device ? device.os_version : '' %>">
</div>
<div class="field">
<label>Status</label>
<select name="status">
<option value="active" <%= device && device.status === 'active' ? 'selected' : '' %>>Active</option>
<option value="standby" <%= device && device.status === 'standby' ? 'selected' : '' %>>Standby</option>
<option value="decommissioned" <%= device && device.status === 'decommissioned' ? 'selected' : '' %>>Decommissioned</option>
</select>
</div>
</div>
<div class="field">
<label>Notities</label>
<textarea name="notes" rows="3"><%= device ? device.notes : '' %></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-accent"><%= device ? 'Opslaan' : 'Aanmaken' %></button>
<a href="/networking" class="btn btn-secondary">Annuleren</a>
</div>
</form>
</div>
<script src="/js/theme.js"></script></body>
</html>
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Netwerk — Mek-Tech Consulting</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" type="image/svg+xml">
</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="container">
<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="page-header">
<h1>Netwerk</h1>
<div class="header-actions">
<a href="/networking/devices/new" class="btn btn-primary">+ Netwerk device</a>
<a href="/networking/connections/new" class="btn btn-secondary">+ Connectie</a>
</div>
</div>
<section class="section">
<h3 class="section-title">Netwerk Devices (<%= devices.length %>)</h3>
<% if (devices.length === 0) { %>
<div class="empty-state"><p>Nog geen netwerk devices. <a href="/networking/devices/new" class="link">Voeg een switch of router toe</a>.</p></div>
<% } else { %>
<div class="device-grid">
<% devices.forEach(function(d) { %>
<a href="/networking/devices/<%= d.id %>" class="device-card <%= d.device_type %>">
<div class="device-icon">
<% if (d.device_type === 'switch') { %>⟷<% } else if (d.device_type === 'router') { %>⟳<% } else if (d.device_type === 'firewall') { %>⊡<% } else { %>◈<% } %>
</div>
<div class="device-info">
<strong><%= d.name %></strong>
<span class="client-meta"><%= d.device_type %> — <%= d.model || 'onbekend' %></span>
<span class="client-meta"><%= d.client_name %></span>
</div>
<span class="status-badge <%= d.status %>"><%= d.status %></span>
</a>
<% }) %>
</div>
<% } %>
</section>
<section class="section">
<h3 class="section-title">Connecties (<%= connections.length %>)</h3>
<% if (connections.length === 0) { %>
<div class="empty-state"><p>Nog geen connecties.</p></div>
<% } else { %>
<table class="data-table">
<thead><tr><th>Naam</th><th>Cliënt</th><th>Van</th><th>Poort</th><th>Naar</th><th>Poort</th><th>Media</th><th>Snelheid</th><th>Status</th></tr></thead>
<tbody>
<% connections.forEach(function(c) { %>
<tr>
<td><%= c.name || '-' %></td>
<td><%= c.client_name %></td>
<td><%= c.from_type %> <%= c.from_id || '' %></td>
<td><%= c.from_port || '-' %></td>
<td><%= c.to_type %> <%= c.to_id || '' %></td>
<td><%= c.to_port || '-' %></td>
<td><span class="media-badge <%= c.media_type %>"><%= c.media_type %></span></td>
<td><%= c.speed %></td>
<td><span class="status-badge <%= c.status %>"><%= c.status %></span></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</section>
</div>
<script src="/js/theme.js"></script></body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Meldingen — 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></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="container">
<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="page-header"><h1>Meldingen <% if (unread > 0) { %><span class="status-badge in_progress"><%= unread %> ongelezen</span><% } %></h1>
<form method="POST" action="/notifications/read-all"><button class="btn btn-secondary">Alles gelezen</button></form></div>
<% if (notifications.length === 0) { %><div class="empty-state"><p>Geen meldingen</p></div><% } else { %>
<div class="advice-list">
<% notifications.forEach(function(n){ %>
<div class="advice-card <%= n.read ? 'medium' : 'high' %>" style="<%= n.read ? 'opacity:.7' : '' %>">
<div class="advice-body"><p><strong><%= n.title %></strong></p><p style="font-size:.75rem;color:var(--text-muted);margin-top:.15rem"><%= n.message %></p><p style="font-size:.65rem;color:var(--text-dim);margin-top:.15rem"><%= new Date(n.created_at).toLocaleString('nl-NL') %> · <%= n.channel %></p></div>
<form method="POST" action="/notifications/<%= n.id %>/read"><button class="btn-small"><%= n.read ? 'Gelezen' : 'Markeer gelezen' %></button></form>
</div>
<% }) %>
</div>
<% } %></div>
<script>lucide.createIcons()</script><script src="/js/theme.js"></script></body></html>
+19
View File
@@ -0,0 +1,19 @@
<% if (typeof showCrmBar !== 'undefined' && showCrmBar !== false) { %>
<div class="crm-bar fade-in-up">
<i data-lucide="link" style="width:16px;height:16px;color:var(--accent)"></i>
<span style="font-size:.72rem;color:var(--text-muted);font-weight:600">CRM Context</span>
<select onchange="if(this.value)window.location='?client_id='+this.value">
<option value="">— Selecteer cliënt —</option>
<% (allClients || []).forEach(function(c) { %>
<option value="<%= c.id %>"<%= activeClientId == c.id ? ' selected' : '' %>><%= c.name %> (<%= c.status %>)</option>
<% }); %>
</select>
<% if (activeClient) { %>
<div class="crm-hub">
<% (typeof clientHubLinks !== 'undefined' ? clientHubLinks : []).forEach(function(link) { %>
<a href="<%= link.href %>"><i data-lucide="<%= link.icon %>" style="width:12px;height:12px"></i> <%= link.label %></a>
<% }); %>
</div>
<% } %>
</div>
<% } %>
+10
View File
@@ -0,0 +1,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= typeof pageTitle !== 'undefined' ? pageTitle + ' — ' : '' %>Mek-Tech Enterprise</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/neon.css">
<link rel="stylesheet" href="/css/dynamic.css">
<link rel="icon" href="/img/favicon.svg" type="image/svg+xml">
<script src="https://unpkg.com/lucide@latest"></script>
<style>:root{--sb-w:220px;--hdr-h:52px;--accent-dim:#0099cc}</style>
+19
View File
@@ -0,0 +1,19 @@
<nav class="navbar" style="position:fixed;top:0;left:0;right:0;z-index:100;height:var(--hdr-h)">
<div class="nav-inner" style="height:100%;max-width:100%">
<a href="/" class="nav-brand"><img src="/img/logo.svg" class="nav-logo" alt="Mek-Tech"></a>
<div class="nav-search" style="display:flex;flex:1;max-width:340px;margin:0 1rem">
<input type="text" placeholder="Zoek cliënten, engagements, designs..." onkeydown="if(event.key==='Enter')window.location='/search?q='+encodeURIComponent(this.value)">
</div>
<div class="nav-right" style="display:flex;align-items:center;gap:.25rem">
<% if (activeClient) { %>
<a href="/clients/<%= activeClient.id %>" class="nav-icon-btn" title="Actieve cliënt: <%= activeClient.name %>" style="width:auto;padding:0 .6rem;font-size:.68rem;color:var(--accent)">
<i data-lucide="building-2"></i> <%= activeClient.name %>
</a>
<% } %>
<a href="/notifications" class="nav-icon-btn" title="Notificaties"><i data-lucide="bell"></i></a>
<a href="/ai" class="nav-icon-btn" title="AI Providers"><i data-lucide="sparkles"></i></a>
<a href="/settings" class="nav-icon-btn" title="Instellingen"><i data-lucide="settings"></i></a>
<a href="/auth/logout" class="nav-icon-btn nav-logout" title="Uitloggen" style="color:var(--red)!important"><i data-lucide="log-out"></i></a>
</div>
</div>
</nav>
+30
View File
@@ -0,0 +1,30 @@
<% var wsRackId = typeof rack !== 'undefined' && rack ? rack.id : (typeof rackId !== 'undefined' ? rackId : ''); %>
<% var wsTab = typeof workspaceTab !== 'undefined' ? workspaceTab : 'rack'; %>
<% var wsFlowId = typeof flowDiagram !== 'undefined' && flowDiagram ? flowDiagram.id : null; %>
<% var wsLandscapeId = typeof landscapeDiagram !== 'undefined' && landscapeDiagram ? landscapeDiagram.id : null; %>
<nav class="rack-ws-nav">
<div class="rwn-inner">
<a href="/racks/<%= wsRackId %>" class="rwn-tab <%= wsTab === 'rack' ? 'active' : '' %>"><i data-lucide="server"></i> Rack Design</a>
<a href="<%= wsFlowId ? '/racks/' + wsRackId + '/diagram/' + wsFlowId : '/racks/' + wsRackId + '/flow/new' %>" class="rwn-tab <%= wsTab === 'flow' ? 'active' : '' %>"><i data-lucide="git-branch"></i> Applicatie Flow</a>
<a href="<%= wsLandscapeId ? '/racks/' + wsRackId + '/diagram/' + wsLandscapeId : '/racks/' + wsRackId + '/landscape/new' %>" class="rwn-tab <%= wsTab === 'landscape' ? 'active' : '' %>"><i data-lucide="layers"></i> Landschap</a>
<a href="/racks/<%= wsRackId %>/studio" class="rwn-tab <%= wsTab === 'studio' ? 'active' : '' %>"><i data-lucide="layout-panel-left"></i> Studio</a>
<a href="/racks/<%= wsRackId %>/report" class="rwn-tab <%= wsTab === 'report' ? 'active' : '' %>"><i data-lucide="file-text"></i> Rapport</a>
</div>
</nav>
<style>
.rack-ws-nav{background:linear-gradient(90deg,rgba(10,22,40,.98),rgba(20,45,75,.98));border-bottom:1px solid rgba(0,212,255,.25);position:sticky;top:0;z-index:300;box-shadow:0 4px 20px rgba(0,0,0,.25)}
.rwn-inner{display:flex;align-items:center;gap:.25rem;padding:.4rem .75rem;overflow-x:auto;max-width:100%}
.rwn-tab{display:inline-flex;align-items:center;gap:.35rem;padding:.45rem .75rem;border-radius:8px;font-size:.72rem;font-weight:600;color:rgba(255,255,255,.65);text-decoration:none;white-space:nowrap;border:1px solid transparent;transition:all .15s}
.rwn-tab:hover{color:#fff;background:rgba(0,212,255,.08);border-color:rgba(0,212,255,.2)}
.rwn-tab.active{color:var(--accent,#00d4ff);background:rgba(0,212,255,.12);border-color:rgba(0,212,255,.35);box-shadow:0 0 16px rgba(0,212,255,.12)}
.rwn-tab svg{width:14px;height:14px}
body.has-rack-ws-nav .navbar{position:relative}
body.arch-editor.has-rack-ws-nav .arch-topbar{top:42px}
body.arch-editor.has-rack-ws-nav .rack-ws-nav{top:0}
body.arch-editor.has-rack-ws-nav .rack-context-bar{top:90px}
body.arch-editor.has-rack-ws-nav .editor-layout{padding-top:90px}
body.arch-editor.embed-mode .arch-topbar,
body.arch-editor.embed-mode .rack-context-bar,
body.arch-editor.embed-mode .rack-ws-nav{display:none!important}
body.arch-editor.embed-mode .editor-layout{padding-top:0!important;height:100vh}
</style>
+2
View File
@@ -0,0 +1,2 @@
<script>if(typeof lucide!=='undefined')lucide.createIcons();</script>
<script src="/js/theme.js"></script>
+20
View File
@@ -0,0 +1,20 @@
<aside class="sidebar" style="position:fixed;top:var(--hdr-h);left:0;width:var(--sb-w);height:calc(100vh - var(--hdr-h));background:var(--bg-nav);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:50;overflow-y:auto">
<div class="sb-brand" style="padding:1rem 1.1rem;border-bottom:1px solid var(--border)">
<h2 style="font-size:.82rem;font-weight:700;color:var(--text);display:flex;align-items:center;gap:.4rem;margin:0">
<span class="brand-dot" style="width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent);animation:pulse-glow 2s ease-in-out infinite"></span>
Mek-Tech
</h2>
<p style="font-size:.6rem;color:var(--text-dim);margin:.15rem 0 0">Consultant Platform</p>
</div>
<nav class="sb-nav" style="flex:1;padding:.4rem 0">
<% (typeof navSections !== 'undefined' ? navSections : []).forEach(function(section) { %>
<div class="sb-sep" style="font-size:.55rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.6px;padding:.6rem 1rem .2rem;font-weight:600"><%= section.label %></div>
<% section.items.forEach(function(item) { %>
<a href="<%= activeClientId && !item.href.includes('client_id') && item.href !== '/' && !item.href.startsWith('/clients/') && !item.href.startsWith('/settings') && !item.href.startsWith('/ai') ? item.href + (item.href.includes('?') ? '&' : '?') + 'client_id=' + activeClientId : item.href %>" class="sb-item<%= item.active ? ' active' : '' %>" style="display:flex;align-items:center;gap:.55rem;padding:.5rem .9rem;margin:1px .45rem;border-radius:8px;font-size:.78rem;color:var(--text-muted);text-decoration:none;transition:all .18s">
<span class="sb-icon" style="width:20px;display:flex;align-items:center;justify-content:center"><i data-lucide="<%= item.icon %>"></i></span>
<span><%= item.label %></span>
</a>
<% }); %>
<% }); %>
</nav>
</aside>
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title><%= file.original_name %> — 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">
<style>body{margin:0;padding:0;background:var(--bg);height:100vh;display:flex;flex-direction:column}.pdf-nav{background:var(--bg-nav);border-bottom:1px solid var(--border);padding:.5rem 1rem;display:flex;align-items:center;gap:.75rem;flex-shrink:0}.pdf-nav .pn-title{flex:1;font-size:.85rem;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pdf-nav .pn-size{font-size:.7rem;color:var(--text-dim)}.pdf-frame{flex:1;width:100%;border:none;background:#fff}</style></head><body>
<div class="pdf-nav">
<a href="/clients/<%= clientId %>#files" class="back-link" style="margin:0"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg> Terug</a>
<span class="pn-title"><%= file.original_name %></span>
<span class="pn-size"><%= (file.file_size / 1024).toFixed(0) %> KB</span>
<a href="/clients/<%= clientId %>/files/<%= file.id %>/download" class="btn btn-primary" style="padding:.3rem .7rem;font-size:.75rem">Downloaden</a>
</div>
<iframe class="pdf-frame" src="/clients/<%= clientId %>/files/<%= file.id %>/download#view=FitH" title="<%= file.original_name %>"></iframe>
</body></html>
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= project ? project.name : 'Project' %> — Mek-Tech</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/neon.css">
<link rel="stylesheet" href="/css/dynamic.css">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.pd-wrap{max-width:960px;margin:0 auto;padding:1.5rem 2rem 3rem}
.pd-hdr{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem}
.pd-hdr h1{font-size:1.4rem;font-weight:800;margin:0}
.pd-meta{font-size:.75rem;color:var(--text-muted);margin-top:.25rem}
.pd-grid{display:grid;grid-template-columns:1fr 1fr;gap:.85rem}
@media(max-width:768px){.pd-grid{grid-template-columns:1fr}}
.task-row{display:flex;align-items:center;gap:.5rem;padding:.5rem 0;border-bottom:1px solid var(--border-light);font-size:.78rem}
.task-row:last-child{border-bottom:none}
</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="/projects" class="nav-icon-btn"><i data-lucide="folder-kanban" style="width:15px;height:15px"></i> Projecten</a>
<a href="/" class="nav-icon-btn"><i data-lucide="layout-dashboard" style="width:15px;height:15px"></i> Dashboard</a>
<a href="/auth/logout" class="nav-icon-btn" style="color:var(--red)"><i data-lucide="log-out" style="width:15px;height:15px"></i></a>
</div></div></nav>
<div class="pd-wrap">
<a href="/projects" 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 naar projecten</a>
<% if (!project) { %>
<div class="card" style="text-align:center;padding:2rem"><p>Project niet gevonden.</p><a href="/projects" class="btn btn-accent btn-small">Naar projecten</a></div>
<% } else { %>
<div class="pd-hdr">
<div>
<h1><%= project.name %></h1>
<p class="pd-meta"><%= project.client_name || 'Geen cliënt' %> · <span class="status-badge <%= project.status %>"><%= project.status %></span> · Prioriteit: <%= project.priority %></p>
<% if (project.description) { %><p class="pd-meta" style="margin-top:.35rem"><%= project.description %></p><% } %>
</div>
<a href="/clients/<%= project.client_id %>" class="btn btn-secondary btn-small">Cliënt bekijken</a>
</div>
<div class="kpi-row" style="margin-bottom:1rem">
<div class="kpi-card accent"><div class="kpi-num"><%= tasks.length %></div><div class="kpi-label">Taken</div></div>
<div class="kpi-card green"><div class="kpi-num"><%= tasks.filter(t=>t.status==='done').length %></div><div class="kpi-label">Voltooid</div></div>
<div class="kpi-card purple"><div class="kpi-num"><%= milestones.length %></div><div class="kpi-label">Milestones</div></div>
</div>
<div class="pd-grid">
<div class="card card-hover">
<div class="card-title">Taken</div>
<% if (tasks.length === 0) { %><p class="empty-state-s">Nog geen taken voor dit project.</p><% } else { %>
<% tasks.forEach(function(t) { %>
<div class="task-row">
<span class="status-badge <%= t.status %>"><%= t.status %></span>
<span style="flex:1"><%= t.title %></span>
<span style="color:var(--text-muted);font-size:.68rem"><%= t.assignee_name || '—' %></span>
</div>
<% }) %>
<% } %>
</div>
<div class="card card-hover">
<div class="card-title">Milestones</div>
<% if (milestones.length === 0) { %><p class="empty-state-s">Geen milestones gepland.</p><% } else { %>
<% milestones.forEach(function(m) { %>
<div class="task-row">
<strong><%= m.name %></strong>
<span style="margin-left:auto;color:var(--text-muted);font-size:.68rem"><%= m.target_date || '—' %></span>
</div>
<% }) %>
<% } %>
</div>
</div>
<% } %>
</div>
<script>lucide.createIcons();</script>
<script src="/js/theme.js"></script>
</body>
</html>
+169
View File
@@ -0,0 +1,169 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Projecten — Mek-Tech Enterprise</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet"><link rel="stylesheet" href="/css/style.css"><link rel="stylesheet" href="/css/neon.css"><link rel="stylesheet" href="/css/dynamic.css"><link rel="icon" href="/img/favicon.svg"><script src="https://unpkg.com/lucide@latest"></script><script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root{--sb-w:210px;--hdr-h:50px}
html,body{height:100%;margin:0;padding:0;scroll-behavior:smooth}
body{background:var(--bg);font-family:'Inter',-apple-system,sans-serif}
.app-layout{display:flex;min-height:100vh;padding-top:var(--hdr-h)}
.sidebar{position:fixed;top:var(--hdr-h);left:0;width:var(--sb-w);height:calc(100vh - var(--hdr-h));background:var(--bg-nav);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:50;overflow-y:auto}
.main-area{flex:1;margin-left:var(--sb-w);padding:1.5rem 2rem 3rem;max-width:1400px}
.page-hdr{margin-bottom:1.5rem}.page-hdr h1{font-size:1.5rem;color:var(--text);font-weight:800;letter-spacing:-.4px}
.kanban-board{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;overflow-x:auto;padding-bottom:1rem}
.kanban-column{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1rem;min-height:400px}
.kanban-header{font-size:.85rem;font-weight:600;color:var(--text);margin-bottom:.75rem;display:flex;justify-content:space-between;align-items:center}
.kanban-count{font-size:.7rem;color:var(--text-muted);background:var(--border-light);padding:.2rem .5rem;border-radius:999px}
.kanban-tasks{display:flex;flex-direction:column;gap:.5rem}
.task-card{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:.75rem;cursor:grab;transition:all .2s}
.task-card:hover{border-color:var(--accent);transform:translateY(-2px)}
.task-card.dragging{opacity:.5}
.task-title{font-size:.8rem;font-weight:500;color:var(--text);margin-bottom:.4rem}
.task-meta{font-size:.7rem;color:var(--text-muted);display:flex;gap:.5rem;align-items:center}
.task-badge{padding:.1rem .3rem;border-radius:4px;font-size:.65rem;font-weight:500}
.task-badge.critical{background:var(--red-bg);color:var(--red)}
.task-badge.high{background:var(--orange-bg);color:var(--orange)}
.task-badge.medium{background:var(--cyan-bg);color:var(--cyan)}
.task-badge.low{background:var(--green-bg);color:var(--green)}
.project-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}
.project-card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.25rem;transition:all .2s;text-decoration:none;color:inherit;display:block}
.project-card:hover{border-color:var(--accent);transform:translateY(-2px);box-shadow:0 8px 25px rgba(0,0,0,.1)}
.project-card-header{display:flex;justify-content:space-between;align-items:start;margin-bottom:.75rem}
.project-title{font-size:.95rem;font-weight:600;color:var(--text)}
.project-client{font-size:.75rem;color:var(--text-muted);margin-bottom:.5rem}
.project-meta{display:flex;gap:1rem;font-size:.7rem;color:var(--text-dim)}
.project-progress{height:4px;background:var(--border-light);border-radius:2px;margin:.75rem 0;overflow:hidden}
.project-progress-fill{height:100%;background:linear-gradient(90deg,var(--accent),var(--purple));transition:width .3s}
.gantt-chart{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.5rem;overflow-x:auto}
.gantt-header{display:flex;gap:2rem;padding-bottom:.75rem;border-bottom:1px solid var(--border);margin-bottom:1rem;font-size:.7rem;color:var(--text-muted)}
.gantt-row{display:flex;align-items:center;padding:.5rem 0;border-bottom:1px solid var(--border-light)}
.gantt-label{width:200px;font-size:.8rem;color:var(--text);flex-shrink:0}
.gantt-bar-container{flex:1;position:relative;height:24px;background:var(--bg);border-radius:4px}
.gantt-bar{position:absolute;height:100%;background:linear-gradient(90deg,var(--accent),var(--purple));border-radius:4px;transition:all .2s}
.gantt-bar:hover{filter:brightness(1.1)}
@media(max-width:1024px){.kanban-board{grid-template-columns:repeat(2,1fr)}}
@media(max-width:768px){.kanban-board{grid-template-columns:1fr}.project-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="navbar"><div class="nav-inner"><a href="/" class="nav-brand"><img src="/img/logo.svg" class="nav-logo" alt="Mek-Tech"></a><div class="nav-links"><a href="/" class="nav-link">Dashboard</a><a href="/projects" class="nav-link active">Projecten</a><a href="/clients" class="nav-link">Cliënten</a><a href="/engagements" class="nav-link">Engagements</a><a href="/finance" class="nav-link">Financiën</a><a href="/market" class="nav-link">Markt</a><a href="/tasks" class="nav-link">Taken</a><a href="/quality" class="nav-link">Data</a><a href="/settings" class="nav-link">Instellingen</a><a href="/auth/logout" class="nav-link logout">Uitloggen</a></div></div></div>
<div class="app-layout">
<div class="sidebar">
<div class="sb-brand"><h2><span class="brand-dot"></span>Mek-Tech Enterprise</h2><p>Project Management</p></div>
<div class="sb-nav">
<div class="sb-sep">Views</div>
<button class="sb-item" data-tab="kanban"><span class="sb-icon"><i data-lucide="trello"></i></span>Kanban Board</button>
<button class="sb-item" data-tab="gantt"><span class="sb-icon"><i data-lucide="bar-chart-2"></i></span>Gantt Chart</button>
<button class="sb-item" data-tab="projects"><span class="sb-icon"><i data-lucide="folder"></i></span>Alle Projecten</button>
<div class="sb-sep">Actions</div>
<button class="sb-item" id="newProjectBtn"><span class="sb-icon"><i data-lucide="plus"></i></span>Nieuw Project</button>
<button class="sb-item" id="newTaskBtn"><span class="sb-icon"><i data-lucide="plus-circle"></i></span>Nieuwe Taak</button>
</div>
</div>
<div class="main-area">
<div class="page-hdr fade-in-up"><h1>Project Management</h1><p><%= projects.length %> projecten</p></div>
<div class="tab-panel active" id="panel-kanban">
<div class="kanban-board">
<% const columns = ['todo','in_progress','review','done']; const columnNames = ['To Do','In Progress','Review','Done']; %>
<% columns.forEach((col,i)=>{ %>
<div class="kanban-column">
<div class="kanban-header"><span><%= columnNames[i] %></span><span class="kanban-count"><%= projectTasks.filter(t=>t.status===col).length %></span></div>
<div class="kanban-tasks" data-status="<%= col %>">
<% projectTasks.filter(t=>t.status===col).forEach(task=>{ %>
<div class="task-card" draggable="true" data-task-id="<%= task.id %>">
<div class="task-title"><%= task.title %></div>
<div class="task-meta">
<span class="task-badge <%= task.priority %>"><%= task.priority %></span>
<span><i data-lucide="user" style="width:12px;height:12px"></i> <%= task.assignee_name||'Unassigned' %></span>
</div>
<div style="height:4px;background:var(--border-light);border-radius:2px;margin:.5rem 0;overflow:hidden">
<div style="height:100%;background:var(--accent);width:<%= task.progress %>%;border-radius:2px"></div>
</div>
</div>
<% }) %>
</div>
</div>
<% }) %>
</div>
</div>
<div class="tab-panel" id="panel-gantt">
<div class="gantt-chart">
<div class="gantt-header"><span>Task</span><span>Progress</span></div>
<% projectTasks.forEach(task=>{ %>
<div class="gantt-row">
<div class="gantt-label"><%= task.title %></div>
<div class="gantt-bar-container">
<div class="gantt-bar" style="width:<%= task.progress %>%;background:<%= task.status==='done'?'var(--green)':task.status==='in_progress'?'var(--accent)':'var(--orange)' %>"></div>
</div>
<div style="width:60px;font-size:.7rem;color:var(--text)"><%= task.progress %>%</div>
</div>
<% }) %>
</div>
</div>
<div class="tab-panel" id="panel-projects">
<div class="project-grid">
<% projects.forEach(project=>{ %>
<a href="/projects/<%= project.id %>" class="project-card fade-in-up">
<div class="project-card-header">
<div class="project-title"><%= project.name %></div>
<span class="status-badge <%= project.status %>"><%= project.status %></span>
</div>
<div class="project-client"><%= project.client_name||'No client' %></div>
<div class="project-meta">
<span><%= project.task_count %> taken</span>
<span><%= project.completed_tasks %> voltooid</span>
</div>
</a>
<% }) %>
</div>
</div>
</div></div>
<div class="modal" id="newProjectModal" style="display:none">
<div class="modal-content">
<div class="modal-header"><h3>Nieuw Project</h3><button class="btn-small" id="closeProjectModal">✕</button></div>
<form method="POST" action="/projects/new">
<div class="form-grid">
<div><label>Naam</label><input type="text" name="name" required></div>
<div><label>Cliënt</label><select name="client_id"><option value="">Selecteer cliënt</option><% clients.forEach(c=>{ %><option value="<%= c.id %>"><%= c.name %></option><% }) %></select></div>
<div><label>Start Datum</label><input type="date" name="start_date"></div>
<div><label>Eind Datum</label><input type="date" name="end_date"></div>
<div><label>Prioriteit</label><select name="priority"><option value="low">Low</option><option value="medium" selected>Medium</option><option value="high">High</option><option value="critical">Critical</option></select></div>
</div>
<div style="margin-top:1rem"><label>Beschrijving</label><textarea name="description" rows="3"></textarea></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Project Aanmaken</button></div>
</form>
</div>
</div>
<script>
document.querySelectorAll('.sb-item').forEach(function(b){
b.addEventListener('click',function(){
var t=this.dataset.tab;
document.querySelectorAll('.sb-item').forEach(function(x){x.classList.remove('active')});
document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});
this.classList.add('active');
document.getElementById('panel-'+t).classList.add('active');
});
});
document.getElementById('newProjectBtn').addEventListener('click',function(){
document.getElementById('newProjectModal').style.display='flex';
});
document.getElementById('closeProjectModal').addEventListener('click',function(){
document.getElementById('newProjectModal').style.display='none';
});
document.getElementById('newTaskBtn').addEventListener('click',function(){
alert('Task modal coming soon');
});
document.querySelectorAll('.task-card').forEach(card=>{
card.addEventListener('dragstart',e=>{
e.dataTransfer.setData('text/plain',card.dataset.taskId);
});
});
document.querySelectorAll('.kanban-tasks').forEach(column=>{
column.addEventListener('dragover',e=>e.preventDefault());
column.addEventListener('drop',e=>{
e.preventDefault();
const taskId=e.dataTransfer.getData('text/plain');
const newStatus=column.dataset.status;
fetch('/projects/tasks/'+taskId+'/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:newStatus})}).then(()=>location.reload());
});
});
lucide.createIcons();
</script>
</body></html>
+369
View File
@@ -0,0 +1,369 @@
<!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>
+149
View File
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Infrastructuur Rapport — <%= rack.name %> — Mek-Tech</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/neon.css">
<link rel="stylesheet" href="/css/dynamic.css">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.report-wrap{max-width:1000px;margin:0 auto;padding:2rem 1.5rem 3rem}
.report-header{text-align:center;padding:1.25rem 0 1.5rem;border-bottom:2px solid var(--accent);margin-bottom:1.5rem}
.report-header h1{font-size:1.5rem;margin:0 0 .25rem}
.report-header .sub{color:var(--text-muted);font-size:.85rem}
.section{margin-bottom:2rem}
.section h2{font-size:1rem;font-weight:700;border-bottom:1px solid var(--border);padding-bottom:.4rem;margin-bottom:.75rem;display:flex;align-items:center;gap:.4rem}
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.5rem;margin-bottom:1rem}
.summary-card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:.65rem;text-align:center}
.summary-card .num{font-size:1.4rem;font-weight:800;color:var(--accent)}
.summary-card .lbl{font-size:.62rem;color:var(--text-muted);text-transform:uppercase}
.rack-table,.flow-table{width:100%;border-collapse:collapse;font-size:.75rem}
.rack-table th,.flow-table th{text-align:left;padding:.45rem .55rem;font-size:.62rem;color:var(--text-muted);text-transform:uppercase;border-bottom:1px solid var(--border);background:var(--bg-card)}
.rack-table td,.flow-table td{padding:.4rem .55rem;border-bottom:1px solid var(--border-light)}
.rack-visual{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.4rem;margin-bottom:1rem}
.ru-card{background:var(--bg-card);border:1px solid var(--border);border-radius:6px;padding:.45rem .55rem;font-size:.68rem;border-left:3px solid var(--accent)}
.ru-card strong{display:block;font-size:.72rem}
.diagram-block{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1rem;margin-bottom:1rem}
.diagram-block h3{font-size:.85rem;margin:0 0 .35rem}
.diagram-meta{font-size:.65rem;color:var(--text-muted);margin-bottom:.65rem}
.tag{display:inline-block;padding:.1rem .4rem;border-radius:999px;font-size:.58rem;font-weight:600;margin-right:.25rem}
.tag.flow{background:var(--cyan-bg);color:var(--cyan)}
.tag.landscape{background:var(--purple-bg);color:var(--purple)}
.tag.data{background:var(--green-bg);color:var(--green)}
.mini-flow{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.5rem}
.mini-node{padding:.25rem .45rem;border-radius:5px;font-size:.62rem;background:var(--bg);border:1px solid var(--border-light)}
.mini-arrow{color:var(--accent);font-size:.7rem}
.action-bar{display:flex;gap:.5rem;justify-content:center;margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border);flex-wrap:wrap}
@media print{.navbar,.action-bar{display:none!important}}
</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;gap:.4rem"><a href="/racks/<%= rack.id %>" class="nav-icon-btn">Rack</a><a href="/auth/logout" class="nav-icon-btn" style="color:var(--red)"><i data-lucide="log-out" style="width:15px;height:15px"></i></a></div></div></nav>
<div class="report-wrap">
<header class="report-header">
<h1>Gecombineerd Infrastructuur Rapport</h1>
<div class="sub"><%= rack.name %> · <%= rack.client_name %></div>
<div class="sub" style="margin-top:.2rem"><%= rack.location || '' %> <%= rack.datacenter ? '· ' + rack.datacenter : '' %> · <%= new Date().toLocaleDateString('nl-NL',{weekday:'long',year:'numeric',month:'long',day:'numeric'}) %></div>
</header>
<section class="section">
<h2><i data-lucide="server" style="width:16px;height:16px;color:var(--cyan)"></i> Rack Overzicht</h2>
<div class="summary-grid">
<div class="summary-card"><div class="num"><%= devices.length %></div><div class="lbl">Devices</div></div>
<div class="summary-card"><div class="num"><%= usedU %>/<%= rack.total_units || 42 %></div><div class="lbl">Rack Units</div></div>
<div class="summary-card"><div class="num"><%= Math.round(usedU/(rack.total_units||42)*100) %>%</div><div class="lbl">Bezetting</div></div>
<div class="summary-card"><div class="num"><%= diagrams.length %></div><div class="lbl">Diagrammen</div></div>
</div>
<div class="rack-visual">
<% devices.forEach(function(d){ %>
<div class="ru-card">
<strong>U<%= d.position_u %> — <%= d.name %></strong>
<%= d.manufacturer || '' %> <%= d.model || '' %> · <%= d.device_type %> · <%= d.height_u %>U
<% if (d.mgmt_ip) { %><br><span style="color:var(--accent);font-family:monospace;font-size:.62rem"><%= d.mgmt_ip %></span><% } %>
</div>
<% }) %>
<% if (devices.length === 0) { %><p style="color:var(--text-muted);font-size:.75rem">Geen devices in dit rack.</p><% } %>
</div>
<table class="rack-table">
<thead><tr><th>Pos</th><th>Naam</th><th>Type</th><th>Vendor / Model</th><th>IP</th><th>Status</th></tr></thead>
<tbody>
<% devices.forEach(function(d){ %>
<tr><td>U<%= d.position_u %></td><td><strong><%= d.name %></strong></td><td><%= d.device_type %></td><td><%= d.manufacturer || '—' %> <%= d.model || '' %></td><td><%= d.mgmt_ip || '—' %></td><td><%= d.status || 'active' %></td></tr>
<% }) %>
</tbody>
</table>
</section>
<% if (diagrams.length > 0) { %>
<section class="section">
<h2><i data-lucide="git-branch" style="width:16px;height:16px;color:var(--purple)"></i> Applicatie Flows & Landschap</h2>
<% diagrams.forEach(function(d){ %>
<div class="diagram-block">
<h3><%= d.name %></h3>
<div class="diagram-meta">
<span class="tag <%= d.diagram_type === 'application_flow' ? 'flow' : d.diagram_type === 'application_landscape' ? 'landscape' : 'data' %>"><%= d.typeLabel %></span>
<%= d.nodeCount %> componenten · <%= d.edgeCount %> flows · bijgewerkt <%= new Date(d.updated_at).toLocaleDateString('nl-NL') %>
</div>
<% if (Object.keys(d.layers || {}).length) { %>
<div style="font-size:.68rem;color:var(--text-muted);margin-bottom:.5rem">
<% Object.entries(d.layers).forEach(function(e){ %><span style="margin-right:.5rem"><strong><%= e[0] %>:</strong> <%= e[1] %></span><% }) %>
</div>
<% } %>
<% if (d.nodes && d.nodes.length) { %>
<div class="mini-flow">
<% d.nodes.slice(0,12).forEach(function(n,i){ %>
<% if (i > 0) { %><span class="mini-arrow">→</span><% } %>
<span class="mini-node" title="<%= n.type || '' %>"><%= n.label || n.type %><% if (n.rack_device_id) { %> <em style="color:var(--green)">●rack</em><% } %></span>
<% }) %>
<% if (d.nodes.length > 12) { %><span class="mini-node">+<%= d.nodes.length - 12 %> meer</span><% } %>
</div>
<% } %>
<% if (d.edges && d.edges.length) { %>
<table class="flow-table" style="margin-top:.75rem">
<thead><tr><th>#</th><th>Van</th><th>Naar</th><th>Protocol</th><th>Animatie</th></tr></thead>
<tbody>
<% d.edges.forEach(function(e,i){
var from = (d.nodes || []).find(function(n){ return n.id == e.from; });
var to = (d.nodes || []).find(function(n){ return n.id == e.to; });
%>
<tr>
<td><%= i+1 %></td>
<td><%= from ? from.label : e.from %></td>
<td>→ <%= to ? to.label : e.to %></td>
<td><%= e.protocol || '—' %></td>
<td><%= e.flow || 'off' %></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
<a href="/architecture/<%= d.id %>" class="btn btn-secondary btn-small" style="margin-top:.65rem;display:inline-flex">Open in designer</a>
</div>
<% }) %>
</section>
<% } else { %>
<section class="section">
<h2><i data-lucide="git-branch" style="width:16px;height:16px;color:var(--purple)"></i> Applicatie Flows & Landschap</h2>
<p style="color:var(--text-muted);font-size:.78rem">Nog geen flows of landschap gekoppeld aan dit rack.</p>
<div style="display:flex;gap:.4rem;margin-top:.5rem;flex-wrap:wrap">
<a href="/racks/<%= rack.id %>/flow/new" class="btn btn-accent btn-small">+ Applicatie Flow</a>
<a href="/racks/<%= rack.id %>/landscape/new" class="btn btn-secondary btn-small">+ Applicatie Landschap</a>
</div>
</section>
<% } %>
<div class="action-bar">
<button class="btn btn-accent" onclick="window.print()">PDF / Printen</button>
<a href="/racks/<%= rack.id %>" class="btn btn-secondary">Terug naar Rack</a>
<a href="/racks/<%= rack.id %>/flow/new" class="btn btn-secondary">Flow toevoegen</a>
<a href="/racks/<%= rack.id %>/landscape/new" class="btn btn-secondary">Landschap toevoegen</a>
</div>
</div>
<script>lucide.createIcons();</script>
<script src="/js/theme.js"></script>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Studio — <%= rack.name %> — Mek-Tech</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/neon.css">
<link rel="stylesheet" href="/css/dynamic.css">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
html,body{height:100%;margin:0;overflow:hidden}
body{background:#0a1628;font-family:Inter,sans-serif;color:#e8f4ff}
.studio-shell{display:flex;flex-direction:column;height:100vh}
.studio-toolbar{display:flex;align-items:center;justify-content:space-between;padding:.45rem .75rem;background:rgba(15,30,55,.95);border-bottom:1px solid rgba(0,212,255,.2);gap:.5rem;flex-wrap:wrap}
.studio-toolbar h1{font-size:.85rem;margin:0;font-weight:700}
.studio-toolbar p{font-size:.65rem;color:#6b9ab8;margin:.1rem 0 0}
.studio-actions{display:flex;gap:.35rem;flex-wrap:wrap}
.studio-split{flex:1;display:flex;min-height:0;position:relative}
.studio-pane{min-width:0;overflow:hidden;background:#0d1f38}
.studio-pane.rack-pane{flex:0 0 var(--rack-w,38%);border-right:1px solid rgba(0,212,255,.15)}
.studio-pane.flow-pane{flex:1}
.studio-pane iframe{width:100%;height:100%;border:none;display:block;background:transparent}
.studio-divider{width:6px;cursor:col-resize;background:rgba(0,212,255,.08);border-left:1px solid rgba(0,212,255,.15);border-right:1px solid rgba(0,212,255,.15);flex-shrink:0;transition:background .15s}
.studio-divider:hover,.studio-divider.dragging{background:rgba(0,212,255,.25)}
.studio-hint{font-size:.62rem;color:#6b9ab8;padding:.35rem .75rem;background:rgba(0,0,0,.2);border-top:1px solid rgba(0,212,255,.1)}
.studio-hint strong{color:var(--accent,#00d4ff)}
</style>
</head>
<body class="has-rack-ws-nav">
<div class="studio-shell">
<%- include('partials/rack-workspace-nav', { rack, flowDiagram, landscapeDiagram, workspaceTab: 'studio' }) %>
<div class="studio-toolbar">
<div>
<h1>Gecombineerde Studio — <%= rack.name %></h1>
<p>Infrastructuur links · Applicatie flows rechts · sleep de scheidingslijn om te vergroten/verkleinen</p>
</div>
<div class="studio-actions">
<button type="button" class="btn btn-secondary btn-small" onclick="loadDemo()">Voorbeeld laden</button>
<button type="button" class="btn btn-secondary btn-small" onclick="setLayout(50)">50/50</button>
<button type="button" class="btn btn-secondary btn-small" onclick="setLayout(35)">Meer flow</button>
<button type="button" class="btn btn-secondary btn-small" onclick="setLayout(60)">Meer rack</button>
<a href="/racks/<%= rack.id %>/report" class="btn btn-accent btn-small">Rapport</a>
</div>
</div>
<div class="studio-split" id="studioSplit">
<div class="studio-pane rack-pane" id="rackPane">
<iframe id="rackFrame" src="/racks/<%= rack.id %>?embed=1" title="Rack Design"></iframe>
</div>
<div class="studio-divider" id="studioDivider" title="Sleep om te vergroten/verkleinen"></div>
<div class="studio-pane flow-pane" id="flowPane">
<iframe id="flowFrame" src="<%= flowDiagram ? '/racks/' + rack.id + '/diagram/' + flowDiagram.id : '/racks/' + rack.id + '/flow/new' %>?embed=1" title="Applicatie Flow"></iframe>
</div>
</div>
<div class="studio-hint">
<strong>Tip:</strong> Wijzig het rack links (devices slepen) en teken flows rechts. Gebruik de tabs bovenaan om te wisselen — je blijft altijd binnen rack <strong><%= rack.name %></strong>.
<% if (!flowDiagram) { %> Nog geen flow? Klik <strong>Voorbeeld laden</strong> voor een startpunt. <% } %>
</div>
</div>
<script>
lucide.createIcons();
const divider = document.getElementById('studioDivider');
const split = document.getElementById('studioSplit');
let dragging = false;
function setLayout(pct){ document.documentElement.style.setProperty('--rack-w', pct + '%'); }
setLayout(38);
divider.addEventListener('mousedown', function(e){ dragging=true; divider.classList.add('dragging'); e.preventDefault(); });
window.addEventListener('mousemove', function(e){
if(!dragging) return;
const r = split.getBoundingClientRect();
const pct = Math.min(70, Math.max(22, ((e.clientX - r.left) / r.width) * 100));
setLayout(pct);
});
window.addEventListener('mouseup', function(){ dragging=false; divider.classList.remove('dragging'); });
async function loadDemo(){
try {
const r = await fetch('/racks/<%= rack.id %>/studio/demo', { method: 'POST' });
const d = await r.json();
if(d.success){
document.getElementById('flowFrame').src = '/racks/<%= rack.id %>/diagram/' + d.flowId + '?embed=1';
setTimeout(function(){ location.reload(); }, 400);
}
} catch(e){ alert('Demo laden mislukt'); }
}
window.addEventListener('message', function(e){
if(e.data && e.data.type === 'mek-navigate-rack') window.location.href = e.data.url;
});
</script>
<script src="/js/theme.js"></script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= rack ? 'Bewerk' : 'Nieuwe' %> Rack — Mek-Tech Consulting</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" type="image/svg+xml">
</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="container">
<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="page-header">
<div>
<a href="/racks" class="back-link">← Terug naar racks</a>
<h1><%= rack ? 'Bewerk: ' + rack.name : 'Nieuwe Rack' %></h1>
</div>
</div>
<form action="/racks/<%= rack ? rack.id + '/edit' : 'new' %>" method="POST" class="form-card">
<% if (!rack) { %>
<div class="field">
<label for="client_id">Cliënt *</label>
<select name="client_id" required>
<option value="">Selecteer cliënt...</option>
<% clients.forEach(function(c) { %>
<option value="<%= c.id %>" <%= (selected && String(selected) === String(c.id)) ? 'selected' : '' %>><%= c.name %></option>
<% }) %>
</select>
</div>
<% } %>
<div class="form-grid">
<div class="field">
<label for="name">Rack naam *</label>
<input type="text" name="name" id="name" value="<%= rack ? rack.name : '' %>" required>
</div>
<div class="field">
<label for="total_units">Hoogte (U)</label>
<input type="number" name="total_units" id="total_units" value="<%= rack ? rack.total_units : 42 %>" min="12" max="52">
</div>
<div class="field">
<label for="location">Locatie</label>
<input type="text" name="location" id="location" value="<%= rack ? rack.location : '' %>" placeholder="Bv. Rij A, Kolom 3">
</div>
<div class="field">
<label for="datacenter">Datacenter</label>
<input type="text" name="datacenter" id="datacenter" value="<%= rack ? rack.datacenter : '' %>">
</div>
</div>
<div class="field">
<label for="notes">Notities</label>
<textarea name="notes" id="notes" rows="3"><%= rack ? rack.notes : '' %></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-accent"><%= rack ? 'Opslaan' : 'Aanmaken' %></button>
<a href="/racks" class="btn btn-secondary">Annuleren</a>
</div>
</form>
</div>
<script src="/js/theme.js"></script></body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Racks — Mek-Tech Consulting</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" type="image/svg+xml">
</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="container">
<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="page-header">
<h1>Server Racks</h1>
<a href="/racks/new" class="btn btn-primary">+ Nieuwe rack</a>
</div>
<% if (racks.length === 0) { %>
<div class="empty-state"><p>Nog geen racks. <a href="/racks/new" class="link">Voeg je eerste rack toe</a>.</p></div>
<% } else { %>
<div class="rack-grid">
<% racks.forEach(function(r) { %>
<a href="/racks/<%= r.id %>" class="rack-card">
<div class="rack-card-visual">
<div class="rack-mini">
<% var filled = Math.min(r.device_count, 20); %>
<% for(var i = 0; i < 20; i++) { %>
<div class="rack-mini-u <%= i < filled ? 'filled' : '' %>"></div>
<% } %>
</div>
</div>
<div class="rack-card-body">
<h4><%= r.name %></h4>
<p class="client-meta"><%= r.client_name %></p>
<% if (r.location) { %><p class="client-meta"><%= r.location %></p><% } %>
<p class="client-meta"><%= r.device_count %> device(s) / <%= r.total_units %>U</p>
</div>
</a>
<% }) %>
</div>
<% } %>
</div>
<script src="/js/theme.js"></script></body>
</html>
+752
View File
@@ -0,0 +1,752 @@
<!DOCTYPE html>
<html lang="nl" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= rack.name %> — Rack View — 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" type="image/svg+xml">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
/* ── Dracula-inspired Rack Theme ── */
:root {
--d-bg: #1e1f2b;
--d-card: #282a36;
--d-border: #44475a;
--d-text: #f8f8f2;
--d-muted: #6272a4;
--d-dim: #44475a;
--d-accent: #ff79c6;
--d-green: #50fa7b;
--d-cyan: #8be9fd;
--d-yellow: #f1fa8c;
--d-orange: #ffb86c;
--d-red: #ff5555;
--rack-w: 280px;
--u-h: 30px;
}
body { background: var(--d-bg); color: var(--d-text); font-family: Inter, sans-serif; min-height: 100vh; }
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:var(--d-bg)}
::-webkit-scrollbar-thumb{background:var(--d-dim);border-radius:3px}
.rack-layout{display:flex;gap:1.5rem;align-items:flex-start;padding:1rem 0}
@media(max-width:1000px){.rack-layout{flex-direction:column}}
/* ── Rack Frame ── */
.rack-col{flex-shrink:0;position:relative}
.rack-view{width:var(--rack-w);background:var(--d-card);border:2px solid var(--d-border);border-radius:6px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.3)}
.rack-top{background:var(--d-border);padding:.4rem .6rem;font-size:.7rem;color:var(--d-text);text-align:center;border-bottom:2px solid var(--d-dim);font-weight:600;letter-spacing:1px;text-transform:uppercase}
.rack-body{display:flex;flex-direction:column;position:relative}
.rack-body::before{content:'';position:absolute;top:0;bottom:0;left:26px;width:2px;background:var(--d-dim);opacity:.3}
.rack-body::after{content:'';position:absolute;top:0;bottom:0;right:26px;width:2px;background:var(--d-dim);opacity:.3}
/* U slot */
.rack-u{display:flex;align-items:center;height:var(--u-h);border-bottom:1px solid var(--d-dim);position:relative;transition:background .15s}
.rack-u:hover{background:rgba(68,71,90,.3)}
.rack-u.empty{background:var(--d-card)}
.rack-u .u-num{width:24px;font-size:.45rem;color:var(--d-muted);text-align:center;flex-shrink:0;font-weight:500;z-index:1}
.rack-u .u-mid{position:absolute;left:26px;right:26px;height:1px;background:rgba(68,71,90,.15);top:50%}
/* Device block */
.rack-device{display:flex;align-items:center;gap:.25rem;cursor:grab;position:absolute;left:24px;right:24px;z-index:10;border-radius:3px;overflow:hidden;transition:box-shadow .15s}
.rack-device:hover{box-shadow:0 0 0 1px rgba(255,255,255,.1),0 4px 12px rgba(0,0,0,.4);z-index:20}
.rack-device.dragging{opacity:.7;z-index:100;box-shadow:0 8px 32px rgba(0,0,0,.5)}
.rack-device .d-lu{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:18px;height:18px}
.rack-device .d-lu svg{width:14px;height:14px;color:rgba(255,255,255,.7)}
.rack-device .d-label{flex:1;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.6rem;text-shadow:0 1px 2px rgba(0,0,0,.3);line-height:1}
.rack-device .d-spec{font-size:.45rem;color:rgba(255,255,255,.5);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:none}
.rack-view:hover .rack-device .d-spec{display:block;max-width:60px}
.rack-device .d-u-badge{font-size:.45rem;color:rgba(255,255,255,.4);background:rgba(0,0,0,.2);border-radius:2px;padding:0 .25rem;flex-shrink:0}
.rack-device .d-ip{font-size:.42rem;color:rgba(255,255,255,.35);font-family:monospace;flex-shrink:0;display:none}
.rack-device:hover .d-ip{display:inline}
/* Vendor color swatches */
.v-hpe{background:linear-gradient(135deg,#ff7b00 0%,#e06e00 100%);border-left:3px solid #ffa500}
.v-dell{background:linear-gradient(135deg,#0076a8 0%,#005a80 100%);border-left:3px solid #00a3e0}
.v-lenovo{background:linear-gradient(135deg,#e41f33 0%,#b01828 100%);border-left:3px solid #ff2d4a}
.v-cisco{background:linear-gradient(135deg,#00529a 0%,#003d73 100%);border-left:3px solid #00a0ff}
.v-juniper{background:linear-gradient(135deg,#44a833 0%,#338026 100%);border-left:3px solid #5dd64a}
.v-arista{background:linear-gradient(135deg,#c42 0%,#992211 100%);border-left:3px solid #ff4422}
.v-netapp{background:linear-gradient(135deg,#00a1df 0%,#007aaa 100%);border-left:3px solid #00ccff}
.v-pure{background:linear-gradient(135deg,#4a90d9 0%,#3670b5 100%);border-left:3px solid #6aafff}
.v-fortinet{background:linear-gradient(135deg,#ee3124 0%,#c4271c 100%);border-left:3px solid #ff4433}
.v-supermicro{background:linear-gradient(135deg,#e6a817 0%,#c48d0e 100%);border-left:3px solid #ffcc33}
.v-apc{background:linear-gradient(135deg,#6e7681 0%,#555d66 100%);border-left:3px solid #8b95a5}
.v-f5{background:linear-gradient(135deg,#d32f2f 0%,#b71c1c 100%);border-left:3px solid #e53935}
.v-paloalto{background:linear-gradient(135deg,#ed7b30 0%,#cc6220 100%);border-left:3px solid #ff8a42}
.v-nvidia{background:linear-gradient(135deg,#76b900 0%,#5c9400 100%);border-left:3px solid #8ce020}
.v-oracle{background:linear-gradient(135deg,#f80000 0%,#c50000 100%);border-left:3px solid #ff2222}
.v-ibm{background:linear-gradient(135deg,#0062ff 0%,#004acc 100%);border-left:3px solid #4488ff}
.v-other{background:linear-gradient(135deg,#555d66 0%,#3d444d 100%);border-left:3px solid #6e7681}
/* Device type icon colors */
.di-server{color:#58a6ff}
.di-switch{color:#8be9fd}
.di-storage{color:#50fa7b}
.di-firewall{color:#ff5555}
.di-router{color:#ff79c6}
.di-loadbalancer{color:#f1fa8c}
.di-san{color:#8be9fd}
.di-patch_panel{color:#6272a4}
.di-pdu{color:#ffb86c}
.di-ups{color:#f1fa8c}
.di-cooling{color:#8be9fd}
.di-tape{color:#bd93f9}
.di-kvm{color:#ff79c6}
.di-other{color:#6272a4}
/* ── Sidebar ── */
.sidebar{flex:1;min-width:0;max-width:480px}
.sidebar-card{background:var(--d-card);border:1px solid var(--d-border);border-radius:6px;padding:.75rem;margin-bottom:.75rem}
.sidebar-card h3{font-size:.75rem;font-weight:600;color:var(--d-cyan);margin-bottom:.5rem;text-transform:uppercase;letter-spacing:1px;display:flex;align-items:center;gap:.35rem}
/* Info grid */
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:.35rem}
.info-grid .ig-label{font-size:.6rem;color:var(--d-muted);text-transform:uppercase;letter-spacing:.5px}
.info-grid .ig-val{font-size:.75rem;color:var(--d-text);font-weight:500}
/* Utilization bar */
.util-bar{display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--d-dim);margin:.35rem 0}
.util-bar .ub-fill{background:var(--d-accent);transition:width .3s}
.util-bar .ub-fill.green{background:var(--d-green)}
.util-bar .ub-fill.yellow{background:var(--d-yellow)}
.util-bar .ub-fill.red{background:var(--d-red)}
/* Tabs */
.rack-tabs{display:flex;gap:2px;background:var(--d-card);border:1px solid var(--d-border);border-radius:6px;overflow:hidden;margin-bottom:.75rem}
.rack-tab{display:flex;align-items:center;justify-content:center;gap:.25rem;padding:.4rem .75rem;background:none;border:none;color:var(--d-muted);font-size:.7rem;font-weight:500;cursor:pointer;font-family:inherit;transition:all .15s;flex:1;text-align:center}
.rack-tab svg{width:14px;height:14px}
.rack-tab:hover{color:var(--d-text);background:rgba(68,71,90,.3)}
.rack-tab.active{color:var(--d-text);background:var(--d-border)}
.tab-content{display:none}
.tab-content.active{display:block}
/* Device library grid */
.lib-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:.35rem}
.lib-item{display:flex;align-items:center;gap:.35rem;background:var(--d-bg);border:1px solid var(--d-border);border-radius:4px;padding:.35rem;cursor:pointer;transition:all .12s}
.lib-item svg{width:16px;height:16px;flex-shrink:0}
.lib-item:hover{border-color:var(--d-accent);transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,.2)}
.lib-item .li-name{font-size:.6rem;font-weight:600;color:var(--d-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
.lib-item .li-detail{font-size:.5rem;color:var(--d-muted);flex-shrink:0}
/* Quick add buttons */
.qadd{display:flex;flex-wrap:wrap;gap:.25rem}
.qadd button{display:inline-flex;align-items:center;gap:.25rem;padding:.25rem .5rem;font-size:.6rem;border-radius:3px;border:none;cursor:pointer;color:#fff;font-weight:500;font-family:inherit;transition:all .12s;opacity:.85}
.qadd button svg{width:12px;height:12px}
.qadd button:hover{opacity:1;transform:translateY(-1px)}
/* Device list */
.dlist{display:flex;flex-direction:column;gap:.2rem}
.dl-item{display:flex;align-items:center;gap:.4rem;background:var(--d-bg);border:1px solid var(--d-border);border-radius:4px;padding:.35rem .5rem;font-size:.65rem;cursor:pointer;transition:all .12s}
.dl-item:hover{border-color:var(--d-muted)}
.dl-item .dl-color{width:3px;height:24px;border-radius:2px;flex-shrink:0}
.dl-item svg{width:14px;height:14px;flex-shrink:0}
.dl-item .dl-name{flex:1;color:var(--d-text);font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dl-item .dl-model{color:var(--d-muted);font-size:.6rem;white-space:nowrap}
.dl-item .dl-u{color:var(--d-dim);font-size:.55rem}
/* View toggle */
.view-toggle{display:flex;gap:2px;margin-bottom:.5rem}
.view-toggle button{display:flex;align-items:center;gap:.25rem;padding:.25rem .5rem;font-size:.55rem;border:1px solid var(--d-border);border-radius:3px;background:var(--d-bg);color:var(--d-muted);cursor:pointer;font-family:inherit;transition:all .12s;text-transform:uppercase;letter-spacing:.5px}
.view-toggle button svg{width:12px;height:12px}
.view-toggle button.active{background:var(--d-border);color:var(--d-text);border-color:var(--d-muted)}
.view-toggle button:hover{border-color:var(--d-muted)}
/* Search */
.search-box{display:flex;gap:.35rem;margin-bottom:.5rem}
.search-box input{flex:1;padding:.3rem .5rem;font-size:.65rem;background:var(--d-bg);border:1px solid var(--d-border);border-radius:4px;color:var(--d-text);font-family:inherit}
.search-box input:focus{border-color:var(--d-accent);outline:none}
.search-box input::placeholder{color:var(--d-dim)}
.search-box svg{width:14px;height:14px;color:var(--d-muted);flex-shrink:0;margin-top:.3rem}
/* Forms in sidebar */
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:.35rem;margin-bottom:.35rem}
.form-row.full{grid-template-columns:1fr}
.form-row label{font-size:.6rem;color:var(--d-muted);display:block;margin-bottom:.15rem;text-transform:uppercase;letter-spacing:.5px}
.form-row input,.form-row select,.form-row textarea{width:100%;padding:.3rem .4rem;font-size:.65rem;background:var(--d-bg);border:1px solid var(--d-border);border-radius:3px;color:var(--d-text);font-family:inherit;box-sizing:border-box}
.form-row input:focus,.form-row select:focus,.form-row textarea:focus{border-color:var(--d-accent);outline:none}
.form-row select option{background:var(--d-bg);color:var(--d-text)}
/* Buttons */
.btn-rack{display:inline-flex;align-items:center;justify-content:center;gap:.3rem;padding:.35rem .65rem;font-size:.65rem;font-weight:600;border-radius:4px;border:none;cursor:pointer;font-family:inherit;transition:all .12s;text-decoration:none}
.btn-rack svg{width:14px;height:14px}
.btn-rack.primary{background:var(--d-accent);color:#000}
.btn-rack.primary:hover{filter:brightness(1.15)}
.btn-rack.secondary{background:var(--d-border);color:var(--d-text)}
.btn-rack.secondary:hover{background:var(--d-muted)}
.btn-rack.danger{background:var(--d-red);color:#fff}
.btn-rack.danger:hover{filter:brightness(1.15)}
.btn-rack.ghost{background:transparent;color:var(--d-muted);border:1px solid var(--d-border)}
.btn-rack.ghost:hover{border-color:var(--d-muted);color:var(--d-text)}
.btn-rack.small{padding:.2rem .4rem;font-size:.55rem}
.btn-rack.full{width:100%;justify-content:center}
.btn-rack:disabled{opacity:.4;cursor:not-allowed}
/* Modal */
.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:200;display:none;align-items:center;justify-content:center;backdrop-filter:blur(2px)}
.modal-overlay.open{display:flex}
.modal-box{background:var(--d-card);border:1px solid var(--d-border);border-radius:8px;width:90%;max-width:520px;max-height:80vh;overflow-y:auto;box-shadow:0 16px 48px rgba(0,0,0,.5)}
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:.65rem .75rem;border-bottom:1px solid var(--d-border)}
.modal-head h3{font-size:.8rem;font-weight:600;color:var(--d-text);display:flex;align-items:center;gap:.35rem}
.modal-head h3 svg{width:18px;height:18px}
.modal-close{background:none;border:none;color:var(--d-muted);font-size:1.1rem;cursor:pointer;padding:0 .25rem;font-family:inherit}
.modal-close:hover{color:var(--d-text)}
.modal-body{padding:.65rem .75rem}
.modal-foot{display:flex;gap:.35rem;justify-content:flex-end;padding:.5rem .75rem;border-top:1px solid var(--d-border)}
.modal-body .dev-avatar{width:48px;height:48px;border-radius:8px;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.modal-body .dev-avatar svg{width:24px;height:24px;color:#fff}
/* Empty state */
.empty-state{text-align:center;padding:1.5rem .5rem;color:var(--d-muted);font-size:.7rem}
.empty-state svg{width:32px;height:32px;opacity:.3;display:block;margin:0 auto .5rem auto}
/* Status badges */
.badge{display:inline-block;padding:.1rem .35rem;border-radius:3px;font-size:.5rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.badge.active{background:rgba(80,250,123,.15);color:var(--d-green)}
.badge.standby{background:rgba(255,184,108,.15);color:var(--d-orange)}
.badge.decommissioned{background:rgba(255,85,85,.15);color:var(--d-red)}
/* Move hint */
.move-hint{font-size:.5rem;color:var(--d-muted);text-align:center;padding:.15rem 0;border-bottom:1px solid var(--d-dim);display:flex;align-items:center;justify-content:center;gap:.25rem}
.move-hint svg{width:10px;height:10px}
</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="container">
<!-- Page header -->
<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="page-header">
<div>
<a href="/racks" class="back-link" style="color:var(--d-cyan);display:flex;align-items:center;gap:.2rem"><i data-lucide="arrow-left" style="width:12px;height:12px"></i> Alle racks</a>
<h1 style="color:var(--d-text)"><%= rack.name %></h1>
<p style="color:var(--d-muted);font-size:.8rem"><%= rack.client_name %> — <%= rack.location || rack.datacenter || 'Locatie onbekend' %></p>
</div>
<div class="header-actions">
<a href="/racks/<%= rack.id %>/edit" class="btn-rack secondary"><i data-lucide="settings"></i> Bewerk rack</a>
<a href="/racks/<%= rack.id %>/flow/new" class="btn-rack primary"><i data-lucide="git-branch"></i> Applicatie Flow</a>
<a href="/racks/<%= rack.id %>/landscape/new" class="btn-rack primary"><i data-lucide="layers"></i> Landschap</a>
<a href="/racks/<%= rack.id %>/report" class="btn-rack ghost"><i data-lucide="file-text"></i> Rapport</a>
<button class="btn-rack ghost" onclick="exportSVG()"><i data-lucide="download"></i> Export</button>
</div>
</div>
<div class="rack-layout">
<!-- Rack View -->
<div class="rack-col">
<div class="rack-view" id="rackView">
<div class="rack-top"><i data-lucide="server" style="width:12px;height:12px;vertical-align:middle;margin-right:.3rem"></i><%= rack.name %> · <span id="usedCount"><%= devices.reduce((s,d)=>s+d.height_u,0) %></span>/<%= rack.total_units %>U</div>
<div class="view-toggle" style="justify-content:center;padding:.3rem 0;border-bottom:1px solid var(--d-dim)">
<button class="active" data-view="front" onclick="switchView('front')"><i data-lucide="monitor"></i> Front</button>
<button data-view="rear" onclick="switchView('rear')"><i data-lucide="monitor"></i> Rear</button>
</div>
<div class="rack-body" id="rackBody">
<div class="move-hint"><i data-lucide="move"></i> Drag devices to reposition</div>
<%
var units = [];
devices.forEach(function(d) { for(var u = d.position_u; u < d.position_u + d.height_u; u++) units[u] = d; });
var _html = '';
for(var u = rack.total_units; u >= 1; u--) {
var dev = units[u];
if (dev && u === dev.position_u) {
var vc = 'v-other', vb = '#555d66';
var vm = (dev.manufacturer||'').toLowerCase();
if(vm.includes('hpe')||vm.includes('hewlett')){vc='v-hpe';vb='#ff7b00'}
else if(vm.includes('dell')){vc='v-dell';vb='#0076a8'}
else if(vm.includes('lenovo')){vc='v-lenovo';vb='#e41f33'}
else if(vm.includes('cisco')){vc='v-cisco';vb='#00529a'}
else if(vm.includes('juniper')){vc='v-juniper';vb='#44a833'}
else if(vm.includes('arista')){vc='v-arista';vb='#cc3333'}
else if(vm.includes('netapp')){vc='v-netapp';vb='#00a1df'}
else if(vm.includes('pure')){vc='v-pure';vb='#4a90d9'}
else if(vm.includes('fortinet')){vc='v-fortinet';vb='#ee3124'}
else if(vm.includes('super')||vm.includes('supermicro')){vc='v-supermicro';vb='#e6a817'}
else if(vm.includes('apc')){vc='v-apc';vb='#6e7681'}
else if(vm.includes('f5')){vc='v-f5';vb='#d32f2f'}
else if(vm.includes('palo')){vc='v-paloalto';vb='#ed7b30'}
else if(vm.includes('nvidia')){vc='v-nvidia';vb='#76b900'}
else if(vm.includes('oracle')){vc='v-oracle';vb='#f80000'}
else if(vm.includes('ibm')){vc='v-ibm';vb='#0062ff'}
else if(dev.device_type==='patch_panel'||dev.device_type==='pdu'||dev.device_type==='ups'){vc='v-apc';vb='#6e7681'}
var dtIcon='server';
if(dev.device_type==='switch') dtIcon='git-branch';
else if(dev.device_type==='storage') dtIcon='database';
else if(dev.device_type==='firewall') dtIcon='shield';
else if(dev.device_type==='router') dtIcon='radio';
else if(dev.device_type==='loadbalancer') dtIcon='scale';
else if(dev.device_type==='san') dtIcon='hard-drive';
else if(dev.device_type==='patch_panel') dtIcon='cable';
else if(dev.device_type==='pdu') dtIcon='zap';
else if(dev.device_type==='ups') dtIcon='battery-charging';
else if(dev.device_type==='cooling') dtIcon='fan';
else if(dev.device_type==='tape') dtIcon='archive';
else if(dev.device_type==='kvm') dtIcon='monitor';
else if(dev.device_type==='other') dtIcon='box';
var topx = (rack.total_units - dev.position_u - dev.height_u + 1) * 30 + 20;
var hx = dev.height_u * 30 - 2;
_html += '<div class="rack-device ' + vc + '" id="dev-' + dev.id + '" style="top:' + topx + 'px;height:' + hx + 'px" data-id="' + dev.id + '" data-u="' + dev.position_u + '" data-h="' + dev.height_u + '" onclick="openModal(' + dev.id + ')" draggable="true" title="' + dev.name.replace(/"/g,'&quot;') + ' · ' + (dev.model||dev.manufacturer||dev.device_type).replace(/"/g,'&quot;') + ' · ' + (dev.mgmt_ip||'').replace(/"/g,'&quot;') + '">';
_html += '<span class="d-lu di-' + dev.device_type + '"><i data-lucide="' + dtIcon + '"></i></span>';
_html += '<span class="d-label">' + dev.name + '</span>';
_html += '<span class="d-spec">' + (dev.model||dev.manufacturer||dev.device_type) + '</span>';
if (dev.mgmt_ip) _html += '<span class="d-ip">' + dev.mgmt_ip + '</span>';
_html += '<span class="d-u-badge">' + dev.height_u + 'U</span>';
_html += '</div>';
} else if (!dev) {
_html += '<div class="rack-u empty" data-u="' + u + '"><span class="u-num">' + u + '</span><div class="u-mid"></div></div>';
}
}
%>
<%- _html %>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- Rack Info -->
<div class="sidebar-card">
<h3><i data-lucide="bar-chart-3"></i> Rack Status</h3>
<% var used = devices.reduce((s,d)=>s+d.height_u,0); var pct = Math.round(used/rack.total_units*100); %>
<div class="util-bar">
<div class="ub-fill <%= pct>80?'red':pct>60?'yellow':'green' %>" style="width:<%= pct %>%"></div>
</div>
<div class="info-grid">
<div><div class="ig-label">Bezetting</div><div class="ig-val"><%= used %> / <%= rack.total_units %>U (<%= pct %>%)</div></div>
<div><div class="ig-label">Apparaten</div><div class="ig-val"><%= devices.length %></div></div>
<div><div class="ig-label">Vrij</div><div class="ig-val"><%= rack.total_units - used %>U</div></div>
<div><div class="ig-label">Datacenter</div><div class="ig-val"><%= rack.datacenter || '-' %></div></div>
</div>
</div>
<!-- Gekoppelde flows -->
<div class="sidebar-card">
<h3><i data-lucide="git-branch"></i> Flows & Landschap</h3>
<% if (typeof linkedDiagrams !== 'undefined' && linkedDiagrams && linkedDiagrams.length) { %>
<% linkedDiagrams.forEach(function(d){ %>
<a href="/architecture/<%= d.id %>" style="display:block;padding:.35rem 0;border-bottom:1px solid var(--d-dim);text-decoration:none;color:var(--d-text);font-size:.7rem">
<strong><%= d.name %></strong><br>
<span style="color:var(--d-muted);font-size:.62rem"><%= (d.diagram_type||'').replace(/_/g,' ') %></span>
</a>
<% }) %>
<% } else { %>
<p style="font-size:.65rem;color:var(--d-muted);margin:0 0 .5rem">Nog geen flows gekoppeld.</p>
<% } %>
<div style="display:flex;flex-direction:column;gap:.3rem;margin-top:.5rem">
<a href="/racks/<%= rack.id %>/flow/new" class="btn-rack primary" style="justify-content:center;font-size:.65rem;padding:.35rem"><i data-lucide="plus"></i> Applicatie Flow</a>
<a href="/racks/<%= rack.id %>/landscape/new" class="btn-rack secondary" style="justify-content:center;font-size:.65rem;padding:.35rem"><i data-lucide="layers"></i> Applicatie Landschap</a>
<a href="/racks/<%= rack.id %>/report" class="btn-rack ghost" style="justify-content:center;font-size:.65rem;padding:.35rem"><i data-lucide="file-text"></i> Gecombineerd Rapport</a>
</div>
</div>
<!-- Tabs -->
<div class="rack-tabs">
<button class="rack-tab active" data-tab="add"><i data-lucide="plus-circle"></i> Add</button>
<button class="rack-tab" data-tab="library"><i data-lucide="book-open"></i> Library</button>
<button class="rack-tab" data-tab="list"><i data-lucide="list"></i> List (<%= devices.length %>)</button>
</div>
<!-- ── Tab: Add Device ── -->
<div class="tab-content active" id="tab-add">
<div class="sidebar-card" style="margin-bottom:0">
<h3><i data-lucide="zap"></i> Quick Add</h3>
<div class="qadd">
<button onclick="quickAdd('HPE','ProLiant DL380 Gen11','server',2)" style="background:#ff7b00"><i data-lucide="server"></i> HPE Server</button>
<button onclick="quickAdd('Dell','PowerEdge R760','server',2)" style="background:#0076a8"><i data-lucide="server"></i> Dell Server</button>
<button onclick="quickAdd('Lenovo','ThinkSystem SR650','server',2)" style="background:#e41f33"><i data-lucide="server"></i> Lenovo Server</button>
<button onclick="quickAdd('Cisco','Catalyst 9300','switch',1)" style="background:#00529a"><i data-lucide="git-branch"></i> Cisco Switch</button>
<button onclick="quickAdd('Cisco','Nexus 93180YC','switch',1)" style="background:#00529a"><i data-lucide="git-branch"></i> Nexus</button>
<button onclick="quickAdd('Juniper','QFX5120','switch',1)" style="background:#44a833"><i data-lucide="git-branch"></i> Juniper QFX</button>
<button onclick="quickAdd('Arista','7050X3','switch',1)" style="background:#cc3333"><i data-lucide="git-branch"></i> Arista 7050</button>
<button onclick="quickAdd('NetApp','AFF A250','storage',2)" style="background:#00a1df"><i data-lucide="database"></i> NetApp AFF</button>
<button onclick="quickAdd('Pure','FlashArray//X','storage',3)" style="background:#4a90d9"><i data-lucide="database"></i> Pure FlashArray</button>
<button onclick="quickAdd('APC','Smart-UPS 1500','ups',2)" style="background:#6e7681"><i data-lucide="battery-charging"></i> APC UPS</button>
<button onclick="quickAdd('','Patch Panel 48-port','patch_panel',1)" style="background:#555d66"><i data-lucide="cable"></i> Patch Panel</button>
<button onclick="quickAdd('APC','PDU 5.7kWh','pdu',1)" style="background:#6e7681"><i data-lucide="zap"></i> PDU</button>
<button onclick="quickAdd('Cisco','UCS C240 M7','server',2)" style="background:#00529a"><i data-lucide="server"></i> Cisco UCS</button>
<button onclick="quickAdd('Supermicro','SYS-420GP','server',4)" style="background:#e6a817"><i data-lucide="server"></i> SuperMicro</button>
</div>
</div>
<div class="sidebar-card">
<h3><i data-lucide="edit-3"></i> Custom Device</h3>
<form action="/racks/<%= rack.id %>/devices" method="POST" id="addForm">
<div class="form-row">
<div><label>Name *</label><input type="text" name="name" id="fName" required placeholder="WEB-01"></div>
<div class="field"><label>Type</label><select name="device_type" id="fType">
<option value="server">Server</option><option value="switch">Switch</option><option value="storage">Storage</option>
<option value="firewall">Firewall</option><option value="router">Router</option><option value="loadbalancer">Load Balancer</option>
<option value="san">SAN Switch</option><option value="patch_panel">Patch Panel</option>
<option value="pdu">PDU</option><option value="ups">UPS</option><option value="cooling">Koeling</option>
<option value="tape">Tape Library</option><option value="kvm">KVM</option><option value="other">Other</option>
</select></div>
</div>
<div class="form-row">
<div><label>Vendor</label><select name="manufacturer" id="fVendor">
<option value="">— Vendor —</option>
<% ['HPE','Dell','Lenovo','Cisco','Juniper','Arista','NetApp','Pure Storage','Fortinet','F5','Palo Alto','Supermicro','NVIDIA','APC','Oracle','IBM','Other'].forEach(function(v){ %>
<option value="<%= v %>"><%= v %></option>
<% }) %>
</select></div>
<div><label>Model</label><input type="text" name="model" id="fModel" placeholder="ProLiant DL380 Gen11"></div>
</div>
<div class="form-row">
<div><label>Positie (U) *</label><input type="number" name="position_u" id="fPos" required min="1" max="<%= rack.total_units %>" placeholder="1-42"></div>
<div><label>Hoogte (U)</label><input type="number" name="height_u" id="fHeight" value="1" min="1" max="20"></div>
</div>
<div class="form-row">
<div><label>Mgmt IP</label><input type="text" name="mgmt_ip" id="fIp" placeholder="10.0.0.10"></div>
<div><label>Serial / Asset</label><input type="text" name="serial" id="fSerial" placeholder="SN1234567"></div>
</div>
<div class="form-row full"><label>Specs</label><input type="text" name="specs" id="fSpecs" placeholder="CPU: Xeon 16C, RAM: 256GB, NVMe 4x1.92TB"></div>
<div class="form-row full"><label>Notities</label><textarea name="notes" id="fNotes" rows="1"></textarea></div>
<button type="submit" class="btn-rack primary full" style="margin-top:.35rem"><i data-lucide="plus"></i> Device Toevoegen</button>
</form>
</div>
</div>
<!-- ── Tab: Library ── -->
<div class="tab-content" id="tab-library">
<div class="sidebar-card">
<h3><i data-lucide="search"></i> Device Library</h3>
<div class="search-box">
<i data-lucide="search"></i>
<input type="text" id="libSearch" placeholder="Search library..." oninput="filterLib(this.value)">
</div>
<div class="lib-filters" style="display:flex;gap:.35rem;margin-bottom:.5rem;flex-wrap:wrap">
<select id="libVendorFilter" onchange="filterLib(document.getElementById('libSearch').value)" style="flex:1;min-width:80px;background:var(--d-bg);border:1px solid var(--d-border);border-radius:4px;padding:.2rem .3rem;color:var(--d-text);font-size:.7rem">
<option value="">Alle Vendors</option>
<% var vendors = [...new Set(hardwareLibrary.map(function(h){return h.vendor}))].sort(); %>
<% vendors.forEach(function(v){ %>
<option value="<%= v %>"><%= v %></option>
<% }) %>
</select>
<select id="libTypeFilter" onchange="filterLib(document.getElementById('libSearch').value)" style="flex:1;min-width:70px;background:var(--d-bg);border:1px solid var(--d-border);border-radius:4px;padding:.2rem .3rem;color:var(--d-text);font-size:.7rem">
<option value="">Alle Types</option>
<option value="server">Server</option>
<option value="storage">Storage</option>
<option value="switch">Switch</option>
<option value="firewall">Firewall</option>
<option value="router">Router</option>
<option value="loadbalancer">Load Balancer</option>
<option value="san">SAN</option>
<option value="ups">UPS</option>
<option value="pdu">PDU</option>
<option value="tape">Tape</option>
<option value="patch_panel">Patch Panel</option>
<option value="cooling">Koeling</option>
<option value="kvm">KVM</option>
<option value="other">Other</option>
</select>
</div>
<div class="lib-grid" id="libGrid">
<% var iconMap = {server:'server',switch:'git-branch',storage:'database',firewall:'shield',router:'radio',loadbalancer:'scale',san:'hard-drive',patch_panel:'cable',pdu:'zap',ups:'battery-charging',cooling:'fan',tape:'archive',kvm:'monitor',other:'box'}; %>
<% hardwareLibrary.forEach(function(h){ %>
<% var li = iconMap[h.type]||'box'; %>
<div class="lib-item" data-vendor="<%= h.vendor %>" data-type="<%= h.type %>" onclick="quickAdd('<%= h.vendor %>','<%= h.model.replace(/'/g,"\\'") %>','<%= h.type %>',<%= h.height %>)">
<i data-lucide="<%= li %>" class="di-<%= h.type %>"></i>
<div class="li-name"><%= h.vendor.split(' ')[0] %> <%= h.model %></div>
<div class="li-detail"><%= h.height > 0 ? h.height + 'U' : '0U' %></div>
</div>
<% }) %>
<div class="lib-item" onclick="quickAdd('','Patch Panel 48-port','patch_panel',1)" data-vendor="" data-type="patch_panel"><i data-lucide="cable" class="di-patch_panel"></i><div class="li-name">Patch Panel 48</div><div class="li-detail">1U</div></div>
<div class="lib-item" onclick="quickAdd('','Generic Server','server',1)" data-vendor="" data-type="server"><i data-lucide="server" class="di-server"></i><div class="li-name">Generic Server</div><div class="li-detail">1U</div></div>
<div class="lib-item" onclick="quickAdd('','Generic Switch','switch',1)" data-vendor="" data-type="switch"><i data-lucide="git-branch" class="di-switch"></i><div class="li-name">Generic Switch</div><div class="li-detail">1U</div></div>
</div>
</div>
</div>
<!-- ── Tab: Device List ── -->
<div class="tab-content" id="tab-list">
<div class="sidebar-card" style="margin-bottom:0">
<h3><i data-lucide="list"></i> Device List</h3>
<div class="search-box">
<i data-lucide="search"></i>
<input type="text" placeholder="Search devices..." oninput="filterDevices(this.value)">
</div>
<% if (devices.length === 0) { %>
<div class="empty-state"><i data-lucide="package-open"></i>Geen devices in deze rack.</div>
<% } else { %>
<div class="dlist" id="deviceList">
<% devices.sort((a,b)=>b.position_u-a.position_u).forEach(function(d) {
var vc='#555d66';
var vm=(d.manufacturer||'').toLowerCase();
if(vm.includes('hpe')) vc='#ff7b00'; else if(vm.includes('dell')) vc='#0076a8';
else if(vm.includes('lenovo')) vc='#e41f33'; else if(vm.includes('cisco')) vc='#00529a';
else if(vm.includes('juniper')) vc='#44a833'; else if(vm.includes('arista')) vc='#cc3333';
else if(vm.includes('netapp')) vc='#00a1df'; else if(vm.includes('pure')) vc='#4a90d9';
else if(vm.includes('fortinet')) vc='#ee3124'; else if(vm.includes('apc')) vc='#6e7681';
else if(vm.includes('f5')) vc='#d32f2f'; else if(vm.includes('palo')) vc='#ed7b30';
else if(vm.includes('nvidia')) vc='#76b900'; else if(vm.includes('oracle')) vc='#f80000';
else if(vm.includes('ibm')) vc='#0062ff';
var di='server';
if(d.device_type==='switch') di='git-branch'; else if(d.device_type==='storage') di='database';
else if(d.device_type==='firewall') di='shield'; else if(d.device_type==='router') di='radio';
else if(d.device_type==='loadbalancer') di='scale'; else if(d.device_type==='san') di='hard-drive';
else if(d.device_type==='patch_panel') di='cable'; else if(d.device_type==='pdu') di='zap';
else if(d.device_type==='ups') di='battery-charging'; else if(d.device_type==='cooling') di='fan';
else if(d.device_type==='tape') di='archive'; else if(d.device_type==='kvm') di='monitor';
else if(d.device_type==='other') di='box';
%>
<div class="dl-item" onclick="openModal(<%= d.id %>)">
<div class="dl-color" style="background:<%= vc %>"></div>
<i data-lucide="<%= di %>" class="di-<%= d.device_type %>"></i>
<div class="dl-name"><%= d.name %></div>
<div class="dl-model"><%= d.model || d.manufacturer || '' %></div>
<span class="badge <%= d.status||'active' %>"><%= d.status||'active' %></span>
<div class="dl-u"><%= d.position_u %>U</div>
</div>
<% }) %>
</div>
<% } %>
</div>
</div>
</div><!-- /sidebar -->
</div><!-- /rack-layout -->
</div><!-- /container -->
<!-- ── Device Modal ── -->
<% devices.forEach(function(d) { %>
<% var vm=(d.manufacturer||'').toLowerCase(); %>
<% var vb='#555d66'; %>
<% if(vm.includes('hpe')) vb='#ff7b00'; else if(vm.includes('dell')) vb='#0076a8';
else if(vm.includes('lenovo')) vb='#e41f33'; else if(vm.includes('cisco')) vb='#00529a';
else if(vm.includes('juniper')) vb='#44a833'; else if(vm.includes('arista')) vb='#cc3333';
else if(vm.includes('netapp')) vb='#00a1df'; else if(vm.includes('pure')) vb='#4a90d9';
else if(vm.includes('fortinet')) vb='#ee3124'; else if(vm.includes('apc')) vb='#6e7681'; %>
<% var di='server'; %>
<% if(d.device_type==='switch') di='git-branch'; else if(d.device_type==='storage') di='database';
else if(d.device_type==='firewall') di='shield'; else if(d.device_type==='router') di='radio';
else if(d.device_type==='loadbalancer') di='scale'; else if(d.device_type==='san') di='hard-drive';
else if(d.device_type==='patch_panel') di='cable'; else if(d.device_type==='pdu') di='zap';
else if(d.device_type==='ups') di='battery-charging'; else if(d.device_type==='cooling') di='fan';
else if(d.device_type==='tape') di='archive'; else if(d.device_type==='kvm') di='monitor';
else if(d.device_type==='other') di='box'; %>
<div class="modal-overlay" id="modal-<%= d.id %>">
<div class="modal-box">
<div class="modal-head">
<h3><i data-lucide="<%= di %>" class="di-<%= d.device_type %>"></i><%= d.name %></h3>
<button class="modal-close" onclick="closeModal(<%= d.id %>)">×</button>
</div>
<form action="/racks/devices/<%= d.id %>/edit" method="POST">
<div class="modal-body">
<div style="display:flex;gap:.75rem;margin-bottom:.65rem">
<div class="dev-avatar" style="background:<%= vb %>"><i data-lucide="<%= di %>"></i></div>
<div style="flex:1">
<div style="font-size:.85rem;font-weight:600;color:var(--d-text)"><%= d.name %></div>
<div style="font-size:.65rem;color:var(--d-muted)"><%= d.manufacturer||'' %> <%= d.model||'' %> · <%= d.device_type %></div>
<div style="margin-top:.2rem"><span class="badge <%= d.status||'active' %>"><%= d.status||'active' %></span></div>
</div>
</div>
<div class="form-row">
<div><label>Naam</label><input type="text" name="name" value="<%= d.name %>"></div>
<div><label>Type</label><select name="device_type">
<option value="server" <%= d.device_type==='server'?'selected':'' %>>Server</option>
<option value="switch" <%= d.device_type==='switch'?'selected':'' %>>Switch</option>
<option value="storage" <%= d.device_type==='storage'?'selected':'' %>>Storage</option>
<option value="firewall" <%= d.device_type==='firewall'?'selected':'' %>>Firewall</option>
<option value="router" <%= d.device_type==='router'?'selected':'' %>>Router</option>
<option value="loadbalancer" <%= d.device_type==='loadbalancer'?'selected':'' %>>Load Balancer</option>
<option value="san" <%= d.device_type==='san'?'selected':'' %>>SAN Switch</option>
<option value="patch_panel" <%= d.device_type==='patch_panel'?'selected':'' %>>Patch Panel</option>
<option value="pdu" <%= d.device_type==='pdu'?'selected':'' %>>PDU</option>
<option value="ups" <%= d.device_type==='ups'?'selected':'' %>>UPS</option>
<option value="cooling" <%= d.device_type==='cooling'?'selected':'' %>>Koeling</option>
<option value="tape" <%= d.device_type==='tape'?'selected':'' %>>Tape Library</option>
<option value="kvm" <%= d.device_type==='kvm'?'selected':'' %>>KVM</option>
<option value="other" <%= d.device_type==='other'?'selected':'' %>>Other</option>
</select></div>
</div>
<div class="form-row">
<div><label>Vendor</label><select name="manufacturer">
<option value="">— Vendor —</option>
<% ['HPE','Dell','Lenovo','Cisco','Juniper','Arista','NetApp','Pure Storage','Fortinet','F5','Palo Alto','Supermicro','NVIDIA','APC','Oracle','IBM','Other'].forEach(function(v){ %>
<option value="<%= v %>" <%= d.manufacturer===v?'selected':'' %>><%= v %></option>
<% }) %>
</select></div>
<div><label>Model</label><input type="text" name="model" value="<%= d.model||'' %>"></div>
</div>
<div class="form-row">
<div><label>Positie (U)</label><input type="number" name="position_u" value="<%= d.position_u %>" min="1" max="<%= rack.total_units %>"></div>
<div><label>Hoogte (U)</label><input type="number" name="height_u" value="<%= d.height_u %>" min="1" max="20"></div>
</div>
<div class="form-row">
<div><label>Mgmt IP</label><input type="text" name="mgmt_ip" value="<%= d.mgmt_ip||'' %>"></div>
<div><label>Serial / Asset</label><input type="text" name="serial" value="<%= d.serial||'' %>" placeholder="SN..."></div>
</div>
<div class="form-row"><div><label>Status</label><select name="status">
<option value="active" <%= d.status==='active'?'selected':'' %>>Active</option>
<option value="standby" <%= d.status==='standby'?'selected':'' %>>Standby</option>
<option value="decommissioned" <%= d.status==='decommissioned'?'selected':'' %>>Decommissioned</option>
</select></div></div>
<div class="form-row full"><label>Specs</label><input type="text" name="specs" value="<%= d.specs||'' %>" placeholder="CPU: Xeon, RAM: 256GB..."></div>
<div class="form-row full"><label>Notities</label><textarea name="notes" rows="2"><%= d.notes||'' %></textarea></div>
</div>
<div class="modal-foot">
<button type="button" class="btn-rack secondary small" onclick="closeModal(<%= d.id %>)">Annuleren</button>
<button type="submit" class="btn-rack primary small"><i data-lucide="save"></i> Opslaan</button>
<button type="button" class="btn-rack danger small" onclick="if(confirm('Device verwijderen?')){var f=document.createElement('form');f.method='POST';f.action='/racks/devices/<%= d.id %>/delete';document.body.appendChild(f);f.submit()}"><i data-lucide="trash-2"></i> Verwijder</button>
</div>
</form>
</div>
</div>
<% }); %>
<script>
// ── Initialize Lucide Icons ──
lucide.createIcons();
// ── Tab switching ──
document.querySelectorAll('.rack-tab').forEach(function(b){
b.addEventListener('click',function(){
document.querySelectorAll('.rack-tab').forEach(function(x){x.classList.remove('active')});
document.querySelectorAll('.tab-content').forEach(function(x){x.classList.remove('active')});
this.classList.add('active');
document.getElementById('tab-'+this.dataset.tab).classList.add('active');
lucide.createIcons();
});
});
var hash=window.location.hash.replace('#','');
if(hash&&document.getElementById('tab-'+hash)){
var tb=document.querySelector('.rack-tab[data-tab="'+hash+'"]');
if(tb) tb.click();
}
// ── View switch (front/rear) ──
function switchView(v){
document.querySelectorAll('.view-toggle button').forEach(function(x){x.classList.remove('active')});
document.querySelector('.view-toggle button[data-view="'+v+'"]').classList.add('active');
if(v==='rear'){
document.querySelectorAll('.rack-device').forEach(function(d){d.style.opacity='.7'});
} else {
document.querySelectorAll('.rack-device').forEach(function(d){d.style.opacity='1'});
}
}
// ── Quick Add ──
function quickAdd(vendor, model, type, height) {
document.getElementById('fName').value = vendor ? vendor.split(' ')[0] + '-' + model.split(' ').pop() : model.split(' ')[0];
document.getElementById('fType').value = type;
document.getElementById('fVendor').value = vendor;
document.getElementById('fModel').value = model;
document.getElementById('fHeight').value = height;
document.getElementById('fPos').focus();
var tb=document.querySelector('.rack-tab[data-tab="add"]');
if(tb) tb.click();
}
// ── Filter devices list ──
function filterDevices(q){
q=q.toLowerCase();
document.querySelectorAll('.dl-item').forEach(function(el){
el.style.display=el.textContent.toLowerCase().includes(q)?'flex':'none';
});
}
// ── Filter library ──
function filterLib(q){
q=q.toLowerCase();
var vf=document.getElementById('libVendorFilter').value;
var tf=document.getElementById('libTypeFilter').value;
document.querySelectorAll('.lib-item').forEach(function(el){
var match=true;
if(q && !el.textContent.toLowerCase().includes(q)) match=false;
if(vf && el.getAttribute('data-vendor')!==vf) match=false;
if(tf && el.getAttribute('data-type')!==tf) match=false;
el.style.display=match?'flex':'none';
});
}
// ── Modal ──
function openModal(id){
var m=document.getElementById('modal-'+id);
if(m) m.classList.add('open');
}
function closeModal(id){
var m=document.getElementById('modal-'+id);
if(m) m.classList.remove('open');
}
document.querySelectorAll('.modal-overlay').forEach(function(m){
m.addEventListener('click',function(e){
if(e.target===this) this.classList.remove('open');
});
});
// ── Drag & Drop ──
var dragEl=null, dragOrigU=0;
document.querySelectorAll('.rack-device').forEach(function(el){
el.addEventListener('dragstart',function(e){
dragEl=this; dragOrigU=parseInt(this.dataset.u);
this.classList.add('dragging');
e.dataTransfer.effectAllowed='move';
e.dataTransfer.setData('text/plain',this.dataset.id);
});
el.addEventListener('dragend',function(){this.classList.remove('dragging');dragEl=null;});
});
document.querySelectorAll('.rack-u.empty').forEach(function(el){
el.addEventListener('dragover',function(e){e.preventDefault();this.style.background='rgba(255,121,198,.1)'});
el.addEventListener('dragleave',function(){this.style.background=''});
el.addEventListener('drop',function(e){
e.preventDefault(); this.style.background='';
var targetU=parseInt(this.dataset.u), devId=dragEl.dataset.id, devH=parseInt(dragEl.dataset.h);
var newPos=targetU;
if(newPos<1) newPos=1;
if(newPos+devH-1><%= rack.total_units %>) newPos=<%= rack.total_units %>-devH+1;
if(newPos===dragOrigU) return;
var f=document.createElement('form'); f.method='POST'; f.action='/racks/devices/'+devId+'/edit';
f.innerHTML='<input name="position_u" value="'+newPos+'"><input name="height_u" value="'+devH+'"><input name="_redirect" value="/racks/<%= rack.id %>">';
document.body.appendChild(f); f.submit();
});
});
// ── Export SVG ──
<%
// Build SVG server-side
var _svgunits = [];
devices.forEach(function(d) { for(var u = d.position_u; u < d.position_u + d.height_u; u++) _svgunits[u] = d; });
var _svg = '<svg xmlns="http://www.w3.org/2000/svg" width="280" height="'+(rack.total_units*30+60)+'" viewBox="0 0 280 '+(rack.total_units*30+60)+'">';
_svg += '<rect width="280" height="'+(rack.total_units*30+60)+'" fill="#1e1f2b"/>';
_svg += '<text x="140" y="20" text-anchor="middle" fill="#f8f8f2" font-family="Inter,sans-serif" font-size="12" font-weight="600">'+rack.name+' · '+rack.total_units+'U</text>';
_svg += '<rect x="10" y="30" width="260" height="'+(rack.total_units*30)+'" fill="#282a36" stroke="#44475a" stroke-width="2" rx="4"/>';
for(var u = rack.total_units; u >= 1; u--) {
var dev = _svgunits[u];
if (dev && u === dev.position_u) {
var vm=(dev.manufacturer||'').toLowerCase(), c='#555d66';
if(vm.includes('hpe')) c='#ff7b00'; else if(vm.includes('dell')) c='#0076a8';
else if(vm.includes('lenovo')) c='#e41f33'; else if(vm.includes('cisco')) c='#00529a';
else if(vm.includes('juniper')) c='#44a833'; else if(vm.includes('arista')) c='#cc3333';
else if(vm.includes('netapp')) c='#00a1df'; else if(vm.includes('pure')) c='#4a90d9';
else if(vm.includes('fortinet')) c='#ee3124'; else if(vm.includes('apc')) c='#6e7681';
else if(vm.includes('f5')) c='#d32f2f'; else if(vm.includes('palo')) c='#ed7b30';
else if(vm.includes('nvidia')) c='#76b900';
var y=30+(rack.total_units-dev.position_u-dev.height_u+1)*30;
_svg += '<rect x="24" y="'+y+'" width="232" height="'+(dev.height_u*30-4)+'" fill="'+c+'" rx="3"/>';
_svg += '<text x="32" y="'+(y+dev.height_u*15+1)+'" fill="#fff" font-family="Inter,sans-serif" font-size="10" font-weight="600">'+dev.name+'</text>';
_svg += '<text x="240" y="'+(y+dev.height_u*15+1)+'" text-anchor="end" fill="rgba(255,255,255,.5)" font-family="Inter,sans-serif" font-size="8">'+dev.height_u+'U</text>';
} else if (!dev) {
var yy=30+(rack.total_units-u)*30;
_svg += '<rect x="12" y="'+yy+'" width="236" height="30" fill="#1e1f2b" stroke="#44475a" stroke-width=".5" opacity=".3"/>';
_svg += '<text x="20" y="'+(yy+19)+'" fill="#6272a4" font-family="monospace" font-size="8">'+u+'</text>';
}
}
_svg += '</svg>';
%>
var _svgContent = <%- JSON.stringify(_svg) %>;
function exportSVG(){
var blob=new Blob([_svgContent],{type:'image/svg+xml'}); var url=URL.createObjectURL(blob);
var a=document.createElement('a');a.href=url;a.download='<%= rack.name.replace(/[^a-zA-Z0-9]/g, '_') %>.svg';
document.body.appendChild(a);a.click();document.body.removeChild(a); URL.revokeObjectURL(url);
}
</script>
<script src="/js/theme.js"></script></body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Rapport — <%= answers.company_name || 'Mek-Tech' %></title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{--bg:#fff;--bg-card:#f6f8fa;--border:#d0d7de;--text:#1f2328;--text-muted:#656d76;--accent:#0969da;--green:#1a7f37;--purple:#8250df;--red:#cf222e;--cyan:#0550ae}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',-apple-system,sans-serif;background:#fff;color:#000;line-height:1.5;padding:2rem;max-width:800px;margin:0 auto}
h1{font-size:1.5rem;margin-bottom:.25rem;color:#000}
h2{font-size:1.1rem;color:var(--accent);border-bottom:2px solid var(--accent);display:inline-block;margin-bottom:.75rem;padding-bottom:.25rem}
h3{font-size:1rem;margin-bottom:.35rem}
p{color:#333;font-size:.85rem;margin-bottom:.5rem}
table{width:100%;border-collapse:collapse;margin-bottom:1rem}
td{padding:.35rem .5rem;border-bottom:1px solid #e8ecef;font-size:.8rem}
td:first-child{width:35%;color:#656d76}
.header{text-align:center;padding-bottom:1rem;border-bottom:2px solid #d0d7de;margin-bottom:1.5rem}
.header h1{font-size:1.8rem;color:#000}
.header p{color:#656d76;font-size:.85rem}
.section{margin-bottom:1.25rem;page-break-inside:avoid}
.card{background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:.75rem;margin-bottom:.75rem}
.card-grid{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}
.badge{display:inline-block;padding:.1rem .5rem;border-radius:999px;font-size:.7rem;font-weight:500}.badge.Starter{background:#dafbe1;color:#1a7f37}.badge.Standard{background:#ddf4ff;color:#0550ae}.badge.Advanced{background:#fbefff;color:#8250df}.badge.Enterprise{background:#ffebe9;color:#cf222e}
.phase{background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:.75rem;margin-bottom:.5rem}
.phase h4{color:#8250df;margin-bottom:.35rem;font-size:.9rem}
.phase ul{list-style:none}
.phase li{padding:.15rem 0;font-size:.8rem}
.phase li::before{content:'→ ';color:#0969da}
.tech-item{display:flex;justify-content:space-between;padding:.35rem .5rem;border-bottom:1px solid #e8ecef;font-size:.8rem}
.tech-item:last-child{border-bottom:none}
.tech-label{color:#656d76}.tech-val{font-weight:500}
.footer{text-align:center;padding-top:1rem;border-top:1px solid #d0d7de;margin-top:1.5rem;color:#656d76;font-size:.75rem}
@media print{body{padding:0;max-width:100%}.no-print{display:none!important}}
</style></head><body>
<div class="no-print" style="margin-bottom:1rem"><button onclick="window.print()" style="padding:.5rem 1rem;background:#0969da;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:.85rem">🖨 Print / PDF</button></div>
<div class="header"><h1>AI & Data Assessment Rapport</h1><p><%= answers.company_name || 'Bedrijf' %> · <%= new Date().toLocaleDateString('nl-NL',{year:'numeric',month:'long',day:'numeric'}) %></p></div>
<h2>Architectuur Advies</h2>
<div class="section">
<h3>Data Lake</h3>
<div class="card"><div class="card-grid">
<div class="tech-item"><span class="tech-label">Tier</span><span class="badge <%= report.architecture.datalake.tier %>"><%= report.architecture.datalake.tier %></span></div>
<div class="tech-item"><span class="tech-label">Storage</span><span class="tech-val"><%= report.architecture.datalake.storage %></span></div>
<div class="tech-item"><span class="tech-label">Compute</span><span class="tech-val"><%= report.architecture.datalake.compute %></span></div>
<div class="tech-item"><span class="tech-label">Catalog</span><span class="tech-val"><%= report.architecture.datalake.catalog %></span></div>
<div class="tech-item"><span class="tech-label">Processing</span><span class="tech-val"><%= report.architecture.datalake.processing %></span></div>
<div class="tech-item"><span class="tech-label">Governance</span><span class="tech-val"><%= report.architecture.datalake.governance %></span></div>
</div></div>
</div>
<div class="section">
<h3>AI/ML Platform</h3>
<div class="card"><div class="card-grid">
<div class="tech-item"><span class="tech-label">Tier</span><span class="badge <%= report.architecture.ai.tier %>"><%= report.architecture.ai.tier %></span></div>
<div class="tech-item"><span class="tech-label">Serving</span><span class="tech-val"><%= report.architecture.ai.serving %></span></div>
<div class="tech-item"><span class="tech-label">Orchestration</span><span class="tech-val"><%= report.architecture.ai.orchestration %></span></div>
<div class="tech-item"><span class="tech-label">Monitoring</span><span class="tech-val"><%= report.architecture.ai.monitoring %></span></div>
<div class="tech-item"><span class="tech-label">Modellen</span><span class="tech-val"><%= report.architecture.ai.models %></span></div>
</div></div>
</div>
<h2>Implementatie Roadmap</h2>
<% report.phases.forEach(function(p){ %>
<div class="phase"><h4><%= p.name %></h4><ul><% p.items.forEach(function(i){ %><li><%= i %></li><% }) %></ul></div>
<% }) %>
<div class="footer">
<p>Gegenereerd door Mek-Tech Consulting Framework · Vertrouwelijk</p>
</div>
</body></html>
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Architectuur Rapport - <%= answers.company_name || 'Mek-Tech Consultancy' %></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" type="image/svg+xml">
</head>
<body>
<div class="container">
<header class="report-header">
<div class="report-org">
<img src="/img/logo.svg" style="height:48px;width:auto;margin-bottom:.25rem" alt="Mek-Tech">
<h1>Mek-Tech</h1>
<p>AI Consultancy & Forward Engineering</p>
</div>
<div class="report-title-block">
<h2>Datacenter & AI Architectuur Rapport</h2>
<p class="report-client">Opgesteld voor: <strong><%= answers.company_name %></strong></p>
<p class="report-date"><%= new Date().toLocaleDateString('nl-NL', { year: 'numeric', month: 'long', day: 'numeric' }) %></p>
</div>
</header>
<section class="section">
<h3 class="section-title">1. Huidige Situatie Analyse</h3>
<div class="card">
<table class="info-table">
<tr><td><strong>Bedrijf</strong></td><td><%= answers.company_name %></td></tr>
<tr><td><strong>Sector</strong></td><td><%= answers.industry %></td></tr>
<tr><td><strong>Servers</strong></td><td><%= answers.servers || 'Niet gespecificeerd' %></td></tr>
<tr><td><strong>Opslag</strong></td><td><%= answers.storage || 'Niet gespecificeerd' %></td></tr>
<tr><td><strong>Virtualisatie</strong></td><td><%= answers.virtualization || 'Niet gespecificeerd' %></td></tr>
<tr><td><strong>Data volume</strong></td><td><%= answers.data_volume || 'Niet gespecificeerd' %></td></tr>
<tr><td><strong>AI doelen</strong></td><td><%= answers.ai_goals || 'Niet gespecificeerd' %></td></tr>
</table>
</div>
</section>
<section class="section">
<h3 class="section-title">2. Voorgestelde Architecture</h3>
<div class="diagram">
<pre class="ascii-diagram">
┌─────────────────────────────────────────────────────────────────┐
│ DATA LAKEHOUSE ARCHITECTUUR │
├─────────────────────────────────────────────────────────────────┤
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Bronnen │ │ Ingest │ │ Opslag │ │ Compute │ │
│ │ │ │ │ │ │ │ │ │
│ │• OLTP │ │• Kafka │ │• MinIO │ │• Spark │ │
│ │• API's │──│• Flink │──│• Iceberg │──│• Trino │ │
│ │• Streaming│ │• Airbyte │ │• Hive │ │• Flink │ │
│ │• IoT │ │• CDC │ │• S3/ADLS │ │• Dremio │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
│ │ │
│ ┌──────────────────────────────────┴──────────────────────┐ │
│ │ AI/ML LAAG │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │
│ │ │ Ollama/ │ │ vLLM/ │ │ MLflow/ │ │ Kubeflow│ │ │
│ │ │ Lama.cpp │ │ SGLang │ │ Evidently│ │ │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ DATACENTER INFRASTRUCTUUR │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │
│ │ │ K8s/ │ │ Leaf- │ │ Zero │ │ Observa-│ │ │
│ │ │ Proxmox │ │ Spine │ │ Trust │ │ bility │ │ │
│ │ │ Cluster │ │ 100GbE │ │ Network │ │ Stack │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
</pre>
</div>
</section>
<section class="section">
<h3 class="section-title">3. Data Lakehouse Ontwerp (<%= report.architecture.datalake.tier %> Tier)</h3>
<div class="card-grid">
<div class="card"><strong>Opslag</strong><br><%= report.architecture.datalake.storage %></div>
<div class="card"><strong>Compute</strong><br><%= report.architecture.datalake.compute %></div>
<div class="card"><strong>Catalogus</strong><br><%= report.architecture.datalake.catalog %></div>
<div class="card"><strong>Processing</strong><br><%= report.architecture.datalake.processing %></div>
<div class="card"><strong>Governance</strong><br><%= report.architecture.datalake.governance %></div>
<div class="card"><strong>Tier</strong><br><span class="badge <%= report.architecture.datalake.tier %>"><%= report.architecture.datalake.tier %></span></div>
</div>
</section>
<section class="section">
<h3 class="section-title">4. AI Backend (<%= report.architecture.ai.tier %> Tier)</h3>
<div class="card-grid">
<div class="card"><strong>Model Serving</strong><br><%= report.architecture.ai.serving %></div>
<div class="card"><strong>Orchestratie</strong><br><%= report.architecture.ai.orchestration %></div>
<div class="card"><strong>Monitoring</strong><br><%= report.architecture.ai.monitoring %></div>
<div class="card"><strong>Modellen</strong><br><%= report.architecture.ai.models %></div>
<div class="card"><strong>Tier</strong><br><span class="badge <%= report.architecture.ai.tier %>"><%= report.architecture.ai.tier %></span></div>
</div>
</section>
<section class="section">
<h3 class="section-title">5. Implementatie Roadmap</h3>
<% report.phases.forEach(function(phase) { %>
<div class="phase">
<h4><%= phase.name %></h4>
<ul>
<% phase.items.forEach(function(item) { %>
<li><%= item %></li>
<% }) %>
</ul>
</div>
<% }) %>
</section>
<section class="section">
<h3 class="section-title">6. Aanbevolen Tech Stack</h3>
<div class="tech-stack">
<div class="tech-item"><span class="tech-label">Virtualisatie</span><span class="tech-val"><%= report.architecture.infrastructure.virtualization %></span></div>
<div class="tech-item"><span class="tech-label">Netwerk</span><span class="tech-val"><%= report.architecture.infrastructure.network %></span></div>
<div class="tech-item"><span class="tech-label">Security</span><span class="tech-val"><%= report.architecture.infrastructure.security %></span></div>
<div class="tech-item"><span class="tech-label">Monitoring</span><span class="tech-val"><%= report.architecture.infrastructure.monitoring %></span></div>
</div>
</section>
<footer class="report-footer">
<p>Gegenereerd door <strong>Mek-Tech AI Consultancy Framework</strong> &copy; <%= new Date().getFullYear() %></p>
<p>Dit rapport is vertrouwelijk en uitsluitend bestemd voor <%= answers.company_name %>.</p>
<button class="btn btn-primary" onclick="window.print()">Download PDF</button>
</footer>
</div>
</body>
</html>
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Reports — Mek-Tech Enterprise</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet"><link rel="stylesheet" href="/css/style.css"><link rel="stylesheet" href="/css/neon.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{--sb-w:210px;--hdr-h:50px}
html,body{height:100%;margin:0;padding:0;scroll-behavior:smooth}
body{background:var(--bg);font-family:'Inter',-apple-system,sans-serif}
.app-layout{display:flex;min-height:100vh;padding-top:var(--hdr-h)}
.sidebar{position:fixed;top:var(--hdr-h);left:0;width:var(--sb-w);height:calc(100vh - var(--hdr-h));background:var(--bg-nav);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:50;overflow-y:auto}
.main-area{flex:1;margin-left:var(--sb-w);padding:1.5rem 2rem 3rem;max-width:1400px}
.page-hdr{margin-bottom:1.5rem}.page-hdr h1{font-size:1.5rem;color:var(--text);font-weight:800;letter-spacing:-.4px}
.report-builder{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.5rem}
.report-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem}
.report-card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.25rem;transition:all .2s;text-decoration:none;color:inherit;display:block}
.report-card:hover{border-color:var(--accent);transform:translateY(-2px);box-shadow:0 8px 25px rgba(0,0,0,.1)}
.report-icon{width:48px;height:48px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--purple));display:flex;align-items:center;justify-content:center;margin-bottom:1rem}
.report-icon svg{width:24px;height:24px;color:#fff}
.report-title{font-size:1rem;font-weight:600;color:var(--text);margin-bottom:.5rem}
.report-desc{font-size:.8rem;color:var(--text-muted)}
</style>
</head>
<body>
<div class="navbar"><div class="nav-inner"><a href="/" class="nav-brand"><img src="/img/logo.svg" class="nav-logo" alt="Mek-Tech"></a><div class="nav-links"><a href="/" class="nav-link">Dashboard</a><a href="/projects" class="nav-link">Projecten</a><a href="/clients" class="nav-link">Cliënten</a><a href="/engagements" class="nav-link">Engagements</a><a href="/finance" class="nav-link">Financiën</a><a href="/market" class="nav-link">Markt</a><a href="/reports" class="nav-link active">Reports</a><a href="/settings" class="nav-link">Instellingen</a><a href="/auth/logout" class="nav-link logout">Uitloggen</a></div></div></div>
<div class="app-layout">
<div class="sidebar">
<div class="sb-brand"><h2><span class="brand-dot"></span>Mek-Tech Enterprise</h2><p>Reports & Analytics</p></div>
<div class="sb-nav">
<div class="sb-sep">Report Types</div>
<button class="sb-item active" data-tab="builder"><span class="sb-icon"><i data-lucide="file-text"></i></span>Report Builder</button>
<button class="sb-item" data-tab="saved"><span class="sb-icon"><i data-lucide="folder"></i></span>Opgeslagen</button>
<button class="sb-item" data-tab="templates"><span class="sb-icon"><i data-lucide="layout-template"></i></span>Templates</button>
</div>
</div>
<div class="main-area">
<div class="page-hdr fade-in-up"><h1>Reports</h1><p>Genereer en beheer rapportages</p></div>
<div class="tab-panel active" id="panel-builder">
<div class="report-builder">
<h3 style="margin-bottom:1rem;font-size:1rem;color:var(--text)">Report Builder</h3>
<form id="reportForm" method="POST" action="/reports/generate">
<div class="form-grid">
<div><label>Report Type</label><select name="report_type" required><option value="">Selecteer type</option><option value="client_summary">Client Summary</option><option value="project_summary">Project Summary</option><option value="financial">Financial Report</option><option value="tasks">Tasks Report</option><option value="time">Time Tracking</option></select></div>
<div><label>Cliënt</label><select name="client_id"><option value="">Alle cliënten</option><% clients.forEach(c=>{ %><option value="<%= c.id %>"><%= c.name %></option><% }) %></select></div>
<div><label>Project</label><select name="project_id"><option value="">Alle projecten</option><% projects.forEach(p=>{ %><option value="<%= p.id %>"><%= p.name %></option><% }) %></select></div>
<div><label>Van Datum</label><input type="date" name="date_from"></div>
<div><label>Tot Datum</label><input type="date" name="date_to"></div>
<div><label>Format</label><select name="format"><option value="json">JSON (Preview)</option><option value="pdf">PDF</option><option value="excel">Excel</option></select></div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Genereer Report</button>
<button type="button" class="btn btn-secondary" onclick="saveReport()">Opslaan</button>
</div>
</form>
</div>
<div style="margin-top:2rem">
<h3 style="margin-bottom:1rem;font-size:1rem;color:var(--text)">Quick Reports</h3>
<div class="report-grid">
<a href="/reports/generate?report_type=client_summary&format=pdf" class="report-card">
<div class="report-icon"><i data-lucide="users"></i></div>
<div class="report-title">Client Summary</div>
<div class="report-desc">Overzicht van alle cliënten en engagements</div>
</a>
<a href="/reports/generate?report_type=project_summary&format=pdf" class="report-card">
<div class="report-icon"><i data-lucide="folder"></i></div>
<div class="report-title">Project Summary</div>
<div class="report-desc">Project status en voortgang</div>
</a>
<a href="/reports/generate?report_type=financial&format=excel" class="report-card">
<div class="report-icon"><i data-lucide="dollar-sign"></i></div>
<div class="report-title">Financial Report</div>
<div class="report-desc">Facturen en omzet overzicht</div>
</a>
<a href="/reports/generate?report_type=time&format=excel" class="report-card">
<div class="report-icon"><i data-lucide="clock"></i></div>
<div class="report-title">Time Tracking</div>
<div class="report-desc">Urenregistratie rapportage</div>
</a>
</div>
</div>
</div>
<div class="tab-panel" id="panel-saved">
<h3 style="margin-bottom:1rem;font-size:1rem;color:var(--text)">Opgeslagen Reports</h3>
<% if(savedReports.length===0){ %><div class="empty-state">Nog geen opgeslagen reports</div><% }else{ %>
<div class="report-grid">
<% savedReports.forEach(report=>{ %>
<div class="report-card">
<div class="report-title"><%= report.name %></div>
<div class="report-desc"><%= report.report_type %> · <%= new Date(report.created_at).toLocaleDateString('nl-NL') %></div>
<div style="margin-top:.5rem">
<a href="/reports/generate?id=<%= report.id %>&format=pdf" class="btn-small">PDF</a>
<a href="/reports/generate?id=<%= report.id %>&format=excel" class="btn-small">Excel</a>
</div>
</div>
<% }) %>
</div><% } %>
</div>
<div class="tab-panel" id="panel-templates">
<h3 style="margin-bottom:1rem;font-size:1rem;color:var(--text)">Report Templates</h3>
<div class="report-grid">
<div class="report-card">
<div class="report-icon"><i data-lucide="file-text"></i></div>
<div class="report-title">Monthly Client Report</div>
<div class="report-desc">Standaard maandelijkse cliënt rapportage</div>
</div>
<div class="report-card">
<div class="report-icon"><i data-lucide="bar-chart-2"></i></div>
<div class="report-title">Project Status Report</div>
<div class="report-desc">Project voortgang en mijlpalen</div>
</div>
<div class="report-card">
<div class="report-icon"><i data-lucide="trending-up"></i></div>
<div class="report-title">Revenue Report</div>
<div class="report-desc">Omzet en facturatie overzicht</div>
</div>
</div>
</div>
</div></div>
<script>
document.querySelectorAll('.sb-item').forEach(function(b){
b.addEventListener('click',function(){
var t=this.dataset.tab;
document.querySelectorAll('.sb-item').forEach(function(x){x.classList.remove('active')});
document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});
this.classList.add('active');
document.getElementById('panel-'+t).classList.add('active');
});
});
function saveReport(){
const form=document.getElementById('reportForm');
const formData=new FormData(form);
const config=Object.fromEntries(formData);
fetch('/reports/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'Custom Report',config})}).then(()=>alert('Report opgeslagen'));
}
lucide.createIcons();
</script>
</body></html>
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Wachtwoord resetten — Mek-Tech</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&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">
<style>
html,body{height:100%;margin:0;padding:0}
body{display:flex;align-items:center;justify-content:center;background:var(--bg);position:relative;overflow:hidden;font-family:'Inter',-apple-system,sans-serif}
body::before{content:'';position:fixed;inset:0;z-index:0;background:radial-gradient(ellipse 80% 60% at 50% -30%,rgba(88,166,255,.06) 0%,transparent 55%),radial-gradient(ellipse 55% 45% at 85% 85%,rgba(130,80,223,.05) 0%,transparent 50%)}
.login-wrap{position:relative;z-index:1;width:100%;max-width:400px;padding:1.5rem}
.login-card{background:var(--bg-card);border:1px solid var(--border);border-radius:14px;padding:2.25rem 2rem;position:relative;overflow:hidden}
.login-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--accent),var(--purple),var(--green));background-size:200% 100%;animation:gradientShift 4s ease infinite}
.login-logo{text-align:center;margin-bottom:1.75rem}
.login-logo img{height:40px;width:auto;margin-bottom:.25rem;filter:drop-shadow(0 0 16px rgba(88,166,255,.12))}
.login-logo .tagline{font-size:.7rem;color:var(--text-dim);letter-spacing:.5px}
.field{margin-bottom:1rem}
.field label{display:block;font-size:.68rem;font-weight:600;color:var(--text-muted);margin-bottom:.3rem;text-transform:uppercase;letter-spacing:.4px}
.field input{width:100%;padding:.55rem .75rem;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.85rem;font-family:inherit;transition:all .2s ease}
.field input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(88,166,255,.1)}
.btn-primary{width:100%;padding:.6rem;background:linear-gradient(135deg,var(--accent),#4d8fd9);color:#fff;border:none;border-radius:8px;font-size:.82rem;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s}
.btn-primary:hover{box-shadow:0 4px 16px rgba(88,166,255,.25);transform:translateY(-1px)}
.alert{font-size:.72rem;padding:.45rem .7rem;border-radius:7px;margin-bottom:1rem;font-weight:500}
.alert-error{background:var(--red-bg);border:1px solid var(--red);color:var(--red)}
</style></head><body>
<div class="login-wrap"><div class="login-card">
<div class="login-logo"><img src="/img/logo.svg" alt="Mek-Tech"><p class="tagline">Nieuw wachtwoord instellen</p></div>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<form method="POST" action="/wachtwoord-reset/<%= token %>">
<div class="field"><label>Nieuw wachtwoord</label><input type="password" name="password" placeholder="Minimaal 6 tekens" required minlength="6"></div>
<div class="field"><label>Bevestig wachtwoord</label><input type="password" name="password2" placeholder="Herhaal uw wachtwoord" required minlength="6"></div>
<button type="submit" class="btn-primary">Wachtwoord wijzigen</button>
</form>
</div></div></body></html>
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Zoeken — 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></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="container">
<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="page-header"><h1>Search</h1></div>
<form method="GET" action="/search" class="task-form-grid" style="margin-bottom:1.25rem;display:flex;gap:.5rem">
<input type="text" name="q" value="<%= query %>" placeholder="Zoek cliënten, engagements, taken, diagrammen, notities..." style="flex:1;padding:.5rem .75rem;font-size:.9rem" autofocus>
<button class="btn btn-primary" type="submit">Search</button>
</form>
<% if (query) { %>
<p style="font-size:.8rem;color:var(--text-muted);margin-bottom:1rem">Resultaten voor "<strong><%= query %></strong>"</p>
<% var hasResults = results.clients.length || results.engagements.length || results.tasks.length || results.diagrams.length || results.notes.length; %>
<% if (!hasResults) { %><div class="empty-state"><p>Geen resultaten gevonden.</p></div><% } %>
<% if (results.clients.length > 0) { %><div class="card" style="margin-bottom:.75rem"><div class="card-title">Cliënten (<%= results.clients.length %>)</div>
<% results.clients.forEach(function(c){ %><a href="/clients/<%= c.id %>" class="client-row"><span class="cr-name"><%= c.name %></span><span class="status-badge <%= c.status %>"><%= c.status %></span></a><% }) %></div><% } %>
<% if (results.engagements.length > 0) { %><div class="card" style="margin-bottom:.75rem"><div class="card-title">Engagements (<%= results.engagements.length %>)</div>
<% results.engagements.forEach(function(e){ %><a href="/engagements/<%= e.id %>" class="engagement-row"><span class="engagement-type <%= e.type %>"><%= e.type %></span><span class="engagement-title"><%= e.title %></span><span class="engagement-client"><%= e.client_name %></span></a><% }) %></div><% } %>
<% if (results.tasks.length > 0) { %><div class="card" style="margin-bottom:.75rem"><div class="card-title">Taken (<%= results.tasks.length %>)</div>
<% results.tasks.forEach(function(t){ %><div class="task-item"><span class="task-prio <%= t.priority %>"><%= t.priority %></span><div class="task-body"><span class="task-title"><a href="/engagements/<%= t.engagement_id %>" class="link"><%= t.title %></a></span></div><span class="status-badge <%= t.status %>"><%= t.status %></span></div><% }) %></div><% } %>
<% if (results.diagrams.length > 0) { %><div class="card" style="margin-bottom:.75rem"><div class="card-title">Diagrammen (<%= results.diagrams.length %>)</div>
<% results.diagrams.forEach(function(d){ %><a href="/architecture/<%= d.id %>" class="engagement-row"><span class="engagement-title"><%= d.name %></span><span class="engagement-client"><%= d.client_name %></span><span class="status-badge"><%= d.diagram_type %></span></a><% }) %></div><% } %>
<% if (results.notes.length > 0) { %><div class="card" style="margin-bottom:.75rem"><div class="card-title">Notities (<%= results.notes.length %>)</div>
<% results.notes.forEach(function(n){ %><a href="/clients/<%= n.client_id %>#notes" class="engagement-row"><span class="engagement-title" style="font-weight:400"><%= n.content.substring(0,120) %><% if (n.content.length > 120) { %>...<% } %></span><span class="engagement-client"><%= n.client_name %></span></a><% }) %></div><% } %>
<% } %></div>
<script>lucide.createIcons()</script><script src="/js/theme.js"></script></body></html>
+115
View File
@@ -0,0 +1,115 @@
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const path = require('path');
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const multer = require('multer');
const app = express();
const PORT = process.env.PORT || 3000;
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(cors());
const { localeMiddleware } = require('./locales');
app.use(localeMiddleware);
app.use(session({
store: new SQLiteStore({ db: 'sessions.db', dir: path.join(__dirname, 'data') }),
secret: process.env.SESSION_SECRET || 'mek-tech-consulting-secret',
resave: false,
saveUninitialized: false,
cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 }
}));
app.use((req, res, next) => {
const publicPaths = ['/login', '/auth/login', '/css/dynamic.css'];
if (!publicPaths.includes(req.path) && !req.session.userId) {
return res.redirect('/login');
}
res.locals.user = req.session.userId ? { username: req.session.username } : null;
next();
});
app.use((req, res, next) => {
try {
const { getSettings } = require('./db');
const settings = getSettings();
res.locals.auto_refresh = settings.auto_refresh || '0';
res.locals.settings_data = JSON.stringify(settings);
} catch (e) {
res.locals.auto_refresh = '0';
res.locals.settings_data = '{}';
}
next();
});
app.get('/css/dynamic.css', (req, res) => {
try {
const { getSettings } = require('./db');
const { THEME_PRESETS, THEME_CSS_KEYS } = require('./themes');
const settings = getSettings();
let css = '/* Mek-Tech Dynamic Settings */\n';
// Theme CSS variables
const themeKey = settings.theme || 'gitea-dark';
const preset = THEME_PRESETS[themeKey] || THEME_PRESETS['gitea-dark'];
const customColors = settings.theme_colors || {};
css += ':root{\n';
THEME_CSS_KEYS.forEach(k => {
const val = customColors[k] || preset[k];
if (val) css += `--${k.replace(/_/g,'-')}:${val};\n`;
});
css += '}\n';
// Icon colors
if (settings.icon_colors && typeof settings.icon_colors === 'object') {
for (const [icon, color] of Object.entries(settings.icon_colors)) {
css += `[data-lucide="${icon}"]{color:${color}!important}\n`;
}
}
res.type('css').send(css);
} catch (e) {
console.error('dynamic.css error:', e.message);
res.type('css').send('/* no settings */');
}
});
app.use('/', require('./routes/auth'));
app.use('/', require('./routes/dashboard'));
app.use('/', require('./routes/password'));
app.use('/clients', require('./routes/clients'));
app.use('/engagements', require('./routes/engagements'));
app.use('/tasks', require('./routes/tasks'));
app.use('/assess', require('./routes/assess'));
app.use('/racks', require('./routes/racks'));
app.use('/networking', require('./routes/networking'));
app.use('/architecture', require('./routes/architecture'));
app.use('/sizing', require('./routes/sizing'));
app.use('/infrastructure', require('./routes/infrastructure'));
app.use('/finance', require('./routes/finance'));
app.use('/settings', require('./routes/settings').router);
app.use('/users', require('./routes/users'));
app.use('/time', require('./routes/time'));
app.use('/invoices', require('./routes/invoices'));
app.use('/notifications', require('./routes/notifications').router);
app.use('/calendar', require('./routes/calendar'));
app.use('/search', require('./routes/search'));
app.use('/audit', require('./routes/audit'));
app.use('/email', require('./routes/email'));
app.use('/quality', require('./routes/quality'));
app.use('/market', require('./routes/market'));
app.use('/consulting', require('./routes/consulting'));
app.use('/api/v1', require('./routes/api'));
app.listen(PORT, '0.0.0.0', () => {
console.log(`Mek-Tech Consulting Framework running on http://0.0.0.0:${PORT}`);
});
+399
View File
@@ -0,0 +1,399 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Instellingen — Mek-Tech Enterprise</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="stylesheet" href="/css/neon.css">
<link rel="icon" href="/img/favicon.svg" type="image/svg+xml">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
body{background:var(--bg);font-family:'Inter',-apple-system,sans-serif;color:var(--text);margin:0;padding:0}
.settings-wrap{max-width:860px;margin:2rem auto;padding:0 1.5rem 3rem}
.settings-wrap h1{font-size:1.5rem;font-weight:700;margin-bottom:.15rem;display:flex;align-items:center;gap:.5rem}
.settings-wrap .sub{color:var(--text-muted);font-size:.8rem;margin-bottom:1.5rem}
.section{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:1.25rem;margin-bottom:1rem}
.section h2{font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:var(--text-muted);margin-bottom:1rem;display:flex;align-items:center;gap:.4rem}
.field{display:flex;align-items:center;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--border-light)}
.field:last-child{border-bottom:none}
.field label{min-width:140px;font-size:.82rem;color:var(--text);font-weight:500}
.field input,.field select{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:.4rem .6rem;color:var(--text);font-size:.82rem;font-family:inherit;width:220px;transition:border-color .12s}
.field input:focus,.field select:focus{outline:none;border-color:var(--accent)}
.field input[type=checkbox]{width:18px;height:18px;accent-color:var(--accent);cursor:pointer}
.field .hint{font-size:.7rem;color:var(--text-dim);margin-left:.5rem}
.btn-pri{background:var(--accent);color:#fff;border:none;padding:.5rem 1.25rem;border-radius:var(--radius);font-size:.82rem;font-weight:600;cursor:pointer;transition:opacity .12s;font-family:inherit}
.btn-pri:hover{opacity:.85}
.btn-sec{background:var(--bg);color:var(--text-muted);border:1px solid var(--border);padding:.5rem 1.25rem;border-radius:var(--radius);font-size:.82rem;cursor:pointer;transition:all .12s;font-family:inherit}
.btn-sec:hover{border-color:var(--text-dim);color:var(--text)}
.btn-del{background:var(--red-bg);color:var(--red);border:1px solid transparent;padding:.5rem 1.25rem;border-radius:var(--radius);font-size:.82rem;cursor:pointer;transition:all .12s;font-family:inherit}
.btn-del:hover{border-color:var(--red)}
.actions{display:flex;gap:.5rem;margin-top:1.5rem;flex-wrap:wrap}
.alert{background:rgba(35,134,54,.15);border:1px solid rgba(35,134,54,.3);color:var(--green);padding:.6rem .9rem;border-radius:var(--radius);font-size:.8rem;margin-bottom:1rem;display:flex;align-items:center;gap:.4rem}
.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:.35rem}
.icon-row{display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--bg);border-radius:var(--radius);border:1px solid var(--border-light)}
.icon-row svg{width:16px;height:16px;flex-shrink:0}
.icon-row .il{font-size:.75rem;color:var(--text-muted);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.icon-row input[type=color]{width:32px;height:28px;padding:0;border:1px solid var(--border);border-radius:4px;cursor:pointer;background:none;flex-shrink:0}
.icon-row .hex-val{width:70px;background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:.15rem .35rem;color:var(--text);font-size:.72rem;font-family:monospace;text-align:center}
.icon-row .hex-val:focus{outline:none;border-color:var(--accent)}
/* Theme cards */
.theme-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:.5rem;margin-bottom:1rem}
.theme-card{cursor:pointer;border:2px solid var(--border);border-radius:var(--radius);padding:.6rem;background:var(--bg);transition:all .15s;position:relative}
.theme-card:hover{border-color:var(--text-dim)}
.theme-card.active{border-color:var(--accent)}
.theme-card input[type=radio]{position:absolute;opacity:0;pointer-events:none}
.theme-card .tc-preview{height:50px;border-radius:4px;margin-bottom:.35rem;display:flex;align-items:center;justify-content:center;gap:3px;overflow:hidden}
.theme-card .tc-preview span{width:20%;height:100%;border-radius:2px}
.theme-card .tc-name{font-size:.7rem;font-weight:600;color:var(--text);text-align:center}
.theme-card .tc-mode{font-size:.6rem;color:var(--text-dim);text-align:center;text-transform:uppercase}
.theme-card.active .tc-name{color:var(--accent)}
/* Custom theme color fields */
.color-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.35rem}
.color-row{display:flex;align-items:center;gap:.4rem;padding:.25rem .4rem;background:var(--bg);border-radius:var(--radius);border:1px solid var(--border-light)}
.color-row .cl{font-size:.68rem;color:var(--text-muted);flex:1;font-family:monospace}
.color-row input[type=color]{width:28px;height:24px;padding:0;border:1px solid var(--border);border-radius:3px;cursor:pointer;background:none;flex-shrink:0}
.color-row .ch{width:62px;background:var(--bg);border:1px solid var(--border);border-radius:3px;padding:.1rem .3rem;color:var(--text);font-size:.65rem;font-family:monospace;text-align:center}
.color-row .ch:focus{outline:none;border-color:var(--accent)}
.custom-theme-toggle{margin-bottom:.65rem}
.custom-theme-toggle label{font-size:.78rem;color:var(--text-muted);cursor:pointer;display:flex;align-items:center;gap:.4rem}
.main-color-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.65rem;margin-bottom:1rem}
.main-color-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:.75rem .85rem;display:flex;flex-direction:column;gap:.35rem}
.main-color-card label{font-size:.78rem;font-weight:600;color:var(--text)}
.main-color-card .hint{font-size:.62rem;color:var(--text-dim);line-height:1.35}
.main-color-card .picker-row{display:flex;align-items:center;gap:.5rem;margin-top:.15rem}
.main-color-card input[type=color]{width:42px;height:34px;padding:2px;border:1px solid var(--border);border-radius:6px;cursor:pointer;background:var(--bg-card);flex-shrink:0}
.main-color-card .ch{flex:1;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;padding:.35rem .5rem;color:var(--text);font-size:.75rem;font-family:monospace}
.main-color-card .ch:focus{outline:none;border-color:var(--accent)}
.live-preview{border-radius:10px;overflow:hidden;border:1px solid var(--border);margin-bottom:1rem;height:72px;display:flex}
.live-preview .lp-nav{width:22%;display:flex;align-items:center;justify-content:center;font-size:.55rem;font-weight:600;color:var(--text-muted)}
.live-preview .lp-main{flex:1;padding:.5rem .65rem;display:flex;flex-direction:column;gap:.35rem}
.live-preview .lp-card{height:28px;border-radius:6px;border:1px solid var(--border);display:flex;align-items:center;padding:0 .5rem;font-size:.55rem;color:var(--text-muted)}
.live-preview .lp-accent{width:40px;height:6px;border-radius:3px;margin-top:auto}
.advanced-colors{margin-top:.75rem;border-top:1px solid var(--border-light);padding-top:.75rem}
.advanced-colors summary{font-size:.72rem;color:var(--text-muted);cursor:pointer;font-weight:600;user-select:none}
.advanced-colors summary:hover{color:var(--accent)}
@media(max-width:600px){.field{flex-wrap:wrap;gap:.3rem}.field label{min-width:100px}.field input,.field select{width:100%}}
</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="settings-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>
<h1><i data-lucide="settings"></i> Instellingen</h1>
<p class="sub">Configureer het platform naar wens.</p>
<% if (saved) { %>
<div class="alert"><i data-lucide="check-circle" style="color:var(--green)"></i> Instellingen opgeslagen!</div>
<% } %>
<form method="POST" action="/settings">
<div class="section">
<h2><i data-lucide="globe"></i> Algemeen</h2>
<div class="field">
<label>Brandnaam</label>
<input type="text" name="brand_name" value="<%= settings.brand_name %>">
</div>
<div class="field">
<label>Taal</label>
<select name="language">
<option value="nl" <%= settings.language==='nl'?'selected':'' %>>Nederlands</option>
<option value="en" <%= settings.language==='en'?'selected':'' %>>English</option>
</select>
</div>
<div class="field">
<label>Standaard pagina</label>
<select name="default_page">
<option value="/" <%= settings.default_page==='/'?'selected':'' %>>Dashboard</option>
<option value="/clients" <%= settings.default_page==='/clients'?'selected':'' %>>Clients</option>
<option value="/racks" <%= settings.default_page==='/racks'?'selected':'' %>>Racks</option>
<option value="/tasks" <%= settings.default_page==='/tasks'?'selected':'' %>>Tasks</option>
</select>
<span class="hint">Na inloggen.</span>
</div>
</div>
<div class="section">
<h2><i data-lucide="palette"></i> Thema & Kleuren</h2>
<p style="font-size:.72rem;color:var(--text-dim);margin:-.5rem 0 .75rem">Kies een preset als basis en pas de achtergrond en kleuren zelf aan.</p>
<input type="hidden" name="theme" id="themeInput" value="<%= settings.theme %>">
<div class="custom-theme-toggle">
<label><input type="checkbox" name="use_custom_colors" value="1" id="useCustomColors" <%= useCustomColors ? 'checked' : '' %> onchange="toggleCustomColors()"> <strong>Eigen kleuren gebruiken</strong> <span style="font-weight:400;color:var(--text-dim)">— overschrijft het gekozen preset</span></label>
</div>
<div id="customColorsBlock" style="<%= useCustomColors ? '' : 'opacity:.55;pointer-events:none' %>">
<p style="font-size:.72rem;color:var(--text-muted);margin:0 0 .5rem;font-weight:600">Jouw kleuren</p>
<div class="live-preview" id="livePreview">
<div class="lp-nav" id="pvNav">Sidebar</div>
<div class="lp-main" id="pvMain">
<div style="font-size:.6rem;font-weight:700" id="pvText">Voorbeeld tekst</div>
<div class="lp-card" id="pvCard">Kaart / panel</div>
<div class="lp-accent" id="pvAccent"></div>
</div>
</div>
<div class="main-color-grid">
<% themeMainColors.forEach(function(mc) {
var val = mergedColors[mc.key] || '';
var hexVal = toHexForPicker(val, '#0a1628');
var fieldName = 'th_' + mc.key;
%>
<div class="main-color-card">
<label for="<%= fieldName %>"><%= mc.label %></label>
<span class="hint"><%= mc.hint %></span>
<div class="picker-row">
<input type="color" id="<%= fieldName %>_picker" value="<%= hexVal %>" data-theme-key="<%= mc.key %>" data-for="<%= fieldName %>" oninput="onColorPick(this)">
<input class="ch" type="text" name="<%= fieldName %>" value="<%= val %>" data-for="<%= fieldName %>" oninput="onHexInput(this)" maxlength="32" placeholder="#0a1628 of rgba(...)">
</div>
</div>
<% }) %>
</div>
<details class="advanced-colors">
<summary>Geavanceerde kleuren (randen, statuskleuren)</summary>
<div class="color-grid" style="margin-top:.65rem">
<% var thKeys = themeCssKeys.filter(function(k){ return !themeMainColors.some(function(m){ return m.key===k; }); }); %>
<% thKeys.forEach(function(k) {
var val = mergedColors[k] || '';
var hexVal = toHexForPicker(val, '#0a1628');
var fieldName = 'th_' + k;
var label = themeColorLabels[k] || ('--' + k.replace(/_/g, '-'));
%>
<div class="color-row">
<label class="cl"><%= label %></label>
<input type="color" value="<%= hexVal %>" oninput="syncAdvColor(this,'<%= fieldName %>')">
<input class="ch" type="text" name="<%= fieldName %>" value="<%= val %>" maxlength="40">
</div>
<% }) %>
</div>
</details>
</div>
<p style="font-size:.72rem;color:var(--text-dim);margin:1rem 0 .5rem">Preset als startpunt:</p>
<div class="theme-grid">
<% var themeKeys = Object.keys(themePresets).filter(function(k){ return k !== 'custom'; }); %>
<% themeKeys.forEach(function(k) { var p = themePresets[k]; var active = settings.theme === k || (settings.theme === 'custom' && !useCustomColors); %>
<label class="theme-card<%= settings.theme === k ? ' active' : '' %>" data-theme="<%= k %>">
<input type="radio" name="_theme" value="<%= k %>" <%= settings.theme === k ? 'checked' : '' %> onchange="selectTheme('<%= k %>')">
<div class="tc-preview" style="background:<%= p.bg %>">
<span style="background:<%= p.bg_card %>"></span>
<span style="background:<%= p.accent %>"></span>
<span style="background:<%= p.green %>"></span>
<span style="background:<%= p.red %>"></span>
<span style="background:<%= p.cyan %>"></span>
</div>
<div class="tc-name"><%= p.label %></div>
<div class="tc-mode"><%= p.mode === 'dark' ? 'Donker' : 'Licht' %></div>
</label>
<% }) %>
</div>
</div>
<div class="section">
<h2><i data-lucide="palette"></i> Icoon Kleuren</h2>
<p style="font-size:.72rem;color:var(--text-dim);margin:-.5rem 0 .75rem">Kies zelf de kleur van elk icoon in de sidebar en tabbladen.</p>
<div class="icon-grid">
<% icons.forEach(function(icon) {
var color = settings.icon_colors[icon.key] || '#58a6ff';
var fieldName = 'color_' + icon.key.replace(/-/g, '_');
%>
<div class="icon-row">
<i data-lucide="<%= icon.key %>" style="color:<%= color %>"></i>
<span class="il"><%= icon.label %></span>
<input type="color" name="<%= fieldName %>" value="<%= color %>" oninput="this.nextElementSibling.value=this.value">
<input class="hex-val" type="text" value="<%= color %>" oninput="this.previousElementSibling.value=this.value" maxlength="7">
</div>
<% }) %>
</div>
</div>
<div class="section">
<h2><i data-lucide="layout-dashboard"></i> Dashboard</h2>
<div class="field">
<label>Auto-refresh</label>
<select name="auto_refresh">
<option value="0" <%= settings.auto_refresh==='0'?'selected':'' %>>Uit</option>
<option value="15" <%= settings.auto_refresh==='15'?'selected':'' %>>15 seconden</option>
<option value="30" <%= settings.auto_refresh==='30'?'selected':'' %>>30 seconden</option>
<option value="60" <%= settings.auto_refresh==='60'?'selected':'' %>>1 minuut</option>
<option value="300" <%= settings.auto_refresh==='300'?'selected':'' %>>5 minuten</option>
</select>
<span class="hint">Automatisch verversen van het dashboard.</span>
</div>
</div>
<div class="section">
<h2><i data-lucide="bell"></i> Notificaties</h2>
<div class="field">
<label>Slack Webhook URL</label>
<input type="url" name="slack_webhook" value="<%= settings.slack_webhook || '' %>" placeholder="https://hooks.slack.com/services/...">
<span class="hint">Voor Slack notificaties bij nieuwe events</span>
</div>
<div class="field">
<label>SMTP Host</label>
<input type="text" name="smtp_host" value="<%= settings.smtp_host || '' %>" placeholder="smtp.example.com">
</div>
<div class="field">
<label>SMTP Gebruiker</label>
<input type="text" name="smtp_user" value="<%= settings.smtp_user || '' %>">
</div>
<div class="field">
<label>SMTP Wachtwoord</label>
<input type="password" name="smtp_pass" value="<%= settings.smtp_pass || '' %>">
</div>
</div>
<div class="section">
<h2><i data-lucide="terminal"></i> Systeem</h2>
<div class="field">
<label>Items per pagina</label>
<input type="number" name="items_per_page" value="<%= settings.items_per_page %>" min="10" max="500" step="10">
</div>
<div class="field">
<label>Debug mode</label>
<input type="checkbox" name="debug_mode" value="1" <%= settings.debug_mode==='1'?'checked':'' %>>
<span class="hint">Toont technische details bij fouten.</span>
</div>
</div>
<div class="actions">
<button class="btn-pri" type="submit"><i data-lucide="save" style="width:14px;height:14px"></i> Opslaan</button>
<button class="btn-sec" type="button" onclick="previewTheme()"><i data-lucide="eye" style="width:14px;height:14px"></i> Voorbeeld</button>
<button class="btn-sec" type="button" onclick="resetDefaults()"><i data-lucide="rotate-ccw" style="width:14px;height:14px"></i> Reset naar standaard</button>
</div>
</form>
</div>
<script>
lucide.createIcons();
var themePresets = <%- JSON.stringify(themePresets) %>;
var THEME_CSS_KEYS = <%- JSON.stringify(themeCssKeys) %>;
function toggleCustomColors() {
var on = document.getElementById('useCustomColors').checked;
var block = document.getElementById('customColorsBlock');
block.style.opacity = on ? '1' : '.55';
block.style.pointerEvents = on ? 'auto' : 'none';
}
function onColorPick(el) {
var text = document.querySelector('input[name="' + el.dataset.for + '"]');
if (text) text.value = el.value;
updateLivePreview();
}
function onHexInput(el) {
var v = el.value.trim();
var picker = document.querySelector('input[data-for="' + el.name + '"]');
if (picker && /^#[0-9a-f]{6}$/i.test(v)) picker.value = v;
updateLivePreview();
}
function syncAdvColor(picker, fieldName) {
var text = document.querySelector('input[name="' + fieldName + '"]');
if (text) text.value = picker.value;
}
function getColor(key) {
var inp = document.querySelector('input[name="th_' + key + '"]');
return inp ? inp.value.trim() : '';
}
function updateLivePreview() {
var bg = getColor('bg') || '#0a1628';
var card = getColor('bg_card') || '#0f1f3a';
var nav = getColor('bg_nav') || '#0c1a30';
var accent = getColor('accent') || '#00d4ff';
var text = getColor('text') || '#e8f4ff';
var muted = getColor('text_muted') || '#7eb8d4';
document.getElementById('livePreview').style.background = bg;
document.getElementById('pvNav').style.background = nav;
document.getElementById('pvNav').style.color = muted;
document.getElementById('pvMain').style.background = bg;
document.getElementById('pvText').style.color = text;
document.getElementById('pvCard').style.background = card;
document.getElementById('pvCard').style.color = muted;
document.getElementById('pvAccent').style.background = accent;
}
function selectTheme(key) {
document.querySelectorAll('.theme-card').forEach(function(c) { c.classList.remove('active'); });
var card = document.querySelector('.theme-card[data-theme="' + key + '"]');
if (card) card.classList.add('active');
document.getElementById('themeInput').value = key;
var p = themePresets[key];
if (!p) return;
['bg','bg_card','bg_nav','accent','text','text_muted'].forEach(function(k) {
var picker = document.querySelector('input[data-for="th_' + k + '"]');
var text = document.querySelector('input[name="th_' + k + '"]');
if (p[k] && text) {
text.value = p[k];
if (picker && /^#[0-9a-f]{6}$/i.test(p[k])) picker.value = p[k];
}
});
THEME_CSS_KEYS.forEach(function(k) {
var adv = document.querySelector('input[name="th_' + k + '"][type=text]');
if (adv && p[k]) adv.value = p[k];
});
document.getElementById('useCustomColors').checked = false;
toggleCustomColors();
updateLivePreview();
}
function previewTheme() {
var colors = {};
THEME_CSS_KEYS.forEach(function(k) {
var v = getColor(k);
if (v) colors[k.replace(/_/g, '-')] = v;
});
var css = ':root{\n';
Object.keys(colors).forEach(function(k) { css += '--' + k + ':' + colors[k] + ';\n'; });
css += '}body{background-color:' + (getColor('bg') || '#0a1628') + '!important}';
var style = document.getElementById('preview-style');
if (!style) {
style = document.createElement('style');
style.id = 'preview-style';
document.head.appendChild(style);
}
style.textContent = css;
alert('Voorbeeld toegepast op deze pagina. Klik Opslaan om permanent te maken.');
}
function resetDefaults() {
if (!confirm('Alle instellingen resetten naar standaard?')) return;
var form = document.createElement('form');
form.method = 'POST';
form.action = '/settings/reset';
document.body.appendChild(form);
form.submit();
}
document.querySelectorAll('.main-color-card .ch').forEach(function(inp) {
inp.addEventListener('change', function() { onHexInput(this); });
});
updateLivePreview();
toggleCustomColors();
</script>
<script src="/js/theme.js"></script></body>
</html>
+59
View File
@@ -0,0 +1,59 @@
const express = require('express');
const router = express.Router();
const { getSettings, updateSetting, getDb } = require('../db');
const ALL_ICONS = [
{ key: 'layout-dashboard', label: 'Dashboard' },
{ key: 'users', label: 'Cliënten' },
{ key: 'briefcase', label: 'Engagements' },
{ key: 'server', label: 'Infrastructuur' },
{ key: 'check-circle', label: 'Taken' },
{ key: 'lightbulb', label: 'Advies' },
{ key: 'clock', label: 'Activiteit' },
{ key: 'eye', label: 'Overzicht (client)' },
{ key: 'phone', label: 'Contact' },
{ key: 'file-text', label: 'Designs' },
{ key: 'edit-3', label: 'Notities' },
{ key: 'folder', label: 'Bestanden' },
{ key: 'clipboard-list', label: 'Assessments' }
];
router.get('/', (req, res) => {
const settings = getSettings();
res.render('settings', {
settings,
icons: ALL_ICONS,
user: { username: req.session.username },
saved: req.query.saved === '1'
});
});
router.post('/', (req, res) => {
const { brand_name, language, theme, default_page, auto_refresh, items_per_page, debug_mode } = req.body;
updateSetting('brand_name', brand_name || 'Mek-Tech Consulting');
updateSetting('language', language || 'nl');
updateSetting('theme', theme || 'dark');
updateSetting('default_page', default_page || '/');
updateSetting('auto_refresh', auto_refresh || '0');
updateSetting('items_per_page', items_per_page || '50');
updateSetting('debug_mode', debug_mode === '1' ? '1' : '0');
const icon_colors = {};
ALL_ICONS.forEach(icon => {
const val = req.body['color_' + icon.key.replace(/-/g, '_')];
if (val) icon_colors[icon.key] = val;
});
updateSetting('icon_colors', icon_colors);
res.redirect('/settings?saved=1');
});
router.post('/reset', (req, res) => {
const { DEFAULTS } = require('../db');
const d = getDb();
d.prepare('DELETE FROM app_settings').run();
res.redirect('/settings');
});
module.exports = router;
+583
View File
@@ -0,0 +1,583 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sizing Calculator — 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" type="image/svg+xml">
<style>
:root{--sb-w:200px;--hdr-h:48px}
html,body{height:100%;margin:0;padding:0}
body{background:var(--bg);font-family:'Inter',-apple-system,sans-serif}
.app-layout{display:flex;min-height:100vh;padding-top:var(--hdr-h)}
.sidebar{position:fixed;top:var(--hdr-h);left:0;width:var(--sb-w);height:calc(100vh - var(--hdr-h));background:var(--bg-nav);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:50;overflow-y:auto}
.sb-brand{padding:.75rem 1rem;border-bottom:1px solid var(--border)}
.sb-brand h2{font-size:.8rem;font-weight:600;color:var(--text)}
.sb-brand p{font-size:.6rem;color:var(--text-dim)}
.sb-nav{flex:1;padding:.35rem 0}
.sb-item{display:flex;align-items:center;gap:.5rem;padding:.45rem .85rem;margin:1px .4rem;border-radius:var(--radius);cursor:pointer;font-size:.78rem;color:var(--text-muted);transition:all .12s;border:none;background:none;width:calc(100% - .8rem);text-align:left;font-family:inherit}
.sb-item:hover{color:var(--text);background:var(--border-light)}
.sb-item.active{color:var(--accent);background:rgba(88,166,255,.08);font-weight:600}
.sb-item .sb-icon{width:20px;text-align:center;font-size:.85rem;flex-shrink:0}
.main-area{flex:1;margin-left:var(--sb-w);padding:1.25rem 1.5rem 3rem;max-width:1200px}
.page-hdr{margin-bottom:1.25rem}
.page-hdr h1{font-size:1.4rem;color:var(--text);font-weight:600}
.page-hdr p{font-size:.8rem;color:var(--text-muted);margin-top:.1rem}
.sel-bar{display:flex;gap:.5rem;margin-bottom:1.25rem;flex-wrap:wrap}
.sel-bar select{padding:.45rem .7rem;font-size:.8rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);cursor:pointer;min-width:200px}
.sel-bar select:focus{outline:none;border-color:var(--accent)}
.calc-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
@media(max-width:768px){.calc-grid{grid-template-columns:1fr}}
.calc-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:1.25rem}
.calc-card h3{font-size:.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;margin-bottom:1rem}
.calc-card h4{font-size:.85rem;color:var(--text);margin-bottom:.35rem}
.calc-input{margin-bottom:.75rem}
.calc-input label{display:block;font-size:.72rem;color:var(--text-muted);margin-bottom:.2rem}
.calc-input input,.calc-input select{width:100%;padding:.4rem .6rem;font-size:.8rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text)}
.calc-input input:focus{outline:none;border-color:var(--accent)}
.result-card{background:var(--bg-nav);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;margin-top:.5rem}
.result-card .r-row{display:flex;justify-content:space-between;align-items:center;padding:.35rem 0;border-bottom:1px solid var(--border-light)}
.result-card .r-row:last-child{border-bottom:none}
.result-card .r-label{font-size:.75rem;color:var(--text-muted)}
.result-card .r-val{font-size:.85rem;color:var(--text);font-weight:600}
.result-card .r-val.accent{color:var(--accent)}
.result-card .r-val.green{color:var(--green)}
.result-card .r-val.orange{color:var(--orange)}
.result-card .r-val.purple{color:var(--purple)}
.r-highlight{background:rgba(88,166,255,.06);border-radius:var(--radius);padding:.6rem .75rem;margin-top:.5rem}
.r-highlight .rh-title{font-size:.65rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px}
.r-highlight .rh-val{font-size:1.1rem;font-weight:700;color:var(--accent);margin-top:.1rem}
.r-recommendation{background:var(--green-bg);border:1px solid var(--green);border-radius:var(--radius);padding:.75rem;margin-top:.75rem}
.r-recommendation .rr-title{font-size:.65rem;color:var(--green);text-transform:uppercase;letter-spacing:.3px;margin-bottom:.25rem}
.r-recommendation .rr-text{font-size:.78rem;color:var(--text)}
.desc-text{font-size:.78rem;color:var(--text-muted);line-height:1.5;margin-bottom:.75rem}
</style>
<script src="https://unpkg.com/lucide@latest"></script></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="app-layout">
<aside class="sidebar">
<div class="sb-brand"><h2>Mek-Tech Sizing</h2><p>Data Architecture Calculator</p></div>
<nav class="sb-nav">
<button class="sb-item active" data-platform="kafka"><span class="sb-icon">📨</span> Apache Kafka</button>
<button class="sb-item" data-platform="spark"><span class="sb-icon">⚡</span> Apache Spark</button>
<button class="sb-item" data-platform="minio"><span class="sb-icon">💾</span> MinIO / S3</button>
<button class="sb-item" data-platform="sap-hana"><span class="sb-icon">🔷</span> SAP HANA</button>
<button class="sb-item" data-platform="warehouse"><span class="sb-icon">🏛</span> Data Warehouse</button>
<button class="sb-item" data-platform="vector"><span class="sb-icon">🧠</span> Vector Store</button>
<button class="sb-item" data-platform="k8s"><span class="sb-icon">⎈</span> Kubernetes</button>
<button class="sb-item" data-platform="hadoop"><span class="sb-icon">📊</span> Hadoop HDFS</button>
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()"></button></nav>
</aside>
<div class="main-area">
<div class="page-hdr">
<h1>Data Architecture Sizing Calculator</h1>
<p>Best-practice dimensionering voor enterprise data platforms — gebaseerd op 15+ jaar ervaring</p>
</div>
<div class="sel-bar">
<select id="platformSelect" onchange="switchPlatform(this.value)">
<option value="kafka">Apache Kafka / Event Streaming</option>
<option value="spark">Apache Spark / Flink</option>
<option value="minio">MinIO / S3 Object Storage</option>
<option value="sap-hana">SAP HANA Platform</option>
<option value="warehouse">Data Warehouse / Iceberg</option>
<option value="vector">Vector Store / Embeddings</option>
<option value="k8s">Kubernetes Cluster</option>
<option value="hadoop">Hadoop HDFS / Data Lake</option>
</select>
</div>
<!-- KAFKA -->
<div class="calc-panel" id="panel-kafka">
<div class="desc-text">Bereken de optimale Kafka cluster configuratie op basis van throughput, retentie en replicatie. Geschikt voor Confluent, Red Hat AMQ Streams, en Apache Kafka.</div>
<div class="calc-grid">
<div class="calc-card">
<h3>Input Parameters</h3>
<div class="calc-input"><label>Berichten per seconde (msg/s)</label><input type="number" id="k-msgs" value="500000" oninput="calcKafka()"></div>
<div class="calc-input"><label>Gem. berichtgrootte (KB)</label><input type="number" id="k-msgSize" value="5" step="0.5" oninput="calcKafka()"></div>
<div class="calc-input"><label>Retentie (dagen)</label><input type="number" id="k-retention" value="7" oninput="calcKafka()"></div>
<div class="calc-input"><label>Replicatie factor</label><select id="k-replicas" onchange="calcKafka()"><option value="2">2 (Standard)</option><option value="3" selected>3 (Enterprise)</option><option value="4">4 (Mission Critical)</option></select></div>
<div class="calc-input"><label>Disk snelheid (MB/s per disk)</label><select id="k-diskSpeed" onchange="calcKafka()"><option value="200">HDD 7200rpm (~200 MB/s)</option><option value="550" selected>SSD SATA (~550 MB/s)</option><option value="3500">NVMe (~3500 MB/s)</option><option value="7000">Optane (~7000 MB/s)</option></select></div>
<div class="calc-input"><label>Compressie</label><select id="k-compress" onchange="calcKafka()"><option value="1">Geen (1:1)</option><option value="0.4" selected>Snappy / LZ4 (0.4x)</option><option value="0.25">ZSTD (0.25x)</option></select></div>
</div>
<div class="calc-card">
<h3>Resultaten</h3>
<div class="result-card">
<div class="r-row"><span class="r-label">Doorvoer (bruto)</span><span class="r-val accent" id="k-throughput">—</span></div>
<div class="r-row"><span class="r-label">Doorvoer (netto na compressie)</span><span class="r-val" id="k-throughputNet">—</span></div>
<div class="r-row"><span class="r-label">Opslag per dag</span><span class="r-val" id="k-daily">—</span></div>
<div class="r-row"><span class="r-label">Totale opslag (retentie)</span><span class="r-val" id="k-total">—</span></div>
<div class="r-row"><span class="r-label">Totaal met replicatie</span><span class="r-val orange" id="k-totalRep">—</span></div>
<div class="r-row"><span class="r-label">Aanbevolen partitions</span><span class="r-val" id="k-partitions">—</span></div>
<div class="r-row"><span class="r-label">Min. brokers (performance)</span><span class="r-val" id="k-brokers">—</span></div>
</div>
<div class="r-highlight">
<div class="rh-title">Aanbevolen Cluster Configuratie</div>
<div class="rh-val" id="k-clusterRec">—</div>
</div>
<div class="r-recommendation" id="k-rec">
<div class="rr-title">Mek-Tech Best Practice</div>
<div class="rr-text">Voor enterprise Kafka deployments raden wij 3 replication factor aan met minstens 3 brokers. Gebruik NVMe storage voor critical workloads, SSD voor standard. Partition count = max(3 × brokers, throughput / 10MB).</div>
</div>
</div>
</div>
</div>
<!-- SPARK -->
<div class="calc-panel" id="panel-spark" style="display:none">
<div class="desc-text">Dimensionering voor Apache Spark en Flink workloads. Optimaliseer executors, cores, memory en dynamic allocation voor batch en streaming.</div>
<div class="calc-grid">
<div class="calc-card">
<h3>Input Parameters</h3>
<div class="calc-input"><label>Data volume per dag (GB)</label><input type="number" id="s-data" value="500" oninput="calcSpark()"></div>
<div class="calc-input"><label>Processing type</label><select id="s-type" onchange="calcSpark()"><option value="batch">Batch (dagelijks)</option><option value="stream" selected>Streaming (real-time)</option><option value="ml">ML Training</option></select></div>
<div class="calc-input"><label>Gewenste processing time (min)</label><input type="number" id="s-time" value="30" oninput="calcSpark()"></div>
<div class="calc-input"><label>Beschikbaar geheugen per node (GB)</label><select id="s-ram" onchange="calcSpark()"><option value="64">64 GB</option><option value="128">128 GB</option><option value="256" selected>256 GB</option><option value="512">512 GB</option><option value="1024">1024 GB</option></select></div>
<div class="calc-input"><label>CPU cores per node</label><select id="s-cores" onchange="calcSpark()"><option value="16">16 cores</option><option value="32" selected>32 cores</option><option value="64">64 cores</option><option value="96">96 cores</option></select></div>
<div class="calc-input"><label>Dynamic allocation</label><select id="s-dynamic" onchange="calcSpark()"><option value="0">Uit</option><option value="1" selected>Aan (min-max)</option></select></div>
</div>
<div class="calc-card">
<h3>Resultaten</h3>
<div class="result-card">
<div class="r-row"><span class="r-label">Aanbevolen executors</span><span class="r-val accent" id="s-executors">—</span></div>
<div class="r-row"><span class="r-label">Cores per executor</span><span class="r-val" id="s-coresPerExec">—</span></div>
<div class="r-row"><span class="r-label">Memory per executor (GB)</span><span class="r-val" id="s-memPerExec">—</span></div>
<div class="r-row"><span class="r-label">Totale cluster RAM (GB)</span><span class="r-val purple" id="s-totalRam">—</span></div>
<div class="r-row"><span class="r-label">Totale vCores</span><span class="r-val" id="s-totalCores">—</span></div>
<div class="r-row"><span class="r-label">Aanbevolen worker nodes</span><span class="r-val green" id="s-workers">—</span></div>
</div>
<div class="r-highlight"><div class="rh-title">Totale cluster capaciteit</div><div class="rh-val" id="s-clusterRec">—</div></div>
<div class="r-recommendation" id="s-rec">
<div class="rr-title">Mek-Tech Best Practice</div>
<div class="rr-text">Standaard Spark config: 5 cores per executor, 4-8 GB overhead per executor. Voor streaming gebruik 2-3 cores per executor. Altijd dynamic allocation inschakelen voor fluctuaties.</div>
</div>
</div>
</div>
</div>
<!-- MINIO -->
<div class="calc-panel" id="panel-minio" style="display:none">
<div class="desc-text">Sizing voor MinIO object storage clusters. Berekent erasure coding, drive configuratie en totale bruikbare capaciteit. Geschikt voor S3-compatible storage.</div>
<div class="calc-grid">
<div class="calc-card">
<h3>Input Parameters</h3>
<div class="calc-input"><label>Bruto opslag capaciteit (TB)</label><input type="number" id="m-capacity" value="500" oninput="calcMinio()"></div>
<div class="calc-input"><label>Drive grootte (TB)</label><select id="m-driveSize" onchange="calcMinio()"><option value="4">4 TB</option><option value="8">8 TB</option><option value="14">14 TB</option><option value="18">18 TB</option><option value="22" selected>22 TB</option><option value="30">30 TB</option></select></div>
<div class="calc-input"><label>Erasure Code (EC)</label><select id="m-ec" onchange="calcMinio()"><option value="4:2">4 data + 2 parity (1.5x)</option><option value="8:4" selected>8 data + 4 parity (1.5x)</option><option value="12:4">12 data + 4 parity (1.33x)</option><option value="10:6">10 data + 6 parity (1.6x)</option><option value="16:8">16 data + 8 parity (1.5x)</option></select></div>
<div class="calc-input"><label>Redundantie</label><select id="m-redundancy" onchange="calcMinio()"><option value="no">Geen (single node)</option><option value="rack" selected>Rack-aware (3 racks)</option><option value="region">Region-aware (2 regio's)</option></select></div>
<div class="calc-input"><label>Compressie (avg)</label><select id="m-compress" onchange="calcMinio()"><option value="1">Geen</option><option value="0.6" selected>Gemiddeld (0.6x)</option><option value="0.3">Hoog (0.3x)</option></select></div>
</div>
<div class="calc-card">
<h3>Resultaten</h3>
<div class="result-card">
<div class="r-row"><span class="r-label">Aantal drives</span><span class="r-val" id="m-drives">—</span></div>
<div class="r-row"><span class="r-label">Aantal nodes</span><span class="r-val" id="m-nodes">—</span></div>
<div class="r-row"><span class="r-label">Bruto capaciteit</span><span class="r-val" id="m-gross">—</span></div>
<div class="r-row"><span class="r-label">EC overhead factor</span><span class="r-val" id="m-ecFactor">—</span></div>
<div class="r-row"><span class="r-label">Bruikbaar na EC</span><span class="r-val accent" id="m-usable">—</span></div>
<div class="r-row"><span class="r-label">Effectief na compressie</span><span class="r-val green" id="m-effective">—</span></div>
<div class="r-row"><span class="r-label">Min. nodes aanbevolen</span><span class="r-val purple" id="m-minNodes">—</span></div>
</div>
<div class="r-highlight"><div class="rh-title">Aanbevolen Configuratie</div><div class="rh-val" id="m-clusterRec">—</div></div>
<div class="r-recommendation" id="m-rec">
<div class="rr-title">Mek-Tech Best Practice</div>
<div class="rr-text">Minio EC:8+4 is de sweet spot voor enterprise — 1.5x overhead met 4 parity failures tolerantie. Gebruik minimaal 12 nodes (24 drives) voor productie. Rack-aware deployment voor datacenter resiliency.</div>
</div>
</div>
</div>
</div>
<!-- SAP HANA -->
<div class="calc-panel" id="panel-sap-hana" style="display:none">
<div class="desc-text">SAP HANA memory en platform sizing. Berekent vereist geheugen, CPU en opslag op basis van SAP best practices en ervaringscijfers.</div>
<div class="calc-grid">
<div class="calc-card">
<h3>Input Parameters</h3>
<div class="calc-input"><label>SAP module / workload</label><select id="h-module" onchange="calcHana()"><option value="s4hana" selected>S/4HANA</option><option value="bw">BW/4HANA</option><option value="bpc">BPC</option><option value="slt">SLT</option><option value="mix">Mixed workload</option></select></div>
<div class="calc-input"><label>Actieve gebruikers</label><input type="number" id="h-users" value="500" oninput="calcHana()"></div>
<div class="calc-input"><label>Data volume (GB, huidig)</label><input type="number" id="h-data" value="500" oninput="calcHana()"></div>
<div class="calc-input"><label>Groei per jaar (%)</label><input type="number" id="h-growth" value="20" oninput="calcHana()"></div>
<div class="calc-input"><label>HA (High Availability)</label><select id="h-ha" onchange="calcHana()"><option value="1">Scale-up (standby)</option><option value="0">Geen HA</option></select></div>
<div class="calc-input"><label>DR (Disaster Recovery)</label><select id="h-dr" onchange="calcHana()"><option value="1">Async replicatie</option><option value="0" selected>Geen DR</option></select></div>
</div>
<div class="calc-card">
<h3>Resultaten</h3>
<div class="result-card">
<div class="r-row"><span class="r-label">Berekend geheugen (RAM)</span><span class="r-val accent" id="h-ram">—</span></div>
<div class="r-row"><span class="r-label">waarvan data footprint</span><span class="r-val" id="h-dataFP">—</span></div>
<div class="r-row"><span class="r-label">waarvan overhead / growth</span><span class="r-val" id="h-overhead">—</span></div>
<div class="r-row"><span class="r-label">CPU cores aanbevolen</span><span class="r-val" id="h-cpu">—</span></div>
<div class="r-row"><span class="r-label">Opslag (data + logs)</span><span class="r-val orange" id="h-storage">—</span></div>
<div class="r-row"><span class="r-label">Aanbevolen node type</span><span class="r-val purple" id="h-nodeType">—</span></div>
</div>
<div class="r-highlight"><div class="rh-title">Totale HANA Platform Sizing</div><div class="rh-val" id="h-clusterRec">—</div></div>
<div class="r-recommendation" id="h-rec">
<div class="rr-title">Mek-Tech Best Practice</div>
<div class="rr-text">SAP HANA geheugen: 1GB RAM per 50GB data (S/4HANA) of 1:4 (BW/4HANA). Tel 20-30% overhead voor growth en workloads. Scale-up voor performance, scale-out voor capaciteit. Gebruik minimaal 2x data voor opslag (logs + backups).</div>
</div>
</div>
</div>
</div>
<!-- DATA WAREHOUSE -->
<div class="calc-panel" id="panel-warehouse" style="display:none">
<div class="desc-text">Dimensionering voor Data Warehouse en Lakehouse architecturen (Iceberg, Snowflake, Redshift, BigQuery). Berekent compute/storage verhouding.</div>
<div class="calc-grid">
<div class="calc-card">
<h3>Input Parameters</h3>
<div class="calc-input"><label>Bruto data volume (TB)</label><input type="number" id="w-volume" value="100" oninput="calcWarehouse()"></div>
<div class="calc-input"><label>Daily change (ingest/CDC)</label><input type="number" id="w-ingest" value="500" oninput="calcWarehouse()"><span style="font-size:.6rem;color:var(--text-dim)">GB per dag</span></div>
<div class="calc-input"><label>Concurrent queries</label><select id="w-queries" onchange="calcWarehouse()"><option value="5">5 (Small)</option><option value="20" selected>20 (Medium)</option><option value="50">50 (Large)</option><option value="100">100 (Enterprise)</option></select></div>
<div class="calc-input"><label>Query complexiteit</label><select id="w-complexity" onchange="calcWarehouse()"><option value="simple">Simpel (SELECT/WHERE)</option><option value="moderate" selected>Gemiddeld (JOINs, aggregaties)</option><option value="complex">Complex (multi-JOIN, window)</option></select></div>
<div class="calc-input"><label>Architectuur</label><select id="w-arch" onchange="calcWarehouse()"><option value="lakehouse" selected>Lakehouse (Iceberg + Trino)</option><option value="cloud">Cloud DW (Snowflake)</option><option value="mpp">MPP (Redshift)</option></select></div>
</div>
<div class="calc-card">
<h3>Resultaten</h3>
<div class="result-card">
<div class="r-row"><span class="r-label">Gecomprimeerde data (Snappy)</span><span class="r-val" id="w-compressed">—</span></div>
<div class="r-row"><span class="r-label">Effectieve opslag (incl. replicatie)</span><span class="r-val" id="w-total">—</span></div>
<div class="r-row"><span class="r-label">Compute eenheden</span><span class="r-val accent" id="w-compute">—</span></div>
<div class="r-row"><span class="r-label">Geheugen (GB)</span><span class="r-val" id="w-ram">—</span></div>
<div class="r-row"><span class="r-label">vCores</span><span class="r-val" id="w-vcores">—</span></div>
<div class="r-row"><span class="r-label">Aanbevolen nodes</span><span class="r-val green" id="w-nodes">—</span></div>
</div>
<div class="r-highlight"><div class="rh-title">Aanbevolen Platform Configuratie</div><div class="rh-val" id="w-clusterRec">—</div></div>
<div class="r-recommendation" id="w-rec">
<div class="rr-title">Mek-Tech Best Practice</div>
<div class="rr-text">Lakehouse: Iceberg tables + Trino/Presto query engine. Reken op 5-8x compressie op Parquet. Compute/storage scheiding geeft flexibiliteit. Voor 100TB data: 4-8 worker nodes met 64GB RAM + 16 cores.</div>
</div>
</div>
</div>
</div>
<!-- VECTOR STORE -->
<div class="calc-panel" id="panel-vector" style="display:none">
<div class="desc-text">Sizing voor vector databases en embedding stores (Pinecone, Weaviate, Qdrant, Milvus). Berekent geheugen, dimensies en index grootte.</div>
<div class="calc-grid">
<div class="calc-card">
<h3>Input Parameters</h3>
<div class="calc-input"><label>Aantal vectors (miljoenen)</label><input type="number" id="v-vectors" value="50" oninput="calcVector()"></div>
<div class="calc-input"><label>Vector dimensies</label><select id="v-dims" onchange="calcVector()"><option value="384">384 (all-MiniLM)</option><option value="768" selected>768 (gte-small)</option><option value="1024">1024 (ADA-002)</option><option value="1536">1536 (text-embedding-3)</option></select></div>
<div class="calc-input"><label>Index type</label><select id="v-index" onchange="calcVector()"><option value="flat">Flat (exact, brute force)</option><option value="hnsw" selected>HNSW (approximate, aanbevolen)</option><option value="ivf">IVF (inverted file)</option></select></div>
<div class="calc-input"><label>Replicatie factor</label><select id="v-replicas" onchange="calcVector()"><option value="1">Geen</option><option value="2" selected>2x (HA)</option><option value="3">3x (Enterprise)</option></select></div>
</div>
<div class="calc-card">
<h3>Resultaten</h3>
<div class="result-card">
<div class="r-row"><span class="r-label">Ruwe vector grootte (GB)</span><span class="r-val" id="v-raw">—</span></div>
<div class="r-row"><span class="r-label">Index overhead</span><span class="r-val" id="v-indexOverhead">—</span></div>
<div class="r-row"><span class="r-label">Totaal geheugen (RAM)</span><span class="r-val accent" id="v-ram">—</span></div>
<div class="r-row"><span class="r-label">Totaal met replicatie</span><span class="r-val orange" id="v-total">—</span></div>
<div class="r-row"><span class="r-label">Aanbevolen nodes</span><span class="r-val green" id="v-nodes">—</span></div>
<div class="r-row"><span class="r-label">Node type</span><span class="r-val purple" id="v-nodeType">—</span></div>
</div>
<div class="r-highlight"><div class="rh-title">Aanbevolen Vector Store</div><div class="rh-val" id="v-clusterRec">—</div></div>
<div class="r-recommendation" id="v-rec">
<div class="rr-title">Mek-Tech Best Practice</div>
<div class="rr-text">HNSW index met 768 dims is de sweet spot voor enterprise. Reken op 2-3x ruwe data voor index overhead. Houd minimaal 2x RAM vrij naast de vector index voor query processing. Gebruik replica's voor HA.</div>
</div>
</div>
</div>
</div>
<!-- KUBERNETES -->
<div class="calc-panel" id="panel-k8s" style="display:none">
<div class="desc-text">Kubernetes cluster sizing voor data workloads. Berekent node pools, resource quotas, pod density en cluster capaciteit.</div>
<div class="calc-grid">
<div class="calc-card">
<h3>Input Parameters</h3>
<div class="calc-input"><label>Totaal aantal pods</label><input type="number" id="k8-pods" value="200" oninput="calcK8s()"></div>
<div class="calc-input"><label>CPU request per pod (cores)</label><input type="number" id="k8-cpu" value="1" step="0.5" oninput="calcK8s()"></div>
<div class="calc-input"><label>Memory request per pod (GB)</label><input type="number" id="k8-mem" value="4" oninput="calcK8s()"></div>
<div class="calc-input"><label>Node type</label><select id="k8-nodeType" onchange="calcK8s()"><option value="small" selected>Small (16 cores, 64GB)</option><option value="medium">Medium (32 cores, 128GB)</option><option value="large">Large (64 cores, 256GB)</option><option value="gpu">GPU (32 cores, 256GB + NVIDIA A100)</option></select></div>
<div class="calc-input"><label>Overcommit ratio</label><select id="k8-overcommit" onchange="calcK8s()"><option value="1">Geen (1:1, critical)</option><option value="1.5" selected>Gemiddeld (1.5x)</option><option value="2">Hoog (2x, burstable)</option></select></div>
</div>
<div class="calc-card">
<h3>Resultaten</h3>
<div class="result-card">
<div class="r-row"><span class="r-label">Totale CPU request</span><span class="r-val" id="k8-totalCpu">—</span></div>
<div class="r-row"><span class="r-label">Totaal geheugen request</span><span class="r-val" id="k8-totalMem">—</span></div>
<div class="r-row"><span class="r-label">Node capaciteit (pods)</span><span class="r-val" id="k8-nodeCap">—</span></div>
<div class="r-row"><span class="r-label">Aanbevolen nodes</span><span class="r-val accent" id="k8-nodes">—</span></div>
<div class="r-row"><span class="r-label">Gebruikte capaciteit</span><span class="r-val" id="k8-usage">—</span></div>
<div class="r-row"><span class="r-label">N+1 redundantie</span><span class="r-val green" id="k8-nodesHA">—</span></div>
</div>
<div class="r-highlight"><div class="rh-title">Aanbevolen Cluster</div><div class="rh-val" id="k8-clusterRec">—</div></div>
<div class="r-recommendation" id="k8-rec">
<div class="rr-title">Mek-Tech Best Practice</div>
<div class="rr-text">Gebruik medium nodes (32c/128GB) voor data workloads. Reken 110 pods max per node. Reserveer 20% capaciteit voor cluster overhead (system daemons, DNS, monitoring). Overcommit 1.5x voor batch jobs.</div>
</div>
</div>
</div>
</div>
<!-- HADOOP -->
<div class="calc-panel" id="panel-hadoop" style="display:none">
<div class="desc-text">Hadoop HDFS en data lake sizing. Berekent storage, replication, block size, en cluster configuratie voor big data workloads.</div>
<div class="calc-grid">
<div class="calc-card">
<h3>Input Parameters</h3>
<div class="calc-input"><label>Bruto data volume (TB)</label><input type="number" id="hd-volume" value="1000" oninput="calcHadoop()"></div>
<div class="calc-input"><label>Replicatie factor</label><select id="hd-replicas" onchange="calcHadoop()"><option value="1">1 (dev/test)</option><option value="2">2 (standard)</option><option value="3" selected>3 (enterprise)</option></select></div>
<div class="calc-input"><label>Block size</label><select id="hd-block" onchange="calcHadoop()"><option value="128">128 MB</option><option value="256" selected>256 MB</option><option value="512">512 MB</option></select></div>
<div class="calc-input"><label>Disk (TB per node)</label><select id="hd-disk" onchange="calcHadoop()"><option value="4">4 TB HDD</option><option value="8">8 TB HDD</option><option value="12">12 TB HDD</option><option value="16" selected>16 TB HDD</option><option value="30">30 TB SSD</option></select></div>
<div class="calc-input"><label>Compressie</label><select id="hd-compress" onchange="calcHadoop()"><option value="1">Geen</option><option value="0.5" selected>Snappy/LZO (0.5x)</option><option value="0.35">ZSTD (0.35x)</option></select></div>
</div>
<div class="calc-card">
<h3>Resultaten</h3>
<div class="result-card">
<div class="r-row"><span class="r-label">Effectieve opslag (na compressie)</span><span class="r-val" id="hd-effective">—</span></div>
<div class="r-row"><span class="r-label">Totaal met replicatie</span><span class="r-val orange" id="hd-totalRep">—</span></div>
<div class="r-row"><span class="r-label">Bruto capacity per node</span><span class="r-val" id="hd-perNode">—</span></div>
<div class="r-row"><span class="r-label">Aantal data nodes</span><span class="r-val accent" id="hd-nodes">—</span></div>
<div class="r-row"><span class="r-label">Aantal blokken in cluster</span><span class="r-val" id="hd-blocks">—</span></div>
<div class="r-row"><span class="r-label">MapReduce capacity</span><span class="r-val purple" id="hd-mr">—</span></div>
</div>
<div class="r-highlight"><div class="rh-title">Aanbevolen Hadoop Cluster</div><div class="rh-val" id="hd-clusterRec">—</div></div>
<div class="r-recommendation" id="hd-rec">
<div class="rr-title">Mek-Tech Best Practice</div>
<div class="rr-text">HDFS replicatie 3x is enterprise standaard. Gebruik 256MB block size voor data lake workloads. Reken 70-80% disk utilization na OS overhead. Snappy compressie voor balance tussen ratio en speed. Namenode heeft 1GB RAM per 1M blokken.</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function fmt(v){if(v>=1e6)return (v/1e6).toFixed(1)+' PB';if(v>=1e3)return (v/1e3).toFixed(1)+' TB';return Math.round(v)+' GB'}
function fmtBytes(v){return fmt(v)}
function fmtNum(v){if(v>=1e6)return (v/1e6).toFixed(1)+'M';if(v>=1e3)return (v/1e3).toFixed(1)+'K';return Math.round(v).toString()}
function tbToGb(t){return t*1000}
function gbToTb(g){return g/1000}
function calcKafka(){
var msgs=+document.getElementById('k-msgs').value||0
var msgKB=+document.getElementById('k-msgSize').value||0
var retention=+document.getElementById('k-retention').value||0
var replicas=+document.getElementById('k-replicas').value||3
var diskSpeed=+document.getElementById('k-diskSpeed').value||550
var compress=+document.getElementById('k-compress').value||0.4
var mbps=msgs*msgKB/1024
var mbpsNet=mbps*compress
var dailyGB=mbpsNet*86400/1024
var totalGB=dailyGB*retention
var totalRepGB=totalGB*replicas
var partitions=Math.max(msgs/100000,3*replicas)
var brokersPerf=Math.ceil(mbps/diskSpeed)
var brokers=max(3,brokersPerf,Math.ceil(totalRepGB/5000))
document.getElementById('k-throughput').textContent=mbps.toFixed(1)+' MB/s'
document.getElementById('k-throughputNet').textContent=mbpsNet.toFixed(1)+' MB/s'
document.getElementById('k-daily').textContent=dailyGB<1000?Math.round(dailyGB)+' GB':(dailyGB/1000).toFixed(2)+' TB'
document.getElementById('k-total').textContent=fmt(totalGB)
document.getElementById('k-totalRep').textContent=fmt(totalRepGB)
document.getElementById('k-partitions').textContent=Math.ceil(partitions)
document.getElementById('k-brokers').textContent=brokers
var nodeRec=''
if(brokers<=3)nodeRec=brokers+' nodes × 4TB NVMe'
else if(brokers<=6)nodeRec=brokers+' nodes × 8TB SSD'
else if(brokers<=10)nodeRec=brokers+' nodes × 16TB SSD'
else nodeRec=brokers+' nodes × 32TB SSD'
document.getElementById('k-clusterRec').textContent=nodeRec+' (total ~'+fmt(totalRepGB)+')'
var recEl=document.getElementById('k-rec').querySelector('.rr-text')
if(compress<0.3)recEl.textContent='ZSTD compressie geeft ~75% reductie. Overweeg tiered storage voor data >14 dagen oud. Gebruik JBOD configuratie (geen RAID) voor max throughput.'
else recEl.textContent='Voor '+msgs.toLocaleString()+' msg/s met '+msgKB+'KB berichten: '+brokers+' brokers '+nodeRec+'. Partities: '+Math.ceil(partitions)+'. Monitor follower fetch latency en under-replicated partitions.'
}
function calcSpark(){
var data=+document.getElementById('s-data').value||0
var type=document.getElementById('s-type').value
var time=+document.getElementById('s-time').value||30
var nodeRam=+document.getElementById('s-ram').value||256
var nodeCores=+document.getElementById('s-cores').value||32
var dynamic=document.getElementById('s-dynamic').value==='1'
var execOverhead=type==='ml'?8:4
var coresPerExec=type==='stream'?2:5
if(type==='ml')coresPerExec=4
var execsPerNode=Math.floor((nodeRam-execOverhead)/(coresPerExec*4+execOverhead))||1
var totalExecs=execsPerNode
var totalRam=totalExecs*(coresPerExec*4+execOverhead)
var totalVCores=totalExecs*coresPerExec
var workers=Math.ceil(totalExecs/execsPerNode)
if(dynamic)workers=Math.ceil(workers*1.3)
document.getElementById('s-executors').textContent=totalExecs
document.getElementById('s-coresPerExec').textContent=coresPerExec
document.getElementById('s-memPerExec').textContent=coresPerExec*4+execOverhead+' GB'
document.getElementById('s-totalRam').textContent=totalRam+' GB'
document.getElementById('s-totalCores').textContent=totalVCores
document.getElementById('s-workers').textContent=workers
document.getElementById('s-clusterRec').textContent=workers+' nodes × '+nodeRam+'GB RAM / '+nodeCores+' cores'
document.getElementById('s-rec').querySelector('.rr-text').textContent='Voor '+type+' workloads met '+data+'GB data: '+workers+' worker nodes. '+(dynamic?'Dynamic allocation actief — efficiënt voor fluctuerende loads.':'Static allocation — consistent maar minder flexibel.')+' Gebruik RDD persistance voor iterative ML workloads.'
}
function max(){return Math.max.apply(null,arguments)}
function calcMinio(){
var capTB=+document.getElementById('m-capacity').value||0
var driveTB=+document.getElementById('m-driveSize').value||22
var ecStr=document.getElementById('m-ec').value
var ecParts=ecStr.split(':')
var ecData=+ecParts[0],ecParity=+ecParts[1]
var ecFactor=1+ecParity/ecData
var redundancy=document.getElementById('m-redundancy').value
var compress=+document.getElementById('m-compress').value||0.6
var grossTB=capTB*ecFactor
var drives=Math.ceil(grossTB/driveTB)
var nodes=Math.ceil(drives/2)
var usableTB=grossTB/ecFactor
var effectiveTB=usableTB/compress
var minNodes=ecData+ecParity
if(redundancy==='rack')nodes=Math.max(nodes,Math.ceil(minNodes/3)*3)
if(redundancy==='region')nodes=Math.max(nodes,minNodes*2)
document.getElementById('m-drives').textContent=drives
document.getElementById('m-nodes').textContent=nodes
document.getElementById('m-gross').textContent=grossTB.toFixed(1)+' TB'
document.getElementById('m-ecFactor').textContent=ecFactor.toFixed(2)+'x'
document.getElementById('m-usable').textContent=usableTB.toFixed(1)+' TB'
document.getElementById('m-effective').textContent=effectiveTB.toFixed(1)+' TB'
document.getElementById('m-minNodes').textContent=minNodes+' (EC:'+ecData+'+'+ecParity+')'
document.getElementById('m-clusterRec').textContent=nodes+' nodes × '+Math.ceil(drives/nodes)+' drives × '+driveTB+'TB ('+Math.round(grossTB)+'TB gross)'
}
function calcHana(){
var module=document.getElementById('h-module').value
var users=+document.getElementById('h-users').value||0
var dataGB=+document.getElementById('h-data').value||0
var growth=+document.getElementById('h-growth').value||0
var ha=document.getElementById('h-ha').value==='1'
var dr=document.getElementById('h-dr').value==='1'
var ratio=module==='bw'?0.25:module==='bpc'?0.15:module==='slt'?0.05:0.02
var dataFP=Math.round(dataGB*ratio*100)/100
var ram=dataFP*(1+growth/100)+users*0.25
var overhead=ram-dataFP
var cpu=Math.max(8,Math.round(ram/32))
var storage=(dataGB+ram*2)*1.2
var nodeType=ram<=256?'1x HPE ProLiant DL380 (256GB)':'2x HPE ProLiant DL380 (512GB)'
if(ram>1024)nodeType='4x HPE ProLiant DL380 (1TB+) — Scale-out'
if(ha)ram*=1.5
document.getElementById('h-ram').textContent=Math.round(ram)+' GB'
document.getElementById('h-dataFP').textContent=Math.round(dataFP)+' GB'
document.getElementById('h-overhead').textContent=Math.round(overhead)+' GB'
document.getElementById('h-cpu').textContent=cpu+' cores'
document.getElementById('h-storage').textContent=Math.round(storage)+' GB'
document.getElementById('h-nodeType').textContent=nodeType
document.getElementById('h-clusterRec').textContent=Math.round(ram)+'GB RAM, '+cpu+' cores, '+(ha?'+ standby (HA) ':'')+(dr?'+ async replicatie (DR)':'')+' — totaal ~'+Math.round(storage/1000)+'TB opslag'
}
function calcWarehouse(){
var vol=+document.getElementById('w-volume').value||0
var ingest=+document.getElementById('w-ingest').value||0
var queries=+document.getElementById('w-queries').value||20
var complexity=document.getElementById('w-complexity').value
var arch=document.getElementById('w-arch').value
var compressRatio=5
var compressed=vol/compressRatio
var totalTB=compressed*1.5
var complexityFactor=complexity==='simple'?0.5:complexity==='complex'?2:1
var computeUnits=Math.ceil(queries*complexityFactor*0.5)
var ram=computeUnits*32
var vcores=computeUnits*8
var nodes=Math.max(2,Math.ceil(computeUnits/4))
document.getElementById('w-compressed').textContent=compressed.toFixed(1)+' TB'
document.getElementById('w-total').textContent=totalTB.toFixed(1)+' TB'
document.getElementById('w-compute').textContent=computeUnits
document.getElementById('w-ram').textContent=ram+' GB'
document.getElementById('w-vcores').textContent=vcores
document.getElementById('w-nodes').textContent=nodes
document.getElementById('w-clusterRec').textContent=nodes+' nodes, '+ram+'GB RAM, '+vcores+' vCores, '+arch+' — '+(arch==='lakehouse'?'Trino + Iceberg':'Snowflake')+' voor '+queries+' concurrent queries'
}
function calcVector(){
var vecM=+document.getElementById('v-vectors').value||0
var dims=+document.getElementById('v-dims').value||768
var idx=document.getElementById('v-index').value
var replicas=+document.getElementById('v-replicas').value||2
var rawGB=vecM*dims*4/1e9
var idxOverhead=idx==='flat'?rawGB*0.5:idx==='ivf'?rawGB*0.8:rawGB*1.3
var totalRam=rawGB+idxOverhead
var totalRep=totalRam*replicas
var nodes=Math.ceil(totalRep/128)
var nodeType=totalRep<=128?'64GB':totalRep<=256?'128GB':totalRep<=512?'256GB':'512GB+ cluster'
document.getElementById('v-raw').textContent=rawGB.toFixed(1)+' GB'
document.getElementById('v-indexOverhead').textContent=idxOverhead.toFixed(1)+' GB'
document.getElementById('v-ram').textContent=totalRam.toFixed(1)+' GB'
document.getElementById('v-total').textContent=totalRep.toFixed(1)+' GB'
document.getElementById('v-nodes').textContent=nodes
document.getElementById('v-nodeType').textContent=nodeType
document.getElementById('v-clusterRec').textContent=nodes+' nodes × '+nodeType+' (HNSW, '+(vecM*replicas).toFixed(1)+'M vectors)'
}
function calcK8s(){
var pods=+document.getElementById('k8-pods').value||0
var cpuReq=+document.getElementById('k8-cpu').value||1
var memReq=+document.getElementById('k8-mem').value||4
var nodeSel=document.getElementById('k8-nodeType').value
var overcommit=+document.getElementById('k8-overcommit').value||1.5
var nodeSpecs={small:{cores:16,ram:64},medium:{cores:32,ram:128},large:{cores:64,ram:256},gpu:{cores:32,ram:256}}
var ns=nodeSpecs[nodeSel]
var totalCpu=Math.ceil(pods*cpuReq/overcommit)
var totalMem=Math.ceil(pods*memReq/overcommit)
var maxPodsPerNode=110
var podCpuCap=Math.floor(ns.cores/cpuReq*overcommit)
var podMemCap=Math.floor(ns.ram/memReq*overcommit)
var nodeCap=Math.min(maxPodsPerNode,podCpuCap,podMemCap)
var nodes=Math.ceil(pods/nodeCap)
var usage=Math.round(pods/(nodes*nodeCap)*100)
var nodesHA=nodes+1
document.getElementById('k8-totalCpu').textContent=totalCpu+' cores ('+(pods*cpuReq)+' actual)'
document.getElementById('k8-totalMem').textContent=totalMem+' GB ('+(pods*memReq)+' actual)'
document.getElementById('k8-nodeCap').textContent=nodeCap+' pods'
document.getElementById('k8-nodes').textContent=nodes
document.getElementById('k8-usage').textContent=usage+'%'
document.getElementById('k8-nodesHA').textContent=nodesHA+' (N+1)'
document.getElementById('k8-clusterRec').textContent=nodes+'× '+nodeSel+' ('+ns.cores+'c/'+ns.ram+'GB) — '+(nodes*ns.ram)+'GB RAM, '+(nodes*ns.cores)+' cores — capacity: '+usage+'%'
}
function calcHadoop(){
var vol=+document.getElementById('hd-volume').value||0
var replicas=+document.getElementById('hd-replicas').value||3
var block=+document.getElementById('hd-block').value||256
var disk=+document.getElementById('hd-disk').value||16
var compress=+document.getElementById('hd-compress').value||0.5
var effectiveTB=vol*compress
var totalRepTB=effectiveTB*replicas
var perNodeTB=disk*0.85
var nodes=Math.ceil(totalRepTB/perNodeTB)
var blocks=Math.ceil(effectiveTB*1024*1024/block)
var mrCap=nodes*Math.floor(disk*0.1)
document.getElementById('hd-effective').textContent=effectiveTB.toFixed(1)+' TB'
document.getElementById('hd-totalRep').textContent=totalRepTB.toFixed(1)+' TB'
document.getElementById('hd-perNode').textContent=Math.round(perNodeTB*100)/100+' TB'
document.getElementById('hd-nodes').textContent=nodes
document.getElementById('hd-blocks').textContent=blocks.toLocaleString()
document.getElementById('hd-mr').textContent=mrCap+' GB input slot'
document.getElementById('hd-clusterRec').textContent=nodes+' nodes × '+disk+'TB ('+Math.round(nodes*disk*0.85)+'TB usable) — replicatie '+replicas+'x — '+block+'MB blocks'
}
function switchPlatform(p){
document.querySelectorAll('.sb-item').forEach(function(b){b.classList.remove('active')})
document.querySelectorAll('.calc-panel').forEach(function(p2){p2.style.display='none'})
var btn=document.querySelector('.sb-item[data-platform="'+p+'"]')
if(btn)btn.classList.add('active')
var panel=document.getElementById('panel-'+p)
if(panel)panel.style.display='block'
document.getElementById('platformSelect').value=p
var fns={kafka:calcKafka,spark:calcSpark,minio:calcMinio,'sap-hana':calcHana,warehouse:calcWarehouse,vector:calcVector,k8s:calcK8s,hadoop:calcHadoop}
if(fns[p])setTimeout(fns[p],50)
}
document.querySelectorAll('.sb-item').forEach(function(btn){
btn.addEventListener('click',function(){switchPlatform(this.dataset.platform)})
})
calcKafka()
</script>
<script src="/js/theme.js"></script><script>lucide.createIcons()</script></body>
</html>
+489
View File
@@ -0,0 +1,489 @@
@import url('neon.css');
:root {
--bg: #0a1628; --bg-card: #0f1f3a; --bg-nav: #0c1a30;
--border: rgba(0,212,255,0.15); --border-light: rgba(0,212,255,0.08);
--text: #e8f4ff; --text-muted: #7eb8d4; --text-dim: #4a7a99;
--accent: #00d4ff; --accent-hover: #4de8ff;
--green: #00ff88; --green-bg: #0a2a1a;
--red: #ff6b8a; --red-bg: #2a0a14;
--orange: #ffb347; --orange-bg: #2a1a0a;
--purple: #a855f7; --purple-bg: #1a0a2a;
--cyan: #00d4ff; --cyan-bg: #0a1a2a;
--pink: #ff6ec7; --pink-bg: #2a0a1f;
--radius: 8px; --radius-lg: 14px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; min-height: 100vh; }
.container { max-width: 960px; margin: 0 auto; padding: 1.5rem; }
/* Navbar - Premium Redesign */
.navbar { background: var(--bg-nav); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100; backdrop-filter: blur(8px); }
.nav-inner { max-width: 100%; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; padding: .35rem 1rem; min-height: 46px; flex-wrap: wrap; gap: .35rem; }
.nav-brand { display: flex; align-items: center; gap: .4rem; color: var(--text); text-decoration: none; font-weight: 700; font-size: .85rem; flex-shrink: 0; }
.nav-brand:hover { color: var(--accent); transition: color .2s; }
.nav-logo { height: 28px; width: auto; }
.login-logo-img { height: 80px; width: auto; margin-bottom: .5rem; }
.nav-links { display: flex; gap: 1px; align-items: center; flex-wrap: wrap; }
.nav-link {
display: inline-flex; align-items: center; gap: .35rem;
padding: .32rem .55rem; border-radius: 7px; color: var(--text-muted);
text-decoration: none; font-size: .72rem; font-weight: 500; transition: all .18s;
border-bottom: 2px solid transparent; position: relative;
}
.nav-link svg { width: 14px; height: 14px; flex-shrink: 0; }
.nav-link:hover { color: var(--text); background: var(--border-light); }
.nav-link.active { color: var(--text); background: var(--border-light); font-weight: 600; border-bottom-color: var(--accent); }
.nav-link.logout { color: var(--red); }
.nav-link.logout:hover { background: var(--red-bg); color: var(--red); }
.nav-link.logout.active { border-bottom-color: var(--red); }
/* Colorful nav-link accents */
.nav-link.cl-dash:hover,.nav-link.cl-dash.active { border-bottom-color: var(--accent); color: var(--accent); }
.nav-link.cl-clients:hover,.nav-link.cl-clients.active { border-bottom-color: var(--green); color: var(--green); }
.nav-link.cl-eng:hover,.nav-link.cl-eng.active { border-bottom-color: var(--purple); color: var(--purple); }
.nav-link.cl-fin:hover,.nav-link.cl-fin.active { border-bottom-color: var(--orange); color: var(--orange); }
.nav-link.cl-market:hover,.nav-link.cl-market.active { border-bottom-color: var(--cyan); color: var(--cyan); }
.nav-link.cl-tasks:hover,.nav-link.cl-tasks.active { border-bottom-color: var(--pink); color: var(--pink); }
.nav-link.cl-data:hover,.nav-link.cl-data.active { border-bottom-color: var(--green); color: var(--green); }
.nav-link.cl-settings:hover,.nav-link.cl-settings.active { border-bottom-color: var(--text-muted); color: var(--text); }
.nav-icon-btn{display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .45rem;font-size:.68rem;text-decoration:none;color:var(--text-muted);border-radius:6px;background:transparent;border:none;cursor:pointer;font-family:inherit;transition:all .15s}
.nav-icon-btn:hover{color:var(--text);background:var(--border-light)}
.nav-icon-btn svg{width:15px;height:15px}
/* Dashboard */
.dash-header { margin-bottom: 1.5rem; }
.dash-header h1 { font-size: 1.5rem; color: var(--text); font-weight: 600; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .75rem; margin-bottom: 1.5rem; }
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; }
.stat-number { font-size: 1.75rem; font-weight: 700; color: var(--accent); }
.stat-card.green .stat-number { color: var(--green); }
.stat-card.orange .stat-number { color: var(--orange); }
.stat-card.purple .stat-number { color: var(--purple); }
.stat-label { font-size: .75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .3px; margin-top: 2px; }
/* Client grid */
.client-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: .75rem; }
.client-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1rem; text-decoration: none; color: inherit; transition: all .15s;
}
.client-card:hover { border-color: var(--text-dim); }
.client-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; }
.client-card-header h4 { font-size: .9rem; color: var(--text); font-weight: 600; }
.client-meta { font-size: .8rem; color: var(--text-muted); }
.client-meta.warn { color: var(--orange); }
.client-meta.small { font-size: .7rem; }
/* Status badges */
.status-badge { display: inline-block; padding: .15rem .5rem; border-radius: 999px; font-size: .65rem; font-weight: 500; border: 1px solid transparent; }
.status-badge.lead { border-color: var(--accent); color: var(--accent); }
.status-badge.active { border-color: var(--green); color: var(--green); }
.status-badge.paused { border-color: var(--orange); color: var(--orange); }
.status-badge.archived { border-color: var(--text-dim); color: var(--text-dim); }
.status-badge.planned { border-color: var(--accent); color: var(--accent); }
.status-badge.in_progress { border-color: var(--green); color: var(--green); }
.status-badge.completed { border-color: var(--purple); color: var(--purple); }
.status-badge.on_hold { border-color: var(--orange); color: var(--orange); }
.status-badge.todo { border-color: var(--text-dim); color: var(--text-dim); }
.status-badge.done { border-color: var(--green); color: var(--green); }
/* Engagement type */
.engagement-type { display: inline-block; padding: .1rem .45rem; border-radius: 3px; font-size: .65rem; font-weight: 500; }
.engagement-type.assessment { background: var(--cyan-bg); color: var(--cyan); }
.engagement-type.architecture { background: var(--purple-bg); color: var(--purple); }
.engagement-type.implementation { background: var(--green-bg); color: var(--green); }
.engagement-type.monitoring { background: var(--orange-bg); color: var(--orange); }
.engagement-type.consulting { background: var(--pink-bg); color: var(--pink); }
.engagement-type.training { background: var(--red-bg); color: var(--red); }
/* Tables */
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left; padding: .5rem .75rem; font-size: .7rem; color: var(--text-muted);
text-transform: uppercase; letter-spacing: .3px;
border-bottom: 1px solid var(--border); background: var(--bg-card);
}
.data-table td { padding: .5rem .75rem; border-bottom: 1px solid var(--border-light); font-size: .8rem; }
.data-table tr:hover td { background: var(--border-light); }
/* Engagement list */
.engagement-list { display: flex; flex-direction: column; gap: .35rem; }
.engagement-row {
display: flex; align-items: center; gap: .75rem;
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: .6rem .75rem; text-decoration: none; color: inherit; transition: all .15s;
}
.engagement-row:hover { border-color: var(--text-dim); }
.engagement-title { flex: 1; font-weight: 500; color: var(--text); font-size: .85rem; }
.engagement-client { color: var(--text-muted); font-size: .8rem; }
.engagement-date { color: var(--text-dim); font-size: .75rem; }
/* Page header */
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem; }
.page-header h1 { font-size: 1.3rem; color: var(--text); font-weight: 600; }
.header-actions { display: flex; gap: .5rem; align-items: center; }
.back-link { color: var(--text-muted); text-decoration: none; font-size: .8rem; display: inline-block; margin-bottom: .35rem; transition: all .12s; }
.back-link:hover { color: var(--accent); padding-left: 2px; }
.back-link svg { width: 14px; height: 14px; vertical-align: middle; margin-right: 2px; }
.breadcrumb { display: flex; align-items: center; gap: .35rem; font-size: .75rem; color: var(--text-dim); margin-bottom: .75rem; flex-wrap: wrap; }
.breadcrumb a { color: var(--text-muted); text-decoration: none; transition: color .12s; }
.breadcrumb a:hover { color: var(--accent); }
.breadcrumb .sep { color: var(--border); font-size: .6rem; }
.breadcrumb .current { color: var(--text); font-weight: 500; }
/* Detail grid */
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; margin-bottom: 1.5rem; }
@media (max-width: 640px) { .detail-grid { grid-template-columns: 1fr; } }
.detail-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; }
.detail-card h3 { font-size: .75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .3px; margin-bottom: .75rem; }
/* Forms */
.form-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }
.form-actions { display: flex; gap: .5rem; margin-top: 1rem; }
input, textarea, select {
width: 100%; padding: .45rem .6rem; background: var(--bg);
border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); font-size: .85rem; font-family: inherit; transition: border-color .15s;
}
input:focus, textarea:focus, select:focus {
outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(88,166,255,.15);
}
select { cursor: pointer; }
select option { background: var(--bg); color: var(--text); }
/* Alerts */
.alert { padding: .5rem .75rem; border-radius: var(--radius); margin-bottom: .75rem; font-size: .8rem; }
.alert-error { background: var(--red-bg); border: 1px solid var(--red); color: var(--red); }
/* Advice */
.advice-list { display: flex; flex-direction: column; gap: .35rem; }
.advice-card {
display: flex; align-items: flex-start; gap: .5rem;
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: .6rem .75rem; border-left: 3px solid var(--border);
}
.advice-card.critical { border-left-color: var(--red); }
.advice-card.high { border-left-color: var(--orange); }
.advice-card.medium { border-left-color: var(--accent); }
.advice-card.low { border-left-color: var(--green); }
.advice-prio {
font-size: .55rem; font-weight: 600; text-transform: uppercase;
padding: .1rem .35rem; border-radius: 3px; white-space: nowrap;
}
.advice-prio.critical { background: var(--red-bg); color: var(--red); }
.advice-prio.high { background: var(--orange-bg); color: var(--orange); }
.advice-prio.medium { background: var(--cyan-bg); color: var(--cyan); }
.advice-prio.low { background: var(--green-bg); color: var(--green); }
.advice-body { flex: 1; }
.advice-body p { font-size: .8rem; color: var(--text); }
/* Tasks */
.task-list { display: flex; flex-direction: column; gap: .35rem; }
.task-item {
display: flex; align-items: center; gap: .5rem;
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: .5rem .75rem;
}
.task-item.done { opacity: .5; }
.task-item.done .task-title { text-decoration: line-through; }
.task-status-form select { width: auto; padding: .2rem .4rem; font-size: .7rem; }
.task-body { flex: 1; display: flex; flex-direction: column; gap: .1rem; }
.task-title { font-size: .85rem; color: var(--text); font-weight: 500; }
.task-desc { font-size: .75rem; color: var(--text-muted); }
.task-meta { font-size: .7rem; color: var(--accent); }
.task-prio { font-size: .55rem; font-weight: 600; text-transform: uppercase; padding: .1rem .35rem; border-radius: 3px; }
.task-prio.critical { background: var(--red-bg); color: var(--red); }
.task-prio.high { background: var(--orange-bg); color: var(--orange); }
.task-prio.medium { background: var(--cyan-bg); color: var(--cyan); }
.task-prio.low { background: var(--green-bg); color: var(--green); }
.task-due { font-size: .7rem; color: var(--text-muted); }
.task-due.overdue { color: var(--red); font-weight: 500; }
.task-form { margin-bottom: .75rem; }
.task-form-grid { display: flex; gap: .35rem; align-items: flex-end; flex-wrap: wrap; }
.task-form-grid input, .task-form-grid select { width: auto; min-width: 120px; flex: 1; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .75rem; }
/* Assessment list */
.assess-list { display: flex; flex-direction: column; gap: .35rem; }
.assess-item { display: flex; align-items: center; gap: .75rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .6rem .75rem; }
.assess-date { font-size: .8rem; color: var(--text-muted); }
.assess-id { font-weight: 500; color: var(--text); flex: 1; font-size: .85rem; }
/* Empty state */
.empty-state { text-align: center; padding: 2.5rem 1rem; color: var(--text-muted); }
.empty-state p { font-size: .85rem; }
/* Status form */
.status-form { display: flex; gap: .35rem; align-items: center; }
.status-form select { width: auto; }
/* Buttons */
.btn {
padding: .45rem .9rem; border: none; border-radius: var(--radius);
font-size: .8rem; font-weight: 500; cursor: pointer; transition: all .15s;
text-decoration: none; display: inline-block; text-align: center; line-height: 1.4;
}
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-secondary { background: var(--border-light); color: var(--text-muted); border: 1px solid var(--border); }
.btn-secondary:hover { background: var(--border); color: var(--text); }
.btn-accent { background: var(--accent); color: #fff; }
.btn-accent:hover { background: var(--accent-hover); }
.btn-full { width: 100%; }
.btn-small {
padding: .2rem .5rem; border: none; border-radius: 4px;
font-size: .7rem; cursor: pointer; background: var(--border-light); color: var(--text-muted);
text-decoration: none; display: inline-block;
}
.btn-small:hover { background: var(--border); color: var(--text); }
.btn-small.danger { background: var(--red-bg); color: var(--red); }
.btn-small.danger:hover { background: #4d1a1c; }
/* Login */
.login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 1.5rem; }
.login-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 2rem; width: 100%; max-width: 360px; }
.login-logo { text-align: center; margin-bottom: 1.5rem; }
.login-logo h1 { font-size: 1.25rem; color: var(--text); }
/* Header */
.header { margin-bottom: 1.5rem; }
.logo { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; }
.logo-icon { font-size: 1.5rem; font-weight: 800; color: var(--accent); }
.logo h1 { font-size: 1.25rem; font-weight: 600; color: var(--text); }
.subtitle { font-size: .8rem; color: var(--text-muted); }
/* Progress bar */
.progress-bar { width: 100%; height: 3px; background: var(--border-light); border-radius: 2px; overflow: hidden; margin-bottom: 1rem; }
.progress-fill { height: 100%; background: var(--accent); transition: width .3s; border-radius: 2px; }
/* Assessment steps */
.step { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; }
.step-header { margin-bottom: 1rem; }
.step-number { font-size: .7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; }
.step-header h2 { font-size: 1.1rem; font-weight: 600; color: var(--text); margin-top: .15rem; }
.fields { display: flex; flex-direction: column; gap: .75rem; }
.field label { display: block; font-size: .8rem; font-weight: 500; color: var(--text-muted); margin-bottom: .25rem; }
.step-actions { display: flex; justify-content: space-between; margin-top: 1.5rem; }
/* Report */
.report-header { text-align: center; padding: 1.5rem 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; }
.report-org { margin-bottom: 1rem; }
.report-org h1 { font-size: 1.5rem; color: var(--text); }
.report-title-block h2 { font-size: 1.3rem; color: var(--text); margin-bottom: .35rem; }
.report-client, .report-date { color: var(--text-muted); font-size: .85rem; }
.section { margin-bottom: 1.5rem; }
.section-title {
font-size: 1rem; font-weight: 600; color: var(--text);
margin-bottom: .75rem; padding-bottom: .35rem;
border-bottom: 2px solid var(--accent); display: inline-block;
}
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; }
.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; }
.info-table { width: 100%; border-collapse: collapse; }
.info-table td { padding: .4rem .6rem; border-bottom: 1px solid var(--border-light); font-size: .8rem; }
.info-table tr:last-child td { border-bottom: none; }
.info-table td:first-child { width: 35%; color: var(--text-muted); }
.badge { display: inline-block; padding: .15rem .6rem; border-radius: 999px; font-size: .7rem; font-weight: 500; }
.badge.Starter { background: var(--green-bg); color: var(--green); }
.badge.Standard { background: var(--cyan-bg); color: var(--cyan); }
.badge.Advanced { background: var(--purple-bg); color: var(--purple); }
.badge.Enterprise { background: var(--red-bg); color: var(--red); }
.diagram { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; overflow-x: auto; }
.ascii-diagram { font-family: 'Courier New', monospace; font-size: .65rem; line-height: 1.3; color: var(--text-muted); }
.phase { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; margin-bottom: .75rem; }
.phase h4 { color: var(--purple); margin-bottom: .5rem; font-size: .9rem; }
.phase ul { list-style: none; }
.phase li { padding: .25rem 0; color: var(--text); font-size: .85rem; }
.phase li::before { content: '→ '; color: var(--accent); }
.tech-stack { display: flex; flex-direction: column; gap: .35rem; }
.tech-item { display: flex; justify-content: space-between; align-items: center; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .75rem; }
.tech-label { color: var(--text-muted); font-size: .8rem; }
.tech-val { color: var(--text); font-weight: 500; font-size: .85rem; text-align: right; }
.report-footer { text-align: center; padding: 1.5rem 0; border-top: 1px solid var(--border); margin-top: 1.5rem; }
.report-footer p { color: var(--text-muted); font-size: .75rem; margin-bottom: .75rem; }
.link { color: var(--accent); text-decoration: none; }
.link:hover { text-decoration: underline; }
/* Rack */
.rack-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: .75rem; }
.rack-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; text-decoration: none; color: inherit; display: flex; gap: .75rem; transition: all .15s; }
.rack-card:hover { border-color: var(--text-dim); }
.rack-mini { width: 20px; display: flex; flex-direction: column; gap: 2px; }
.rack-mini-u { width: 100%; height: 5px; background: var(--border); border-radius: 1px; }
.rack-mini-u.filled { background: var(--accent); }
.rack-card-body h4 { font-size: .9rem; color: var(--text); }
.rack-layout { display: flex; gap: 1.5rem; align-items: flex-start; }
@media (max-width: 768px) { .rack-layout { flex-direction: column; } }
.rack-view { flex-shrink: 0; width: 240px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.rack-header { background: var(--bg-nav); padding: .35rem .75rem; font-size: .75rem; color: var(--text-muted); text-align: center; border-bottom: 1px solid var(--border); }
.rack-body { display: flex; flex-direction: column; }
.rack-empty { display: flex; align-items: center; padding: 0 .4rem; height: 28px; border-bottom: 1px solid var(--border-light); background: var(--bg); }
.rack-u { width: 22px; font-size: .55rem; color: var(--text-dim); flex-shrink: 0; text-align: right; margin-right: .35rem; }
.rack-device { display: flex; align-items: center; padding: 0 .4rem; gap: .25rem; border-bottom: 1px solid var(--border-light); cursor: pointer; transition: all .15s; font-size: .6rem; overflow: hidden; }
.rack-device:hover { filter: brightness(1.15); }
.rack-device.server { background: linear-gradient(90deg, var(--cyan-bg), var(--bg)); border-left: 2px solid var(--accent); }
.rack-device.switch { background: linear-gradient(90deg, var(--purple-bg), var(--bg)); border-left: 2px solid var(--purple); }
.rack-device.storage { background: linear-gradient(90deg, var(--green-bg), var(--bg)); border-left: 2px solid var(--green); }
.rack-device.firewall { background: linear-gradient(90deg, var(--red-bg), var(--bg)); border-left: 2px solid var(--red); }
.rack-device.patch_panel { background: linear-gradient(90deg, var(--orange-bg), var(--bg)); border-left: 2px solid var(--orange); }
.rack-device.pdu { background: linear-gradient(90deg, var(--cyan-bg), var(--bg)); border-left: 2px solid var(--cyan); }
.rack-device.ups { background: linear-gradient(90deg, var(--purple-bg), var(--bg)); border-left: 2px solid var(--purple); }
.rack-device-name { flex: 1; font-weight: 500; color: var(--text); white-space: nowrap; font-size: .65rem; }
/* Modal */
.modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.7); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-content { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.25rem; width: 90%; max-width: 560px; max-height: 80vh; overflow-y: auto; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .75rem; }
.modal-header h3 { color: var(--text); font-size: 1rem; }
/* Network */
.device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: .5rem; }
.device-card { display: flex; align-items: center; gap: .5rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; text-decoration: none; color: inherit; transition: all .15s; }
.device-card:hover { border-color: var(--text-dim); }
.device-icon { width: 32px; height: 32px; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; }
.device-card.switch .device-icon { background: var(--purple-bg); color: var(--purple); }
.device-card.router .device-icon { background: var(--cyan-bg); color: var(--cyan); }
.device-card.firewall .device-icon { background: var(--red-bg); color: var(--red); }
.device-info { flex: 1; display: flex; flex-direction: column; gap: .1rem; }
.device-info strong { font-size: .85rem; color: var(--text); }
.media-badge { display: inline-block; padding: .1rem .35rem; border-radius: 3px; font-size: .65rem; font-weight: 500; }
.media-badge.copper { background: var(--orange-bg); color: var(--orange); }
.media-badge.sfp, .media-badge.sfp\+ { background: var(--cyan-bg); color: var(--cyan); }
.media-badge.qsfp { background: var(--purple-bg); color: var(--purple); }
.media-badge.dac { background: var(--green-bg); color: var(--green); }
.media-badge.fiber { background: var(--pink-bg); color: var(--pink); }
.conn-list { display: flex; flex-direction: column; gap: .35rem; }
.conn-item { background: var(--bg); border: 1px solid var(--border-light); border-radius: var(--radius); padding: .5rem .6rem; }
.conn-name { font-size: .8rem; color: var(--text); font-weight: 500; }
.conn-path { font-size: .7rem; color: var(--text-muted); }
.conn-meta { font-size: .65rem; color: var(--accent); }
/* Diagram grid */
.diagram-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: .75rem; }
.diagram-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; text-decoration: none; color: inherit; display: flex; gap: .75rem; transition: all .15s; position: relative; }
.diagram-card:hover { border-color: var(--text-dim); }
.diagram-card .diagram-card-body { padding-bottom: 1.5rem; }
.diagram-card-type { width: 36px; height: 36px; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .7rem; flex-shrink: 0; }
.diagram-card-type.data_architecture { background: var(--cyan-bg); color: var(--cyan); }
.diagram-card-type.network_topology { background: var(--purple-bg); color: var(--purple); }
.diagram-card-type.system_architecture { background: var(--green-bg); color: var(--green); }
.diagram-card-body h4 { font-size: .85rem; color: var(--text); }
code { background: var(--border-light); padding: .1rem .3rem; border-radius: 3px; font-size: .75rem; color: var(--accent); }
@media print {
body { background: #fff; color: #000; }
.navbar { display: none; }
.step, .card, .phase, .tech-item, .diagram, .detail-card, .client-card, .advice-card, .engagement-row, .task-item, .assess-item, .stat-card, .data-table { background: #f5f5f5; border-color: #ddd; }
.report-header { border-bottom-color: #ddd; }
.section-title { border-bottom-color: #58a6ff; }
.logo h1, .step-header h2, .report-title-block h2, .report-org h1, .phase h4, .tech-val, .detail-card h3 { color: #000; }
.btn, .nav-links, .header-actions, .btn-small { display: none; }
.info-table td { border-bottom-color: #ddd; }
.ascii-diagram { color: #333; }
}
/* 🔥 PREMIUM ANIMATIONS */
@keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:.7 } }
@keyframes pulse-glow { 0%,100% { box-shadow:0 0 0 0 rgba(88,166,255,.15) } 50% { box-shadow:0 0 15px 4px rgba(88,166,255,.08) } }
@keyframes shimmer { 0% { background-position:-200% 0 } 100% { background-position:200% 0 } }
@keyframes fadeInUp { from { opacity:0; transform:translateY(12px) } to { opacity:1; transform:translateY(0) } }
@keyframes fadeIn { from { opacity:0 } to { opacity:1 } }
@keyframes slideInLeft { from { opacity:0; transform:translateX(-8px) } to { opacity:1; transform:translateX(0) } }
@keyframes gradientShift { 0% { background-position:0% 50% } 50% { background-position:100% 50% } 100% { background-position:0% 50% } }
@keyframes float { 0%,100% { transform:translateY(0) } 50% { transform:translateY(-4px) } }
@keyframes borderGlow { 0%,100% { border-color:var(--border) } 50% { border-color:rgba(88,166,255,.3) } }
.pulse { animation: pulse 2s ease-in-out infinite }
.pulse-glow { animation: pulse-glow 3s ease-in-out infinite }
.fade-in { animation: fadeIn .4s ease-out }
.fade-in-up { animation: fadeInUp .5s ease-out }
.slide-in { animation: slideInLeft .4s ease-out }
.gradient-text {
background: linear-gradient(135deg, var(--accent), var(--purple), var(--accent));
background-size: 200% auto;
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
animation: gradientShift 4s ease infinite;
}
.gradient-border {
position:relative;
background:var(--bg-card); border:1px solid transparent;
background-clip:padding-box;
}
.gradient-border::before {
content:''; position:absolute; inset:0; margin:-1px; border-radius:inherit;
background:linear-gradient(135deg,var(--accent),transparent 40%,var(--purple) 60%,transparent);
z-index:-1; opacity:.3; transition:opacity .3s;
}
.gradient-border:hover::before { opacity:.6 }
/* Card premium hover */
.card-hover {
transition: all .25s cubic-bezier(.4,0,.2,1);
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0,0,0,.12), 0 0 0 1px var(--accent);
border-color: var(--accent);
}
/* KPI pulse dots */
.kpi-dot {
width:8px;height:8px;border-radius:50%;display:inline-block;
background:var(--green);animation:pulse-glow 2s ease-in-out infinite;
}
/* Shimmer loading */
.shimmer {
background:linear-gradient(90deg,var(--border-light) 25%,var(--border) 50%,var(--border-light) 75%);
background-size:200% 100%; animation:shimmer 2s infinite;
border-radius:var(--radius); height:16px;
}
/* Theme toggle */
.theme-toggle {
width:44px;height:24px;border-radius:12px;background:var(--border);
cursor:pointer;position:relative;transition:all .3s;border:none;flex-shrink:0;
}
.theme-toggle::after {
content:'';position:absolute;top:2px;left:2px;width:20px;height:20px;
border-radius:50%;background:var(--accent);transition:all .3s;
box-shadow:0 1px 3px rgba(0,0,0,.2);
}
.theme-toggle.light { background:var(--accent) }
.theme-toggle.light::after { left:22px; background:#fff }
/* Light mode overrides */
body.light-mode {
--bg: #f8f9fa; --bg-card: #ffffff; --bg-nav: #ffffff;
--border: #e0e4e8; --border-light: #f0f2f5;
--text: #1a1d21; --text-muted: #5a5f66; --text-dim: #8b9098;
--accent: #0969da; --accent-hover: #0550ae;
--green: #1a7f37; --green-bg: #dafbe1;
--red: #cf222e; --red-bg: #ffebe9;
--orange: #953800; --orange-bg: #fff1e5;
--purple: #8250df; --purple-bg: #fbefff;
--cyan: #0550ae; --cyan-bg: #ddf4ff;
--pink: #bf3989; --pink-bg: #ffeff7;
}
body.light-mode .navbar { box-shadow: 0 1px 3px rgba(0,0,0,.04), 0 1px 6px rgba(0,0,0,.03) }
body.light-mode .sidebar { box-shadow: 1px 0 3px rgba(0,0,0,.03) }
body.light-mode .card-hover:hover { box-shadow: 0 4px 20px rgba(0,0,0,.06), 0 0 0 1px var(--accent) }
body.light-mode .kpi-card { box-shadow: 0 1px 2px rgba(0,0,0,.02) }
body.light-mode .kpi-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06) }
/* Smooth transitions for theme switch */
body, .navbar, .sidebar, .card, .kpi-card, .client-card, .data-table, input, select, textarea, .btn {
transition: background-color .3s ease, color .3s ease, border-color .3s ease, box-shadow .3s ease;
}
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Taken — Mek-Tech Consulting</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" type="image/svg+xml">
</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="container">
<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="page-header">
<h1>Tasks</h1>
</div>
<% if (tasks.length === 0) { %>
<div class="empty-state"><p>Geen openstaande taken.</p></div>
<% } else { %>
<div class="task-list full">
<% tasks.forEach(function(t) { %>
<div class="task-item <%= t.status %>">
<form action="/tasks/<%= t.id %>/status" method="POST" class="task-status-form">
<select name="status" onchange="this.form.submit()">
<option value="todo" <%= t.status === 'todo' ? 'selected' : '' %>>Todo</option>
<option value="in_progress" <%= t.status === 'in_progress' ? 'selected' : '' %>>Bezig</option>
<option value="done" <%= t.status === 'done' ? 'selected' : '' %>>Done</option>
</select>
</form>
<div class="task-body">
<span class="task-title"><%= t.title %></span>
<% if (t.description) { %><span class="task-desc"><%= t.description %></span><% } %>
<span class="task-meta"><%= t.client_name %> — <%= t.engagement_title %></span>
</div>
<span class="task-prio <%= t.priority %>"><%= t.priority %></span>
<% if (t.due_date) { %>
<span class="task-due <%= new Date(t.due_date) < new Date() && t.status !== 'done' ? 'overdue' : '' %>">
<%= new Date(t.due_date).toLocaleDateString('nl-NL') %>
</span>
<% } %>
<form action="/tasks/<%= t.id %>/delete" method="POST" style="display:inline">
<button class="btn-small danger" type="submit">×</button>
</form>
</div>
<% }) %>
</div>
<% } %>
</div>
<script src="/js/theme.js"></script></body>
</html>
+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Urenregistratie — 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>
.time-tools{display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap}
.time-tools .tt-btn{display:flex;align-items:center;gap:.35rem;padding:.4rem .7rem;border-radius:var(--radius);font-size:.75rem;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--bg-card);color:var(--text-muted);text-decoration:none;transition:all .12s;font-family:inherit}
.time-tools .tt-btn:hover{border-color:var(--accent);color:var(--accent)}
.time-tools .tt-btn svg{width:14px;height:14px}
.alert-sent{background:var(--green-bg);border:1px solid var(--green);color:var(--green);padding:.5rem .75rem;border-radius:var(--radius);font-size:.8rem;margin-bottom:.75rem;display:flex;align-items:center;gap:.4rem}
.alert-err{background:var(--red-bg);border:1px solid var(--red);color:var(--red);padding:.5rem .75rem;border-radius:var(--radius);font-size:.8rem;margin-bottom:.75rem}
.inline-modal{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:1000;align-items:center;justify-content:center}
.inline-modal.open{display:flex}
.inline-modal .im-box{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:1.25rem;width:90%;max-width:480px}
.inline-modal .im-box h3{font-size:.9rem;color:var(--text);margin-bottom:.75rem;display:flex;justify-content:space-between}
.inline-modal .im-close{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:1.1rem}
</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="container">
<% if (sent) { %><div class="alert-sent"><i data-lucide="check-circle" style="width:16px;height:16px"></i> Urenoverzicht per email verzonden!</div><% } %>
<% if (error) { %><div class="alert-err"><%= error %></div><% } %>
<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="page-header"><h1>Urenregistratie</h1>
<div class="time-tools">
<button class="tt-btn" onclick="showTimeModal('invoiceModal')"><i data-lucide="receipt"></i> Factuur maken</button>
<button class="tt-btn" onclick="showTimeModal('emailModal')"><i data-lucide="send"></i> Email overzicht</button>
</div></div>
<div class="stats-grid">
<div class="stat-card"><div class="stat-number"><%= totalHours.toFixed(1) %></div><div class="stat-label">Totaal uren</div></div>
<div class="stat-card green"><div class="stat-number"><%= billableHours.toFixed(1) %></div><div class="stat-label">Facturabel</div></div>
</div>
<div class="form-card" style="margin-bottom:1rem">
<form method="POST" action="/time/new">
<div class="task-form-grid">
<select name="client_id"><option value="">Cliënt...</option><% clients.forEach(function(c){ %><option value="<%= c.id %>"><%= c.name %></option><% }) %></select>
<select name="engagement_id"><option value="">Engagement...</option><% engagements.forEach(function(e){ %><option value="<%= e.id %>"><%= e.title %> (<%= e.client_name %>)</option><% }) %></select>
<input type="date" name="date" required value="<%= new Date().toISOString().split('T')[0] %>">
<input type="number" name="hours" placeholder="Uren" step="0.25" required min="0" max="24">
<input type="text" name="description" placeholder="Omschrijving" style="min-width:200px">
<input type="number" name="hourly_rate" placeholder="Uurtarief" value="150" step="5" style="width:100px">
<label style="display:flex;align-items:center;gap:.3rem;font-size:.8rem;color:var(--text-muted);white-space:nowrap"><input type="checkbox" name="billable" value="1" checked> Facturabel</label>
<button class="btn btn-primary" type="submit">Toevoegen</button>
</div></form></div>
<table class="data-table"><thead><tr><th>Datum</th><th>Gebruiker</th><th>Cliënt</th><th>Engagement</th><th>Hours</th><th>Omschrijving</th><th>Tarief</th><th>Fact.</th><th></th></tr></thead><tbody>
<% entries.forEach(function(e){ %>
<tr><td><%= e.date %></td><td><%= e.username || '-' %></td><td><%= e.client_name || '-' %></td><td><%= e.engagement_title || '-' %></td><td><strong><%= e.hours %></strong></td><td><%= e.description || '' %></td><td>€<%= (e.hourly_rate || 150).toFixed(2) %></td><td><%= e.billable ? '✓' : '-' %></td><td><form method="POST" action="/time/<%= e.id %>/delete" style="display:inline"><button class="btn-small danger">✕</button></form></td></tr>
<% }) %>
</tbody></table></div>
<!-- Invoice Modal -->
<div class="inline-modal" id="invoiceModal"><div class="im-box">
<h3>Factuur maken uit uren <button class="im-close" onclick="hideTimeModal('invoiceModal')">✕</button></h3>
<form method="POST" action="/time/create-invoice">
<select name="client_id" required style="width:100%;margin-bottom:.5rem"><option value="">Kies cliënt...</option><% clients.forEach(function(c){ %><option value="<%= c.id %>"><%= c.name %></option><% }) %></select>
<div style="display:flex;gap:.35rem;margin-bottom:.5rem"><input type="date" name="date_from" placeholder="Van"><input type="date" name="date_to" placeholder="Tot"></div>
<button class="btn btn-primary" type="submit" style="width:100%">Factuur aanmaken</button>
</form></div></div>
<!-- Email Modal -->
<div class="inline-modal" id="emailModal"><div class="im-box">
<h3>Urenoverzicht emailen <button class="im-close" onclick="hideTimeModal('emailModal')">✕</button></h3>
<form method="POST" action="/time/send-email">
<select name="client_id" required style="width:100%;margin-bottom:.5rem"><option value="">Kies cliënt...</option><% clients.forEach(function(c){ %><option value="<%= c.id %>"><%= c.name %></option><% }) %></select>
<div style="display:flex;gap:.35rem;margin-bottom:.5rem"><input type="date" name="date_from" placeholder="Van"><input type="date" name="date_to" placeholder="Tot"></div>
<input type="email" name="to_email" placeholder="Naar emailadres (optioneel)" style="margin-bottom:.5rem">
<button class="btn btn-primary" type="submit" style="width:100%">Versturen</button>
</form></div></div>
<script>
lucide.createIcons();
function showTimeModal(id){document.getElementById(id).classList.add('open')}
function hideTimeModal(id){document.getElementById(id).classList.remove('open')}
document.querySelectorAll('.inline-modal').forEach(function(m){m.addEventListener('click',function(e){if(e.target===this)this.classList.remove('open')})});
</script><script src="/js/theme.js"></script></body></html>
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title><%= editUser ? 'Bewerk' : 'Nieuwe' %> Gebruiker — 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></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="container">
<div class="page-header"><h1><%= editUser ? 'Bewerk Gebruiker' : 'Nieuwe Gebruiker' %></h1></div>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<div class="form-card">
<form method="POST" action="<%= editUser ? '/users/' + editUser.id + '/edit' : '/users/new' %>">
<div class="form-grid">
<div class="field"><label>Gebruikersnaam</label><input type="text" name="username" value="<%= editUser ? editUser.username : '' %>" required></div>
<div class="field"><label>E-mail</label><input type="email" name="email" value="<%= editUser ? editUser.email : '' %>"></div>
<div class="field"><label>Wachtwoord</label><input type="password" name="password" <%= editUser ? 'placeholder="Laat leeg om te behouden"' : 'required' %>></div>
<div class="field"><label>Rol</label><select name="role">
<option value="admin" <%= editUser && editUser.role === 'admin' ? 'selected' : '' %>>Admin</option>
<option value="consultant" <%= editUser && editUser.role === 'consultant' ? 'selected' : '' %>>Consultant</option>
<option value="viewer" <%= editUser && editUser.role === 'viewer' ? 'selected' : '' %>>Viewer</option>
</select></div>
</div>
<div class="form-actions"><button class="btn btn-primary">Opslaan</button><a href="/users" class="btn btn-secondary">Annuleren</a></div>
</form></div></div><script src="/js/theme.js"></script><script>lucide.createIcons()</script></body></html>
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Gebruikers — 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></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="container">
<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="page-header"><h1>Gebruikersbeheer</h1><a href="/users/new" class="btn btn-primary">+ Nieuwe gebruiker</a></div>
<table class="data-table"><thead><tr><th>Gebruikersnaam</th><th>E-mail</th><th>Rol</th><th>Aangemaakt</th><th></th></tr></thead><tbody>
<% users.forEach(function(u){ %>
<tr><td><%= u.username %></td><td><%= u.email || '-' %></td><td><span class="status-badge <%= u.role %>"><%= u.role %></span></td><td><%= new Date(u.created_at).toLocaleDateString('nl-NL') %></td><td><a href="/users/<%= u.id %>/edit" class="btn-small">Bewerk</a></td></tr>
<% }) %>
</tbody></table></div><script src="/js/theme.js"></script><script>lucide.createIcons()</script></body></html>