Initial commit — Mek-Tech AI Consultancy Framework v2.0
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1,66 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const { getDb, logAudit, getUserSettings } = require('../db');
|
||||
|
||||
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).send('Alleen voor admins');
|
||||
next();
|
||||
}
|
||||
|
||||
router.get('/', requireAdmin, (req, res) => {
|
||||
const db = getDb();
|
||||
const users = db.prepare('SELECT id, username, email, role, created_at FROM users ORDER BY username').all();
|
||||
res.render('user-list', { users, user: { username: req.session.username } });
|
||||
});
|
||||
|
||||
router.get('/new', requireAdmin, (req, res) => {
|
||||
res.render('user-form', { editUser: null, error: null });
|
||||
});
|
||||
|
||||
router.post('/new', requireAdmin, (req, res) => {
|
||||
const db = getDb();
|
||||
const { username, password, email, role } = req.body;
|
||||
if (!username || !password) return res.render('user-form', { editUser: null, error: 'Gebruikersnaam en wachtwoord zijn verplicht' });
|
||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||
if (existing) return res.render('user-form', { editUser: null, error: 'Gebruikersnaam bestaat al' });
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
const result = db.prepare('INSERT INTO users (username, password_hash, email, role) VALUES (?, ?, ?, ?)').run(username, hash, email || '', role || 'viewer');
|
||||
db.prepare('INSERT INTO user_settings (user_id) VALUES (?)').run(result.lastInsertRowid);
|
||||
logAudit(req.session.userId, req.session.username, 'user_created', 'user', result.lastInsertRowid, `Gebruiker ${username} aangemaakt`);
|
||||
res.redirect('/users');
|
||||
});
|
||||
|
||||
router.get('/:id/edit', requireAdmin, (req, res) => {
|
||||
const db = getDb();
|
||||
const editUser = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!editUser) return res.redirect('/users');
|
||||
res.render('user-form', { editUser, error: null });
|
||||
});
|
||||
|
||||
router.post('/:id/edit', requireAdmin, (req, res) => {
|
||||
const db = getDb();
|
||||
const { username, password, email, role } = req.body;
|
||||
const editUser = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!editUser) return res.redirect('/users');
|
||||
if (password && password.trim()) {
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
db.prepare('UPDATE users SET username=?, password_hash=?, email=?, role=? WHERE id=?').run(username, hash, email || '', role, req.params.id);
|
||||
} else {
|
||||
db.prepare('UPDATE users SET username=?, email=?, role=? WHERE id=?').run(username, email || '', role, req.params.id);
|
||||
}
|
||||
logAudit(req.session.userId, req.session.username, 'user_updated', 'user', req.params.id, `Gebruiker ${username} bijgewerkt`);
|
||||
res.redirect('/users');
|
||||
});
|
||||
|
||||
router.post('/:id/delete', requireAdmin, (req, res) => {
|
||||
const db = getDb();
|
||||
if (Number(req.params.id) === Number(req.session.userId)) return res.redirect('/users');
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||
res.redirect('/users');
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user