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
+4
View File
@@ -0,0 +1,4 @@
node_modules
npm-debug.log
.git
.env
+19
View File
@@ -0,0 +1,19 @@
node_modules/
.env
data/*.db
data/*.db-shm
data/*.db-wal
uploads/
public/css/
public/js/
python-*/__pycache__/
python-*/.venv/
python-*/venv/
__pycache__/
*.pyc
.DS_Store
*.log
data/backups/*
data/reports/*
!data/backups/.gitkeep
!data/reports/.gitkeep
+8
View File
@@ -0,0 +1,8 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]
+191
View File
@@ -0,0 +1,191 @@
const { getDb } = require('./db');
const ADVICE_ACTIONS = {
lead_followup: (id) => `/clients/${id}`,
initial_assessment: (id) => `/consulting?client_id=${id}`,
reassessment: (id) => `/consulting?client_id=${id}`,
start_engagement: (id) => `/engagements/new/${id}`,
next_phase: (id) => `/clients/${id}`,
architecture_phase: (id) => `/architecture?client_id=${id}`,
implementation_phase: (id) => `/engagements/new/${id}`,
monitoring_phase: (id) => `/engagements/new/${id}`,
training: (id) => `/engagements/new/${id}`,
overdue_tasks: (id) => `/clients/${id}`,
no_diagram: (id) => id ? `/architecture/new/${id}` : '/architecture/new',
data_quality: (id) => `/quality?client_id=${id}`,
ai_readiness: () => '/ai',
no_time_logged: (id) => `/time?client_id=${id}`,
stale_engagement: (id) => `/clients/${id}`,
leadgen_opportunity: (id) => `/?tab=leadgen`,
proposal_ready: (id) => `/consulting/report/${id}`,
};
function adviceAction(type, clientId, extra) {
const fn = ADVICE_ACTIONS[type];
if (!fn) return clientId ? `/clients/${clientId}` : '/clients';
return fn(clientId, extra);
}
function generateAdvice(clientId) {
const db = getDb();
const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(clientId);
if (!client) return [];
const engagementCount = db.prepare('SELECT COUNT(*) as cnt FROM engagements WHERE client_id = ?').get(clientId);
const lastAssessment = db.prepare('SELECT * FROM assessments WHERE client_id = ? ORDER BY created_at DESC').get(clientId);
const activeEngagements = db.prepare("SELECT * FROM engagements WHERE client_id = ? AND status = 'in_progress'").all(clientId);
const completedEngagements = db.prepare("SELECT * FROM engagements WHERE client_id = ? AND status = 'completed'").all(clientId);
const diagramCount = db.prepare('SELECT COUNT(*) as cnt FROM data_diagrams WHERE client_id = ?').get(clientId);
const timeLogged = db.prepare('SELECT COUNT(*) as cnt FROM time_entries WHERE client_id = ?').get(clientId);
const existingAdvice = db.prepare("SELECT advice_type FROM advice_log WHERE client_id = ? AND dismissed = 0").all(clientId);
const existing = new Set(existingAdvice.map(a => a.advice_type));
const advice = [];
function checkAdvice(type, text, priority) {
if (existing.has(type)) return;
advice.push({
client_id: clientId,
client_name: client.name,
advice_type: type,
advice_text: text,
priority: priority || 'medium',
action_url: adviceAction(type, clientId, lastAssessment?.id),
});
}
if (client.status === 'lead') {
checkAdvice('lead_followup',
`${client.name} is nog een lead. Plan een intakegesprek en start het Enterprise Framework assessment.`,
'high');
}
if (!lastAssessment) {
checkAdvice('initial_assessment',
`Start een eerste assessment voor ${client.name}: infra, data, AI-maturity en roadmap.`,
'critical');
}
if (lastAssessment) {
const daysSince = Math.floor((Date.now() - new Date(lastAssessment.created_at).getTime()) / (1000 * 60 * 60 * 24));
if (daysSince > 90) {
checkAdvice('reassessment',
`Laatste assessment voor ${client.name} was ${daysSince} dagen geleden. Tijd voor her-evaluatie.`,
'medium');
}
if (diagramCount.cnt === 0) {
checkAdvice('no_diagram',
`Assessment afgerond voor ${client.name}, maar nog geen architectuurdiagram. Maak een to-be design.`,
'high');
}
}
if (client.status === 'active' && engagementCount.cnt === 0) {
checkAdvice('start_engagement',
`${client.name} is actief maar heeft geen engagements. Start een discovery- of designtraject.`,
'high');
}
if (client.status === 'active' && activeEngagements.length === 0 && completedEngagements.length > 0) {
checkAdvice('next_phase',
`${client.name} heeft geen lopende projecten. Bespreek volgende fase: monitoring, AI Ops of opschaling.`,
'high');
}
const hasArchitecture = [...completedEngagements, ...activeEngagements].some(e => e.type === 'architecture');
const hasImplementation = [...completedEngagements, ...activeEngagements].some(e => e.type === 'implementation');
const hasMonitoring = [...completedEngagements, ...activeEngagements].some(e => e.type === 'monitoring');
const hasConsulting = [...completedEngagements, ...activeEngagements].some(e => e.type === 'consulting');
if (lastAssessment && !hasArchitecture && !hasConsulting) {
checkAdvice('architecture_phase',
`Assessment klaar voor ${client.name}. Start architectuur- of design engagement.`,
'high');
}
if (hasArchitecture && !hasImplementation) {
checkAdvice('implementation_phase',
`Architectuur gereed voor ${client.name}. Start implementatiefase of POC.`,
'high');
}
if (hasImplementation && !hasMonitoring) {
checkAdvice('monitoring_phase',
`Implementatie afgerond bij ${client.name}. Richt monitoring & observability in.`,
'medium');
}
const hasTraining = [...completedEngagements, ...activeEngagements].some(e => e.type === 'training');
if (hasImplementation && !hasTraining) {
checkAdvice('training',
`${client.name} heeft implementatie gehad. Bied training & knowledge transfer aan.`,
'low');
}
if (lastAssessment && diagramCount.cnt > 0) {
checkAdvice('data_quality',
`Voer een data quality scan uit voor ${client.name} ter onderbouwing van het design.`,
'medium');
}
if (lastAssessment && client.status === 'active') {
checkAdvice('ai_readiness',
`Beoordeel AI/LLM readiness voor ${client.name} en configureer de juiste provider.`,
'low');
}
if (client.status === 'active' && timeLogged.cnt === 0 && engagementCount.cnt > 0) {
checkAdvice('no_time_logged',
`Nog geen uren geregistreerd voor ${client.name}. Log tijd voor facturatie & projectinzicht.`,
'medium');
}
for (const eng of activeEngagements) {
const daysSinceUpdate = Math.floor((Date.now() - new Date(eng.updated_at || eng.created_at).getTime()) / (1000 * 60 * 60 * 24));
if (daysSinceUpdate > 14) {
checkAdvice('stale_engagement',
`Engagement "${eng.title}" bij ${client.name} is ${daysSinceUpdate} dagen niet bijgewerkt. Plan een statusreview.`,
'medium');
break;
}
}
const allTasks = db.prepare(`
SELECT t.* FROM tasks t
JOIN engagements e ON e.id = t.engagement_id
WHERE e.client_id = ? AND t.status IN ('todo','in_progress')
`).all(clientId);
if (allTasks.length > 0) {
const overdue = allTasks.filter(t => t.due_date && new Date(t.due_date) < new Date());
if (overdue.length > 0) {
checkAdvice('overdue_tasks',
`${overdue.length} ta(a)k(en) voor ${client.name} zijn verlopen. Herprioriteer en communiceer.`,
'critical');
}
}
if (client.status === 'lead' && !client.website) {
checkAdvice('leadgen_opportunity',
`Voeg een website toe voor ${client.name} en scan met LeadGen AI op tech signals.`,
'low');
}
return advice;
}
function syncAdviceToDb(clientId, adviceItems) {
if (!adviceItems || adviceItems.length === 0) return;
const db = getDb();
const insert = db.prepare('INSERT INTO advice_log (client_id, advice_type, advice_text, priority) VALUES (?, ?, ?, ?)');
for (const a of adviceItems) {
try {
insert.run(clientId, a.advice_type, a.advice_text, a.priority || 'medium');
} catch (e) {}
}
}
function getAdviceActionUrl(type, clientId, assessmentId) {
return adviceAction(type, clientId, assessmentId);
}
module.exports = { generateAdvice, syncAdviceToDb, getAdviceActionUrl, ADVICE_ACTIONS };
+265
View File
@@ -0,0 +1,265 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const { getDb } = require('../db');
const AI_CONFIG_PATH = path.join(__dirname, '..', 'data', 'ai_config.json');
const PROVIDER_PRESETS = {
openrouter: {
label: 'OpenRouter',
base_url: 'https://openrouter.ai/api/v1',
models: ['qwen/qwen3-coder:free', 'meta-llama/llama-3.3-70b-instruct:free', 'google/gemma-3-27b-it:free', 'anthropic/claude-3.5-sonnet', 'openai/gpt-4o-mini']
},
openai: {
label: 'OpenAI',
base_url: 'https://api.openai.com/v1',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1-mini']
},
anthropic: {
label: 'Anthropic',
base_url: 'https://api.anthropic.com/v1',
models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229']
},
deepseek: {
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-chat', 'deepseek-reasoner']
},
groq: {
label: 'Groq',
base_url: 'https://api.groq.com/openai/v1',
models: ['llama-3.3-70b-versatile', 'mixtral-8x7b-32768']
},
ollama: {
label: 'Ollama (local)',
base_url: 'http://localhost:11434/v1',
models: ['llama3.2', 'mistral', 'codellama', 'qwen2.5']
},
azure: {
label: 'Azure OpenAI',
base_url: '',
models: ['gpt-4o', 'gpt-4o-mini']
},
custom: {
label: 'Custom (OpenAI-compatible)',
base_url: '',
models: []
}
};
function initAiSchema() {
const db = getDb();
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'))
);
`);
const count = db.prepare('SELECT COUNT(*) as c FROM ai_providers').get().c;
if (count === 0 && process.env.DEEPSEEK_API_KEY) {
db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_default)
VALUES (?, ?, ?, ?, ?, 1)`).run(
'OpenRouter Default', 'openrouter', process.env.DEEPSEEK_API_KEY,
'https://openrouter.ai/api/v1', 'qwen/qwen3-coder:free'
);
}
}
let _aiSchemaReady = false;
function ensureAiSchema() {
if (_aiSchemaReady) return;
initAiSchema();
_aiSchemaReady = true;
}
function getProviders() {
ensureAiSchema();
return getDb().prepare('SELECT id, name, provider_type, base_url, default_model, is_active, is_default, created_at, updated_at FROM ai_providers ORDER BY is_default DESC, name').all();
}
function getProvider(id) {
ensureAiSchema();
return getDb().prepare('SELECT * FROM ai_providers WHERE id = ?').get(id);
}
function getDefaultProvider() {
ensureAiSchema();
let p = getDb().prepare('SELECT * FROM ai_providers WHERE is_default = 1 AND is_active = 1 LIMIT 1').get();
if (!p) p = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1 ORDER BY id LIMIT 1').get();
return p;
}
function saveProvider(data) {
ensureAiSchema();
const db = getDb();
if (data.is_default) {
db.prepare('UPDATE ai_providers SET is_default = 0').run();
}
if (data.id) {
db.prepare(`UPDATE ai_providers SET name=?, provider_type=?, api_key=?, base_url=?, default_model=?,
is_active=?, is_default=?, updated_at=datetime('now') WHERE id=?`).run(
data.name, data.provider_type, data.api_key || '', data.base_url || '',
data.default_model || '', data.is_active ? 1 : 0, data.is_default ? 1 : 0, data.id
);
syncAiConfigFile();
return data.id;
}
const r = db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_active, is_default)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
data.name, data.provider_type, data.api_key || '', data.base_url || '',
data.default_model || '', data.is_active !== false ? 1 : 0, data.is_default ? 1 : 0
);
syncAiConfigFile();
return r.lastInsertRowid;
}
function deleteProvider(id) {
ensureAiSchema();
getDb().prepare('DELETE FROM ai_providers WHERE id = ?').run(id);
syncAiConfigFile();
}
function syncAiConfigFile() {
ensureAiSchema();
const providers = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1').all();
const defaultProvider = providers.find(p => p.is_default) || providers[0] || null;
const config = {
default_provider_id: defaultProvider?.id || null,
providers: providers.map(p => ({
id: p.id,
name: p.name,
provider_type: p.provider_type,
api_key: p.api_key,
base_url: p.base_url || PROVIDER_PRESETS[p.provider_type]?.base_url || '',
default_model: p.default_model,
is_default: !!p.is_default
}))
};
const dir = path.dirname(AI_CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(AI_CONFIG_PATH, JSON.stringify(config, null, 2));
const leadgenConfig = {
api_key: defaultProvider?.api_key || process.env.DEEPSEEK_API_KEY || '',
base_url: defaultProvider?.base_url || 'https://openrouter.ai/api/v1',
model: defaultProvider?.default_model || 'qwen/qwen3-coder:free',
provider_type: defaultProvider?.provider_type || 'openrouter'
};
fs.writeFileSync(path.join(dir, 'leadgen_config.json'), JSON.stringify(leadgenConfig, null, 2));
}
function maskKey(key) {
if (!key || key.length < 8) return key ? '••••••••' : '';
return key.slice(0, 6) + '••••' + key.slice(-4);
}
function chatCompletion(messages, options = {}) {
return new Promise((resolve, reject) => {
const provider = options.providerId
? getProvider(options.providerId)
: getDefaultProvider();
if (!provider || !provider.api_key) {
return reject(new Error('Geen actieve AI provider geconfigureerd. Ga naar /ai om een provider toe te voegen.'));
}
const preset = PROVIDER_PRESETS[provider.provider_type] || PROVIDER_PRESETS.custom;
const baseUrl = provider.base_url || preset.base_url;
const model = options.model || provider.default_model || preset.models[0];
let url;
try {
url = new URL('/chat/completions', baseUrl.endsWith('/') ? baseUrl : baseUrl + '/');
} catch (e) {
return reject(new Error('Ongeldige base URL: ' + baseUrl));
}
const body = JSON.stringify({
model,
messages,
temperature: options.temperature ?? 0.7,
max_tokens: options.max_tokens ?? 4096
});
const isHttps = url.protocol === 'https:';
const lib = isHttps ? https : http;
const headers = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
};
if (provider.provider_type === 'anthropic') {
headers['x-api-key'] = provider.api_key;
headers['anthropic-version'] = '2023-06-01';
} else {
headers['Authorization'] = `Bearer ${provider.api_key}`;
}
if (provider.provider_type === 'openrouter') {
headers['HTTP-Referer'] = process.env.BASE_URL || 'http://localhost:3000';
headers['X-Title'] = 'Mek-Tech Platform';
}
const reqOpts = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'POST',
headers,
timeout: 120000
};
const req = lib.request(reqOpts, (res) => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (res.statusCode >= 400) {
reject(new Error(parsed.error?.message || parsed.message || data.slice(0, 200)));
} else {
resolve(parsed);
}
} catch (e) {
reject(new Error('AI response parse error: ' + data.slice(0, 200)));
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('AI request timeout')); });
req.write(body);
req.end();
});
}
module.exports = {
PROVIDER_PRESETS,
initAiSchema: ensureAiSchema,
getProviders,
getProvider,
getDefaultProvider,
saveProvider,
deleteProvider,
syncAiConfigFile,
maskKey,
chatCompletion
};
+953
View File
@@ -0,0 +1,953 @@
<!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<%= (typeof embed !== 'undefined' && embed) ? ' embed-mode' : '' %><%= (typeof rack !== 'undefined' && rack && !(typeof embed !== 'undefined' && embed)) ? ' has-rack-ws-nav' : '' %>">
<% if (typeof rack !== 'undefined' && rack && !(typeof embed !== 'undefined' && embed)) { %>
<%- include('partials/rack-workspace-nav', { rack, flowDiagram, landscapeDiagram, workspaceTab: typeof workspaceTab !== 'undefined' ? workspaceTab : 'flow' }) %>
<% } %>
<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-pri" style="font-size:.65rem;padding:.35rem .75rem">← Terug naar Rack</a>
<a href="/racks/<%= rack.id %>/studio" class="btn btn-sec" style="font-size:.65rem;padding:.35rem .75rem">Studio</a>
<a href="/racks/<%= rack.id %>/report" class="btn btn-sec" style="font-size:.65rem;padding:.35rem .75rem">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){
document.getElementById('diagramMeta').dataset.id=d.id;
if(RACK_ID){
window.history.replaceState(null,'','/racks/'+RACK_ID+'/diagram/'+d.id);
}else if(!DIAGRAM_ID){
window.history.replaceState(null,'','/architecture/'+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>
+164
View File
@@ -0,0 +1,164 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
const { parseDiagramJson, DIAGRAM_TYPE_LABELS } = require('../lib/rack-diagram');
router.get('/designer', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const clientId = req.query.client_id || req.session.activeClientId || null;
const host = process.env.BASE_URL || `http://${req.hostname}:3000`;
const designerBase = host.replace(':3000', ':3001') + '/python-editor/';
res.render('designer-embed', { clients, clientId, designerUrl: designerBase });
});
router.get('/', (req, res) => {
const db = getDb();
const { search, client_id, diagram_type } = req.query;
let sql = `SELECT d.*, c.name as client_name,
json_array_length(d.nodes) as node_count
FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE 1=1`;
const params = [];
if (search) {
sql += ` AND (d.name LIKE ? OR d.description LIKE ?)`;
params.push(`%${search}%`, `%${search}%`);
}
if (client_id) {
sql += ` AND d.client_id = ?`;
params.push(client_id);
}
if (diagram_type) {
sql += ` AND d.diagram_type = ?`;
params.push(diagram_type);
}
sql += ` ORDER BY d.updated_at DESC`;
const diagrams = db.prepare(sql).all(...params);
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('arch-list', { diagrams, clients, filters: req.query });
});
router.get('/new', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('arch-editor', { diagram: null, clients, clientId: null, rack: null, rackDevices: [], linkedDiagrams: [], defaultDiagramType: null, error: null });
});
router.get('/new/:clientId', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('arch-editor', { diagram: null, clients, clientId: parseInt(req.params.clientId), rack: null, rackDevices: [], linkedDiagrams: [], defaultDiagramType: null, error: null });
});
router.post('/save', (req, res) => {
const db = getDb();
const { id, client_id, name, description, diagram_type, nodes, edges, rack_id } = req.body;
if (!name || !client_id) return res.status(400).json({ error: 'Name and client required' });
if (id) {
db.prepare("UPDATE data_diagrams SET name=?, description=?, diagram_type=?, nodes=?, edges=?, rack_id=?, updated_at=datetime('now') WHERE id=?")
.run(name, description, diagram_type || 'data_architecture', nodes || '[]', edges || '[]', rack_id || null, id);
res.json({ id: parseInt(id, 10) });
} else {
const result = db.prepare('INSERT INTO data_diagrams (client_id, name, description, diagram_type, nodes, edges, rack_id) VALUES (?,?,?,?,?,?,?)')
.run(client_id, name, description, diagram_type || 'data_architecture', nodes || '[]', edges || '[]', rack_id || null);
res.json({ id: result.lastInsertRowid });
}
});
router.get('/:id', (req, res) => {
const db = getDb();
const diagram = db.prepare('SELECT d.*, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE d.id = ?').get(req.params.id);
if (!diagram) return res.redirect('/architecture');
if (diagram.rack_id && !req.query.embed && req.query.stay !== '1') {
return res.redirect('/racks/' + diagram.rack_id + '/diagram/' + diagram.id);
}
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
let rack = null;
let rackDevices = [];
let linkedDiagrams = [];
let flowDiagram = null;
let landscapeDiagram = null;
if (diagram.rack_id) {
rack = db.prepare('SELECT r.*, c.name as client_name FROM racks r JOIN clients c ON c.id = r.client_id WHERE r.id = ?').get(diagram.rack_id);
rackDevices = db.prepare('SELECT * FROM rack_devices WHERE rack_id = ? ORDER BY position_u ASC').all(diagram.rack_id);
linkedDiagrams = db.prepare('SELECT id, name, diagram_type FROM data_diagrams WHERE rack_id = ?').all(diagram.rack_id);
flowDiagram = linkedDiagrams.find(d => d.diagram_type === 'application_flow') || null;
landscapeDiagram = linkedDiagrams.find(d => d.diagram_type === 'application_landscape') || null;
}
const { getWorkspaceTab } = require('../lib/rack-diagram');
res.render('arch-editor', {
diagram,
clients,
clientId: diagram.client_id,
rack,
rackDevices,
linkedDiagrams,
flowDiagram,
landscapeDiagram,
defaultDiagramType: null,
workspaceTab: rack ? getWorkspaceTab(diagram.diagram_type) : null,
embed: req.query.embed === '1',
error: null
});
});
router.post('/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM data_diagrams WHERE id = ?').run(req.params.id);
res.redirect('/architecture');
});
router.get('/report/:id', (req, res) => {
const db = getDb();
const diagram = db.prepare('SELECT d.*, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE d.id = ?').get(req.params.id);
if (!diagram) return res.redirect('/architecture');
if (diagram.rack_id) return res.redirect('/racks/' + diagram.rack_id + '/report');
let nodes = JSON.parse(diagram.nodes);
let edges = JSON.parse(diagram.edges);
if (!Array.isArray(nodes) && nodes && nodes.nodes) { edges = nodes.edges || []; nodes = nodes.nodes || []; }
if (!Array.isArray(edges) && edges && edges.nodes) { nodes = edges.nodes || []; edges = edges.edges || []; }
const typeCount = {};
const vendorCount = {};
nodes.forEach(n => {
typeCount[n.type ? n.type.split('-')[0] : 'other'] = (typeCount[n.type ? n.type.split('-')[0] : 'other'] || 0) + 1;
if (n.vendor) vendorCount[n.vendor] = (vendorCount[n.vendor] || 0) + 1;
});
res.render('arch-report', { diagram, nodes, edges, typeCount, vendorCount });
});
router.get('/render/:id', (req, res) => {
const db = getDb();
const diagram = db.prepare('SELECT * FROM data_diagrams WHERE id = ?').get(req.params.id);
if (!diagram) return res.status(404).json({ error: 'not found' });
let nodes = JSON.parse(diagram.nodes);
let edges = JSON.parse(diagram.edges);
// Handle legacy Python-editor format: nodes/edges columns store {"nodes":[...],"edges":[...]}
if (!Array.isArray(nodes) && nodes && nodes.nodes) {
edges = nodes.edges || [];
nodes = nodes.nodes || [];
}
if (!Array.isArray(edges) && edges && edges.nodes) {
nodes = edges.nodes || [];
edges = edges.edges || [];
}
res.json({
name: diagram.name,
description: diagram.description,
diagram_type: diagram.diagram_type,
nodes,
edges
});
});
module.exports = router;
+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>
+279
View File
@@ -0,0 +1,279 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');
const { getDb } = require('../db');
const APP_VERSION = '2.0.0';
const MAX_STORED_BACKUPS = 20;
function getPaths() {
const root = path.join(__dirname, '..');
const dataDir = process.env.DB_PATH
? path.dirname(path.resolve(process.env.DB_PATH))
: path.join(root, 'data');
return {
root,
dataDir,
dbPath: process.env.DB_PATH || path.join(dataDir, 'consulting.db'),
sessionsPath: path.join(dataDir, 'sessions.db'),
uploadsDir: path.join(dataDir, 'uploads'),
backupsDir: path.join(dataDir, 'backups')
};
}
function ensureBackupsDir() {
const { backupsDir } = getPaths();
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
return backupsDir;
}
function checkpointDatabases() {
try {
const db = getDb();
db.pragma('wal_checkpoint(TRUNCATE)');
} catch (e) { /* ignore */ }
const { sessionsPath } = getPaths();
if (fs.existsSync(sessionsPath)) {
try {
const Database = require('better-sqlite3');
const sdb = new Database(sessionsPath);
sdb.pragma('wal_checkpoint(TRUNCATE)');
sdb.close();
} catch (e) { /* ignore */ }
}
}
function collectStats() {
const db = getDb();
const tables = ['clients', 'engagements', 'racks', 'rack_devices', 'data_diagrams', 'projects', 'invoices', 'assessments', 'users'];
const counts = {};
for (const t of tables) {
try {
counts[t] = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c;
} catch (e) {
counts[t] = 0;
}
}
const { dbPath, sessionsPath, uploadsDir, dataDir } = getPaths();
let uploadsBytes = 0;
let uploadFiles = 0;
if (fs.existsSync(uploadsDir)) {
const walk = (dir) => {
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
const st = fs.statSync(p);
if (st.isDirectory()) walk(p);
else { uploadsBytes += st.size; uploadFiles++; }
}
};
walk(uploadsDir);
}
return {
counts,
dbSizeBytes: fs.existsSync(dbPath) ? fs.statSync(dbPath).size : 0,
sessionsSizeBytes: fs.existsSync(sessionsPath) ? fs.statSync(sessionsPath).size : 0,
uploadsBytes,
uploadFiles,
dataDir
};
}
function formatBytes(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
function timestampLabel() {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
function safeBackupName(name) {
return /^mek-tech-backup-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}\.tar\.gz$/.test(name);
}
function pruneOldBackups() {
const list = listBackups();
if (list.length <= MAX_STORED_BACKUPS) return;
const toRemove = list.slice(MAX_STORED_BACKUPS);
for (const b of toRemove) {
try { fs.unlinkSync(b.path); } catch (e) { /* ignore */ }
}
}
function createBackup() {
ensureBackupsDir();
checkpointDatabases();
const paths = getPaths();
const stats = collectStats();
const label = timestampLabel();
const bundleName = `mek-tech-backup-${label}`;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-backup-'));
const bundleDir = path.join(tmpDir, bundleName);
try {
fs.mkdirSync(bundleDir, { recursive: true });
const manifest = {
app: 'mek-tech-consulting-framework',
version: APP_VERSION,
created_at: new Date().toISOString(),
stats: stats.counts,
includes: []
};
if (fs.existsSync(paths.dbPath)) {
fs.copyFileSync(paths.dbPath, path.join(bundleDir, 'consulting.db'));
manifest.includes.push('consulting.db');
}
if (fs.existsSync(paths.sessionsPath)) {
fs.copyFileSync(paths.sessionsPath, path.join(bundleDir, 'sessions.db'));
manifest.includes.push('sessions.db');
}
if (fs.existsSync(paths.uploadsDir)) {
copyDirSync(paths.uploadsDir, path.join(bundleDir, 'uploads'));
manifest.includes.push('uploads/');
}
fs.writeFileSync(path.join(bundleDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
const archiveName = `${bundleName}.tar.gz`;
const archivePath = path.join(paths.backupsDir, archiveName);
execSync(`tar -czf "${archivePath}" -C "${tmpDir}" "${bundleName}"`, { stdio: 'pipe' });
const size = fs.statSync(archivePath).size;
pruneOldBackups();
return {
filename: archiveName,
path: archivePath,
size,
sizeLabel: formatBytes(size),
created_at: manifest.created_at,
stats: stats.counts
};
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const name of fs.readdirSync(src)) {
const s = path.join(src, name);
const d = path.join(dest, name);
if (fs.statSync(s).isDirectory()) copyDirSync(s, d);
else fs.copyFileSync(s, d);
}
}
function listBackups() {
ensureBackupsDir();
const { backupsDir } = getPaths();
if (!fs.existsSync(backupsDir)) return [];
return fs.readdirSync(backupsDir)
.filter(f => f.endsWith('.tar.gz') && safeBackupName(f))
.map(f => {
const p = path.join(backupsDir, f);
const st = fs.statSync(p);
return {
filename: f,
path: p,
size: st.size,
sizeLabel: formatBytes(st.size),
created_at: st.mtime.toISOString()
};
})
.sort((a, b) => b.created_at.localeCompare(a.created_at));
}
function getBackupPath(filename) {
if (!safeBackupName(filename)) return null;
const p = path.join(getPaths().backupsDir, filename);
return fs.existsSync(p) ? p : null;
}
function deleteBackup(filename) {
const p = getBackupPath(filename);
if (!p) return false;
fs.unlinkSync(p);
return true;
}
function validateArchive(archivePath) {
if (!fs.existsSync(archivePath)) throw new Error('Backupbestand niet gevonden');
const listing = execSync(`tar -tzf "${archivePath}"`, { encoding: 'utf8' });
if (!listing.includes('manifest.json')) throw new Error('Ongeldig backup: manifest.json ontbreekt');
if (!listing.includes('consulting.db')) throw new Error('Ongeldig backup: consulting.db ontbreekt');
return listing;
}
function restoreBackup(archivePath) {
validateArchive(archivePath);
const auto = createBackup();
const paths = getPaths();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-restore-'));
try {
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { stdio: 'pipe' });
const entries = fs.readdirSync(tmpDir);
const bundleDir = entries.length === 1 ? path.join(tmpDir, entries[0]) : tmpDir;
const manifestPath = path.join(bundleDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) throw new Error('manifest.json niet gevonden in backup');
checkpointDatabases();
const dbDest = paths.dbPath;
const dbSrc = path.join(bundleDir, 'consulting.db');
if (fs.existsSync(dbSrc)) {
for (const ext of ['-wal', '-shm']) {
const sidecar = dbDest + ext;
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
}
fs.copyFileSync(dbSrc, dbDest);
}
const sessSrc = path.join(bundleDir, 'sessions.db');
if (fs.existsSync(sessSrc)) {
for (const ext of ['-wal', '-shm']) {
const sidecar = paths.sessionsPath + ext;
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
}
fs.copyFileSync(sessSrc, paths.sessionsPath);
}
const uploadsSrc = path.join(bundleDir, 'uploads');
if (fs.existsSync(uploadsSrc)) {
if (fs.existsSync(paths.uploadsDir)) fs.rmSync(paths.uploadsDir, { recursive: true, force: true });
copyDirSync(uploadsSrc, paths.uploadsDir);
}
return {
success: true,
preRestoreBackup: auto.filename,
restored_at: new Date().toISOString()
};
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
module.exports = {
getPaths,
collectStats,
formatBytes,
createBackup,
listBackups,
getBackupPath,
deleteBackup,
restoreBackup,
safeBackupName,
APP_VERSION
};
+50
View File
@@ -0,0 +1,50 @@
const { getDb } = require('../db');
function crmContextMiddleware(req, res, next) {
const db = getDb();
const qClientId = req.query.client_id || req.query.clientId;
if (qClientId && req.session) {
req.session.activeClientId = parseInt(qClientId, 10) || null;
}
const activeClientId = (req.session && req.session.activeClientId) || null;
let activeClient = null;
if (activeClientId) {
activeClient = db.prepare('SELECT id, name, status, industry FROM clients WHERE id = ?').get(activeClientId);
if (!activeClient) req.session.activeClientId = null;
}
const allClients = db.prepare('SELECT id, name, status FROM clients ORDER BY name').all();
res.locals.activeClient = activeClient;
res.locals.activeClientId = activeClient ? activeClient.id : null;
res.locals.allClients = allClients;
res.locals.clientQuery = (path) => {
if (!activeClient) return path;
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}client_id=${activeClient.id}`;
};
next();
}
function getClientHubLinks(clientId) {
const id = clientId;
const q = `?client_id=${id}`;
return [
{ href: `/clients/${id}`, icon: 'eye', label: 'Overzicht' },
{ href: `/engagements/new${q}`, icon: 'briefcase', label: 'Engagement' },
{ href: `/consulting${q}`, icon: 'clipboard-list', label: 'Framework' },
{ href: `/assess/${id}`, icon: 'file-search', label: 'Assessment' },
{ href: `/architecture/new/${id}`, icon: 'network', label: 'Design' },
{ href: `/architecture/designer${q}`, icon: 'pen-tool', label: 'Pro Designer' },
{ href: `/quality${q}`, icon: 'shield-check', label: 'Data Quality' },
{ href: `/racks${q}`, icon: 'server', label: 'Racks' },
{ href: `/networking${q}`, icon: 'share-2', label: 'Netwerk' },
{ href: `/time${q}`, icon: 'clock', label: 'Uren' },
{ href: `/invoices/new${q}`, icon: 'receipt', label: 'Factuur' },
{ href: `/email/compose${q}`, icon: 'mail', label: 'Email' }
];
}
module.exports = { crmContextMiddleware, getClientHubLinks };
+816
View File
@@ -0,0 +1,816 @@
<!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>
<a href="<%= showcaseUrl %>" target="_blank" rel="noopener" class="qa-btn" style="--qa:var(--accent)"><span class="qa-icon"><i data-lucide="external-link" style="color:var(--accent)"></i></span>Live Labs</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>
+148
View File
@@ -0,0 +1,148 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const { getDb, getSettings } = require('../db');
const { generateAdvice, syncAdviceToDb, getAdviceActionUrl } = require('../advice');
const { getConsultingToolkit } = require('../lib/consulting-hub');
const { getServicesStatus } = require('../lib/services');
router.get('/', async (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;
let datacenterCount = 0;
let marketCompaniesCount = 0;
try {
datacenterCount = db.prepare('SELECT COUNT(*) as cnt FROM datacenters').get().cnt;
marketCompaniesCount = db.prepare('SELECT COUNT(*) as cnt FROM companies').get().cnt;
} catch (e) { /* tables may not exist yet */ }
let qualityFileCount = 0;
try {
const uploadDir = path.join(__dirname, '..', 'data', 'uploads');
if (fs.existsSync(uploadDir)) {
qualityFileCount = fs.readdirSync(uploadDir).filter(f => f !== '.gitkeep').length;
}
} catch (e) { /* ignore */ }
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();
for (const client of clients) {
const adviceItems = generateAdvice(client.id);
if (adviceItems.length > 0) syncAdviceToDb(client.id, adviceItems);
}
const activeAdvice = db.prepare(`
SELECT a.*, c.name as client_name
FROM advice_log a
JOIN clients c ON c.id = a.client_id
WHERE a.dismissed = 0
ORDER BY CASE a.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
a.created_at DESC
LIMIT 12
`).all().map(a => ({
...a,
action_url: getAdviceActionUrl(a.advice_type, a.client_id)
}));
const consultingStats = {
assessments: assessmentsCount,
activeConsulting: db.prepare("SELECT COUNT(*) as cnt FROM engagements WHERE status = 'in_progress' AND type IN ('consulting','architecture')").get().cnt,
clientsNeedingAssessment: db.prepare(`
SELECT COUNT(*) as cnt FROM clients c
WHERE c.status IN ('active','lead')
AND NOT EXISTS (SELECT 1 FROM assessments a WHERE a.client_id = c.id)
`).get().cnt,
openAdvice: db.prepare('SELECT COUNT(*) as cnt FROM advice_log WHERE dismissed = 0').get().cnt,
};
const { toolkit, playbooks, maturityLayers } = getConsultingToolkit();
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;
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();
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();
let servicesStatus = {};
try {
servicesStatus = await getServicesStatus();
} catch (e) {
servicesStatus = { consulting: { online: true, label: 'Mek-Tech Core' } };
}
res.render('dashboard', {
tab, clients, stats,
showcaseUrl: getSettings().showcase_url || 'http://192.168.1.247:3010',
totalDiagrams, totalRacks, totalRackDevices, totalNetworkDevices,
datacenterCount, marketCompaniesCount, qualityFileCount,
overdueTasks, totalTasks, completedTasks, assessmentsCount,
recentEngagements, allEngagements, recentAssessments,
engagementByType, engagementByStatus, clientsByIndustry,
activeAdvice,
consultingStats, toolkit, playbooks, maturityLayers,
timeEntries, totalHours, billableHours, invoices, totalInvoiced, totalPaid, totalOutstanding,
racks, netDevices, diagrams,
allTasks, servicesStatus
});
});
module.exports = router;
+4
View File
@@ -0,0 +1,4 @@
{
"default_provider_id": null,
"providers": []
}
+320
View File
@@ -0,0 +1,320 @@
const HARDWARE_LIBRARY = [
// ── HPE ──
{vendor:'HPE',model:'ProLiant DL380 Gen11',type:'server',height:2},
{vendor:'HPE',model:'ProLiant DL360 Gen11',type:'server',height:1},
{vendor:'HPE',model:'ProLiant DL560 Gen11',type:'server',height:2},
{vendor:'HPE',model:'ProLiant DL385 Gen11',type:'server',height:2},
{vendor:'HPE',model:'ProLiant DL325 Gen11',type:'server',height:1},
{vendor:'HPE',model:'ProLiant ML350 Gen11',type:'server',height:5},
{vendor:'HPE',model:'ProLiant XL675d Gen11',type:'server',height:2},
{vendor:'HPE',model:'ProLiant XL645d Gen11',type:'server',height:2},
{vendor:'HPE',model:'Synergy 480 Gen11',type:'server',height:1},
{vendor:'HPE',model:'Synergy 660 Gen11',type:'server',height:2},
{vendor:'HPE',model:'Apollo 4200 Gen11',type:'server',height:4},
{vendor:'HPE',model:'Apollo 6500 Gen10+',type:'server',height:4},
{vendor:'HPE',model:'Apollo 2000 Gen10+',type:'server',height:2},
{vendor:'HPE',model:'3PAR 8450',type:'storage',height:3},
{vendor:'HPE',model:'3PAR 8440',type:'storage',height:3},
{vendor:'HPE',model:'3PAR 8200',type:'storage',height:2},
{vendor:'HPE',model:'Nimble AF60',type:'storage',height:2},
{vendor:'HPE',model:'Nimble AF40',type:'storage',height:2},
{vendor:'HPE',model:'Nimble AF20',type:'storage',height:2},
{vendor:'HPE',model:'Nimble HF60',type:'storage',height:2},
{vendor:'HPE',model:'Alletra 9060',type:'storage',height:4},
{vendor:'HPE',model:'Alletra 6050',type:'storage',height:2},
{vendor:'HPE',model:'Alletra 5050',type:'storage',height:2},
{vendor:'HPE',model:'Aruba 6300M',type:'switch',height:1},
{vendor:'HPE',model:'Aruba 8320',type:'switch',height:1},
{vendor:'HPE',model:'Aruba 8360',type:'switch',height:1},
{vendor:'HPE',model:'Aruba CX 10000',type:'switch',height:1},
{vendor:'HPE',model:'Aruba CX 8325',type:'switch',height:1},
{vendor:'HPE',model:'Aruba CX 6400',type:'switch',height:2},
{vendor:'HPE',model:'Aruba CX 6300',type:'switch',height:1},
{vendor:'HPE',model:'Aruba 2930F',type:'switch',height:1},
{vendor:'HPE',model:'MSA 2060',type:'storage',height:2},
{vendor:'HPE',model:'StoreOnce 5200',type:'tape',height:2},
{vendor:'HPE',model:'StoreOnce 6500',type:'tape',height:4},
{vendor:'HPE',model:'StoreEver LTO-9',type:'tape',height:2},
{vendor:'HPE',model:'iLO Advanced',type:'other',height:0},
// ── Dell ──
{vendor:'Dell',model:'PowerEdge R760',type:'server',height:2},
{vendor:'Dell',model:'PowerEdge R660',type:'server',height:1},
{vendor:'Dell',model:'PowerEdge R760xa',type:'server',height:3},
{vendor:'Dell',model:'PowerEdge R7625',type:'server',height:2},
{vendor:'Dell',model:'PowerEdge R6615',type:'server',height:1},
{vendor:'Dell',model:'PowerEdge R860',type:'server',height:2},
{vendor:'Dell',model:'PowerEdge R960',type:'server',height:4},
{vendor:'Dell',model:'PowerEdge R450',type:'server',height:1},
{vendor:'Dell',model:'PowerEdge R350',type:'server',height:1},
{vendor:'Dell',model:'PowerEdge R250',type:'server',height:1},
{vendor:'Dell',model:'PowerEdge MX760c',type:'server',height:2},
{vendor:'Dell',model:'PowerEdge C6620',type:'server',height:2},
{vendor:'Dell',model:'PowerEdge XR4520c',type:'server',height:1},
{vendor:'Dell',model:'PowerEdge XR5610',type:'server',height:1},
{vendor:'Dell',model:'PowerStore 5200',type:'storage',height:2},
{vendor:'Dell',model:'PowerStore 3200',type:'storage',height:2},
{vendor:'Dell',model:'PowerStore 1200',type:'storage',height:2},
{vendor:'Dell',model:'PowerStore 9200',type:'storage',height:4},
{vendor:'Dell',model:'PowerScale F900',type:'storage',height:2},
{vendor:'Dell',model:'PowerScale H700',type:'storage',height:2},
{vendor:'Dell',model:'PowerScale A300',type:'storage',height:2},
{vendor:'Dell',model:'PowerVault ME5084',type:'storage',height:4},
{vendor:'Dell',model:'PowerVault ME5024',type:'storage',height:2},
{vendor:'Dell',model:'PowerVault ME5012',type:'storage',height:2},
{vendor:'Dell',model:'PowerSwitch Z9332F',type:'switch',height:1},
{vendor:'Dell',model:'PowerSwitch Z9264F',type:'switch',height:1},
{vendor:'Dell',model:'PowerSwitch S5248F',type:'switch',height:1},
{vendor:'Dell',model:'PowerSwitch S5232F',type:'switch',height:1},
{vendor:'Dell',model:'PowerSwitch S5224F',type:'switch',height:1},
{vendor:'Dell',model:'PowerSwitch N3248TEV',type:'switch',height:1},
{vendor:'Dell',model:'PowerSwitch N3248X',type:'switch',height:1},
{vendor:'Dell',model:'PowerSwitch N3224F',type:'switch',height:1},
{vendor:'Dell',model:'PowerVault TL4000',type:'tape',height:2},
{vendor:'Dell',model:'PowerVault ML6000',type:'tape',height:4},
{vendor:'Dell',model:'Wyse 5070',type:'other',height:0},
// ── Lenovo ──
{vendor:'Lenovo',model:'ThinkSystem SR650 V3',type:'server',height:2},
{vendor:'Lenovo',model:'ThinkSystem SR630 V3',type:'server',height:1},
{vendor:'Lenovo',model:'ThinkSystem SR850 V3',type:'server',height:2},
{vendor:'Lenovo',model:'ThinkSystem SR860 V3',type:'server',height:4},
{vendor:'Lenovo',model:'ThinkSystem SR950 V3',type:'server',height:4},
{vendor:'Lenovo',model:'ThinkSystem SR645 V3',type:'server',height:1},
{vendor:'Lenovo',model:'ThinkSystem SR665 V3',type:'server',height:2},
{vendor:'Lenovo',model:'ThinkSystem SR675 V3',type:'server',height:3},
{vendor:'Lenovo',model:'ThinkSystem SR250 V3',type:'server',height:1},
{vendor:'Lenovo',model:'ThinkSystem SR150 V3',type:'server',height:1},
{vendor:'Lenovo',model:'ThinkSystem SD550 V3',type:'server',height:2},
{vendor:'Lenovo',model:'ThinkSystem SD650 V3',type:'server',height:2},
{vendor:'Lenovo',model:'ThinkEdge SE350 V2',type:'server',height:1},
{vendor:'Lenovo',model:'ThinkSystem DE4000H',type:'storage',height:2},
{vendor:'Lenovo',model:'ThinkSystem DE6000H',type:'storage',height:2},
{vendor:'Lenovo',model:'ThinkSystem DE2000H',type:'storage',height:2},
{vendor:'Lenovo',model:'ThinkSystem DM7000',type:'storage',height:4},
{vendor:'Lenovo',model:'ThinkSystem DM5000',type:'storage',height:2},
{vendor:'Lenovo',model:'ThinkSystem DM3000',type:'storage',height:2},
{vendor:'Lenovo',model:'ThinkSystem NE2580',type:'switch',height:1},
{vendor:'Lenovo',model:'ThinkSystem NE1032',type:'switch',height:1},
{vendor:'Lenovo',model:'ThinkSystem NE0164',type:'switch',height:1},
{vendor:'Lenovo',model:'ThinkSystem NE10032',type:'switch',height:1},
{vendor:'Lenovo',model:'RackSwitch G8296',type:'switch',height:2},
{vendor:'Lenovo',model:'RackSwitch G8272',type:'switch',height:1},
{vendor:'Lenovo',model:'ThinkSystem LTO-9',type:'tape',height:2},
// ── Cisco ──
{vendor:'Cisco',model:'UCS X210c M8',type:'server',height:2},
{vendor:'Cisco',model:'UCS B200 M7',type:'server',height:0},
{vendor:'Cisco',model:'UCS B480 M7',type:'server',height:0},
{vendor:'Cisco',model:'UCS C240 M7',type:'server',height:2},
{vendor:'Cisco',model:'UCS C220 M7',type:'server',height:1},
{vendor:'Cisco',model:'UCS C245 M7',type:'server',height:2},
{vendor:'Cisco',model:'UCS C225 M7',type:'server',height:1},
{vendor:'Cisco',model:'UCS C480 M7',type:'server',height:4},
{vendor:'Cisco',model:'UCS X4308',type:'server',height:1},
{vendor:'Cisco',model:'UCS X440p',type:'server',height:2},
{vendor:'Cisco',model:'Nexus 93180YC-FX3',type:'switch',height:1},
{vendor:'Cisco',model:'Nexus 9364C-GX2',type:'switch',height:1},
{vendor:'Cisco',model:'Nexus 92348GC-X',type:'switch',height:1},
{vendor:'Cisco',model:'Nexus 9508',type:'switch',height:8},
{vendor:'Cisco',model:'Nexus 7700',type:'switch',height:10},
{vendor:'Cisco',model:'Nexus 56128P',type:'switch',height:1},
{vendor:'Cisco',model:'Catalyst 9500-48Y4C',type:'switch',height:1},
{vendor:'Cisco',model:'Catalyst 9300-48UXM',type:'switch',height:1},
{vendor:'Cisco',model:'Catalyst 9200-48P',type:'switch',height:1},
{vendor:'Cisco',model:'Catalyst 8200-1N-4T',type:'router',height:1},
{vendor:'Cisco',model:'Catalyst 8500-4X',type:'router',height:2},
{vendor:'Cisco',model:'Firepower 4245',type:'firewall',height:2},
{vendor:'Cisco',model:'Firepower 4115',type:'firewall',height:1},
{vendor:'Cisco',model:'Firepower 1120',type:'firewall',height:1},
{vendor:'Cisco',model:'ASA 5585-X',type:'firewall',height:2},
{vendor:'Cisco',model:'MDS 9710',type:'san',height:8},
{vendor:'Cisco',model:'MDS 9250i',type:'san',height:1},
{vendor:'Cisco',model:'MDS 9148S',type:'san',height:1},
// ── Juniper ──
{vendor:'Juniper',model:'QFX5240',type:'switch',height:1},
{vendor:'Juniper',model:'QFX5220',type:'switch',height:1},
{vendor:'Juniper',model:'QFX5210',type:'switch',height:1},
{vendor:'Juniper',model:'QFX5120',type:'switch',height:1},
{vendor:'Juniper',model:'QFX5110',type:'switch',height:1},
{vendor:'Juniper',model:'EX9250',type:'switch',height:2},
{vendor:'Juniper',model:'EX4650',type:'switch',height:1},
{vendor:'Juniper',model:'EX4400',type:'switch',height:1},
{vendor:'Juniper',model:'EX3400',type:'switch',height:1},
{vendor:'Juniper',model:'EX2300',type:'switch',height:1},
{vendor:'Juniper',model:'ACX710',type:'router',height:1},
{vendor:'Juniper',model:'ACX6360',type:'router',height:1},
{vendor:'Juniper',model:'SRX5800',type:'firewall',height:5},
{vendor:'Juniper',model:'SRX4600',type:'firewall',height:2},
{vendor:'Juniper',model:'SRX1500',type:'firewall',height:1},
{vendor:'Juniper',model:'SRX345',type:'firewall',height:1},
{vendor:'Juniper',model:'NFX350',type:'other',height:1},
{vendor:'Juniper',model:'PTX10008',type:'router',height:8},
// ── Arista ──
{vendor:'Arista',model:'7800R4',type:'switch',height:4},
{vendor:'Arista',model:'7700R4',type:'switch',height:4},
{vendor:'Arista',model:'7280R3',type:'switch',height:1},
{vendor:'Arista',model:'7368X4',type:'switch',height:1},
{vendor:'Arista',model:'7050X4',type:'switch',height:1},
{vendor:'Arista',model:'7050SX3',type:'switch',height:1},
{vendor:'Arista',model:'7150',type:'switch',height:1},
{vendor:'Arista',model:'7010',type:'switch',height:1},
{vendor:'Arista',model:'720XP',type:'switch',height:1},
{vendor:'Arista',model:'ACS-7010T',type:'switch',height:1},
// ── NetApp ──
{vendor:'NetApp',model:'AFF A90',type:'storage',height:3},
{vendor:'NetApp',model:'AFF A70',type:'storage',height:2},
{vendor:'NetApp',model:'AFF A50',type:'storage',height:2},
{vendor:'NetApp',model:'AFF A30',type:'storage',height:2},
{vendor:'NetApp',model:'AFF A20',type:'storage',height:1},
{vendor:'NetApp',model:'AFF C80',type:'storage',height:3},
{vendor:'NetApp',model:'AFF C60',type:'storage',height:2},
{vendor:'NetApp',model:'FAS 9500',type:'storage',height:4},
{vendor:'NetApp',model:'FAS 8700',type:'storage',height:3},
{vendor:'NetApp',model:'FAS 500f',type:'storage',height:2},
{vendor:'NetApp',model:'FAS 2750',type:'storage',height:2},
{vendor:'NetApp',model:'E-Series E4000',type:'storage',height:2},
{vendor:'NetApp',model:'E-Series E2800',type:'storage',height:2},
{vendor:'NetApp',model:'E-Series E5700',type:'storage',height:2},
{vendor:'NetApp',model:'NetApp DS460C',type:'storage',height:4},
{vendor:'NetApp',model:'StorageGrid SG6100',type:'storage',height:2},
// ── Pure Storage ──
{vendor:'Pure Storage',model:'FlashArray//X90',type:'storage',height:3},
{vendor:'Pure Storage',model:'FlashArray//X70',type:'storage',height:3},
{vendor:'Pure Storage',model:'FlashArray//X50',type:'storage',height:2},
{vendor:'Pure Storage',model:'FlashArray//X20',type:'storage',height:2},
{vendor:'Pure Storage',model:'FlashArray//C60',type:'storage',height:3},
{vendor:'Pure Storage',model:'FlashArray//C40',type:'storage',height:2},
{vendor:'Pure Storage',model:'FlashBlade//S500',type:'storage',height:4},
{vendor:'Pure Storage',model:'FlashBlade//S200',type:'storage',height:4},
{vendor:'Pure Storage',model:'FlashStack',type:'other',height:0},
// ── Fortinet ──
{vendor:'Fortinet',model:'FortiGate 4200F',type:'firewall',height:2},
{vendor:'Fortinet',model:'FortiGate 3600F',type:'firewall',height:2},
{vendor:'Fortinet',model:'FortiGate 2600F',type:'firewall',height:1},
{vendor:'Fortinet',model:'FortiGate 1800F',type:'firewall',height:1},
{vendor:'Fortinet',model:'FortiGate 900G',type:'firewall',height:1},
{vendor:'Fortinet',model:'FortiGate 600F',type:'firewall',height:1},
{vendor:'Fortinet',model:'FortiGate 400F',type:'firewall',height:1},
{vendor:'Fortinet',model:'FortiGate 100F',type:'firewall',height:0},
{vendor:'Fortinet',model:'FortiGate 60F',type:'firewall',height:0},
{vendor:'Fortinet',model:'FortiSwitch 424E',type:'switch',height:1},
{vendor:'Fortinet',model:'FortiSwitch 148F',type:'switch',height:1},
{vendor:'Fortinet',model:'FortiSwitch 108E',type:'switch',height:1},
{vendor:'Fortinet',model:'FortiAnalyzer 3000G',type:'other',height:2},
{vendor:'Fortinet',model:'FortiManager 2000G',type:'other',height:1},
{vendor:'Fortinet',model:'FortiMail 6000G',type:'other',height:2},
// ── F5 ──
{vendor:'F5',model:'BIG-IP i15800',type:'loadbalancer',height:2},
{vendor:'F5',model:'BIG-IP i11800',type:'loadbalancer',height:2},
{vendor:'F5',model:'BIG-IP i10600',type:'loadbalancer',height:1},
{vendor:'F5',model:'BIG-IP i7600',type:'loadbalancer',height:1},
{vendor:'F5',model:'BIG-IP i5800',type:'loadbalancer',height:1},
{vendor:'F5',model:'BIG-IP i2800',type:'loadbalancer',height:1},
{vendor:'F5',model:'BIG-IP i2600',type:'loadbalancer',height:1},
{vendor:'F5',model:'BIG-IP VELOS-2',type:'loadbalancer',height:2},
{vendor:'F5',model:'BIG-IP VELOS-4',type:'loadbalancer',height:4},
{vendor:'F5',model:'BIG-IP VELOS-8',type:'loadbalancer',height:8},
{vendor:'F5',model:'BIG-IP Next SPK',type:'loadbalancer',height:1},
// ── Palo Alto ──
{vendor:'Palo Alto',model:'PA-5450',type:'firewall',height:2},
{vendor:'Palo Alto',model:'PA-5260',type:'firewall',height:2},
{vendor:'Palo Alto',model:'PA-5250',type:'firewall',height:2},
{vendor:'Palo Alto',model:'PA-460',type:'firewall',height:1},
{vendor:'Palo Alto',model:'PA-450',type:'firewall',height:1},
{vendor:'Palo Alto',model:'PA-440',type:'firewall',height:1},
{vendor:'Palo Alto',model:'PA-415',type:'firewall',height:0},
{vendor:'Palo Alto',model:'PA-220',type:'firewall',height:0},
{vendor:'Palo Alto',model:'VM-300',type:'other',height:0},
// ── Supermicro ──
{vendor:'Supermicro',model:'SYS-420GP',type:'server',height:4},
{vendor:'Supermicro',model:'SYS-220GP',type:'server',height:2},
{vendor:'Supermicro',model:'SYS-620P',type:'server',height:2},
{vendor:'Supermicro',model:'SYS-520P',type:'server',height:2},
{vendor:'Supermicro',model:'SYS-120P',type:'server',height:1},
{vendor:'Supermicro',model:'SYS-820GP',type:'server',height:4},
{vendor:'Supermicro',model:'AS-4125GS',type:'server',height:4},
{vendor:'Supermicro',model:'AS-2125HS',type:'server',height:2},
{vendor:'Supermicro',model:'AS-1125HS',type:'server',height:1},
{vendor:'Supermicro',model:'SYS-510P',type:'server',height:2},
{vendor:'Supermicro',model:'SuperStorage 6049P',type:'storage',height:4},
{vendor:'Supermicro',model:'SuperStorage 2029P',type:'storage',height:2},
{vendor:'Supermicro',model:'SSG-640SP',type:'storage',height:4},
{vendor:'Supermicro',model:'SSG-540P',type:'storage',height:2},
{vendor:'Supermicro',model:'SYS-E302-9A',type:'other',height:0},
// ── NVIDIA ──
{vendor:'NVIDIA',model:'DGX H100',type:'server',height:4},
{vendor:'NVIDIA',model:'DGX A100',type:'server',height:4},
{vendor:'NVIDIA',model:'DGX B200',type:'server',height:4},
{vendor:'NVIDIA',model:'HGX H100 8-GPU',type:'server',height:4},
{vendor:'NVIDIA',model:'HGX A100 8-GPU',type:'server',height:4},
{vendor:'NVIDIA',model:'HGX H200',type:'server',height:4},
{vendor:'NVIDIA',model:'BlueField-3 DPU',type:'other',height:0},
{vendor:'NVIDIA',model:'ConnectX-7 NIC',type:'other',height:0},
{vendor:'NVIDIA',model:'Quantum QM9700',type:'switch',height:1},
{vendor:'NVIDIA',model:'Quantum QM8700',type:'switch',height:1},
{vendor:'NVIDIA',model:'Spectrum SN5600',type:'switch',height:1},
{vendor:'NVIDIA',model:'Spectrum SN4600',type:'switch',height:1},
{vendor:'NVIDIA',model:'Spectrum SN3420',type:'switch',height:1},
// ── APC ──
{vendor:'APC',model:'Smart-UPS SRT 3000',type:'ups',height:2},
{vendor:'APC',model:'Smart-UPS SRT 5000',type:'ups',height:2},
{vendor:'APC',model:'Smart-UPS SRT 8000',type:'ups',height:3},
{vendor:'APC',model:'Smart-UPS SRT 10000',type:'ups',height:3},
{vendor:'APC',model:'Smart-UPS SRT 15000',type:'ups',height:4},
{vendor:'APC',model:'Symmetra LX 16kVA',type:'ups',height:4},
{vendor:'APC',model:'Symmetra PX 40kVA',type:'ups',height:6},
{vendor:'APC',model:'Symmetra PX 80kVA',type:'ups',height:8},
{vendor:'APC',model:'Rack PDU 2G Switched 0U',type:'pdu',height:0},
{vendor:'APC',model:'Rack PDU 2G Metered 0U',type:'pdu',height:0},
{vendor:'APC',model:'Rack PDU 2G Basic 0U',type:'pdu',height:0},
{vendor:'APC',model:'NetShelter SX 42U',type:'other',height:42},
{vendor:'APC',model:'NetShelter SX 48U',type:'other',height:48},
{vendor:'APC',model:'NetBotz Rack Monitor 200',type:'other',height:0},
// ── Oracle ──
{vendor:'Oracle',model:'Oracle Server X9-2L',type:'server',height:2},
{vendor:'Oracle',model:'Oracle Server X9-2',type:'server',height:1},
{vendor:'Oracle',model:'Oracle Server X8-8',type:'server',height:4},
{vendor:'Oracle',model:'Oracle Server X8-2L',type:'server',height:2},
{vendor:'Oracle',model:'Oracle Server X8-2',type:'server',height:1},
{vendor:'Oracle',model:'SPARC M8-8',type:'server',height:8},
{vendor:'Oracle',model:'SPARC T8-4',type:'server',height:4},
{vendor:'Oracle',model:'SPARC T8-2',type:'server',height:2},
{vendor:'Oracle',model:'SPARC S7-2L',type:'server',height:2},
{vendor:'Oracle',model:'Oracle FS Flash',type:'storage',height:3},
{vendor:'Oracle',model:'Oracle ZFS ZS9-2',type:'storage',height:2},
{vendor:'Oracle',model:'Oracle ZFS ZS7-2',type:'storage',height:2},
{vendor:'Oracle',model:'Oracle FS1-2',type:'storage',height:4},
// ── IBM ──
{vendor:'IBM',model:'IBM Power S1014',type:'server',height:4},
{vendor:'IBM',model:'IBM Power S1022s',type:'server',height:2},
{vendor:'IBM',model:'IBM Power S1024',type:'server',height:4},
{vendor:'IBM',model:'IBM Power L1022',type:'server',height:2},
{vendor:'IBM',model:'IBM Power L1024',type:'server',height:4},
{vendor:'IBM',model:'IBM Power E1050',type:'server',height:4},
{vendor:'IBM',model:'IBM Power E1080',type:'server',height:8},
{vendor:'IBM',model:'IBM FlashSystem 7300',type:'storage',height:4},
{vendor:'IBM',model:'IBM FlashSystem 5200',type:'storage',height:2},
{vendor:'IBM',model:'IBM FlashSystem 9500',type:'storage',height:4},
{vendor:'IBM',model:'IBM FlashSystem 5100',type:'storage',height:2},
{vendor:'IBM',model:'IBM DS8900F',type:'storage',height:8},
{vendor:'IBM',model:'IBM DS8000',type:'storage',height:8},
{vendor:'IBM',model:'IBM SAN64B-7',type:'san',height:1},
{vendor:'IBM',model:'IBM SAN64B-6',type:'san',height:1},
{vendor:'IBM',model:'IBM Storwize V7000',type:'storage',height:2},
{vendor:'IBM',model:'IBM Storwize V5000',type:'storage',height:2}
];
module.exports = HARDWARE_LIBRARY;
+6
View File
@@ -0,0 +1,6 @@
{
"api_key": "",
"base_url": "https://openrouter.ai/api/v1",
"model": "qwen/qwen3-coder:free",
"provider_type": "openrouter"
}
+555
View File
@@ -0,0 +1,555 @@
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: '{}',
showcase_url: 'http://192.168.1.247:3010'
};
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 };
+87
View File
@@ -0,0 +1,87 @@
services:
consulting:
build: .
container_name: mek-tech-consulting
ports:
- "3000:3000"
environment:
- PORT=3000
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-WaQTUw2t}
- SESSION_SECRET=${SESSION_SECRET:-verander-dit-naar-iets-veiligs}
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- SMTP_FROM=${SMTP_FROM:-noreply@mek-tech.nl}
- SLACK_WEBHOOK=${SLACK_WEBHOOK}
- BASE_URL=${BASE_URL:-http://192.168.1.247:3000}
- API_KEY=${API_KEY:-mek-tech-api-key-2024}
- QS_HOST=python-quality
- QS_PORT=3002
- NODE_ENV=production
volumes:
- ./data:/app/data
- ./lib:/app/lib
- ./routes:/app/routes
- ./views:/app/views
- ./public:/app/public
- ./locales:/app/locales
- ./server.js:/app/server.js
- ./db.js:/app/db.js
- ./themes.js:/app/themes.js
- ./advice.js:/app/advice.js
- ./email.js:/app/email.js
- ./settings.js:/app/settings.js
depends_on:
- python-quality
- python-designer
- python-leadgen
restart: unless-stopped
python-designer:
build: ./python-editor
container_name: mek-tech-python-designer
ports:
- "3001:3001"
environment:
- DB_PATH=/app/data/consulting.db
- PY_PORT=3001
volumes:
- ./data:/app/data
restart: unless-stopped
python-quality:
build: ./python-quality
container_name: mek-tech-quality
ports:
- "3002:3002"
environment:
- QS_PORT=3002
volumes:
- ./data:/app/data
restart: unless-stopped
python-leadgen:
build: ./python-leadgen
container_name: mek-tech-leadgen
ports:
- "3003:3003"
environment:
- LEADGEN_PORT=3003
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
volumes:
- ./data:/app/data
restart: unless-stopped
showcase:
build: ../mek-tech-showcase
container_name: mek-tech-showcase
ports:
- "3010:3010"
environment:
- PORT=3010
- CACHE_DIR=/app/cache
volumes:
- ../mek-tech-showcase/cache:/app/cache
restart: unless-stopped
+177
View File
@@ -0,0 +1,177 @@
const Imap = require('imap');
const nodemailer = require('nodemailer');
const { simpleParser } = require('mailparser');
const { getDb } = require('./db');
const util = require('util');
function getAccount(accountId) {
const db = getDb();
return db.prepare('SELECT * FROM email_accounts WHERE id = ?').get(accountId);
}
function getUserAccounts(userId) {
const db = getDb();
return db.prepare('SELECT id, email, name, imap_host, smtp_host, active FROM email_accounts WHERE user_id = ?').all(userId);
}
function createImapConnection(account) {
return new Promise((resolve, reject) => {
const imap = new Imap({
user: account.imap_user,
password: account.imap_pass,
host: account.imap_host,
port: account.imap_port || 993,
tls: account.imap_secure !== 0,
tlsOptions: { rejectUnauthorized: false },
connTimeout: 10000,
authTimeout: 5000
});
imap.once('ready', () => resolve(imap));
imap.once('error', (err) => reject(err));
imap.connect();
});
}
function openMailbox(imap, folder) {
return new Promise((resolve, reject) => {
imap.openBox(folder || 'INBOX', true, (err, box) => {
if (err) return reject(err);
resolve(box);
});
});
}
function searchMessages(imap, criteria) {
return new Promise((resolve, reject) => {
imap.search(criteria, (err, uids) => {
if (err) return reject(err);
resolve(uids || []);
});
});
}
function fetchMessages(imap, uids) {
return new Promise((resolve, reject) => {
if (!uids || uids.length === 0) return resolve([]);
const f = imap.fetch(uids, { bodies: '', struct: true });
const messages = [];
f.on('message', (msg, seqno) => {
const message = { uid: null, seqno, body: '' };
msg.on('body', (stream, info) => {
let buffer = '';
stream.on('data', chunk => buffer += chunk.toString('utf8'));
stream.on('end', () => { message.body = buffer; });
});
msg.on('attributes', attrs => {
message.uid = attrs.uid;
message.flags = attrs.flags || [];
message.date = attrs.date;
message.size = attrs.size;
});
msg.on('end', () => { if (message.uid) messages.push(message); });
});
f.once('error', reject);
f.once('end', () => resolve(messages));
});
}
async function fetchAndStoreMessages(accountId, folder, limit) {
const account = getAccount(accountId);
if (!account) throw new Error('Account not found');
const db = getDb();
const imap = await createImapConnection(account);
const box = await openMailbox(imap, folder || 'INBOX');
const total = Math.min(box.messages.total, limit || 50);
const start = Math.max(1, box.messages.total - total + 1);
const uids = [];
for (let i = start; i <= box.messages.total; i++) uids.push(i);
if (uids.length === 0) { imap.end(); return []; }
const messages = await fetchMessages(imap, uids);
imap.end();
const results = [];
for (const msg of messages) {
try {
const parsed = await simpleParser(msg.body);
const seen = msg.flags.includes('\\Seen');
const flagged = msg.flags.includes('\\Flagged');
const existing = db.prepare('SELECT id FROM email_messages WHERE account_id = ? AND uid = ?').get(accountId, msg.uid);
if (!existing) {
db.prepare(`INSERT INTO email_messages (account_id, uid, folder, subject, from_name, from_addr, to_addr, date, body_text, body_html, attachments, seen, flagged, size)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
accountId, msg.uid, folder || 'INBOX',
(parsed.subject || '').substring(0, 500),
(parsed.from && parsed.from.text || '').substring(0, 200),
(parsed.from && parsed.from.value && parsed.from.value[0] && parsed.from.value[0].address || ''),
(parsed.to && parsed.to.text || '').substring(0, 500),
(parsed.date && parsed.date.toISOString() || msg.date || ''),
(parsed.text || '').substring(0, 100000),
(parsed.html || '').substring(0, 100000),
(parsed.attachments && parsed.attachments.length) || 0,
seen ? 1 : 0, flagged ? 1 : 0, msg.size || 0
);
} else {
db.prepare('UPDATE email_messages SET seen=?, flagged=? WHERE id=?').run(seen ? 1 : 0, flagged ? 1 : 0, existing.id);
}
results.push({
uid: msg.uid, subject: parsed.subject || '', from: parsed.from ? parsed.from.text : '',
date: parsed.date || msg.date, seen, flagged, size: msg.size
});
} catch (e) {
console.error('Error parsing message:', e.message);
}
}
return results;
}
async function sendEmail(accountId, to, subject, text, html, cc, bcc) {
const account = getAccount(accountId);
if (!account) throw new Error('Account not found');
const transporter = nodemailer.createTransport({
host: account.smtp_host,
port: account.smtp_port || 587,
secure: account.smtp_secure === 1,
auth: { user: account.smtp_user || account.imap_user, pass: account.smtp_pass || account.imap_pass }
});
const info = await transporter.sendMail({
from: `"${account.name || account.email}" <${account.email}>`,
to, subject, text, html, cc, bcc
});
return info;
}
async function getMailboxes(accountId) {
const account = getAccount(accountId);
if (!account) return [];
const imap = await createImapConnection(account);
const list = await new Promise((resolve, reject) => {
imap.getBoxes((err, boxes) => {
if (err) return reject(err);
resolve(boxes);
});
});
imap.end();
function flatten(boxes, path) {
const result = [];
for (const [name, box] of Object.entries(boxes)) {
const fullPath = path ? `${path}${box.delimiter}${name}` : name;
result.push({ name: fullPath, delimiter: box.delimiter });
if (box.children) result.push(...flatten(box.children, fullPath));
}
return result;
}
return flatten(list, '');
}
async function getMessageBody(accountId, uid) {
const account = getAccount(accountId);
if (!account) return null;
const imap = await createImapConnection(account);
await openMailbox(imap, 'INBOX');
const messages = await fetchMessages(imap, [uid]);
imap.end();
if (messages.length === 0) return null;
const parsed = await simpleParser(messages[0].body);
return parsed;
}
module.exports = { getAccount, getUserAccounts, createImapConnection, openMailbox, searchMessages, fetchMessages, fetchAndStoreMessages, sendEmail, getMailboxes, getMessageBody };
+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>
+86
View File
@@ -0,0 +1,86 @@
const express = require('express');
const router = express.Router();
const { getDb, logAudit } = require('../db');
router.get('/', (req, res) => {
const db = getDb();
const { type, status, client_id } = req.query;
let sql = `SELECT e.*, c.name as client_name
FROM engagements e JOIN clients c ON c.id = e.client_id WHERE 1=1`;
const params = [];
if (type) { sql += ' AND e.type = ?'; params.push(type); }
if (status) { sql += ' AND e.status = ?'; params.push(status); }
if (client_id) { sql += ' AND e.client_id = ?'; params.push(client_id); }
sql += ' ORDER BY e.updated_at DESC';
const engagements = db.prepare(sql).all(...params);
res.render('engagement-list', { engagements, filters: req.query });
});
router.get('/new', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('engagement-form', { engagement: null, clients, selected: req.query.client_id || null, error: null });
});
router.get('/new/:clientId', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const selected = req.params.clientId;
res.render('engagement-form', { engagement: null, clients, selected, error: null });
});
router.post('/new', (req, res) => {
const db = getDb();
const { client_id, type, title, description, status, start_date, end_date } = req.body;
if (!client_id || !title || !title.trim()) {
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
return res.render('engagement-form', { engagement: null, clients, selected: client_id || null, error: 'Titel is verplicht' });
}
const result = db.prepare(`INSERT INTO engagements (client_id, type, title, description, status, start_date, end_date)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(client_id, type, title, description, status || 'planned', start_date, end_date);
res.redirect('/engagements/' + result.lastInsertRowid);
});
router.get('/:id', (req, res) => {
const db = getDb();
const engagement = db.prepare(`
SELECT e.*, c.name as client_name
FROM engagements e
JOIN clients c ON c.id = e.client_id
WHERE e.id = ?
`).get(req.params.id);
if (!engagement) return res.redirect('/engagements');
const tasks = db.prepare('SELECT * FROM tasks WHERE engagement_id = ? ORDER BY created_at DESC').all(req.params.id);
res.render('engagement-detail', { engagement, tasks });
});
router.get('/:id/edit', (req, res) => {
const db = getDb();
const engagement = db.prepare('SELECT * FROM engagements WHERE id = ?').get(req.params.id);
if (!engagement) return res.redirect('/engagements');
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('engagement-form', { engagement, clients, selected: engagement.client_id, error: null });
});
router.post('/:id/edit', (req, res) => {
const db = getDb();
const { client_id, type, title, description, status, start_date, end_date, outcome } = req.body;
db.prepare(`UPDATE engagements SET client_id=?, type=?, title=?, description=?, status=?, start_date=?, end_date=?, outcome=?, updated_at=datetime('now') WHERE id=?`)
.run(client_id, type, title, description, status, start_date, end_date, outcome, req.params.id);
res.redirect('/engagements/' + req.params.id);
});
router.post('/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM engagements WHERE id = ?').run(req.params.id);
res.redirect('/engagements');
});
router.post('/:id/status', (req, res) => {
const db = getDb();
db.prepare("UPDATE engagements SET status=?, updated_at=datetime('now') WHERE id=?")
.run(req.body.status, req.params.id);
res.redirect('/engagements/' + req.params.id);
});
module.exports = router;
+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 && String(selectedClient) === String(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>
+265
View File
@@ -0,0 +1,265 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const { getDb } = require('../db');
const AI_CONFIG_PATH = path.join(__dirname, '..', 'data', 'ai_config.json');
const PROVIDER_PRESETS = {
openrouter: {
label: 'OpenRouter',
base_url: 'https://openrouter.ai/api/v1',
models: ['qwen/qwen3-coder:free', 'meta-llama/llama-3.3-70b-instruct:free', 'google/gemma-3-27b-it:free', 'anthropic/claude-3.5-sonnet', 'openai/gpt-4o-mini']
},
openai: {
label: 'OpenAI',
base_url: 'https://api.openai.com/v1',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1-mini']
},
anthropic: {
label: 'Anthropic',
base_url: 'https://api.anthropic.com/v1',
models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229']
},
deepseek: {
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-chat', 'deepseek-reasoner']
},
groq: {
label: 'Groq',
base_url: 'https://api.groq.com/openai/v1',
models: ['llama-3.3-70b-versatile', 'mixtral-8x7b-32768']
},
ollama: {
label: 'Ollama (local)',
base_url: 'http://localhost:11434/v1',
models: ['llama3.2', 'mistral', 'codellama', 'qwen2.5']
},
azure: {
label: 'Azure OpenAI',
base_url: '',
models: ['gpt-4o', 'gpt-4o-mini']
},
custom: {
label: 'Custom (OpenAI-compatible)',
base_url: '',
models: []
}
};
function initAiSchema() {
const db = getDb();
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'))
);
`);
const count = db.prepare('SELECT COUNT(*) as c FROM ai_providers').get().c;
if (count === 0 && process.env.DEEPSEEK_API_KEY) {
db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_default)
VALUES (?, ?, ?, ?, ?, 1)`).run(
'OpenRouter Default', 'openrouter', process.env.DEEPSEEK_API_KEY,
'https://openrouter.ai/api/v1', 'qwen/qwen3-coder:free'
);
}
}
let _aiSchemaReady = false;
function ensureAiSchema() {
if (_aiSchemaReady) return;
initAiSchema();
_aiSchemaReady = true;
}
function getProviders() {
ensureAiSchema();
return getDb().prepare('SELECT id, name, provider_type, base_url, default_model, is_active, is_default, created_at, updated_at FROM ai_providers ORDER BY is_default DESC, name').all();
}
function getProvider(id) {
ensureAiSchema();
return getDb().prepare('SELECT * FROM ai_providers WHERE id = ?').get(id);
}
function getDefaultProvider() {
ensureAiSchema();
let p = getDb().prepare('SELECT * FROM ai_providers WHERE is_default = 1 AND is_active = 1 LIMIT 1').get();
if (!p) p = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1 ORDER BY id LIMIT 1').get();
return p;
}
function saveProvider(data) {
ensureAiSchema();
const db = getDb();
if (data.is_default) {
db.prepare('UPDATE ai_providers SET is_default = 0').run();
}
if (data.id) {
db.prepare(`UPDATE ai_providers SET name=?, provider_type=?, api_key=?, base_url=?, default_model=?,
is_active=?, is_default=?, updated_at=datetime('now') WHERE id=?`).run(
data.name, data.provider_type, data.api_key || '', data.base_url || '',
data.default_model || '', data.is_active ? 1 : 0, data.is_default ? 1 : 0, data.id
);
syncAiConfigFile();
return data.id;
}
const r = db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_active, is_default)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
data.name, data.provider_type, data.api_key || '', data.base_url || '',
data.default_model || '', data.is_active !== false ? 1 : 0, data.is_default ? 1 : 0
);
syncAiConfigFile();
return r.lastInsertRowid;
}
function deleteProvider(id) {
ensureAiSchema();
getDb().prepare('DELETE FROM ai_providers WHERE id = ?').run(id);
syncAiConfigFile();
}
function syncAiConfigFile() {
ensureAiSchema();
const providers = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1').all();
const defaultProvider = providers.find(p => p.is_default) || providers[0] || null;
const config = {
default_provider_id: defaultProvider?.id || null,
providers: providers.map(p => ({
id: p.id,
name: p.name,
provider_type: p.provider_type,
api_key: p.api_key,
base_url: p.base_url || PROVIDER_PRESETS[p.provider_type]?.base_url || '',
default_model: p.default_model,
is_default: !!p.is_default
}))
};
const dir = path.dirname(AI_CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(AI_CONFIG_PATH, JSON.stringify(config, null, 2));
const leadgenConfig = {
api_key: defaultProvider?.api_key || process.env.DEEPSEEK_API_KEY || '',
base_url: defaultProvider?.base_url || 'https://openrouter.ai/api/v1',
model: defaultProvider?.default_model || 'qwen/qwen3-coder:free',
provider_type: defaultProvider?.provider_type || 'openrouter'
};
fs.writeFileSync(path.join(dir, 'leadgen_config.json'), JSON.stringify(leadgenConfig, null, 2));
}
function maskKey(key) {
if (!key || key.length < 8) return key ? '••••••••' : '';
return key.slice(0, 6) + '••••' + key.slice(-4);
}
function chatCompletion(messages, options = {}) {
return new Promise((resolve, reject) => {
const provider = options.providerId
? getProvider(options.providerId)
: getDefaultProvider();
if (!provider || !provider.api_key) {
return reject(new Error('Geen actieve AI provider geconfigureerd. Ga naar /ai om een provider toe te voegen.'));
}
const preset = PROVIDER_PRESETS[provider.provider_type] || PROVIDER_PRESETS.custom;
const baseUrl = provider.base_url || preset.base_url;
const model = options.model || provider.default_model || preset.models[0];
let url;
try {
url = new URL('/chat/completions', baseUrl.endsWith('/') ? baseUrl : baseUrl + '/');
} catch (e) {
return reject(new Error('Ongeldige base URL: ' + baseUrl));
}
const body = JSON.stringify({
model,
messages,
temperature: options.temperature ?? 0.7,
max_tokens: options.max_tokens ?? 4096
});
const isHttps = url.protocol === 'https:';
const lib = isHttps ? https : http;
const headers = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
};
if (provider.provider_type === 'anthropic') {
headers['x-api-key'] = provider.api_key;
headers['anthropic-version'] = '2023-06-01';
} else {
headers['Authorization'] = `Bearer ${provider.api_key}`;
}
if (provider.provider_type === 'openrouter') {
headers['HTTP-Referer'] = process.env.BASE_URL || 'http://localhost:3000';
headers['X-Title'] = 'Mek-Tech Platform';
}
const reqOpts = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'POST',
headers,
timeout: 120000
};
const req = lib.request(reqOpts, (res) => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (res.statusCode >= 400) {
reject(new Error(parsed.error?.message || parsed.message || data.slice(0, 200)));
} else {
resolve(parsed);
}
} catch (e) {
reject(new Error('AI response parse error: ' + data.slice(0, 200)));
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('AI request timeout')); });
req.write(body);
req.end();
});
}
module.exports = {
PROVIDER_PRESETS,
initAiSchema: ensureAiSchema,
getProviders,
getProvider,
getDefaultProvider,
saveProvider,
deleteProvider,
syncAiConfigFile,
maskKey,
chatCompletion
};
+279
View File
@@ -0,0 +1,279 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');
const { getDb } = require('../db');
const APP_VERSION = '2.0.0';
const MAX_STORED_BACKUPS = 20;
function getPaths() {
const root = path.join(__dirname, '..');
const dataDir = process.env.DB_PATH
? path.dirname(path.resolve(process.env.DB_PATH))
: path.join(root, 'data');
return {
root,
dataDir,
dbPath: process.env.DB_PATH || path.join(dataDir, 'consulting.db'),
sessionsPath: path.join(dataDir, 'sessions.db'),
uploadsDir: path.join(dataDir, 'uploads'),
backupsDir: path.join(dataDir, 'backups')
};
}
function ensureBackupsDir() {
const { backupsDir } = getPaths();
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
return backupsDir;
}
function checkpointDatabases() {
try {
const db = getDb();
db.pragma('wal_checkpoint(TRUNCATE)');
} catch (e) { /* ignore */ }
const { sessionsPath } = getPaths();
if (fs.existsSync(sessionsPath)) {
try {
const Database = require('better-sqlite3');
const sdb = new Database(sessionsPath);
sdb.pragma('wal_checkpoint(TRUNCATE)');
sdb.close();
} catch (e) { /* ignore */ }
}
}
function collectStats() {
const db = getDb();
const tables = ['clients', 'engagements', 'racks', 'rack_devices', 'data_diagrams', 'projects', 'invoices', 'assessments', 'users'];
const counts = {};
for (const t of tables) {
try {
counts[t] = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c;
} catch (e) {
counts[t] = 0;
}
}
const { dbPath, sessionsPath, uploadsDir, dataDir } = getPaths();
let uploadsBytes = 0;
let uploadFiles = 0;
if (fs.existsSync(uploadsDir)) {
const walk = (dir) => {
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
const st = fs.statSync(p);
if (st.isDirectory()) walk(p);
else { uploadsBytes += st.size; uploadFiles++; }
}
};
walk(uploadsDir);
}
return {
counts,
dbSizeBytes: fs.existsSync(dbPath) ? fs.statSync(dbPath).size : 0,
sessionsSizeBytes: fs.existsSync(sessionsPath) ? fs.statSync(sessionsPath).size : 0,
uploadsBytes,
uploadFiles,
dataDir
};
}
function formatBytes(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
function timestampLabel() {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
function safeBackupName(name) {
return /^mek-tech-backup-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}\.tar\.gz$/.test(name);
}
function pruneOldBackups() {
const list = listBackups();
if (list.length <= MAX_STORED_BACKUPS) return;
const toRemove = list.slice(MAX_STORED_BACKUPS);
for (const b of toRemove) {
try { fs.unlinkSync(b.path); } catch (e) { /* ignore */ }
}
}
function createBackup() {
ensureBackupsDir();
checkpointDatabases();
const paths = getPaths();
const stats = collectStats();
const label = timestampLabel();
const bundleName = `mek-tech-backup-${label}`;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-backup-'));
const bundleDir = path.join(tmpDir, bundleName);
try {
fs.mkdirSync(bundleDir, { recursive: true });
const manifest = {
app: 'mek-tech-consulting-framework',
version: APP_VERSION,
created_at: new Date().toISOString(),
stats: stats.counts,
includes: []
};
if (fs.existsSync(paths.dbPath)) {
fs.copyFileSync(paths.dbPath, path.join(bundleDir, 'consulting.db'));
manifest.includes.push('consulting.db');
}
if (fs.existsSync(paths.sessionsPath)) {
fs.copyFileSync(paths.sessionsPath, path.join(bundleDir, 'sessions.db'));
manifest.includes.push('sessions.db');
}
if (fs.existsSync(paths.uploadsDir)) {
copyDirSync(paths.uploadsDir, path.join(bundleDir, 'uploads'));
manifest.includes.push('uploads/');
}
fs.writeFileSync(path.join(bundleDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
const archiveName = `${bundleName}.tar.gz`;
const archivePath = path.join(paths.backupsDir, archiveName);
execSync(`tar -czf "${archivePath}" -C "${tmpDir}" "${bundleName}"`, { stdio: 'pipe' });
const size = fs.statSync(archivePath).size;
pruneOldBackups();
return {
filename: archiveName,
path: archivePath,
size,
sizeLabel: formatBytes(size),
created_at: manifest.created_at,
stats: stats.counts
};
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const name of fs.readdirSync(src)) {
const s = path.join(src, name);
const d = path.join(dest, name);
if (fs.statSync(s).isDirectory()) copyDirSync(s, d);
else fs.copyFileSync(s, d);
}
}
function listBackups() {
ensureBackupsDir();
const { backupsDir } = getPaths();
if (!fs.existsSync(backupsDir)) return [];
return fs.readdirSync(backupsDir)
.filter(f => f.endsWith('.tar.gz') && safeBackupName(f))
.map(f => {
const p = path.join(backupsDir, f);
const st = fs.statSync(p);
return {
filename: f,
path: p,
size: st.size,
sizeLabel: formatBytes(st.size),
created_at: st.mtime.toISOString()
};
})
.sort((a, b) => b.created_at.localeCompare(a.created_at));
}
function getBackupPath(filename) {
if (!safeBackupName(filename)) return null;
const p = path.join(getPaths().backupsDir, filename);
return fs.existsSync(p) ? p : null;
}
function deleteBackup(filename) {
const p = getBackupPath(filename);
if (!p) return false;
fs.unlinkSync(p);
return true;
}
function validateArchive(archivePath) {
if (!fs.existsSync(archivePath)) throw new Error('Backupbestand niet gevonden');
const listing = execSync(`tar -tzf "${archivePath}"`, { encoding: 'utf8' });
if (!listing.includes('manifest.json')) throw new Error('Ongeldig backup: manifest.json ontbreekt');
if (!listing.includes('consulting.db')) throw new Error('Ongeldig backup: consulting.db ontbreekt');
return listing;
}
function restoreBackup(archivePath) {
validateArchive(archivePath);
const auto = createBackup();
const paths = getPaths();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-restore-'));
try {
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { stdio: 'pipe' });
const entries = fs.readdirSync(tmpDir);
const bundleDir = entries.length === 1 ? path.join(tmpDir, entries[0]) : tmpDir;
const manifestPath = path.join(bundleDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) throw new Error('manifest.json niet gevonden in backup');
checkpointDatabases();
const dbDest = paths.dbPath;
const dbSrc = path.join(bundleDir, 'consulting.db');
if (fs.existsSync(dbSrc)) {
for (const ext of ['-wal', '-shm']) {
const sidecar = dbDest + ext;
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
}
fs.copyFileSync(dbSrc, dbDest);
}
const sessSrc = path.join(bundleDir, 'sessions.db');
if (fs.existsSync(sessSrc)) {
for (const ext of ['-wal', '-shm']) {
const sidecar = paths.sessionsPath + ext;
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
}
fs.copyFileSync(sessSrc, paths.sessionsPath);
}
const uploadsSrc = path.join(bundleDir, 'uploads');
if (fs.existsSync(uploadsSrc)) {
if (fs.existsSync(paths.uploadsDir)) fs.rmSync(paths.uploadsDir, { recursive: true, force: true });
copyDirSync(uploadsSrc, paths.uploadsDir);
}
return {
success: true,
preRestoreBackup: auto.filename,
restored_at: new Date().toISOString()
};
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
module.exports = {
getPaths,
collectStats,
formatBytes,
createBackup,
listBackups,
getBackupPath,
deleteBackup,
restoreBackup,
safeBackupName,
APP_VERSION
};
+90
View File
@@ -0,0 +1,90 @@
/** Mek-Tech Consultancy Hub — toolkits, playbooks & delivery frameworks */
const TOOLKIT = [
{
id: 'discovery',
title: 'Discovery & Assessment',
desc: 'Intake, maturity scans & stakeholder alignment',
color: '#00d4ff',
tools: [
{ icon: 'brain', label: 'Enterprise Framework', href: '/consulting', desc: '5-staps solution architect assessment + roadmap' },
{ icon: 'clipboard-list', label: 'Quick Assessment', href: '/assess', desc: 'Snelle infra/data/AI intake per cliënt' },
{ icon: 'search', label: 'LeadGen AI Scan', href: '/?tab=leadgen', desc: 'Website tech-stack & pain point analyse' },
{ icon: 'building-2', label: 'Market Intel', href: '/market', desc: 'Prospects, datacenters & sector mapping' },
{ icon: 'users', label: 'Cliënt 360°', href: '/clients', desc: 'Volledig klantbeeld & engagement history' },
]
},
{
id: 'architecture',
title: 'Architectuur & Design',
desc: 'Data, systeem & netwerk ontwerp',
color: '#a855f7',
tools: [
{ icon: 'layers', label: 'Architectuur Designer', href: '/architecture', desc: 'Visueel data-architectuur canvas' },
{ icon: 'pen-tool', label: 'Pro Designer', href: '/architecture/designer', desc: 'Geavanceerde Python design editor' },
{ icon: 'server', label: 'Rack & DC Design', href: '/racks', desc: 'Server racks, PDU, koeling & patching' },
{ icon: 'wifi', label: 'Netwerk Topologie', href: '/networking', desc: 'Switches, firewalls & verbindingen' },
{ icon: 'calculator', label: 'Sizing Calculator', href: '/sizing', desc: 'Capaciteit & hardware sizing' },
]
},
{
id: 'data-ai',
title: 'Data, AI & Kwaliteit',
desc: 'Profiling, governance & AI readiness',
color: '#00ff88',
tools: [
{ icon: 'shield-check', label: 'Data Quality', href: '/quality', desc: 'Profiling, validatie & kwaliteitsrapporten' },
{ icon: 'bot', label: 'AI Providers', href: '/ai', desc: 'Multi-provider LLM configuratie' },
{ icon: 'database', label: 'Data Modellen', href: '/quality', desc: 'SQL schema & dbt model editor' },
{ icon: 'file-bar-chart', label: 'Rapporten', href: '/reports', desc: 'Consultancy deliverables exporteren' },
{ icon: 'plug', label: 'Integraties', href: '/integrations', desc: 'Externe systemen koppelen' },
]
},
{
id: 'delivery',
title: 'Delivery & Operations',
desc: 'Projectuitvoering, taken & tijd',
color: '#ffb347',
tools: [
{ icon: 'briefcase', label: 'Engagements', href: '/engagements', desc: 'Projecten per fase beheren' },
{ icon: 'check-square', label: 'Taken & Deadlines', href: '/?tab=tasks', desc: 'Werkpakketten & prioriteiten' },
{ icon: 'clock', label: 'Urenregistratie', href: '/time', desc: 'Billable hours & project tracking' },
{ icon: 'folder-kanban', label: 'Projecten', href: '/projects', desc: 'Portfolio & milestone overzicht' },
{ icon: 'calendar', label: 'Planning', href: '/calendar', desc: 'Afspraken, reviews & deadlines' },
]
},
{
id: 'commercial',
title: 'Commercial & Relatie',
desc: 'Offertes, facturatie & communicatie',
color: '#ff6b8a',
tools: [
{ icon: 'receipt', label: 'Facturatie', href: '/invoices', desc: 'Facturen & betalingsstatus' },
{ icon: 'bar-chart-2', label: 'Finance Dashboard', href: '/finance', desc: 'Omzet, marge & outstanding' },
{ icon: 'mail', label: 'Email Hub', href: '/email', desc: 'Klantcommunicatie & follow-ups' },
{ icon: 'bell', label: 'Notificaties', href: '/notifications', desc: 'Alerts & follow-up reminders' },
{ icon: 'file-text', label: 'Audit Trail', href: '/audit', desc: 'Alle consultancy acties gelogd' },
]
}
];
const PLAYBOOKS = [
{ phase: '1', title: 'Intake & Discovery', steps: ['Kick-off meeting', 'Stakeholder mapping', 'Enterprise assessment', 'Pain points documenteren'], duration: '1-2 weken', color: '#00d4ff' },
{ phase: '2', title: 'Analyse & Design', steps: ['As-is architectuur', 'Gap analyse', 'To-be design', 'Sizing & kostenraming'], duration: '2-4 weken', color: '#a855f7' },
{ phase: '3', title: 'Validatie & Proposal', steps: ['Design review', 'POC / pilot scope', 'Offerte & SOW', 'Go/no-go beslissing'], duration: '1-2 weken', color: '#00ff88' },
{ phase: '4', title: 'Implementatie', steps: ['Projectplan & sprints', 'Infra provisioning', 'Data pipelines', 'Test & acceptatie'], duration: '4-12 weken', color: '#ffb347' },
{ phase: '5', title: 'Run & Optimize', steps: ['Monitoring opzetten', 'Knowledge transfer', 'Retainer / SLA', 'Continuous improvement'], duration: 'Doorlopend', color: '#ff6b8a' },
];
const MATURITY_LAYERS = [
{ name: 'Data Foundation', items: ['Bronnen in kaart', 'Data governance', 'Quality profiling', 'Warehouse / Lakehouse'] },
{ name: 'Platform & Infra', items: ['Cloud / hybrid strategie', 'Netwerk & security', 'Compute sizing', 'Observability'] },
{ name: 'Analytics & AI', items: ['BI maturity', 'ML use cases', 'LLM / GenAI readiness', 'MLOps pipeline'] },
{ name: 'Organisatie', items: ['Team capabilities', 'Change management', 'Training plan', 'Operating model'] },
];
function getConsultingToolkit() {
return { toolkit: TOOLKIT, playbooks: PLAYBOOKS, maturityLayers: MATURITY_LAYERS };
}
module.exports = { getConsultingToolkit, TOOLKIT, PLAYBOOKS, MATURITY_LAYERS };
+50
View File
@@ -0,0 +1,50 @@
const { getDb } = require('../db');
function crmContextMiddleware(req, res, next) {
const db = getDb();
const qClientId = req.query.client_id || req.query.clientId;
if (qClientId && req.session) {
req.session.activeClientId = parseInt(qClientId, 10) || null;
}
const activeClientId = (req.session && req.session.activeClientId) || null;
let activeClient = null;
if (activeClientId) {
activeClient = db.prepare('SELECT id, name, status, industry FROM clients WHERE id = ?').get(activeClientId);
if (!activeClient) req.session.activeClientId = null;
}
const allClients = db.prepare('SELECT id, name, status FROM clients ORDER BY name').all();
res.locals.activeClient = activeClient;
res.locals.activeClientId = activeClient ? activeClient.id : null;
res.locals.allClients = allClients;
res.locals.clientQuery = (path) => {
if (!activeClient) return path;
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}client_id=${activeClient.id}`;
};
next();
}
function getClientHubLinks(clientId) {
const id = clientId;
const q = `?client_id=${id}`;
return [
{ href: `/clients/${id}`, icon: 'eye', label: 'Overzicht' },
{ href: `/engagements/new${q}`, icon: 'briefcase', label: 'Engagement' },
{ href: `/consulting${q}`, icon: 'clipboard-list', label: 'Framework' },
{ href: `/assess/${id}`, icon: 'file-search', label: 'Assessment' },
{ href: `/architecture/new/${id}`, icon: 'network', label: 'Design' },
{ href: `/architecture/designer${q}`, icon: 'pen-tool', label: 'Pro Designer' },
{ href: `/quality${q}`, icon: 'shield-check', label: 'Data Quality' },
{ href: `/racks${q}`, icon: 'server', label: 'Racks' },
{ href: `/networking${q}`, icon: 'share-2', label: 'Netwerk' },
{ href: `/time${q}`, icon: 'clock', label: 'Uren' },
{ href: `/invoices/new${q}`, icon: 'receipt', label: 'Factuur' },
{ href: `/email/compose${q}`, icon: 'mail', label: 'Email' }
];
}
module.exports = { crmContextMiddleware, getClientHubLinks };
+63
View File
@@ -0,0 +1,63 @@
const NAV_SECTIONS = [
{
label: 'CRM',
items: [
{ href: '/', icon: 'layout-dashboard', label: 'Dashboard', match: /^\/(\?.*)?$/ },
{ href: '/clients', icon: 'users', label: 'Cliënten', match: /^\/clients/ },
{ href: '/engagements', icon: 'briefcase', label: 'Engagements', match: /^\/engagements/ },
{ href: '/tasks', icon: 'check-circle', label: 'Taken', match: /^\/tasks/ },
{ href: '/calendar', icon: 'calendar', label: 'Agenda', match: /^\/calendar/ },
{ href: '/email', icon: 'mail', label: 'Email', match: /^\/email/ }
]
},
{
label: 'Delivery',
items: [
{ href: '/consulting', icon: 'clipboard-list', label: 'Framework', match: /^\/consulting/ },
{ href: '/assess', icon: 'file-search', label: 'Assessments', match: /^\/assess/ },
{ href: '/projects', icon: 'folder-kanban', label: 'Projecten', match: /^\/projects/ },
{ href: '/time', icon: 'clock', label: 'Uren', match: /^\/time/ },
{ href: '/invoices', icon: 'receipt', label: 'Facturen', match: /^\/invoices/ },
{ href: '/reports', icon: 'bar-chart-3', label: 'Rapporten', match: /^\/reports/ }
]
},
{
label: 'Data Engineering',
items: [
{ href: '/quality', icon: 'shield-check', label: 'Data Quality', match: /^\/quality/ },
{ href: '/architecture', icon: 'network', label: 'Architecture', match: /^\/architecture/ },
{ href: '/architecture/designer', icon: 'pen-tool', label: 'Pro Designer', match: /^\/architecture\/designer/ },
{ href: '/market', icon: 'radar', label: 'Market Intel', match: /^\/market/ },
{ href: '/sizing', icon: 'cpu', label: 'Sizing', match: /^\/sizing/ }
]
},
{
label: 'Infrastructure',
items: [
{ href: '/racks', icon: 'server', label: 'Racks', match: /^\/racks/ },
{ href: '/networking', icon: 'share-2', label: 'Netwerk', match: /^\/networking/ },
{ href: '/infrastructure', icon: 'building-2', label: 'Infra', match: /^\/infrastructure/ }
]
},
{
label: 'Platform',
items: [
{ href: '/finance', icon: 'wallet', label: 'Finance', match: /^\/finance/ },
{ href: '/ai', icon: 'sparkles', label: 'AI Providers', match: /^\/ai/ },
{ href: '/integrations', icon: 'plug', label: 'Integraties', match: /^\/integrations/ },
{ href: '/settings', icon: 'settings', label: 'Instellingen', match: /^\/settings/ }
]
}
];
function getNavSections(activePath) {
return NAV_SECTIONS.map(section => ({
...section,
items: section.items.map(item => ({
...item,
active: item.match.test(activePath || '/')
}))
}));
}
module.exports = { NAV_SECTIONS, getNavSections };
+124
View File
@@ -0,0 +1,124 @@
const DEVICE_TO_NODE = {
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',
cooling: 'infra-cooling',
patch_panel: 'infra-patch-panel',
tape: 'storage-netapp',
kvm: 'infra-rack',
other: 'infra-rack'
};
const MANUFACTURER_NODE = {
hpe: 'hw-hpe-dl',
dell: 'hw-dell-pe',
lenovo: 'hw-lenovo-ts',
cisco: 'net-catalyst',
juniper: 'net-junper-ex',
arista: 'net-arista',
netapp: 'storage-netapp',
pure: 'storage-pure',
fortinet: 'net-fortinet',
f5: 'net-f5',
palo: 'net-paloalto',
nvidia: 'ai-nvidia',
apc: 'infra-apc-ups'
};
const DIAGRAM_TYPE_LABELS = {
data_architecture: 'Data Architectuur',
network_topology: 'Netwerk Topologie',
system_architecture: 'Systeem Architectuur',
application_flow: 'Applicatie Flow',
application_landscape: 'Applicatie Landschap'
};
function resolveNodeType(device) {
const m = (device.manufacturer || '').toLowerCase();
for (const [key, type] of Object.entries(MANUFACTURER_NODE)) {
if (m.includes(key)) return type;
}
return DEVICE_TO_NODE[device.device_type] || 'hw-dell-pe';
}
function rackDevicesToNodes(devices, startX = 40, startY = 40) {
return devices.map((d, i) => ({
id: 'rd-' + d.id,
type: resolveNodeType(d),
x: startX + (i % 4) * 180,
y: startY + Math.floor(i / 4) * 90,
label: d.name,
sub: (d.model || d.device_type || '').toString(),
css: 'node-hw',
w: 160,
h: 48,
ip: d.mgmt_ip || '',
vendor: d.manufacturer || '',
model: d.model || '',
specs: d.specs || '',
notes: d.notes || '',
rack_device_id: d.id,
layer: 'infra'
}));
}
function parseDiagramJson(diagram) {
let nodes = [];
let edges = [];
try {
nodes = JSON.parse(diagram.nodes || '[]');
edges = JSON.parse(diagram.edges || '[]');
} catch (e) { /* ignore */ }
if (!Array.isArray(nodes) && nodes && nodes.nodes) {
edges = nodes.edges || [];
nodes = nodes.nodes || [];
}
if (!Array.isArray(edges) && edges && edges.edges) {
nodes = edges.nodes || nodes;
edges = edges.edges || [];
}
if (!Array.isArray(nodes)) nodes = [];
if (!Array.isArray(edges)) edges = [];
return { nodes, edges };
}
function summarizeDiagram(diagram) {
const { nodes, edges } = parseDiagramJson(diagram);
const layers = {};
nodes.forEach(n => {
const layer = n.layer || (n.type || '').split('-')[0] || 'other';
layers[layer] = (layers[layer] || 0) + 1;
});
const protocols = {};
edges.forEach(e => {
const p = e.protocol || 'unspecified';
protocols[p] = (protocols[p] || 0) + 1;
});
return { nodes, edges, layers, protocols, nodeCount: nodes.length, edgeCount: edges.length };
}
function getLayerForCategory(catLabel) {
const c = (catLabel || '').toLowerCase();
if (c.includes('data bron') || c.includes('ingest') || c.includes('opslag') || c.includes('database')) return 'data';
if (c.includes('microservice') || c.includes('apps')) return 'app';
if (c.includes('api') || c.includes('messaging') || c.includes('queue')) return 'integration';
if (c.includes('netwerk') || c.includes('compute / server') || c.includes('infrastructure') || c.includes('security') || c.includes('observability') || c.includes('ci/cd')) return 'infra';
if (c.includes('ai')) return 'app';
return 'other';
}
module.exports = {
DEVICE_TO_NODE,
DIAGRAM_TYPE_LABELS,
resolveNodeType,
rackDevicesToNodes,
parseDiagramJson,
summarizeDiagram,
getLayerForCategory
};
+29
View File
@@ -0,0 +1,29 @@
const http = require('http');
function checkService(host, port, path, timeout = 3000) {
return new Promise((resolve) => {
const req = http.request({ hostname: host, port, path, method: 'GET', timeout }, (res) => {
resolve({ online: res.statusCode < 500, status: res.statusCode });
});
req.on('error', () => resolve({ online: false, status: 0 }));
req.on('timeout', () => { req.destroy(); resolve({ online: false, status: 0 }); });
req.end();
});
}
async function getServicesStatus() {
const qsHost = process.env.QS_HOST || 'python-quality';
const [quality, designer, leadgen] = await Promise.all([
checkService(qsHost, 3002, '/python-quality/'),
checkService('python-designer', 3001, '/python-editor/'),
checkService('python-leadgen', 3003, '/python-leadgen/health')
]);
return {
consulting: { online: true, label: 'Mek-Tech Core', port: 3000 },
quality: { ...quality, label: 'Data Quality', port: 3002 },
designer: { ...designer, label: 'Pro Designer', port: 3001 },
leadgen: { ...leadgen, label: 'Lead Generation', port: 3003 }
};
}
module.exports = { checkService, getServicesStatus };
+49
View File
@@ -0,0 +1,49 @@
{
"app_name": "Mek-Tech Consulting",
"dashboard": "Dashboard",
"clients": "Clients",
"engagements": "Engagements",
"tasks": "Tasks",
"settings": "Settings",
"logout": "Logout",
"login": "Login",
"username": "Username",
"password": "Password",
"forgot_password": "Forgot password?",
"new_client": "New client",
"new_engagement": "New engagement",
"new_task": "New task",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"search": "Search",
"no_results": "No results",
"total": "Total",
"active": "Active",
"lead": "Lead",
"paused": "Paused",
"archived": "Archived",
"status": "Status",
"type": "Type",
"date": "Date",
"description": "Description",
"notes": "Notes",
"hours": "Hours",
"time_registration": "Time Registration",
"invoices": "Invoices",
"new_invoice": "New invoice",
"calendar": "Calendar",
"notifications": "Notifications",
"users": "Users",
"audit_log": "Audit Log",
"infrastructure": "Infrastructure",
"architecture": "Architecture",
"networking": "Networking",
"racks": "Racks",
"assessments": "Assessments",
"reports": "Reports",
"export_pdf": "Export PDF",
"welcome": "Welcome",
"overview": "Overview"
}
+34
View File
@@ -0,0 +1,34 @@
const fs = require('fs');
const path = require('path');
const cache = {};
function loadLocale(lang) {
if (cache[lang]) return cache[lang];
try {
const filePath = path.join(__dirname, lang + '.json');
if (fs.existsSync(filePath)) {
cache[lang] = JSON.parse(fs.readFileSync(filePath, 'utf8'));
return cache[lang];
}
} catch (e) {}
return loadLocale('nl');
}
function t(lang, key, ...args) {
const dict = loadLocale(lang || 'nl');
let val = dict[key] || key;
if (args.length > 0) {
args.forEach((a, i) => { val = val.replace(`{${i}}`, a); });
}
return val;
}
function localeMiddleware(req, res, next) {
const lang = (req.session && req.session.lang) || 'nl';
res.locals.__ = (key, ...args) => t(lang, key, ...args);
res.locals.lang = lang;
next();
}
module.exports = { t, loadLocale, localeMiddleware };
+49
View File
@@ -0,0 +1,49 @@
{
"app_name": "Mek-Tech Consulting",
"dashboard": "Dashboard",
"clients": "Cliënten",
"engagements": "Engagements",
"tasks": "Taken",
"settings": "Instellingen",
"logout": "Uitloggen",
"login": "Inloggen",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"forgot_password": "Wachtwoord vergeten?",
"new_client": "Nieuwe cliënt",
"new_engagement": "Nieuw engagement",
"new_task": "Nieuwe taak",
"save": "Opslaan",
"cancel": "Annuleren",
"delete": "Verwijderen",
"edit": "Bewerken",
"search": "Zoeken",
"no_results": "Geen resultaten",
"total": "Totaal",
"active": "Actief",
"lead": "Lead",
"paused": "Gepauzeerd",
"archived": "Gearchiveerd",
"status": "Status",
"type": "Type",
"date": "Datum",
"description": "Omschrijving",
"notes": "Notities",
"hours": "Uren",
"time_registration": "Urenregistratie",
"invoices": "Facturen",
"new_invoice": "Nieuwe factuur",
"calendar": "Agenda",
"notifications": "Meldingen",
"users": "Gebruikers",
"audit_log": "Audit Log",
"infrastructure": "Infrastructuur",
"architecture": "Architectuur",
"networking": "Netwerk",
"racks": "Racks",
"assessments": "Assessments",
"reports": "Rapporten",
"export_pdf": "Exporteer PDF",
"welcome": "Welkom",
"overview": "Overzicht"
}
+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" style="gap:.5rem"><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="/clients" class="nav-link">Clients</a><a href="/engagements" class="nav-link">Engagements</a><a href="/finance" class="nav-link">Finance</a><a href="/market" class="nav-link active">Market</a><a href="/consulting" class="nav-link">Consulting</a><a href="/quality" class="nav-link">Data</a><a href="/settings" class="nav-link">Settings</a><a href="/auth/logout" class="nav-link logout">Logout</a></div><button class="theme-toggle" id="themeToggle" onclick="toggleTheme()"></button></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_arehouse||'').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>
+101
View File
@@ -0,0 +1,101 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
const CITY_COORDS = {
'Amsterdam':{lat:52.3676,lng:4.9041},'Rotterdam':{lat:51.9244,lng:4.4777},
'Den Haag':{lat:52.0705,lng:4.3007},'Utrecht':{lat:52.0907,lng:5.1214},
'Eindhoven':{lat:51.4416,lng:5.4697},'Groningen':{lat:53.2194,lng:6.5665},
'Tilburg':{lat:51.5719,lng:5.0672},'Almere':{lat:52.3508,lng:5.2647},
'Haarlem':{lat:52.3874,lng:4.6462},'Nijmegen':{lat:51.8126,lng:5.8372},
'Arnhem':{lat:51.9969,lng:6.1832},'Amersfoort':{lat:52.1561,lng:5.3878},
'Apeldoorn':{lat:52.2112,lng:5.9474},'Zwolle':{lat:52.5168,lng:6.0830},
'Enschede':{lat:52.2215,lng:6.8937},'Maastricht':{lat:50.8514,lng:5.6900},
'Leiden':{lat:52.1601,lng:4.4970},'Delft':{lat:51.9999,lng:4.3540},
'Breda':{lat:51.5719,lng:4.7683},'Hengelo':{lat:52.2636,lng:6.7945},
'Dronten':{lat:52.5689,lng:5.7175},'Schiphol-Rijk':{lat:52.2845,lng:4.7555},
'Schiphol':{lat:52.3105,lng:4.7683},'Zaandam':{lat:52.4382,lng:4.8158},
'Veldhoven':{lat:51.4184,lng:5.4030},'Deventer':{lat:52.2661,lng:6.1557},
'Wageningen':{lat:51.9692,lng:5.6653},'Eygelshoven':{lat:50.7639,lng:6.0473},
'Veghel':{lat:51.6665,lng:5.5556},'Leusden':{lat:52.1418,lng:5.4165},
'Rosmalen':{lat:51.6699,lng:5.7867},'Eemshaven':{lat:53.4430,lng:6.8530},
'Sliedrecht':{lat:51.8220,lng:4.7733}
};
router.get('/', (req, res) => {
const db = getDb();
const allDcs = db.prepare('SELECT * FROM datacenters').all();
const allCompanies = db.prepare('SELECT * FROM tech_companies').all();
const regions = [...new Set(allDcs.map(d => d.region).filter(Boolean))].sort();
const industries = [...new Set(allCompanies.map(c => c.industry).filter(Boolean))].sort();
const dwVendors = [...new Set(allCompanies.flatMap(c => (c.data_warehouse||'').split(',').map(s=>s.trim()).filter(Boolean)))].sort();
const allVendors = [...new Set(allDcs.flatMap(d => ((d.network_vendors||'')+','+(d.server_vendors||'')+','+(d.storage_vendors||'')).split(',').map(s=>s.trim()).filter(Boolean)))].sort();
const cities = [...new Set(allCompanies.map(c => c.city).filter(Boolean))].sort();
const totalPower = allDcs.reduce((s,d) => s + (d.power_mw||0), 0);
const dcMapPoints = allDcs.map(d => ({
name: d.name, city: d.city, region: d.region, operator: d.operator,
power: d.power_mw, tier: d.tier, type: d.type,
lat: (CITY_COORDS[d.city] || {}).lat || 52.0,
lng: (CITY_COORDS[d.city] || {}).lng || 5.0
}));
res.render('market-intel', {
datacenters: allDcs, companies: allCompanies, regions, industries, dwVendors, allVendors, cities,
totalPower, dcMapPoints
});
});
// Realtime API: search datacenters
router.get('/search/dc', (req, res) => {
const db = getDb();
const { search, vendor, region, type, sort } = req.query;
let sql = 'SELECT * FROM datacenters WHERE 1=1';
const params = [];
if (search) { sql += ' AND (name LIKE ? OR operator LIKE ? OR city LIKE ? OR network_vendors LIKE ? OR server_vendors LIKE ? OR storage_vendors LIKE ?)'; const s = '%'+search+'%'; params.push(s,s,s,s,s,s); }
if (vendor) { sql += ' AND (network_vendors LIKE ? OR server_vendors LIKE ? OR storage_vendors LIKE ?)'; const v = '%'+vendor+'%'; params.push(v,v,v); }
if (region) { sql += ' AND region LIKE ?'; params.push('%'+region+'%'); }
if (type) { sql += ' AND type = ?'; params.push(type); }
sql += ' ORDER BY ' + (sort === 'name' ? 'name' : 'power_mw DESC');
res.json(db.prepare(sql).all(...params));
});
// Realtime API: search companies
router.get('/search/co', (req, res) => {
const db = getDb();
const { search, industry, dw, city, sort } = req.query;
let sql = 'SELECT * FROM tech_companies WHERE 1=1';
const params = [];
if (search) { sql += ' AND (name LIKE ? OR industry LIKE ? OR city LIKE ? OR data_warehouse LIKE ? OR bi_tools LIKE ? OR cloud_provider LIKE ? OR ai_ml LIKE ? OR database_vendor LIKE ?)'; const s = '%'+search+'%'; params.push(s,s,s,s,s,s,s,s); }
if (industry) { sql += ' AND industry LIKE ?'; params.push('%'+industry+'%'); }
if (dw) { sql += ' AND data_warehouse LIKE ?'; params.push('%'+dw+'%'); }
if (city) { sql += ' AND city LIKE ?'; params.push('%'+city+'%'); }
sql += ' ORDER BY ' + (sort === 'name' ? 'name' : sort === 'employees' ? 'employees DESC' : 'lead_score DESC');
res.json(db.prepare(sql).all(...params));
});
// Map data
router.get('/map.json', (req, res) => {
const db = getDb();
const dcs = db.prepare('SELECT * FROM datacenters').all();
const companies = db.prepare('SELECT name, city, industry, employees, lead_score, data_warehouse FROM tech_companies').all();
const dcPoints = dcs.map(d => ({...d, lat: (CITY_COORDS[d.city]||{}).lat||52.0, lng: (CITY_COORDS[d.city]||{}).lng||5.0}));
const compPoints = companies.map(c => ({name:c.name, city:c.city, lat:(CITY_COORDS[c.city]||{}).lat||52.0, lng:(CITY_COORDS[c.city]||{}).lng||5.0, industry:c.industry, employees:c.employees, lead_score:c.lead_score}));
res.json({ datacenters: dcPoints, companies: compPoints });
});
// Filter options
router.get('/filters.json', (req, res) => {
const db = getDb();
const allDcs = db.prepare('SELECT * FROM datacenters').all();
const allCompanies = db.prepare('SELECT * FROM tech_companies').all();
res.json({
regions: [...new Set(allDcs.map(d => d.region).filter(Boolean))].sort(),
industries: [...new Set(allCompanies.map(c => c.industry).filter(Boolean))].sort(),
dwVendors: [...new Set(allCompanies.flatMap(c => (c.data_warehouse||'').split(',').map(s=>s.trim()).filter(Boolean)))].sort(),
cities: [...new Set(allCompanies.map(c => c.city).filter(Boolean))].sort(),
vendors: [...new Set(allDcs.flatMap(d => ((d.network_vendors||'')+','+(d.server_vendors||'')+','+(d.storage_vendors||'')).split(',').map(s=>s.trim()).filter(Boolean)))].sort()
});
});
module.exports = router;
+63
View File
@@ -0,0 +1,63 @@
const NAV_SECTIONS = [
{
label: 'CRM',
items: [
{ href: '/', icon: 'layout-dashboard', label: 'Dashboard', match: /^\/(\?.*)?$/ },
{ href: '/clients', icon: 'users', label: 'Cliënten', match: /^\/clients/ },
{ href: '/engagements', icon: 'briefcase', label: 'Engagements', match: /^\/engagements/ },
{ href: '/tasks', icon: 'check-circle', label: 'Taken', match: /^\/tasks/ },
{ href: '/calendar', icon: 'calendar', label: 'Agenda', match: /^\/calendar/ },
{ href: '/email', icon: 'mail', label: 'Email', match: /^\/email/ }
]
},
{
label: 'Delivery',
items: [
{ href: '/consulting', icon: 'clipboard-list', label: 'Framework', match: /^\/consulting/ },
{ href: '/assess', icon: 'file-search', label: 'Assessments', match: /^\/assess/ },
{ href: '/projects', icon: 'folder-kanban', label: 'Projecten', match: /^\/projects/ },
{ href: '/time', icon: 'clock', label: 'Uren', match: /^\/time/ },
{ href: '/invoices', icon: 'receipt', label: 'Facturen', match: /^\/invoices/ },
{ href: '/reports', icon: 'bar-chart-3', label: 'Rapporten', match: /^\/reports/ }
]
},
{
label: 'Data Engineering',
items: [
{ href: '/quality', icon: 'shield-check', label: 'Data Quality', match: /^\/quality/ },
{ href: '/architecture', icon: 'network', label: 'Architecture', match: /^\/architecture/ },
{ href: '/architecture/designer', icon: 'pen-tool', label: 'Pro Designer', match: /^\/architecture\/designer/ },
{ href: '/market', icon: 'radar', label: 'Market Intel', match: /^\/market/ },
{ href: '/sizing', icon: 'cpu', label: 'Sizing', match: /^\/sizing/ }
]
},
{
label: 'Infrastructure',
items: [
{ href: '/racks', icon: 'server', label: 'Racks', match: /^\/racks/ },
{ href: '/networking', icon: 'share-2', label: 'Netwerk', match: /^\/networking/ },
{ href: '/infrastructure', icon: 'building-2', label: 'Infra', match: /^\/infrastructure/ }
]
},
{
label: 'Platform',
items: [
{ href: '/finance', icon: 'wallet', label: 'Finance', match: /^\/finance/ },
{ href: '/ai', icon: 'sparkles', label: 'AI Providers', match: /^\/ai/ },
{ href: '/integrations', icon: 'plug', label: 'Integraties', match: /^\/integrations/ },
{ href: '/settings', icon: 'settings', label: 'Instellingen', match: /^\/settings/ }
]
}
];
function getNavSections(activePath) {
return NAV_SECTIONS.map(section => ({
...section,
items: section.items.map(item => ({
...item,
active: item.match.test(activePath || '/')
}))
}));
}
module.exports = { NAV_SECTIONS, getNavSections };
+184
View File
@@ -0,0 +1,184 @@
/* 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 ── */
body {
background-color: var(--bg) !important;
background-image:
radial-gradient(ellipse 90% 55% at 50% -15%, rgba(0, 212, 255, 0.12) 0%, transparent 60%),
radial-gradient(ellipse 50% 35% at 95% 90%, rgba(168, 85, 247, 0.08) 0%, transparent 55%),
radial-gradient(ellipse 40% 30% at 5% 70%, rgba(0, 255, 136, 0.06) 0%, transparent 50%) !important;
}
/* ── Navbar ── */
.navbar {
backdrop-filter: blur(16px) !important;
border-bottom: 1px solid rgba(0, 212, 255, 0.2) !important;
box-shadow: 0 1px 0 rgba(0, 212, 255, 0.1), 0 4px 32px rgba(0, 0, 0, 0.4), 0 0 40px rgba(0, 212, 255, 0.04) !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.15) !important;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2), inset -1px 0 0 rgba(0, 212, 255, 0.05) !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.35) !important;
box-shadow: 0 0 28px rgba(0, 212, 255, 0.15), 0 8px 32px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.06) !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); }
+2853
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "mek-tech-consulting-framework",
"version": "2.0.0",
"description": "Mek-Tech AI Consultancy Framework — Client management, assessments, en advies-engine voor data consultants & forward deployed engineers",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.0.0",
"body-parser": "^1.20.2",
"connect-sqlite3": "^0.9.15",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"ejs": "^3.1.9",
"express": "^4.18.2",
"express-session": "^1.18.0",
"form-data": "^4.0.6",
"imap": "^0.8.19",
"mailparser": "^3.9.14",
"multer": "^1.4.5-lts.1",
"nodemailer": "^9.0.3"
}
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="fg" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#58a6ff"/>
<stop offset="1" stop-color="#3fb950"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="#0d1117"/>
<path d="M16 4 L27 11 L27 21 L16 28 L5 21 L5 11 Z" stroke="url(#fg)" stroke-width="1.8" fill="url(#fg)" fill-opacity="0.12"/>
<path d="M12 16 L16 12 L20 16 L16 20 Z" stroke="#58a6ff" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="16" cy="16" r="1.8" fill="#58a6ff"/>
</svg>

After

Width:  |  Height:  |  Size: 676 B

+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 40" fill="none">
<defs>
<linearGradient id="g1" x1="0" y1="0" x2="40" y2="40" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#58a6ff"/>
<stop offset="1" stop-color="#3fb950"/>
</linearGradient>
<linearGradient id="g2" x1="0" y1="0" x2="40" y2="40" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#58a6ff"/>
<stop offset="1" stop-color="#bc8cff"/>
</linearGradient>
</defs>
<!-- Hexagon mark -->
<path d="M20 2 L36 11 L36 29 L20 38 L4 29 L4 11 Z" stroke="url(#g1)" stroke-width="2.5" fill="url(#g2)" fill-opacity="0.08"/>
<!-- Inner data flow lines -->
<path d="M14 20 L20 14 L26 20 L20 26 Z" stroke="#58a6ff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="20" cy="20" r="2.5" fill="#58a6ff"/>
<!-- Small connection dots -->
<circle cx="12" cy="16" r="1.5" fill="#3fb950" opacity="0.7"/>
<circle cx="28" cy="16" r="1.5" fill="#3fb950" opacity="0.7"/>
<circle cx="12" cy="24" r="1.5" fill="#bc8cff" opacity="0.7"/>
<circle cx="28" cy="24" r="1.5" fill="#bc8cff" opacity="0.7"/>
<!-- Pulse lines -->
<path d="M13.5 16 L16 18" stroke="#3fb950" stroke-width="1" opacity="0.5"/>
<path d="M24 18 L26.5 16" stroke="#3fb950" stroke-width="1" opacity="0.5"/>
<path d="M16 22 L13.5 24" stroke="#bc8cff" stroke-width="1" opacity="0.5"/>
<path d="M26.5 24 L24 22" stroke="#bc8cff" stroke-width="1" opacity="0.5"/>
<!-- Text -->
<text x="48" y="24" font-family="Inter, -apple-system, sans-serif" font-size="15" font-weight="700" fill="#e6edf3" letter-spacing="1">MEK-TECH</text>
<text x="48" y="35" font-family="Inter, -apple-system, sans-serif" font-size="8" font-weight="400" fill="#8b949e" letter-spacing="2">DATA CENTER EXPERT</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"name": "Mek-Tech Consulting Framework",
"short_name": "Mek-Tech",
"description": "Enterprise consultancy management platform",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#58a6ff",
"orientation": "portrait-primary",
"icons": [
{
"src": "/img/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/img/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
const CACHE_NAME = 'mek-tech-v1';
const urlsToCache = ['/', '/css/style.css', '/css/dynamic.css', '/img/logo.svg'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV DB_PATH=/app/data/consulting.db
ENV PY_PORT=3001
EXPOSE 3001
CMD ["python3", "app.py"]
+183
View File
@@ -0,0 +1,183 @@
import os
import sqlite3
import json
from flask import Flask, render_template_string, request, jsonify, send_from_directory
DB_PATH = os.environ.get('DB_PATH', '/app/data/consulting.db')
app = Flask(__name__, static_folder='static', static_url_path='/python-editor/static')
HTML = '''<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Architectuur Designer — 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:#0d1117;--bg-card:#161b22;--bg-nav:#161b22;--border:#30363d;--text:#e6edf3;--text-muted:#8b949e;--accent:#58a6ff;--accent2:#3fb950;--danger:#f85149;--warning:#d29922;--purple:#bc8cff}
*{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;overflow:hidden;height:100vh}
.nav{display:flex;align-items:center;gap:.75rem;padding:.5rem 1rem;background:var(--bg-nav);border-bottom:1px solid var(--border);height:48px}
.nav-brand{font-weight:700;font-size:1rem;color:var(--text);display:flex;align-items:center;gap:.4rem}
.nav-brand svg{width:20px;height:20px}
.nav-sep{width:1px;height:20px;background:var(--border)}
.nav input,.nav select{padding:.3rem .5rem;font-size:.75rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:inherit}
.nav input{width:180px}
.nav .btn{padding:.3rem .7rem;font-size:.7rem;border-radius:4px;border:none;cursor:pointer;font-weight:500;color:#fff}
.btn-primary{background:#238636}.btn-primary:hover{background:#2ea043}
.btn-accent{background:var(--accent)}.btn-accent:hover{background:#79c0ff}
.btn-ghost{background:transparent;color:var(--text-muted);border:1px solid var(--border)!important}
.btn-ghost:hover{color:var(--text);background:var(--bg-card)}
.nav-status{font-size:.7rem;color:var(--text-muted);margin-left:auto}
.layout{display:flex;height:calc(100vh - 48px)}
.sidebar{width:200px;background:var(--bg-nav);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
.sidebar input{margin:.5rem;padding:.35rem .5rem;font-size:.7rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text)}
.palette{flex:1;overflow-y:auto;padding:0 .5rem .5rem}
.cat{font-size:.6rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin:.6rem .25rem .25rem;padding:0 .25rem}
.p-item{display:flex;align-items:center;gap:.35rem;padding:.3rem .45rem;margin-bottom:1px;border-radius:4px;cursor:grab;font-size:.7rem;color:var(--text-muted);transition:all .1s;user-select:none}
.p-item:hover{background:var(--bg-card);color:var(--text)}
.p-icon{width:18px;height:18px;border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:.5rem;font-weight:700;flex-shrink:0}
.canvas-wrap{flex:1;position:relative;overflow:hidden;background:var(--bg);background-image:linear-gradient(rgba(88,166,255,.04) 1px,transparent 1px),linear-gradient(90deg,rgba(88,166,255,.04) 1px,transparent 1px);background-size:20px 20px;cursor:grab}
.canvas-wrap:active{cursor:grabbing}
.viewport{position:absolute;top:0;left:0;transform-origin:0 0;width:6000px;height:4000px}
.node{position:absolute;min-width:120px;min-height:40px;background:var(--bg-card);border:2px solid var(--border);border-radius:6px;padding:.35rem .5rem;cursor:move;user-select:none;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:.7rem;color:var(--text-muted);z-index:10;transition:box-shadow .15s,border-color .15s}
.node:hover{box-shadow:0 0 20px rgba(88,166,255,.15);z-index:11}
.node.selected{border-color:var(--accent)!important;box-shadow:0 0 25px rgba(88,166,255,.3);z-index:12}
.node .nl{font-weight:600;font-size:.7rem;color:var(--text);text-align:center;line-height:1.2}
.node .ns{font-size:.6rem;color:var(--text-muted);line-height:1.2}
.node .rm{position:absolute;top:-6px;right:-6px;width:14px;height:14px;border-radius:50%;background:#3d1214;border:1px solid var(--danger);color:var(--danger);display:none;align-items:center;justify-content:center;font-size:.45rem;cursor:pointer}
.node:hover .rm{display:flex}
.node .port{width:9px;height:9px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);position:absolute;cursor:crosshair;z-index:5;transition:all .15s}
.node .port:hover{background:var(--accent);transform:scale(1.4)}
.port-in{left:-5px;top:50%;transform:translateY(-50%)}
.port-out{right:-5px;top:50%;transform:translateY(-50%)}
.props{width:240px;background:var(--bg-nav);border-left:1px solid var(--border);display:none;flex-direction:column;padding:.75rem;flex-shrink:0}
.props.open{display:flex}
.props h3{font-size:.65rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:.75rem}
.pg{margin-bottom:.5rem}
.pg label{font-size:.65rem;color:var(--text-muted);display:block;margin-bottom:.15rem}
.pg input,.pg select{width:100%;padding:.3rem .45rem;font-size:.7rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text)}
svg.conn{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:4}
.zoom{position:absolute;bottom:1rem;right:1rem;background:var(--bg-nav);border:1px solid var(--border);border-radius:6px;padding:.25rem .5rem;font-size:.65rem;color:var(--text-muted);z-index:30}
.stats{font-size:.6rem;color:var(--text-muted);padding:.4rem .5rem;border-top:1px solid var(--border)}
</style>
</head>
<body>
<div class="nav">
<span class="nav-brand"><svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg> Mek-Tech Designer</span>
<div class="nav-sep"></div>
<input id="diagramName" placeholder="Diagram naam...">
<select id="clientSelect"><option value="">Cliënt...</option></select>
<button class="btn btn-primary" onclick="save()">Opslaan</button>
<button class="btn btn-ghost" onclick="undo()">↩</button>
<button class="btn btn-ghost" onclick="redo()">↪</button>
<button class="btn btn-ghost" onclick="fit()">⊞</button>
<span class="nav-status" id="status"></span>
</div>
<div class="layout">
<div class="sidebar">
<input id="paletteSearch" placeholder="Zoek component..." oninput="filterPalette(this.value)">
<div class="palette" id="palette"></div>
<div class="stats" id="stats">0 componenten · 0 connecties</div>
</div>
<div class="canvas-wrap" id="wrap">
<div class="viewport" id="viewport">
<svg class="conn" id="svg"></svg>
<div id="canvas" style="position:absolute;top:0;left:0;width:100%;height:100%"></div>
</div>
<div class="zoom" id="zoom">100%</div>
</div>
<div class="props" id="props">
<h3>Eigenschappen</h3>
<div class="pg"><label>Label</label><input id="pLabel" onchange="up('label',this.value)"></div>
<div class="pg"><label>Subtitel</label><input id="pSub" onchange="up('sub',this.value)"></div>
<div class="pg"><label>Type</label><select id="pType" onchange="up('type',this.value)"></select></div>
<div class="pg"><label>Breedte</label><input id="pWidth" type="number" min="100" max="400" onchange="up('w',+this.value)"></div>
<button style="margin-top:auto;padding:.4rem;background:#3d1214;color:var(--danger);border:1px solid var(--danger);border-radius:4px;cursor:pointer;font-size:.7rem" onclick="delNode()">Verwijder</button>
</div>
</div>
<script>
const DIAGRAM_ID = '{{ diagram_id }}';
const NODES_JSON = {{ nodes_json | safe }};
const EDGES_JSON = {{ edges_json | safe }};
const NODE_TYPES = {
'source-db':{label:'Database',color:'#d29922',css:'bd-src'},'source-api':{label:'API Service',color:'#d29922',css:'bd-src'},
'source-iot':{label:'IoT Sensor',color:'#d29922',css:'bd-src'},'source-stream':{label:'Streaming',color:'#d29922',css:'bd-src'},
'ingest-kafka':{label:'Kafka',color:'#58a6ff',css:'bd-ing'},'ingest-airbyte':{label:'Airbyte',color:'#58a6ff',css:'bd-ing'},
'ingest-flink':{label:'Spark/Flink',color:'#58a6ff',css:'bd-ing'},'storage-minio':{label:'MinIO/S3',color:'#3fb950',css:'bd-sto'},
'storage-iceberg':{label:'Iceberg',color:'#3fb950',css:'bd-sto'},'storage-warehouse':{label:'Data Warehouse',color:'#3fb950',css:'bd-sto'},
'storage-lake':{label:'Lakehouse',color:'#3fb950',css:'bd-sto'},'compute-spark':{label:'Apache Spark',color:'#bc8cff',css:'bd-comp'},
'compute-trino':{label:'Trino/Presto',color:'#bc8cff',css:'bd-comp'},'compute-k8s':{label:'Kubernetes',color:'#bc8cff',css:'bd-comp'},
'compute-dbt':{label:'dbt',color:'#bc8cff',css:'bd-comp'},'ai-ollama':{label:'Ollama/vLLM',color:'#f85149',css:'bd-ai'},
'ai-mlflow':{label:'MLflow',color:'#f85149',css:'bd-ai'},'ai-vector':{label:'Vector Store',color:'#f85149',css:'bd-ai'},
'ai-model':{label:'Model Serving',color:'#f85149',css:'bd-ai'},'queue-kafka':{label:'Kafka Queue',color:'#79c0ff',css:'bd-q'},
'queue-rabbit':{label:'RabbitMQ',color:'#79c0ff',css:'bd-q'},'network-switch':{label:'Switch',color:'#7ee787',css:'bd-net'},
'network-router':{label:'Router',color:'#7ee787',css:'bd-net'},'network-fw':{label:'Firewall',color:'#7ee787',css:'bd-net'},
'network-lb':{label:'Load Balancer',color:'#7ee787',css:'bd-net'},'security-iam':{label:'IAM/Auth',color:'#f778ba',css:'bd-sec'},
'security-vault':{label:'Vault',color:'#f778ba',css:'bd-sec'},'security-grafana':{label:'Grafana',color:'#f778ba',css:'bd-sec'}
};
const CATS = [['Data Bronnen',['source-db','source-api','source-iot','source-stream']],['Ingest',['ingest-kafka','ingest-airbyte','ingest-flink']],['Opslag',['storage-minio','storage-iceberg','storage-warehouse']],['Compute',['compute-spark','compute-trino','compute-k8s','compute-dbt']],['AI/ML',['ai-ollama','ai-mlflow','ai-vector','ai-model']],['Messaging',['queue-kafka','queue-rabbit']],['Netwerk',['network-switch','network-router','network-fw','network-lb']],['Security',['security-iam','security-vault','security-grafana']]];
</script>
<script src="/python-editor/static/editor.js"></script>
</body>
</html>'''
@app.route('/python-editor/')
def index():
diagram_id = request.args.get('id')
nodes_json = '[]'
edges_json = '[]'
if diagram_id:
try:
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT nodes FROM data_diagrams WHERE id=?', (diagram_id,))
row = c.fetchone()
conn.close()
if row:
data = json.loads(row[0])
nodes_json = json.dumps(data.get('nodes', []))
edges_json = json.dumps(data.get('edges', []))
except: pass
return render_template_string(HTML, diagram_id=diagram_id or '', nodes_json=nodes_json, edges_json=edges_json)
@app.route('/python-editor/api/clients')
def api_clients():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT id, name FROM clients ORDER BY name')
clients = [{'id': r[0], 'name': r[1]} for r in c.fetchall()]
conn.close()
return jsonify(clients)
@app.route('/python-editor/api/save', methods=['POST'])
def api_save():
data = request.get_json()
name = data.get('name')
client_id = data.get('client_id')
nodes = json.dumps(data.get('nodes', []))
edges = json.dumps(data.get('edges', []))
diagram_id = data.get('id')
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
if diagram_id:
c.execute('UPDATE data_diagrams SET name=?, client_id=?, nodes=?, edges=?, updated_at=datetime(\'now\') WHERE id=?',
(name, client_id, nodes, edges, diagram_id))
else:
c.execute('INSERT INTO data_diagrams (client_id, name, nodes, edges, diagram_type) VALUES (?,?,?,?,?)',
(client_id, name, nodes, edges, 'data_architecture'))
diagram_id = c.lastrowid
conn.commit()
conn.close()
return jsonify({'id': diagram_id})
if __name__ == '__main__':
port = int(os.environ.get('PY_PORT', 3001))
app.run(host='0.0.0.0', port=port, debug=False)
+1
View File
@@ -0,0 +1 @@
flask>=3.0
+333
View File
@@ -0,0 +1,333 @@
let nodes = [], edges = [], selId = null, dragId = null, dOffX, dOffY;
let isPan = false, pSX, pSY, pOffX = 0, pOffY = 0, zoom = 1;
let connFrom = null, tmpLine = null, nIdCnt = 0;
let undoStk = [], redoStk = [];
const wrap = document.getElementById('wrap'), vp = document.getElementById('viewport');
const cv = document.getElementById('canvas'), svg = document.getElementById('svg');
const props = document.getElementById('props');
function sn(v) { return Math.round(v / 20) * 20; }
function buildPalette() {
const p = document.getElementById('palette');
CATS.forEach(([cat, types]) => {
p.innerHTML += `<div class="cat">${cat}</div>`;
types.forEach(t => {
const info = NODE_TYPES[t];
p.innerHTML += `<div class="p-item" draggable="true" data-type="${t}">
<span class="p-icon" style="background:${info.color}33;color:${info.color}">${t.split('-')[0].substring(0,2).toUpperCase()}</span>
${info.label}</div>`;
});
});
document.querySelectorAll('.p-item').forEach(el => {
el.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', el.dataset.type); });
});
}
function addNode(type, x, y, id, label) {
const def = NODE_TYPES[type]; if (!def) return null;
const nid = id || (++nIdCnt);
const el = document.createElement('div');
el.className = 'node'; el.dataset.id = nid;
el.style.cssText = `left:${sn(x)}px;top:${sn(y)}px;width:130px;border-color:${def.color}`;
el.innerHTML = `<div class="rm" onclick="rmNode(${nid})">×</div>
<div class="port port-in" data-n="${nid}"></div>
<div class="nl">${label||def.label}</div>
<div class="ns">${def.label}</div>
<div class="port port-out" data-n="${nid}"></div>`;
el.addEventListener('mousedown', e => {
if (e.target.closest('.rm,.port')) return;
e.stopPropagation(); selNode(nid);
const r = el.getBoundingClientRect();
dragId = nid; dOffX = e.clientX - r.left; dOffY = e.clientY - r.top;
el.style.zIndex = 20; el.style.cursor = 'grabbing';
saveSt();
});
el.querySelectorAll('.port').forEach(p => {
p.addEventListener('mousedown', e => {
e.stopPropagation(); e.preventDefault();
if (p.classList.contains('port-out')) {
connFrom = { id: +p.dataset.n, el: p };
const r = p.getBoundingClientRect(), wr = vp.getBoundingClientRect();
tmpLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
tmpLine.setAttribute('stroke', '#58a6ff'); tmpLine.setAttribute('stroke-width', '2');
tmpLine.setAttribute('stroke-dasharray', '6,4');
tmpLine.setAttribute('x1', (r.left - wr.left) / zoom);
tmpLine.setAttribute('y1', (r.top - wr.top + 4) / zoom);
tmpLine.setAttribute('x2', (e.clientX - wr.left) / zoom);
tmpLine.setAttribute('y2', (e.clientY - wr.top) / zoom);
svg.appendChild(tmpLine);
}
});
});
cv.appendChild(el);
nodes.push({ id: nid, type, x: sn(x), y: sn(y), label: label || def.label });
updStats(); return nid;
}
function rmNode(id) {
const el = cv.querySelector(`.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);
renEdges(); updStats(); if (selId === id) { selId = null; props.classList.remove('open'); }
saveSt();
}
function selNode(id) {
cv.querySelectorAll('.node').forEach(n => n.classList.remove('selected'));
const el = cv.querySelector(`.node[data-id="${id}"]`);
if (el) el.classList.add('selected');
selId = id; showProps(id);
}
function showProps(id) {
const n = nodes.find(x => x.id === id); if (!n) return;
props.classList.add('open');
document.getElementById('pLabel').value = n.label;
document.getElementById('pSub').value = n.sub || '';
document.getElementById('pWidth').value = 130;
const sel = document.getElementById('pType');
sel.innerHTML = '';
Object.keys(NODE_TYPES).forEach(k => {
const o = document.createElement('option');
o.value = k; o.textContent = NODE_TYPES[k].label;
if (k === n.type) o.selected = true;
sel.appendChild(o);
});
}
function up(f, v) {
const n = nodes.find(x => x.id === selId); if (!n) return;
const el = cv.querySelector(`.node[data-id="${selId}"]`); if (!el) return;
if (f === 'label') { n.label = v; el.querySelector('.nl').textContent = v; }
else if (f === 'type') {
const def = NODE_TYPES[v]; if (!def) return;
n.type = v; n.label = def.label;
el.style.borderColor = def.color;
el.querySelector('.nl').textContent = def.label;
el.querySelector('.ns').textContent = def.label;
document.getElementById('pLabel').value = def.label;
}
else if (f === 'w') { el.style.width = v + 'px'; }
renEdges(); saveSt();
}
function delNode() { if (selId !== null) rmNode(selId); }
function addEdge(from, to) {
if (from === to || edges.some(e => e.from === from && e.to === to)) return;
edges.push({ from, to }); renEdges(); updStats(); saveSt();
}
function renEdges() {
svg.querySelectorAll('.eg').forEach(g => g.remove());
const wr = vp.getBoundingClientRect();
edges.forEach((e, i) => {
const fEl = cv.querySelector(`.node[data-id="${e.from}"]`);
const tEl = cv.querySelector(`.node[data-id="${e.to}"]`);
if (!fEl || !tEl) return;
const fr = fEl.getBoundingClientRect(), tr = tEl.getBoundingClientRect();
const x1 = (fr.right - wr.left - 8) / zoom, y1 = (fr.top + fr.height/2 - wr.top) / zoom;
const x2 = (tr.left - wr.left + 8) / zoom, y2 = (tr.top + tr.height/2 - wr.top) / zoom;
const cp = Math.max(40, Math.abs(x2 - x1) * 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('eg');
// glow
const gl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
gl.setAttribute('d', d); gl.setAttribute('stroke', '#58a6ff');
gl.setAttribute('stroke-width', '5'); gl.setAttribute('fill', 'none');
gl.setAttribute('opacity', '0.1');
g.appendChild(gl);
// main
const m = document.createElementNS('http://www.w3.org/2000/svg', 'path');
m.setAttribute('d', d); m.setAttribute('stroke', '#58a6ff');
m.setAttribute('stroke-width', '2'); m.setAttribute('fill', 'none');
m.setAttribute('stroke-linecap', 'round');
m.addEventListener('dblclick', () => { edges.splice(i,1); renEdges(); updStats(); saveSt(); });
g.appendChild(m);
// pulse
for (let j = 0; j < 2; j++) {
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', d); p.setAttribute('stroke', '#79c0ff');
p.setAttribute('stroke-width', '1.5'); p.setAttribute('fill', 'none');
p.setAttribute('stroke-dasharray', '6,14');
p.style.animation = `pulseF ${0.8 + j*0.1}s linear infinite`;
p.style.animationDelay = `-${j * 0.4}s`;
g.appendChild(p);
}
// arrow
const ang = Math.atan2(y2 - y1, x2 - x1);
const ax = x2 - 4, ay = y2 - 2;
const ar = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
ar.setAttribute('points', `${ax},${ay} ${ax-8*Math.cos(ang-.4)},${ay-8*Math.sin(ang-.4)} ${ax-8*Math.cos(ang+.4)},${ay-8*Math.sin(ang+.4)}`);
ar.setAttribute('fill', '#58a6ff');
g.appendChild(ar);
svg.appendChild(g);
});
}
document.addEventListener('mousemove', e => {
if (dragId !== null) {
const el = cv.querySelector(`.node[data-id="${dragId}"]`); if (!el) return;
const wr = vp.getBoundingClientRect();
let x = (e.clientX - wr.left - dOffX) / zoom, y = (e.clientY - wr.top - dOffY) / zoom;
x = sn(Math.max(0,x)); y = sn(Math.max(0,y));
el.style.left = x + 'px'; el.style.top = y + 'px';
const n = nodes.find(nn => nn.id === dragId); if (n) { n.x = x; n.y = y; }
renEdges();
}
if (tmpLine) {
const wr = vp.getBoundingClientRect();
tmpLine.setAttribute('x2', (e.clientX - wr.left)/zoom);
tmpLine.setAttribute('y2', (e.clientY - wr.top)/zoom);
}
if (isPan) {
pOffX += e.clientX - pSX; pOffY += e.clientY - pSY;
pSX = e.clientX; pSY = e.clientY;
vp.style.transform = `translate(${pOffX}px,${pOffY}px) scale(${zoom})`;
}
});
document.addEventListener('mouseup', e => {
if (dragId !== null) {
const el = cv.querySelector(`.node[data-id="${dragId}"]`);
if (el) el.style.cursor = 'move';
dragId = null;
}
if (tmpLine) {
tmpLine.remove(); tmpLine = null;
const t = e.target.closest('.port-in');
if (t && connFrom) addEdge(connFrom.id, +t.dataset.n);
connFrom = null;
}
if (isPan) { isPan = false; wrap.style.cursor = 'grab'; }
});
wrap.addEventListener('mousedown', e => {
if (e.target.closest('.node')) return;
cv.querySelectorAll('.node').forEach(n => n.classList.remove('selected'));
selId = null; props.classList.remove('open');
isPan = true; pSX = e.clientX; pSY = e.clientY; wrap.style.cursor = 'grabbing';
});
wrap.addEventListener('wheel', e => {
e.preventDefault();
const d = e.deltaY > 0 ? 0.9 : 1.1, nz = Math.max(0.2, Math.min(3, zoom * d));
const wr = wrap.getBoundingClientRect();
const vx = (e.clientX - wr.left - pOffX) / zoom, vy = (e.clientY - wr.top - pOffY) / zoom;
zoom = nz;
pOffX = e.clientX - wr.left - vx * zoom; pOffY = e.clientY - wr.top - vy * zoom;
vp.style.transform = `translate(${pOffX}px,${pOffY}px) scale(${zoom})`;
document.getElementById('zoom').textContent = Math.round(zoom*100) + '%';
}, { passive: false });
cv.addEventListener('dragover', e => e.preventDefault());
cv.addEventListener('drop', e => {
e.preventDefault();
const type = e.dataTransfer.getData('text/plain');
if (!type || !NODE_TYPES[type]) return;
const wr = vp.getBoundingClientRect();
let x = (e.clientX - wr.left)/zoom - 65, y = (e.clientY - wr.top)/zoom - 20;
addNode(type, sn(Math.max(0,x)), sn(Math.max(0,y)));
saveSt();
});
function saveSt() {
undoStk.push(JSON.stringify({ nodes: nodes.map(n => ({...n})), edges: [...edges] }));
if (undoStk.length > 50) undoStk.shift();
redoStk = [];
}
function undo() {
if (undoStk.length < 2) return;
redoStk.push(undoStk.pop());
loadSt(JSON.parse(undoStk[undoStk.length-1]));
}
function redo() {
if (!redoStk.length) return;
undoStk.push(redoStk.pop());
loadSt(JSON.parse(undoStk[undoStk.length-1]));
}
function loadSt(st) {
cv.querySelectorAll('.node').forEach(el => el.remove());
svg.querySelectorAll('.eg').forEach(g => g.remove());
nodes = []; edges = st.edges || [];
(st.nodes || []).forEach(n => addNode(n.type, n.x, n.y, n.id, n.label));
renEdges(); updStats();
}
function updStats() {
document.getElementById('stats').textContent = `${nodes.length} componenten · ${edges.length} connecties`;
}
function fit() {
if (!nodes.length) return;
const mx = Math.min(...nodes.map(n => n.x)), my = Math.min(...nodes.map(n => n.y));
const Mx = Math.max(...nodes.map(n => n.x + 130)), My = Math.max(...nodes.map(n => n.y + 44));
const wr = wrap.getBoundingClientRect();
zoom = Math.max(0.2, Math.min(wr.width/(Mx-mx+150), (wr.height-60)/(My-my+80), 1.5));
pOffX = -mx * zoom + 50; pOffY = -my * zoom + 30;
vp.style.transform = `translate(${pOffX}px,${pOffY}px) scale(${zoom})`;
document.getElementById('zoom').textContent = Math.round(zoom*100)+'%';
}
function filterPalette(q) {
q = q.toLowerCase();
document.querySelectorAll('.p-item').forEach(el => el.style.display = el.textContent.toLowerCase().includes(q) ? 'flex' : 'none');
document.querySelectorAll('.cat').forEach(el => {
let sib = el.nextElementSibling, has = false;
while (sib && sib.classList.contains('p-item')) { if (sib.style.display !== 'none') has = true; sib = sib.nextElementSibling; }
el.style.display = has ? 'block' : 'none';
});
}
async function save() {
const name = document.getElementById('diagramName').value;
const client_id = document.getElementById('clientSelect').value;
if (!name || !client_id) { setStatus('Naam en cliënt verplicht', 'err'); return; }
const data = { id: DIAGRAM_ID || null, client_id: +client_id, name,
nodes: nodes.map(n => ({...n})), edges: [...edges] };
try {
const r = await fetch('/python-editor/api/save', {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data)
});
const d = await r.json();
setStatus('Opgeslagen ✓', 'ok');
if (d.id && !DIAGRAM_ID) history.replaceState(null, '', '/python-editor/?id=' + d.id);
} catch(e) { setStatus('Fout bij opslaan', 'err'); }
}
function setStatus(msg, t) {
const el = document.getElementById('status');
el.textContent = msg; el.style.color = t === 'err' ? '#f85149' : '#3fb950';
setTimeout(() => { el.textContent = ''; }, 3000);
}
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === 'Delete' || e.key === 'Backspace') { delNode(); e.preventDefault(); }
if ((e.ctrlKey||e.metaKey) && e.key === 'z') { e.shiftKey ? redo() : undo(); e.preventDefault(); }
if ((e.ctrlKey||e.metaKey) && e.key === 's') { save(); e.preventDefault(); }
});
const style = document.createElement('style');
style.textContent = `@keyframes pulseF{0%{stroke-dashoffset:20}100%{stroke-dashoffset:0}}`;
document.head.appendChild(style);
buildPalette();
fetch('/python-editor/api/clients').then(r=>r.json()).then(clients => {
const sel = document.getElementById('clientSelect');
clients.forEach(c => { sel.innerHTML += `<option value="${c.id}">${c.name}</option>`; });
});
if (NODES_JSON && NODES_JSON.length) {
NODES_JSON.forEach(n => addNode(n.type, n.x, n.y, n.id, n.label));
edges = EDGES_JSON || [];
renEdges(); updStats(); saveSt();
setTimeout(fit, 100);
}
nIdCnt = nodes.length;
updStats(); saveSt();
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --default-timeout=120 --retries 5 -r requirements.txt
COPY . .
ENV LEADGEN_PORT=3003
EXPOSE 3003
CMD ["python", "app.py"]
+248
View File
@@ -0,0 +1,248 @@
"""Flask API server for Lead Generation & Tech Scoping Engine."""
from __future__ import annotations
import json
import logging
import os
import sys
from pathlib import Path
import httpx
from flask import Flask, request, jsonify
from models import CompanyTechProfile, LeadGenRequest, LeadGenResponse
from engine import LeadScopingEngine
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
stream=sys.stdout,
)
logger = logging.getLogger("leadgen")
# ---------------------------------------------------------------------------
# Config file
# ---------------------------------------------------------------------------
CONFIG_PATH = Path(os.getenv("LEADGEN_CONFIG", "/app/data/leadgen_config.json"))
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
DEFAULT_CONFIG = {
"api_key": os.getenv("DEEPSEEK_API_KEY", ""),
"base_url": "https://openrouter.ai/api/v1",
"model": "qwen/qwen3-coder:free",
"fallback_models": [
"meta-llama/llama-3.3-70b-instruct:free",
"google/gemma-4-26b-a4b-it:free",
"nvidia/nemotron-3-super-120b-a12b:free",
],
}
def load_config() -> dict:
if CONFIG_PATH.exists():
try:
return json.loads(CONFIG_PATH.read_text())
except Exception:
pass
cfg = dict(DEFAULT_CONFIG)
save_config(cfg)
return cfg
def save_config(cfg: dict) -> None:
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
config = load_config()
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = Flask(__name__)
def get_engine() -> LeadScopingEngine:
cfg = load_config()
return LeadScopingEngine(
api_key=cfg.get("api_key"),
api_base=cfg.get("base_url"),
)
@app.route("/python-leadgen/", methods=["GET"])
def index():
return jsonify({
"service": "Mek-Tech Lead Generation & Tech Scoping Engine",
"version": "2.0",
"endpoints": {
"GET /python-leadgen/config": "Get current config (model, base_url, key masked)",
"POST /python-leadgen/config": "Update config",
"GET /python-leadgen/models": "List available free models from configured API",
"POST /python-leadgen/analyze": "Analyze a single URL",
"POST /python-leadgen/batch": "Analyze multiple URLs",
"GET /python-leadgen/health": "Health check",
},
})
@app.route("/python-leadgen/health", methods=["GET"])
def health():
cfg = load_config()
return jsonify({
"status": "healthy",
"api_configured": bool(cfg.get("api_key")),
"model": cfg.get("model"),
"base_url": cfg.get("base_url"),
})
@app.route("/python-leadgen/config", methods=["GET"])
def get_config():
cfg = load_config()
key = cfg.get("api_key", "")
return jsonify({
"base_url": cfg.get("base_url"),
"model": cfg.get("model"),
"fallback_models": cfg.get("fallback_models", []),
"api_key_set": bool(key),
"api_key_preview": (key[:12] + "..." + key[-4:]) if len(key) > 16 else ("***" if key else ""),
})
@app.route("/python-leadgen/config", methods=["POST"])
def set_config():
try:
body = request.get_json(silent=True) or {}
except Exception:
return jsonify({"success": False, "error": "Invalid JSON"}), 400
cfg = load_config()
if "api_key" in body and body["api_key"]:
cfg["api_key"] = body["api_key"]
if "base_url" in body:
cfg["base_url"] = body["base_url"]
if "model" in body:
cfg["model"] = body["model"]
if "fallback_models" in body:
cfg["fallback_models"] = body["fallback_models"]
save_config(cfg)
logger.info("Config updated: model=%s base_url=%s", cfg.get("model"), cfg.get("base_url"))
return jsonify({"success": True, "model": cfg["model"]})
@app.route("/python-leadgen/models", methods=["GET"])
async def list_models():
"""Fetch available free models from the configured API endpoint."""
cfg = load_config()
base = cfg.get("base_url", "https://openrouter.ai/api/v1")
key = cfg.get("api_key", "")
models_url = base.rstrip("/") + "/models"
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
models_url,
headers={"Authorization": f"Bearer {key}"} if key else {},
)
if resp.status_code != 200:
return jsonify({
"success": False,
"error": f"API returned {resp.status_code}",
"models": _fallback_model_list(),
})
data = resp.json()
all_models = data.get("data", [])
free_models = [
{
"id": m["id"],
"name": m.get("name", m["id"]),
"context_length": m.get("context_length", 0),
"pricing": str(m.get("pricing", {})),
}
for m in all_models
if ":free" in m.get("id", "")
]
free_models.sort(key=lambda m: -m["context_length"])
return jsonify({"success": True, "total": len(free_models), "models": free_models})
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"models": _fallback_model_list(),
})
def _fallback_model_list():
return [
{"id": "qwen/qwen3-coder:free", "name": "Qwen 3 Coder (Free)", "context_length": 1048576},
{"id": "google/gemma-4-31b-it:free", "name": "Gemma 4 31B (Free)", "context_length": 262144},
{"id": "google/gemma-4-26b-a4b-it:free", "name": "Gemma 4 26B (Free)", "context_length": 262144},
{"id": "meta-llama/llama-3.3-70b-instruct:free", "name": "Llama 3.3 70B (Free)", "context_length": 131072},
{"id": "nvidia/nemotron-3-super-120b-a12b:free", "name": "Nemotron Super 120B (Free)", "context_length": 1000000},
]
@app.route("/python-leadgen/analyze", methods=["POST"])
async def analyze():
try:
body = request.get_json(silent=True) or {}
except Exception:
return jsonify({"success": False, "error": "Invalid JSON body"}), 400
url = body.get("url", "").strip()
if not url:
return jsonify({"success": False, "error": "Missing required field: url"}), 400
engine = get_engine()
try:
result: LeadGenResponse = await engine.analyze_url(url)
except Exception as exc:
logger.exception("Unexpected error analyzing %s", url)
return jsonify({"success": False, "error": str(exc)}), 500
if result.success and result.profile:
return jsonify(result.model_dump())
else:
return jsonify(result.model_dump()), 422
@app.route("/python-leadgen/batch", methods=["POST"])
async def batch():
try:
body = request.get_json(silent=True) or {}
except Exception:
return jsonify({"success": False, "error": "Invalid JSON body"}), 400
urls = body.get("urls", [])
if not urls or not isinstance(urls, list):
return jsonify({"success": False, "error": "Missing required field: urls (list)"}), 400
if len(urls) > 10:
return jsonify({"success": False, "error": "Maximum 10 URLs per batch"}), 400
engine = get_engine()
try:
results = await engine.batch_analyze(urls)
except Exception as exc:
logger.exception("Batch analysis failed")
return jsonify({"success": False, "error": str(exc)}), 500
return jsonify({
"success": True,
"total": len(urls),
"results": [r.model_dump() for r in results],
})
if __name__ == "__main__":
port = int(os.getenv("LEADGEN_PORT", "3003"))
debug = os.getenv("LEADGEN_DEBUG", "0") == "1"
app.run(host="0.0.0.0", port=port, debug=debug)
+133
View File
@@ -0,0 +1,133 @@
"""LeadScopingEngine orchestrates scraping + AI extraction for lead generation."""
from __future__ import annotations
import json
import logging
import os
import time
from pathlib import Path
from typing import Optional
from scraper import AsyncScraper
from extractor import DeepSeekExtractor
from models import CompanyTechProfile, ScrapeResult, LeadGenResponse
logger = logging.getLogger(__name__)
CONFIG_PATH = Path(os.getenv("LEADGEN_CONFIG", "/app/data/leadgen_config.json"))
CAREER_PATHS = ["/careers", "/jobs", "/about/careers", "/careers/openings", "/company/careers", "/en/careers"]
def _load_config() -> dict:
if CONFIG_PATH.exists():
try:
return json.loads(CONFIG_PATH.read_text())
except Exception:
pass
return {}
class LeadScopingEngine:
"""Orchestrates async scraping and AI-powered tech signal extraction."""
def __init__(
self,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
scrape_timeout_ms: int = 30_000,
):
self.api_key = api_key
self.api_base = api_base
self.scraper = AsyncScraper(timeout_ms=scrape_timeout_ms)
self._extractor: Optional[DeepSeekExtractor] = None
@property
def extractor(self) -> DeepSeekExtractor:
if self._extractor is None:
cfg = _load_config()
key = self.api_key or cfg.get("api_key", "")
base = self.api_base or cfg.get("base_url", "https://openrouter.ai/api/v1")
self._extractor = DeepSeekExtractor(api_key=key, base_url=base)
return self._extractor
async def analyze_url(self, url: str) -> LeadGenResponse:
"""Analyze a company URL: scrape main page + careers → extract → return structured profile."""
t0 = time.monotonic()
# Step 1: Scrape main page
logger.info("Scraping %s ...", url)
main_result: ScrapeResult = await self.scraper.scrape(url)
if main_result.error or not main_result.text_content:
logger.warning("Scrape failed for %s: %s", url, main_result.error)
return LeadGenResponse(
success=False,
error=main_result.error or "No content extracted",
elapsed_ms=round((time.monotonic() - t0) * 1000, 1),
)
# Step 2: Also scrape careers page for hiring data
combined_text = main_result.text_content
career_sections = []
base = url.rstrip("/")
for path in CAREER_PATHS:
career_url = base + path
try:
cr = await self.scraper.scrape(career_url)
if cr.text_content and len(cr.text_content) > 100:
logger.info("Careers page found: %s (%d chars)", career_url, len(cr.text_content))
career_sections.append(f"\n\n=== JOB LISTINGS FROM {career_url} ===\n\n" + cr.text_content[:20000])
break # Stop after first match
except Exception:
continue
if career_sections:
combined_text += "\n".join(career_sections)
logger.info(
"Scraped %s — main: %d chars, careers: %d chars — %.1fs",
url, len(main_result.text_content),
sum(len(c) for c in career_sections),
time.monotonic() - t0,
)
# Step 3: AI Extraction
try:
cfg = _load_config()
model = cfg.get("model", "qwen/qwen3-coder:free")
fallbacks = cfg.get("fallback_models", [])
profile: CompanyTechProfile = await self.extractor.extract(
combined_text, url, model=model, fallback_models=fallbacks
)
except Exception as exc:
logger.exception("AI extraction failed for %s", url)
return LeadGenResponse(
success=False,
error=f"AI extraction error: {str(exc)}",
elapsed_ms=round((time.monotonic() - t0) * 1000, 1),
)
elapsed = round((time.monotonic() - t0) * 1000, 1)
logger.info(
"Analysis complete — %s | Tech: %d | Pains: %d | Roles: %d | Skills: %d | Maturity: %s | %.0fms",
profile.company_name,
len(profile.detected_technologies),
len(profile.current_pain_points),
len(profile.hiring_roles),
len(profile.desired_skills),
profile.estimated_data_maturity,
elapsed,
)
return LeadGenResponse(
success=True,
profile=profile,
elapsed_ms=elapsed,
)
async def batch_analyze(self, urls: list[str]) -> list[LeadGenResponse]:
"""Analyze multiple URLs concurrently."""
import asyncio
tasks = [self.analyze_url(url) for url in urls]
return await asyncio.gather(*tasks, return_exceptions=False)
+169
View File
@@ -0,0 +1,169 @@
"""DeepSeek AI extractor for structured tech profiling from scraped text."""
from __future__ import annotations
import json
import logging
import os
import re
from datetime import datetime, timezone
from typing import Optional
from openai import AsyncOpenAI
from models import CompanyTechProfile
logger = logging.getLogger(__name__)
DEEPSEEK_BASE_URL = "https://openrouter.ai/api/v1"
DEEPSEEK_MODEL = "qwen/qwen3-coder:free"
FALLBACK_MODELS = [
"meta-llama/llama-3.3-70b-instruct:free",
"google/gemma-4-26b-a4b-it:free",
"nvidia/nemotron-3-super-120b-a12b:free",
]
MAX_INPUT_CHARS = 50_000 # Truncate input to stay within context limits
EXTRACTION_SYSTEM_PROMPT = """You are a Principal AI Architect and technical due-diligence expert.
Analyze the company website text and extract structured technical intelligence.
CRITICAL RULES:
- detected_technologies MUST contain at least 3 items. Look for ANY technology mention: programming languages, databases, cloud providers, DevOps tools, frameworks, AI/ML tools, data platforms, monitoring, CI/CD, etc. If the page mentions nothing technical, list technologies commonly associated with their industry.
- current_pain_points MUST contain at least 2 items. Infer from their industry, product description, or company stage.
- hiring_roles and desired_skills: If there is a careers/jobs section in the text, list actual job titles and required skills. Look for words like "hiring", "we are looking for", "join our team", "job", "position", "engineer", "developer", "scientist", "architect".
- outreach_hook MUST be at least 30 characters. Never leave it empty.
Return ONLY valid JSON matching this exact schema:
{
"company_name": "string",
"detected_technologies": ["at least 3 items, be creative if needed based on industry"],
"current_pain_points": ["at least 2 items"],
"estimated_data_maturity": "Low | Medium | High",
"outreach_hook": "string (minimum 1 sentence, be specific)",
"industry": "string or null",
"company_size_hint": "Startup | Scale-up | Mid-market | Enterprise or null",
"tech_blog_posts": "url or null",
"hiring_roles": ["list of job titles from careers section, empty if no jobs section found"],
"desired_skills": ["list of skills from job requirements, empty if no jobs section found"],
"job_listings_url": "url to careers page or null"
}
No markdown fences, no trailing commas. Every array MUST have content unless truly no information exists."""
class DeepSeekExtractor:
"""Extracts structured CompanyTechProfile from text using DeepSeek API."""
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
self.api_key = api_key or os.getenv("DEEPSEEK_API_KEY", "")
self.base_url = base_url or os.getenv("DEEPSEEK_BASE_URL", DEEPSEEK_BASE_URL)
self._client: Optional[AsyncOpenAI] = None
@property
def client(self) -> AsyncOpenAI:
if self._client is None:
if not self.api_key:
raise ValueError(
"DeepSeek API key not configured. "
"Set DEEPSEEK_API_KEY environment variable or pass api_key parameter."
)
self._client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
max_retries=2,
timeout=60.0,
default_headers={
"HTTP-Referer": "http://mek-tech.nl",
"X-Title": "Mek-Tech LeadGen Engine",
},
)
return self._client
async def extract(self, text: str, url: str, model: str = DEEPSEEK_MODEL, fallback_models: list[str] | None = None) -> CompanyTechProfile:
"""Extract CompanyTechProfile from scraped page text."""
if not text.strip():
raise ValueError("Empty text content cannot extract profile")
# Truncate text to fit model context
truncated = text[:MAX_INPUT_CHARS]
if len(text) > MAX_INPUT_CHARS:
logger.info("Text truncated from %d to %d chars", len(text), MAX_INPUT_CHARS)
user_prompt = f"URL: {url}\n\nANALYZE THIS COMPANY WEBSITE TEXT:\n\n{truncated}"
models_to_try = [model] + (fallback_models or FALLBACK_MODELS)
last_error = None
for m in models_to_try:
try:
response = await self.client.chat.completions.create(
model=m,
messages=[
{"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.1,
max_tokens=2048,
)
logger.info("Used model: %s", m)
break
except Exception as exc:
last_error = exc
logger.warning("Model %s failed: %s", m, str(exc)[:200])
if m != models_to_try[-1]:
import asyncio
await asyncio.sleep(1)
continue
else:
raise RuntimeError(f"All models failed. Last error: {str(last_error)}") from last_error
raw_content = response.choices[0].message.content or "{}"
parsed = self._parse_json(raw_content)
# Enrich with metadata
parsed["extracted_at"] = datetime.now(timezone.utc).isoformat()
parsed["source_url"] = url
# Normalize maturity
maturity = str(parsed.get("estimated_data_maturity", "")).strip().capitalize()
if maturity not in ("Low", "Medium", "High"):
maturity = "Medium"
parsed["estimated_data_maturity"] = maturity
# Ensure lists are lists, strings are strings
for field in ("detected_technologies", "current_pain_points", "hiring_roles", "desired_skills"):
if not isinstance(parsed.get(field), list):
parsed[field] = []
for field in ("outreach_hook", "industry", "company_size_hint", "tech_blog_posts", "job_listings_url"):
if parsed.get(field) is None:
parsed[field] = ""
if not parsed.get("company_name"):
parsed["company_name"] = url.split("//")[-1].split("/")[0]
try:
return CompanyTechProfile(**parsed)
except Exception as exc:
logger.error("Pydantic validation failed: %s\nRaw data: %s", exc, parsed)
raise ValueError(f"Failed to validate extracted profile: {str(exc)}") from exc
@staticmethod
def _parse_json(text: str) -> dict:
"""Robust JSON parsing from LLM output."""
# Strip markdown code fences if present
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Attempt to extract JSON object from text
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
raise ValueError(f"Unable to parse valid JSON from LLM response: {text[:500]}")
+81
View File
@@ -0,0 +1,81 @@
"""Pydantic v2 data models for AI-driven Lead Generation & Tech Scoping."""
from __future__ import annotations
from pydantic import BaseModel, Field
from typing import List, Optional
class CompanyTechProfile(BaseModel):
company_name: str = Field(
...,
description="Official company name as detected from the page content",
min_length=1,
)
detected_technologies: List[str] = Field(
default_factory=list,
description="Detected technologies: Spark, Kafka, AWS, Kubernetes, Snowflake, Airflow, dbt, etc.",
)
current_pain_points: List[str] = Field(
default_factory=list,
description="Inferred pain points: legacy migration, data quality, scalability, missing AI/ML, etc.",
)
estimated_data_maturity: Optional[str] = Field(
default="Medium",
description="Estimated data maturity level: Low, Medium, or High",
)
outreach_hook: Optional[str] = Field(
default="",
description="Concrete reason why a Forward Deployed Engineer can help this company right now",
)
industry: Optional[str] = Field(
default=None,
description="Detected industry or sector",
)
company_size_hint: Optional[str] = Field(
default=None,
description="Estimated company size: Startup, Scale-up, Mid-market, Enterprise",
)
tech_blog_posts: Optional[str] = Field(
default=None,
description="URL to engineering/tech blog if detected",
)
hiring_roles: List[str] = Field(
default_factory=list,
description="Job titles/roles detected from careers page: Data Engineer, ML Engineer, DevOps, etc.",
)
desired_skills: List[str] = Field(
default_factory=list,
description="Specific skills/technologies they are hiring for: Python, Spark, Terraform, dbt, etc.",
)
job_listings_url: Optional[str] = Field(
default=None,
description="URL to careers/jobs page if detected on the site",
)
extracted_at: Optional[str] = Field(
default=None,
description="ISO timestamp of extraction",
)
source_url: Optional[str] = Field(
default=None,
description="Original URL that was scraped",
)
class ScrapeResult(BaseModel):
url: str
title: str
text_content: str
text_length: int
status_code: Optional[int] = None
error: Optional[str] = None
class LeadGenRequest(BaseModel):
url: str = Field(..., description="URL of the target company page to analyze")
force_refresh: bool = Field(default=False, description="Skip cache and force fresh scrape")
class LeadGenResponse(BaseModel):
success: bool
profile: Optional[CompanyTechProfile] = None
error: Optional[str] = None
elapsed_ms: Optional[float] = None
+6
View File
@@ -0,0 +1,6 @@
flask[async]>=3.0
pydantic>=2.0
openai>=1.12
httpx>=0.27
beautifulsoup4>=4.12
python-dotenv>=1.0
+157
View File
@@ -0,0 +1,157 @@
"""Async httpx web scraper with retries, rotating user agents, and fallback."""
from __future__ import annotations
import logging
import random
import re
from typing import Optional
import httpx
from bs4 import BeautifulSoup
from models import ScrapeResult
logger = logging.getLogger(__name__)
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
]
REMOVE_TAGS = {"script", "style", "nav", "footer", "header", "noscript", "iframe", "svg"}
class AsyncScraper:
"""Asynchronous web scraper with retries, rotating UAs, and fallback HTTP clients."""
def __init__(self, timeout_ms: int = 20_000):
self.timeout = timeout_ms / 1000.0
async def scrape(self, url: str) -> ScrapeResult:
"""Scrape a URL with retries and fallbacks."""
errors = []
# Strategy 1: Standard httpx client
for attempt in range(3):
try:
result = await self._try_scrape(url, attempt)
if result.text_content and len(result.text_content) > 50:
return result
if result.error:
errors.append(f"Attempt {attempt+1}: {result.error}")
except Exception as e:
errors.append(f"Attempt {attempt+1}: {str(e)}")
# Strategy 2: Minimal headers (like curl)
for attempt in range(2):
try:
result = await self._try_minimal(url)
if result.text_content and len(result.text_content) > 50:
return result
if result.error:
errors.append(f"Minimal {attempt+1}: {result.error}")
except Exception as e:
errors.append(f"Minimal {attempt+1}: {str(e)}")
return ScrapeResult(url=url, title="", text_content="", text_length=0,
error="; ".join(errors[-3:]) or "All scrape attempts failed")
async def _try_scrape(self, url: str, attempt: int) -> ScrapeResult:
ua = USER_AGENTS[attempt % len(USER_AGENTS)]
headers = {
"User-Agent": ua,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,nl;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"DNT": "1",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Cache-Control": "max-age=0",
}
async with httpx.AsyncClient(
timeout=self.timeout,
follow_redirects=True,
headers=headers,
http2=True,
) as client:
response = await client.get(url)
return self._parse_response(response, url)
async def _try_minimal(self, url: str) -> ScrapeResult:
headers = {
"User-Agent": "curl/8.4.0",
"Accept": "*/*",
}
async with httpx.AsyncClient(
timeout=self.timeout,
follow_redirects=True,
headers=headers,
http2=False,
) as client:
response = await client.get(url)
return self._parse_response(response, url)
def _parse_response(self, response, url: str) -> ScrapeResult:
result = ScrapeResult(url=url, title="", text_content="", text_length=0)
result.status_code = response.status_code
if response.status_code >= 500:
result.error = f"HTTP {response.status_code}"
return result
if response.status_code >= 400 and response.status_code < 500:
if response.status_code in (403, 429):
result.error = f"HTTP {response.status_code} (blocked/rate-limited)"
else:
result.error = f"HTTP {response.status_code}"
return result
try:
html = response.text
except Exception:
result.error = "Could not decode response"
return result
if not html or len(html) < 100:
result.error = "Empty response"
return result
soup = BeautifulSoup(html, "html.parser")
result.title = soup.title.string.strip() if soup.title else ""
# Remove non-content elements
for tag in soup(REMOVE_TAGS):
tag.decompose()
body = soup.find("body")
text = body.get_text(separator="\n") if body else soup.get_text(separator="\n")
# Also extract text from JSON-LD structured data
for script in soup.find_all("script", type="application/ld+json"):
try:
import json
ld = json.loads(script.string or "")
if isinstance(ld, dict):
desc = ld.get("description", "")
if desc:
text += "\n" + desc
except Exception:
pass
result.text_content = self._clean_text(text)
result.text_length = len(result.text_content)
return result
@staticmethod
def _clean_text(text: str) -> str:
if not text:
return ""
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
lines = [ln.strip() for ln in text.split("\n")]
lines = [ln for ln in lines if len(ln) > 2]
return "\n".join(lines).strip()
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.11-slim
WORKDIR /app
RUN pip install --upgrade pip
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV QS_PORT=3002
EXPOSE 3002
CMD ["python3", "app.py"]
+286
View File
@@ -0,0 +1,286 @@
import os, json, uuid
import pandas as pd, numpy as np
from flask import Flask, request, jsonify
from werkzeug.utils import secure_filename
app = Flask(__name__)
UPLOAD_FOLDER = '/app/data/uploads'
REPORT_FOLDER = '/app/data/reports'
DBT_PROJECT = '/app/data/dbt_projects'
for d in [UPLOAD_FOLDER, REPORT_FOLDER, DBT_PROJECT]: os.makedirs(d, exist_ok=True)
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024
ALLOWED_EXT = {'csv', 'xlsx', 'xls', 'parquet', 'json', 'tsv'}
def load_df(fp):
e = fp.rsplit('.',1)[1].lower()
if e == 'csv': return pd.read_csv(fp)
if e == 'tsv': return pd.read_csv(fp, sep='\t')
if e in ('xlsx','xls'): return pd.read_excel(fp)
if e == 'parquet': return pd.read_parquet(fp)
if e == 'json': return pd.read_json(fp)
def safe_float(v):
try: return round(float(v), 4)
except: return None
@app.route('/python-quality/')
def idx():
return jsonify({'service':'Mek-Tech Data Quality Engine v2.0','features':['profiling','quality','dbt','correlation','distribution','lineage']})
@app.route('/python-quality/api/profile', methods=['POST'])
def profile():
d = request.get_json()
sp = d.get('shared_path')
fid = d.get('file_id')
fp = None
if sp and os.path.exists(sp): fp = sp
elif fid:
for f in os.listdir(UPLOAD_FOLDER):
if f.startswith(fid): fp = os.path.join(UPLOAD_FOLDER, f); break
if not fp: return jsonify({'error':'File not found'}), 404
df = load_df(fp)
if df is None: return jsonify({'error':'Cannot parse'}), 500
p = _profile(df)
p['correlation'] = _correlation(df)
p['distribution'] = _distribution(df)
return jsonify({'file_id':fid or os.path.basename(fp).split('.')[0], 'rows':len(df), 'columns':len(df.columns), 'profile':p})
@app.route('/python-quality/api/quality-check', methods=['POST'])
def check():
d = request.get_json()
sp = d.get('shared_path')
fid = d.get('file_id')
checks = d.get('checks', ['completeness','uniqueness','range','type','drift'])
fp = None
if sp and os.path.exists(sp): fp = sp
elif fid:
for f in os.listdir(UPLOAD_FOLDER):
if f.startswith(fid): fp = os.path.join(UPLOAD_FOLDER, f); break
if not fp: return jsonify({'error':'File not found'}), 404
df = load_df(fp)
if df is None: return jsonify({'error':'Cannot parse'}), 500
r = _checks(df, checks)
rid = str(uuid.uuid4())
with open(os.path.join(REPORT_FOLDER,f"{rid}.json"),'w') as f: json.dump(r, f)
return jsonify({'report_id':rid,'results':r})
@app.route('/python-quality/api/report/<rid>')
def report(rid):
p = os.path.join(REPORT_FOLDER, f"{rid}.json")
if not os.path.exists(p): return jsonify({'error':'Not found'}), 404
with open(p) as f: return jsonify(json.load(f))
@app.route('/python-quality/api/dbt/init', methods=['POST'])
def dbt_init():
d = request.get_json()
name = d.get('project_name', 'default')
proj_dir = os.path.join(DBT_PROJECT, name)
os.makedirs(proj_dir, exist_ok=True)
# Create dbt_project.yml
with open(os.path.join(proj_dir, 'dbt_project.yml'), 'w') as f:
f.write(f"name: '{name}'\nversion: '1.0'\nprofile: '{name}'\nmodels:\n {name}:\n +materialized: table\n")
os.makedirs(os.path.join(proj_dir, 'models'), exist_ok=True)
os.makedirs(os.path.join(proj_dir, 'tests'), exist_ok=True)
return jsonify({'status':'created','project':name,'path':proj_dir})
@app.route('/python-quality/api/dbt/validate', methods=['POST'])
def dbt_validate():
d = request.get_json()
models = d.get('models', [])
issues = []
for m in models:
sql = (m.get('sql','')).lower()
cols = m.get('columns', [])
warns = []
if 'select *' in sql: warns.append('SELECT * gedetecteerd — specificeer kolommen')
if 'union all' not in sql and 'union' in sql: warns.append('UNION zonder ALL — verwijdert duplicaten')
if len(sql) > 0 and 'where' not in sql and 'limit' not in sql: warns.append('Geen WHERE/LIMIT — mogelijk performance issue')
for col in cols:
sql_col = sql.lower()
if col.lower() in sql_col:
if f"cast({col.lower()}" not in sql_col and f"{col.lower()}::" not in sql_col:
pass
issues.append({'model_name':m.get('name','unnamed'),'warnings':warns,'status':'warning' if warns else 'ok'})
return jsonify({'validated':len(models),'issues':issues})
@app.route('/python-quality/api/dbt/test', methods=['POST'])
def dbt_test():
d = request.get_json()
tests = d.get('tests', [])
results = []
for t in tests:
test_type = t.get('type','')
col = t.get('column','')
df_info = t.get('data', {})
passed = True
msg = ''
try:
if test_type == 'unique':
vals = df_info.get('unique_count',0)
total = df_info.get('row_count',1)
passed = vals >= total
msg = f"{vals}/{total} unieke waarden"
elif test_type == 'not_null':
nulls = df_info.get('null_count',0)
passed = nulls == 0
msg = f"{nulls} nulls"
elif test_type == 'accepted_values':
vals = set(df_info.get('values',[]))
accepted = set(t.get('values',[]))
passed = vals.issubset(accepted)
msg = f"{len(vals)} waarden, {len(accepted)} toegestaan"
elif test_type == 'relationships':
passed = True
msg = 'Referenties OK'
else:
passed = True
msg = f'Test {test_type} uitgevoerd'
except: passed = False; msg = 'Error'
results.append({'test':test_type,'column':col,'passed':passed,'message':msg})
p = sum(1 for r in results if r['passed'])
return jsonify({'results':results,'summary':{'passed':p,'failed':len(results)-p,'total':len(results)}})
def _profile(df):
prof = {'rows':len(df),'columns':len(df.columns),'memory_mb':round(df.memory_usage(deep=True).sum()/(1024*1024),2)}
cols = []
for col in df.columns:
cp = {'name':col,'dtype':str(df[col].dtype)}
cp['non_null']=int(df[col].notna().sum())
cp['null_count']=int(df[col].isna().sum())
cp['null_pct']=round(float(df[col].isna().mean()*100),2)
cp['unique']=int(df[col].nunique())
cp['unique_pct']=round(float(df[col].nunique()/max(len(df),1)*100),2)
vals = df[col].dropna()
if pd.api.types.is_numeric_dtype(df[col]):
cp['min']=safe_float(vals.min()) if len(vals)>0 else None
cp['max']=safe_float(vals.max()) if len(vals)>0 else None
cp['mean']=safe_float(vals.mean()) if len(vals)>0 else None
cp['std']=safe_float(vals.std()) if len(vals)>0 else None
cp['zeros']=int((df[col]==0).sum())
cp['negative']=int((df[col]<0).sum())
qtls = vals.quantile([0.25,0.5,0.75]).to_dict()
cp['q25']=safe_float(qtls.get(0.25)); cp['q50']=safe_float(qtls.get(0.5)); cp['q75']=safe_float(qtls.get(0.75))
bins = min(20,max(3,int(len(vals)/5)))
hist,b_edges = np.histogram(vals,bins=bins)
cp['histogram']={'bins':[round(float(e),4) for e in b_edges.tolist()],'counts':hist.tolist()}
else:
top = vals.value_counts().head(5).to_dict()
cp['top_values'] = {str(k):int(v) for k,v in top.items()}
cp['sample'] = vals.head(3).tolist()
if pd.api.types.is_string_dtype(vals):
cp['min_len']=int(vals.astype(str).str.len().min()) if len(vals)>0 else 0
cp['max_len']=int(vals.astype(str).str.len().max()) if len(vals)>0 else 0
try:
pd_dt = pd.to_datetime(vals, errors='coerce')
if pd_dt.notna().sum()>0:
cp['date_min']=str(pd_dt.min().date()); cp['date_max']=str(pd_dt.max().date())
except: pass
cols.append(cp)
prof['columns_profile']=cols
prof['overall_quality_score']=_score(cols)
prof['suggestions']=_suggest(cols)
return prof
def _correlation(df):
nums = df.select_dtypes(include=[np.number])
if len(nums.columns)<2: return []
corr = nums.corr().round(3)
rels = []
for i,ci in enumerate(corr.columns):
for j,cj in enumerate(corr.columns):
if i<j:
v = float(corr.iloc[i,j])
if not np.isnan(v):
rels.append({'from':ci,'to':cj,'value':v,'strength':'high' if abs(v)>0.7 else 'medium' if abs(v)>0.4 else 'low','direction':'positive' if v>0 else 'negative'})
rels.sort(key=lambda x: abs(x['value']), reverse=True)
return rels[:15]
def _distribution(df):
dist = {}
for col in df.select_dtypes(include=[np.number]).columns[:5]:
vals = df[col].dropna()
if len(vals)<2: continue
dist[col] = {
'min':safe_float(vals.min()),'max':safe_float(vals.max()),'mean':safe_float(vals.mean()),
'median':safe_float(vals.median()),'std':safe_float(vals.std()),
'skew':safe_float(vals.skew()) if len(vals)>2 else 0,
'kurtosis':safe_float(vals.kurtosis()) if len(vals)>3 else 0,
'outliers_iqr':int(((vals<(vals.quantile(0.25)-1.5*(vals.quantile(0.75)-vals.quantile(0.25))))|(vals>(vals.quantile(0.75)+1.5*(vals.quantile(0.75)-vals.quantile(0.25))))).sum())
}
return dist
def _score(profiles):
if not profiles: return 100
scs = []
for p in profiles:
s = 100
if p['null_pct']>50: s-=35
elif p['null_pct']>20: s-=20
elif p['null_pct']>5: s-=8
if p['unique_pct']>99 and p['null_pct']<50: s-=10
if p['unique_pct']<1: s-=25
scs.append(max(0,s))
return round(sum(scs)/len(scs),1)
def _suggest(profiles):
sug = []
for p in profiles:
if p['null_pct']>30: sug.append({'column':p['name'],'issue':f"Zeer hoge null-rate ({p['null_pct']}%)",'severity':'high','action':'Imputeer met mediaan/modus of overweeg kolom te verwijderen'})
elif p['null_pct']>10: sug.append({'column':p['name'],'issue':f"Missende waarden ({p['null_pct']}%)",'severity':'medium','action':'Overweeg imputatie of markeer als optioneel'})
elif p['null_pct']>2: sug.append({'column':p['name'],'issue':f"Enkele nulls ({p['null_pct']}%)",'severity':'low','action':'Controleer of nulls verwacht worden'})
if p.get('zeros') and p['zeros']/(max(p['non_null'],1))>0.4: sug.append({'column':p['name'],'issue':f"Veel nullen (>{round(p['zeros']/max(p['non_null'],1)*100)}%)",'severity':'medium','action':'Controleer of 0 legitieme waarden of missende data zijn'})
if p.get('negative') and p['negative']>0: sug.append({'column':p['name'],'issue':f"Negatieve waarden ({p['negative']}) in numerieke kolom",'severity':'medium','action':'Valideer of negatieve waarden zakelijk correct zijn'})
if p['unique_pct']>95 and p['null_pct']<50: sug.append({'column':p['name'],'issue':'Nagenoeg uniek — vermoedelijk ID kolom','severity':'low','action':'Geschikt als primary key, niet voor aggregatie of model training'})
if p['unique_pct']<1 and p['null_pct']<90: sug.append({'column':p['name'],'issue':'Constante kolom — geen variatie','severity':'low','action':'Overweeg deze kolom te verwijderen voor analyse'})
if p.get('std') and p.get('mean') and p['mean']!=0 and p['std']/abs(p['mean'])>2: sug.append({'column':p['name'],'issue':'Hoge variabiliteit (CV > 200%)','severity':'medium','action':'Controleer op outliers, overweeg winsorization of log-transformatie'})
return sug
def _checks(df, checks):
res = {'checks':[],'summary':{'passed':0,'failed':0,'total':0}}
for col in df.columns:
if 'completeness' in checks:
npct = float(df[col].isna().mean()*100)
ok = npct<20
res['checks'].append({'type':'completeness','column':col,'passed':ok,'value':round(npct,2),'threshold':'<20% null'})
res['summary']['total']+=1
if ok: res['summary']['passed']+=1
else: res['summary']['failed']+=1
if 'uniqueness' in checks:
upct = float(df[col].nunique()/max(len(df),1)*100)
ok = upct>1
res['checks'].append({'type':'uniqueness','column':col,'passed':ok,'value':round(upct,2),'threshold':'>1% uniek'})
res['summary']['total']+=1
if ok: res['summary']['passed']+=1
else: res['summary']['failed']+=1
is_num = False
try: is_num = np.issubdtype(df[col].dtype, np.number)
except: pass
if 'range' in checks and is_num:
vals = df[col].dropna()
if len(vals)>1:
q1=float(vals.quantile(0.25)); q3=float(vals.quantile(0.75)); iqr=q3-q1
out = int(((vals<(q1-1.5*iqr))|(vals>(q3+1.5*iqr))).sum())
ok = out/max(len(vals),1)<0.1
res['checks'].append({'type':'outliers','column':col,'passed':ok,'value':out,'threshold':'<10% outliers'})
res['summary']['total']+=1
if ok: res['summary']['passed']+=1
else: res['summary']['failed']+=1
if 'drift' in checks and is_num:
vals = df[col].dropna()
if len(vals)>1:
m=float(vals.mean()); s=float(vals.std())
ok = s/(abs(m)+1)<1.0 if m!=0 else True
res['checks'].append({'type':'drift','column':col,'passed':ok,'value':round(s/max(abs(m),0.01),3),'threshold':'std/mean < 1.0'})
res['summary']['total']+=1
if ok: res['summary']['passed']+=1
else: res['summary']['failed']+=1
if 'type' in checks:
res['checks'].append({'type':'type','column':col,'passed':True,'value':str(df[col].dtype),'threshold':'consistent'})
res['summary']['total']+=1; res['summary']['passed']+=1
sc = round((res['summary']['passed']/max(res['summary']['total'],1))*100,1)
res['quality_score']=sc
res['grade']='A' if sc>=90 else 'B' if sc>=75 else 'C' if sc>=50 else 'D' if sc>=25 else 'F'
return res
if __name__ == '__main__':
app.run(host='0.0.0.0', port=int(os.environ.get('QS_PORT',3002)), debug=False)
+9
View File
@@ -0,0 +1,9 @@
flask>=3.0
pandas>=2.0,<3.0
numpy>=1.24,<2.0
openpyxl>=3.1
python-multipart>=0.0.6
scikit-learn>=1.3
scipy>=1.11
matplotlib>=3.7
pyarrow>=14.0
+171
View File
@@ -0,0 +1,171 @@
const DEVICE_TO_NODE = {
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',
cooling: 'infra-cooling',
patch_panel: 'infra-patch-panel',
tape: 'storage-netapp',
kvm: 'infra-rack',
other: 'infra-rack'
};
const MANUFACTURER_NODE = {
hpe: 'hw-hpe-dl',
dell: 'hw-dell-pe',
lenovo: 'hw-lenovo-ts',
cisco: 'net-catalyst',
juniper: 'net-junper-ex',
arista: 'net-arista',
netapp: 'storage-netapp',
pure: 'storage-pure',
fortinet: 'net-fortinet',
f5: 'net-f5',
palo: 'net-paloalto',
nvidia: 'ai-nvidia',
apc: 'infra-apc-ups'
};
const DIAGRAM_TYPE_LABELS = {
data_architecture: 'Data Architectuur',
network_topology: 'Netwerk Topologie',
system_architecture: 'Systeem Architectuur',
application_flow: 'Applicatie Flow',
application_landscape: 'Applicatie Landschap'
};
function resolveNodeType(device) {
const m = (device.manufacturer || '').toLowerCase();
for (const [key, type] of Object.entries(MANUFACTURER_NODE)) {
if (m.includes(key)) return type;
}
return DEVICE_TO_NODE[device.device_type] || 'hw-dell-pe';
}
function rackDevicesToNodes(devices, startX = 40, startY = 40) {
return devices.map((d, i) => ({
id: 'rd-' + d.id,
type: resolveNodeType(d),
x: startX + (i % 4) * 180,
y: startY + Math.floor(i / 4) * 90,
label: d.name,
sub: (d.model || d.device_type || '').toString(),
css: 'node-hw',
w: 160,
h: 48,
ip: d.mgmt_ip || '',
vendor: d.manufacturer || '',
model: d.model || '',
specs: d.specs || '',
notes: d.notes || '',
rack_device_id: d.id,
layer: 'infra'
}));
}
function parseDiagramJson(diagram) {
let nodes = [];
let edges = [];
try {
nodes = JSON.parse(diagram.nodes || '[]');
edges = JSON.parse(diagram.edges || '[]');
} catch (e) { /* ignore */ }
if (!Array.isArray(nodes) && nodes && nodes.nodes) {
edges = nodes.edges || [];
nodes = nodes.nodes || [];
}
if (!Array.isArray(edges) && edges && edges.edges) {
nodes = edges.nodes || nodes;
edges = edges.edges || [];
}
if (!Array.isArray(nodes)) nodes = [];
if (!Array.isArray(edges)) edges = [];
return { nodes, edges };
}
function summarizeDiagram(diagram) {
const { nodes, edges } = parseDiagramJson(diagram);
const layers = {};
nodes.forEach(n => {
const layer = n.layer || (n.type || '').split('-')[0] || 'other';
layers[layer] = (layers[layer] || 0) + 1;
});
const protocols = {};
edges.forEach(e => {
const p = e.protocol || 'unspecified';
protocols[p] = (protocols[p] || 0) + 1;
});
return { nodes, edges, layers, protocols, nodeCount: nodes.length, edgeCount: edges.length };
}
function buildStudioDemo(rack, devices) {
const infraNodes = rackDevicesToNodes(devices, 40, 80);
const appNodes = [
{ id: 'demo-api', type: 'api-gateway', x: 480, y: 90, label: 'API Gateway', sub: 'Ingress / Routing', css: 'node-ingest', w: 150, h: 44, layer: 'integration' },
{ id: 'demo-erp', type: 'app-java', x: 480, y: 190, label: 'ERP Core', sub: 'Java / Spring', css: 'node-compute', w: 140, h: 44, layer: 'app' },
{ id: 'demo-dw', type: 'storage-warehouse', x: 480, y: 290, label: 'Data Warehouse', sub: 'Analytics', css: 'node-storage', w: 155, h: 44, layer: 'data' },
{ id: 'demo-kafka', type: 'queue-kafka', x: 680, y: 140, label: 'Event Bus', sub: 'Kafka', css: 'node-queue', w: 140, h: 44, layer: 'integration' },
{ id: 'demo-portal', type: 'app-react', x: 680, y: 240, label: 'Web Portal', sub: 'React Frontend', css: 'node-compute', w: 145, h: 44, layer: 'app' }
];
const nodes = [...infraNodes, ...appNodes];
const edges = [];
if (infraNodes[0]) {
edges.push({ from: infraNodes[0].id, to: 'demo-api', flow: 'both', protocol: 'sync' });
edges.push({ from: 'demo-api', to: 'demo-erp', flow: 'flow', protocol: 'sync' });
edges.push({ from: 'demo-erp', to: 'demo-dw', flow: 'flow', protocol: 'batch' });
edges.push({ from: 'demo-api', to: 'demo-kafka', flow: 'pulse', protocol: 'event' });
edges.push({ from: 'demo-kafka', to: 'demo-portal', flow: 'both', protocol: 'async' });
if (infraNodes[1]) edges.push({ from: infraNodes[1].id, to: 'demo-dw', flow: 'flow', protocol: 'batch' });
}
return {
flow: {
name: 'Applicatie Flow — ' + rack.name,
diagram_type: 'application_flow',
nodes: JSON.stringify(nodes),
edges: JSON.stringify(edges)
},
landscape: {
name: 'Applicatie Landschap — ' + rack.name,
diagram_type: 'application_landscape',
nodes: JSON.stringify(appNodes.map(n => ({ ...n, x: n.x - 200, y: n.y }))),
edges: JSON.stringify([
{ from: 'demo-portal', to: 'demo-api', flow: 'off', protocol: 'sync' },
{ from: 'demo-api', to: 'demo-erp', flow: 'off', protocol: 'sync' },
{ from: 'demo-erp', to: 'demo-dw', flow: 'off', protocol: 'batch' }
])
}
};
}
function getWorkspaceTab(diagramType) {
if (diagramType === 'application_flow') return 'flow';
if (diagramType === 'application_landscape') return 'landscape';
return 'flow';
}
function getLayerForCategory(catLabel) {
const c = (catLabel || '').toLowerCase();
if (c.includes('data bron') || c.includes('ingest') || c.includes('opslag') || c.includes('database')) return 'data';
if (c.includes('microservice') || c.includes('apps')) return 'app';
if (c.includes('api') || c.includes('messaging') || c.includes('queue')) return 'integration';
if (c.includes('netwerk') || c.includes('compute / server') || c.includes('infrastructure') || c.includes('security') || c.includes('observability') || c.includes('ci/cd')) return 'infra';
if (c.includes('ai')) return 'app';
return 'other';
}
module.exports = {
DEVICE_TO_NODE,
DIAGRAM_TYPE_LABELS,
resolveNodeType,
rackDevicesToNodes,
parseDiagramJson,
summarizeDiagram,
getLayerForCategory,
buildStudioDemo,
getWorkspaceTab
};
+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>
+756
View File
@@ -0,0 +1,756 @@
<!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<% if (typeof embed !== 'undefined' && embed) { %> style="overflow:hidden"<% } else { %> class="has-rack-ws-nav"<% } %>>
<% if (!(typeof embed !== 'undefined' && embed)) { %>
<%- include('partials/rack-workspace-nav', { rack, flowDiagram, landscapeDiagram, workspaceTab: typeof workspaceTab !== 'undefined' ? workspaceTab : 'rack' }) %>
<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 (typeof embed !== 'undefined' && embed) { %> style="padding:.5rem;max-width:none"<% } %>>
<!-- 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 %>/studio" class="btn-rack primary"><i data-lucide="layout-panel-left"></i> Studio</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="/racks/<%= rack.id %>/diagram/<%= 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 %>/studio" class="btn-rack ghost" style="justify-content:center;font-size:.65rem;padding:.35rem"><i data-lucide="layout-panel-left"></i> Studio</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>
+257
View File
@@ -0,0 +1,257 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
const { parseDiagramJson, summarizeDiagram, rackDevicesToNodes, DIAGRAM_TYPE_LABELS, buildStudioDemo, getWorkspaceTab } = require('../lib/rack-diagram');
function loadRackWorkspace(db, rackId) {
const rack = db.prepare('SELECT r.*, c.name as client_name FROM racks r JOIN clients c ON c.id = r.client_id WHERE r.id = ?').get(rackId);
if (!rack) return null;
const rackDevices = db.prepare('SELECT * FROM rack_devices WHERE rack_id = ? ORDER BY position_u ASC').all(rackId);
const linkedDiagrams = db.prepare('SELECT id, name, diagram_type, updated_at FROM data_diagrams WHERE rack_id = ? ORDER BY updated_at DESC').all(rackId);
const flowDiagram = linkedDiagrams.find(d => d.diagram_type === 'application_flow') || null;
const landscapeDiagram = linkedDiagrams.find(d => d.diagram_type === 'application_landscape') || null;
return { rack, rackDevices, linkedDiagrams, flowDiagram, landscapeDiagram };
}
function renderArchEditor(res, opts) {
const diagram = opts.diagram || null;
const rack = opts.rack;
const workspaceTab = opts.workspaceTab || (diagram ? getWorkspaceTab(diagram.diagram_type) : opts.defaultDiagramType === 'application_landscape' ? 'landscape' : 'flow');
res.render('arch-editor', {
diagram,
clients: opts.clients,
clientId: opts.clientId || (rack ? rack.client_id : null),
rack,
rackDevices: opts.rackDevices || [],
linkedDiagrams: opts.linkedDiagrams || [],
flowDiagram: opts.flowDiagram || null,
landscapeDiagram: opts.landscapeDiagram || null,
defaultDiagramType: opts.defaultDiagramType || null,
workspaceTab,
embed: !!opts.embed,
error: opts.error || null
});
}
router.get('/', (req, res) => {
const db = getDb();
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
`).all();
res.render('racks-list', { racks });
});
router.get('/new', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('racks-form', { rack: null, clients, selected: req.query.client_id ? parseInt(req.query.client_id, 10) : null, error: null });
});
router.get('/new/:clientId', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('racks-form', { rack: null, clients, selected: parseInt(req.params.clientId), error: null });
});
router.post('/new', (req, res) => {
const db = getDb();
const { client_id, name, location, datacenter, total_units, notes } = req.body;
if (!name || !client_id) return res.redirect('/racks');
db.prepare('INSERT INTO racks (client_id, name, location, datacenter, total_units, notes) VALUES (?,?,?,?,?,?)')
.run(client_id, name, location, datacenter, total_units || 42, notes);
res.redirect('/racks');
});
router.get('/:id', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.redirect('/racks');
const hardwareLibrary = require('../data/hardware-library');
if (req.query.embed === '1') {
return res.render('racks-view', {
...ws,
devices: ws.rackDevices,
hardwareLibrary,
embed: true,
workspaceTab: 'rack'
});
}
res.render('racks-view', {
rack: ws.rack,
devices: ws.rackDevices,
hardwareLibrary,
linkedDiagrams: ws.linkedDiagrams,
flowDiagram: ws.flowDiagram,
landscapeDiagram: ws.landscapeDiagram,
workspaceTab: 'rack',
embed: false
});
});
router.get('/:id/edit', (req, res) => {
const db = getDb();
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
if (!rack) return res.redirect('/racks');
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('racks-form', { rack, clients, selected: rack.client_id, error: null });
});
router.post('/:id/edit', (req, res) => {
const db = getDb();
const { name, location, datacenter, total_units, notes } = req.body;
db.prepare("UPDATE racks SET name=?, location=?, datacenter=?, total_units=?, notes=?, updated_at=datetime('now') WHERE id=?")
.run(name, location, datacenter, total_units, notes, req.params.id);
res.redirect('/racks/' + req.params.id);
});
router.post('/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM racks WHERE id = ?').run(req.params.id);
res.redirect('/racks');
});
router.post('/:id/devices', (req, res) => {
const db = getDb();
const { name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, status } = req.body;
if (!name) return res.redirect('/racks/' + req.params.id);
db.prepare('INSERT INTO rack_devices (rack_id, name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, status) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
.run(req.params.id, name, device_type || 'server', model, manufacturer, parseInt(position_u), parseInt(height_u) || 1, specs, mgmt_ip, notes, status || 'active');
res.redirect('/racks/' + req.params.id);
});
router.post('/devices/:deviceId/edit', (req, res) => {
const db = getDb();
const { name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, status } = req.body;
const device = db.prepare('SELECT rack_id FROM rack_devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.redirect('/racks');
db.prepare("UPDATE rack_devices SET name=?, device_type=?, model=?, manufacturer=?, position_u=?, height_u=?, specs=?, mgmt_ip=?, notes=?, status=?, updated_at=datetime('now') WHERE id=?")
.run(name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, status, req.params.deviceId);
res.redirect('/racks/' + device.rack_id);
});
router.post('/devices/:deviceId/delete', (req, res) => {
const db = getDb();
const device = db.prepare('SELECT rack_id FROM rack_devices WHERE id = ?').get(req.params.deviceId);
if (device) {
db.prepare('DELETE FROM rack_devices WHERE id = ?').run(req.params.deviceId);
res.redirect('/racks/' + device.rack_id);
} else {
res.redirect('/racks');
}
});
router.get('/:id/flow/new', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.redirect('/racks');
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
renderArchEditor(res, {
...ws,
clients,
diagram: null,
defaultDiagramType: 'application_flow',
workspaceTab: 'flow',
embed: req.query.embed === '1'
});
});
router.get('/:id/landscape/new', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.redirect('/racks');
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
renderArchEditor(res, {
...ws,
clients,
diagram: null,
defaultDiagramType: 'application_landscape',
workspaceTab: 'landscape',
embed: req.query.embed === '1'
});
});
router.get('/:id/diagram/:diagramId', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.redirect('/racks');
const diagram = db.prepare('SELECT d.*, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE d.id = ? AND d.rack_id = ?').get(req.params.diagramId, req.params.id);
if (!diagram) return res.redirect('/racks/' + req.params.id);
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
renderArchEditor(res, {
...ws,
clients,
diagram,
workspaceTab: getWorkspaceTab(diagram.diagram_type),
embed: req.query.embed === '1'
});
});
router.get('/:id/studio', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.redirect('/racks');
res.render('rack-studio', { ...ws, workspaceTab: 'studio' });
});
router.post('/:id/studio/demo', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.status(404).json({ error: 'Rack not found' });
const demo = buildStudioDemo(ws.rack, ws.rackDevices);
let flowId = ws.flowDiagram ? ws.flowDiagram.id : null;
let landscapeId = ws.landscapeDiagram ? ws.landscapeDiagram.id : null;
if (flowId) {
db.prepare("UPDATE data_diagrams SET name=?, nodes=?, edges=?, updated_at=datetime('now') WHERE id=?")
.run(demo.flow.name, demo.flow.nodes, demo.flow.edges, flowId);
} else {
const r = db.prepare('INSERT INTO data_diagrams (client_id, name, description, diagram_type, nodes, edges, rack_id) VALUES (?,?,?,?,?,?,?)')
.run(ws.rack.client_id, demo.flow.name, 'Studio demo flow', 'application_flow', demo.flow.nodes, demo.flow.edges, ws.rack.id);
flowId = r.lastInsertRowid;
}
if (landscapeId) {
db.prepare("UPDATE data_diagrams SET name=?, nodes=?, edges=?, updated_at=datetime('now') WHERE id=?")
.run(demo.landscape.name, demo.landscape.nodes, demo.landscape.edges, landscapeId);
} else {
const r = db.prepare('INSERT INTO data_diagrams (client_id, name, description, diagram_type, nodes, edges, rack_id) VALUES (?,?,?,?,?,?,?)')
.run(ws.rack.client_id, demo.landscape.name, 'Studio demo landschap', 'application_landscape', demo.landscape.nodes, demo.landscape.edges, ws.rack.id);
landscapeId = r.lastInsertRowid;
}
res.json({ success: true, flowId, landscapeId, redirect: '/racks/' + ws.rack.id + '/studio' });
});
router.get('/:id/devices.json', (req, res) => {
const db = getDb();
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
if (!rack) return res.status(404).json({ error: 'Rack not found' });
const devices = db.prepare('SELECT * FROM rack_devices WHERE rack_id = ? ORDER BY position_u ASC').all(req.params.id);
res.json({ rack, devices, nodes: rackDevicesToNodes(devices) });
});
router.get('/:id/report', (req, res) => {
const db = getDb();
const rack = db.prepare('SELECT r.*, c.name as client_name FROM racks r JOIN clients c ON c.id = r.client_id WHERE r.id = ?').get(req.params.id);
if (!rack) return res.redirect('/racks');
const devices = db.prepare('SELECT * FROM rack_devices WHERE rack_id = ? ORDER BY position_u ASC').all(req.params.id);
const diagrams = db.prepare(`
SELECT d.*, c.name as client_name FROM data_diagrams d
JOIN clients c ON c.id = d.client_id WHERE d.rack_id = ?
ORDER BY CASE d.diagram_type WHEN 'application_flow' THEN 0 WHEN 'application_landscape' THEN 1 ELSE 2 END, d.updated_at DESC
`).all(req.params.id);
const diagramSummaries = diagrams.map(d => ({
...d,
...summarizeDiagram(d),
typeLabel: DIAGRAM_TYPE_LABELS[d.diagram_type] || d.diagram_type
}));
const usedU = devices.reduce((s, d) => s + (d.height_u || 1), 0);
res.render('rack-infra-report', {
rack,
devices,
diagrams: diagramSummaries,
usedU,
typeLabels: DIAGRAM_TYPE_LABELS
});
});
module.exports = router;
+69
View File
@@ -0,0 +1,69 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
function initReportsTable() {
const db = getDb();
db.exec(`
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'))
)
`);
}
router.get('/', (req, res) => {
initReportsTable();
const db = getDb();
const tab = req.query.tab || 'builder';
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
let projects = [];
try { projects = db.prepare('SELECT id, name FROM projects ORDER BY name').all(); } catch (e) {}
const savedReports = db.prepare(`
SELECT r.*, c.name as client_name FROM reports r
LEFT JOIN clients c ON c.id = r.client_id ORDER BY r.created_at DESC LIMIT 20
`).all();
res.render('reports', { tab, clients, projects, savedReports });
});
router.post('/generate', async (req, res) => {
const { report_type, client_id, project_id, date_from, date_to, format } = req.body;
const db = getDb();
let data = [];
if (report_type === 'client_summary') {
data = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id) as engagement_count,
(SELECT COALESCE(SUM(i.total),0) FROM invoices i WHERE i.client_id = c.id) as total_revenue
FROM clients c WHERE (? = '' OR c.id = ?)
`).all(client_id || '', client_id || '');
} else if (report_type === 'financial') {
data = db.prepare(`
SELECT i.*, c.name as client_name FROM invoices i
LEFT JOIN clients c ON c.id = i.client_id
WHERE (? = '' OR i.created_at >= ?) AND (? = '' OR i.created_at <= ?)
`).all(date_from || '', date_from || '', date_to || '', date_to || '');
}
if (format === 'pdf' || format === 'excel') {
return res.json({ data, note: 'Export naar PDF/Excel — installeer pdfkit/exceljs of gebruik JSON' });
}
res.json({ data });
});
router.post('/save', (req, res) => {
initReportsTable();
const db = getDb();
const { name, report_type, client_id, project_id, config } = req.body;
db.prepare(`INSERT INTO reports (name, report_type, client_id, project_id, config) VALUES (?, ?, ?, ?, ?)`)
.run(name, report_type, client_id, project_id, JSON.stringify(config || {}));
res.redirect('/reports');
});
module.exports = router;
+35
View File
@@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
router.get('/setup', (req, res) => {
const secret = speakeasy.generateSecret({ name: 'Mek-Tech Consulting', length: 32 });
req.session.tfaSecret = secret.base32;
QRCode.toDataURL(secret.otpauth_url, (err, imageUrl) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Failed to generate QR code' });
}
res.json({ secret: secret.base32, qrCode: imageUrl });
});
});
router.post('/verify', (req, res) => {
const { token, secret } = req.body;
const verified = speakeasy.totp.verify({
secret: secret || req.session.tfaSecret,
encoding: 'base32',
token: token
});
if (verified) {
req.session.tfaVerified = true;
res.json({ success: true });
} else {
res.json({ success: false, error: 'Invalid token' });
}
});
module.exports = router;
+88
View File
@@ -0,0 +1,88 @@
const express = require('express');
const router = express.Router();
const {
PROVIDER_PRESETS, getProviders, getProvider, saveProvider, deleteProvider,
maskKey, chatCompletion, syncAiConfigFile
} = require('../lib/ai');
const { logAudit } = require('../db');
router.get('/', (req, res) => {
const providers = getProviders().map(p => ({
...p,
api_key_masked: maskKey(getProvider(p.id)?.api_key)
}));
res.render('ai-providers', {
providers,
presets: PROVIDER_PRESETS,
saved: req.query.saved === '1',
tested: req.query.tested,
error: req.query.error || null
});
});
router.post('/save', (req, res) => {
const { id, name, provider_type, api_key, base_url, default_model, is_active, is_default } = req.body;
const preset = PROVIDER_PRESETS[provider_type] || PROVIDER_PRESETS.custom;
let key = api_key;
if (id && (!key || key.includes('••••'))) {
const existing = getProvider(id);
key = existing?.api_key || '';
}
saveProvider({
id: id ? parseInt(id, 10) : null,
name: name || preset.label,
provider_type: provider_type || 'openrouter',
api_key: key,
base_url: base_url || preset.base_url,
default_model: default_model || preset.models[0] || '',
is_active: is_active === '1',
is_default: is_default === '1'
});
logAudit(req.session.userId, req.session.username, 'ai_provider_saved', 'ai_provider', id || null, name);
res.redirect('/ai?saved=1');
});
router.post('/delete/:id', (req, res) => {
deleteProvider(req.params.id);
logAudit(req.session.userId, req.session.username, 'ai_provider_deleted', 'ai_provider', req.params.id, '');
res.redirect('/ai?saved=1');
});
router.post('/sync', (req, res) => {
syncAiConfigFile();
res.redirect('/ai?saved=1');
});
router.post('/test/:id', async (req, res) => {
try {
const result = await chatCompletion(
[{ role: 'user', content: 'Antwoord met exact: Mek-Tech AI verbinding OK' }],
{ providerId: parseInt(req.params.id, 10), max_tokens: 50 }
);
const text = result.choices?.[0]?.message?.content || 'OK';
res.redirect('/ai?tested=' + encodeURIComponent(text.slice(0, 80)));
} catch (e) {
res.redirect('/ai?error=' + encodeURIComponent(e.message));
}
});
router.post('/chat', async (req, res) => {
try {
const { messages, provider_id, model, client_id } = req.body;
const result = await chatCompletion(messages, {
providerId: provider_id ? parseInt(provider_id, 10) : null,
model
});
const content = result.choices?.[0]?.message?.content || '';
if (client_id) {
const { getDb } = require('../db');
getDb().prepare('INSERT INTO client_notes (client_id, content, created_by) VALUES (?, ?, ?)')
.run(client_id, `[AI] ${content.slice(0, 2000)}`, req.session.username || 'ai');
}
res.json({ success: true, content, model: result.model });
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
module.exports = router;
+152
View File
@@ -0,0 +1,152 @@
const express = require('express');
const router = express.Router();
const { getDb, logAudit } = require('../db');
function apiAuth(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (apiKey === process.env.API_KEY) return next();
if (req.session && req.session.userId) return next();
return res.status(401).json({ error: 'Unauthorized' });
}
const LEADGEN_URL = process.env.LEADGEN_URL || 'http://python-leadgen:3003';
async function proxyLeadgen(path, body, method) {
const https = require('https');
const http = require('http');
const url = new URL(LEADGEN_URL + path);
const client = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : undefined;
const opts = {
method: method || 'POST',
headers: { 'Content-Type': 'application/json' },
};
if (data) opts.headers['Content-Length'] = Buffer.byteLength(data);
const req = client.request(url, opts, (res) => {
let buf = '';
res.on('data', c => buf += c);
res.on('end', () => { try { resolve(JSON.parse(buf)); } catch(e) { resolve({success:false,error:'Invalid JSON response'}); } });
});
req.on('error', (e) => resolve({success:false,error:'LeadGen service unavailable: '+e.message}));
if (data) req.write(data);
req.end();
});
}
router.post('/leadgen-analyze', async (req, res) => {
try {
const result = await proxyLeadgen('/python-leadgen/analyze', req.body);
res.json(result);
} catch (e) {
res.status(500).json({ success: false, error: 'LeadGen service unavailable: ' + e.message });
}
});
router.post('/leadgen-batch', async (req, res) => {
try {
const result = await proxyLeadgen('/python-leadgen/batch', req.body);
res.json(result);
} catch (e) {
res.status(500).json({ success: false, error: 'LeadGen service unavailable: ' + e.message });
}
});
router.get('/leadgen-config', async (req, res) => {
try {
const result = await proxyLeadgen('/python-leadgen/config', null, 'GET');
res.json(result);
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
router.post('/leadgen-config', async (req, res) => {
try {
const result = await proxyLeadgen('/python-leadgen/config', req.body, 'POST');
res.json(result);
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
router.get('/leadgen-models', async (req, res) => {
try {
const result = await proxyLeadgen('/python-leadgen/models', null, 'GET');
res.json(result);
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
router.use(apiAuth);
router.get('/clients', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT * FROM clients ORDER BY name').all();
res.json(clients);
});
router.get('/clients/:id', (req, res) => {
const db = getDb();
const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.id);
if (!client) return res.status(404).json({ error: 'Not found' });
res.json(client);
});
router.post('/clients', (req, res) => {
const db = getDb();
const { name, industry, website, contact_name, contact_email, contact_phone, status, source, notes } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const result = db.prepare('INSERT INTO clients (name, industry, website, contact_name, contact_email, contact_phone, status, source, notes) VALUES (?,?,?,?,?,?,?,?,?)')
.run(name, industry, website, contact_name, contact_email, contact_phone, status || 'lead', source, notes);
res.status(201).json({ id: result.lastInsertRowid });
});
router.put('/clients/:id', (req, res) => {
const db = getDb();
const { name, industry, website, contact_name, contact_email, contact_phone, status, source, notes } = req.body;
db.prepare("UPDATE clients SET name=?, industry=?, website=?, contact_name=?, contact_email=?, contact_phone=?, status=?, source=?, notes=?, updated_at=datetime('now') WHERE id=?")
.run(name, industry, website, contact_name, contact_email, contact_phone, status, source, notes, req.params.id);
res.json({ ok: true });
});
router.delete('/clients/:id', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM clients WHERE id = ?').run(req.params.id);
res.json({ ok: true });
});
router.get('/engagements', (req, res) => {
const db = getDb();
const engagements = db.prepare('SELECT e.*, c.name as client_name FROM engagements e JOIN clients c ON c.id = e.client_id ORDER BY e.created_at DESC').all();
res.json(engagements);
});
router.get('/assessments', (req, res) => {
const db = getDb();
const assessments = db.prepare('SELECT a.*, c.name as client_name FROM assessments a JOIN clients c ON c.id = a.client_id ORDER BY a.created_at DESC').all();
res.json(assessments);
});
router.get('/racks', (req, res) => {
const db = getDb();
const racks = db.prepare('SELECT r.*, c.name as client_name FROM racks r JOIN clients c ON c.id = r.client_id ORDER BY r.name').all();
res.json(racks);
});
router.get('/stats', (req, res) => {
const db = getDb();
res.json({
clients: db.prepare('SELECT COUNT(*) as cnt FROM clients').get().cnt,
engagements: db.prepare('SELECT COUNT(*) as cnt FROM engagements').get().cnt,
assessments: db.prepare('SELECT COUNT(*) as cnt FROM assessments').get().cnt,
tasks: db.prepare('SELECT COUNT(*) as cnt FROM tasks').get().cnt,
racks: db.prepare('SELECT COUNT(*) as cnt FROM racks').get().cnt,
diagrams: db.prepare('SELECT COUNT(*) as cnt FROM data_diagrams').get().cnt,
invoices: db.prepare('SELECT COUNT(*) as cnt FROM invoices').get().cnt,
timeEntries: db.prepare('SELECT COUNT(*) as cnt FROM time_entries').get().cnt
});
});
module.exports = router;
+143
View File
@@ -0,0 +1,143 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
const { parseDiagramJson, DIAGRAM_TYPE_LABELS } = require('../lib/rack-diagram');
router.get('/designer', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const clientId = req.query.client_id || req.session.activeClientId || null;
const host = process.env.BASE_URL || `http://${req.hostname}:3000`;
const designerBase = host.replace(':3000', ':3001') + '/python-editor/';
res.render('designer-embed', { clients, clientId, designerUrl: designerBase });
});
router.get('/', (req, res) => {
const db = getDb();
const { search, client_id, diagram_type } = req.query;
let sql = `SELECT d.*, c.name as client_name,
json_array_length(d.nodes) as node_count
FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE 1=1`;
const params = [];
if (search) {
sql += ` AND (d.name LIKE ? OR d.description LIKE ?)`;
params.push(`%${search}%`, `%${search}%`);
}
if (client_id) {
sql += ` AND d.client_id = ?`;
params.push(client_id);
}
if (diagram_type) {
sql += ` AND d.diagram_type = ?`;
params.push(diagram_type);
}
sql += ` ORDER BY d.updated_at DESC`;
const diagrams = db.prepare(sql).all(...params);
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('arch-list', { diagrams, clients, filters: req.query });
});
router.get('/new', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('arch-editor', { diagram: null, clients, clientId: null, rack: null, rackDevices: [], linkedDiagrams: [], defaultDiagramType: null, error: null });
});
router.get('/new/:clientId', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('arch-editor', { diagram: null, clients, clientId: parseInt(req.params.clientId), rack: null, rackDevices: [], linkedDiagrams: [], defaultDiagramType: null, error: null });
});
router.post('/save', (req, res) => {
const db = getDb();
const { id, client_id, name, description, diagram_type, nodes, edges, rack_id } = req.body;
if (!name || !client_id) return res.status(400).json({ error: 'Name and client required' });
if (id) {
db.prepare("UPDATE data_diagrams SET name=?, description=?, diagram_type=?, nodes=?, edges=?, rack_id=?, updated_at=datetime('now') WHERE id=?")
.run(name, description, diagram_type || 'data_architecture', nodes || '[]', edges || '[]', rack_id || null, id);
res.json({ id: parseInt(id, 10) });
} else {
const result = db.prepare('INSERT INTO data_diagrams (client_id, name, description, diagram_type, nodes, edges, rack_id) VALUES (?,?,?,?,?,?,?)')
.run(client_id, name, description, diagram_type || 'data_architecture', nodes || '[]', edges || '[]', rack_id || null);
res.json({ id: result.lastInsertRowid });
}
});
router.get('/:id', (req, res) => {
const db = getDb();
const diagram = db.prepare('SELECT d.*, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE d.id = ?').get(req.params.id);
if (!diagram) return res.redirect('/architecture');
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
let rack = null;
let rackDevices = [];
let linkedDiagrams = [];
if (diagram.rack_id) {
rack = db.prepare('SELECT r.*, c.name as client_name FROM racks r JOIN clients c ON c.id = r.client_id WHERE r.id = ?').get(diagram.rack_id);
rackDevices = db.prepare('SELECT * FROM rack_devices WHERE rack_id = ? ORDER BY position_u ASC').all(diagram.rack_id);
linkedDiagrams = db.prepare('SELECT id, name, diagram_type FROM data_diagrams WHERE rack_id = ?').all(diagram.rack_id);
}
res.render('arch-editor', { diagram, clients, clientId: diagram.client_id, rack, rackDevices, linkedDiagrams, defaultDiagramType: null, error: null });
});
router.post('/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM data_diagrams WHERE id = ?').run(req.params.id);
res.redirect('/architecture');
});
router.get('/report/:id', (req, res) => {
const db = getDb();
const diagram = db.prepare('SELECT d.*, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE d.id = ?').get(req.params.id);
if (!diagram) return res.redirect('/architecture');
if (diagram.rack_id) return res.redirect('/racks/' + diagram.rack_id + '/report');
let nodes = JSON.parse(diagram.nodes);
let edges = JSON.parse(diagram.edges);
if (!Array.isArray(nodes) && nodes && nodes.nodes) { edges = nodes.edges || []; nodes = nodes.nodes || []; }
if (!Array.isArray(edges) && edges && edges.nodes) { nodes = edges.nodes || []; edges = edges.edges || []; }
const typeCount = {};
const vendorCount = {};
nodes.forEach(n => {
typeCount[n.type ? n.type.split('-')[0] : 'other'] = (typeCount[n.type ? n.type.split('-')[0] : 'other'] || 0) + 1;
if (n.vendor) vendorCount[n.vendor] = (vendorCount[n.vendor] || 0) + 1;
});
res.render('arch-report', { diagram, nodes, edges, typeCount, vendorCount });
});
router.get('/render/:id', (req, res) => {
const db = getDb();
const diagram = db.prepare('SELECT * FROM data_diagrams WHERE id = ?').get(req.params.id);
if (!diagram) return res.status(404).json({ error: 'not found' });
let nodes = JSON.parse(diagram.nodes);
let edges = JSON.parse(diagram.edges);
// Handle legacy Python-editor format: nodes/edges columns store {"nodes":[...],"edges":[...]}
if (!Array.isArray(nodes) && nodes && nodes.nodes) {
edges = nodes.edges || [];
nodes = nodes.nodes || [];
}
if (!Array.isArray(edges) && edges && edges.nodes) {
nodes = edges.nodes || [];
edges = edges.edges || [];
}
res.json({
name: diagram.name,
description: diagram.description,
diagram_type: diagram.diagram_type,
nodes,
edges
});
});
module.exports = router;
+211
View File
@@ -0,0 +1,211 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
const questions = [
{
id: 'company',
title: 'Bedrijfsinformatie',
fields: [
{ name: 'company_name', label: 'Bedrijfsnaam', type: 'text', required: true },
{ name: 'industry', label: 'Sector/Industrie', type: 'text', required: true },
{ name: 'current_situation', label: 'Huidige situatie (uitdagingen, doelen)', type: 'textarea', required: true }
]
},
{
id: 'infrastructure',
title: 'Huidige Infrastructuur',
fields: [
{ name: 'servers', label: 'Aantal servers (fysiek/virtueel)', type: 'text' },
{ name: 'storage', label: 'Huidige opslag (TB, type: NAS/SAN/object)', type: 'text' },
{ name: 'network', label: 'Netwerk infrastructuur (switches, bandbreedte)', type: 'textarea' },
{ name: 'virtualization', label: 'Virtualisatie platform (VMware, Hyper-V, Proxmox)', type: 'text' }
]
},
{
id: 'data',
title: 'Data & Analytics',
fields: [
{ name: 'data_volume', label: 'Huidige data volume (TB/maand groei)', type: 'text' },
{ name: 'data_types', label: 'Data types (gestructureerd, ongestructureerd, streaming)', type: 'textarea' },
{ name: 'analytics_tools', label: 'Huidige analytics tools (PowerBI, Tableau, custom)', type: 'text' },
{ name: 'data_governance', label: 'Data governance / compliance eisen (AVG, HIPAA, etc)', type: 'textarea' }
]
},
{
id: 'ai',
title: 'AI & Machine Learning',
fields: [
{ name: 'ai_current', label: 'Huidige AI/ML initiatieven', type: 'textarea' },
{ name: 'ai_goals', label: 'AI doelen (predictie, NLP, computer vision, generatief)', type: 'textarea' },
{ name: 'ai_models', label: 'Gewenste modellen (LLMs, custom modellen, open-source)', type: 'text' },
{ name: 'ai_skills', label: 'Beschikbare AI/Data skills in team', type: 'text' }
]
},
{
id: 'requirements',
title: 'Technische Eisen',
fields: [
{ name: 'performance', label: 'Prestatie eisen (latency, throughput)', type: 'text' },
{ name: 'availability', label: 'Beschikbaarheid (SLA, 99.9%/99.99%)', type: 'text' },
{ name: 'security', label: 'Security eisen (encryptie, IAM, netwerk segmentatie)', type: 'textarea' },
{ name: 'budget', label: 'Budget indicatie (CAPEX/OPEX)', type: 'text' },
{ name: 'timeline', label: 'Gewenste implementatie timeline', type: 'text' }
]
}
];
router.get('/', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('assess', { questions, clients, clientId: null, engagementId: null });
});
router.get('/:clientId', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.clientId);
if (!client) return res.redirect('/assess');
res.render('assess', { questions, clients, clientId: parseInt(req.params.clientId), engagementId: null });
});
router.get('/:clientId/:engagementId', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('assess', {
questions, clients,
clientId: parseInt(req.params.clientId),
engagementId: parseInt(req.params.engagementId)
});
});
router.post('/report', async (req, res) => {
const db = getDb();
const answers = req.body;
const clientId = req.body._client_id || null;
const engagementId = req.body._engagement_id || null;
const report = generateReport(answers);
if (clientId) {
const reportJson = JSON.stringify(report);
const answersJson = JSON.stringify(answers);
db.prepare('INSERT INTO assessments (client_id, engagement_id, answers, report) VALUES (?, ?, ?, ?)')
.run(clientId, engagementId || null, answersJson, reportJson);
const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(clientId);
if (client && client.status === 'lead') {
db.prepare("UPDATE clients SET status = 'active', updated_at = datetime('now') WHERE id = ?").run(clientId);
}
}
res.render('report', { report, answers });
});
function generateReport(data) {
const storageTypes = (data.storage || '').toLowerCase();
const hasDatalake = storageTypes.includes('object') || storageTypes.includes('s3');
const hasHighVolume = parseInt(data.data_volume) > 10 || false;
let datalakeTier = 'Standard';
let aiTier = 'Starter';
if (hasHighVolume) datalakeTier = 'Enterprise';
if (hasDatalake) datalakeTier = 'Advanced';
const aiGoals = (data.ai_goals || '').toLowerCase();
if (aiGoals.includes('generatief') || aiGoals.includes('llm')) aiTier = 'Enterprise';
else if (aiGoals.includes('nlp') || aiGoals.includes('predictie')) aiTier = 'Advanced';
const architecture = {
datalake: {
tier: datalakeTier,
storage: datalakeTier === 'Enterprise' ? 'MinIO/Object Store (multi-region)' : 'MinIO/S3-compatible storage',
compute: datalakeTier === 'Enterprise' ? 'Kubernetes cluster (GPU nodes)' : 'Docker Swarm / Kubernetes',
catalog: 'Apache Iceberg + Hive Metastore',
processing: hasHighVolume ? 'Apache Spark structured streaming' : 'Apache Spark batch processing',
governance: 'Apache Atlas + Ranger (GDPR/HIPAA compliant)'
},
ai: {
tier: aiTier,
serving: aiTier === 'Enterprise' ? 'vLLM + SGLang (multi-GPU)' : 'Ollama / vLLM',
orchestration: aiTier === 'Enterprise' ? 'Kubeflow + MLflow' : 'MLflow',
monitoring: 'Prometheus + Grafana + Evidently AI',
models: aiTier === 'Enterprise' ? 'DeepSeek, Llama, Mixtral (8x7B+)' : 'DeepSeek-Coder, Phi-3, Qwen'
},
infrastructure: {
virtualization: data.virtualization || 'Proxmox VE / VMware vSphere',
network: 'Leaf-Spine architecture (100GbE)',
security: 'Zero Trust + microsegmentatie + HSM',
monitoring: 'Datadog / Grafana + Loki + Tempo'
}
};
const phases = [
{
name: 'Fase 1: Quick Win (0-3 maanden)',
items: [
'Inventarisatie & assessment huidige infrastructuur',
'Proof of Concept: Data lake op MinIO + Spark',
'AI readiness check en model selectie',
'Security baseline implementatie'
]
},
{
name: 'Fase 2: Foundation (3-6 maanden)',
items: [
'Lakehouse architectuur implementatie (Iceberg)',
'Data governance framework roll-out',
'Kubernetes cluster opzetten (Rancher / kubeadm)',
'CI/CD pipeline voor ML modellen (MLflow)'
]
},
{
name: 'Fase 3: AI Ops (6-12 maanden)',
items: [
'vLLM/SGLang serving voor LLMs',
'Automated model retraining & monitoring',
'Self-service data platform voor analytics',
'Cost optimization & FinOps implementatie'
]
}
];
return { architecture, phases };
}
router.get('/report/:id/print', (req, res) => {
const db = getDb();
const assessment = db.prepare(`
SELECT a.*, c.name as company_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('/');
const answers = JSON.parse(assessment.answers);
const report = JSON.parse(assessment.report);
if (!answers.company_name && assessment.company_name) {
answers.company_name = assessment.company_name;
}
res.render('report-print', { report, answers });
});
router.get('/report/:id', (req, res) => {
const db = getDb();
const assessment = db.prepare(`
SELECT a.*, c.name as company_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('/');
const answers = JSON.parse(assessment.answers);
const report = JSON.parse(assessment.report);
if (!answers.company_name && assessment.company_name) {
answers.company_name = assessment.company_name;
}
res.render('report', { report, answers });
});
module.exports = router;
module.exports.questions = questions;
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
router.get('/', (req, res) => {
const db = getDb();
const page = parseInt(req.query.page) || 1;
const limit = 50;
const offset = (page - 1) * limit;
const total = db.prepare('SELECT COUNT(*) as cnt FROM audit_log').get().cnt;
const entries = db.prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset);
res.render('audit-list', { entries, page, total, pages: Math.ceil(total / limit) });
});
module.exports = router;
+27
View File
@@ -0,0 +1,27 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const { getDb } = require('../db');
router.get('/login', (req, res) => {
if (req.session.userId) return res.redirect('/');
res.render('login', { error: null, reset: req.query.reset === '1' });
});
router.post('/auth/login', (req, res) => {
const { username, password } = req.body;
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.render('login', { error: 'Ongeldige gebruikersnaam of wachtwoord', reset: false });
}
req.session.userId = user.id;
req.session.username = user.username;
res.redirect('/');
});
router.get('/auth/logout', (req, res) => {
req.session.destroy(() => res.redirect('/login'));
});
module.exports = router;
+38
View File
@@ -0,0 +1,38 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
router.get('/', (req, res) => {
const db = getDb();
const automations = db.prepare('SELECT * FROM automations ORDER BY created_at DESC').all();
const templates = [
{ id: 'client_welcome', name: 'Client Welcome', description: 'Send welcome email when new client is created', icon: 'mail' },
{ id: 'task_reminder', name: 'Task Reminder', description: 'Send reminder when task is overdue', icon: 'bell' },
{ id: 'invoice_followup', name: 'Invoice Follow-up', description: 'Send follow-up when invoice is overdue', icon: 'dollar-sign' },
{ id: 'project_complete', name: 'Project Complete', description: 'Notify when project is completed', icon: 'check-circle' }
];
res.render('automations', { automations, templates });
});
router.post('/create', (req, res) => {
const db = getDb();
const { name, trigger_type, actions } = req.body;
db.prepare('INSERT INTO automations (name, trigger_type, actions, status, created_at) VALUES (?, ?, ?, "active", CURRENT_TIMESTAMP)').run(name, trigger_type, JSON.stringify(actions));
res.redirect('/automations');
});
router.post('/:id/toggle', (req, res) => {
const db = getDb();
const automation = db.prepare('SELECT status FROM automations WHERE id = ?').get(req.params.id);
const newStatus = automation.status === 'active' ? 'paused' : 'active';
db.prepare('UPDATE automations SET status = ? WHERE id = ?').run(newStatus, req.params.id);
res.json({ success: true, status: newStatus });
});
module.exports = router;
+126
View File
@@ -0,0 +1,126 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { getDb, logAudit } = require('../db');
const {
collectStats,
formatBytes,
createBackup,
listBackups,
getBackupPath,
deleteBackup,
restoreBackup,
getPaths
} = require('../lib/backup');
function requireAdmin(req, res, next) {
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.userId);
if (!user || user.role !== 'admin') return res.status(403).render('404', { path: req.path });
next();
}
const uploadDir = path.join(getPaths().dataDir, 'backups', 'uploads-tmp');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const upload = multer({
dest: uploadDir,
limits: { fileSize: 500 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.originalname.endsWith('.tar.gz') || file.originalname.endsWith('.tgz') || file.mimetype === 'application/gzip') {
cb(null, true);
} else {
cb(new Error('Alleen .tar.gz backupbestanden'));
}
}
});
router.get('/', requireAdmin, (req, res) => {
const stats = collectStats();
const backups = listBackups();
const disk = getPaths();
let diskFree = null;
try {
const { execSync } = require('child_process');
const out = execSync(`df -B1 "${disk.dataDir}" 2>/dev/null | tail -1`, { encoding: 'utf8' });
const parts = out.trim().split(/\s+/);
if (parts.length >= 4) diskFree = formatBytes(parseInt(parts[3], 10));
} catch (e) { /* ignore */ }
res.render('backup', {
stats,
backups,
diskFree,
formatBytes,
restored: req.query.restored === '1',
created: req.query.created === '1',
deleted: req.query.deleted === '1',
error: req.query.error || null,
user: { username: req.session.username }
});
});
router.post('/create', requireAdmin, (req, res) => {
try {
const backup = createBackup();
logAudit(req.session.userId, req.session.username, 'backup_create', 'system', 0, backup.filename);
if (req.query.download === '1') {
return res.download(backup.path, backup.filename);
}
res.redirect('/backup?created=1');
} catch (e) {
console.error('Backup create error:', e);
res.redirect('/backup?error=' + encodeURIComponent('Backup mislukt: ' + e.message));
}
});
router.get('/download/:filename', requireAdmin, (req, res) => {
const p = getBackupPath(req.params.filename);
if (!p) return res.redirect('/backup?error=' + encodeURIComponent('Backup niet gevonden'));
logAudit(req.session.userId, req.session.username, 'backup_download', 'system', 0, req.params.filename);
res.download(p, req.params.filename);
});
router.post('/delete/:filename', requireAdmin, (req, res) => {
try {
if (!deleteBackup(req.params.filename)) {
return res.redirect('/backup?error=' + encodeURIComponent('Backup niet gevonden'));
}
logAudit(req.session.userId, req.session.username, 'backup_delete', 'system', 0, req.params.filename);
res.redirect('/backup?deleted=1');
} catch (e) {
res.redirect('/backup?error=' + encodeURIComponent(e.message));
}
});
router.post('/restore', requireAdmin, upload.single('backup_file'), (req, res) => {
if (!req.file) {
return res.redirect('/backup?error=' + encodeURIComponent('Geen bestand geüpload'));
}
try {
const result = restoreBackup(req.file.path);
fs.unlinkSync(req.file.path);
logAudit(req.session.userId, req.session.username, 'backup_restore', 'system', 0, JSON.stringify(result));
res.redirect('/backup?restored=1');
} catch (e) {
console.error('Restore error:', e);
try { fs.unlinkSync(req.file.path); } catch (err) { /* ignore */ }
res.redirect('/backup?error=' + encodeURIComponent('Herstel mislukt: ' + e.message));
}
});
router.post('/restore/:filename', requireAdmin, (req, res) => {
const p = getBackupPath(req.params.filename);
if (!p) return res.redirect('/backup?error=' + encodeURIComponent('Backup niet gevonden'));
try {
const result = restoreBackup(p);
logAudit(req.session.userId, req.session.username, 'backup_restore', 'system', 0, req.params.filename);
res.redirect('/backup?restored=1');
} catch (e) {
res.redirect('/backup?error=' + encodeURIComponent('Herstel mislukt: ' + e.message));
}
});
module.exports = router;
+64
View File
@@ -0,0 +1,64 @@
const express = require('express');
const router = express.Router();
const { getDb, logAudit } = require('../db');
router.get('/', (req, res) => {
const db = getDb();
const events = db.prepare(`
SELECT e.*, c.name as client_name
FROM calendar_events e
LEFT JOIN clients c ON c.id = e.client_id
WHERE e.user_id = ? OR e.user_id IS NULL
ORDER BY e.start_time ASC
`).all(req.session.userId);
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const engagements = db.prepare('SELECT id, title FROM engagements ORDER BY title').all();
res.render('calendar-list', { events, clients, engagements });
});
router.get('/api/events', (req, res) => {
const db = getDb();
const events = db.prepare(`
SELECT e.*, c.name as client_name
FROM calendar_events e
LEFT JOIN clients c ON c.id = e.client_id
WHERE (e.user_id = ? OR e.user_id IS NULL)
ORDER BY e.start_time ASC
`).all(req.session.userId);
res.json(events.map(e => ({
id: e.id,
title: e.title,
start: e.start_time,
end: e.end_time,
allDay: !!e.all_day,
color: e.color,
extendedProps: { description: e.description, client_name: e.client_name, event_type: e.event_type }
})));
});
router.post('/new', (req, res) => {
const db = getDb();
const { title, description, client_id, engagement_id, event_type, start_time, end_time, all_day, color } = req.body;
if (!title || !start_time) return res.redirect('/calendar');
db.prepare('INSERT INTO calendar_events (user_id, client_id, engagement_id, title, description, event_type, start_time, end_time, all_day, color) VALUES (?,?,?,?,?,?,?,?,?,?)')
.run(req.session.userId, client_id || null, engagement_id || null, title, description || '', event_type || 'meeting', start_time, end_time || start_time, all_day === '1' ? 1 : 0, color || '#58a6ff');
logAudit(req.session.userId, req.session.username, 'event_created', 'calendar_event', null, `Event "${title}" aangemaakt`);
res.redirect('/calendar');
});
router.post('/:id/edit', (req, res) => {
const db = getDb();
const { title, description, client_id, engagement_id, event_type, start_time, end_time, all_day, color } = req.body;
if (!title) return res.redirect('/calendar');
db.prepare("UPDATE calendar_events SET title=?, description=?, client_id=?, engagement_id=?, event_type=?, start_time=?, end_time=?, all_day=?, color=?, updated_at=datetime('now') WHERE id=?")
.run(title, description, client_id || null, engagement_id || null, event_type, start_time, end_time, all_day === '1' ? 1 : 0, color, req.params.id);
res.redirect('/calendar');
});
router.post('/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM calendar_events WHERE id = ?').run(req.params.id);
res.redirect('/calendar');
});
module.exports = router;
+168
View File
@@ -0,0 +1,168 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { getDb, logAudit } = require('../db');
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => cb(null, Date.now() + '-' + Math.round(Math.random() * 1e9) + path.extname(file.originalname))
});
const upload = multer({ storage, limits: { fileSize: 100 * 1024 * 1024 } });
router.get('/', (req, res) => {
const db = getDb();
const { status, search } = req.query;
let sql = `SELECT c.*,
(SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id) as engagement_count,
(SELECT MAX(a.created_at) FROM assessments a WHERE a.client_id = c.id) as last_assessment
FROM clients c WHERE 1=1`;
const params = [];
if (status) { sql += ' AND c.status = ?'; params.push(status); }
if (search) { sql += ' AND (c.name LIKE ? OR c.contact_name LIKE ? OR c.industry LIKE ?)'; params.push(`%${search}%`, `%${search}%`, `%${search}%`); }
sql += ' ORDER BY c.updated_at DESC';
const clients = db.prepare(sql).all(...params);
res.render('client-list', { clients, filters: req.query });
});
router.get('/new', (req, res) => {
res.render('client-form', { client: null, error: null });
});
router.post('/new', (req, res) => {
const db = getDb();
const { name, industry, website, contact_name, contact_email, contact_phone, status, source, notes } = req.body;
if (!name || !name.trim()) {
return res.render('client-form', { client: null, error: 'Bedrijfsnaam is verplicht' });
}
const result = db.prepare(`INSERT INTO clients (name, industry, website, contact_name, contact_email, contact_phone, status, source, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(name, industry, website, contact_name, contact_email, contact_phone, status || 'lead', source, notes);
logAudit(req.session.userId, req.session.username, 'client_created', 'client', result.lastInsertRowid, `Cliënt ${name} aangemaakt`);
res.redirect('/clients');
});
router.get('/:id', (req, res) => {
const db = getDb();
const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.id);
if (!client) return res.redirect('/clients');
const engagements = db.prepare('SELECT * FROM engagements WHERE client_id = ? ORDER BY created_at DESC').all(req.params.id);
const assessments = db.prepare('SELECT * FROM assessments WHERE client_id = ? ORDER BY created_at DESC').all(req.params.id);
const advice = db.prepare('SELECT * FROM advice_log WHERE client_id = ? AND dismissed = 0 ORDER BY created_at DESC').all(req.params.id);
const diagrams = db.prepare('SELECT * FROM data_diagrams WHERE client_id = ? ORDER BY updated_at DESC').all(req.params.id);
const notes = db.prepare('SELECT * FROM client_notes WHERE client_id = ? ORDER BY created_at DESC').all(req.params.id);
const files = db.prepare('SELECT * FROM client_files WHERE client_id = ? ORDER BY created_at DESC').all(req.params.id);
res.render('client-detail', { client, engagements, assessments, advice, diagrams, notes, files });
});
router.get('/:id/edit', (req, res) => {
const db = getDb();
const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.id);
if (!client) return res.redirect('/clients');
res.render('client-form', { client, error: null });
});
router.post('/:id/edit', (req, res) => {
const db = getDb();
const { name, industry, website, contact_name, contact_email, contact_phone, status, source, notes } = req.body;
if (!name || !name.trim()) {
const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.id);
return res.render('client-form', { client, error: 'Bedrijfsnaam is verplicht' });
}
db.prepare(`UPDATE clients SET name=?, industry=?, website=?, contact_name=?, contact_email=?, contact_phone=?, status=?, source=?, notes=?, updated_at=datetime('now') WHERE id=?`)
.run(name, industry, website, contact_name, contact_email, contact_phone, status, source, notes, req.params.id);
logAudit(req.session.userId, req.session.username, 'client_updated', 'client', req.params.id, `Cliënt ${name} bijgewerkt`);
res.redirect('/clients/' + req.params.id);
});
router.post('/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM clients WHERE id = ?').run(req.params.id);
res.redirect('/clients');
});
router.post('/:id/advice/:adviceId/dismiss', (req, res) => {
const db = getDb();
db.prepare("UPDATE advice_log SET dismissed = 1 WHERE id = ? AND client_id = ?").run(req.params.adviceId, req.params.id);
res.redirect('/clients/' + req.params.id);
});
router.post('/:id/notes', (req, res) => {
const db = getDb();
const { content } = req.body;
if (content && content.trim()) {
db.prepare('INSERT INTO client_notes (client_id, content, created_by) VALUES (?, ?, ?)')
.run(req.params.id, content.trim(), req.session.username || '');
}
res.redirect('/clients/' + req.params.id + '#notes');
});
router.post('/:id/notes/:noteId/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM client_notes WHERE id = ? AND client_id = ?').run(req.params.noteId, req.params.id);
res.redirect('/clients/' + req.params.id + '#notes');
});
router.post('/:id/upload', upload.single('file'), (req, res) => {
if (!req.file) return res.redirect('/clients/' + req.params.id + '#files');
const db = getDb();
db.prepare('INSERT INTO client_files (client_id, original_name, stored_name, mime_type, file_size) VALUES (?, ?, ?, ?, ?)')
.run(req.params.id, req.file.originalname, req.file.filename, req.file.mimetype, req.file.size);
res.redirect('/clients/' + req.params.id + '#files');
});
router.get('/:id/files/:fileId/view', (req, res) => {
const db = getDb();
const file = db.prepare('SELECT * FROM client_files WHERE id = ? AND client_id = ?').get(req.params.fileId, req.params.id);
if (!file) return res.status(404).send('Bestand niet gevonden');
const filePath = path.join(UPLOAD_DIR, file.stored_name);
if (!fs.existsSync(filePath)) return res.status(404).send('Bestand niet gevonden op schijf');
if (file.mime_type === 'application/pdf') {
res.render('pdf-viewer', { file, clientId: req.params.id });
} else {
res.download(filePath, file.original_name);
}
});
router.get('/:id/files/:fileId/download', (req, res) => {
const db = getDb();
const file = db.prepare('SELECT * FROM client_files WHERE id = ? AND client_id = ?').get(req.params.fileId, req.params.id);
if (!file) return res.status(404).send('Bestand niet gevonden');
const filePath = path.join(UPLOAD_DIR, file.stored_name);
if (!fs.existsSync(filePath)) return res.status(404).send('Bestand niet gevonden op schijf');
res.download(filePath, file.original_name);
});
router.post('/:id/files/:fileId/delete', (req, res) => {
const db = getDb();
const file = db.prepare('SELECT * FROM client_files WHERE id = ? AND client_id = ?').get(req.params.fileId, req.params.id);
if (file) {
const filePath = path.join(UPLOAD_DIR, file.stored_name);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
db.prepare('DELETE FROM client_files WHERE id = ?').run(req.params.fileId);
}
res.redirect('/clients/' + req.params.id + '#files');
});
router.post('/:id/email/send', upload.array('attachments', 5), async (req, res) => {
const db = getDb();
const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.id);
if (!client) return res.status(404).json({ error: 'Client not found' });
const { to, cc, bcc, subject, text } = req.body;
if (!to) return res.status(400).json({ error: 'Ontvanger is verplicht' });
const emailService = require('../email');
const account = db.prepare('SELECT * FROM email_accounts WHERE user_id = ? AND active = 1 LIMIT 1').get(req.session.userId);
if (!account) return res.status(400).json({ error: 'Geen email account geconfigureerd. Ga naar /email/settings' });
try {
await emailService.sendEmail(account.id, to, subject || `Mek-Tech — ${client.name}`, text || '', '', cc, bcc);
logAudit(req.session.userId, req.session.username, 'email_sent_to_client', 'client', client.id, `Email naar ${to}: ${subject}`);
res.redirect('/clients/' + req.params.id + '?email=sent');
} catch (e) {
res.redirect('/clients/' + req.params.id + '?email=error&msg=' + encodeURIComponent(e.message));
}
});
module.exports = router;
+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;
+302
View File
@@ -0,0 +1,302 @@
<!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-v2.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;transition:all .3s ease}
.main-area{flex:1;margin-left:var(--sb-w);padding:1.5rem 2rem 3rem;max-width:1300px}
.sidebar .sb-brand{padding:1rem 1.1rem;border-bottom:1px solid var(--border);background:linear-gradient(135deg,transparent,rgba(88,166,255,.03))}
.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}
.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(88,166,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(160px,1fr));gap:.65rem;margin-bottom:1.25rem}
.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(88,166,255,.15);border-color:rgba(88,166,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:768px){.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}
.card:hover{border-color:rgba(88,166,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}
/* Client grid */
.client-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:.7rem}
.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(88,166,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{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(88,166,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}
.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(88,166,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(190px,1fr));gap:.5rem}
.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(88,166,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(180px,1fr));gap:.5rem}
.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(88,166,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(--accent);color:var(--accent);background:rgba(88,166,255,.04);transform:translateY(-2px);box-shadow:0 4px 12px rgba(88,166,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:.5rem;margin-bottom:.85rem;flex-wrap:wrap}.time-tools .tt-btn{display:flex;align-items:center;gap:.35rem;padding:.42rem .75rem;border-radius:8px;font-size:.72rem;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(88,166,255,.06)}.time-tools .tt-btn svg{width:14px;height:14px}
.nav-link.logout{color:var(--red)}.nav-link.logout:hover{background:var(--red-bg);color:var(--red)}
.nav-link:hover{color:var(--text);background:var(--border-light)}
.nav-link.active{background:var(--border-light);font-weight:600}
</style></head><body>
<nav class="navbar"><div class="nav-inner" style="gap:.5rem">
<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 active">Dashboard</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="/projects" class="nav-link">Projecten</a><a href="/market" class="nav-link">Markt</a><a href="/tasks" class="nav-link">Taken</a><a href="/reports" class="nav-link">Reports</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>
<button class="theme-toggle" id="themeToggle" title="Toggle light/dark mode" onclick="toggleTheme()"></button>
</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">Kern</div>
<button class="sb-item active" data-tab="overview"><span class="sb-icon"><i data-lucide="layout-dashboard"></i></span>Overzicht</button>
<button class="sb-item" data-tab="clients"><span class="sb-icon"><i data-lucide="users"></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"></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"></i></span>Financiën</button>
<div class="sb-sep">Operaties</div>
<button class="sb-item" data-tab="infra"><span class="sb-icon"><i data-lucide="server"></i></span>Infrastructuur</button>
<button class="sb-item" data-tab="tasks"><span class="sb-icon"><i data-lucide="check-circle"></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"></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="market"><span class="sb-icon"><i data-lucide="target"></i></span>Markt Intel</button>
<button class="sb-item" data-tab="calendar"><span class="sb-icon"><i data-lucide="calendar"></i></span>Agenda</button>
<button class="sb-item" data-tab="data"><span class="sb-icon"><i data-lucide="shield-check"></i></span>Data Quality</button>
<div class="sb-sep">Tools</div>
<button class="sb-item" data-tab="email"><span class="sb-icon"><i data-lucide="mail"></i></span>Email</button>
<button class="sb-item" data-tab="activity"><span class="sb-icon"><i data-lucide="clock"></i></span>Activiteit</button>
</nav></aside>
<div class="main-area">
<!-- ===== OVERVIEW ===== -->
<div class="tab-panel active" id="panel-overview">
<div class="page-hdr fade-in-up"><h1><span class="gradient-text">Enterprise Dashboard</span></h1><p>Real-time 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"><%= 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"><%= 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"><%= 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"><%= overdueTasks %></div><div class="kpi-label">Overdue Taken</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"><%= 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">Cliënten Status</div>
<% var sts={active:'Actief',lead:'Lead',paused:'Gepauzeerd',archived:'Gearchiveerd'};var clrs={active:'var(--green)',lead:'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">Recente 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"><span class="qa-icon"><i data-lucide="user-plus"></i></span>Cliënt</a>
<a href="/engagements/new" class="qa-btn"><span class="qa-icon"><i data-lucide="briefcase"></i></span>Engagement</a>
<a href="/time" class="qa-btn"><span class="qa-icon"><i data-lucide="clock"></i></span>Uren</a>
<a href="/invoices/new" class="qa-btn"><span class="qa-icon"><i data-lucide="receipt"></i></span>Factuur</a>
<a href="/quality" class="qa-btn"><span class="qa-icon"><i data-lucide="shield-check"></i></span>Data</a>
<a href="/assess" class="qa-btn"><span class="qa-icon"><i data-lucide="clipboard-list"></i></span>Assess</a>
</div></div></div></div>
<!-- ===== CLIENTS ===== -->
<div class="tab-panel" id="panel-clients"><div class="page-hdr fade-in-up"><h1>Cliënten</h1><p><%= stats.total %> totaal · <%= stats.active %> actief</p></div>
<% if (clients.length === 0) { %><div class="empty-state-s">Nog geen cliënten</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">Sector: <%= c.industry %></p><% } %><p class="client-meta"><%= c.engagement_count %> engagement(s) · <%= c.active_engagements %> actief</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">Nog 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 %> totaal · <%= stats.activeEngagements %> actief</p></div>
<div class="grid-2" style="margin-bottom:.85rem">
<div class="card card-hover"><div class="card-title">Per Type</div><div style="height:200px"><canvas id="engagementTypeChart"></canvas></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">Per Status</div><div style="height:200px"><canvas id="engagementStatusChart"></canvas></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>Financiën</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">Totaal uren</div><div class="kpi-sub"><%= billableHours.toFixed(1) %> facturabel</div></div><div class="kpi-card green fade-in-up"><div class="kpi-num">€<%= totalInvoiced.toFixed(0) %></div><div class="kpi-label">Gefactureerd</div><div class="kpi-sub">€<%= totalPaid.toFixed(0) %> betaald</div></div><div class="kpi-card orange fade-in-up"><div class="kpi-num">€<%= totalOutstanding.toFixed(0) %></div><div class="kpi-label">Openstaand</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></div>
<div class="grid-2"><div class="card card-hover"><div class="card-title">Revenue Overview</div><div style="height:200px"><canvas id="revenueChart"></canvas></div>
<% if(timeEntries.length===0){ %><div class="empty-state-s">Nog geen uren</div><% }else{ %><table class="data-table"><thead><tr><th>Datum</th><th>Uren</th><th>Cliënt</th><th>Omschrijving</th></tr></thead><tbody><% timeEntries.forEach(function(t){ %><tr><td><%= t.date %></td><td><strong><%= t.hours %></strong></td><td><%= t.client_name||'-' %></td><td style="color:var(--text-muted);font-size:.7rem"><%= 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>Nr</th><th>Cliënt</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.total||0).toFixed(2) %></td><td><span class="status-badge <%= i.status %>"><%= i.status %></span></td></tr><% }) %></tbody></table><% } %></div></div></div>
<!-- ===== INFRA ===== -->
<div class="tab-panel" id="panel-infra"><div class="page-hdr fade-in-up"><h1>Infrastructuur</h1><p>Racks, netwerk & architectuur</p></div>
<div class="kpi-row"><div class="kpi-card cyan"><div class="kpi-num"><%= totalRacks %></div><div class="kpi-label">Racks</div><div class="kpi-sub"><%= totalRackDevices %> devices</div></div><div class="kpi-card purple"><div class="kpi-num"><%= totalNetworkDevices %></div><div class="kpi-label">Netwerk</div></div><div class="kpi-card green"><div class="kpi-num"><%= totalDiagrams %></div><div class="kpi-label">Diagrammen</div></div></div>
<div class="grid-2"><div class="card card-hover"><div class="card-title">Server Racks</div>
<% if(racks.length===0){ %><div class="empty-state-s">Nog geen racks</div><% }else{ %><% 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 class="card card-hover"><div class="card-title">Netwerk Devices</div>
<% if(netDevices.length===0){ %><div class="empty-state-s">Nog geen devices</div><% }else{ %><% 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 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>
<!-- ===== TASKS ===== -->
<div class="tab-panel" id="panel-tasks"><div class="page-hdr fade-in-up"><h1>Taken</h1><p><%= completedTasks %>/<%= totalTasks %> voltooid <% if(overdueTasks>0){ %><span style="color:var(--red);font-weight:600"><%= overdueTasks %> over tijd</span><% } %></p></div>
<div class="kpi-row"><div class="grid-2"><div class="card card-hover"><div class="card-title">Tasks Overview</div><div style="height:200px"><canvas id="taskChart"></canvas></div></div><div class="card card-hover"><div class="card-title">Clients by Industry</div><div style="height:200px"><canvas id="industryChart"></canvas></div></div></div><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>Advies & Acties</h1><p>Automatisch advies op basis van cliënt data</p></div>
<% if(activeAdvice.length===0){ %><div class="empty-state-s">Geen advies items</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></div><% }) %></div><% } %></div>
<!-- ===== MARKET ===== -->
<div class="tab-panel" id="panel-market"><div class="page-hdr fade-in-up"><h1>Markt Intelligence</h1><p>Datacenters & prospects — vind je volgende klant</p></div>
<div class="quick-actions"><a href="/market" class="qa-btn"><span class="qa-icon"><i data-lucide="target"></i></span>Markt verkenner openen</a></div></div>
<!-- ===== CALENDAR ===== -->
<div class="tab-panel" id="panel-calendar"><div class="page-hdr fade-in-up"><h1>Agenda</h1><p>Planning en afspraken</p></div>
<div class="quick-actions"><a href="/calendar" class="qa-btn"><span class="qa-icon"><i data-lucide="calendar"></i></span>Agenda openen</a></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 & dbt modellen</p></div>
<div class="quick-actions"><a href="/quality" class="qa-btn"><span class="qa-icon"><i data-lucide="shield-check"></i></span>Data Quality Engine</a></div></div>
<!-- ===== EMAIL ===== -->
<div class="tab-panel" id="panel-email"><div class="page-hdr fade-in-up"><h1>Email</h1><p>IMAP inbox & versturen</p></div>
<div class="quick-actions"><a href="/email" class="qa-btn"><span class="qa-icon"><i data-lucide="inbox"></i></span>Inbox</a><a href="/email/compose" class="qa-btn"><span class="qa-icon"><i data-lucide="send"></i></span>Opstellen</a></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">Recente Assessments</div>
<% if(recentAssessments.length===0){ %><div class="empty-state-s">Nog geen 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>Meldingen</a><a href="/users" class="qa-btn"><span class="qa-icon"><i data-lucide="users"></i></span>Gebruikers</a></div></div>
</div></div>
<script src="/js/dashboard-charts.js"></script><script>
function initTheme(){var m=localStorage.getItem('theme');if(m==='light'){document.body.classList.add('light-mode');document.getElementById('themeToggle').classList.add('light')}}
function toggleTheme(){var b=document.body;var t=document.getElementById('themeToggle');b.classList.toggle('light-mode');t.classList.toggle('light');localStorage.setItem('theme',b.classList.contains('light-mode')?'light':'dark')}
initTheme();
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');
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')}
lucide.createIcons();
</script>
</body></html>
+302
View File
@@ -0,0 +1,302 @@
<!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/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}
.sidebar .sb-brand{padding:1rem 1.1rem;border-bottom:1px solid var(--border);background:linear-gradient(135deg,transparent,rgba(88,166,255,.03))}
.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}
.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(88,166,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(160px,1fr));gap:.65rem;margin-bottom:1.25rem}
.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(88,166,255,.15);border-color:rgba(88,166,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:768px){.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}
.card:hover{border-color:rgba(88,166,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}
/* Client grid */
.client-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:.7rem}
.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(88,166,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{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(88,166,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}
.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(88,166,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(190px,1fr));gap:.5rem}
.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(88,166,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(180px,1fr));gap:.5rem}
.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(88,166,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(--accent);color:var(--accent);background:rgba(88,166,255,.04);transform:translateY(-2px);box-shadow:0 4px 12px rgba(88,166,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:.5rem;margin-bottom:.85rem;flex-wrap:wrap}.time-tools .tt-btn{display:flex;align-items:center;gap:.35rem;padding:.42rem .75rem;border-radius:8px;font-size:.72rem;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(88,166,255,.06)}.time-tools .tt-btn svg{width:14px;height:14px}
.nav-link.logout{color:var(--red)}.nav-link.logout:hover{background:var(--red-bg);color:var(--red)}
.nav-link:hover{color:var(--text);background:var(--border-light)}
.nav-link.active{background:var(--border-light);font-weight:600}
</style></head><body>
<nav class="navbar"><div class="nav-inner" style="gap:.5rem">
<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 active">Dashboard</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>
<button class="theme-toggle" id="themeToggle" title="Toggle light/dark mode" onclick="toggleTheme()"></button>
</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">Kern</div>
<button class="sb-item active" data-tab="overview"><span class="sb-icon"><i data-lucide="layout-dashboard"></i></span>Overzicht</button>
<button class="sb-item" data-tab="clients"><span class="sb-icon"><i data-lucide="users"></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"></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"></i></span>Financiën</button>
<div class="sb-sep">Operaties</div>
<button class="sb-item" data-tab="infra"><span class="sb-icon"><i data-lucide="server"></i></span>Infrastructuur</button>
<button class="sb-item" data-tab="tasks"><span class="sb-icon"><i data-lucide="check-circle"></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"></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="market"><span class="sb-icon"><i data-lucide="target"></i></span>Markt Intel</button>
<button class="sb-item" data-tab="calendar"><span class="sb-icon"><i data-lucide="calendar"></i></span>Agenda</button>
<button class="sb-item" data-tab="data"><span class="sb-icon"><i data-lucide="shield-check"></i></span>Data Quality</button>
<div class="sb-sep">Tools</div>
<button class="sb-item" data-tab="email"><span class="sb-icon"><i data-lucide="mail"></i></span>Email</button>
<button class="sb-item" data-tab="activity"><span class="sb-icon"><i data-lucide="clock"></i></span>Activiteit</button>
</nav></aside>
<div class="main-area">
<!-- ===== OVERVIEW ===== -->
<div class="tab-panel active" id="panel-overview">
<div class="page-hdr fade-in-up"><h1><span class="gradient-text">Enterprise Dashboard</span></h1><p>Real-time 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"><%= 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"><%= 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"><%= 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"><%= overdueTasks %></div><div class="kpi-label">Overdue Taken</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"><%= 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">Cliënten Status</div>
<% var sts={active:'Actief',lead:'Lead',paused:'Gepauzeerd',archived:'Gearchiveerd'};var clrs={active:'var(--green)',lead:'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">Recente 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"><span class="qa-icon"><i data-lucide="user-plus"></i></span>Cliënt</a>
<a href="/engagements/new" class="qa-btn"><span class="qa-icon"><i data-lucide="briefcase"></i></span>Engagement</a>
<a href="/time" class="qa-btn"><span class="qa-icon"><i data-lucide="clock"></i></span>Uren</a>
<a href="/invoices/new" class="qa-btn"><span class="qa-icon"><i data-lucide="receipt"></i></span>Factuur</a>
<a href="/quality" class="qa-btn"><span class="qa-icon"><i data-lucide="shield-check"></i></span>Data</a>
<a href="/assess" class="qa-btn"><span class="qa-icon"><i data-lucide="clipboard-list"></i></span>Assess</a>
</div></div></div></div>
<!-- ===== CLIENTS ===== -->
<div class="tab-panel" id="panel-clients"><div class="page-hdr fade-in-up"><h1>Cliënten</h1><p><%= stats.total %> totaal · <%= stats.active %> actief</p></div>
<% if (clients.length === 0) { %><div class="empty-state-s">Nog geen cliënten</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">Sector: <%= c.industry %></p><% } %><p class="client-meta"><%= c.engagement_count %> engagement(s) · <%= c.active_engagements %> actief</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">Nog 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 %> totaal · <%= stats.activeEngagements %> actief</p></div>
<div class="grid-2" style="margin-bottom:.85rem">
<div class="card card-hover"><div class="card-title">Per 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">Per 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>Financiën</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">Totaal uren</div><div class="kpi-sub"><%= billableHours.toFixed(1) %> facturabel</div></div><div class="kpi-card green fade-in-up"><div class="kpi-num">€<%= totalInvoiced.toFixed(0) %></div><div class="kpi-label">Gefactureerd</div><div class="kpi-sub">€<%= totalPaid.toFixed(0) %> betaald</div></div><div class="kpi-card orange fade-in-up"><div class="kpi-num">€<%= totalOutstanding.toFixed(0) %></div><div class="kpi-label">Openstaand</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></div>
<div class="grid-2"><div class="card card-hover"><div class="card-title">Recente uren</div>
<% if(timeEntries.length===0){ %><div class="empty-state-s">Nog geen uren</div><% }else{ %><table class="data-table"><thead><tr><th>Datum</th><th>Uren</th><th>Cliënt</th><th>Omschrijving</th></tr></thead><tbody><% timeEntries.forEach(function(t){ %><tr><td><%= t.date %></td><td><strong><%= t.hours %></strong></td><td><%= t.client_name||'-' %></td><td style="color:var(--text-muted);font-size:.7rem"><%= 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>Nr</th><th>Cliënt</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.total||0).toFixed(2) %></td><td><span class="status-badge <%= i.status %>"><%= i.status %></span></td></tr><% }) %></tbody></table><% } %></div></div></div>
<!-- ===== INFRA ===== -->
<div class="tab-panel" id="panel-infra"><div class="page-hdr fade-in-up"><h1>Infrastructuur</h1><p>Racks, netwerk & architectuur</p></div>
<div class="kpi-row"><div class="kpi-card cyan"><div class="kpi-num"><%= totalRacks %></div><div class="kpi-label">Racks</div><div class="kpi-sub"><%= totalRackDevices %> devices</div></div><div class="kpi-card purple"><div class="kpi-num"><%= totalNetworkDevices %></div><div class="kpi-label">Netwerk</div></div><div class="kpi-card green"><div class="kpi-num"><%= totalDiagrams %></div><div class="kpi-label">Diagrammen</div></div></div>
<div class="grid-2"><div class="card card-hover"><div class="card-title">Server Racks</div>
<% if(racks.length===0){ %><div class="empty-state-s">Nog geen racks</div><% }else{ %><% 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 class="card card-hover"><div class="card-title">Netwerk Devices</div>
<% if(netDevices.length===0){ %><div class="empty-state-s">Nog geen devices</div><% }else{ %><% 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 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>
<!-- ===== TASKS ===== -->
<div class="tab-panel" id="panel-tasks"><div class="page-hdr fade-in-up"><h1>Taken</h1><p><%= completedTasks %>/<%= totalTasks %> voltooid <% if(overdueTasks>0){ %><span style="color:var(--red);font-weight:600"><%= overdueTasks %> over tijd</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>Advies & Acties</h1><p>Automatisch advies op basis van cliënt data</p></div>
<% if(activeAdvice.length===0){ %><div class="empty-state-s">Geen advies items</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></div><% }) %></div><% } %></div>
<!-- ===== MARKET ===== -->
<div class="tab-panel" id="panel-market"><div class="page-hdr fade-in-up"><h1>Markt Intelligence</h1><p>Datacenters & prospects — vind je volgende klant</p></div>
<div class="quick-actions"><a href="/market" class="qa-btn"><span class="qa-icon"><i data-lucide="target"></i></span>Markt verkenner openen</a></div></div>
<!-- ===== CALENDAR ===== -->
<div class="tab-panel" id="panel-calendar"><div class="page-hdr fade-in-up"><h1>Agenda</h1><p>Planning en afspraken</p></div>
<div class="quick-actions"><a href="/calendar" class="qa-btn"><span class="qa-icon"><i data-lucide="calendar"></i></span>Agenda openen</a></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 & dbt modellen</p></div>
<div class="quick-actions"><a href="/quality" class="qa-btn"><span class="qa-icon"><i data-lucide="shield-check"></i></span>Data Quality Engine</a></div></div>
<!-- ===== EMAIL ===== -->
<div class="tab-panel" id="panel-email"><div class="page-hdr fade-in-up"><h1>Email</h1><p>IMAP inbox & versturen</p></div>
<div class="quick-actions"><a href="/email" class="qa-btn"><span class="qa-icon"><i data-lucide="inbox"></i></span>Inbox</a><a href="/email/compose" class="qa-btn"><span class="qa-icon"><i data-lucide="send"></i></span>Opstellen</a></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">Recente Assessments</div>
<% if(recentAssessments.length===0){ %><div class="empty-state-s">Nog geen 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>Meldingen</a><a href="/users" class="qa-btn"><span class="qa-icon"><i data-lucide="users"></i></span>Gebruikers</a></div></div>
</div></div>
<script>
function initTheme(){var m=localStorage.getItem('theme');if(m==='light'){document.body.classList.add('light-mode');document.getElementById('themeToggle').classList.add('light')}}
function toggleTheme(){var b=document.body;var t=document.getElementById('themeToggle');b.classList.toggle('light-mode');t.classList.toggle('light');localStorage.setItem('theme',b.classList.contains('light-mode')?'light':'dark')}
initTheme();
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');
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')}
lucide.createIcons();
</script>
</body></html>
+148
View File
@@ -0,0 +1,148 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const { getDb, getSettings } = require('../db');
const { generateAdvice, syncAdviceToDb, getAdviceActionUrl } = require('../advice');
const { getConsultingToolkit } = require('../lib/consulting-hub');
const { getServicesStatus } = require('../lib/services');
router.get('/', async (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;
let datacenterCount = 0;
let marketCompaniesCount = 0;
try {
datacenterCount = db.prepare('SELECT COUNT(*) as cnt FROM datacenters').get().cnt;
marketCompaniesCount = db.prepare('SELECT COUNT(*) as cnt FROM companies').get().cnt;
} catch (e) { /* tables may not exist yet */ }
let qualityFileCount = 0;
try {
const uploadDir = path.join(__dirname, '..', 'data', 'uploads');
if (fs.existsSync(uploadDir)) {
qualityFileCount = fs.readdirSync(uploadDir).filter(f => f !== '.gitkeep').length;
}
} catch (e) { /* ignore */ }
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();
for (const client of clients) {
const adviceItems = generateAdvice(client.id);
if (adviceItems.length > 0) syncAdviceToDb(client.id, adviceItems);
}
const activeAdvice = db.prepare(`
SELECT a.*, c.name as client_name
FROM advice_log a
JOIN clients c ON c.id = a.client_id
WHERE a.dismissed = 0
ORDER BY CASE a.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
a.created_at DESC
LIMIT 12
`).all().map(a => ({
...a,
action_url: getAdviceActionUrl(a.advice_type, a.client_id)
}));
const consultingStats = {
assessments: assessmentsCount,
activeConsulting: db.prepare("SELECT COUNT(*) as cnt FROM engagements WHERE status = 'in_progress' AND type IN ('consulting','architecture')").get().cnt,
clientsNeedingAssessment: db.prepare(`
SELECT COUNT(*) as cnt FROM clients c
WHERE c.status IN ('active','lead')
AND NOT EXISTS (SELECT 1 FROM assessments a WHERE a.client_id = c.id)
`).get().cnt,
openAdvice: db.prepare('SELECT COUNT(*) as cnt FROM advice_log WHERE dismissed = 0').get().cnt,
};
const { toolkit, playbooks, maturityLayers } = getConsultingToolkit();
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;
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();
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();
let servicesStatus = {};
try {
servicesStatus = await getServicesStatus();
} catch (e) {
servicesStatus = { consulting: { online: true, label: 'Mek-Tech Core' } };
}
res.render('dashboard', {
tab, clients, stats,
showcaseUrl: getSettings().showcase_url || 'http://192.168.1.247:3010',
totalDiagrams, totalRacks, totalRackDevices, totalNetworkDevices,
datacenterCount, marketCompaniesCount, qualityFileCount,
overdueTasks, totalTasks, completedTasks, assessmentsCount,
recentEngagements, allEngagements, recentAssessments,
engagementByType, engagementByStatus, clientsByIndustry,
activeAdvice,
consultingStats, toolkit, playbooks, maturityLayers,
timeEntries, totalHours, billableHours, invoices, totalInvoiced, totalPaid, totalOutstanding,
racks, netDevices, diagrams,
allTasks, servicesStatus
});
});
module.exports = router;
+121
View File
@@ -0,0 +1,121 @@
const express = require('express');
const router = express.Router();
const { getDb, logAudit } = require('../db');
const emailService = require('../email');
function getActiveAccount(userId) {
const db = getDb();
return db.prepare('SELECT * FROM email_accounts WHERE user_id = ? AND active = 1 LIMIT 1').get(userId);
}
router.get('/', async (req, res) => {
const db = getDb();
const accounts = emailService.getUserAccounts(req.session.userId);
const account = getActiveAccount(req.session.userId);
let messages = [];
let folders = [];
let error = null;
if (account) {
try {
messages = db.prepare('SELECT * FROM email_messages WHERE account_id = ? AND folder = ? ORDER BY date DESC LIMIT 50').all(account.id, 'INBOX');
folders = await emailService.getMailboxes(account.id);
} catch (e) { error = e.message; }
}
res.render('email-inbox', { accounts, account, messages, folders, error, user: { username: req.session.username } });
});
router.post('/sync', async (req, res) => {
const account = getActiveAccount(req.session.userId);
if (!account) return res.redirect('/email/settings');
try {
await emailService.fetchAndStoreMessages(account.id, 'INBOX', 50);
logAudit(req.session.userId, req.session.username, 'email_sync', 'email', account.id, 'Email inbox gesynchroniseerd');
} catch (e) { console.error('Sync error:', e.message); }
res.redirect('/email');
});
router.get('/sync', async (req, res) => {
const account = getActiveAccount(req.session.userId);
if (!account) return res.json({ error: 'No account' });
try {
const results = await emailService.fetchAndStoreMessages(account.id, 'INBOX', 50);
res.json({ synced: results.length });
} catch (e) { res.json({ error: e.message }); }
});
router.get('/compose', (req, res) => {
const accounts = emailService.getUserAccounts(req.session.userId);
const account = getActiveAccount(req.session.userId);
res.render('email-compose', { accounts, account, to: req.query.to || '', subject: req.query.subject || '', sent: false, error: null, user: { username: req.session.username } });
});
router.post('/compose', async (req, res) => {
const account = getActiveAccount(req.session.userId);
if (!account) return res.render('email-compose', { accounts: emailService.getUserAccounts(req.session.userId), account: null, to: '', subject: '', sent: false, error: 'Geen email account geconfigureerd', user: { username: req.session.username } });
const { to, cc, bcc, subject, text, html } = req.body;
if (!to || !subject) return res.render('email-compose', { accounts: emailService.getUserAccounts(req.session.userId), account, to, subject, sent: false, error: 'Ontvanger en onderwerp zijn verplicht', user: { username: req.session.username } });
try {
await emailService.sendEmail(account.id, to, subject, text, html, cc, bcc);
logAudit(req.session.userId, req.session.username, 'email_sent', 'email', account.id, `Email naar ${to}: ${subject}`);
res.render('email-compose', { accounts: emailService.getUserAccounts(req.session.userId), account, to: '', subject: '', sent: true, error: null, user: { username: req.session.username } });
} catch (e) {
res.render('email-compose', { accounts: emailService.getUserAccounts(req.session.userId), account, to, subject, sent: false, error: e.message, user: { username: req.session.username } });
}
});
router.get('/message/:id', async (req, res) => {
const db = getDb();
const msg = db.prepare('SELECT * FROM email_messages WHERE id = ?').get(req.params.id);
if (!msg) return res.redirect('/email');
db.prepare('UPDATE email_messages SET seen = 1 WHERE id = ?').run(req.params.id);
let fullHtml = msg.body_html;
let fullText = msg.body_text;
if (!fullHtml && !fullText) {
try {
const parsed = await emailService.getMessageBody(msg.account_id, msg.uid);
if (parsed) {
fullHtml = parsed.html || '';
fullText = parsed.text || '';
db.prepare('UPDATE email_messages SET body_text=?, body_html=? WHERE id=?').run(fullText || '', fullHtml || '', msg.id);
}
} catch (e) { console.error('Error fetching body:', e.message); }
}
msg.body_html = fullHtml;
msg.body_text = fullText;
res.render('email-detail', { msg, user: { username: req.session.username } });
});
router.post('/message/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM email_messages WHERE id = ?').run(req.params.id);
res.redirect('/email');
});
router.get('/settings', (req, res) => {
const accounts = emailService.getUserAccounts(req.session.userId);
res.render('email-settings', { accounts, error: null, saved: req.query.saved === '1', user: { username: req.session.username } });
});
router.post('/settings', (req, res) => {
const db = getDb();
const { email, name, imap_host, imap_port, imap_secure, imap_user, imap_pass, smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass } = req.body;
if (!email || !imap_host || !imap_user || !imap_pass) {
const accounts = emailService.getUserAccounts(req.session.userId);
return res.render('email-settings', { accounts, error: 'Email, IMAP host, gebruiker en wachtwoord zijn verplicht', saved: false, user: { username: req.session.username } });
}
db.prepare(`INSERT INTO email_accounts (user_id, email, name, imap_host, imap_port, imap_secure, imap_user, imap_pass, smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
req.session.userId, email, name || '', imap_host, parseInt(imap_port) || 993, imap_secure === '1' ? 1 : 0,
imap_user, imap_pass, smtp_host || imap_host, parseInt(smtp_port) || 587, smtp_secure === '1' ? 1 : 0,
smtp_user || imap_user, smtp_pass || imap_pass
);
res.redirect('/email/settings?saved=1');
});
router.post('/settings/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM email_accounts WHERE id = ? AND user_id = ?').run(req.params.id, req.session.userId);
res.redirect('/email/settings');
});
module.exports = router;
+86
View File
@@ -0,0 +1,86 @@
const express = require('express');
const router = express.Router();
const { getDb, logAudit } = require('../db');
router.get('/', (req, res) => {
const db = getDb();
const { type, status, client_id } = req.query;
let sql = `SELECT e.*, c.name as client_name
FROM engagements e JOIN clients c ON c.id = e.client_id WHERE 1=1`;
const params = [];
if (type) { sql += ' AND e.type = ?'; params.push(type); }
if (status) { sql += ' AND e.status = ?'; params.push(status); }
if (client_id) { sql += ' AND e.client_id = ?'; params.push(client_id); }
sql += ' ORDER BY e.updated_at DESC';
const engagements = db.prepare(sql).all(...params);
res.render('engagement-list', { engagements, filters: req.query });
});
router.get('/new', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('engagement-form', { engagement: null, clients, selected: req.query.client_id || null, error: null });
});
router.get('/new/:clientId', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const selected = req.params.clientId;
res.render('engagement-form', { engagement: null, clients, selected, error: null });
});
router.post('/new', (req, res) => {
const db = getDb();
const { client_id, type, title, description, status, start_date, end_date } = req.body;
if (!client_id || !title || !title.trim()) {
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
return res.render('engagement-form', { engagement: null, clients, selected: client_id || null, error: 'Titel is verplicht' });
}
const result = db.prepare(`INSERT INTO engagements (client_id, type, title, description, status, start_date, end_date)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(client_id, type, title, description, status || 'planned', start_date, end_date);
res.redirect('/engagements/' + result.lastInsertRowid);
});
router.get('/:id', (req, res) => {
const db = getDb();
const engagement = db.prepare(`
SELECT e.*, c.name as client_name
FROM engagements e
JOIN clients c ON c.id = e.client_id
WHERE e.id = ?
`).get(req.params.id);
if (!engagement) return res.redirect('/engagements');
const tasks = db.prepare('SELECT * FROM tasks WHERE engagement_id = ? ORDER BY created_at DESC').all(req.params.id);
res.render('engagement-detail', { engagement, tasks });
});
router.get('/:id/edit', (req, res) => {
const db = getDb();
const engagement = db.prepare('SELECT * FROM engagements WHERE id = ?').get(req.params.id);
if (!engagement) return res.redirect('/engagements');
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('engagement-form', { engagement, clients, selected: engagement.client_id, error: null });
});
router.post('/:id/edit', (req, res) => {
const db = getDb();
const { client_id, type, title, description, status, start_date, end_date, outcome } = req.body;
db.prepare(`UPDATE engagements SET client_id=?, type=?, title=?, description=?, status=?, start_date=?, end_date=?, outcome=?, updated_at=datetime('now') WHERE id=?`)
.run(client_id, type, title, description, status, start_date, end_date, outcome, req.params.id);
res.redirect('/engagements/' + req.params.id);
});
router.post('/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM engagements WHERE id = ?').run(req.params.id);
res.redirect('/engagements');
});
router.post('/:id/status', (req, res) => {
const db = getDb();
db.prepare("UPDATE engagements SET status=?, updated_at=datetime('now') WHERE id=?")
.run(req.body.status, req.params.id);
res.redirect('/engagements/' + req.params.id);
});
module.exports = router;
+22
View File
@@ -0,0 +1,22 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
router.get('/', (req, res) => {
const db = getDb();
const tab = req.query.tab || 'overview';
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 entries = db.prepare('SELECT t.*, c.name as client_name FROM time_entries t LEFT JOIN clients c ON c.id = t.client_id ORDER BY t.date DESC LIMIT 10').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 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;
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const engagements = db.prepare('SELECT e.id, e.title, c.name as client_name FROM engagements e JOIN clients c ON c.id = e.client_id ORDER BY e.title').all();
res.render('finance', { tab, invoices, entries, clients, engagements, totalHours, billableHours, totalInvoiced, totalPaid, totalOutstanding });
});
module.exports = router;
+92
View File
@@ -0,0 +1,92 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
router.get('/', (req, res) => {
const db = getDb();
const tab = req.query.tab || 'overview';
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 totalConnections = db.prepare('SELECT COUNT(*) as cnt FROM network_connections').get().cnt;
const totalDiagrams = db.prepare('SELECT COUNT(*) as cnt FROM data_diagrams').get().cnt;
const racksByClient = db.prepare(`
SELECT c.name as client_name, COUNT(*) as cnt
FROM racks r JOIN clients c ON c.id = r.client_id
GROUP BY c.id ORDER BY cnt DESC LIMIT 10
`).all();
const deviceTypes = db.prepare(`
SELECT device_type, COUNT(*) as cnt
FROM rack_devices GROUP BY device_type ORDER BY cnt DESC
`).all();
const netDeviceTypes = db.prepare(`
SELECT device_type, COUNT(*) as cnt
FROM network_devices GROUP BY device_type ORDER BY cnt DESC
`).all();
const diagramTypes = db.prepare(`
SELECT diagram_type, COUNT(*) as cnt
FROM data_diagrams GROUP BY diagram_type ORDER BY cnt DESC
`).all();
const recentRacks = 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 recentDiagrams = 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();
const recentNetDevices = 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 6
`).all();
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
`).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
`).all();
const connections = db.prepare(`
SELECT nc.*, c.name as client_name
FROM network_connections nc JOIN clients c ON c.id = nc.client_id
ORDER BY nc.created_at DESC
`).all();
const diagrams = db.prepare(`
SELECT d.*, c.name as client_name,
json_array_length(d.nodes) as node_count
FROM data_diagrams d JOIN clients c ON c.id = d.client_id
ORDER BY d.updated_at DESC
`).all();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const hardwareLibrary = require('../data/hardware-library');
res.render('infrastructure', {
tab, racks, clients, hardwareLibrary,
netDevices, connections, diagrams,
totalRacks, totalRackDevices, totalNetworkDevices, totalConnections, totalDiagrams,
racksByClient, deviceTypes, netDeviceTypes, diagramTypes,
recentRacks, recentDiagrams, recentNetDevices
});
});
module.exports = router;
+52
View File
@@ -0,0 +1,52 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
const { getProviders } = require('../lib/ai');
router.get('/', (req, res) => {
const db = getDb();
const aiProviders = getProviders();
const integrations = [
{ id: 'openrouter', name: 'OpenRouter', description: 'Multi-model AI gateway', icon: 'sparkles', category: 'ai', status: 'available' },
{ id: 'openai', name: 'OpenAI', description: 'GPT-4o, o1 models', icon: 'bot', category: 'ai', status: 'available' },
{ id: 'anthropic', name: 'Anthropic', description: 'Claude models', icon: 'brain', category: 'ai', status: 'available' },
{ id: 'ollama', name: 'Ollama', description: 'Lokale AI modellen', icon: 'cpu', category: 'ai', status: 'available' },
{ id: 'google_calendar', name: 'Google Calendar', description: 'Sync afspraken', icon: 'calendar', category: 'calendar', status: 'available' },
{ id: 'slack', name: 'Slack', description: 'Team notificaties', icon: 'message-square', category: 'comms', status: 'available' },
{ id: 'hubspot', name: 'HubSpot CRM', description: 'Externe CRM sync', icon: 'users', category: 'crm', status: 'available' },
{ id: 'quickbooks', name: 'QuickBooks', description: 'Boekhouding', icon: 'dollar-sign', category: 'finance', status: 'available' }
];
let activeIntegrations = [];
try {
activeIntegrations = db.prepare('SELECT * FROM integrations WHERE status = ?').all('active');
} catch (e) { activeIntegrations = []; }
res.render('integrations', { integrations, activeIntegrations, aiProviders, saved: req.query.saved });
});
router.post('/connect/:id', (req, res) => {
const db = getDb();
const { id } = req.params;
const { api_key, webhook_url, config } = req.body;
db.prepare(`
INSERT OR REPLACE INTO integrations (id, name, api_key, webhook_url, config, status, created_at)
VALUES (?, ?, ?, ?, ?, 'active', datetime('now'))
`).run(id, id, api_key || '', webhook_url || '', JSON.stringify(config || {}));
res.redirect('/integrations?saved=1');
});
router.post('/disconnect/:id', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM integrations WHERE id = ?').run(req.params.id);
res.redirect('/integrations');
});
router.post('/test/:id', (req, res) => {
res.json({ success: true, message: `Integration ${req.params.id} test successful` });
});
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
const express = require('express');
const router = express.Router();
const { getDb, logAudit } = require('../db');
const emailService = require('../email');
router.get('/', (req, res) => {
const db = getDb();
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
`).all();
const totalOutstanding = invoices.filter(i => i.status === 'sent' || i.status === 'overdue').reduce((s, i) => s + i.total, 0);
const totalPaid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + i.total, 0);
res.render('invoice-list', { invoices, totalOutstanding, totalPaid });
});
router.get('/new', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('invoice-form', { invoice: null, clients, selectedClient: null, timeEntries: [], error: null });
});
router.get('/new/:clientId', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.clientId);
if (!client) return res.redirect('/invoices/new');
const timeEntries = db.prepare(`
SELECT t.*, e.title as engagement_title
FROM time_entries t
LEFT JOIN engagements e ON e.id = t.engagement_id
WHERE t.client_id = ? AND t.billable = 1
ORDER BY t.date DESC
`).all(req.params.clientId);
res.render('invoice-form', { invoice: null, clients, selectedClient: parseInt(req.params.clientId), timeEntries, error: null });
});
router.post('/new', (req, res) => {
const db = getDb();
const { client_id, date, due_date, notes, items } = req.body;
if (!client_id) return res.redirect('/invoices');
const count = db.prepare('SELECT COUNT(*) as cnt FROM invoices').get().cnt + 1;
const number = `FACT-${new Date().getFullYear()}-${String(count).padStart(4, '0')}`;
const subtotal = parseFloat(req.body.subtotal) || 0;
const tax = parseFloat(req.body.tax) || 0;
const total = subtotal + tax;
const result = db.prepare('INSERT INTO invoices (client_id, number, status, date, due_date, subtotal, tax, total, notes) VALUES (?,?,?,?,?,?,?,?,?)')
.run(client_id, number, 'draft', date || new Date().toISOString().split('T')[0], due_date || '', subtotal, tax, total, notes || '');
const invoiceId = result.lastInsertRowid;
if (items && Array.isArray(items)) {
const insert = db.prepare('INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)');
for (const item of items) {
if (item.description) {
const itemTotal = (parseFloat(item.quantity) || 1) * (parseFloat(item.unit_price) || 0);
insert.run(invoiceId, item.description, parseFloat(item.quantity) || 1, parseFloat(item.unit_price) || 0, itemTotal);
}
}
}
logAudit(req.session.userId, req.session.username, 'invoice_created', 'invoice', invoiceId, `Factuur ${number} aangemaakt`);
res.redirect('/invoices/' + invoiceId);
});
router.get('/:id', (req, res) => {
const db = getDb();
const invoice = db.prepare('SELECT i.*, c.name as client_name, c.contact_name, c.contact_email, c.contact_phone, c.website FROM invoices i JOIN clients c ON c.id = i.client_id WHERE i.id = ?').get(req.params.id);
if (!invoice) return res.redirect('/invoices');
const items = db.prepare('SELECT * FROM invoice_items WHERE invoice_id = ?').all(req.params.id);
res.render('invoice-detail', { invoice, items, sent: req.query.sent === '1', error: req.query.error || null });
});
router.post('/:id/status', (req, res) => {
const db = getDb();
db.prepare("UPDATE invoices SET status=?, updated_at=datetime('now') WHERE id=?").run(req.body.status, req.params.id);
res.redirect('/invoices/' + req.params.id);
});
router.post('/:id/send-email', async (req, res) => {
const db = getDb();
const invoice = db.prepare('SELECT i.*, c.name as client_name, c.contact_email FROM invoices i JOIN clients c ON c.id = i.client_id WHERE i.id = ?').get(req.params.id);
if (!invoice) return res.redirect('/invoices');
const account = db.prepare('SELECT * FROM email_accounts WHERE user_id = ? AND active = 1 LIMIT 1').get(req.session.userId);
if (!account) return res.redirect('/invoices/' + req.params.id + '?error=noemail');
const items = db.prepare('SELECT * FROM invoice_items WHERE invoice_id = ?').all(req.params.id);
let text = `Factuur ${invoice.number}\n\nCliënt: ${invoice.client_name}\nDatum: ${invoice.date}\nVervaldatum: ${invoice.due_date || '-'}\n\n`;
items.forEach(item => { text += `${item.description} x${item.quantity}${(item.unit_price||0).toFixed(2)}${(item.total||0).toFixed(2)}\n`; });
text += `\nSubtotaal: €${(invoice.subtotal||0).toFixed(2)}\nBTW: €${(invoice.tax||0).toFixed(2)}\nTotaal: €${(invoice.total||0).toFixed(2)}\n\nMek-Tech Consulting`;
const to = invoice.contact_email || (await emailService.getAccount(account.id)).email;
try {
await emailService.sendEmail(account.id, to, `Factuur ${invoice.number}`, text, text.replace(/\n/g, '<br>'));
logAudit(req.session.userId, req.session.username, 'invoice_emailed', 'invoice', invoice.id, `Factuur ${invoice.number} gemaild naar ${to}`);
db.prepare("UPDATE invoices SET status='sent', updated_at=datetime('now') WHERE id=?").run(req.params.id);
res.redirect('/invoices/' + req.params.id + '?sent=1');
} catch (e) {
res.redirect('/invoices/' + req.params.id + '?error=' + encodeURIComponent(e.message));
}
});
router.post('/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM invoices WHERE id = ?').run(req.params.id);
res.redirect('/invoices');
});
module.exports = router;
+130
View File
@@ -0,0 +1,130 @@
<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Markt Intelligence — 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>
.mi-wrap{max-width:1300px;margin:0 auto;padding:1.25rem 1.5rem 3rem}
.mi-hdr h1{font-size:1.5rem;font-weight:800;color:var(--text);letter-spacing:-.3px}
.mi-hdr .sub{font-size:.78rem;color:var(--text-muted)}
.mi-kpi{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:.5rem;margin:1rem 0}
.mi-kpi .mk{border:1px solid var(--border);border-radius:var(--radius);padding:.6rem .8rem;background:var(--bg-card)}
.mi-kpi .mk b{font-size:1.2rem;color:var(--text);display:block}.mi-kpi .mk span{font-size:.6rem;color:var(--text-muted);text-transform:uppercase}
.tabs{display:flex;margin-bottom:1rem;background:var(--bg-card);border-radius:var(--radius);border:1px solid var(--border);overflow:hidden}
.tab{padding:.45rem .9rem;font-size:.75rem;font-weight:500;color:var(--text-muted);cursor:pointer;border:none;background:none;font-family:inherit;display:flex;align-items:center;gap:.35rem;border-right:1px solid var(--border)}
.tab:last-child{border-right:none}.tab:hover{color:var(--text);background:var(--border-light)}
.tab.active{color:var(--accent);background:rgba(88,166,255,.06);font-weight:600}.tab svg{width:13px;height:13px}
.pane{display:none}.pane.active{display:block}
.filter{display:flex;gap:.3rem;flex-wrap:wrap;margin-bottom:.75rem;align-items:center}
.filter input,.filter select{padding:.3rem .5rem;font-size:.7rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-family:inherit}
.filter .btn{padding:.3rem .65rem;font-size:.7rem}
/* DC cards */
.dc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.5rem}
.dc-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:.75rem;position:relative}
.dc-card::after{content:'';position:absolute;top:0;left:0;right:0;height:3px}
.dc-card.t4::after{background:linear-gradient(90deg,var(--purple),var(--accent))}.dc-card.t3::after{background:linear-gradient(90deg,var(--accent),var(--green))}
.dc-card h3{font-size:.82rem;font-weight:600;color:var(--text)}
.dc-card .dc-loc{font-size:.65rem;color:var(--text-muted);margin:.15rem 0}
.dc-card .dc-row{display:flex;gap:.15rem;flex-wrap:wrap;margin:.2rem 0}
.dc-card .dc-chip{padding:.08rem .3rem;border-radius:3px;font-size:.55rem;font-weight:500}
.dc-chip.blue{background:var(--cyan-bg);color:var(--cyan)}.dc-chip.purple{background:var(--purple-bg);color:var(--purple)}.dc-chip.green{background:var(--green-bg);color:var(--green)}.dc-chip.orange{background:var(--orange-bg);color:var(--orange)}
.dc-card .dc-spec{font-size:.6rem;color:var(--text-dim);margin-top:.25rem;line-height:1.4}
/* Company cards */
.cc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:.5rem}
.cc-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:.8rem;position:relative}
.cc-card::after{content:'';position:absolute;top:0;left:0;right:0;height:3px}
.cc-card.prospect-high::after{background:linear-gradient(90deg,var(--green),var(--accent))}.cc-card.prospect-medium::after{background:linear-gradient(90deg,var(--accent),var(--purple))}.cc-card.prospect-low::after{background:linear-gradient(90deg,var(--text-dim),var(--border))}
.cc-card h3{font-size:.82rem;font-weight:600;color:var(--text)}
.cc-card .cc-sub{font-size:.65rem;color:var(--text-muted);margin:.1rem 0}
.cc-card .cc-chips{display:flex;flex-wrap:wrap;gap:.13rem;margin:.25rem 0}
.cc-card .cc-chip{padding:.08rem .32rem;border-radius:3px;font-size:.55rem;font-weight:500}
.cc-chip.dw{background:var(--purple-bg);color:var(--purple)}.cc-chip.bi{background:var(--cyan-bg);color:var(--cyan)}.cc-chip.cloud{background:var(--green-bg);color:var(--green)}.cc-chip.ai{background:var(--orange-bg);color:var(--orange)}.cc-chip.db{background:var(--pink-bg);color:var(--pink)}
.cc-card .cc-info{font-size:.6rem;color:var(--text-dim);margin-top:.3rem;line-height:1.3}
.cc-card .cc-contact{font-size:.6rem;color:var(--text-dim);margin-top:.3rem}
.cc-card .cc-score{position:absolute;top:.5rem;right:.5rem;padding:.1rem .4rem;border-radius:999px;font-size:.6rem;font-weight:700}
.cc-score.high{background:var(--green-bg);color:var(--green)}.cc-score.medium{background:var(--orange-bg);color:var(--orange)}.cc-score.low{background:var(--border-light);color:var(--text-dim)}
/* Status badges */
.st-badge{padding:.08rem .3rem;border-radius:3px;font-size:.55rem;font-weight:500}
.st-badge.new{background:var(--cyan-bg);color:var(--cyan)}.st-badge.contacted{background:var(--orange-bg);color:var(--orange)}.st-badge.qualified{background:var(--green-bg);color:var(--green)}
</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-links"><a href="/" class="nav-link">Dashboard</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 active">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></nav>
<div class="mi-wrap">
<div class="mi-hdr"><h1>🎯 Markt Intelligence</h1><p class="sub">Datacenters & prospects — alle bedrijven met hun tech stack, contactgegevens en lead score</p></div>
<div class="mi-kpi">
<div class="mk"><b><%= datacenters.length %></b><span>datacenters</span></div>
<div class="mk"><b><%= totalPower.toFixed(0) %> MW</b><span>totaal vermogen</span></div>
<div class="mk"><b><%= companies.length %></b><span>tech prospects</span></div>
<div class="mk"><b><%= dwVendors.length %></b><span>DW vendors</span></div>
<div class="mk"><b><%= industries.length %></b><span>sectoren</span></div>
<div class="mk"><b><%= regions.length %></b><span>regio's</span></div>
</div>
<div class="tabs">
<button class="tab <%= tab==='datacenters'?'active':'' %>" onclick="setTab('datacenters')"><i data-lucide="database"></i> Datacenters</button>
<button class="tab <%= tab==='companies'?'active':'' %>" onclick="setTab('companies')"><i data-lucide="building-2"></i> Tech Prospects</button>
</div>
<% var p = tab||'datacenters' %>
<!-- ---- DATACENTERS ---- -->
<div class="pane <%= p==='datacenters'?'active':'' %>" id="pane-datacenters">
<form class="filter" method="GET"><input type="hidden" name="tab" value="datacenters">
<input type="text" name="search" placeholder="Zoek naam, operator, vendor..." value="<%= search %>" style="min-width:180px"><select name="region" onchange="this.form.submit()"><option value="">Alle regio's</option><% regions.forEach(function(r){ %><option value="<%= r %>" <%= region===r?'selected':'' %>><%= r %></option><% }) %></select>
<select name="vendor" onchange="this.form.submit()"><option value="">Alle vendors</option><% allVendors.forEach(function(v){ %><option value="<%= v %>" <%= vendor===v?'selected':'' %>><%= v %></option><% }) %></select>
<button class="btn btn-primary">Filter</button></form>
<div class="dc-grid">
<% datacenters.forEach(function(dc){ %><div class="dc-card <%= dc.tier==='IV'?'t4':'t3' %>">
<h3><%= dc.name %></h3>
<div class="dc-loc"><i data-lucide="map-pin" style="width:10px;height:10px"></i> <%= dc.city %>, <%= dc.region %> · <%= dc.address||'' %></div>
<% if(dc.phone){ %><div class="dc-loc" style="margin-top:1px"><i data-lucide="phone" style="width:10px;height:10px"></i> <%= dc.phone %></div><% } %>
<div class="dc-row"><span class="dc-chip purple">Tier <%= dc.tier %></span><span class="dc-chip blue"><%= dc.type %></span><span class="dc-chip green"><%= dc.operator %></span></div>
<div class="dc-spec"><strong><%= dc.power_mw||0 %> MW</strong> · <%= (dc.size_sqm||0).toLocaleString() %> m²</div>
<% if(dc.network_vendors){ %><div class="dc-spec"><strong>Network:</strong> <%= dc.network_vendors %></div><% } %>
<% if(dc.server_vendors){ %><div class="dc-spec"><strong>Servers:</strong> <%= dc.server_vendors %></div><% } %>
<% if(dc.storage_vendors){ %><div class="dc-spec"><strong>Storage:</strong> <%= dc.storage_vendors %></div><% } %>
<% if(dc.certifications){ %><div class="dc-spec"><strong>Certs:</strong> <%= dc.certifications %></div><% } %>
</div><% }) %>
</div></div>
<!-- ---- COMPANIES ---- -->
<div class="pane <%= p==='companies'?'active':'' %>" id="pane-companies">
<form class="filter" method="GET"><input type="hidden" name="tab" value="companies">
<input type="text" name="search" placeholder="Zoek naam, tech stack, vendor..." value="<%= search %>" style="min-width:180px">
<select name="industry" onchange="this.form.submit()"><option value="">Alle sectoren</option><% industries.forEach(function(i){ %><option value="<%= i %>" <%= industry===i?'selected':'' %>><%= i %></option><% }) %></select>
<select name="dw" onchange="this.form.submit()"><option value="">DW vendor</option><% dwVendors.forEach(function(v){ %><option value="<%= v %>" <%= dw===v?'selected':'' %>><%= v %></option><% }) %></select>
<select name="city" onchange="this.form.submit()"><option value="">Stad</option><% cities.forEach(function(c){ %><option value="<%= c %>" <%= city===c?'selected':'' %>><%= c %></option><% }) %></select>
<select name="sort" onchange="this.form.submit()"><option value="lead_score" <%= sort==='lead_score'?'selected':'' %>>Lead score</option><option value="employees" <%= sort==='employees'?'selected':'' %>>Grootte</option><option value="name" <%= sort==='name'?'selected':'' %>>Naam</option></select>
<button class="btn btn-primary">Filter</button></form>
<div class="cc-grid">
<% companies.forEach(function(c){
var prospectClass = c.lead_score >= 70 ? 'prospect-high' : c.lead_score >= 50 ? 'prospect-medium' : 'prospect-low';
var scoreClass = c.lead_score >= 70 ? 'high' : c.lead_score >= 50 ? 'medium' : 'low';
%>
<div class="cc-card <%= prospectClass %>">
<div class="cc-score <%= scoreClass %>"><%= c.lead_score %></div>
<h3><%= c.name %></h3>
<div class="cc-sub"><%= c.industry %> · <%= c.city %> · ~<%= (c.employees||0).toLocaleString() %> medewerkers</div>
<div class="cc-sub" style="margin-top:0"><%= c.address||'' %> <%= c.postal_code||'' %></div>
<% if(c.phone || c.email || c.website){ %><div class="cc-contact">📞 <%= c.phone||'-' %> · ✉ <%= c.email||'-' %> · 🌐 <%= c.website||'-' %></div><% } %>
<% if(c.data_warehouse){ %><div class="cc-chips"><% c.data_warehouse.split(',').slice(0,6).forEach(function(v){ %><span class="cc-chip dw"><%= v.trim() %></span><% }) %></div><% } %>
<% if(c.bi_tools){ %><div class="cc-chips"><% c.bi_tools.split(',').slice(0,4).forEach(function(v){ %><span class="cc-chip bi"><%= v.trim() %></span><% }) %></div><% } %>
<% if(c.cloud_provider){ %><div class="cc-chips"><% c.cloud_provider.split(',').slice(0,3).forEach(function(v){ %><span class="cc-chip cloud"><%= v.trim() %></span><% }) %></div><% } %>
<% if(c.ai_ml){ %><div class="cc-chips"><% c.ai_ml.split(',').slice(0,3).forEach(function(v){ %><span class="cc-chip ai"><%= v.trim() %></span><% }) %></div><% } %>
<% if(c.database_vendor){ %><div class="cc-chips"><% c.database_vendor.split(',').slice(0,3).forEach(function(v){ %><span class="cc-chip db"><%= v.trim() %></span><% }) %></div><% } %>
<div class="cc-info">Data lake: <%= c.data_lake||'-' %> · Revenue: <%= c.revenue_range||'-' %></div>
</div><% }) %>
</div></div>
</div>
<script>
function setTab(t){
var u=new URL(window.location);u.searchParams.set('tab',t);window.location.href=u.toString();
}
lucide.createIcons();
</script>
</body></html>
+93
View File
@@ -0,0 +1,93 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
const CITY_COORDS = {
'Amsterdam':{lat:52.3676,lng:4.9041},'Rotterdam':{lat:51.9244,lng:4.4777},
'Den Haag':{lat:52.0705,lng:4.3007},'Utrecht':{lat:52.0907,lng:5.1214},
'Eindhoven':{lat:51.4416,lng:5.4697},'Groningen':{lat:53.2194,lng:6.5665},
'Tilburg':{lat:51.5719,lng:5.0672},'Almere':{lat:52.3508,lng:5.2647},
'Haarlem':{lat:52.3874,lng:4.6462},'Nijmegen':{lat:51.8126,lng:5.8372},
'Arnhem':{lat:51.9969,lng:6.1832},'Amersfoort':{lat:52.1561,lng:5.3878},
'Apeldoorn':{lat:52.2112,lng:5.9474},'Zwolle':{lat:52.5168,lng:6.0830},
'Enschede':{lat:52.2215,lng:6.8937},'Maastricht':{lat:50.8514,lng:5.6900},
'Leiden':{lat:52.1601,lng:4.4970},'Delft':{lat:51.9999,lng:4.3540},
'Breda':{lat:51.5719,lng:4.7683},'Hengelo':{lat:52.2636,lng:6.7945},
'Dronten':{lat:52.5689,lng:5.7175},'Schiphol-Rijk':{lat:52.2845,lng:4.7555},
'Schiphol':{lat:52.3105,lng:4.7683},'Zaandam':{lat:52.4382,lng:4.8158},
'Veldhoven':{lat:51.4184,lng:5.4030},'Deventer':{lat:52.2661,lng:6.1557},
'Wageningen':{lat:51.9692,lng:5.6653},'Eygelshoven':{lat:50.7639,lng:6.0473},
'Veghel':{lat:51.6665,lng:5.5556},'Leusden':{lat:52.1418,lng:5.4165},
'Rosmalen':{lat:51.6699,lng:5.7867},'Eemshaven':{lat:53.4430,lng:6.8530},
'Sliedrecht':{lat:51.8220,lng:4.7733}
};
router.get('/', (req, res) => {
const db = getDb();
const allDcs = db.prepare('SELECT * FROM datacenters').all();
const allCompanies = db.prepare('SELECT * FROM tech_companies').all();
const regions = [...new Set(allDcs.map(d => d.region).filter(Boolean))].sort();
const industries = [...new Set(allCompanies.map(c => c.industry).filter(Boolean))].sort();
const dwVendors = [...new Set(allCompanies.flatMap(c => (c.data_warehouse||'').split(',').map(s=>s.trim()).filter(Boolean)))].sort();
const allVendors = [...new Set(allDcs.flatMap(d => ((d.network_vendors||'')+','+(d.server_vendors||'')+','+(d.storage_vendors||'')).split(',').map(s=>s.trim()).filter(Boolean)))].sort();
const cities = [...new Set(allCompanies.map(c => c.city).filter(Boolean))].sort();
const totalPower = allDcs.reduce((s,d) => s + (d.power_mw||0), 0);
res.render('market-intel', {
datacenters: allDcs, companies: allCompanies, regions, industries, dwVendors, allVendors, cities,
totalPower
});
});
// Realtime API: search datacenters
router.get('/search/dc', (req, res) => {
const db = getDb();
const { search, vendor, region, type, sort } = req.query;
let sql = 'SELECT * FROM datacenters WHERE 1=1';
const params = [];
if (search) { sql += ' AND (name LIKE ? OR operator LIKE ? OR city LIKE ? OR network_vendors LIKE ? OR server_vendors LIKE ? OR storage_vendors LIKE ?)'; const s = '%'+search+'%'; params.push(s,s,s,s,s,s); }
if (vendor) { sql += ' AND (network_vendors LIKE ? OR server_vendors LIKE ? OR storage_vendors LIKE ?)'; const v = '%'+vendor+'%'; params.push(v,v,v); }
if (region) { sql += ' AND region LIKE ?'; params.push('%'+region+'%'); }
if (type) { sql += ' AND type = ?'; params.push(type); }
sql += ' ORDER BY ' + (sort === 'name' ? 'name' : 'power_mw DESC');
res.json(db.prepare(sql).all(...params));
});
// Realtime API: search companies
router.get('/search/co', (req, res) => {
const db = getDb();
const { search, industry, dw, city, sort } = req.query;
let sql = 'SELECT * FROM tech_companies WHERE 1=1';
const params = [];
if (search) { sql += ' AND (name LIKE ? OR industry LIKE ? OR city LIKE ? OR data_warehouse LIKE ? OR bi_tools LIKE ? OR cloud_provider LIKE ? OR ai_ml LIKE ? OR database_vendor LIKE ?)'; const s = '%'+search+'%'; params.push(s,s,s,s,s,s,s,s); }
if (industry) { sql += ' AND industry LIKE ?'; params.push('%'+industry+'%'); }
if (dw) { sql += ' AND data_warehouse LIKE ?'; params.push('%'+dw+'%'); }
if (city) { sql += ' AND city LIKE ?'; params.push('%'+city+'%'); }
sql += ' ORDER BY ' + (sort === 'name' ? 'name' : sort === 'employees' ? 'employees DESC' : 'lead_score DESC');
res.json(db.prepare(sql).all(...params));
});
// Map data
router.get('/map.json', (req, res) => {
const db = getDb();
const dcs = db.prepare('SELECT * FROM datacenters').all();
const companies = db.prepare('SELECT name, city, industry, employees, lead_score, data_warehouse FROM tech_companies').all();
const dcPoints = dcs.map(d => ({...d, lat: (CITY_COORDS[d.city]||{}).lat||52.0, lng: (CITY_COORDS[d.city]||{}).lng||5.0}));
const compPoints = companies.map(c => ({name:c.name, city:c.city, lat:(CITY_COORDS[c.city]||{}).lat||52.0, lng:(CITY_COORDS[c.city]||{}).lng||5.0, industry:c.industry, employees:c.employees, lead_score:c.lead_score}));
res.json({ datacenters: dcPoints, companies: compPoints });
});
// Filter options
router.get('/filters.json', (req, res) => {
const db = getDb();
const allDcs = db.prepare('SELECT * FROM datacenters').all();
const allCompanies = db.prepare('SELECT * FROM tech_companies').all();
res.json({
regions: [...new Set(allDcs.map(d => d.region).filter(Boolean))].sort(),
industries: [...new Set(allCompanies.map(c => c.industry).filter(Boolean))].sort(),
dwVendors: [...new Set(allCompanies.flatMap(c => (c.data_warehouse||'').split(',').map(s=>s.trim()).filter(Boolean)))].sort(),
cities: [...new Set(allCompanies.map(c => c.city).filter(Boolean))].sort(),
vendors: [...new Set(allDcs.flatMap(d => ((d.network_vendors||'')+','+(d.server_vendors||'')+','+(d.storage_vendors||'')).split(',').map(s=>s.trim()).filter(Boolean)))].sort()
});
});
module.exports = router;
+110
View File
@@ -0,0 +1,110 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
router.get('/', (req, res) => {
const db = getDb();
const devices = 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
`).all();
const connections = db.prepare(`
SELECT nc.*, c.name as client_name
FROM network_connections nc JOIN clients c ON c.id = nc.client_id
ORDER BY nc.created_at DESC
`).all();
res.render('networking-list', { devices, connections });
});
router.get('/devices/new', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('net-device-form', { device: null, clients, error: null });
});
router.post('/devices/new', (req, res) => {
const db = getDb();
const { client_id, name, device_type, model, manufacturer, ip_address, mgmt_ip, ports_count, os_version, notes, status } = req.body;
if (!name || !client_id) return res.redirect('/networking');
db.prepare('INSERT INTO network_devices (client_id, name, device_type, model, manufacturer, ip_address, mgmt_ip, ports_count, os_version, notes, status) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
.run(client_id, name, device_type, model, manufacturer, ip_address, mgmt_ip, ports_count || 24, os_version, notes, status || 'active');
res.redirect('/networking');
});
router.get('/devices/:id', (req, res) => {
const db = getDb();
const device = db.prepare('SELECT nd.*, c.name as client_name FROM network_devices nd JOIN clients c ON c.id = nd.client_id WHERE nd.id = ?').get(req.params.id);
if (!device) return res.redirect('/networking');
const connections = db.prepare(`
SELECT * FROM network_connections
WHERE client_id = ? AND ((from_type = 'network_device' AND from_id = ?) OR (to_type = 'network_device' AND to_id = ?))
ORDER BY created_at DESC
`).all(device.client_id, req.params.id, req.params.id);
res.render('net-device-detail', { device, connections });
});
router.get('/devices/:id/edit', (req, res) => {
const db = getDb();
const device = db.prepare('SELECT * FROM network_devices WHERE id = ?').get(req.params.id);
if (!device) return res.redirect('/networking');
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('net-device-form', { device, clients, error: null });
});
router.post('/devices/:id/edit', (req, res) => {
const db = getDb();
const { name, device_type, model, manufacturer, ip_address, mgmt_ip, ports_count, os_version, notes, status } = req.body;
db.prepare("UPDATE network_devices SET name=?, device_type=?, model=?, manufacturer=?, ip_address=?, mgmt_ip=?, ports_count=?, os_version=?, notes=?, status=?, updated_at=datetime('now') WHERE id=?")
.run(name, device_type, model, manufacturer, ip_address, mgmt_ip, ports_count, os_version, notes, status, req.params.id);
res.redirect('/networking/devices/' + req.params.id);
});
router.post('/devices/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM network_devices WHERE id = ?').run(req.params.id);
res.redirect('/networking');
});
router.get('/connections/new', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const netDevices = db.prepare('SELECT id, name, device_type FROM network_devices ORDER BY name').all();
const rackDevices = db.prepare('SELECT rd.id, rd.name, rd.device_type, r.name as rack_name FROM rack_devices rd JOIN racks r ON r.id = rd.rack_id ORDER BY r.name, rd.position_u').all();
res.render('net-connection-form', { connection: null, clients, netDevices, rackDevices, error: null });
});
router.post('/connections/new', (req, res) => {
const db = getDb();
const { client_id, name, from_type, from_id, from_port, to_type, to_id, to_port, media_type, speed, vlan, status, notes } = req.body;
if (!client_id) return res.redirect('/networking');
db.prepare('INSERT INTO network_connections (client_id, name, from_type, from_id, from_port, to_type, to_id, to_port, media_type, speed, vlan, status, notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)')
.run(client_id, name, from_type, from_id || null, from_port, to_type, to_id || null, to_port, media_type || 'copper', speed || '1GbE', vlan, status || 'active', notes);
res.redirect('/networking');
});
router.get('/connections/:id/edit', (req, res) => {
const db = getDb();
const connection = db.prepare('SELECT * FROM network_connections WHERE id = ?').get(req.params.id);
if (!connection) return res.redirect('/networking');
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const netDevices = db.prepare('SELECT id, name, device_type FROM network_devices ORDER BY name').all();
const rackDevices = db.prepare('SELECT rd.id, rd.name, rd.device_type, r.name as rack_name FROM rack_devices rd JOIN racks r ON r.id = rd.rack_id ORDER BY r.name, rd.position_u').all();
res.render('net-connection-form', { connection, clients, netDevices, rackDevices, error: null });
});
router.post('/connections/:id/edit', (req, res) => {
const db = getDb();
const { name, from_type, from_id, from_port, to_type, to_id, to_port, media_type, speed, vlan, status, notes } = req.body;
db.prepare('UPDATE network_connections SET name=?, from_type=?, from_id=?, from_port=?, to_type=?, to_id=?, to_port=?, media_type=?, speed=?, vlan=?, status=?, notes=? WHERE id=?')
.run(name, from_type, from_id || null, from_port, to_type, to_id || null, to_port, media_type, speed, vlan, status, notes, req.params.id);
res.redirect('/networking');
});
router.post('/connections/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM network_connections WHERE id = ?').run(req.params.id);
res.redirect('/networking');
});
module.exports = router;
+72
View File
@@ -0,0 +1,72 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
const https = require('https');
const http = require('http');
function sendSlack(webhookUrl, message) {
return new Promise((resolve) => {
if (!webhookUrl) return resolve(false);
const data = JSON.stringify({ text: message });
const url = new URL(webhookUrl);
const client = url.protocol === 'https:' ? https : http;
const req = client.request(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } }, (res) => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => resolve(true));
});
req.on('error', () => resolve(false));
req.write(data);
req.end();
});
}
function createNotification(userId, type, title, message, channel) {
const db = getDb();
const result = db.prepare('INSERT INTO notifications (user_id, type, title, message, channel) VALUES (?,?,?,?,?)')
.run(userId || null, type, title, message || '', channel || 'in-app');
return result.lastInsertRowid;
}
async function sendNotification(userId, type, title, message) {
const db = getDb();
const nid = createNotification(userId, type, title, message, 'in-app');
const settings = db.prepare('SELECT * FROM user_settings WHERE user_id = ?').get(userId);
if (settings) {
if (settings.slack_webhook && type !== 'test') {
const user = db.prepare('SELECT username FROM users WHERE id = ?').get(userId);
const msg = `[Mek-Tech] ${title}: ${message}`;
sendSlack(settings.slack_webhook, msg).then(sent => {
if (sent) db.prepare("UPDATE notifications SET sent=1, channel='slack' WHERE id=?").run(nid);
});
}
}
return nid;
}
router.get('/', (req, res) => {
const db = getDb();
const notifications = db.prepare('SELECT * FROM notifications WHERE user_id = ? OR user_id IS NULL ORDER BY created_at DESC LIMIT 100').all(req.session.userId);
const unread = notifications.filter(n => !n.read).length;
res.render('notification-list', { notifications, unread });
});
router.post('/:id/read', (req, res) => {
const db = getDb();
db.prepare("UPDATE notifications SET read=1 WHERE id=? AND (user_id=? OR user_id IS NULL)").run(req.params.id, req.session.userId);
res.redirect('/notifications');
});
router.post('/read-all', (req, res) => {
const db = getDb();
db.prepare("UPDATE notifications SET read=1 WHERE user_id=? AND read=0").run(req.session.userId);
res.redirect('/notifications');
});
router.get('/api/unread', (req, res) => {
const db = getDb();
const count = db.prepare("SELECT COUNT(*) as cnt FROM notifications WHERE (user_id=? OR user_id IS NULL) AND read=0").get(req.session.userId);
res.json({ unread: count.cnt });
});
module.exports = { router, createNotification, sendNotification, sendSlack };
+43
View File
@@ -0,0 +1,43 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const { getDb } = require('../db');
router.get('/wachtwoord-vergeten', (req, res) => {
res.render('forgot-password', { error: null, success: null });
});
router.post('/wachtwoord-vergeten', (req, res) => {
const db = getDb();
const { email } = req.body;
if (!email) return res.render('forgot-password', { error: 'Voer je e-mailadres in', success: null });
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) return res.render('forgot-password', { error: null, success: 'Als dit e-mailadres bekend is, ontvang je een reset-link' });
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 3600000).toISOString();
db.prepare('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?').run(token, expires, user.id);
console.log(`[WACHTWOORD-RESET] Link voor ${email}: ${process.env.BASE_URL || 'http://192.168.1.247:3000'}/wachtwoord-reset/${token}`);
res.render('forgot-password', { error: null, success: 'Als dit e-mailadres bekend is, ontvang je een reset-link' });
});
router.get('/wachtwoord-reset/:token', (req, res) => {
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE reset_token = ? AND reset_expires > datetime(\'now\')').get(req.params.token);
if (!user) return res.render('forgot-password', { error: 'Ongeldige of verlopen reset-link', success: null });
res.render('reset-password', { token: req.params.token, error: null });
});
router.post('/wachtwoord-reset/:token', (req, res) => {
const db = getDb();
const { password, password2 } = req.body;
if (!password || password.length < 6) return res.render('reset-password', { token: req.params.token, error: 'Wachtwoord moet minimaal 6 tekens zijn' });
if (password !== password2) return res.render('reset-password', { token: req.params.token, error: 'Wachtwoorden komen niet overeen' });
const user = db.prepare('SELECT * FROM users WHERE reset_token = ? AND reset_expires > datetime(\'now\')').get(req.params.token);
if (!user) return res.render('forgot-password', { error: 'Ongeldige of verlopen reset-link', success: null });
const hash = bcrypt.hashSync(password, 10);
db.prepare('UPDATE users SET password_hash = ?, reset_token = \'\', reset_expires = \'\' WHERE id = ?').run(hash, user.id);
res.redirect('/login?reset=1');
});
module.exports = router;
+177
View File
@@ -0,0 +1,177 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
// Initialize projects table
function initProjectsTable() {
const db = getDb();
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
client_id INTEGER,
start_date TEXT,
end_date TEXT,
status TEXT DEFAULT 'planning',
priority TEXT DEFAULT 'medium',
budget REAL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS project_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'todo',
priority TEXT DEFAULT 'medium',
assignee_id INTEGER,
start_date TEXT,
due_date TEXT,
progress INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id),
FOREIGN KEY (assignee_id) REFERENCES users(id)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS project_milestones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
name TEXT NOT NULL,
target_date TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id)
)
`);
}
router.get('/', (req, res) => {
initProjectsTable();
const db = getDb();
const tab = req.query.tab || 'kanban';
const projects = db.prepare(`
SELECT p.*, c.name as client_name,
(SELECT COUNT(*) FROM project_tasks pt WHERE pt.project_id = p.id) as task_count,
(SELECT COUNT(*) FROM project_tasks pt WHERE pt.project_id = p.id AND pt.status = 'done') as completed_tasks
FROM projects p
LEFT JOIN clients c ON c.id = p.client_id
ORDER BY p.updated_at DESC
`).all();
const projectTasks = db.prepare(`
SELECT pt.*, p.name as project_name, u.username as assignee_name
FROM project_tasks pt
LEFT JOIN projects p ON p.id = pt.project_id
LEFT JOIN users u ON u.id = pt.assignee_id
ORDER BY pt.due_date ASC
`).all();
const milestones = db.prepare(`
SELECT m.*, p.name as project_name
FROM project_milestones m
LEFT JOIN projects p ON p.id = m.project_id
ORDER BY m.target_date ASC
`).all();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const users = db.prepare('SELECT id, username FROM users ORDER BY username').all();
res.render('projects', {
tab, projects, projectTasks, milestones, clients, users
});
});
router.post('/new', (req, res) => {
initProjectsTable();
const db = getDb();
const { name, description, client_id, start_date, end_date, priority, budget } = req.body;
db.prepare(`
INSERT INTO projects (name, description, client_id, start_date, end_date, priority, budget)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(name, description, client_id, start_date, end_date, priority, budget);
res.redirect('/projects');
});
router.post('/tasks/new', (req, res) => {
initProjectsTable();
const db = getDb();
const { project_id, title, description, priority, assignee_id, start_date, due_date } = req.body;
db.prepare(`
INSERT INTO project_tasks (project_id, title, description, priority, assignee_id, start_date, due_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(project_id, title, description, priority, assignee_id, start_date, due_date);
res.redirect('/projects');
});
router.post('/tasks/:id/status', (req, res) => {
initProjectsTable();
const db = getDb();
const { status } = req.body;
const progress = status === 'done' ? 100 : status === 'in_progress' ? 50 : 0;
db.prepare(`
UPDATE project_tasks SET status = ?, progress = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(status, progress, req.params.id);
res.json({ success: true });
});
router.post('/tasks/:id/progress', (req, res) => {
initProjectsTable();
const db = getDb();
const { progress } = req.body;
db.prepare(`
UPDATE project_tasks SET progress = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(progress, req.params.id);
res.json({ success: true });
});
router.get('/:id', (req, res) => {
initProjectsTable();
const db = getDb();
const project = db.prepare(`
SELECT p.*, c.name as client_name
FROM projects p
LEFT JOIN clients c ON c.id = p.client_id
WHERE p.id = ?
`).get(req.params.id);
if (!project) return res.redirect('/projects');
const tasks = db.prepare(`
SELECT pt.*, u.username as assignee_name
FROM project_tasks pt
LEFT JOIN users u ON u.id = pt.assignee_id
WHERE pt.project_id = ?
ORDER BY pt.due_date ASC
`).all(req.params.id);
const milestones = db.prepare(`
SELECT * FROM project_milestones
WHERE project_id = ?
ORDER BY target_date ASC
`).all(req.params.id);
res.render('project-detail', { project, tasks, milestones });
});
module.exports = router;
+104
View File
@@ -0,0 +1,104 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const http = require('http');
const QS_HOST = process.env.QS_HOST || 'python-quality';
const QS_PORT = parseInt(process.env.QS_PORT || '3002');
const SHARED_UPLOAD_DIR = path.join(__dirname, '..', 'data', 'uploads');
if (!fs.existsSync(SHARED_UPLOAD_DIR)) fs.mkdirSync(SHARED_UPLOAD_DIR, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, SHARED_UPLOAD_DIR),
filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname)
});
const upload = multer({ storage, limits: { fileSize: 100 * 1024 * 1024 } });
function qsApi(path, method, body) {
return new Promise((resolve, reject) => {
const opts = { hostname: QS_HOST, port: QS_PORT, path, method, headers: { 'Content-Type': 'application/json' }, timeout: 30000 };
const req = http.request(opts, (res) => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
if (method === 'GET' && res.statusCode >= 400) return reject(new Error('offline'));
try { resolve(JSON.parse(data)); } catch(e) { resolve({ raw: data }); }
});
});
req.on('error', (e) => reject(e));
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (body) req.write(JSON.stringify(body));
req.end();
});
}
router.get('/', async (req, res) => {
let files = [];
let qsStatus = 'offline';
let services = {};
try {
const dirFiles = fs.readdirSync(SHARED_UPLOAD_DIR).filter(f => f !== '.gitkeep');
files = dirFiles.map(f => ({ file_id: f.split('.')[0], filename: f, size: fs.statSync(path.join(SHARED_UPLOAD_DIR, f)).size }));
await qsApi('/python-quality/', 'GET');
qsStatus = 'online';
} catch (e) { qsStatus = 'offline'; }
try {
const { getServicesStatus } = require('../lib/services');
services = await getServicesStatus();
} catch (e) {}
const { getDb } = require('../db');
const clients = getDb().prepare('SELECT id, name FROM clients ORDER BY name').all();
const clientId = req.query.client_id || req.session.activeClientId || null;
res.render('quality-dashboard', { files, qsStatus, services, clients, clientId, uploaded: null, profile: null, report: null, error: null });
});
router.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) return res.redirect('/quality');
try {
const resp = await qsApi('/python-quality/api/profile', 'POST', { file_id: req.file.filename.split('.')[0], shared_path: path.join(SHARED_UPLOAD_DIR, req.file.filename) });
const profile = resp.profile || resp;
const uploaded = { file_id: req.file.filename.split('.')[0], original_name: req.file.originalname, rows: profile.rows, columns: profile.columns };
res.render('quality-dashboard', { files: [], qsStatus: 'online', uploaded, profile, report: null, error: null });
} catch (e) {
res.render('quality-dashboard', { files: [], qsStatus: 'online', uploaded: null, profile: null, report: null, error: 'Kon bestand niet analyseren: ' + (e.message || 'onbekende fout') });
}
});
router.post('/quality-check/:fileId', async (req, res) => {
try {
const result = await qsApi('/python-quality/api/quality-check', 'POST', { file_id: req.params.fileId, checks: ['completeness', 'uniqueness', 'range', 'type'] });
res.json(result);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.get('/profile/:fileId', async (req, res) => {
try {
const result = await qsApi('/python-quality/api/profile', 'POST', { file_id: req.params.fileId });
res.json(result);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.get('/report/:reportId', async (req, res) => {
try {
const result = await qsApi('/python-quality/api/report/' + req.params.reportId, 'GET');
res.json(result);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/dbt/validate', async (req, res) => {
try {
const result = await qsApi('/python-quality/api/dbt/validate', 'POST', req.body);
res.json(result);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/dbt/test', async (req, res) => {
try {
const result = await qsApi('/python-quality/api/dbt/test', 'POST', req.body);
res.json(result);
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;
+257
View File
@@ -0,0 +1,257 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
const { parseDiagramJson, summarizeDiagram, rackDevicesToNodes, DIAGRAM_TYPE_LABELS, buildStudioDemo, getWorkspaceTab } = require('../lib/rack-diagram');
function loadRackWorkspace(db, rackId) {
const rack = db.prepare('SELECT r.*, c.name as client_name FROM racks r JOIN clients c ON c.id = r.client_id WHERE r.id = ?').get(rackId);
if (!rack) return null;
const rackDevices = db.prepare('SELECT * FROM rack_devices WHERE rack_id = ? ORDER BY position_u ASC').all(rackId);
const linkedDiagrams = db.prepare('SELECT id, name, diagram_type, updated_at FROM data_diagrams WHERE rack_id = ? ORDER BY updated_at DESC').all(rackId);
const flowDiagram = linkedDiagrams.find(d => d.diagram_type === 'application_flow') || null;
const landscapeDiagram = linkedDiagrams.find(d => d.diagram_type === 'application_landscape') || null;
return { rack, rackDevices, linkedDiagrams, flowDiagram, landscapeDiagram };
}
function renderArchEditor(res, opts) {
const diagram = opts.diagram || null;
const rack = opts.rack;
const workspaceTab = opts.workspaceTab || (diagram ? getWorkspaceTab(diagram.diagram_type) : opts.defaultDiagramType === 'application_landscape' ? 'landscape' : 'flow');
res.render('arch-editor', {
diagram,
clients: opts.clients,
clientId: opts.clientId || (rack ? rack.client_id : null),
rack,
rackDevices: opts.rackDevices || [],
linkedDiagrams: opts.linkedDiagrams || [],
flowDiagram: opts.flowDiagram || null,
landscapeDiagram: opts.landscapeDiagram || null,
defaultDiagramType: opts.defaultDiagramType || null,
workspaceTab,
embed: !!opts.embed,
error: opts.error || null
});
}
router.get('/', (req, res) => {
const db = getDb();
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
`).all();
res.render('racks-list', { racks });
});
router.get('/new', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('racks-form', { rack: null, clients, selected: req.query.client_id ? parseInt(req.query.client_id, 10) : null, error: null });
});
router.get('/new/:clientId', (req, res) => {
const db = getDb();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('racks-form', { rack: null, clients, selected: parseInt(req.params.clientId), error: null });
});
router.post('/new', (req, res) => {
const db = getDb();
const { client_id, name, location, datacenter, total_units, notes } = req.body;
if (!name || !client_id) return res.redirect('/racks');
db.prepare('INSERT INTO racks (client_id, name, location, datacenter, total_units, notes) VALUES (?,?,?,?,?,?)')
.run(client_id, name, location, datacenter, total_units || 42, notes);
res.redirect('/racks');
});
router.get('/:id', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.redirect('/racks');
const hardwareLibrary = require('../data/hardware-library');
if (req.query.embed === '1') {
return res.render('racks-view', {
...ws,
devices: ws.rackDevices,
hardwareLibrary,
embed: true,
workspaceTab: 'rack'
});
}
res.render('racks-view', {
rack: ws.rack,
devices: ws.rackDevices,
hardwareLibrary,
linkedDiagrams: ws.linkedDiagrams,
flowDiagram: ws.flowDiagram,
landscapeDiagram: ws.landscapeDiagram,
workspaceTab: 'rack',
embed: false
});
});
router.get('/:id/edit', (req, res) => {
const db = getDb();
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
if (!rack) return res.redirect('/racks');
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
res.render('racks-form', { rack, clients, selected: rack.client_id, error: null });
});
router.post('/:id/edit', (req, res) => {
const db = getDb();
const { name, location, datacenter, total_units, notes } = req.body;
db.prepare("UPDATE racks SET name=?, location=?, datacenter=?, total_units=?, notes=?, updated_at=datetime('now') WHERE id=?")
.run(name, location, datacenter, total_units, notes, req.params.id);
res.redirect('/racks/' + req.params.id);
});
router.post('/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM racks WHERE id = ?').run(req.params.id);
res.redirect('/racks');
});
router.post('/:id/devices', (req, res) => {
const db = getDb();
const { name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, status } = req.body;
if (!name) return res.redirect('/racks/' + req.params.id);
db.prepare('INSERT INTO rack_devices (rack_id, name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, status) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
.run(req.params.id, name, device_type || 'server', model, manufacturer, parseInt(position_u), parseInt(height_u) || 1, specs, mgmt_ip, notes, status || 'active');
res.redirect('/racks/' + req.params.id);
});
router.post('/devices/:deviceId/edit', (req, res) => {
const db = getDb();
const { name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, status } = req.body;
const device = db.prepare('SELECT rack_id FROM rack_devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.redirect('/racks');
db.prepare("UPDATE rack_devices SET name=?, device_type=?, model=?, manufacturer=?, position_u=?, height_u=?, specs=?, mgmt_ip=?, notes=?, status=?, updated_at=datetime('now') WHERE id=?")
.run(name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, status, req.params.deviceId);
res.redirect('/racks/' + device.rack_id);
});
router.post('/devices/:deviceId/delete', (req, res) => {
const db = getDb();
const device = db.prepare('SELECT rack_id FROM rack_devices WHERE id = ?').get(req.params.deviceId);
if (device) {
db.prepare('DELETE FROM rack_devices WHERE id = ?').run(req.params.deviceId);
res.redirect('/racks/' + device.rack_id);
} else {
res.redirect('/racks');
}
});
router.get('/:id/flow/new', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.redirect('/racks');
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
renderArchEditor(res, {
...ws,
clients,
diagram: null,
defaultDiagramType: 'application_flow',
workspaceTab: 'flow',
embed: req.query.embed === '1'
});
});
router.get('/:id/landscape/new', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.redirect('/racks');
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
renderArchEditor(res, {
...ws,
clients,
diagram: null,
defaultDiagramType: 'application_landscape',
workspaceTab: 'landscape',
embed: req.query.embed === '1'
});
});
router.get('/:id/diagram/:diagramId', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.redirect('/racks');
const diagram = db.prepare('SELECT d.*, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE d.id = ? AND d.rack_id = ?').get(req.params.diagramId, req.params.id);
if (!diagram) return res.redirect('/racks/' + req.params.id);
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
renderArchEditor(res, {
...ws,
clients,
diagram,
workspaceTab: getWorkspaceTab(diagram.diagram_type),
embed: req.query.embed === '1'
});
});
router.get('/:id/studio', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.redirect('/racks');
res.render('rack-studio', { ...ws, workspaceTab: 'studio' });
});
router.post('/:id/studio/demo', (req, res) => {
const db = getDb();
const ws = loadRackWorkspace(db, req.params.id);
if (!ws) return res.status(404).json({ error: 'Rack not found' });
const demo = buildStudioDemo(ws.rack, ws.rackDevices);
let flowId = ws.flowDiagram ? ws.flowDiagram.id : null;
let landscapeId = ws.landscapeDiagram ? ws.landscapeDiagram.id : null;
if (flowId) {
db.prepare("UPDATE data_diagrams SET name=?, nodes=?, edges=?, updated_at=datetime('now') WHERE id=?")
.run(demo.flow.name, demo.flow.nodes, demo.flow.edges, flowId);
} else {
const r = db.prepare('INSERT INTO data_diagrams (client_id, name, description, diagram_type, nodes, edges, rack_id) VALUES (?,?,?,?,?,?,?)')
.run(ws.rack.client_id, demo.flow.name, 'Studio demo flow', 'application_flow', demo.flow.nodes, demo.flow.edges, ws.rack.id);
flowId = r.lastInsertRowid;
}
if (landscapeId) {
db.prepare("UPDATE data_diagrams SET name=?, nodes=?, edges=?, updated_at=datetime('now') WHERE id=?")
.run(demo.landscape.name, demo.landscape.nodes, demo.landscape.edges, landscapeId);
} else {
const r = db.prepare('INSERT INTO data_diagrams (client_id, name, description, diagram_type, nodes, edges, rack_id) VALUES (?,?,?,?,?,?,?)')
.run(ws.rack.client_id, demo.landscape.name, 'Studio demo landschap', 'application_landscape', demo.landscape.nodes, demo.landscape.edges, ws.rack.id);
landscapeId = r.lastInsertRowid;
}
res.json({ success: true, flowId, landscapeId, redirect: '/racks/' + ws.rack.id + '/studio' });
});
router.get('/:id/devices.json', (req, res) => {
const db = getDb();
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
if (!rack) return res.status(404).json({ error: 'Rack not found' });
const devices = db.prepare('SELECT * FROM rack_devices WHERE rack_id = ? ORDER BY position_u ASC').all(req.params.id);
res.json({ rack, devices, nodes: rackDevicesToNodes(devices) });
});
router.get('/:id/report', (req, res) => {
const db = getDb();
const rack = db.prepare('SELECT r.*, c.name as client_name FROM racks r JOIN clients c ON c.id = r.client_id WHERE r.id = ?').get(req.params.id);
if (!rack) return res.redirect('/racks');
const devices = db.prepare('SELECT * FROM rack_devices WHERE rack_id = ? ORDER BY position_u ASC').all(req.params.id);
const diagrams = db.prepare(`
SELECT d.*, c.name as client_name FROM data_diagrams d
JOIN clients c ON c.id = d.client_id WHERE d.rack_id = ?
ORDER BY CASE d.diagram_type WHEN 'application_flow' THEN 0 WHEN 'application_landscape' THEN 1 ELSE 2 END, d.updated_at DESC
`).all(req.params.id);
const diagramSummaries = diagrams.map(d => ({
...d,
...summarizeDiagram(d),
typeLabel: DIAGRAM_TYPE_LABELS[d.diagram_type] || d.diagram_type
}));
const usedU = devices.reduce((s, d) => s + (d.height_u || 1), 0);
res.render('rack-infra-report', {
rack,
devices,
diagrams: diagramSummaries,
usedU,
typeLabels: DIAGRAM_TYPE_LABELS
});
});
module.exports = router;
+138
View File
@@ -0,0 +1,138 @@
const express = require('express');
const router = express.Router();
const questions = [
{
id: 'company',
title: 'Bedrijfsinformatie',
fields: [
{ name: 'company_name', label: 'Bedrijfsnaam', type: 'text', required: true },
{ name: 'industry', label: 'Sector/Industrie', type: 'text', required: true },
{ name: 'current_situation', label: 'Huidige situatie (uitdagingen, doelen)', type: 'textarea', required: true }
]
},
{
id: 'infrastructure',
title: 'Huidige Infrastructuur',
fields: [
{ name: 'servers', label: 'Aantal servers (fysiek/virtueel)', type: 'text' },
{ name: 'storage', label: 'Huidige opslag (TB, type: NAS/SAN/object)', type: 'text' },
{ name: 'network', label: 'Netwerk infrastructuur (switches, bandbreedte)', type: 'textarea' },
{ name: 'virtualization', label: 'Virtualisatie platform (VMware, Hyper-V, Proxmox)', type: 'text' }
]
},
{
id: 'data',
title: 'Data & Analytics',
fields: [
{ name: 'data_volume', label: 'Huidige data volume (TB/maand groei)', type: 'text' },
{ name: 'data_types', label: 'Data types (gestructureerd, ongestructureerd, streaming)', type: 'textarea' },
{ name: 'analytics_tools', label: 'Huidige analytics tools (PowerBI, Tableau, custom)', type: 'text' },
{ name: 'data_governance', label: 'Data governance / compliance eisen (AVG, HIPAA, etc)', type: 'textarea' }
]
},
{
id: 'ai',
title: 'AI & Machine Learning',
fields: [
{ name: 'ai_current', label: 'Huidige AI/ML initiatieven', type: 'textarea' },
{ name: 'ai_goals', label: 'AI doelen (predictie, NLP, computer vision, generatief)', type: 'textarea' },
{ name: 'ai_models', label: 'Gewenste modellen (LLMs, custom modellen, open-source)', type: 'text' },
{ name: 'ai_skills', label: 'Beschikbare AI/Data skills in team', type: 'text' }
]
},
{
id: 'requirements',
title: 'Technische Eisen',
fields: [
{ name: 'performance', label: 'Prestatie eisen (latency, throughput)', type: 'text' },
{ name: 'availability', label: 'Beschikbaarheid (SLA, 99.9%/99.99%)', type: 'text' },
{ name: 'security', label: 'Security eisen (encryptie, IAM, netwerk segmentatie)', type: 'textarea' },
{ name: 'budget', label: 'Budget indicatie (CAPEX/OPEX)', type: 'text' },
{ name: 'timeline', label: 'Gewenste implementatie timeline', type: 'text' }
]
}
];
router.get('/', (req, res) => {
res.render('index', { questions });
});
router.post('/report', async (req, res) => {
const answers = req.body;
const report = generateReport(answers);
res.render('report', { report, answers });
});
function generateReport(data) {
const storageTypes = (data.storage || '').toLowerCase();
const hasDatalake = storageTypes.includes('object') || storageTypes.includes('s3');
const hasHighVolume = parseInt(data.data_volume) > 10 || false;
let datalakeTier = 'Standard';
let aiTier = 'Starter';
if (hasHighVolume) datalakeTier = 'Enterprise';
if (hasDatalake) datalakeTier = 'Advanced';
const aiGoals = (data.ai_goals || '').toLowerCase();
if (aiGoals.includes('generatief') || aiGoals.includes('llm')) aiTier = 'Enterprise';
else if (aiGoals.includes('nlp') || aiGoals.includes('predictie')) aiTier = 'Advanced';
const architecture = {
datalake: {
tier: datalakeTier,
storage: datalakeTier === 'Enterprise' ? 'MinIO/Object Store (multi-region)' : 'MinIO/S3-compatible storage',
compute: datalakeTier === 'Enterprise' ? 'Kubernetes cluster (GPU nodes)' : 'Docker Swarm / Kubernetes',
catalog: 'Apache Iceberg + Hive Metastore',
processing: hasHighVolume ? 'Apache Spark structured streaming' : 'Apache Spark batch processing',
governance: 'Apache Atlas + Ranger (GDPR/HIPAA compliant)'
},
ai: {
tier: aiTier,
serving: aiTier === 'Enterprise' ? 'vLLM + SGLang (multi-GPU)' : 'Ollama / vLLM',
orchestration: aiTier === 'Enterprise' ? 'Kubeflow + MLflow' : 'MLflow',
monitoring: 'Prometheus + Grafana + Evidently AI',
models: aiTier === 'Enterprise' ? 'DeepSeek, Llama, Mixtral (8x7B+)' : 'DeepSeek-Coder, Phi-3, Qwen'
},
infrastructure: {
virtualization: data.virtualization || 'Proxmox VE / VMware vSphere',
network: 'Leaf-Spine architecture (100GbE)',
security: 'Zero Trust + microsegmentatie + HSM',
monitoring: 'Datadog / Grafana + Loki + Tempo'
}
};
const phases = [
{
name: 'Fase 1: Quick Win (0-3 maanden)',
items: [
'Inventarisatie & assessment huidige infrastructuur',
'Proof of Concept: Data lake op MinIO + Spark',
'AI readiness check en model selectie',
'Security baseline implementatie'
]
},
{
name: 'Fase 2: Foundation (3-6 maanden)',
items: [
'Lakehouse architectuur implementatie (Iceberg)',
'Data governance framework roll-out',
'Kubernetes cluster opzetten (Rancher / kubeadm)',
'CI/CD pipeline voor ML modellen (MLflow)'
]
},
{
name: 'Fase 3: AI Ops (6-12 maanden)',
items: [
'vLLM/SGLang serving voor LLMs',
'Automated model retraining & monitoring',
'Self-service data platform voor analytics',
'Cost optimization & FinOps implementatie'
]
}
];
return { architecture, phases };
}
module.exports = router;
+120
View File
@@ -0,0 +1,120 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
function initReportsTable() {
const db = getDb();
db.exec(`
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'))
)
`);
}
router.get('/', (req, res) => {
initReportsTable();
const db = getDb();
const tab = req.query.tab || 'builder';
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
let projects = [];
try { projects = db.prepare('SELECT id, name FROM projects ORDER BY name').all(); } catch (e) {}
const savedReports = db.prepare(`
SELECT r.*, c.name as client_name FROM reports r
LEFT JOIN clients c ON c.id = r.client_id ORDER BY r.created_at DESC LIMIT 20
`).all();
res.render('reports', { tab, clients, projects, savedReports });
});
router.get('/generate', async (req, res) => {
const { report_type, client_id, project_id, date_from, date_to, format } = req.query;
const db = getDb();
let data = [];
if (report_type === 'client_summary') {
data = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id) as engagement_count,
(SELECT COALESCE(SUM(i.total),0) FROM invoices i WHERE i.client_id = c.id) as total_revenue
FROM clients c WHERE (? = '' OR ? IS NULL OR c.id = ?)
`).all(client_id || '', client_id || '', client_id || '');
} else if (report_type === 'financial') {
data = db.prepare(`
SELECT i.*, c.name as client_name FROM invoices i
LEFT JOIN clients c ON c.id = i.client_id
WHERE (? = '' OR i.created_at >= ?) AND (? = '' OR i.created_at <= ?)
`).all(date_from || '', date_from || '', date_to || '', date_to || '');
} else if (report_type === 'time') {
data = db.prepare(`
SELECT t.*, c.name as client_name, e.title as engagement_title
FROM time_entries t
LEFT JOIN clients c ON c.id = t.client_id
LEFT JOIN engagements e ON e.id = t.engagement_id
ORDER BY t.date DESC LIMIT 500
`).all();
} else if (report_type === 'project_summary') {
data = db.prepare(`
SELECT p.*, c.name as client_name,
(SELECT COUNT(*) FROM project_tasks pt WHERE pt.project_id = p.id) as task_count
FROM projects p LEFT JOIN clients c ON c.id = p.client_id
WHERE (? = '' OR ? IS NULL OR p.id = ?)
`).all(project_id || '', project_id || '', project_id || '');
}
if (format === 'pdf' || format === 'excel') {
res.setHeader('Content-Type', 'application/json');
return res.json({ report_type, format, data, generated_at: new Date().toISOString(), note: 'JSON export — open in browser of gebruik Opslaan in rapporten' });
}
res.setHeader('Content-Type', 'application/json');
res.json({ report_type, data, count: data.length });
});
router.post('/generate', async (req, res) => {
const { report_type, client_id, project_id, date_from, date_to, format } = req.body;
const db = getDb();
let data = [];
if (report_type === 'client_summary') {
data = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id) as engagement_count,
(SELECT COALESCE(SUM(i.total),0) FROM invoices i WHERE i.client_id = c.id) as total_revenue
FROM clients c WHERE (? = '' OR c.id = ?)
`).all(client_id || '', client_id || '');
} else if (report_type === 'financial') {
data = db.prepare(`
SELECT i.*, c.name as client_name FROM invoices i
LEFT JOIN clients c ON c.id = i.client_id
WHERE (? = '' OR i.created_at >= ?) AND (? = '' OR i.created_at <= ?)
`).all(date_from || '', date_from || '', date_to || '', date_to || '');
}
if (format === 'pdf' || format === 'excel') {
return res.json({ data, note: 'Export naar PDF/Excel — installeer pdfkit/exceljs of gebruik JSON' });
}
res.json({ data });
});
router.post('/save', (req, res) => {
initReportsTable();
const db = getDb();
const body = req.body;
const name = body.name || 'Rapport ' + new Date().toLocaleDateString('nl-NL');
const report_type = body.report_type || (body.config && body.config.report_type) || 'client_summary';
const client_id = body.client_id || (body.config && body.config.client_id) || null;
const project_id = body.project_id || (body.config && body.config.project_id) || null;
const config = typeof body.config === 'string' ? body.config : JSON.stringify(body.config || body);
db.prepare(`INSERT INTO reports (name, report_type, client_id, project_id, config) VALUES (?, ?, ?, ?, ?)`)
.run(name, report_type, client_id, project_id, config);
if (req.headers.accept && req.headers.accept.includes('application/json')) {
return res.json({ success: true });
}
res.redirect('/reports?saved=1');
});
module.exports = router;
+24
View File
@@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
router.get('/', (req, res) => {
const db = getDb();
const q = (req.query.q || '').trim();
if (!q) return res.render('search-results', { query: q, results: { clients: [], engagements: [], tasks: [], diagrams: [], notes: [] } });
const like = `%${q}%`;
const clients = db.prepare(`SELECT id, name, industry, status FROM clients WHERE name LIKE ? OR industry LIKE ? OR contact_name LIKE ? OR contact_email LIKE ? OR notes LIKE ? LIMIT 20`).all(like, like, like, like, like);
const engagements = db.prepare(`SELECT e.id, e.title, e.type, e.status, c.name as client_name FROM engagements e JOIN clients c ON c.id = e.client_id WHERE e.title LIKE ? OR e.description LIKE ? OR e.outcome LIKE ? LIMIT 20`).all(like, like, like);
const tasks = db.prepare(`SELECT t.id, t.title, t.status, t.priority, e.title as engagement_title FROM tasks t JOIN engagements e ON e.id = t.engagement_id WHERE t.title LIKE ? OR t.description LIKE ? LIMIT 20`).all(like, like);
const diagrams = db.prepare(`SELECT d.id, d.name, d.diagram_type, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE d.name LIKE ? OR d.description LIKE ? LIMIT 20`).all(like, like);
const notes = db.prepare(`SELECT n.id, n.content, n.created_at, c.name as client_name, c.id as client_id FROM client_notes n JOIN clients c ON c.id = n.client_id WHERE n.content LIKE ? LIMIT 20`).all(like);
res.render('search-results', { query: q, results: { clients, engagements, tasks, diagrams, notes } });
});
module.exports = router;
+98
View File
@@ -0,0 +1,98 @@
const express = require('express');
const router = express.Router();
const { getSettings, updateSetting, getDb } = require('../db');
const { THEME_PRESETS, THEME_CSS_KEYS, THEME_MAIN_COLORS, THEME_COLOR_LABELS } = require('../themes');
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' }
];
function toHexForPicker(val, fallback) {
if (val && /^#[0-9a-f]{6}$/i.test(val)) return val;
return fallback || '#0a1628';
}
function getMergedThemeColors(settings) {
const preset = THEME_PRESETS[settings.theme] || THEME_PRESETS['mek-neon-blue'];
const custom = settings.theme_colors || {};
const merged = {};
THEME_CSS_KEYS.forEach(k => { merged[k] = custom[k] || preset[k] || ''; });
return { preset, custom, merged };
}
router.get('/', (req, res) => {
const settings = getSettings();
const { preset, custom, merged } = getMergedThemeColors(settings);
const useCustomColors = Object.keys(custom).length > 0 || settings.theme === 'custom';
res.render('settings', {
settings,
icons: ALL_ICONS,
themePresets: THEME_PRESETS,
themeCssKeys: THEME_CSS_KEYS,
themeMainColors: THEME_MAIN_COLORS,
themeColorLabels: THEME_COLOR_LABELS,
mergedColors: merged,
useCustomColors,
toHexForPicker,
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, slack_webhook, smtp_host, smtp_user, smtp_pass, use_custom_colors, showcase_url } = req.body;
updateSetting('brand_name', brand_name || 'Mek-Tech Consulting');
updateSetting('showcase_url', showcase_url || 'http://192.168.1.247:3010');
updateSetting('language', language || 'nl');
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');
updateSetting('slack_webhook', slack_webhook || '');
updateSetting('smtp_host', smtp_host || '');
updateSetting('smtp_user', smtp_user || '');
updateSetting('smtp_pass', smtp_pass || '');
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);
const themeColors = {};
if (use_custom_colors === '1') {
THEME_CSS_KEYS.forEach(k => {
const val = req.body['th_' + k];
if (val && String(val).trim()) themeColors[k] = String(val).trim();
});
updateSetting('theme', 'custom');
updateSetting('theme_colors', themeColors);
} else {
updateSetting('theme', theme || 'mek-neon-blue');
updateSetting('theme_colors', {});
}
res.redirect('/settings?saved=1');
});
router.post('/reset', (req, res) => {
const d = getDb();
d.prepare('DELETE FROM app_settings').run();
res.redirect('/settings');
});
module.exports = { router, THEME_PRESETS, THEME_CSS_KEYS, ALL_ICONS };
+8
View File
@@ -0,0 +1,8 @@
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.render('sizing');
});
module.exports = router;
+470
View File
@@ -0,0 +1,470 @@
:root {
--bg: #0d1117; --bg-card: #161b22; --bg-nav: #161b22;
--border: #30363d; --border-light: #21262d;
--text: #e6edf3; --text-muted: #8b949e; --text-dim: #6e7681;
--accent: #58a6ff; --accent-hover: #79c0ff;
--green: #3fb950; --green-bg: #12261c;
--red: #f85149; --red-bg: #3d1214;
--orange: #d29922; --orange-bg: #2d1f0d;
--purple: #bc8cff; --purple-bg: #21173d;
--cyan: #79c0ff; --cyan-bg: #0a1f3a;
--pink: #f778ba; --pink-bg: #2d1524;
--radius: 6px; --radius-lg: 12px;
}
* { 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 */
.navbar { background: var(--bg-nav); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100; }
.nav-inner { max-width: 960px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; padding: 0 1rem; height: 48px; }
.nav-brand { display: flex; align-items: center; gap: .4rem; color: var(--text); text-decoration: none; font-weight: 600; font-size: .9rem; }
.nav-logo { height: 32px; width: auto; }
.login-logo-img { height: 80px; width: auto; margin-bottom: .5rem; }
.nav-links { display: flex; gap: 2px; align-items: center; }
.nav-link {
padding: .3rem .6rem; border-radius: var(--radius); color: var(--text-muted);
text-decoration: none; font-size: .8rem; font-weight: 500; transition: all .15s;
white-space: nowrap;
}
.nav-link:hover { color: var(--text); background: var(--border-light); }
.nav-link.active { color: var(--text); background: var(--border-light); }
.nav-link.logout { color: var(--red); }
.nav-link.logout:hover { background: var(--red-bg); }
/* 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;
}
+41
View File
@@ -0,0 +1,41 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
router.get('/', (req, res) => {
const db = getDb();
const tasks = db.prepare(`
SELECT t.*, e.title as engagement_title, 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.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('task-list', { tasks });
});
router.post('/new', (req, res) => {
const db = getDb();
const { engagement_id, title, description, priority, due_date } = req.body;
if (!title || !title.trim()) return res.redirect('back');
db.prepare('INSERT INTO tasks (engagement_id, title, description, priority, due_date) VALUES (?, ?, ?, ?, ?)')
.run(engagement_id, title, description, priority || 'medium', due_date);
res.redirect('back');
});
router.post('/:id/status', (req, res) => {
const db = getDb();
db.prepare("UPDATE tasks SET status=?, updated_at=datetime('now') WHERE id=?")
.run(req.body.status, req.params.id);
res.redirect('back');
});
router.post('/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id);
res.redirect('back');
});
module.exports = router;
+98
View File
@@ -0,0 +1,98 @@
const express = require('express');
const router = express.Router();
const { getDb, logAudit } = require('../db');
const emailService = require('../email');
router.get('/', (req, res) => {
const db = getDb();
const entries = 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, t.created_at DESC
`).all();
const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all();
const engagements = db.prepare('SELECT e.id, e.title, c.name as client_name FROM engagements e JOIN clients c ON c.id = e.client_id ORDER BY e.title').all();
const users = db.prepare('SELECT id, username FROM users ORDER BY username').all();
const totalHours = entries.reduce((s, e) => s + e.hours, 0);
const billableHours = entries.filter(e => e.billable).reduce((s, e) => s + e.hours, 0);
res.render('time-list', { entries, clients, engagements, users, totalHours, billableHours, sent: req.query.sent === '1', error: req.query.error || null });
});
router.post('/new', (req, res) => {
const db = getDb();
const { client_id, engagement_id, date, hours, description, billable, hourly_rate } = req.body;
if (!date || !hours) return res.redirect('/time');
db.prepare('INSERT INTO time_entries (user_id, client_id, engagement_id, date, hours, description, billable, hourly_rate) VALUES (?,?,?,?,?,?,?,?)')
.run(req.session.userId, client_id || null, engagement_id || null, date, parseFloat(hours), description || '', billable === '1' ? 1 : 0, parseFloat(hourly_rate) || 150.0);
logAudit(req.session.userId, req.session.username, 'time_entry_created', 'time_entry', null, `${hours}u geregistreerd op ${date}`);
res.redirect('/time');
});
router.post('/:id/delete', (req, res) => {
const db = getDb();
db.prepare('DELETE FROM time_entries WHERE id = ?').run(req.params.id);
res.redirect('/time');
});
router.post('/create-invoice', (req, res) => {
const db = getDb();
const { client_id, date_from, date_to } = req.body;
if (!client_id) return res.redirect('/time');
const entries = db.prepare(`
SELECT t.*, e.title as engagement_title, c.name as client_name
FROM time_entries t
LEFT JOIN engagements e ON e.id = t.engagement_id
JOIN clients c ON c.id = t.client_id
WHERE t.client_id = ? AND t.billable = 1
AND (? IS NULL OR t.date >= ?) AND (? IS NULL OR t.date <= ?)
`).all(client_id, date_from || null, date_from || null, date_to || null, date_to || null);
if (entries.length === 0) return res.redirect('/time?error=nobillable');
const totalHours = entries.reduce((s, e) => s + e.hours, 0);
const rate = entries[0].hourly_rate || 150;
const subtotal = totalHours * rate;
const tax = subtotal * 0.21;
const count = db.prepare('SELECT COUNT(*) as cnt FROM invoices').get().cnt + 1;
const number = `FACT-${new Date().getFullYear()}-${String(count).padStart(4, '0')}`;
const date = new Date().toISOString().split('T')[0];
const result = db.prepare('INSERT INTO invoices (client_id, number, status, date, due_date, subtotal, tax, total, notes) VALUES (?,?,?,?,?,?,?,?,?)')
.run(client_id, number, 'draft', date, '', subtotal, tax, subtotal + tax, `Factuur gegenereerd uit ${entries.length} tijdregistraties`);
const invoiceId = result.lastInsertRowid;
const insert = db.prepare('INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)');
entries.forEach(e => {
const lineTotal = e.hours * (e.hourly_rate || rate);
insert.run(invoiceId, `${e.date} - ${e.engagement_title || 'Werkzaamheden'}: ${e.description || ''}`, e.hours, e.hourly_rate || rate, lineTotal);
});
logAudit(req.session.userId, req.session.username, 'invoice_from_time', 'invoice', invoiceId, `Factuur ${number} gemaakt uit ${entries.length} uren`);
res.redirect('/invoices/' + invoiceId);
});
router.post('/send-email', async (req, res) => {
const db = getDb();
const { client_id, date_from, date_to, to_email } = req.body;
if (!client_id) return res.redirect('/time');
const account = db.prepare('SELECT * FROM email_accounts WHERE user_id = ? AND active = 1 LIMIT 1').get(req.session.userId);
if (!account) return res.redirect('/time?error=noemail');
const entries = db.prepare(`
SELECT t.*, e.title as engagement_title, c.name as client_name
FROM time_entries t
LEFT JOIN engagements e ON e.id = t.engagement_id
JOIN clients c ON c.id = t.client_id
WHERE t.client_id = ? AND (? IS NULL OR t.date >= ?) AND (? IS NULL OR t.date <= ?)
ORDER BY t.date
`).all(client_id, date_from || null, date_from || null, date_to || null, date_to || null);
if (entries.length === 0) return res.redirect('/time?error=noentries');
const totalHours = entries.reduce((s, e) => s + e.hours, 0);
let text = `Urenoverzicht - ${entries[0].client_name}\n\nPeriode: ${date_from || 'begin'} t/m ${date_to || 'heden'}\nTotaal: ${totalHours} uur\n\n`;
entries.forEach(e => { text += `${e.date} ${e.hours}u ${e.engagement_title || ''} ${e.description || ''}\n`; });
text += `\nMek-Tech Consulting`;
const to = to_email;
try {
await emailService.sendEmail(account.id, to, `Urenoverzicht - ${entries[0].client_name}`, text, text.replace(/\n/g, '<br>'));
res.redirect('/time?sent=1');
} catch (e) { res.redirect('/time?error=' + encodeURIComponent(e.message)); }
});
module.exports = router;

Some files were not shown because too many files have changed in this diff Show More