Initial commit — Mek-Tech AI Consultancy Framework v2.0
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.env
|
||||
+19
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 RAM: 512GB Opslag: 4x 3.84TB NVMe 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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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 https://bedrijf2.nl/about 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
</script>
|
||||
<script src="/js/theme.js"></script></body></html>
|
||||
+148
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"default_provider_id": null,
|
||||
"providers": []
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"api_key": "",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"model": "qwen/qwen3-coder:free",
|
||||
"provider_type": "openrouter"
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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:'© OpenStreetMap, © 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>
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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); }
|
||||
Generated
+2853
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
flask>=3.0
|
||||
@@ -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();
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]}")
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
flask[async]>=3.0
|
||||
pydantic>=2.0
|
||||
openai>=1.12
|
||||
httpx>=0.27
|
||||
beautifulsoup4>=4.12
|
||||
python-dotenv>=1.0
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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,'"') + ' · ' + (dev.model||dev.manufacturer||dev.device_type).replace(/"/g,'"') + ' · ' + (dev.mgmt_ip||'').replace(/"/g,'"') + '">';
|
||||
_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>
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.render('sizing');
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user