commit 18921c021bd349b2a4e100e47f6c41324c3b63de Author: mo Date: Sun Jul 12 21:54:22 2026 +0000 Initial commit — Mek-Tech AI Consultancy Framework v2.0 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b8b83d3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +npm-debug.log +.git +.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6813870 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules/ +.env +data/*.db +data/*.db-shm +data/*.db-wal +uploads/ +public/css/ +public/js/ +python-*/__pycache__/ +python-*/.venv/ +python-*/venv/ +__pycache__/ +*.pyc +.DS_Store +*.log +data/backups/* +data/reports/* +!data/backups/.gitkeep +!data/reports/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..04d934c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM node:18-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --production +COPY . . +ENV PORT=3000 +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/advice.js b/advice.js new file mode 100644 index 0000000..b3a829c --- /dev/null +++ b/advice.js @@ -0,0 +1,191 @@ +const { getDb } = require('./db'); + +const ADVICE_ACTIONS = { + lead_followup: (id) => `/clients/${id}`, + initial_assessment: (id) => `/consulting?client_id=${id}`, + reassessment: (id) => `/consulting?client_id=${id}`, + start_engagement: (id) => `/engagements/new/${id}`, + next_phase: (id) => `/clients/${id}`, + architecture_phase: (id) => `/architecture?client_id=${id}`, + implementation_phase: (id) => `/engagements/new/${id}`, + monitoring_phase: (id) => `/engagements/new/${id}`, + training: (id) => `/engagements/new/${id}`, + overdue_tasks: (id) => `/clients/${id}`, + no_diagram: (id) => id ? `/architecture/new/${id}` : '/architecture/new', + data_quality: (id) => `/quality?client_id=${id}`, + ai_readiness: () => '/ai', + no_time_logged: (id) => `/time?client_id=${id}`, + stale_engagement: (id) => `/clients/${id}`, + leadgen_opportunity: (id) => `/?tab=leadgen`, + proposal_ready: (id) => `/consulting/report/${id}`, +}; + +function adviceAction(type, clientId, extra) { + const fn = ADVICE_ACTIONS[type]; + if (!fn) return clientId ? `/clients/${clientId}` : '/clients'; + return fn(clientId, extra); +} + +function generateAdvice(clientId) { + const db = getDb(); + const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(clientId); + if (!client) return []; + + const engagementCount = db.prepare('SELECT COUNT(*) as cnt FROM engagements WHERE client_id = ?').get(clientId); + const lastAssessment = db.prepare('SELECT * FROM assessments WHERE client_id = ? ORDER BY created_at DESC').get(clientId); + const activeEngagements = db.prepare("SELECT * FROM engagements WHERE client_id = ? AND status = 'in_progress'").all(clientId); + const completedEngagements = db.prepare("SELECT * FROM engagements WHERE client_id = ? AND status = 'completed'").all(clientId); + const diagramCount = db.prepare('SELECT COUNT(*) as cnt FROM data_diagrams WHERE client_id = ?').get(clientId); + const timeLogged = db.prepare('SELECT COUNT(*) as cnt FROM time_entries WHERE client_id = ?').get(clientId); + const existingAdvice = db.prepare("SELECT advice_type FROM advice_log WHERE client_id = ? AND dismissed = 0").all(clientId); + const existing = new Set(existingAdvice.map(a => a.advice_type)); + + const advice = []; + + function checkAdvice(type, text, priority) { + if (existing.has(type)) return; + advice.push({ + client_id: clientId, + client_name: client.name, + advice_type: type, + advice_text: text, + priority: priority || 'medium', + action_url: adviceAction(type, clientId, lastAssessment?.id), + }); + } + + if (client.status === 'lead') { + checkAdvice('lead_followup', + `${client.name} is nog een lead. Plan een intakegesprek en start het Enterprise Framework assessment.`, + 'high'); + } + + if (!lastAssessment) { + checkAdvice('initial_assessment', + `Start een eerste assessment voor ${client.name}: infra, data, AI-maturity en roadmap.`, + 'critical'); + } + + if (lastAssessment) { + const daysSince = Math.floor((Date.now() - new Date(lastAssessment.created_at).getTime()) / (1000 * 60 * 60 * 24)); + if (daysSince > 90) { + checkAdvice('reassessment', + `Laatste assessment voor ${client.name} was ${daysSince} dagen geleden. Tijd voor her-evaluatie.`, + 'medium'); + } + if (diagramCount.cnt === 0) { + checkAdvice('no_diagram', + `Assessment afgerond voor ${client.name}, maar nog geen architectuurdiagram. Maak een to-be design.`, + 'high'); + } + } + + if (client.status === 'active' && engagementCount.cnt === 0) { + checkAdvice('start_engagement', + `${client.name} is actief maar heeft geen engagements. Start een discovery- of designtraject.`, + 'high'); + } + + if (client.status === 'active' && activeEngagements.length === 0 && completedEngagements.length > 0) { + checkAdvice('next_phase', + `${client.name} heeft geen lopende projecten. Bespreek volgende fase: monitoring, AI Ops of opschaling.`, + 'high'); + } + + const hasArchitecture = [...completedEngagements, ...activeEngagements].some(e => e.type === 'architecture'); + const hasImplementation = [...completedEngagements, ...activeEngagements].some(e => e.type === 'implementation'); + const hasMonitoring = [...completedEngagements, ...activeEngagements].some(e => e.type === 'monitoring'); + const hasConsulting = [...completedEngagements, ...activeEngagements].some(e => e.type === 'consulting'); + + if (lastAssessment && !hasArchitecture && !hasConsulting) { + checkAdvice('architecture_phase', + `Assessment klaar voor ${client.name}. Start architectuur- of design engagement.`, + 'high'); + } + + if (hasArchitecture && !hasImplementation) { + checkAdvice('implementation_phase', + `Architectuur gereed voor ${client.name}. Start implementatiefase of POC.`, + 'high'); + } + + if (hasImplementation && !hasMonitoring) { + checkAdvice('monitoring_phase', + `Implementatie afgerond bij ${client.name}. Richt monitoring & observability in.`, + 'medium'); + } + + const hasTraining = [...completedEngagements, ...activeEngagements].some(e => e.type === 'training'); + if (hasImplementation && !hasTraining) { + checkAdvice('training', + `${client.name} heeft implementatie gehad. Bied training & knowledge transfer aan.`, + 'low'); + } + + if (lastAssessment && diagramCount.cnt > 0) { + checkAdvice('data_quality', + `Voer een data quality scan uit voor ${client.name} ter onderbouwing van het design.`, + 'medium'); + } + + if (lastAssessment && client.status === 'active') { + checkAdvice('ai_readiness', + `Beoordeel AI/LLM readiness voor ${client.name} en configureer de juiste provider.`, + 'low'); + } + + if (client.status === 'active' && timeLogged.cnt === 0 && engagementCount.cnt > 0) { + checkAdvice('no_time_logged', + `Nog geen uren geregistreerd voor ${client.name}. Log tijd voor facturatie & projectinzicht.`, + 'medium'); + } + + for (const eng of activeEngagements) { + const daysSinceUpdate = Math.floor((Date.now() - new Date(eng.updated_at || eng.created_at).getTime()) / (1000 * 60 * 60 * 24)); + if (daysSinceUpdate > 14) { + checkAdvice('stale_engagement', + `Engagement "${eng.title}" bij ${client.name} is ${daysSinceUpdate} dagen niet bijgewerkt. Plan een statusreview.`, + 'medium'); + break; + } + } + + const allTasks = db.prepare(` + SELECT t.* FROM tasks t + JOIN engagements e ON e.id = t.engagement_id + WHERE e.client_id = ? AND t.status IN ('todo','in_progress') + `).all(clientId); + if (allTasks.length > 0) { + const overdue = allTasks.filter(t => t.due_date && new Date(t.due_date) < new Date()); + if (overdue.length > 0) { + checkAdvice('overdue_tasks', + `${overdue.length} ta(a)k(en) voor ${client.name} zijn verlopen. Herprioriteer en communiceer.`, + 'critical'); + } + } + + if (client.status === 'lead' && !client.website) { + checkAdvice('leadgen_opportunity', + `Voeg een website toe voor ${client.name} en scan met LeadGen AI op tech signals.`, + 'low'); + } + + return advice; +} + +function syncAdviceToDb(clientId, adviceItems) { + if (!adviceItems || adviceItems.length === 0) return; + const db = getDb(); + const insert = db.prepare('INSERT INTO advice_log (client_id, advice_type, advice_text, priority) VALUES (?, ?, ?, ?)'); + for (const a of adviceItems) { + try { + insert.run(clientId, a.advice_type, a.advice_text, a.priority || 'medium'); + } catch (e) {} + } +} + +function getAdviceActionUrl(type, clientId, assessmentId) { + return adviceAction(type, clientId, assessmentId); +} + +module.exports = { generateAdvice, syncAdviceToDb, getAdviceActionUrl, ADVICE_ACTIONS }; diff --git a/ai.js b/ai.js new file mode 100644 index 0000000..62faa45 --- /dev/null +++ b/ai.js @@ -0,0 +1,265 @@ +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const http = require('http'); +const { getDb } = require('../db'); + +const AI_CONFIG_PATH = path.join(__dirname, '..', 'data', 'ai_config.json'); + +const PROVIDER_PRESETS = { + openrouter: { + label: 'OpenRouter', + base_url: 'https://openrouter.ai/api/v1', + models: ['qwen/qwen3-coder:free', 'meta-llama/llama-3.3-70b-instruct:free', 'google/gemma-3-27b-it:free', 'anthropic/claude-3.5-sonnet', 'openai/gpt-4o-mini'] + }, + openai: { + label: 'OpenAI', + base_url: 'https://api.openai.com/v1', + models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1-mini'] + }, + anthropic: { + label: 'Anthropic', + base_url: 'https://api.anthropic.com/v1', + models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'] + }, + deepseek: { + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + models: ['deepseek-chat', 'deepseek-reasoner'] + }, + groq: { + label: 'Groq', + base_url: 'https://api.groq.com/openai/v1', + models: ['llama-3.3-70b-versatile', 'mixtral-8x7b-32768'] + }, + ollama: { + label: 'Ollama (local)', + base_url: 'http://localhost:11434/v1', + models: ['llama3.2', 'mistral', 'codellama', 'qwen2.5'] + }, + azure: { + label: 'Azure OpenAI', + base_url: '', + models: ['gpt-4o', 'gpt-4o-mini'] + }, + custom: { + label: 'Custom (OpenAI-compatible)', + base_url: '', + models: [] + } +}; + +function initAiSchema() { + const db = getDb(); + db.exec(` + CREATE TABLE IF NOT EXISTS ai_providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + provider_type TEXT NOT NULL DEFAULT 'openrouter', + api_key TEXT DEFAULT '', + base_url TEXT DEFAULT '', + default_model TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + is_default INTEGER DEFAULT 0, + extra_headers TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS integrations ( + id TEXT PRIMARY KEY, + name TEXT DEFAULT '', + api_key TEXT DEFAULT '', + webhook_url TEXT DEFAULT '', + config TEXT DEFAULT '{}', + status TEXT DEFAULT 'inactive', + created_at TEXT DEFAULT (datetime('now')) + ); + `); + + const count = db.prepare('SELECT COUNT(*) as c FROM ai_providers').get().c; + if (count === 0 && process.env.DEEPSEEK_API_KEY) { + db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_default) + VALUES (?, ?, ?, ?, ?, 1)`).run( + 'OpenRouter Default', 'openrouter', process.env.DEEPSEEK_API_KEY, + 'https://openrouter.ai/api/v1', 'qwen/qwen3-coder:free' + ); + } +} + +let _aiSchemaReady = false; +function ensureAiSchema() { + if (_aiSchemaReady) return; + initAiSchema(); + _aiSchemaReady = true; +} + +function getProviders() { + ensureAiSchema(); + return getDb().prepare('SELECT id, name, provider_type, base_url, default_model, is_active, is_default, created_at, updated_at FROM ai_providers ORDER BY is_default DESC, name').all(); +} + +function getProvider(id) { + ensureAiSchema(); + return getDb().prepare('SELECT * FROM ai_providers WHERE id = ?').get(id); +} + +function getDefaultProvider() { + ensureAiSchema(); + let p = getDb().prepare('SELECT * FROM ai_providers WHERE is_default = 1 AND is_active = 1 LIMIT 1').get(); + if (!p) p = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1 ORDER BY id LIMIT 1').get(); + return p; +} + +function saveProvider(data) { + ensureAiSchema(); + const db = getDb(); + if (data.is_default) { + db.prepare('UPDATE ai_providers SET is_default = 0').run(); + } + if (data.id) { + db.prepare(`UPDATE ai_providers SET name=?, provider_type=?, api_key=?, base_url=?, default_model=?, + is_active=?, is_default=?, updated_at=datetime('now') WHERE id=?`).run( + data.name, data.provider_type, data.api_key || '', data.base_url || '', + data.default_model || '', data.is_active ? 1 : 0, data.is_default ? 1 : 0, data.id + ); + syncAiConfigFile(); + return data.id; + } + const r = db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_active, is_default) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run( + data.name, data.provider_type, data.api_key || '', data.base_url || '', + data.default_model || '', data.is_active !== false ? 1 : 0, data.is_default ? 1 : 0 + ); + syncAiConfigFile(); + return r.lastInsertRowid; +} + +function deleteProvider(id) { + ensureAiSchema(); + getDb().prepare('DELETE FROM ai_providers WHERE id = ?').run(id); + syncAiConfigFile(); +} + +function syncAiConfigFile() { + ensureAiSchema(); + const providers = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1').all(); + const defaultProvider = providers.find(p => p.is_default) || providers[0] || null; + const config = { + default_provider_id: defaultProvider?.id || null, + providers: providers.map(p => ({ + id: p.id, + name: p.name, + provider_type: p.provider_type, + api_key: p.api_key, + base_url: p.base_url || PROVIDER_PRESETS[p.provider_type]?.base_url || '', + default_model: p.default_model, + is_default: !!p.is_default + })) + }; + const dir = path.dirname(AI_CONFIG_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(AI_CONFIG_PATH, JSON.stringify(config, null, 2)); + + const leadgenConfig = { + api_key: defaultProvider?.api_key || process.env.DEEPSEEK_API_KEY || '', + base_url: defaultProvider?.base_url || 'https://openrouter.ai/api/v1', + model: defaultProvider?.default_model || 'qwen/qwen3-coder:free', + provider_type: defaultProvider?.provider_type || 'openrouter' + }; + fs.writeFileSync(path.join(dir, 'leadgen_config.json'), JSON.stringify(leadgenConfig, null, 2)); +} + +function maskKey(key) { + if (!key || key.length < 8) return key ? '••••••••' : ''; + return key.slice(0, 6) + '••••' + key.slice(-4); +} + +function chatCompletion(messages, options = {}) { + return new Promise((resolve, reject) => { + const provider = options.providerId + ? getProvider(options.providerId) + : getDefaultProvider(); + if (!provider || !provider.api_key) { + return reject(new Error('Geen actieve AI provider geconfigureerd. Ga naar /ai om een provider toe te voegen.')); + } + + const preset = PROVIDER_PRESETS[provider.provider_type] || PROVIDER_PRESETS.custom; + const baseUrl = provider.base_url || preset.base_url; + const model = options.model || provider.default_model || preset.models[0]; + + let url; + try { + url = new URL('/chat/completions', baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'); + } catch (e) { + return reject(new Error('Ongeldige base URL: ' + baseUrl)); + } + + const body = JSON.stringify({ + model, + messages, + temperature: options.temperature ?? 0.7, + max_tokens: options.max_tokens ?? 4096 + }); + + const isHttps = url.protocol === 'https:'; + const lib = isHttps ? https : http; + const headers = { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + }; + + if (provider.provider_type === 'anthropic') { + headers['x-api-key'] = provider.api_key; + headers['anthropic-version'] = '2023-06-01'; + } else { + headers['Authorization'] = `Bearer ${provider.api_key}`; + } + if (provider.provider_type === 'openrouter') { + headers['HTTP-Referer'] = process.env.BASE_URL || 'http://localhost:3000'; + headers['X-Title'] = 'Mek-Tech Platform'; + } + + const reqOpts = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname, + method: 'POST', + headers, + timeout: 120000 + }; + + const req = lib.request(reqOpts, (res) => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + if (res.statusCode >= 400) { + reject(new Error(parsed.error?.message || parsed.message || data.slice(0, 200))); + } else { + resolve(parsed); + } + } catch (e) { + reject(new Error('AI response parse error: ' + data.slice(0, 200))); + } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('AI request timeout')); }); + req.write(body); + req.end(); + }); +} + +module.exports = { + PROVIDER_PRESETS, + initAiSchema: ensureAiSchema, + getProviders, + getProvider, + getDefaultProvider, + saveProvider, + deleteProvider, + syncAiConfigFile, + maskKey, + chatCompletion +}; diff --git a/arch-editor.ejs b/arch-editor.ejs new file mode 100644 index 0000000..969cb70 --- /dev/null +++ b/arch-editor.ejs @@ -0,0 +1,953 @@ + + + + + +<%= diagram ? diagram.name : 'Nieuw Diagram' %> — Mek-Tech Architectuur + + + + + + + + + +<% if (typeof rack !== 'undefined' && rack && !(typeof embed !== 'undefined' && embed)) { %> +<%- include('partials/rack-workspace-nav', { rack, flowDiagram, landscapeDiagram, workspaceTab: typeof workspaceTab !== 'undefined' ? workspaceTab : 'flow' }) %> +<% } %> +
+ + Mek-Tech + Architectuur Designerv2 + + +
+ +<% if (typeof rack !== 'undefined' && rack) { %> +
+
+ Rack: <%= rack.name %> · <%= rack.client_name %> + <%= (typeof rackDevices !== 'undefined' ? rackDevices.length : 0) %> devices gekoppeld +
+
+ ← Terug naar Rack + Studio + Rapport +
+
+<% } %> + +
+
+
+

Componenten

+ +
+ + + + + + + + + +
+ <% if (typeof rack !== 'undefined' && rack) { %> +
+
Flow filters
+
+ + + + + +
+ + +
+ <% } %> +
+
+
Data Bronnen
+
DB Database
+
API API Service
+
IoT IoT Sensor
+
ST Streaming
+
SAP SAP HANA
+
OR Oracle DB
+ +
Ingest & Pipeline
+
K Kafka
+
AB Airbyte
+
SF Spark/Flink
+
CDC Debezium/CDC
+
NF NiFi
+
DMS AWS DMS
+ +
Opslag & Storage
+
M MinIO / S3
+
IC Iceberg
+
DW Data Warehouse
+
LH Data Lakehouse
+
HN HPE Nimble
+
3P HPE 3PAR
+
PS Dell PowerStore
+
PSC Dell PowerScale
+
NA NetApp AFF/FAS
+
PU Pure Storage
+ +
Compute / Servers
+
HP HPE ProLiant DL
+
HPM HPE ProLiant ML
+
SYN HPE Synergy
+
DE Dell PowerEdge
+
MX Dell PowerEdge MX
+
LE Lenovo ThinkSystem
+
TA Lenovo ThinkAgile
+
SM Supermicro SYS
+
UCS Cisco UCS
+
VM VMware vSphere
+ +
Netwerk
+
CAT Cisco Catalyst
+
NX Cisco Nexus
+
JEX Juniper EX
+
JQ Juniper QFX
+
SRX Juniper SRX
+
AR Arista EOS
+
FG Fortinet FortiGate
+
F5 F5 BIG-IP
+
PA Palo Alto
+ +
Compute / Data Platform
+
SP Apache Spark
+
TR Trino / Presto
+
K8 Kubernetes
+
DBT dbt
+
HD Hadoop Cluster
+
H SAP HANA Platform
+ +
AI / ML
+
O Ollama / vLLM
+
MF MLflow
+
VS Vector Store
+
MS Model Serving
+
NV NVIDIA DGX
+ +
Microservices & Apps
+
NJS Node.js App
+
PY Python App
+
JV Java / Spring Boot
+
GO Golang App
+
DN .NET App
+
RE React / Frontend
+
μS Microservice
+
CT Container / Docker
+
λ Serverless Function
+ +
API & Gateway
+
GW API Gateway
+
REST REST API
+
GQL GraphQL API
+
gRPC gRPC Service
+
IN K8s Ingress
+
RP Reverse Proxy
+
SM Service Mesh
+ +
Messaging & Queue
+
KQ Kafka / Confluent
+
RM RabbitMQ
+
N NATS / Pulsar
+
RD Redis Pub/Sub
+
MQ MQTT Broker
+
SQS AWS SQS / SNS
+ +
Databases & Cache
+
PG PostgreSQL
+
MY MySQL / MariaDB
+
MD MongoDB
+
RE Redis Cache
+
ES Elasticsearch
+
CA Cassandra / Scylla
+
CH ClickHouse
+
DY DynamoDB / Cosmos
+ +
CI/CD & DevOps
+
GH GitHub Actions
+
GL GitLab CI/CD
+
JK Jenkins
+
AR ArgoCD
+
TF Terraform
+
DK Docker Registry
+
HL Helm Charts
+ +
Observability
+
PR Prometheus
+
GF Grafana
+
LO Loki / Logs
+
TP Tempo / Tracing
+
DD Datadog
+
OT OpenTelemetry
+
ELK ELK Stack
+ +
Security
+
IAM IAM / Auth
+
V HashiCorp Vault
+
G Grafana
+ +
Infrastructure
+
RK Server Rack
+
UPS APC UPS
+
PDU PDU / Stroom
+
AC Koeling / AC
+
PP Patch Panel
+
+
0 componenten · 0 connecties
+
+ +
+
+
+ + + +
+
+
+ + +
+
+
+ Nieuwe lijnen: + + + + +
+
+
+ + + + + +
+
+📄 Rapport + +
+ +
+
+ +

Sleep componenten hierheen

+ Verbind nodes via de poorten aan de zijkanten +
+
+ + + + + + +
+
+
100%
+
+
+ +
+

Eigenschappen

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + diff --git a/arch-list.ejs b/arch-list.ejs new file mode 100644 index 0000000..fe9bb1e --- /dev/null +++ b/arch-list.ejs @@ -0,0 +1,129 @@ + + + + + + Architectuur Diagrammen — Mek-Tech + + + + + + + + + + +
+
+

Architectuur Diagrammen

+ Nieuw Diagram +
+ +
+
+ + + + <%= diagrams.length %> diagram<%= diagrams.length !== 1 ? 'men' : '' %> + <% if (filters.search || filters.client_id || filters.diagram_type) { %> + Wis filters + <% } %> +
+
+ + <% if (diagrams.length === 0) { %> +
+ +

Geen diagrammen gevonden

+ <% if (filters.search || filters.client_id || filters.diagram_type) { %> +

Probeer andere filters of wis alle filters.

+ <% } else { %> +

Maak je eerste architectuur-diagram om data pipelines, netwerken en systemen te ontwerpen.

+ Nieuw Diagram + <% } %> +
+ <% } else { %> + + <% } %> +
+ + + diff --git a/architecture.js b/architecture.js new file mode 100644 index 0000000..87b7cef --- /dev/null +++ b/architecture.js @@ -0,0 +1,164 @@ +const express = require('express'); +const router = express.Router(); +const { getDb } = require('../db'); +const { parseDiagramJson, DIAGRAM_TYPE_LABELS } = require('../lib/rack-diagram'); + +router.get('/designer', (req, res) => { + const db = getDb(); + const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all(); + const clientId = req.query.client_id || req.session.activeClientId || null; + const host = process.env.BASE_URL || `http://${req.hostname}:3000`; + const designerBase = host.replace(':3000', ':3001') + '/python-editor/'; + res.render('designer-embed', { clients, clientId, designerUrl: designerBase }); +}); + +router.get('/', (req, res) => { + const db = getDb(); + const { search, client_id, diagram_type } = req.query; + + let sql = `SELECT d.*, c.name as client_name, + json_array_length(d.nodes) as node_count + FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE 1=1`; + const params = []; + + if (search) { + sql += ` AND (d.name LIKE ? OR d.description LIKE ?)`; + params.push(`%${search}%`, `%${search}%`); + } + if (client_id) { + sql += ` AND d.client_id = ?`; + params.push(client_id); + } + if (diagram_type) { + sql += ` AND d.diagram_type = ?`; + params.push(diagram_type); + } + + sql += ` ORDER BY d.updated_at DESC`; + + const diagrams = db.prepare(sql).all(...params); + const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all(); + res.render('arch-list', { diagrams, clients, filters: req.query }); +}); + +router.get('/new', (req, res) => { + const db = getDb(); + const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all(); + res.render('arch-editor', { diagram: null, clients, clientId: null, rack: null, rackDevices: [], linkedDiagrams: [], defaultDiagramType: null, error: null }); +}); + +router.get('/new/:clientId', (req, res) => { + const db = getDb(); + const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all(); + res.render('arch-editor', { diagram: null, clients, clientId: parseInt(req.params.clientId), rack: null, rackDevices: [], linkedDiagrams: [], defaultDiagramType: null, error: null }); +}); + +router.post('/save', (req, res) => { + const db = getDb(); + const { id, client_id, name, description, diagram_type, nodes, edges, rack_id } = req.body; + if (!name || !client_id) return res.status(400).json({ error: 'Name and client required' }); + + if (id) { + db.prepare("UPDATE data_diagrams SET name=?, description=?, diagram_type=?, nodes=?, edges=?, rack_id=?, updated_at=datetime('now') WHERE id=?") + .run(name, description, diagram_type || 'data_architecture', nodes || '[]', edges || '[]', rack_id || null, id); + res.json({ id: parseInt(id, 10) }); + } else { + const result = db.prepare('INSERT INTO data_diagrams (client_id, name, description, diagram_type, nodes, edges, rack_id) VALUES (?,?,?,?,?,?,?)') + .run(client_id, name, description, diagram_type || 'data_architecture', nodes || '[]', edges || '[]', rack_id || null); + res.json({ id: result.lastInsertRowid }); + } +}); + +router.get('/:id', (req, res) => { + const db = getDb(); + const diagram = db.prepare('SELECT d.*, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE d.id = ?').get(req.params.id); + if (!diagram) return res.redirect('/architecture'); + if (diagram.rack_id && !req.query.embed && req.query.stay !== '1') { + return res.redirect('/racks/' + diagram.rack_id + '/diagram/' + diagram.id); + } + const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all(); + let rack = null; + let rackDevices = []; + let linkedDiagrams = []; + let flowDiagram = null; + let landscapeDiagram = null; + if (diagram.rack_id) { + rack = db.prepare('SELECT r.*, c.name as client_name FROM racks r JOIN clients c ON c.id = r.client_id WHERE r.id = ?').get(diagram.rack_id); + rackDevices = db.prepare('SELECT * FROM rack_devices WHERE rack_id = ? ORDER BY position_u ASC').all(diagram.rack_id); + linkedDiagrams = db.prepare('SELECT id, name, diagram_type FROM data_diagrams WHERE rack_id = ?').all(diagram.rack_id); + flowDiagram = linkedDiagrams.find(d => d.diagram_type === 'application_flow') || null; + landscapeDiagram = linkedDiagrams.find(d => d.diagram_type === 'application_landscape') || null; + } + const { getWorkspaceTab } = require('../lib/rack-diagram'); + res.render('arch-editor', { + diagram, + clients, + clientId: diagram.client_id, + rack, + rackDevices, + linkedDiagrams, + flowDiagram, + landscapeDiagram, + defaultDiagramType: null, + workspaceTab: rack ? getWorkspaceTab(diagram.diagram_type) : null, + embed: req.query.embed === '1', + error: null + }); +}); + +router.post('/:id/delete', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM data_diagrams WHERE id = ?').run(req.params.id); + res.redirect('/architecture'); +}); + +router.get('/report/:id', (req, res) => { + const db = getDb(); + const diagram = db.prepare('SELECT d.*, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE d.id = ?').get(req.params.id); + if (!diagram) return res.redirect('/architecture'); + if (diagram.rack_id) return res.redirect('/racks/' + diagram.rack_id + '/report'); + + let nodes = JSON.parse(diagram.nodes); + let edges = JSON.parse(diagram.edges); + + if (!Array.isArray(nodes) && nodes && nodes.nodes) { edges = nodes.edges || []; nodes = nodes.nodes || []; } + if (!Array.isArray(edges) && edges && edges.nodes) { nodes = edges.nodes || []; edges = edges.edges || []; } + + const typeCount = {}; + const vendorCount = {}; + nodes.forEach(n => { + typeCount[n.type ? n.type.split('-')[0] : 'other'] = (typeCount[n.type ? n.type.split('-')[0] : 'other'] || 0) + 1; + if (n.vendor) vendorCount[n.vendor] = (vendorCount[n.vendor] || 0) + 1; + }); + + res.render('arch-report', { diagram, nodes, edges, typeCount, vendorCount }); +}); + +router.get('/render/:id', (req, res) => { + const db = getDb(); + const diagram = db.prepare('SELECT * FROM data_diagrams WHERE id = ?').get(req.params.id); + if (!diagram) return res.status(404).json({ error: 'not found' }); + + let nodes = JSON.parse(diagram.nodes); + let edges = JSON.parse(diagram.edges); + + // Handle legacy Python-editor format: nodes/edges columns store {"nodes":[...],"edges":[...]} + if (!Array.isArray(nodes) && nodes && nodes.nodes) { + edges = nodes.edges || []; + nodes = nodes.nodes || []; + } + if (!Array.isArray(edges) && edges && edges.nodes) { + nodes = edges.nodes || []; + edges = edges.edges || []; + } + + res.json({ + name: diagram.name, + description: diagram.description, + diagram_type: diagram.diagram_type, + nodes, + edges + }); +}); + +module.exports = router; diff --git a/backup.ejs b/backup.ejs new file mode 100644 index 0000000..fa3a6cf --- /dev/null +++ b/backup.ejs @@ -0,0 +1,129 @@ + + + + + +Backup & Herstel — Mek-Tech + + + + + + + + + + +
+ Terug naar instellingen +

Backup & Herstel

+

Volledige backup van database, sessies, uploads en alle cliëntdata.

+ +<% if (created) { %>
Backup succesvol aangemaakt op de server.
<% } %> +<% if (restored) { %>
Herstel voltooid. Vernieuw het dashboard — bij problemen herstart de container.
<% } %> +<% if (deleted) { %>
Backup verwijderd.
<% } %> +<% if (error) { %>
<%= error %>
<% } %> + +
+

Huidige data

+
+
<%= stats.counts.clients || 0 %>
Cliënten
+
<%= stats.counts.engagements || 0 %>
Engagements
+
<%= stats.counts.racks || 0 %>
Racks
+
<%= stats.counts.data_diagrams || 0 %>
Diagrammen
+
<%= stats.counts.projects || 0 %>
Projecten
+
<%= stats.counts.invoices || 0 %>
Facturen
+
+

+Database: <%= formatBytes(stats.dbSizeBytes) %> +<% if (stats.uploadFiles) { %> · Uploads: <%= stats.uploadFiles %> bestanden (<%= formatBytes(stats.uploadsBytes) %>)<% } %> +<% if (diskFree) { %> · Schijf vrij: <%= diskFree %><% } %> +

+
+ +
+

Nieuwe backup maken

+

Maakt een .tar.gz archief met:

+
    +
  • consulting.db — alle cliënten, racks, diagrammen, facturen, instellingen
  • +
  • sessions.db — actieve sessies
  • +
  • uploads/ — geüploade data quality bestanden
  • +
+
+
+
+
+
+ +
+

Backups op server (<%= backups.length %>)

+<% if (backups.length === 0) { %> +

Nog geen backups opgeslagen op de server.

+<% } else { %> + + + +<% backups.forEach(function(b) { %> + + + + + + +<% }) %> + +
BestandGrootteDatum
<%= b.filename %><%= b.sizeLabel %><%= new Date(b.created_at).toLocaleString('nl-NL') %> +Download +
+ +
+
+ +
+
+<% } %> +
+ +
+

Herstellen vanaf bestand

+
+

Let op: Herstellen overschrijft alle huidige data. Vóór herstel wordt automatisch een veiligheidsbackup gemaakt op de server.

+
+ + +
+
+
+
+ + + + diff --git a/backup.js b/backup.js new file mode 100644 index 0000000..78972d1 --- /dev/null +++ b/backup.js @@ -0,0 +1,279 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execSync } = require('child_process'); +const { getDb } = require('../db'); + +const APP_VERSION = '2.0.0'; +const MAX_STORED_BACKUPS = 20; + +function getPaths() { + const root = path.join(__dirname, '..'); + const dataDir = process.env.DB_PATH + ? path.dirname(path.resolve(process.env.DB_PATH)) + : path.join(root, 'data'); + return { + root, + dataDir, + dbPath: process.env.DB_PATH || path.join(dataDir, 'consulting.db'), + sessionsPath: path.join(dataDir, 'sessions.db'), + uploadsDir: path.join(dataDir, 'uploads'), + backupsDir: path.join(dataDir, 'backups') + }; +} + +function ensureBackupsDir() { + const { backupsDir } = getPaths(); + if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true }); + return backupsDir; +} + +function checkpointDatabases() { + try { + const db = getDb(); + db.pragma('wal_checkpoint(TRUNCATE)'); + } catch (e) { /* ignore */ } + const { sessionsPath } = getPaths(); + if (fs.existsSync(sessionsPath)) { + try { + const Database = require('better-sqlite3'); + const sdb = new Database(sessionsPath); + sdb.pragma('wal_checkpoint(TRUNCATE)'); + sdb.close(); + } catch (e) { /* ignore */ } + } +} + +function collectStats() { + const db = getDb(); + const tables = ['clients', 'engagements', 'racks', 'rack_devices', 'data_diagrams', 'projects', 'invoices', 'assessments', 'users']; + const counts = {}; + for (const t of tables) { + try { + counts[t] = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c; + } catch (e) { + counts[t] = 0; + } + } + const { dbPath, sessionsPath, uploadsDir, dataDir } = getPaths(); + let uploadsBytes = 0; + let uploadFiles = 0; + if (fs.existsSync(uploadsDir)) { + const walk = (dir) => { + for (const name of fs.readdirSync(dir)) { + const p = path.join(dir, name); + const st = fs.statSync(p); + if (st.isDirectory()) walk(p); + else { uploadsBytes += st.size; uploadFiles++; } + } + }; + walk(uploadsDir); + } + return { + counts, + dbSizeBytes: fs.existsSync(dbPath) ? fs.statSync(dbPath).size : 0, + sessionsSizeBytes: fs.existsSync(sessionsPath) ? fs.statSync(sessionsPath).size : 0, + uploadsBytes, + uploadFiles, + dataDir + }; +} + +function formatBytes(n) { + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; + return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'; +} + +function timestampLabel() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; +} + +function safeBackupName(name) { + return /^mek-tech-backup-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}\.tar\.gz$/.test(name); +} + +function pruneOldBackups() { + const list = listBackups(); + if (list.length <= MAX_STORED_BACKUPS) return; + const toRemove = list.slice(MAX_STORED_BACKUPS); + for (const b of toRemove) { + try { fs.unlinkSync(b.path); } catch (e) { /* ignore */ } + } +} + +function createBackup() { + ensureBackupsDir(); + checkpointDatabases(); + + const paths = getPaths(); + const stats = collectStats(); + const label = timestampLabel(); + const bundleName = `mek-tech-backup-${label}`; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-backup-')); + const bundleDir = path.join(tmpDir, bundleName); + + try { + fs.mkdirSync(bundleDir, { recursive: true }); + + const manifest = { + app: 'mek-tech-consulting-framework', + version: APP_VERSION, + created_at: new Date().toISOString(), + stats: stats.counts, + includes: [] + }; + + if (fs.existsSync(paths.dbPath)) { + fs.copyFileSync(paths.dbPath, path.join(bundleDir, 'consulting.db')); + manifest.includes.push('consulting.db'); + } + if (fs.existsSync(paths.sessionsPath)) { + fs.copyFileSync(paths.sessionsPath, path.join(bundleDir, 'sessions.db')); + manifest.includes.push('sessions.db'); + } + if (fs.existsSync(paths.uploadsDir)) { + copyDirSync(paths.uploadsDir, path.join(bundleDir, 'uploads')); + manifest.includes.push('uploads/'); + } + + fs.writeFileSync(path.join(bundleDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + + const archiveName = `${bundleName}.tar.gz`; + const archivePath = path.join(paths.backupsDir, archiveName); + execSync(`tar -czf "${archivePath}" -C "${tmpDir}" "${bundleName}"`, { stdio: 'pipe' }); + + const size = fs.statSync(archivePath).size; + pruneOldBackups(); + + return { + filename: archiveName, + path: archivePath, + size, + sizeLabel: formatBytes(size), + created_at: manifest.created_at, + stats: stats.counts + }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +function copyDirSync(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const name of fs.readdirSync(src)) { + const s = path.join(src, name); + const d = path.join(dest, name); + if (fs.statSync(s).isDirectory()) copyDirSync(s, d); + else fs.copyFileSync(s, d); + } +} + +function listBackups() { + ensureBackupsDir(); + const { backupsDir } = getPaths(); + if (!fs.existsSync(backupsDir)) return []; + return fs.readdirSync(backupsDir) + .filter(f => f.endsWith('.tar.gz') && safeBackupName(f)) + .map(f => { + const p = path.join(backupsDir, f); + const st = fs.statSync(p); + return { + filename: f, + path: p, + size: st.size, + sizeLabel: formatBytes(st.size), + created_at: st.mtime.toISOString() + }; + }) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); +} + +function getBackupPath(filename) { + if (!safeBackupName(filename)) return null; + const p = path.join(getPaths().backupsDir, filename); + return fs.existsSync(p) ? p : null; +} + +function deleteBackup(filename) { + const p = getBackupPath(filename); + if (!p) return false; + fs.unlinkSync(p); + return true; +} + +function validateArchive(archivePath) { + if (!fs.existsSync(archivePath)) throw new Error('Backupbestand niet gevonden'); + const listing = execSync(`tar -tzf "${archivePath}"`, { encoding: 'utf8' }); + if (!listing.includes('manifest.json')) throw new Error('Ongeldig backup: manifest.json ontbreekt'); + if (!listing.includes('consulting.db')) throw new Error('Ongeldig backup: consulting.db ontbreekt'); + return listing; +} + +function restoreBackup(archivePath) { + validateArchive(archivePath); + + const auto = createBackup(); + const paths = getPaths(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-restore-')); + + try { + execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { stdio: 'pipe' }); + const entries = fs.readdirSync(tmpDir); + const bundleDir = entries.length === 1 ? path.join(tmpDir, entries[0]) : tmpDir; + + const manifestPath = path.join(bundleDir, 'manifest.json'); + if (!fs.existsSync(manifestPath)) throw new Error('manifest.json niet gevonden in backup'); + + checkpointDatabases(); + + const dbDest = paths.dbPath; + const dbSrc = path.join(bundleDir, 'consulting.db'); + if (fs.existsSync(dbSrc)) { + for (const ext of ['-wal', '-shm']) { + const sidecar = dbDest + ext; + if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar); + } + fs.copyFileSync(dbSrc, dbDest); + } + + const sessSrc = path.join(bundleDir, 'sessions.db'); + if (fs.existsSync(sessSrc)) { + for (const ext of ['-wal', '-shm']) { + const sidecar = paths.sessionsPath + ext; + if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar); + } + fs.copyFileSync(sessSrc, paths.sessionsPath); + } + + const uploadsSrc = path.join(bundleDir, 'uploads'); + if (fs.existsSync(uploadsSrc)) { + if (fs.existsSync(paths.uploadsDir)) fs.rmSync(paths.uploadsDir, { recursive: true, force: true }); + copyDirSync(uploadsSrc, paths.uploadsDir); + } + + return { + success: true, + preRestoreBackup: auto.filename, + restored_at: new Date().toISOString() + }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +module.exports = { + getPaths, + collectStats, + formatBytes, + createBackup, + listBackups, + getBackupPath, + deleteBackup, + restoreBackup, + safeBackupName, + APP_VERSION +}; diff --git a/crm.js b/crm.js new file mode 100644 index 0000000..fad10a6 --- /dev/null +++ b/crm.js @@ -0,0 +1,50 @@ +const { getDb } = require('../db'); + +function crmContextMiddleware(req, res, next) { + const db = getDb(); + const qClientId = req.query.client_id || req.query.clientId; + if (qClientId && req.session) { + req.session.activeClientId = parseInt(qClientId, 10) || null; + } + + const activeClientId = (req.session && req.session.activeClientId) || null; + let activeClient = null; + if (activeClientId) { + activeClient = db.prepare('SELECT id, name, status, industry FROM clients WHERE id = ?').get(activeClientId); + if (!activeClient) req.session.activeClientId = null; + } + + const allClients = db.prepare('SELECT id, name, status FROM clients ORDER BY name').all(); + + res.locals.activeClient = activeClient; + res.locals.activeClientId = activeClient ? activeClient.id : null; + res.locals.allClients = allClients; + res.locals.clientQuery = (path) => { + if (!activeClient) return path; + const sep = path.includes('?') ? '&' : '?'; + return `${path}${sep}client_id=${activeClient.id}`; + }; + + next(); +} + +function getClientHubLinks(clientId) { + const id = clientId; + const q = `?client_id=${id}`; + return [ + { href: `/clients/${id}`, icon: 'eye', label: 'Overzicht' }, + { href: `/engagements/new${q}`, icon: 'briefcase', label: 'Engagement' }, + { href: `/consulting${q}`, icon: 'clipboard-list', label: 'Framework' }, + { href: `/assess/${id}`, icon: 'file-search', label: 'Assessment' }, + { href: `/architecture/new/${id}`, icon: 'network', label: 'Design' }, + { href: `/architecture/designer${q}`, icon: 'pen-tool', label: 'Pro Designer' }, + { href: `/quality${q}`, icon: 'shield-check', label: 'Data Quality' }, + { href: `/racks${q}`, icon: 'server', label: 'Racks' }, + { href: `/networking${q}`, icon: 'share-2', label: 'Netwerk' }, + { href: `/time${q}`, icon: 'clock', label: 'Uren' }, + { href: `/invoices/new${q}`, icon: 'receipt', label: 'Factuur' }, + { href: `/email/compose${q}`, icon: 'mail', label: 'Email' } + ]; +} + +module.exports = { crmContextMiddleware, getClientHubLinks }; diff --git a/dashboard.ejs b/dashboard.ejs new file mode 100644 index 0000000..722ab95 --- /dev/null +++ b/dashboard.ejs @@ -0,0 +1,816 @@ +Dashboard — Mek-Tech Enterprise + + + +
+ + +
+ + +
+
+
+

Welkom terug<% if (typeof user !== 'undefined' && user && user.username) { %>, <%= user.username %><% } %>

+

Jouw consultancy command center — live inzicht in cliënten, trajecten en platformstatus.

+
+
+
+ +<% if (activeAdvice.length > 0) { %> + +<% } %> + +
+<% var svcList = servicesStatus || {}; +Object.keys(svcList).forEach(function(key) { + var s = svcList[key]; + var on = s.online !== false; +%> +
+ +<%= s.label %>
<%= on ? 'Online' : 'Offline' %>
+
+<% }) %> +
+ +

Enterprise Dashboard

Realtime consultancy intelligence platform

+
+ +
<%= stats.activeEngagements %>
Actieve Engagements
van <%= stats.totalEngagements %> totaal
+
<%= totalRackDevices + totalNetworkDevices %>
Infra Devices
<%= totalRacks %> racks · <%= totalDiagrams %> diagrammen
+
<%= overdueTasks %>
Taken over tijd
<%= completedTasks %>/<%= totalTasks %> voltooid
+
<%= assessmentsCount %>
Assessments
Data quality engine
+
+
+
Clients Status
+<% var sts={active:'Actief',leads:'Lead',paused:'Paused',archived:'Archived'};var clrs={active:'var(--green)',leads:'var(--accent)',paused:'var(--orange)',archived:'var(--text-dim)'};var maxC=Math.max(stats.active,stats.leads,stats.paused,stats.archived,1); +for(var k in sts){var cnt=stats[k]||0;%>
<%= sts[k] %><%= cnt %>
<% } %> +
+
Engagements per Type
+<% 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){ %>
<%= e.type %><%= e.cnt %>
<% }) %> +
+
+
Recent Assessments
+<% if(recentAssessments.length===0){ %>
Nog geen assessments
<% }else{ %>
<% recentAssessments.forEach(function(a){ %>
Assessment voor <%= a.client_name %>
<%= new Date(a.created_at).toLocaleDateString('nl-NL',{hour:'2-digit',minute:'2-digit'}) %>
<% }) %>
<% } %> +
+
+ + + + + +

Engagements

<%= stats.totalEngagements %> total · <%= stats.activeEngagements %> active

+
+
By Type
<% engagementByType.forEach(function(e){ %>
<%= e.type %><%= e.cnt %>
<% }) %>
+
By Status
<% engagementByStatus.forEach(function(e){ %>
<%= e.status.replace('_',' ') %><%= e.cnt %>
<% }) %>
+
Alle Engagements
+<% if(allEngagements.length===0){ %>
Nog geen engagements
<% }else{ %> +<% allEngagements.forEach(function(e){ %><% }) %>
TitelCliëntTypeStatus
<%= e.title %><%= e.client_name %><%= e.status %>Details
<% } %> +
+ + +

Finance

Urenregistratie en facturatie

+
<%= totalHours.toFixed(1) %>
Total hours
<%= billableHours.toFixed(1) %> billable
€<%= totalInvoiced.toFixed(0) %>
Invoiced
€<%= totalPaid.toFixed(0) %> paid
€<%= totalOutstanding.toFixed(0) %>
Outstanding
+ +
Recente urenregistraties
+<% if(timeEntries.length===0){ %>
Nog geen uren geregistreerd
<% }else{ %><% timeEntries.forEach(function(t){ %><% }) %>
DatumUrenCliëntEngagementOmschrijving
<%= t.date %><%= t.hours %><%= t.client_name||'-' %><%= t.engagement_title||'-' %><%= t.description||'' %>
<% } %>
+
Recente facturen
+<% if(invoices.length===0){ %>
Nog geen facturen
<% }else{ %><% invoices.forEach(function(i){ %><% }) %>
FactuurnrCliëntBedragStatus
<%= i.number %><%= i.client_name %>€<%= (i.total||0).toFixed(2) %><%= i.status %>Open
<% } %>
+ + +

Infrastructure

Racks, netwerkapparatuur, verbindingen & architectuurdiagrammen

+
<%= totalRacks %>
Server Racks
<%= totalRackDevices %> devices
<%= totalNetworkDevices %>
Netwerk Devices
Switches, routers & firewalls
<%= totalDiagrams %>
Diagrammen
Data, netwerk & systeem
+ +
+
Server Racks <% if(racks.length>0){ %>Alle<% } %>
+<% if(racks.length===0){ %>
Nog geen racks
<% }else{ %><% } %>
+
Netwerk Devices <% if(netDevices.length>0){ %>Alle<% } %>
+<% if(netDevices.length===0){ %>
Nog geen netwerk apparaten
<% }else{ %><% } %>
+
+
Architectuur Diagrammen <% if(diagrams.length>0){ %>Alle<% } %>
+<% if(diagrams.length===0){ %>
Nog geen diagrammen
<% }else{ %><% } %>
+ + +

Tasks

<%= completedTasks %>/<%= totalTasks %> completed <% if(overdueTasks>0){ %><%= overdueTasks %> overdue<% } %>

+
<%= completedTasks %>
Voltooid
<%= totalTasks-completedTasks %>
Open
<%= overdueTasks %>
Over tijd
+<% if(allTasks.length===0){ %>
Nog geen taken
<% }else{ %>
<% allTasks.forEach(function(t){ var io=t.status!=='done'&&t.status!=='cancelled'&&t.due_date&&new Date(t.due_date)
<%= t.priority %>
<%= t.title %><%= t.client_name %> · <%= t.engagement_title %>
<%= t.status.replace('_',' ') %><% if(t.due_date){ %><%= new Date(t.due_date).toLocaleDateString('nl-NL') %><% } %>
<% }) %>
<% } %>
+ + +
+

Advice & Consulting

Jouw complete consultancy werkplaats — van discovery tot delivery

+ +
+
+

Mek-Tech Consultancy Hub

+

Assessments, architectuur, data quality, AI readiness en commerciële follow-up — alles op één plek voor jouw FDE- en enterprise consultancy.

+
+ +
+ +
+
+
+
<%= consultingStats.assessments %>
+
Assessments
+
<%= consultingStats.clientsNeedingAssessment %> wachten op intake
+
+
+
+
<%= consultingStats.activeConsulting %>
+
Lopende Trajecten
+
Consulting & architectuur
+
+
+
+
<%= totalDiagrams %>
+
Designs
+
Architectuurdiagrammen
+
+
+
+
<%= consultingStats.openAdvice %>
+
Open Adviezen
+
Actie-items per cliënt
+
+
+ +<% toolkit.forEach(function(section) { %> +
+
+ +

<%= section.title %>

+ — <%= section.desc %> +
+
+ <% section.tools.forEach(function(tool) { %> + +
+ +
+ <%= tool.label %> + <%= tool.desc %> +
+ <% }) %> +
+
+<% }) %> + +
+
+
Delivery Playbook
+

Standaard consultancy traject in 5 fasen

+
+ <% playbooks.forEach(function(pb) { %> +
+
Fase <%= pb.phase %>
+

<%= pb.title %>

+
    <% pb.steps.forEach(function(s) { %>
  • <%= s %>
  • <% }) %>
+
<%= pb.duration %>
+
+ <% }) %> +
+
+
+
Maturity Model
+

Consultancy dimensies per engagement

+
+ <% maturityLayers.forEach(function(layer) { %> +
+
<%= layer.name %>
+ <% layer.items.forEach(function(item) { %><%= item %><% }) %> +
+ <% }) %> +
+
+
+ +
+
+
+

Slim Advies

+ Automatisch gegenereerd uit CRM-data +
+ <% if(activeAdvice.length===0){ %> +
+ +

Geen open actie-items — voeg cliënten toe om gepersonaliseerd advies te krijgen.

+ Eerste cliënt toevoegen +
+ <% }else{ %> +
+ <% activeAdvice.forEach(function(a) { %> +
+
<%= a.priority %>
+
+

<%= a.advice_text %>

+
<%= a.client_name %> · <%= a.advice_type.replace(/_/g,' ') %>
+
+ Actie → +
+ <% }) %> +
+ <% } %> +
+
+
Recente Assessments <% if(recentAssessments.length>0){ %>Alle<% } %>
+ <% if(recentAssessments.length===0){ %> +
Nog geen assessments — start via Enterprise Framework
+ <% }else{ %> +
+ <% recentAssessments.forEach(function(a) { %> +
+
+
+
Assessment voor <%= a.client_name %>
+
<%= new Date(a.created_at).toLocaleString('nl-NL') %>
+ Rapport bekijken → +
+
+ <% }) %> +
+ <% } %> +
+ +
+
+
+ + +

LeadGen AI

AI-driven Lead Generation & Tech Scoping — analyseer bedrijfswebsites op technische signalen

+ +
+
URL Analyseren
+
+ + +
+ +
+ +
API Configuratie
+
+ + + + + +

Plak een bedrijfs-URL en klik Analyseren. De AI scant de pagina op:

Tech stack detectiePain pointsData maturityOutreach hook
+ + +
+ + +

Market Intelligence

Nederlandse datacenters & data-gedreven bedrijven

+
<%= datacenterCount || 0 %>
Datacenters
Nederlandse markt
<%= marketCompaniesCount || 0 %>
Prospects
Data-gedreven bedrijven
+
+ + +

Calendar

Planning, deadlines & afspraken

+
<%= overdueTasks %>
Open Tasks
<%= completedTasks %> completed
+
+ + +

Data Quality

Data profiling, kwaliteitschecks & modellen

+
<%= assessmentsCount %>
Assessments
Data kwaliteit checks
<%= qualityFileCount || 0 %>
Data Bestanden
SQL & dbt modellen
+
+ + +

Recente Activiteit

+
Recent Assessments
+<% if(recentAssessments.length===0){ %>
Nog geen recente activiteit
<% }else{ %>
<% recentAssessments.forEach(function(a){ %>
Rapport voor <%= a.client_name %>
<%= new Date(a.created_at).toLocaleString('nl-NL') %>
<% }) %>
<% } %>
+
+ +
+ + + + + + + + + + diff --git a/dashboard.js b/dashboard.js new file mode 100644 index 0000000..89afaea --- /dev/null +++ b/dashboard.js @@ -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; diff --git a/data/ai_config.json b/data/ai_config.json new file mode 100644 index 0000000..b5774d5 --- /dev/null +++ b/data/ai_config.json @@ -0,0 +1,4 @@ +{ + "default_provider_id": null, + "providers": [] +} \ No newline at end of file diff --git a/data/hardware-library.js b/data/hardware-library.js new file mode 100644 index 0000000..d3eb353 --- /dev/null +++ b/data/hardware-library.js @@ -0,0 +1,320 @@ +const HARDWARE_LIBRARY = [ + // ── HPE ── + {vendor:'HPE',model:'ProLiant DL380 Gen11',type:'server',height:2}, + {vendor:'HPE',model:'ProLiant DL360 Gen11',type:'server',height:1}, + {vendor:'HPE',model:'ProLiant DL560 Gen11',type:'server',height:2}, + {vendor:'HPE',model:'ProLiant DL385 Gen11',type:'server',height:2}, + {vendor:'HPE',model:'ProLiant DL325 Gen11',type:'server',height:1}, + {vendor:'HPE',model:'ProLiant ML350 Gen11',type:'server',height:5}, + {vendor:'HPE',model:'ProLiant XL675d Gen11',type:'server',height:2}, + {vendor:'HPE',model:'ProLiant XL645d Gen11',type:'server',height:2}, + {vendor:'HPE',model:'Synergy 480 Gen11',type:'server',height:1}, + {vendor:'HPE',model:'Synergy 660 Gen11',type:'server',height:2}, + {vendor:'HPE',model:'Apollo 4200 Gen11',type:'server',height:4}, + {vendor:'HPE',model:'Apollo 6500 Gen10+',type:'server',height:4}, + {vendor:'HPE',model:'Apollo 2000 Gen10+',type:'server',height:2}, + {vendor:'HPE',model:'3PAR 8450',type:'storage',height:3}, + {vendor:'HPE',model:'3PAR 8440',type:'storage',height:3}, + {vendor:'HPE',model:'3PAR 8200',type:'storage',height:2}, + {vendor:'HPE',model:'Nimble AF60',type:'storage',height:2}, + {vendor:'HPE',model:'Nimble AF40',type:'storage',height:2}, + {vendor:'HPE',model:'Nimble AF20',type:'storage',height:2}, + {vendor:'HPE',model:'Nimble HF60',type:'storage',height:2}, + {vendor:'HPE',model:'Alletra 9060',type:'storage',height:4}, + {vendor:'HPE',model:'Alletra 6050',type:'storage',height:2}, + {vendor:'HPE',model:'Alletra 5050',type:'storage',height:2}, + {vendor:'HPE',model:'Aruba 6300M',type:'switch',height:1}, + {vendor:'HPE',model:'Aruba 8320',type:'switch',height:1}, + {vendor:'HPE',model:'Aruba 8360',type:'switch',height:1}, + {vendor:'HPE',model:'Aruba CX 10000',type:'switch',height:1}, + {vendor:'HPE',model:'Aruba CX 8325',type:'switch',height:1}, + {vendor:'HPE',model:'Aruba CX 6400',type:'switch',height:2}, + {vendor:'HPE',model:'Aruba CX 6300',type:'switch',height:1}, + {vendor:'HPE',model:'Aruba 2930F',type:'switch',height:1}, + {vendor:'HPE',model:'MSA 2060',type:'storage',height:2}, + {vendor:'HPE',model:'StoreOnce 5200',type:'tape',height:2}, + {vendor:'HPE',model:'StoreOnce 6500',type:'tape',height:4}, + {vendor:'HPE',model:'StoreEver LTO-9',type:'tape',height:2}, + {vendor:'HPE',model:'iLO Advanced',type:'other',height:0}, + + // ── Dell ── + {vendor:'Dell',model:'PowerEdge R760',type:'server',height:2}, + {vendor:'Dell',model:'PowerEdge R660',type:'server',height:1}, + {vendor:'Dell',model:'PowerEdge R760xa',type:'server',height:3}, + {vendor:'Dell',model:'PowerEdge R7625',type:'server',height:2}, + {vendor:'Dell',model:'PowerEdge R6615',type:'server',height:1}, + {vendor:'Dell',model:'PowerEdge R860',type:'server',height:2}, + {vendor:'Dell',model:'PowerEdge R960',type:'server',height:4}, + {vendor:'Dell',model:'PowerEdge R450',type:'server',height:1}, + {vendor:'Dell',model:'PowerEdge R350',type:'server',height:1}, + {vendor:'Dell',model:'PowerEdge R250',type:'server',height:1}, + {vendor:'Dell',model:'PowerEdge MX760c',type:'server',height:2}, + {vendor:'Dell',model:'PowerEdge C6620',type:'server',height:2}, + {vendor:'Dell',model:'PowerEdge XR4520c',type:'server',height:1}, + {vendor:'Dell',model:'PowerEdge XR5610',type:'server',height:1}, + {vendor:'Dell',model:'PowerStore 5200',type:'storage',height:2}, + {vendor:'Dell',model:'PowerStore 3200',type:'storage',height:2}, + {vendor:'Dell',model:'PowerStore 1200',type:'storage',height:2}, + {vendor:'Dell',model:'PowerStore 9200',type:'storage',height:4}, + {vendor:'Dell',model:'PowerScale F900',type:'storage',height:2}, + {vendor:'Dell',model:'PowerScale H700',type:'storage',height:2}, + {vendor:'Dell',model:'PowerScale A300',type:'storage',height:2}, + {vendor:'Dell',model:'PowerVault ME5084',type:'storage',height:4}, + {vendor:'Dell',model:'PowerVault ME5024',type:'storage',height:2}, + {vendor:'Dell',model:'PowerVault ME5012',type:'storage',height:2}, + {vendor:'Dell',model:'PowerSwitch Z9332F',type:'switch',height:1}, + {vendor:'Dell',model:'PowerSwitch Z9264F',type:'switch',height:1}, + {vendor:'Dell',model:'PowerSwitch S5248F',type:'switch',height:1}, + {vendor:'Dell',model:'PowerSwitch S5232F',type:'switch',height:1}, + {vendor:'Dell',model:'PowerSwitch S5224F',type:'switch',height:1}, + {vendor:'Dell',model:'PowerSwitch N3248TEV',type:'switch',height:1}, + {vendor:'Dell',model:'PowerSwitch N3248X',type:'switch',height:1}, + {vendor:'Dell',model:'PowerSwitch N3224F',type:'switch',height:1}, + {vendor:'Dell',model:'PowerVault TL4000',type:'tape',height:2}, + {vendor:'Dell',model:'PowerVault ML6000',type:'tape',height:4}, + {vendor:'Dell',model:'Wyse 5070',type:'other',height:0}, + + // ── Lenovo ── + {vendor:'Lenovo',model:'ThinkSystem SR650 V3',type:'server',height:2}, + {vendor:'Lenovo',model:'ThinkSystem SR630 V3',type:'server',height:1}, + {vendor:'Lenovo',model:'ThinkSystem SR850 V3',type:'server',height:2}, + {vendor:'Lenovo',model:'ThinkSystem SR860 V3',type:'server',height:4}, + {vendor:'Lenovo',model:'ThinkSystem SR950 V3',type:'server',height:4}, + {vendor:'Lenovo',model:'ThinkSystem SR645 V3',type:'server',height:1}, + {vendor:'Lenovo',model:'ThinkSystem SR665 V3',type:'server',height:2}, + {vendor:'Lenovo',model:'ThinkSystem SR675 V3',type:'server',height:3}, + {vendor:'Lenovo',model:'ThinkSystem SR250 V3',type:'server',height:1}, + {vendor:'Lenovo',model:'ThinkSystem SR150 V3',type:'server',height:1}, + {vendor:'Lenovo',model:'ThinkSystem SD550 V3',type:'server',height:2}, + {vendor:'Lenovo',model:'ThinkSystem SD650 V3',type:'server',height:2}, + {vendor:'Lenovo',model:'ThinkEdge SE350 V2',type:'server',height:1}, + {vendor:'Lenovo',model:'ThinkSystem DE4000H',type:'storage',height:2}, + {vendor:'Lenovo',model:'ThinkSystem DE6000H',type:'storage',height:2}, + {vendor:'Lenovo',model:'ThinkSystem DE2000H',type:'storage',height:2}, + {vendor:'Lenovo',model:'ThinkSystem DM7000',type:'storage',height:4}, + {vendor:'Lenovo',model:'ThinkSystem DM5000',type:'storage',height:2}, + {vendor:'Lenovo',model:'ThinkSystem DM3000',type:'storage',height:2}, + {vendor:'Lenovo',model:'ThinkSystem NE2580',type:'switch',height:1}, + {vendor:'Lenovo',model:'ThinkSystem NE1032',type:'switch',height:1}, + {vendor:'Lenovo',model:'ThinkSystem NE0164',type:'switch',height:1}, + {vendor:'Lenovo',model:'ThinkSystem NE10032',type:'switch',height:1}, + {vendor:'Lenovo',model:'RackSwitch G8296',type:'switch',height:2}, + {vendor:'Lenovo',model:'RackSwitch G8272',type:'switch',height:1}, + {vendor:'Lenovo',model:'ThinkSystem LTO-9',type:'tape',height:2}, + + // ── Cisco ── + {vendor:'Cisco',model:'UCS X210c M8',type:'server',height:2}, + {vendor:'Cisco',model:'UCS B200 M7',type:'server',height:0}, + {vendor:'Cisco',model:'UCS B480 M7',type:'server',height:0}, + {vendor:'Cisco',model:'UCS C240 M7',type:'server',height:2}, + {vendor:'Cisco',model:'UCS C220 M7',type:'server',height:1}, + {vendor:'Cisco',model:'UCS C245 M7',type:'server',height:2}, + {vendor:'Cisco',model:'UCS C225 M7',type:'server',height:1}, + {vendor:'Cisco',model:'UCS C480 M7',type:'server',height:4}, + {vendor:'Cisco',model:'UCS X4308',type:'server',height:1}, + {vendor:'Cisco',model:'UCS X440p',type:'server',height:2}, + {vendor:'Cisco',model:'Nexus 93180YC-FX3',type:'switch',height:1}, + {vendor:'Cisco',model:'Nexus 9364C-GX2',type:'switch',height:1}, + {vendor:'Cisco',model:'Nexus 92348GC-X',type:'switch',height:1}, + {vendor:'Cisco',model:'Nexus 9508',type:'switch',height:8}, + {vendor:'Cisco',model:'Nexus 7700',type:'switch',height:10}, + {vendor:'Cisco',model:'Nexus 56128P',type:'switch',height:1}, + {vendor:'Cisco',model:'Catalyst 9500-48Y4C',type:'switch',height:1}, + {vendor:'Cisco',model:'Catalyst 9300-48UXM',type:'switch',height:1}, + {vendor:'Cisco',model:'Catalyst 9200-48P',type:'switch',height:1}, + {vendor:'Cisco',model:'Catalyst 8200-1N-4T',type:'router',height:1}, + {vendor:'Cisco',model:'Catalyst 8500-4X',type:'router',height:2}, + {vendor:'Cisco',model:'Firepower 4245',type:'firewall',height:2}, + {vendor:'Cisco',model:'Firepower 4115',type:'firewall',height:1}, + {vendor:'Cisco',model:'Firepower 1120',type:'firewall',height:1}, + {vendor:'Cisco',model:'ASA 5585-X',type:'firewall',height:2}, + {vendor:'Cisco',model:'MDS 9710',type:'san',height:8}, + {vendor:'Cisco',model:'MDS 9250i',type:'san',height:1}, + {vendor:'Cisco',model:'MDS 9148S',type:'san',height:1}, + + // ── Juniper ── + {vendor:'Juniper',model:'QFX5240',type:'switch',height:1}, + {vendor:'Juniper',model:'QFX5220',type:'switch',height:1}, + {vendor:'Juniper',model:'QFX5210',type:'switch',height:1}, + {vendor:'Juniper',model:'QFX5120',type:'switch',height:1}, + {vendor:'Juniper',model:'QFX5110',type:'switch',height:1}, + {vendor:'Juniper',model:'EX9250',type:'switch',height:2}, + {vendor:'Juniper',model:'EX4650',type:'switch',height:1}, + {vendor:'Juniper',model:'EX4400',type:'switch',height:1}, + {vendor:'Juniper',model:'EX3400',type:'switch',height:1}, + {vendor:'Juniper',model:'EX2300',type:'switch',height:1}, + {vendor:'Juniper',model:'ACX710',type:'router',height:1}, + {vendor:'Juniper',model:'ACX6360',type:'router',height:1}, + {vendor:'Juniper',model:'SRX5800',type:'firewall',height:5}, + {vendor:'Juniper',model:'SRX4600',type:'firewall',height:2}, + {vendor:'Juniper',model:'SRX1500',type:'firewall',height:1}, + {vendor:'Juniper',model:'SRX345',type:'firewall',height:1}, + {vendor:'Juniper',model:'NFX350',type:'other',height:1}, + {vendor:'Juniper',model:'PTX10008',type:'router',height:8}, + + // ── Arista ── + {vendor:'Arista',model:'7800R4',type:'switch',height:4}, + {vendor:'Arista',model:'7700R4',type:'switch',height:4}, + {vendor:'Arista',model:'7280R3',type:'switch',height:1}, + {vendor:'Arista',model:'7368X4',type:'switch',height:1}, + {vendor:'Arista',model:'7050X4',type:'switch',height:1}, + {vendor:'Arista',model:'7050SX3',type:'switch',height:1}, + {vendor:'Arista',model:'7150',type:'switch',height:1}, + {vendor:'Arista',model:'7010',type:'switch',height:1}, + {vendor:'Arista',model:'720XP',type:'switch',height:1}, + {vendor:'Arista',model:'ACS-7010T',type:'switch',height:1}, + + // ── NetApp ── + {vendor:'NetApp',model:'AFF A90',type:'storage',height:3}, + {vendor:'NetApp',model:'AFF A70',type:'storage',height:2}, + {vendor:'NetApp',model:'AFF A50',type:'storage',height:2}, + {vendor:'NetApp',model:'AFF A30',type:'storage',height:2}, + {vendor:'NetApp',model:'AFF A20',type:'storage',height:1}, + {vendor:'NetApp',model:'AFF C80',type:'storage',height:3}, + {vendor:'NetApp',model:'AFF C60',type:'storage',height:2}, + {vendor:'NetApp',model:'FAS 9500',type:'storage',height:4}, + {vendor:'NetApp',model:'FAS 8700',type:'storage',height:3}, + {vendor:'NetApp',model:'FAS 500f',type:'storage',height:2}, + {vendor:'NetApp',model:'FAS 2750',type:'storage',height:2}, + {vendor:'NetApp',model:'E-Series E4000',type:'storage',height:2}, + {vendor:'NetApp',model:'E-Series E2800',type:'storage',height:2}, + {vendor:'NetApp',model:'E-Series E5700',type:'storage',height:2}, + {vendor:'NetApp',model:'NetApp DS460C',type:'storage',height:4}, + {vendor:'NetApp',model:'StorageGrid SG6100',type:'storage',height:2}, + + // ── Pure Storage ── + {vendor:'Pure Storage',model:'FlashArray//X90',type:'storage',height:3}, + {vendor:'Pure Storage',model:'FlashArray//X70',type:'storage',height:3}, + {vendor:'Pure Storage',model:'FlashArray//X50',type:'storage',height:2}, + {vendor:'Pure Storage',model:'FlashArray//X20',type:'storage',height:2}, + {vendor:'Pure Storage',model:'FlashArray//C60',type:'storage',height:3}, + {vendor:'Pure Storage',model:'FlashArray//C40',type:'storage',height:2}, + {vendor:'Pure Storage',model:'FlashBlade//S500',type:'storage',height:4}, + {vendor:'Pure Storage',model:'FlashBlade//S200',type:'storage',height:4}, + {vendor:'Pure Storage',model:'FlashStack',type:'other',height:0}, + + // ── Fortinet ── + {vendor:'Fortinet',model:'FortiGate 4200F',type:'firewall',height:2}, + {vendor:'Fortinet',model:'FortiGate 3600F',type:'firewall',height:2}, + {vendor:'Fortinet',model:'FortiGate 2600F',type:'firewall',height:1}, + {vendor:'Fortinet',model:'FortiGate 1800F',type:'firewall',height:1}, + {vendor:'Fortinet',model:'FortiGate 900G',type:'firewall',height:1}, + {vendor:'Fortinet',model:'FortiGate 600F',type:'firewall',height:1}, + {vendor:'Fortinet',model:'FortiGate 400F',type:'firewall',height:1}, + {vendor:'Fortinet',model:'FortiGate 100F',type:'firewall',height:0}, + {vendor:'Fortinet',model:'FortiGate 60F',type:'firewall',height:0}, + {vendor:'Fortinet',model:'FortiSwitch 424E',type:'switch',height:1}, + {vendor:'Fortinet',model:'FortiSwitch 148F',type:'switch',height:1}, + {vendor:'Fortinet',model:'FortiSwitch 108E',type:'switch',height:1}, + {vendor:'Fortinet',model:'FortiAnalyzer 3000G',type:'other',height:2}, + {vendor:'Fortinet',model:'FortiManager 2000G',type:'other',height:1}, + {vendor:'Fortinet',model:'FortiMail 6000G',type:'other',height:2}, + + // ── F5 ── + {vendor:'F5',model:'BIG-IP i15800',type:'loadbalancer',height:2}, + {vendor:'F5',model:'BIG-IP i11800',type:'loadbalancer',height:2}, + {vendor:'F5',model:'BIG-IP i10600',type:'loadbalancer',height:1}, + {vendor:'F5',model:'BIG-IP i7600',type:'loadbalancer',height:1}, + {vendor:'F5',model:'BIG-IP i5800',type:'loadbalancer',height:1}, + {vendor:'F5',model:'BIG-IP i2800',type:'loadbalancer',height:1}, + {vendor:'F5',model:'BIG-IP i2600',type:'loadbalancer',height:1}, + {vendor:'F5',model:'BIG-IP VELOS-2',type:'loadbalancer',height:2}, + {vendor:'F5',model:'BIG-IP VELOS-4',type:'loadbalancer',height:4}, + {vendor:'F5',model:'BIG-IP VELOS-8',type:'loadbalancer',height:8}, + {vendor:'F5',model:'BIG-IP Next SPK',type:'loadbalancer',height:1}, + + // ── Palo Alto ── + {vendor:'Palo Alto',model:'PA-5450',type:'firewall',height:2}, + {vendor:'Palo Alto',model:'PA-5260',type:'firewall',height:2}, + {vendor:'Palo Alto',model:'PA-5250',type:'firewall',height:2}, + {vendor:'Palo Alto',model:'PA-460',type:'firewall',height:1}, + {vendor:'Palo Alto',model:'PA-450',type:'firewall',height:1}, + {vendor:'Palo Alto',model:'PA-440',type:'firewall',height:1}, + {vendor:'Palo Alto',model:'PA-415',type:'firewall',height:0}, + {vendor:'Palo Alto',model:'PA-220',type:'firewall',height:0}, + {vendor:'Palo Alto',model:'VM-300',type:'other',height:0}, + + // ── Supermicro ── + {vendor:'Supermicro',model:'SYS-420GP',type:'server',height:4}, + {vendor:'Supermicro',model:'SYS-220GP',type:'server',height:2}, + {vendor:'Supermicro',model:'SYS-620P',type:'server',height:2}, + {vendor:'Supermicro',model:'SYS-520P',type:'server',height:2}, + {vendor:'Supermicro',model:'SYS-120P',type:'server',height:1}, + {vendor:'Supermicro',model:'SYS-820GP',type:'server',height:4}, + {vendor:'Supermicro',model:'AS-4125GS',type:'server',height:4}, + {vendor:'Supermicro',model:'AS-2125HS',type:'server',height:2}, + {vendor:'Supermicro',model:'AS-1125HS',type:'server',height:1}, + {vendor:'Supermicro',model:'SYS-510P',type:'server',height:2}, + {vendor:'Supermicro',model:'SuperStorage 6049P',type:'storage',height:4}, + {vendor:'Supermicro',model:'SuperStorage 2029P',type:'storage',height:2}, + {vendor:'Supermicro',model:'SSG-640SP',type:'storage',height:4}, + {vendor:'Supermicro',model:'SSG-540P',type:'storage',height:2}, + {vendor:'Supermicro',model:'SYS-E302-9A',type:'other',height:0}, + + // ── NVIDIA ── + {vendor:'NVIDIA',model:'DGX H100',type:'server',height:4}, + {vendor:'NVIDIA',model:'DGX A100',type:'server',height:4}, + {vendor:'NVIDIA',model:'DGX B200',type:'server',height:4}, + {vendor:'NVIDIA',model:'HGX H100 8-GPU',type:'server',height:4}, + {vendor:'NVIDIA',model:'HGX A100 8-GPU',type:'server',height:4}, + {vendor:'NVIDIA',model:'HGX H200',type:'server',height:4}, + {vendor:'NVIDIA',model:'BlueField-3 DPU',type:'other',height:0}, + {vendor:'NVIDIA',model:'ConnectX-7 NIC',type:'other',height:0}, + {vendor:'NVIDIA',model:'Quantum QM9700',type:'switch',height:1}, + {vendor:'NVIDIA',model:'Quantum QM8700',type:'switch',height:1}, + {vendor:'NVIDIA',model:'Spectrum SN5600',type:'switch',height:1}, + {vendor:'NVIDIA',model:'Spectrum SN4600',type:'switch',height:1}, + {vendor:'NVIDIA',model:'Spectrum SN3420',type:'switch',height:1}, + + // ── APC ── + {vendor:'APC',model:'Smart-UPS SRT 3000',type:'ups',height:2}, + {vendor:'APC',model:'Smart-UPS SRT 5000',type:'ups',height:2}, + {vendor:'APC',model:'Smart-UPS SRT 8000',type:'ups',height:3}, + {vendor:'APC',model:'Smart-UPS SRT 10000',type:'ups',height:3}, + {vendor:'APC',model:'Smart-UPS SRT 15000',type:'ups',height:4}, + {vendor:'APC',model:'Symmetra LX 16kVA',type:'ups',height:4}, + {vendor:'APC',model:'Symmetra PX 40kVA',type:'ups',height:6}, + {vendor:'APC',model:'Symmetra PX 80kVA',type:'ups',height:8}, + {vendor:'APC',model:'Rack PDU 2G Switched 0U',type:'pdu',height:0}, + {vendor:'APC',model:'Rack PDU 2G Metered 0U',type:'pdu',height:0}, + {vendor:'APC',model:'Rack PDU 2G Basic 0U',type:'pdu',height:0}, + {vendor:'APC',model:'NetShelter SX 42U',type:'other',height:42}, + {vendor:'APC',model:'NetShelter SX 48U',type:'other',height:48}, + {vendor:'APC',model:'NetBotz Rack Monitor 200',type:'other',height:0}, + + // ── Oracle ── + {vendor:'Oracle',model:'Oracle Server X9-2L',type:'server',height:2}, + {vendor:'Oracle',model:'Oracle Server X9-2',type:'server',height:1}, + {vendor:'Oracle',model:'Oracle Server X8-8',type:'server',height:4}, + {vendor:'Oracle',model:'Oracle Server X8-2L',type:'server',height:2}, + {vendor:'Oracle',model:'Oracle Server X8-2',type:'server',height:1}, + {vendor:'Oracle',model:'SPARC M8-8',type:'server',height:8}, + {vendor:'Oracle',model:'SPARC T8-4',type:'server',height:4}, + {vendor:'Oracle',model:'SPARC T8-2',type:'server',height:2}, + {vendor:'Oracle',model:'SPARC S7-2L',type:'server',height:2}, + {vendor:'Oracle',model:'Oracle FS Flash',type:'storage',height:3}, + {vendor:'Oracle',model:'Oracle ZFS ZS9-2',type:'storage',height:2}, + {vendor:'Oracle',model:'Oracle ZFS ZS7-2',type:'storage',height:2}, + {vendor:'Oracle',model:'Oracle FS1-2',type:'storage',height:4}, + + // ── IBM ── + {vendor:'IBM',model:'IBM Power S1014',type:'server',height:4}, + {vendor:'IBM',model:'IBM Power S1022s',type:'server',height:2}, + {vendor:'IBM',model:'IBM Power S1024',type:'server',height:4}, + {vendor:'IBM',model:'IBM Power L1022',type:'server',height:2}, + {vendor:'IBM',model:'IBM Power L1024',type:'server',height:4}, + {vendor:'IBM',model:'IBM Power E1050',type:'server',height:4}, + {vendor:'IBM',model:'IBM Power E1080',type:'server',height:8}, + {vendor:'IBM',model:'IBM FlashSystem 7300',type:'storage',height:4}, + {vendor:'IBM',model:'IBM FlashSystem 5200',type:'storage',height:2}, + {vendor:'IBM',model:'IBM FlashSystem 9500',type:'storage',height:4}, + {vendor:'IBM',model:'IBM FlashSystem 5100',type:'storage',height:2}, + {vendor:'IBM',model:'IBM DS8900F',type:'storage',height:8}, + {vendor:'IBM',model:'IBM DS8000',type:'storage',height:8}, + {vendor:'IBM',model:'IBM SAN64B-7',type:'san',height:1}, + {vendor:'IBM',model:'IBM SAN64B-6',type:'san',height:1}, + {vendor:'IBM',model:'IBM Storwize V7000',type:'storage',height:2}, + {vendor:'IBM',model:'IBM Storwize V5000',type:'storage',height:2} +]; + +module.exports = HARDWARE_LIBRARY; diff --git a/data/leadgen_config.json b/data/leadgen_config.json new file mode 100644 index 0000000..1406911 --- /dev/null +++ b/data/leadgen_config.json @@ -0,0 +1,6 @@ +{ + "api_key": "", + "base_url": "https://openrouter.ai/api/v1", + "model": "qwen/qwen3-coder:free", + "provider_type": "openrouter" +} \ No newline at end of file diff --git a/db.js b/db.js new file mode 100644 index 0000000..0bcb12e --- /dev/null +++ b/db.js @@ -0,0 +1,555 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const bcrypt = require('bcryptjs'); + +const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'consulting.db'); + +let db; + +function getDb() { + if (!db) { + const fs = require('fs'); + const dir = path.dirname(DB_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + initSchema(); + migrateSchema(); + seedDefaults(); + } + return db; +} + +function migrateSchema() { + const existingColumns = db.prepare("PRAGMA table_info('users')").all().map(r => r.name); + const newCols = { email: "TEXT DEFAULT ''", role: "TEXT DEFAULT 'admin'", reset_token: "TEXT DEFAULT ''", reset_expires: "TEXT DEFAULT ''" }; + for (const [col, def] of Object.entries(newCols)) { + if (!existingColumns.includes(col)) { + db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`); + } + } + + db.exec(` + CREATE TABLE IF NOT EXISTS ai_providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + provider_type TEXT NOT NULL DEFAULT 'openrouter', + api_key TEXT DEFAULT '', + base_url TEXT DEFAULT '', + default_model TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + is_default INTEGER DEFAULT 0, + extra_headers TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS integrations ( + id TEXT PRIMARY KEY, + name TEXT DEFAULT '', + api_key TEXT DEFAULT '', + webhook_url TEXT DEFAULT '', + config TEXT DEFAULT '{}', + status TEXT DEFAULT 'inactive', + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + report_type TEXT DEFAULT 'client_summary', + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + project_id INTEGER, + config TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + start_date TEXT, + end_date TEXT, + status TEXT DEFAULT 'planning', + priority TEXT DEFAULT 'medium', + budget REAL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + `); + + const diagramCols = db.prepare("PRAGMA table_info('data_diagrams')").all().map(r => r.name); + if (!diagramCols.includes('engagement_id')) { + db.exec('ALTER TABLE data_diagrams ADD COLUMN engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL'); + } + if (!diagramCols.includes('rack_id')) { + db.exec('ALTER TABLE data_diagrams ADD COLUMN rack_id INTEGER REFERENCES racks(id) ON DELETE SET NULL'); + } + + const tblRow = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='data_diagrams'").get(); + const tblSql = tblRow ? tblRow.sql : ''; + if (tblSql && tblSql.includes('CHECK') && !tblSql.includes('application_flow')) { + const cols = db.prepare("PRAGMA table_info('data_diagrams')").all(); + const colNames = cols.map(c => c.name); + const selectCols = colNames.join(', '); + db.exec(` + CREATE TABLE data_diagrams_v2 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + diagram_type TEXT DEFAULT 'data_architecture', + nodes TEXT NOT NULL DEFAULT '[]', + edges TEXT NOT NULL DEFAULT '[]', + engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL, + rack_id INTEGER REFERENCES racks(id) ON DELETE SET NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + INSERT INTO data_diagrams_v2 (${selectCols}) SELECT ${selectCols} FROM data_diagrams; + DROP TABLE data_diagrams; + ALTER TABLE data_diagrams_v2 RENAME TO data_diagrams; + `); + } +} + +function initSchema() { + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + role TEXT DEFAULT 'admin' CHECK(role IN ('admin','consultant','viewer')), + reset_token TEXT DEFAULT '', + reset_expires TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + industry TEXT, + website TEXT, + contact_name TEXT, + contact_email TEXT, + contact_phone TEXT, + status TEXT DEFAULT 'lead' CHECK(status IN ('lead','active','paused','archived')), + source TEXT, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS engagements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK(type IN ('assessment','architecture','implementation','monitoring','consulting','training')), + status TEXT DEFAULT 'planned' CHECK(status IN ('planned','in_progress','completed','on_hold')), + title TEXT NOT NULL, + description TEXT, + start_date TEXT, + end_date TEXT, + outcome TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS assessments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL, + answers TEXT NOT NULL, + report TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + engagement_id INTEGER NOT NULL REFERENCES engagements(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'todo' CHECK(status IN ('todo','in_progress','done','cancelled')), + priority TEXT DEFAULT 'medium' CHECK(priority IN ('low','medium','high','critical')), + due_date TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS advice_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + advice_type TEXT NOT NULL, + advice_text TEXT NOT NULL, + priority TEXT DEFAULT 'medium', + dismissed INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS racks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + name TEXT NOT NULL, + location TEXT, + datacenter TEXT, + total_units INTEGER DEFAULT 42, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS rack_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rack_id INTEGER NOT NULL REFERENCES racks(id) ON DELETE CASCADE, + name TEXT NOT NULL, + device_type TEXT DEFAULT 'server', + model TEXT, + manufacturer TEXT, + position_u INTEGER NOT NULL, + height_u INTEGER DEFAULT 1, + specs TEXT, + mgmt_ip TEXT, + notes TEXT, + serial TEXT DEFAULT '', + asset_tag TEXT DEFAULT '', + status TEXT DEFAULT 'active', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS network_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + name TEXT NOT NULL, + device_type TEXT NOT NULL CHECK(device_type IN ('switch','router','firewall','loadbalancer','ap','other')), + model TEXT, + manufacturer TEXT, + ip_address TEXT, + mgmt_ip TEXT, + ports_count INTEGER DEFAULT 24, + os_version TEXT, + notes TEXT, + status TEXT DEFAULT 'active' CHECK(status IN ('active','standby','decommissioned')), + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS network_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + name TEXT, + from_type TEXT NOT NULL CHECK(from_type IN ('rack_device','network_device','external')), + from_id INTEGER, + from_port TEXT, + to_type TEXT NOT NULL CHECK(to_type IN ('rack_device','network_device','external')), + to_id INTEGER, + to_port TEXT, + media_type TEXT DEFAULT 'copper' CHECK(media_type IN ('copper','sfp','sfp+','qsfp','dac','fiber')), + speed TEXT DEFAULT '1GbE', + vlan TEXT, + status TEXT DEFAULT 'active' CHECK(status IN ('active','inactive','planned')), + notes TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS data_diagrams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + diagram_type TEXT DEFAULT 'data_architecture' CHECK(diagram_type IN ('data_architecture','network_topology','system_architecture')), + nodes TEXT NOT NULL DEFAULT '[]', + edges TEXT NOT NULL DEFAULT '[]', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS client_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_by TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS client_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + original_name TEXT NOT NULL, + stored_name TEXT NOT NULL, + mime_type TEXT DEFAULT '', + file_size INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + username TEXT DEFAULT '', + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + details TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS time_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL, + date TEXT NOT NULL, + hours REAL NOT NULL, + description TEXT DEFAULT '', + billable INTEGER DEFAULT 1, + hourly_rate REAL DEFAULT 150.0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + number TEXT UNIQUE NOT NULL, + status TEXT DEFAULT 'draft' CHECK(status IN ('draft','sent','paid','overdue','cancelled')), + date TEXT NOT NULL, + due_date TEXT NOT NULL, + subtotal REAL DEFAULT 0, + tax REAL DEFAULT 0, + total REAL DEFAULT 0, + notes TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS invoice_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL, + quantity REAL DEFAULT 1, + unit_price REAL DEFAULT 0, + total REAL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + type TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT DEFAULT '', + channel TEXT DEFAULT 'in-app' CHECK(channel IN ('in-app','email','slack','webhook')), + read INTEGER DEFAULT 0, + sent INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS calendar_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL, + title TEXT NOT NULL, + description TEXT DEFAULT '', + event_type TEXT DEFAULT 'meeting' CHECK(event_type IN ('meeting','call','deadline','workshop','review','other')), + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + all_day INTEGER DEFAULT 0, + color TEXT DEFAULT '#58a6ff', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS user_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + notify_email INTEGER DEFAULT 1, + notify_slack INTEGER DEFAULT 0, + notify_webhook INTEGER DEFAULT 0, + webhook_url TEXT DEFAULT '', + slack_webhook TEXT DEFAULT '', + language TEXT DEFAULT 'nl' + ); + + CREATE TABLE IF NOT EXISTS email_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + email TEXT NOT NULL, + name TEXT DEFAULT '', + imap_host TEXT NOT NULL, + imap_port INTEGER DEFAULT 993, + imap_secure INTEGER DEFAULT 1, + imap_user TEXT NOT NULL, + imap_pass TEXT NOT NULL, + smtp_host TEXT NOT NULL, + smtp_port INTEGER DEFAULT 587, + smtp_secure INTEGER DEFAULT 0, + smtp_user TEXT DEFAULT '', + smtp_pass TEXT DEFAULT '', + active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS email_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + uid INTEGER NOT NULL, + folder TEXT DEFAULT 'INBOX', + subject TEXT DEFAULT '', + from_name TEXT DEFAULT '', + from_addr TEXT DEFAULT '', + to_addr TEXT DEFAULT '', + cc TEXT DEFAULT '', + bcc TEXT DEFAULT '', + date TEXT DEFAULT '', + body_text TEXT DEFAULT '', + body_html TEXT DEFAULT '', + attachments INTEGER DEFAULT 0, + seen INTEGER DEFAULT 0, + flagged INTEGER DEFAULT 0, + replied INTEGER DEFAULT 0, + size INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS datacenters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + address TEXT DEFAULT '', + city TEXT DEFAULT '', + region TEXT DEFAULT '', + postal_code TEXT DEFAULT '', + phone TEXT DEFAULT '', + website TEXT DEFAULT '', + type TEXT DEFAULT 'colo', + operator TEXT DEFAULT '', + tier TEXT DEFAULT 'III', + power_mw REAL DEFAULT 0, + size_sqm INTEGER DEFAULT 0, + network_vendors TEXT DEFAULT '', + server_vendors TEXT DEFAULT '', + storage_vendors TEXT DEFAULT '', + certifications TEXT DEFAULT '', + notes TEXT DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS tech_companies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + industry TEXT DEFAULT '', + city TEXT DEFAULT '', + address TEXT DEFAULT '', + postal_code TEXT DEFAULT '', + phone TEXT DEFAULT '', + email TEXT DEFAULT '', + website TEXT DEFAULT '', + linkedin TEXT DEFAULT '', + employees INTEGER DEFAULT 0, + revenue_range TEXT DEFAULT '', + data_warehouse TEXT DEFAULT '', + bi_tools TEXT DEFAULT '', + cloud_provider TEXT DEFAULT '', + data_lake TEXT DEFAULT '', + ai_ml TEXT DEFAULT '', + database_vendor TEXT DEFAULT '', + lead_score INTEGER DEFAULT 0, + lead_status TEXT DEFAULT 'new', + notes TEXT DEFAULT '', + source TEXT DEFAULT '' + ); + `); +} + +function seedDefaults() { + const existing = db.prepare('SELECT COUNT(*) as cnt FROM users').get(); + if (existing.cnt === 0) { + const pw = process.env.ADMIN_PASSWORD || 'admin'; + const hash = bcrypt.hashSync(pw, 10); + const result = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run('admin', hash, 'admin'); + db.prepare('INSERT INTO user_settings (user_id, language) VALUES (?, ?)').run(result.lastInsertRowid, 'nl'); + console.log(`Default user created: admin / ${pw}`); + } +} + +const DEFAULTS = { + brand_name: 'Mek-Tech Consulting', + language: 'nl', + theme: 'mek-neon-blue', + default_page: '/', + auto_refresh: '0', + items_per_page: '50', + debug_mode: '0', + icon_colors: JSON.stringify({ + 'layout-dashboard': '#58a6ff', + users: '#3fb950', + briefcase: '#d29922', + server: '#bc8cff', + 'check-circle': '#3fb950', + lightbulb: '#d29922', + clock: '#79c0ff', + eye: '#f778ba', + phone: '#3fb950', + 'file-text': '#f778ba', + 'edit-3': '#79c0ff', + folder: '#d29922', + 'clipboard-list': '#bc8cff' + }), + theme_colors: '{}', + showcase_url: 'http://192.168.1.247:3010' +}; + +function getSettings() { + const d = getDb(); + const rows = d.prepare('SELECT key, value FROM app_settings').all(); + const s = { ...DEFAULTS }; + // Ensure defaults exist in DB + const upsert = d.prepare(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`); + for (const [k, v] of Object.entries(DEFAULTS)) { + const row = rows.find(r => r.key === k); + if (row) { + s[k] = row.value; + } else { + upsert.run(k, v); + } + } + // Also store icon_colors as parsed object + if (typeof s.icon_colors === 'string') { + try { s.icon_colors = JSON.parse(s.icon_colors); } catch (e) { s.icon_colors = JSON.parse(DEFAULTS.icon_colors); } + } + if (typeof s.theme_colors === 'string') { + try { s.theme_colors = JSON.parse(s.theme_colors || '{}'); } catch (e) { s.theme_colors = {}; } + } + if (!s.theme_colors || typeof s.theme_colors !== 'object') s.theme_colors = {}; + return s; +} + +function updateSetting(key, value) { + const d = getDb(); + if (typeof value === 'object') value = JSON.stringify(value); + d.prepare(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`).run(key, value); +} + +function logAudit(userId, username, action, entityType, entityId, details) { + const d = getDb(); + d.prepare('INSERT INTO audit_log (user_id, username, action, entity_type, entity_id, details) VALUES (?, ?, ?, ?, ?, ?)') + .run(userId || null, username || '', action, entityType, entityId || null, details || ''); +} + +function getUserSettings(userId) { + const d = getDb(); + let s = d.prepare('SELECT * FROM user_settings WHERE user_id = ?').get(userId); + if (!s) { + d.prepare('INSERT INTO user_settings (user_id) VALUES (?)').run(userId); + s = d.prepare('SELECT * FROM user_settings WHERE user_id = ?').get(userId); + } + return s; +} + +module.exports = { getDb, getSettings, updateSetting, logAudit, getUserSettings, DEFAULTS }; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bdcbc73 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,87 @@ +services: + consulting: + build: . + container_name: mek-tech-consulting + ports: + - "3000:3000" + environment: + - PORT=3000 + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-WaQTUw2t} + - SESSION_SECRET=${SESSION_SECRET:-verander-dit-naar-iets-veiligs} + - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER} + - SMTP_PASS=${SMTP_PASS} + - SMTP_FROM=${SMTP_FROM:-noreply@mek-tech.nl} + - SLACK_WEBHOOK=${SLACK_WEBHOOK} + - BASE_URL=${BASE_URL:-http://192.168.1.247:3000} + - API_KEY=${API_KEY:-mek-tech-api-key-2024} + - QS_HOST=python-quality + - QS_PORT=3002 + - NODE_ENV=production + volumes: + - ./data:/app/data + - ./lib:/app/lib + - ./routes:/app/routes + - ./views:/app/views + - ./public:/app/public + - ./locales:/app/locales + - ./server.js:/app/server.js + - ./db.js:/app/db.js + - ./themes.js:/app/themes.js + - ./advice.js:/app/advice.js + - ./email.js:/app/email.js + - ./settings.js:/app/settings.js + depends_on: + - python-quality + - python-designer + - python-leadgen + restart: unless-stopped + + python-designer: + build: ./python-editor + container_name: mek-tech-python-designer + ports: + - "3001:3001" + environment: + - DB_PATH=/app/data/consulting.db + - PY_PORT=3001 + volumes: + - ./data:/app/data + restart: unless-stopped + + python-quality: + build: ./python-quality + container_name: mek-tech-quality + ports: + - "3002:3002" + environment: + - QS_PORT=3002 + volumes: + - ./data:/app/data + restart: unless-stopped + + python-leadgen: + build: ./python-leadgen + container_name: mek-tech-leadgen + ports: + - "3003:3003" + environment: + - LEADGEN_PORT=3003 + - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY} + volumes: + - ./data:/app/data + restart: unless-stopped + + showcase: + build: ../mek-tech-showcase + container_name: mek-tech-showcase + ports: + - "3010:3010" + environment: + - PORT=3010 + - CACHE_DIR=/app/cache + volumes: + - ../mek-tech-showcase/cache:/app/cache + restart: unless-stopped diff --git a/email.js b/email.js new file mode 100644 index 0000000..d3c0b59 --- /dev/null +++ b/email.js @@ -0,0 +1,177 @@ +const Imap = require('imap'); +const nodemailer = require('nodemailer'); +const { simpleParser } = require('mailparser'); +const { getDb } = require('./db'); +const util = require('util'); + +function getAccount(accountId) { + const db = getDb(); + return db.prepare('SELECT * FROM email_accounts WHERE id = ?').get(accountId); +} + +function getUserAccounts(userId) { + const db = getDb(); + return db.prepare('SELECT id, email, name, imap_host, smtp_host, active FROM email_accounts WHERE user_id = ?').all(userId); +} + +function createImapConnection(account) { + return new Promise((resolve, reject) => { + const imap = new Imap({ + user: account.imap_user, + password: account.imap_pass, + host: account.imap_host, + port: account.imap_port || 993, + tls: account.imap_secure !== 0, + tlsOptions: { rejectUnauthorized: false }, + connTimeout: 10000, + authTimeout: 5000 + }); + imap.once('ready', () => resolve(imap)); + imap.once('error', (err) => reject(err)); + imap.connect(); + }); +} + +function openMailbox(imap, folder) { + return new Promise((resolve, reject) => { + imap.openBox(folder || 'INBOX', true, (err, box) => { + if (err) return reject(err); + resolve(box); + }); + }); +} + +function searchMessages(imap, criteria) { + return new Promise((resolve, reject) => { + imap.search(criteria, (err, uids) => { + if (err) return reject(err); + resolve(uids || []); + }); + }); +} + +function fetchMessages(imap, uids) { + return new Promise((resolve, reject) => { + if (!uids || uids.length === 0) return resolve([]); + const f = imap.fetch(uids, { bodies: '', struct: true }); + const messages = []; + f.on('message', (msg, seqno) => { + const message = { uid: null, seqno, body: '' }; + msg.on('body', (stream, info) => { + let buffer = ''; + stream.on('data', chunk => buffer += chunk.toString('utf8')); + stream.on('end', () => { message.body = buffer; }); + }); + msg.on('attributes', attrs => { + message.uid = attrs.uid; + message.flags = attrs.flags || []; + message.date = attrs.date; + message.size = attrs.size; + }); + msg.on('end', () => { if (message.uid) messages.push(message); }); + }); + f.once('error', reject); + f.once('end', () => resolve(messages)); + }); +} + +async function fetchAndStoreMessages(accountId, folder, limit) { + const account = getAccount(accountId); + if (!account) throw new Error('Account not found'); + const db = getDb(); + const imap = await createImapConnection(account); + const box = await openMailbox(imap, folder || 'INBOX'); + const total = Math.min(box.messages.total, limit || 50); + const start = Math.max(1, box.messages.total - total + 1); + const uids = []; + for (let i = start; i <= box.messages.total; i++) uids.push(i); + if (uids.length === 0) { imap.end(); return []; } + const messages = await fetchMessages(imap, uids); + imap.end(); + const results = []; + for (const msg of messages) { + try { + const parsed = await simpleParser(msg.body); + const seen = msg.flags.includes('\\Seen'); + const flagged = msg.flags.includes('\\Flagged'); + const existing = db.prepare('SELECT id FROM email_messages WHERE account_id = ? AND uid = ?').get(accountId, msg.uid); + if (!existing) { + db.prepare(`INSERT INTO email_messages (account_id, uid, folder, subject, from_name, from_addr, to_addr, date, body_text, body_html, attachments, seen, flagged, size) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( + accountId, msg.uid, folder || 'INBOX', + (parsed.subject || '').substring(0, 500), + (parsed.from && parsed.from.text || '').substring(0, 200), + (parsed.from && parsed.from.value && parsed.from.value[0] && parsed.from.value[0].address || ''), + (parsed.to && parsed.to.text || '').substring(0, 500), + (parsed.date && parsed.date.toISOString() || msg.date || ''), + (parsed.text || '').substring(0, 100000), + (parsed.html || '').substring(0, 100000), + (parsed.attachments && parsed.attachments.length) || 0, + seen ? 1 : 0, flagged ? 1 : 0, msg.size || 0 + ); + } else { + db.prepare('UPDATE email_messages SET seen=?, flagged=? WHERE id=?').run(seen ? 1 : 0, flagged ? 1 : 0, existing.id); + } + results.push({ + uid: msg.uid, subject: parsed.subject || '', from: parsed.from ? parsed.from.text : '', + date: parsed.date || msg.date, seen, flagged, size: msg.size + }); + } catch (e) { + console.error('Error parsing message:', e.message); + } + } + return results; +} + +async function sendEmail(accountId, to, subject, text, html, cc, bcc) { + const account = getAccount(accountId); + if (!account) throw new Error('Account not found'); + const transporter = nodemailer.createTransport({ + host: account.smtp_host, + port: account.smtp_port || 587, + secure: account.smtp_secure === 1, + auth: { user: account.smtp_user || account.imap_user, pass: account.smtp_pass || account.imap_pass } + }); + const info = await transporter.sendMail({ + from: `"${account.name || account.email}" <${account.email}>`, + to, subject, text, html, cc, bcc + }); + return info; +} + +async function getMailboxes(accountId) { + const account = getAccount(accountId); + if (!account) return []; + const imap = await createImapConnection(account); + const list = await new Promise((resolve, reject) => { + imap.getBoxes((err, boxes) => { + if (err) return reject(err); + resolve(boxes); + }); + }); + imap.end(); + function flatten(boxes, path) { + const result = []; + for (const [name, box] of Object.entries(boxes)) { + const fullPath = path ? `${path}${box.delimiter}${name}` : name; + result.push({ name: fullPath, delimiter: box.delimiter }); + if (box.children) result.push(...flatten(box.children, fullPath)); + } + return result; + } + return flatten(list, ''); +} + +async function getMessageBody(accountId, uid) { + const account = getAccount(accountId); + if (!account) return null; + const imap = await createImapConnection(account); + await openMailbox(imap, 'INBOX'); + const messages = await fetchMessages(imap, [uid]); + imap.end(); + if (messages.length === 0) return null; + const parsed = await simpleParser(messages[0].body); + return parsed; +} + +module.exports = { getAccount, getUserAccounts, createImapConnection, openMailbox, searchMessages, fetchMessages, fetchAndStoreMessages, sendEmail, getMailboxes, getMessageBody }; diff --git a/engagement-form.ejs b/engagement-form.ejs new file mode 100644 index 0000000..18b6991 --- /dev/null +++ b/engagement-form.ejs @@ -0,0 +1,92 @@ + + + + + + <%= engagement ? 'Bewerk' : 'Nieuw' %> Engagement — Mek-Tech Consulting + + + + + + + + +
+ Terug + + + <% if (error) { %>
<%= error %>
<% } %> + +
+ <% if (!engagement) { %> +
+ + +
+ <% } else { %> + + <% } %> + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ <% if (engagement) { %> +
+ + +
+ <% } %> +
+ + Annuleren +
+
+
+ + diff --git a/engagements.js b/engagements.js new file mode 100644 index 0000000..360f879 --- /dev/null +++ b/engagements.js @@ -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; diff --git a/invoice-form.ejs b/invoice-form.ejs new file mode 100644 index 0000000..5eae139 --- /dev/null +++ b/invoice-form.ejs @@ -0,0 +1,31 @@ +Nieuwe Factuur — Mek-Tech + +
+ Terug + +<% if (error) { %>
<%= error %>
<% } %> +
+
+
+
+
+
+
+
+
+

Factuurregels

+
+ +
+
+
+
+
+
+
+
+
Annuleren
+
+ + diff --git a/lib/ai.js b/lib/ai.js new file mode 100644 index 0000000..62faa45 --- /dev/null +++ b/lib/ai.js @@ -0,0 +1,265 @@ +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const http = require('http'); +const { getDb } = require('../db'); + +const AI_CONFIG_PATH = path.join(__dirname, '..', 'data', 'ai_config.json'); + +const PROVIDER_PRESETS = { + openrouter: { + label: 'OpenRouter', + base_url: 'https://openrouter.ai/api/v1', + models: ['qwen/qwen3-coder:free', 'meta-llama/llama-3.3-70b-instruct:free', 'google/gemma-3-27b-it:free', 'anthropic/claude-3.5-sonnet', 'openai/gpt-4o-mini'] + }, + openai: { + label: 'OpenAI', + base_url: 'https://api.openai.com/v1', + models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1-mini'] + }, + anthropic: { + label: 'Anthropic', + base_url: 'https://api.anthropic.com/v1', + models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'] + }, + deepseek: { + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + models: ['deepseek-chat', 'deepseek-reasoner'] + }, + groq: { + label: 'Groq', + base_url: 'https://api.groq.com/openai/v1', + models: ['llama-3.3-70b-versatile', 'mixtral-8x7b-32768'] + }, + ollama: { + label: 'Ollama (local)', + base_url: 'http://localhost:11434/v1', + models: ['llama3.2', 'mistral', 'codellama', 'qwen2.5'] + }, + azure: { + label: 'Azure OpenAI', + base_url: '', + models: ['gpt-4o', 'gpt-4o-mini'] + }, + custom: { + label: 'Custom (OpenAI-compatible)', + base_url: '', + models: [] + } +}; + +function initAiSchema() { + const db = getDb(); + db.exec(` + CREATE TABLE IF NOT EXISTS ai_providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + provider_type TEXT NOT NULL DEFAULT 'openrouter', + api_key TEXT DEFAULT '', + base_url TEXT DEFAULT '', + default_model TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + is_default INTEGER DEFAULT 0, + extra_headers TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS integrations ( + id TEXT PRIMARY KEY, + name TEXT DEFAULT '', + api_key TEXT DEFAULT '', + webhook_url TEXT DEFAULT '', + config TEXT DEFAULT '{}', + status TEXT DEFAULT 'inactive', + created_at TEXT DEFAULT (datetime('now')) + ); + `); + + const count = db.prepare('SELECT COUNT(*) as c FROM ai_providers').get().c; + if (count === 0 && process.env.DEEPSEEK_API_KEY) { + db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_default) + VALUES (?, ?, ?, ?, ?, 1)`).run( + 'OpenRouter Default', 'openrouter', process.env.DEEPSEEK_API_KEY, + 'https://openrouter.ai/api/v1', 'qwen/qwen3-coder:free' + ); + } +} + +let _aiSchemaReady = false; +function ensureAiSchema() { + if (_aiSchemaReady) return; + initAiSchema(); + _aiSchemaReady = true; +} + +function getProviders() { + ensureAiSchema(); + return getDb().prepare('SELECT id, name, provider_type, base_url, default_model, is_active, is_default, created_at, updated_at FROM ai_providers ORDER BY is_default DESC, name').all(); +} + +function getProvider(id) { + ensureAiSchema(); + return getDb().prepare('SELECT * FROM ai_providers WHERE id = ?').get(id); +} + +function getDefaultProvider() { + ensureAiSchema(); + let p = getDb().prepare('SELECT * FROM ai_providers WHERE is_default = 1 AND is_active = 1 LIMIT 1').get(); + if (!p) p = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1 ORDER BY id LIMIT 1').get(); + return p; +} + +function saveProvider(data) { + ensureAiSchema(); + const db = getDb(); + if (data.is_default) { + db.prepare('UPDATE ai_providers SET is_default = 0').run(); + } + if (data.id) { + db.prepare(`UPDATE ai_providers SET name=?, provider_type=?, api_key=?, base_url=?, default_model=?, + is_active=?, is_default=?, updated_at=datetime('now') WHERE id=?`).run( + data.name, data.provider_type, data.api_key || '', data.base_url || '', + data.default_model || '', data.is_active ? 1 : 0, data.is_default ? 1 : 0, data.id + ); + syncAiConfigFile(); + return data.id; + } + const r = db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_active, is_default) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run( + data.name, data.provider_type, data.api_key || '', data.base_url || '', + data.default_model || '', data.is_active !== false ? 1 : 0, data.is_default ? 1 : 0 + ); + syncAiConfigFile(); + return r.lastInsertRowid; +} + +function deleteProvider(id) { + ensureAiSchema(); + getDb().prepare('DELETE FROM ai_providers WHERE id = ?').run(id); + syncAiConfigFile(); +} + +function syncAiConfigFile() { + ensureAiSchema(); + const providers = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1').all(); + const defaultProvider = providers.find(p => p.is_default) || providers[0] || null; + const config = { + default_provider_id: defaultProvider?.id || null, + providers: providers.map(p => ({ + id: p.id, + name: p.name, + provider_type: p.provider_type, + api_key: p.api_key, + base_url: p.base_url || PROVIDER_PRESETS[p.provider_type]?.base_url || '', + default_model: p.default_model, + is_default: !!p.is_default + })) + }; + const dir = path.dirname(AI_CONFIG_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(AI_CONFIG_PATH, JSON.stringify(config, null, 2)); + + const leadgenConfig = { + api_key: defaultProvider?.api_key || process.env.DEEPSEEK_API_KEY || '', + base_url: defaultProvider?.base_url || 'https://openrouter.ai/api/v1', + model: defaultProvider?.default_model || 'qwen/qwen3-coder:free', + provider_type: defaultProvider?.provider_type || 'openrouter' + }; + fs.writeFileSync(path.join(dir, 'leadgen_config.json'), JSON.stringify(leadgenConfig, null, 2)); +} + +function maskKey(key) { + if (!key || key.length < 8) return key ? '••••••••' : ''; + return key.slice(0, 6) + '••••' + key.slice(-4); +} + +function chatCompletion(messages, options = {}) { + return new Promise((resolve, reject) => { + const provider = options.providerId + ? getProvider(options.providerId) + : getDefaultProvider(); + if (!provider || !provider.api_key) { + return reject(new Error('Geen actieve AI provider geconfigureerd. Ga naar /ai om een provider toe te voegen.')); + } + + const preset = PROVIDER_PRESETS[provider.provider_type] || PROVIDER_PRESETS.custom; + const baseUrl = provider.base_url || preset.base_url; + const model = options.model || provider.default_model || preset.models[0]; + + let url; + try { + url = new URL('/chat/completions', baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'); + } catch (e) { + return reject(new Error('Ongeldige base URL: ' + baseUrl)); + } + + const body = JSON.stringify({ + model, + messages, + temperature: options.temperature ?? 0.7, + max_tokens: options.max_tokens ?? 4096 + }); + + const isHttps = url.protocol === 'https:'; + const lib = isHttps ? https : http; + const headers = { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + }; + + if (provider.provider_type === 'anthropic') { + headers['x-api-key'] = provider.api_key; + headers['anthropic-version'] = '2023-06-01'; + } else { + headers['Authorization'] = `Bearer ${provider.api_key}`; + } + if (provider.provider_type === 'openrouter') { + headers['HTTP-Referer'] = process.env.BASE_URL || 'http://localhost:3000'; + headers['X-Title'] = 'Mek-Tech Platform'; + } + + const reqOpts = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname, + method: 'POST', + headers, + timeout: 120000 + }; + + const req = lib.request(reqOpts, (res) => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + if (res.statusCode >= 400) { + reject(new Error(parsed.error?.message || parsed.message || data.slice(0, 200))); + } else { + resolve(parsed); + } + } catch (e) { + reject(new Error('AI response parse error: ' + data.slice(0, 200))); + } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('AI request timeout')); }); + req.write(body); + req.end(); + }); +} + +module.exports = { + PROVIDER_PRESETS, + initAiSchema: ensureAiSchema, + getProviders, + getProvider, + getDefaultProvider, + saveProvider, + deleteProvider, + syncAiConfigFile, + maskKey, + chatCompletion +}; diff --git a/lib/backup.js b/lib/backup.js new file mode 100644 index 0000000..78972d1 --- /dev/null +++ b/lib/backup.js @@ -0,0 +1,279 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execSync } = require('child_process'); +const { getDb } = require('../db'); + +const APP_VERSION = '2.0.0'; +const MAX_STORED_BACKUPS = 20; + +function getPaths() { + const root = path.join(__dirname, '..'); + const dataDir = process.env.DB_PATH + ? path.dirname(path.resolve(process.env.DB_PATH)) + : path.join(root, 'data'); + return { + root, + dataDir, + dbPath: process.env.DB_PATH || path.join(dataDir, 'consulting.db'), + sessionsPath: path.join(dataDir, 'sessions.db'), + uploadsDir: path.join(dataDir, 'uploads'), + backupsDir: path.join(dataDir, 'backups') + }; +} + +function ensureBackupsDir() { + const { backupsDir } = getPaths(); + if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true }); + return backupsDir; +} + +function checkpointDatabases() { + try { + const db = getDb(); + db.pragma('wal_checkpoint(TRUNCATE)'); + } catch (e) { /* ignore */ } + const { sessionsPath } = getPaths(); + if (fs.existsSync(sessionsPath)) { + try { + const Database = require('better-sqlite3'); + const sdb = new Database(sessionsPath); + sdb.pragma('wal_checkpoint(TRUNCATE)'); + sdb.close(); + } catch (e) { /* ignore */ } + } +} + +function collectStats() { + const db = getDb(); + const tables = ['clients', 'engagements', 'racks', 'rack_devices', 'data_diagrams', 'projects', 'invoices', 'assessments', 'users']; + const counts = {}; + for (const t of tables) { + try { + counts[t] = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c; + } catch (e) { + counts[t] = 0; + } + } + const { dbPath, sessionsPath, uploadsDir, dataDir } = getPaths(); + let uploadsBytes = 0; + let uploadFiles = 0; + if (fs.existsSync(uploadsDir)) { + const walk = (dir) => { + for (const name of fs.readdirSync(dir)) { + const p = path.join(dir, name); + const st = fs.statSync(p); + if (st.isDirectory()) walk(p); + else { uploadsBytes += st.size; uploadFiles++; } + } + }; + walk(uploadsDir); + } + return { + counts, + dbSizeBytes: fs.existsSync(dbPath) ? fs.statSync(dbPath).size : 0, + sessionsSizeBytes: fs.existsSync(sessionsPath) ? fs.statSync(sessionsPath).size : 0, + uploadsBytes, + uploadFiles, + dataDir + }; +} + +function formatBytes(n) { + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; + return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'; +} + +function timestampLabel() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; +} + +function safeBackupName(name) { + return /^mek-tech-backup-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}\.tar\.gz$/.test(name); +} + +function pruneOldBackups() { + const list = listBackups(); + if (list.length <= MAX_STORED_BACKUPS) return; + const toRemove = list.slice(MAX_STORED_BACKUPS); + for (const b of toRemove) { + try { fs.unlinkSync(b.path); } catch (e) { /* ignore */ } + } +} + +function createBackup() { + ensureBackupsDir(); + checkpointDatabases(); + + const paths = getPaths(); + const stats = collectStats(); + const label = timestampLabel(); + const bundleName = `mek-tech-backup-${label}`; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-backup-')); + const bundleDir = path.join(tmpDir, bundleName); + + try { + fs.mkdirSync(bundleDir, { recursive: true }); + + const manifest = { + app: 'mek-tech-consulting-framework', + version: APP_VERSION, + created_at: new Date().toISOString(), + stats: stats.counts, + includes: [] + }; + + if (fs.existsSync(paths.dbPath)) { + fs.copyFileSync(paths.dbPath, path.join(bundleDir, 'consulting.db')); + manifest.includes.push('consulting.db'); + } + if (fs.existsSync(paths.sessionsPath)) { + fs.copyFileSync(paths.sessionsPath, path.join(bundleDir, 'sessions.db')); + manifest.includes.push('sessions.db'); + } + if (fs.existsSync(paths.uploadsDir)) { + copyDirSync(paths.uploadsDir, path.join(bundleDir, 'uploads')); + manifest.includes.push('uploads/'); + } + + fs.writeFileSync(path.join(bundleDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + + const archiveName = `${bundleName}.tar.gz`; + const archivePath = path.join(paths.backupsDir, archiveName); + execSync(`tar -czf "${archivePath}" -C "${tmpDir}" "${bundleName}"`, { stdio: 'pipe' }); + + const size = fs.statSync(archivePath).size; + pruneOldBackups(); + + return { + filename: archiveName, + path: archivePath, + size, + sizeLabel: formatBytes(size), + created_at: manifest.created_at, + stats: stats.counts + }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +function copyDirSync(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const name of fs.readdirSync(src)) { + const s = path.join(src, name); + const d = path.join(dest, name); + if (fs.statSync(s).isDirectory()) copyDirSync(s, d); + else fs.copyFileSync(s, d); + } +} + +function listBackups() { + ensureBackupsDir(); + const { backupsDir } = getPaths(); + if (!fs.existsSync(backupsDir)) return []; + return fs.readdirSync(backupsDir) + .filter(f => f.endsWith('.tar.gz') && safeBackupName(f)) + .map(f => { + const p = path.join(backupsDir, f); + const st = fs.statSync(p); + return { + filename: f, + path: p, + size: st.size, + sizeLabel: formatBytes(st.size), + created_at: st.mtime.toISOString() + }; + }) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); +} + +function getBackupPath(filename) { + if (!safeBackupName(filename)) return null; + const p = path.join(getPaths().backupsDir, filename); + return fs.existsSync(p) ? p : null; +} + +function deleteBackup(filename) { + const p = getBackupPath(filename); + if (!p) return false; + fs.unlinkSync(p); + return true; +} + +function validateArchive(archivePath) { + if (!fs.existsSync(archivePath)) throw new Error('Backupbestand niet gevonden'); + const listing = execSync(`tar -tzf "${archivePath}"`, { encoding: 'utf8' }); + if (!listing.includes('manifest.json')) throw new Error('Ongeldig backup: manifest.json ontbreekt'); + if (!listing.includes('consulting.db')) throw new Error('Ongeldig backup: consulting.db ontbreekt'); + return listing; +} + +function restoreBackup(archivePath) { + validateArchive(archivePath); + + const auto = createBackup(); + const paths = getPaths(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-restore-')); + + try { + execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { stdio: 'pipe' }); + const entries = fs.readdirSync(tmpDir); + const bundleDir = entries.length === 1 ? path.join(tmpDir, entries[0]) : tmpDir; + + const manifestPath = path.join(bundleDir, 'manifest.json'); + if (!fs.existsSync(manifestPath)) throw new Error('manifest.json niet gevonden in backup'); + + checkpointDatabases(); + + const dbDest = paths.dbPath; + const dbSrc = path.join(bundleDir, 'consulting.db'); + if (fs.existsSync(dbSrc)) { + for (const ext of ['-wal', '-shm']) { + const sidecar = dbDest + ext; + if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar); + } + fs.copyFileSync(dbSrc, dbDest); + } + + const sessSrc = path.join(bundleDir, 'sessions.db'); + if (fs.existsSync(sessSrc)) { + for (const ext of ['-wal', '-shm']) { + const sidecar = paths.sessionsPath + ext; + if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar); + } + fs.copyFileSync(sessSrc, paths.sessionsPath); + } + + const uploadsSrc = path.join(bundleDir, 'uploads'); + if (fs.existsSync(uploadsSrc)) { + if (fs.existsSync(paths.uploadsDir)) fs.rmSync(paths.uploadsDir, { recursive: true, force: true }); + copyDirSync(uploadsSrc, paths.uploadsDir); + } + + return { + success: true, + preRestoreBackup: auto.filename, + restored_at: new Date().toISOString() + }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +module.exports = { + getPaths, + collectStats, + formatBytes, + createBackup, + listBackups, + getBackupPath, + deleteBackup, + restoreBackup, + safeBackupName, + APP_VERSION +}; diff --git a/lib/consulting-hub.js b/lib/consulting-hub.js new file mode 100644 index 0000000..5f21207 --- /dev/null +++ b/lib/consulting-hub.js @@ -0,0 +1,90 @@ +/** Mek-Tech Consultancy Hub — toolkits, playbooks & delivery frameworks */ + +const TOOLKIT = [ + { + id: 'discovery', + title: 'Discovery & Assessment', + desc: 'Intake, maturity scans & stakeholder alignment', + color: '#00d4ff', + tools: [ + { icon: 'brain', label: 'Enterprise Framework', href: '/consulting', desc: '5-staps solution architect assessment + roadmap' }, + { icon: 'clipboard-list', label: 'Quick Assessment', href: '/assess', desc: 'Snelle infra/data/AI intake per cliënt' }, + { icon: 'search', label: 'LeadGen AI Scan', href: '/?tab=leadgen', desc: 'Website tech-stack & pain point analyse' }, + { icon: 'building-2', label: 'Market Intel', href: '/market', desc: 'Prospects, datacenters & sector mapping' }, + { icon: 'users', label: 'Cliënt 360°', href: '/clients', desc: 'Volledig klantbeeld & engagement history' }, + ] + }, + { + id: 'architecture', + title: 'Architectuur & Design', + desc: 'Data, systeem & netwerk ontwerp', + color: '#a855f7', + tools: [ + { icon: 'layers', label: 'Architectuur Designer', href: '/architecture', desc: 'Visueel data-architectuur canvas' }, + { icon: 'pen-tool', label: 'Pro Designer', href: '/architecture/designer', desc: 'Geavanceerde Python design editor' }, + { icon: 'server', label: 'Rack & DC Design', href: '/racks', desc: 'Server racks, PDU, koeling & patching' }, + { icon: 'wifi', label: 'Netwerk Topologie', href: '/networking', desc: 'Switches, firewalls & verbindingen' }, + { icon: 'calculator', label: 'Sizing Calculator', href: '/sizing', desc: 'Capaciteit & hardware sizing' }, + ] + }, + { + id: 'data-ai', + title: 'Data, AI & Kwaliteit', + desc: 'Profiling, governance & AI readiness', + color: '#00ff88', + tools: [ + { icon: 'shield-check', label: 'Data Quality', href: '/quality', desc: 'Profiling, validatie & kwaliteitsrapporten' }, + { icon: 'bot', label: 'AI Providers', href: '/ai', desc: 'Multi-provider LLM configuratie' }, + { icon: 'database', label: 'Data Modellen', href: '/quality', desc: 'SQL schema & dbt model editor' }, + { icon: 'file-bar-chart', label: 'Rapporten', href: '/reports', desc: 'Consultancy deliverables exporteren' }, + { icon: 'plug', label: 'Integraties', href: '/integrations', desc: 'Externe systemen koppelen' }, + ] + }, + { + id: 'delivery', + title: 'Delivery & Operations', + desc: 'Projectuitvoering, taken & tijd', + color: '#ffb347', + tools: [ + { icon: 'briefcase', label: 'Engagements', href: '/engagements', desc: 'Projecten per fase beheren' }, + { icon: 'check-square', label: 'Taken & Deadlines', href: '/?tab=tasks', desc: 'Werkpakketten & prioriteiten' }, + { icon: 'clock', label: 'Urenregistratie', href: '/time', desc: 'Billable hours & project tracking' }, + { icon: 'folder-kanban', label: 'Projecten', href: '/projects', desc: 'Portfolio & milestone overzicht' }, + { icon: 'calendar', label: 'Planning', href: '/calendar', desc: 'Afspraken, reviews & deadlines' }, + ] + }, + { + id: 'commercial', + title: 'Commercial & Relatie', + desc: 'Offertes, facturatie & communicatie', + color: '#ff6b8a', + tools: [ + { icon: 'receipt', label: 'Facturatie', href: '/invoices', desc: 'Facturen & betalingsstatus' }, + { icon: 'bar-chart-2', label: 'Finance Dashboard', href: '/finance', desc: 'Omzet, marge & outstanding' }, + { icon: 'mail', label: 'Email Hub', href: '/email', desc: 'Klantcommunicatie & follow-ups' }, + { icon: 'bell', label: 'Notificaties', href: '/notifications', desc: 'Alerts & follow-up reminders' }, + { icon: 'file-text', label: 'Audit Trail', href: '/audit', desc: 'Alle consultancy acties gelogd' }, + ] + } +]; + +const PLAYBOOKS = [ + { phase: '1', title: 'Intake & Discovery', steps: ['Kick-off meeting', 'Stakeholder mapping', 'Enterprise assessment', 'Pain points documenteren'], duration: '1-2 weken', color: '#00d4ff' }, + { phase: '2', title: 'Analyse & Design', steps: ['As-is architectuur', 'Gap analyse', 'To-be design', 'Sizing & kostenraming'], duration: '2-4 weken', color: '#a855f7' }, + { phase: '3', title: 'Validatie & Proposal', steps: ['Design review', 'POC / pilot scope', 'Offerte & SOW', 'Go/no-go beslissing'], duration: '1-2 weken', color: '#00ff88' }, + { phase: '4', title: 'Implementatie', steps: ['Projectplan & sprints', 'Infra provisioning', 'Data pipelines', 'Test & acceptatie'], duration: '4-12 weken', color: '#ffb347' }, + { phase: '5', title: 'Run & Optimize', steps: ['Monitoring opzetten', 'Knowledge transfer', 'Retainer / SLA', 'Continuous improvement'], duration: 'Doorlopend', color: '#ff6b8a' }, +]; + +const MATURITY_LAYERS = [ + { name: 'Data Foundation', items: ['Bronnen in kaart', 'Data governance', 'Quality profiling', 'Warehouse / Lakehouse'] }, + { name: 'Platform & Infra', items: ['Cloud / hybrid strategie', 'Netwerk & security', 'Compute sizing', 'Observability'] }, + { name: 'Analytics & AI', items: ['BI maturity', 'ML use cases', 'LLM / GenAI readiness', 'MLOps pipeline'] }, + { name: 'Organisatie', items: ['Team capabilities', 'Change management', 'Training plan', 'Operating model'] }, +]; + +function getConsultingToolkit() { + return { toolkit: TOOLKIT, playbooks: PLAYBOOKS, maturityLayers: MATURITY_LAYERS }; +} + +module.exports = { getConsultingToolkit, TOOLKIT, PLAYBOOKS, MATURITY_LAYERS }; diff --git a/lib/crm.js b/lib/crm.js new file mode 100644 index 0000000..fad10a6 --- /dev/null +++ b/lib/crm.js @@ -0,0 +1,50 @@ +const { getDb } = require('../db'); + +function crmContextMiddleware(req, res, next) { + const db = getDb(); + const qClientId = req.query.client_id || req.query.clientId; + if (qClientId && req.session) { + req.session.activeClientId = parseInt(qClientId, 10) || null; + } + + const activeClientId = (req.session && req.session.activeClientId) || null; + let activeClient = null; + if (activeClientId) { + activeClient = db.prepare('SELECT id, name, status, industry FROM clients WHERE id = ?').get(activeClientId); + if (!activeClient) req.session.activeClientId = null; + } + + const allClients = db.prepare('SELECT id, name, status FROM clients ORDER BY name').all(); + + res.locals.activeClient = activeClient; + res.locals.activeClientId = activeClient ? activeClient.id : null; + res.locals.allClients = allClients; + res.locals.clientQuery = (path) => { + if (!activeClient) return path; + const sep = path.includes('?') ? '&' : '?'; + return `${path}${sep}client_id=${activeClient.id}`; + }; + + next(); +} + +function getClientHubLinks(clientId) { + const id = clientId; + const q = `?client_id=${id}`; + return [ + { href: `/clients/${id}`, icon: 'eye', label: 'Overzicht' }, + { href: `/engagements/new${q}`, icon: 'briefcase', label: 'Engagement' }, + { href: `/consulting${q}`, icon: 'clipboard-list', label: 'Framework' }, + { href: `/assess/${id}`, icon: 'file-search', label: 'Assessment' }, + { href: `/architecture/new/${id}`, icon: 'network', label: 'Design' }, + { href: `/architecture/designer${q}`, icon: 'pen-tool', label: 'Pro Designer' }, + { href: `/quality${q}`, icon: 'shield-check', label: 'Data Quality' }, + { href: `/racks${q}`, icon: 'server', label: 'Racks' }, + { href: `/networking${q}`, icon: 'share-2', label: 'Netwerk' }, + { href: `/time${q}`, icon: 'clock', label: 'Uren' }, + { href: `/invoices/new${q}`, icon: 'receipt', label: 'Factuur' }, + { href: `/email/compose${q}`, icon: 'mail', label: 'Email' } + ]; +} + +module.exports = { crmContextMiddleware, getClientHubLinks }; diff --git a/lib/nav.js b/lib/nav.js new file mode 100644 index 0000000..31fe113 --- /dev/null +++ b/lib/nav.js @@ -0,0 +1,63 @@ +const NAV_SECTIONS = [ + { + label: 'CRM', + items: [ + { href: '/', icon: 'layout-dashboard', label: 'Dashboard', match: /^\/(\?.*)?$/ }, + { href: '/clients', icon: 'users', label: 'Cliënten', match: /^\/clients/ }, + { href: '/engagements', icon: 'briefcase', label: 'Engagements', match: /^\/engagements/ }, + { href: '/tasks', icon: 'check-circle', label: 'Taken', match: /^\/tasks/ }, + { href: '/calendar', icon: 'calendar', label: 'Agenda', match: /^\/calendar/ }, + { href: '/email', icon: 'mail', label: 'Email', match: /^\/email/ } + ] + }, + { + label: 'Delivery', + items: [ + { href: '/consulting', icon: 'clipboard-list', label: 'Framework', match: /^\/consulting/ }, + { href: '/assess', icon: 'file-search', label: 'Assessments', match: /^\/assess/ }, + { href: '/projects', icon: 'folder-kanban', label: 'Projecten', match: /^\/projects/ }, + { href: '/time', icon: 'clock', label: 'Uren', match: /^\/time/ }, + { href: '/invoices', icon: 'receipt', label: 'Facturen', match: /^\/invoices/ }, + { href: '/reports', icon: 'bar-chart-3', label: 'Rapporten', match: /^\/reports/ } + ] + }, + { + label: 'Data Engineering', + items: [ + { href: '/quality', icon: 'shield-check', label: 'Data Quality', match: /^\/quality/ }, + { href: '/architecture', icon: 'network', label: 'Architecture', match: /^\/architecture/ }, + { href: '/architecture/designer', icon: 'pen-tool', label: 'Pro Designer', match: /^\/architecture\/designer/ }, + { href: '/market', icon: 'radar', label: 'Market Intel', match: /^\/market/ }, + { href: '/sizing', icon: 'cpu', label: 'Sizing', match: /^\/sizing/ } + ] + }, + { + label: 'Infrastructure', + items: [ + { href: '/racks', icon: 'server', label: 'Racks', match: /^\/racks/ }, + { href: '/networking', icon: 'share-2', label: 'Netwerk', match: /^\/networking/ }, + { href: '/infrastructure', icon: 'building-2', label: 'Infra', match: /^\/infrastructure/ } + ] + }, + { + label: 'Platform', + items: [ + { href: '/finance', icon: 'wallet', label: 'Finance', match: /^\/finance/ }, + { href: '/ai', icon: 'sparkles', label: 'AI Providers', match: /^\/ai/ }, + { href: '/integrations', icon: 'plug', label: 'Integraties', match: /^\/integrations/ }, + { href: '/settings', icon: 'settings', label: 'Instellingen', match: /^\/settings/ } + ] + } +]; + +function getNavSections(activePath) { + return NAV_SECTIONS.map(section => ({ + ...section, + items: section.items.map(item => ({ + ...item, + active: item.match.test(activePath || '/') + })) + })); +} + +module.exports = { NAV_SECTIONS, getNavSections }; diff --git a/lib/rack-diagram.js b/lib/rack-diagram.js new file mode 100644 index 0000000..26e35d6 --- /dev/null +++ b/lib/rack-diagram.js @@ -0,0 +1,124 @@ +const DEVICE_TO_NODE = { + server: 'hw-dell-pe', + switch: 'net-catalyst', + storage: 'storage-netapp', + firewall: 'net-fortinet', + router: 'net-junper-srx', + loadbalancer: 'net-f5', + san: 'storage-pure', + pdu: 'infra-pdu', + ups: 'infra-apc-ups', + cooling: 'infra-cooling', + patch_panel: 'infra-patch-panel', + tape: 'storage-netapp', + kvm: 'infra-rack', + other: 'infra-rack' +}; + +const MANUFACTURER_NODE = { + hpe: 'hw-hpe-dl', + dell: 'hw-dell-pe', + lenovo: 'hw-lenovo-ts', + cisco: 'net-catalyst', + juniper: 'net-junper-ex', + arista: 'net-arista', + netapp: 'storage-netapp', + pure: 'storage-pure', + fortinet: 'net-fortinet', + f5: 'net-f5', + palo: 'net-paloalto', + nvidia: 'ai-nvidia', + apc: 'infra-apc-ups' +}; + +const DIAGRAM_TYPE_LABELS = { + data_architecture: 'Data Architectuur', + network_topology: 'Netwerk Topologie', + system_architecture: 'Systeem Architectuur', + application_flow: 'Applicatie Flow', + application_landscape: 'Applicatie Landschap' +}; + +function resolveNodeType(device) { + const m = (device.manufacturer || '').toLowerCase(); + for (const [key, type] of Object.entries(MANUFACTURER_NODE)) { + if (m.includes(key)) return type; + } + return DEVICE_TO_NODE[device.device_type] || 'hw-dell-pe'; +} + +function rackDevicesToNodes(devices, startX = 40, startY = 40) { + return devices.map((d, i) => ({ + id: 'rd-' + d.id, + type: resolveNodeType(d), + x: startX + (i % 4) * 180, + y: startY + Math.floor(i / 4) * 90, + label: d.name, + sub: (d.model || d.device_type || '').toString(), + css: 'node-hw', + w: 160, + h: 48, + ip: d.mgmt_ip || '', + vendor: d.manufacturer || '', + model: d.model || '', + specs: d.specs || '', + notes: d.notes || '', + rack_device_id: d.id, + layer: 'infra' + })); +} + +function parseDiagramJson(diagram) { + let nodes = []; + let edges = []; + try { + nodes = JSON.parse(diagram.nodes || '[]'); + edges = JSON.parse(diagram.edges || '[]'); + } catch (e) { /* ignore */ } + if (!Array.isArray(nodes) && nodes && nodes.nodes) { + edges = nodes.edges || []; + nodes = nodes.nodes || []; + } + if (!Array.isArray(edges) && edges && edges.edges) { + nodes = edges.nodes || nodes; + edges = edges.edges || []; + } + if (!Array.isArray(nodes)) nodes = []; + if (!Array.isArray(edges)) edges = []; + return { nodes, edges }; +} + +function summarizeDiagram(diagram) { + const { nodes, edges } = parseDiagramJson(diagram); + const layers = {}; + nodes.forEach(n => { + const layer = n.layer || (n.type || '').split('-')[0] || 'other'; + layers[layer] = (layers[layer] || 0) + 1; + }); + const protocols = {}; + edges.forEach(e => { + const p = e.protocol || 'unspecified'; + protocols[p] = (protocols[p] || 0) + 1; + }); + return { nodes, edges, layers, protocols, nodeCount: nodes.length, edgeCount: edges.length }; +} + +function getLayerForCategory(catLabel) { + const c = (catLabel || '').toLowerCase(); + if (c.includes('data bron') || c.includes('ingest') || c.includes('opslag') || c.includes('database')) return 'data'; + if (c.includes('microservice') || c.includes('apps')) return 'app'; + if (c.includes('api') || c.includes('messaging') || c.includes('queue')) return 'integration'; + if (c.includes('netwerk') || c.includes('compute / server') || c.includes('infrastructure') || c.includes('security') || c.includes('observability') || c.includes('ci/cd')) return 'infra'; + if (c.includes('ai')) return 'app'; + return 'other'; +} + +module.exports = { + DEVICE_TO_NODE, + DIAGRAM_TYPE_LABELS, + resolveNodeType, + rackDevicesToNodes, + parseDiagramJson, + summarizeDiagram, + getLayerForCategory +}; diff --git a/lib/services.js b/lib/services.js new file mode 100644 index 0000000..bfa26d1 --- /dev/null +++ b/lib/services.js @@ -0,0 +1,29 @@ +const http = require('http'); + +function checkService(host, port, path, timeout = 3000) { + return new Promise((resolve) => { + const req = http.request({ hostname: host, port, path, method: 'GET', timeout }, (res) => { + resolve({ online: res.statusCode < 500, status: res.statusCode }); + }); + req.on('error', () => resolve({ online: false, status: 0 })); + req.on('timeout', () => { req.destroy(); resolve({ online: false, status: 0 }); }); + req.end(); + }); +} + +async function getServicesStatus() { + const qsHost = process.env.QS_HOST || 'python-quality'; + const [quality, designer, leadgen] = await Promise.all([ + checkService(qsHost, 3002, '/python-quality/'), + checkService('python-designer', 3001, '/python-editor/'), + checkService('python-leadgen', 3003, '/python-leadgen/health') + ]); + return { + consulting: { online: true, label: 'Mek-Tech Core', port: 3000 }, + quality: { ...quality, label: 'Data Quality', port: 3002 }, + designer: { ...designer, label: 'Pro Designer', port: 3001 }, + leadgen: { ...leadgen, label: 'Lead Generation', port: 3003 } + }; +} + +module.exports = { checkService, getServicesStatus }; diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..e544a26 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,49 @@ +{ + "app_name": "Mek-Tech Consulting", + "dashboard": "Dashboard", + "clients": "Clients", + "engagements": "Engagements", + "tasks": "Tasks", + "settings": "Settings", + "logout": "Logout", + "login": "Login", + "username": "Username", + "password": "Password", + "forgot_password": "Forgot password?", + "new_client": "New client", + "new_engagement": "New engagement", + "new_task": "New task", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "search": "Search", + "no_results": "No results", + "total": "Total", + "active": "Active", + "lead": "Lead", + "paused": "Paused", + "archived": "Archived", + "status": "Status", + "type": "Type", + "date": "Date", + "description": "Description", + "notes": "Notes", + "hours": "Hours", + "time_registration": "Time Registration", + "invoices": "Invoices", + "new_invoice": "New invoice", + "calendar": "Calendar", + "notifications": "Notifications", + "users": "Users", + "audit_log": "Audit Log", + "infrastructure": "Infrastructure", + "architecture": "Architecture", + "networking": "Networking", + "racks": "Racks", + "assessments": "Assessments", + "reports": "Reports", + "export_pdf": "Export PDF", + "welcome": "Welcome", + "overview": "Overview" +} diff --git a/locales/index.js b/locales/index.js new file mode 100644 index 0000000..6af9b91 --- /dev/null +++ b/locales/index.js @@ -0,0 +1,34 @@ +const fs = require('fs'); +const path = require('path'); + +const cache = {}; + +function loadLocale(lang) { + if (cache[lang]) return cache[lang]; + try { + const filePath = path.join(__dirname, lang + '.json'); + if (fs.existsSync(filePath)) { + cache[lang] = JSON.parse(fs.readFileSync(filePath, 'utf8')); + return cache[lang]; + } + } catch (e) {} + return loadLocale('nl'); +} + +function t(lang, key, ...args) { + const dict = loadLocale(lang || 'nl'); + let val = dict[key] || key; + if (args.length > 0) { + args.forEach((a, i) => { val = val.replace(`{${i}}`, a); }); + } + return val; +} + +function localeMiddleware(req, res, next) { + const lang = (req.session && req.session.lang) || 'nl'; + res.locals.__ = (key, ...args) => t(lang, key, ...args); + res.locals.lang = lang; + next(); +} + +module.exports = { t, loadLocale, localeMiddleware }; diff --git a/locales/nl.json b/locales/nl.json new file mode 100644 index 0000000..d694ac6 --- /dev/null +++ b/locales/nl.json @@ -0,0 +1,49 @@ +{ + "app_name": "Mek-Tech Consulting", + "dashboard": "Dashboard", + "clients": "Cliënten", + "engagements": "Engagements", + "tasks": "Taken", + "settings": "Instellingen", + "logout": "Uitloggen", + "login": "Inloggen", + "username": "Gebruikersnaam", + "password": "Wachtwoord", + "forgot_password": "Wachtwoord vergeten?", + "new_client": "Nieuwe cliënt", + "new_engagement": "Nieuw engagement", + "new_task": "Nieuwe taak", + "save": "Opslaan", + "cancel": "Annuleren", + "delete": "Verwijderen", + "edit": "Bewerken", + "search": "Zoeken", + "no_results": "Geen resultaten", + "total": "Totaal", + "active": "Actief", + "lead": "Lead", + "paused": "Gepauzeerd", + "archived": "Gearchiveerd", + "status": "Status", + "type": "Type", + "date": "Datum", + "description": "Omschrijving", + "notes": "Notities", + "hours": "Uren", + "time_registration": "Urenregistratie", + "invoices": "Facturen", + "new_invoice": "Nieuwe factuur", + "calendar": "Agenda", + "notifications": "Meldingen", + "users": "Gebruikers", + "audit_log": "Audit Log", + "infrastructure": "Infrastructuur", + "architecture": "Architectuur", + "networking": "Netwerk", + "racks": "Racks", + "assessments": "Assessments", + "reports": "Rapporten", + "export_pdf": "Exporteer PDF", + "welcome": "Welkom", + "overview": "Overzicht" +} diff --git a/market-intel.ejs b/market-intel.ejs new file mode 100644 index 0000000..fd59c4c --- /dev/null +++ b/market-intel.ejs @@ -0,0 +1,189 @@ +Market Intelligence — Mek-Tech + + +
+

Market Intelligence

Dutch datacenters & data-driven enterprises — filter, search and explore on the map

+ +
+
0datacenters
+
0 MWpower
+
0prospects
+
0DW vendors
+
0industries
+
0regions
+
+ +
+ + + +
+ +
+ + + + + + + + +
+ +
+
+
+
+ +
+
+
CompanyData WarehouseBI / CloudAI / DatabaseContact
+
+ +
+
+
+Tier IV +Tier III +Hyperscale +Company HQ +Edge/Regional +
+
+
+ + + + \ No newline at end of file diff --git a/market.js b/market.js new file mode 100644 index 0000000..db827a9 --- /dev/null +++ b/market.js @@ -0,0 +1,101 @@ +const express = require('express'); +const router = express.Router(); +const { getDb } = require('../db'); + +const CITY_COORDS = { + 'Amsterdam':{lat:52.3676,lng:4.9041},'Rotterdam':{lat:51.9244,lng:4.4777}, + 'Den Haag':{lat:52.0705,lng:4.3007},'Utrecht':{lat:52.0907,lng:5.1214}, + 'Eindhoven':{lat:51.4416,lng:5.4697},'Groningen':{lat:53.2194,lng:6.5665}, + 'Tilburg':{lat:51.5719,lng:5.0672},'Almere':{lat:52.3508,lng:5.2647}, + 'Haarlem':{lat:52.3874,lng:4.6462},'Nijmegen':{lat:51.8126,lng:5.8372}, + 'Arnhem':{lat:51.9969,lng:6.1832},'Amersfoort':{lat:52.1561,lng:5.3878}, + 'Apeldoorn':{lat:52.2112,lng:5.9474},'Zwolle':{lat:52.5168,lng:6.0830}, + 'Enschede':{lat:52.2215,lng:6.8937},'Maastricht':{lat:50.8514,lng:5.6900}, + 'Leiden':{lat:52.1601,lng:4.4970},'Delft':{lat:51.9999,lng:4.3540}, + 'Breda':{lat:51.5719,lng:4.7683},'Hengelo':{lat:52.2636,lng:6.7945}, + 'Dronten':{lat:52.5689,lng:5.7175},'Schiphol-Rijk':{lat:52.2845,lng:4.7555}, + 'Schiphol':{lat:52.3105,lng:4.7683},'Zaandam':{lat:52.4382,lng:4.8158}, + 'Veldhoven':{lat:51.4184,lng:5.4030},'Deventer':{lat:52.2661,lng:6.1557}, + 'Wageningen':{lat:51.9692,lng:5.6653},'Eygelshoven':{lat:50.7639,lng:6.0473}, + 'Veghel':{lat:51.6665,lng:5.5556},'Leusden':{lat:52.1418,lng:5.4165}, + 'Rosmalen':{lat:51.6699,lng:5.7867},'Eemshaven':{lat:53.4430,lng:6.8530}, + 'Sliedrecht':{lat:51.8220,lng:4.7733} +}; + +router.get('/', (req, res) => { + const db = getDb(); + const allDcs = db.prepare('SELECT * FROM datacenters').all(); + const allCompanies = db.prepare('SELECT * FROM tech_companies').all(); + const regions = [...new Set(allDcs.map(d => d.region).filter(Boolean))].sort(); + const industries = [...new Set(allCompanies.map(c => c.industry).filter(Boolean))].sort(); + const dwVendors = [...new Set(allCompanies.flatMap(c => (c.data_warehouse||'').split(',').map(s=>s.trim()).filter(Boolean)))].sort(); + const allVendors = [...new Set(allDcs.flatMap(d => ((d.network_vendors||'')+','+(d.server_vendors||'')+','+(d.storage_vendors||'')).split(',').map(s=>s.trim()).filter(Boolean)))].sort(); + const cities = [...new Set(allCompanies.map(c => c.city).filter(Boolean))].sort(); + const totalPower = allDcs.reduce((s,d) => s + (d.power_mw||0), 0); + + const dcMapPoints = allDcs.map(d => ({ + name: d.name, city: d.city, region: d.region, operator: d.operator, + power: d.power_mw, tier: d.tier, type: d.type, + lat: (CITY_COORDS[d.city] || {}).lat || 52.0, + lng: (CITY_COORDS[d.city] || {}).lng || 5.0 + })); + + res.render('market-intel', { + datacenters: allDcs, companies: allCompanies, regions, industries, dwVendors, allVendors, cities, + totalPower, dcMapPoints + }); +}); + +// Realtime API: search datacenters +router.get('/search/dc', (req, res) => { + const db = getDb(); + const { search, vendor, region, type, sort } = req.query; + let sql = 'SELECT * FROM datacenters WHERE 1=1'; + const params = []; + if (search) { sql += ' AND (name LIKE ? OR operator LIKE ? OR city LIKE ? OR network_vendors LIKE ? OR server_vendors LIKE ? OR storage_vendors LIKE ?)'; const s = '%'+search+'%'; params.push(s,s,s,s,s,s); } + if (vendor) { sql += ' AND (network_vendors LIKE ? OR server_vendors LIKE ? OR storage_vendors LIKE ?)'; const v = '%'+vendor+'%'; params.push(v,v,v); } + if (region) { sql += ' AND region LIKE ?'; params.push('%'+region+'%'); } + if (type) { sql += ' AND type = ?'; params.push(type); } + sql += ' ORDER BY ' + (sort === 'name' ? 'name' : 'power_mw DESC'); + res.json(db.prepare(sql).all(...params)); +}); + +// Realtime API: search companies +router.get('/search/co', (req, res) => { + const db = getDb(); + const { search, industry, dw, city, sort } = req.query; + let sql = 'SELECT * FROM tech_companies WHERE 1=1'; + const params = []; + if (search) { sql += ' AND (name LIKE ? OR industry LIKE ? OR city LIKE ? OR data_warehouse LIKE ? OR bi_tools LIKE ? OR cloud_provider LIKE ? OR ai_ml LIKE ? OR database_vendor LIKE ?)'; const s = '%'+search+'%'; params.push(s,s,s,s,s,s,s,s); } + if (industry) { sql += ' AND industry LIKE ?'; params.push('%'+industry+'%'); } + if (dw) { sql += ' AND data_warehouse LIKE ?'; params.push('%'+dw+'%'); } + if (city) { sql += ' AND city LIKE ?'; params.push('%'+city+'%'); } + sql += ' ORDER BY ' + (sort === 'name' ? 'name' : sort === 'employees' ? 'employees DESC' : 'lead_score DESC'); + res.json(db.prepare(sql).all(...params)); +}); + +// Map data +router.get('/map.json', (req, res) => { + const db = getDb(); + const dcs = db.prepare('SELECT * FROM datacenters').all(); + const companies = db.prepare('SELECT name, city, industry, employees, lead_score, data_warehouse FROM tech_companies').all(); + const dcPoints = dcs.map(d => ({...d, lat: (CITY_COORDS[d.city]||{}).lat||52.0, lng: (CITY_COORDS[d.city]||{}).lng||5.0})); + const compPoints = companies.map(c => ({name:c.name, city:c.city, lat:(CITY_COORDS[c.city]||{}).lat||52.0, lng:(CITY_COORDS[c.city]||{}).lng||5.0, industry:c.industry, employees:c.employees, lead_score:c.lead_score})); + res.json({ datacenters: dcPoints, companies: compPoints }); +}); + +// Filter options +router.get('/filters.json', (req, res) => { + const db = getDb(); + const allDcs = db.prepare('SELECT * FROM datacenters').all(); + const allCompanies = db.prepare('SELECT * FROM tech_companies').all(); + res.json({ + regions: [...new Set(allDcs.map(d => d.region).filter(Boolean))].sort(), + industries: [...new Set(allCompanies.map(c => c.industry).filter(Boolean))].sort(), + dwVendors: [...new Set(allCompanies.flatMap(c => (c.data_warehouse||'').split(',').map(s=>s.trim()).filter(Boolean)))].sort(), + cities: [...new Set(allCompanies.map(c => c.city).filter(Boolean))].sort(), + vendors: [...new Set(allDcs.flatMap(d => ((d.network_vendors||'')+','+(d.server_vendors||'')+','+(d.storage_vendors||'')).split(',').map(s=>s.trim()).filter(Boolean)))].sort() + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/nav.js b/nav.js new file mode 100644 index 0000000..31fe113 --- /dev/null +++ b/nav.js @@ -0,0 +1,63 @@ +const NAV_SECTIONS = [ + { + label: 'CRM', + items: [ + { href: '/', icon: 'layout-dashboard', label: 'Dashboard', match: /^\/(\?.*)?$/ }, + { href: '/clients', icon: 'users', label: 'Cliënten', match: /^\/clients/ }, + { href: '/engagements', icon: 'briefcase', label: 'Engagements', match: /^\/engagements/ }, + { href: '/tasks', icon: 'check-circle', label: 'Taken', match: /^\/tasks/ }, + { href: '/calendar', icon: 'calendar', label: 'Agenda', match: /^\/calendar/ }, + { href: '/email', icon: 'mail', label: 'Email', match: /^\/email/ } + ] + }, + { + label: 'Delivery', + items: [ + { href: '/consulting', icon: 'clipboard-list', label: 'Framework', match: /^\/consulting/ }, + { href: '/assess', icon: 'file-search', label: 'Assessments', match: /^\/assess/ }, + { href: '/projects', icon: 'folder-kanban', label: 'Projecten', match: /^\/projects/ }, + { href: '/time', icon: 'clock', label: 'Uren', match: /^\/time/ }, + { href: '/invoices', icon: 'receipt', label: 'Facturen', match: /^\/invoices/ }, + { href: '/reports', icon: 'bar-chart-3', label: 'Rapporten', match: /^\/reports/ } + ] + }, + { + label: 'Data Engineering', + items: [ + { href: '/quality', icon: 'shield-check', label: 'Data Quality', match: /^\/quality/ }, + { href: '/architecture', icon: 'network', label: 'Architecture', match: /^\/architecture/ }, + { href: '/architecture/designer', icon: 'pen-tool', label: 'Pro Designer', match: /^\/architecture\/designer/ }, + { href: '/market', icon: 'radar', label: 'Market Intel', match: /^\/market/ }, + { href: '/sizing', icon: 'cpu', label: 'Sizing', match: /^\/sizing/ } + ] + }, + { + label: 'Infrastructure', + items: [ + { href: '/racks', icon: 'server', label: 'Racks', match: /^\/racks/ }, + { href: '/networking', icon: 'share-2', label: 'Netwerk', match: /^\/networking/ }, + { href: '/infrastructure', icon: 'building-2', label: 'Infra', match: /^\/infrastructure/ } + ] + }, + { + label: 'Platform', + items: [ + { href: '/finance', icon: 'wallet', label: 'Finance', match: /^\/finance/ }, + { href: '/ai', icon: 'sparkles', label: 'AI Providers', match: /^\/ai/ }, + { href: '/integrations', icon: 'plug', label: 'Integraties', match: /^\/integrations/ }, + { href: '/settings', icon: 'settings', label: 'Instellingen', match: /^\/settings/ } + ] + } +]; + +function getNavSections(activePath) { + return NAV_SECTIONS.map(section => ({ + ...section, + items: section.items.map(item => ({ + ...item, + active: item.match.test(activePath || '/') + })) + })); +} + +module.exports = { NAV_SECTIONS, getNavSections }; diff --git a/neon.css b/neon.css new file mode 100644 index 0000000..0a2105b --- /dev/null +++ b/neon.css @@ -0,0 +1,184 @@ +/* Mek-Tech Neon Theme — Global Visual Layer */ + +@keyframes neonPulse { + 0%, 100% { opacity: 1; filter: drop-shadow(0 0 8px var(--accent)); } + 50% { opacity: 0.85; filter: drop-shadow(0 0 18px var(--accent)); } +} + +@keyframes gradientShift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 6px var(--accent), 0 0 12px rgba(0,212,255,0.3); opacity: 1; } + 50% { box-shadow: 0 0 14px var(--accent), 0 0 28px rgba(0,212,255,0.5); opacity: 0.85; } +} + +@keyframes neon-border-flow { + 0% { background-position: 0% 50%; } + 100% { background-position: 200% 50%; } +} + +/* ── Page background ── */ +body { + background-color: var(--bg) !important; + background-image: + radial-gradient(ellipse 90% 55% at 50% -15%, rgba(0, 212, 255, 0.12) 0%, transparent 60%), + radial-gradient(ellipse 50% 35% at 95% 90%, rgba(168, 85, 247, 0.08) 0%, transparent 55%), + radial-gradient(ellipse 40% 30% at 5% 70%, rgba(0, 255, 136, 0.06) 0%, transparent 50%) !important; +} + +/* ── Navbar ── */ +.navbar { + backdrop-filter: blur(16px) !important; + border-bottom: 1px solid rgba(0, 212, 255, 0.2) !important; + box-shadow: 0 1px 0 rgba(0, 212, 255, 0.1), 0 4px 32px rgba(0, 0, 0, 0.4), 0 0 40px rgba(0, 212, 255, 0.04) !important; +} + +.nav-brand:hover { color: var(--accent) !important; text-shadow: 0 0 12px rgba(0,212,255,0.5); } + +/* ── Sidebar ── */ +.sidebar { + border-right: 1px solid rgba(0, 212, 255, 0.15) !important; + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2), inset -1px 0 0 rgba(0, 212, 255, 0.05) !important; +} + +.sidebar .sb-brand { + background: linear-gradient(135deg, transparent, rgba(0, 212, 255, 0.06)) !important; + border-bottom: 1px solid rgba(0, 212, 255, 0.12) !important; +} + +.brand-dot, .sidebar .sb-brand h2 .brand-dot { + background: var(--accent) !important; + box-shadow: 0 0 10px var(--accent), 0 0 20px rgba(0,212,255,0.4) !important; + animation: pulse-glow 2s ease-in-out infinite !important; +} + +.sb-item.active { + color: var(--accent) !important; + background: rgba(0, 212, 255, 0.1) !important; + box-shadow: inset 3px 0 0 var(--accent), 0 0 16px rgba(0, 212, 255, 0.08) !important; +} + +.sb-item.active .sb-icon svg, +.sb-item:hover .sb-icon svg { + filter: drop-shadow(0 0 5px var(--accent)); +} + +.sb-item:hover { + background: rgba(0, 212, 255, 0.06) !important; + color: var(--text) !important; +} + +/* ── Cards & KPIs ── */ +.card, .kpi-card, .client-card, .integration-card, .provider-card, +.stat-card, .advice-card, .task-item, .rm-card, .nd-card, .login-card { + border: 1px solid rgba(0, 212, 255, 0.12) !important; + box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.04) !important; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important; +} + +.card:hover, .kpi-card:hover, .client-card:hover, .stat-card:hover, +.advice-card:hover, .task-item:hover, .rm-card:hover, .nd-card:hover { + border-color: rgba(0, 212, 255, 0.35) !important; + box-shadow: 0 0 28px rgba(0, 212, 255, 0.15), 0 8px 32px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.06) !important; + transform: translateY(-2px); +} + +.kpi-card .kpi-num { + text-shadow: 0 0 30px rgba(0, 212, 255, 0.25) !important; +} + +.kpi-card.accent .kpi-num { color: var(--accent) !important; text-shadow: 0 0 24px rgba(0, 212, 255, 0.55) !important; } +.kpi-card.green .kpi-num { text-shadow: 0 0 24px rgba(0, 255, 136, 0.45) !important; } +.kpi-card.purple .kpi-num { text-shadow: 0 0 24px rgba(168, 85, 247, 0.45) !important; } +.kpi-card.orange .kpi-num { text-shadow: 0 0 24px rgba(255, 179, 71, 0.45) !important; } + +/* ── Buttons ── */ +.btn-primary, .login-btn, button.btn-primary { + background: linear-gradient(135deg, #00d4ff, #0099cc) !important; + border: 1px solid rgba(0, 212, 255, 0.4) !important; + box-shadow: 0 0 20px rgba(0, 212, 255, 0.3), inset 0 1px 0 rgba(255,255,255,0.15) !important; + text-shadow: 0 0 20px rgba(0, 212, 255, 0.4) !important; + color: #fff !important; +} + +.btn-primary:hover, .login-btn:hover { + box-shadow: 0 0 36px rgba(0, 212, 255, 0.5), 0 4px 16px rgba(0, 0, 0, 0.2) !important; + transform: translateY(-1px); +} + +/* ── Status badges ── */ +.status-badge.lead { border-color: var(--accent) !important; color: var(--accent) !important; box-shadow: 0 0 8px rgba(0,212,255,0.2); } +.status-badge.active { border-color: var(--green) !important; color: var(--green) !important; box-shadow: 0 0 8px rgba(0,255,136,0.2); } + +/* ── Form focus ── */ +input:focus, select:focus, textarea:focus { + border-color: var(--accent) !important; + box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.15), 0 0 20px rgba(0, 212, 255, 0.1) !important; + outline: none !important; +} + +/* ── Tables ── */ +.data-table th { background: rgba(0, 212, 255, 0.04) !important; } +.data-table tr:hover td { background: rgba(0, 212, 255, 0.04) !important; } + +/* ── Links ── */ +.link, a.link { color: var(--accent) !important; } +.link:hover { text-shadow: 0 0 8px rgba(0,212,255,0.4); } + +/* ── CRM bar ── */ +.crm-bar { + background: rgba(0, 212, 255, 0.07) !important; + border: 1px solid rgba(0, 212, 255, 0.2) !important; + box-shadow: 0 0 20px rgba(0, 212, 255, 0.06) !important; + border-radius: 10px !important; +} + +/* ── Page headers ── */ +.page-hdr h1, .dash-header h1 { + background: linear-gradient(135deg, var(--text) 0%, var(--accent) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ── Neon top accent on cards ── */ +.kpi-card::after, .client-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0; + transition: opacity 0.25s; +} + +.kpi-card { position: relative; overflow: hidden; } +.kpi-card:hover::after { opacity: 1; } + +.fade-in-up { animation: fadeInUp 0.45s ease-out forwards; } + +.designer-frame { + border: 1px solid rgba(0, 212, 255, 0.25) !important; + border-radius: 12px !important; + box-shadow: 0 0 40px rgba(0, 212, 255, 0.1) !important; +} + +/* ── Scrollbar ── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: rgba(0,212,255,0.25); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: rgba(0,212,255,0.45); } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..186ac25 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2853 @@ +{ + "name": "mek-tech-consulting-framework", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mek-tech-consulting-framework", + "version": "2.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.0.0", + "body-parser": "^1.20.2", + "connect-sqlite3": "^0.9.15", + "cors": "^2.8.5", + "dotenv": "^16.4.0", + "ejs": "^3.1.9", + "express": "^4.18.2", + "express-session": "^1.18.0", + "form-data": "^4.0.6", + "imap": "^0.8.19", + "mailparser": "^3.9.14", + "multer": "^1.4.5-lts.1", + "nodemailer": "^9.0.3" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "optional": true + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz", + "integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.4.1", + "libqp": "2.1.1" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "optional": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "optional": true + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "optional": true + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/connect-sqlite3": { + "version": "0.9.16", + "resolved": "https://registry.npmjs.org/connect-sqlite3/-/connect-sqlite3-0.9.16.tgz", + "integrity": "sha512-2gqo0QmcBBL8p8+eqpBETn7RgM/PaoKvpQGl8PfjEgwlr0VuMYNMxRJRrRCo3KR3fxMYeSsCw2tGNG0JKN9Nvg==", + "dependencies": { + "sqlite3": "^5.0.2" + }, + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "optional": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "optional": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "optional": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "optional": true + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "optional": true + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-to-text": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz", + "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^7.1.5", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "optional": true + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/imap": { + "version": "0.8.19", + "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz", + "integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==", + "dependencies": { + "readable-stream": "1.1.x", + "utf7": ">=1.0.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/imap/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "node_modules/imap/node_modules/readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/imap/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "optional": true + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "optional": true + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==" + }, + "node_modules/libmime": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz", + "integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==" + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mailparser": { + "version": "3.9.14", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.14.tgz", + "integrity": "sha512-3QD6TRXcyXtq2NCuyA2AEjqmallQkyxYmZI9GMCIvQDCaB9Uc034WUI1x8RUBYFnk5+p7h14JEz4O/lrxQmttw==", + "dependencies": { + "@zone-eu/mailsplit": "5.4.14", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "10.0.0", + "iconv-lite": "0.7.3", + "libmime": "5.4.1", + "linkify-it": "5.0.2", + "nodemailer": "9.0.3", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, + "node_modules/mailparser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==" + }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "optional": true + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "optional": true, + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "optional": true + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utf7": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz", + "integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==", + "dependencies": { + "semver": "~5.3.0" + } + }, + "node_modules/utf7/node_modules/semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1424759 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "mek-tech-consulting-framework", + "version": "2.0.0", + "description": "Mek-Tech AI Consultancy Framework — Client management, assessments, en advies-engine voor data consultants & forward deployed engineers", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.0.0", + "body-parser": "^1.20.2", + "connect-sqlite3": "^0.9.15", + "cors": "^2.8.5", + "dotenv": "^16.4.0", + "ejs": "^3.1.9", + "express": "^4.18.2", + "express-session": "^1.18.0", + "form-data": "^4.0.6", + "imap": "^0.8.19", + "mailparser": "^3.9.14", + "multer": "^1.4.5-lts.1", + "nodemailer": "^9.0.3" + } +} diff --git a/public/img/favicon.svg b/public/img/favicon.svg new file mode 100644 index 0000000..b2d9fad --- /dev/null +++ b/public/img/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/img/logo.svg b/public/img/logo.svg new file mode 100644 index 0000000..37e2523 --- /dev/null +++ b/public/img/logo.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + MEK-TECH + DATA CENTER EXPERT + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..0247a8b --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Mek-Tech Consulting Framework", + "short_name": "Mek-Tech", + "description": "Enterprise consultancy management platform", + "start_url": "/", + "display": "standalone", + "background_color": "#0d1117", + "theme_color": "#58a6ff", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/img/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/img/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..fd9e7df --- /dev/null +++ b/public/sw.js @@ -0,0 +1,16 @@ +const CACHE_NAME = 'mek-tech-v1'; +const urlsToCache = ['/', '/css/style.css', '/css/dynamic.css', '/img/logo.svg']; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(urlsToCache)) + ); +}); + +self.addEventListener('fetch', event => { + event.respondWith( + caches.match(event.request) + .then(response => response || fetch(event.request)) + ); +}); diff --git a/python-editor/Dockerfile b/python-editor/Dockerfile new file mode 100644 index 0000000..1acf2ee --- /dev/null +++ b/python-editor/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DB_PATH=/app/data/consulting.db +ENV PY_PORT=3001 +EXPOSE 3001 +CMD ["python3", "app.py"] diff --git a/python-editor/app.py b/python-editor/app.py new file mode 100644 index 0000000..e170371 --- /dev/null +++ b/python-editor/app.py @@ -0,0 +1,183 @@ +import os +import sqlite3 +import json +from flask import Flask, render_template_string, request, jsonify, send_from_directory + +DB_PATH = os.environ.get('DB_PATH', '/app/data/consulting.db') +app = Flask(__name__, static_folder='static', static_url_path='/python-editor/static') + +HTML = ''' + + + + +Architectuur Designer — Mek-Tech + + + + + + +
+ +
+
+ +
+
+
100%
+
+
+

Eigenschappen

+
+
+
+
+ +
+
+ + + + +''' + +@app.route('/python-editor/') +def index(): + diagram_id = request.args.get('id') + nodes_json = '[]' + edges_json = '[]' + if diagram_id: + try: + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('SELECT nodes FROM data_diagrams WHERE id=?', (diagram_id,)) + row = c.fetchone() + conn.close() + if row: + data = json.loads(row[0]) + nodes_json = json.dumps(data.get('nodes', [])) + edges_json = json.dumps(data.get('edges', [])) + except: pass + return render_template_string(HTML, diagram_id=diagram_id or '', nodes_json=nodes_json, edges_json=edges_json) + +@app.route('/python-editor/api/clients') +def api_clients(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('SELECT id, name FROM clients ORDER BY name') + clients = [{'id': r[0], 'name': r[1]} for r in c.fetchall()] + conn.close() + return jsonify(clients) + +@app.route('/python-editor/api/save', methods=['POST']) +def api_save(): + data = request.get_json() + name = data.get('name') + client_id = data.get('client_id') + nodes = json.dumps(data.get('nodes', [])) + edges = json.dumps(data.get('edges', [])) + diagram_id = data.get('id') + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + if diagram_id: + c.execute('UPDATE data_diagrams SET name=?, client_id=?, nodes=?, edges=?, updated_at=datetime(\'now\') WHERE id=?', + (name, client_id, nodes, edges, diagram_id)) + else: + c.execute('INSERT INTO data_diagrams (client_id, name, nodes, edges, diagram_type) VALUES (?,?,?,?,?)', + (client_id, name, nodes, edges, 'data_architecture')) + diagram_id = c.lastrowid + conn.commit() + conn.close() + return jsonify({'id': diagram_id}) + +if __name__ == '__main__': + port = int(os.environ.get('PY_PORT', 3001)) + app.run(host='0.0.0.0', port=port, debug=False) diff --git a/python-editor/requirements.txt b/python-editor/requirements.txt new file mode 100644 index 0000000..001e7c4 --- /dev/null +++ b/python-editor/requirements.txt @@ -0,0 +1 @@ +flask>=3.0 diff --git a/python-editor/static/editor.js b/python-editor/static/editor.js new file mode 100644 index 0000000..f69feeb --- /dev/null +++ b/python-editor/static/editor.js @@ -0,0 +1,333 @@ +let nodes = [], edges = [], selId = null, dragId = null, dOffX, dOffY; +let isPan = false, pSX, pSY, pOffX = 0, pOffY = 0, zoom = 1; +let connFrom = null, tmpLine = null, nIdCnt = 0; +let undoStk = [], redoStk = []; +const wrap = document.getElementById('wrap'), vp = document.getElementById('viewport'); +const cv = document.getElementById('canvas'), svg = document.getElementById('svg'); +const props = document.getElementById('props'); + +function sn(v) { return Math.round(v / 20) * 20; } + +function buildPalette() { + const p = document.getElementById('palette'); + CATS.forEach(([cat, types]) => { + p.innerHTML += `
${cat}
`; + types.forEach(t => { + const info = NODE_TYPES[t]; + p.innerHTML += `
+ ${t.split('-')[0].substring(0,2).toUpperCase()} + ${info.label}
`; + }); + }); + document.querySelectorAll('.p-item').forEach(el => { + el.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', el.dataset.type); }); + }); +} + +function addNode(type, x, y, id, label) { + const def = NODE_TYPES[type]; if (!def) return null; + const nid = id || (++nIdCnt); + const el = document.createElement('div'); + el.className = 'node'; el.dataset.id = nid; + el.style.cssText = `left:${sn(x)}px;top:${sn(y)}px;width:130px;border-color:${def.color}`; + el.innerHTML = `
×
+
+
${label||def.label}
+
${def.label}
+
`; + el.addEventListener('mousedown', e => { + if (e.target.closest('.rm,.port')) return; + e.stopPropagation(); selNode(nid); + const r = el.getBoundingClientRect(); + dragId = nid; dOffX = e.clientX - r.left; dOffY = e.clientY - r.top; + el.style.zIndex = 20; el.style.cursor = 'grabbing'; + saveSt(); + }); + el.querySelectorAll('.port').forEach(p => { + p.addEventListener('mousedown', e => { + e.stopPropagation(); e.preventDefault(); + if (p.classList.contains('port-out')) { + connFrom = { id: +p.dataset.n, el: p }; + const r = p.getBoundingClientRect(), wr = vp.getBoundingClientRect(); + tmpLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + tmpLine.setAttribute('stroke', '#58a6ff'); tmpLine.setAttribute('stroke-width', '2'); + tmpLine.setAttribute('stroke-dasharray', '6,4'); + tmpLine.setAttribute('x1', (r.left - wr.left) / zoom); + tmpLine.setAttribute('y1', (r.top - wr.top + 4) / zoom); + tmpLine.setAttribute('x2', (e.clientX - wr.left) / zoom); + tmpLine.setAttribute('y2', (e.clientY - wr.top) / zoom); + svg.appendChild(tmpLine); + } + }); + }); + cv.appendChild(el); + nodes.push({ id: nid, type, x: sn(x), y: sn(y), label: label || def.label }); + updStats(); return nid; +} + +function rmNode(id) { + const el = cv.querySelector(`.node[data-id="${id}"]`); + if (el) el.remove(); + nodes = nodes.filter(n => n.id !== id); + edges = edges.filter(e => e.from !== id && e.to !== id); + renEdges(); updStats(); if (selId === id) { selId = null; props.classList.remove('open'); } + saveSt(); +} + +function selNode(id) { + cv.querySelectorAll('.node').forEach(n => n.classList.remove('selected')); + const el = cv.querySelector(`.node[data-id="${id}"]`); + if (el) el.classList.add('selected'); + selId = id; showProps(id); +} + +function showProps(id) { + const n = nodes.find(x => x.id === id); if (!n) return; + props.classList.add('open'); + document.getElementById('pLabel').value = n.label; + document.getElementById('pSub').value = n.sub || ''; + document.getElementById('pWidth').value = 130; + const sel = document.getElementById('pType'); + sel.innerHTML = ''; + Object.keys(NODE_TYPES).forEach(k => { + const o = document.createElement('option'); + o.value = k; o.textContent = NODE_TYPES[k].label; + if (k === n.type) o.selected = true; + sel.appendChild(o); + }); +} + +function up(f, v) { + const n = nodes.find(x => x.id === selId); if (!n) return; + const el = cv.querySelector(`.node[data-id="${selId}"]`); if (!el) return; + if (f === 'label') { n.label = v; el.querySelector('.nl').textContent = v; } + else if (f === 'type') { + const def = NODE_TYPES[v]; if (!def) return; + n.type = v; n.label = def.label; + el.style.borderColor = def.color; + el.querySelector('.nl').textContent = def.label; + el.querySelector('.ns').textContent = def.label; + document.getElementById('pLabel').value = def.label; + } + else if (f === 'w') { el.style.width = v + 'px'; } + renEdges(); saveSt(); +} + +function delNode() { if (selId !== null) rmNode(selId); } + +function addEdge(from, to) { + if (from === to || edges.some(e => e.from === from && e.to === to)) return; + edges.push({ from, to }); renEdges(); updStats(); saveSt(); +} + +function renEdges() { + svg.querySelectorAll('.eg').forEach(g => g.remove()); + const wr = vp.getBoundingClientRect(); + edges.forEach((e, i) => { + const fEl = cv.querySelector(`.node[data-id="${e.from}"]`); + const tEl = cv.querySelector(`.node[data-id="${e.to}"]`); + if (!fEl || !tEl) return; + const fr = fEl.getBoundingClientRect(), tr = tEl.getBoundingClientRect(); + const x1 = (fr.right - wr.left - 8) / zoom, y1 = (fr.top + fr.height/2 - wr.top) / zoom; + const x2 = (tr.left - wr.left + 8) / zoom, y2 = (tr.top + tr.height/2 - wr.top) / zoom; + const cp = Math.max(40, Math.abs(x2 - x1) * 0.4); + const d = `M${x1} ${y1} C${x1+cp} ${y1},${x2-cp} ${y2},${x2} ${y2}`; + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + g.classList.add('eg'); + // glow + const gl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + gl.setAttribute('d', d); gl.setAttribute('stroke', '#58a6ff'); + gl.setAttribute('stroke-width', '5'); gl.setAttribute('fill', 'none'); + gl.setAttribute('opacity', '0.1'); + g.appendChild(gl); + // main + const m = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + m.setAttribute('d', d); m.setAttribute('stroke', '#58a6ff'); + m.setAttribute('stroke-width', '2'); m.setAttribute('fill', 'none'); + m.setAttribute('stroke-linecap', 'round'); + m.addEventListener('dblclick', () => { edges.splice(i,1); renEdges(); updStats(); saveSt(); }); + g.appendChild(m); + // pulse + for (let j = 0; j < 2; j++) { + const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + p.setAttribute('d', d); p.setAttribute('stroke', '#79c0ff'); + p.setAttribute('stroke-width', '1.5'); p.setAttribute('fill', 'none'); + p.setAttribute('stroke-dasharray', '6,14'); + p.style.animation = `pulseF ${0.8 + j*0.1}s linear infinite`; + p.style.animationDelay = `-${j * 0.4}s`; + g.appendChild(p); + } + // arrow + const ang = Math.atan2(y2 - y1, x2 - x1); + const ax = x2 - 4, ay = y2 - 2; + const ar = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + ar.setAttribute('points', `${ax},${ay} ${ax-8*Math.cos(ang-.4)},${ay-8*Math.sin(ang-.4)} ${ax-8*Math.cos(ang+.4)},${ay-8*Math.sin(ang+.4)}`); + ar.setAttribute('fill', '#58a6ff'); + g.appendChild(ar); + svg.appendChild(g); + }); +} + +document.addEventListener('mousemove', e => { + if (dragId !== null) { + const el = cv.querySelector(`.node[data-id="${dragId}"]`); if (!el) return; + const wr = vp.getBoundingClientRect(); + let x = (e.clientX - wr.left - dOffX) / zoom, y = (e.clientY - wr.top - dOffY) / zoom; + x = sn(Math.max(0,x)); y = sn(Math.max(0,y)); + el.style.left = x + 'px'; el.style.top = y + 'px'; + const n = nodes.find(nn => nn.id === dragId); if (n) { n.x = x; n.y = y; } + renEdges(); + } + if (tmpLine) { + const wr = vp.getBoundingClientRect(); + tmpLine.setAttribute('x2', (e.clientX - wr.left)/zoom); + tmpLine.setAttribute('y2', (e.clientY - wr.top)/zoom); + } + if (isPan) { + pOffX += e.clientX - pSX; pOffY += e.clientY - pSY; + pSX = e.clientX; pSY = e.clientY; + vp.style.transform = `translate(${pOffX}px,${pOffY}px) scale(${zoom})`; + } +}); + +document.addEventListener('mouseup', e => { + if (dragId !== null) { + const el = cv.querySelector(`.node[data-id="${dragId}"]`); + if (el) el.style.cursor = 'move'; + dragId = null; + } + if (tmpLine) { + tmpLine.remove(); tmpLine = null; + const t = e.target.closest('.port-in'); + if (t && connFrom) addEdge(connFrom.id, +t.dataset.n); + connFrom = null; + } + if (isPan) { isPan = false; wrap.style.cursor = 'grab'; } +}); + +wrap.addEventListener('mousedown', e => { + if (e.target.closest('.node')) return; + cv.querySelectorAll('.node').forEach(n => n.classList.remove('selected')); + selId = null; props.classList.remove('open'); + isPan = true; pSX = e.clientX; pSY = e.clientY; wrap.style.cursor = 'grabbing'; +}); + +wrap.addEventListener('wheel', e => { + e.preventDefault(); + const d = e.deltaY > 0 ? 0.9 : 1.1, nz = Math.max(0.2, Math.min(3, zoom * d)); + const wr = wrap.getBoundingClientRect(); + const vx = (e.clientX - wr.left - pOffX) / zoom, vy = (e.clientY - wr.top - pOffY) / zoom; + zoom = nz; + pOffX = e.clientX - wr.left - vx * zoom; pOffY = e.clientY - wr.top - vy * zoom; + vp.style.transform = `translate(${pOffX}px,${pOffY}px) scale(${zoom})`; + document.getElementById('zoom').textContent = Math.round(zoom*100) + '%'; +}, { passive: false }); + +cv.addEventListener('dragover', e => e.preventDefault()); +cv.addEventListener('drop', e => { + e.preventDefault(); + const type = e.dataTransfer.getData('text/plain'); + if (!type || !NODE_TYPES[type]) return; + const wr = vp.getBoundingClientRect(); + let x = (e.clientX - wr.left)/zoom - 65, y = (e.clientY - wr.top)/zoom - 20; + addNode(type, sn(Math.max(0,x)), sn(Math.max(0,y))); + saveSt(); +}); + +function saveSt() { + undoStk.push(JSON.stringify({ nodes: nodes.map(n => ({...n})), edges: [...edges] })); + if (undoStk.length > 50) undoStk.shift(); + redoStk = []; +} + +function undo() { + if (undoStk.length < 2) return; + redoStk.push(undoStk.pop()); + loadSt(JSON.parse(undoStk[undoStk.length-1])); +} + +function redo() { + if (!redoStk.length) return; + undoStk.push(redoStk.pop()); + loadSt(JSON.parse(undoStk[undoStk.length-1])); +} + +function loadSt(st) { + cv.querySelectorAll('.node').forEach(el => el.remove()); + svg.querySelectorAll('.eg').forEach(g => g.remove()); + nodes = []; edges = st.edges || []; + (st.nodes || []).forEach(n => addNode(n.type, n.x, n.y, n.id, n.label)); + renEdges(); updStats(); +} + +function updStats() { + document.getElementById('stats').textContent = `${nodes.length} componenten · ${edges.length} connecties`; +} + +function fit() { + if (!nodes.length) return; + const mx = Math.min(...nodes.map(n => n.x)), my = Math.min(...nodes.map(n => n.y)); + const Mx = Math.max(...nodes.map(n => n.x + 130)), My = Math.max(...nodes.map(n => n.y + 44)); + const wr = wrap.getBoundingClientRect(); + zoom = Math.max(0.2, Math.min(wr.width/(Mx-mx+150), (wr.height-60)/(My-my+80), 1.5)); + pOffX = -mx * zoom + 50; pOffY = -my * zoom + 30; + vp.style.transform = `translate(${pOffX}px,${pOffY}px) scale(${zoom})`; + document.getElementById('zoom').textContent = Math.round(zoom*100)+'%'; +} + +function filterPalette(q) { + q = q.toLowerCase(); + document.querySelectorAll('.p-item').forEach(el => el.style.display = el.textContent.toLowerCase().includes(q) ? 'flex' : 'none'); + document.querySelectorAll('.cat').forEach(el => { + let sib = el.nextElementSibling, has = false; + while (sib && sib.classList.contains('p-item')) { if (sib.style.display !== 'none') has = true; sib = sib.nextElementSibling; } + el.style.display = has ? 'block' : 'none'; + }); +} + +async function save() { + const name = document.getElementById('diagramName').value; + const client_id = document.getElementById('clientSelect').value; + if (!name || !client_id) { setStatus('Naam en cliënt verplicht', 'err'); return; } + const data = { id: DIAGRAM_ID || null, client_id: +client_id, name, + nodes: nodes.map(n => ({...n})), edges: [...edges] }; + try { + const r = await fetch('/python-editor/api/save', { + method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data) + }); + const d = await r.json(); + setStatus('Opgeslagen ✓', 'ok'); + if (d.id && !DIAGRAM_ID) history.replaceState(null, '', '/python-editor/?id=' + d.id); + } catch(e) { setStatus('Fout bij opslaan', 'err'); } +} + +function setStatus(msg, t) { + const el = document.getElementById('status'); + el.textContent = msg; el.style.color = t === 'err' ? '#f85149' : '#3fb950'; + setTimeout(() => { el.textContent = ''; }, 3000); +} + +document.addEventListener('keydown', e => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; + if (e.key === 'Delete' || e.key === 'Backspace') { delNode(); e.preventDefault(); } + if ((e.ctrlKey||e.metaKey) && e.key === 'z') { e.shiftKey ? redo() : undo(); e.preventDefault(); } + if ((e.ctrlKey||e.metaKey) && e.key === 's') { save(); e.preventDefault(); } +}); + +const style = document.createElement('style'); +style.textContent = `@keyframes pulseF{0%{stroke-dashoffset:20}100%{stroke-dashoffset:0}}`; +document.head.appendChild(style); + +buildPalette(); +fetch('/python-editor/api/clients').then(r=>r.json()).then(clients => { + const sel = document.getElementById('clientSelect'); + clients.forEach(c => { sel.innerHTML += ``; }); +}); +if (NODES_JSON && NODES_JSON.length) { + NODES_JSON.forEach(n => addNode(n.type, n.x, n.y, n.id, n.label)); + edges = EDGES_JSON || []; + renEdges(); updStats(); saveSt(); + setTimeout(fit, 100); +} +nIdCnt = nodes.length; +updStats(); saveSt(); diff --git a/python-leadgen/Dockerfile b/python-leadgen/Dockerfile new file mode 100644 index 0000000..3e368cc --- /dev/null +++ b/python-leadgen/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir --default-timeout=120 --retries 5 -r requirements.txt + +COPY . . + +ENV LEADGEN_PORT=3003 +EXPOSE 3003 + +CMD ["python", "app.py"] diff --git a/python-leadgen/app.py b/python-leadgen/app.py new file mode 100644 index 0000000..9752be6 --- /dev/null +++ b/python-leadgen/app.py @@ -0,0 +1,248 @@ +"""Flask API server for Lead Generation & Tech Scoping Engine.""" +from __future__ import annotations +import json +import logging +import os +import sys +from pathlib import Path + +import httpx +from flask import Flask, request, jsonify + +from models import CompanyTechProfile, LeadGenRequest, LeadGenResponse +from engine import LeadScopingEngine + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + stream=sys.stdout, +) +logger = logging.getLogger("leadgen") + +# --------------------------------------------------------------------------- +# Config file +# --------------------------------------------------------------------------- +CONFIG_PATH = Path(os.getenv("LEADGEN_CONFIG", "/app/data/leadgen_config.json")) +CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + +DEFAULT_CONFIG = { + "api_key": os.getenv("DEEPSEEK_API_KEY", ""), + "base_url": "https://openrouter.ai/api/v1", + "model": "qwen/qwen3-coder:free", + "fallback_models": [ + "meta-llama/llama-3.3-70b-instruct:free", + "google/gemma-4-26b-a4b-it:free", + "nvidia/nemotron-3-super-120b-a12b:free", + ], +} + + +def load_config() -> dict: + if CONFIG_PATH.exists(): + try: + return json.loads(CONFIG_PATH.read_text()) + except Exception: + pass + cfg = dict(DEFAULT_CONFIG) + save_config(cfg) + return cfg + + +def save_config(cfg: dict) -> None: + CONFIG_PATH.write_text(json.dumps(cfg, indent=2)) + + +config = load_config() + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- +app = Flask(__name__) + + +def get_engine() -> LeadScopingEngine: + cfg = load_config() + return LeadScopingEngine( + api_key=cfg.get("api_key"), + api_base=cfg.get("base_url"), + ) + + +@app.route("/python-leadgen/", methods=["GET"]) +def index(): + return jsonify({ + "service": "Mek-Tech Lead Generation & Tech Scoping Engine", + "version": "2.0", + "endpoints": { + "GET /python-leadgen/config": "Get current config (model, base_url, key masked)", + "POST /python-leadgen/config": "Update config", + "GET /python-leadgen/models": "List available free models from configured API", + "POST /python-leadgen/analyze": "Analyze a single URL", + "POST /python-leadgen/batch": "Analyze multiple URLs", + "GET /python-leadgen/health": "Health check", + }, + }) + + +@app.route("/python-leadgen/health", methods=["GET"]) +def health(): + cfg = load_config() + return jsonify({ + "status": "healthy", + "api_configured": bool(cfg.get("api_key")), + "model": cfg.get("model"), + "base_url": cfg.get("base_url"), + }) + + +@app.route("/python-leadgen/config", methods=["GET"]) +def get_config(): + cfg = load_config() + key = cfg.get("api_key", "") + return jsonify({ + "base_url": cfg.get("base_url"), + "model": cfg.get("model"), + "fallback_models": cfg.get("fallback_models", []), + "api_key_set": bool(key), + "api_key_preview": (key[:12] + "..." + key[-4:]) if len(key) > 16 else ("***" if key else ""), + }) + + +@app.route("/python-leadgen/config", methods=["POST"]) +def set_config(): + try: + body = request.get_json(silent=True) or {} + except Exception: + return jsonify({"success": False, "error": "Invalid JSON"}), 400 + + cfg = load_config() + if "api_key" in body and body["api_key"]: + cfg["api_key"] = body["api_key"] + if "base_url" in body: + cfg["base_url"] = body["base_url"] + if "model" in body: + cfg["model"] = body["model"] + if "fallback_models" in body: + cfg["fallback_models"] = body["fallback_models"] + + save_config(cfg) + logger.info("Config updated: model=%s base_url=%s", cfg.get("model"), cfg.get("base_url")) + return jsonify({"success": True, "model": cfg["model"]}) + + +@app.route("/python-leadgen/models", methods=["GET"]) +async def list_models(): + """Fetch available free models from the configured API endpoint.""" + cfg = load_config() + base = cfg.get("base_url", "https://openrouter.ai/api/v1") + key = cfg.get("api_key", "") + + models_url = base.rstrip("/") + "/models" + + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + models_url, + headers={"Authorization": f"Bearer {key}"} if key else {}, + ) + if resp.status_code != 200: + return jsonify({ + "success": False, + "error": f"API returned {resp.status_code}", + "models": _fallback_model_list(), + }) + + data = resp.json() + all_models = data.get("data", []) + + free_models = [ + { + "id": m["id"], + "name": m.get("name", m["id"]), + "context_length": m.get("context_length", 0), + "pricing": str(m.get("pricing", {})), + } + for m in all_models + if ":free" in m.get("id", "") + ] + free_models.sort(key=lambda m: -m["context_length"]) + + return jsonify({"success": True, "total": len(free_models), "models": free_models}) + + except Exception as e: + return jsonify({ + "success": False, + "error": str(e), + "models": _fallback_model_list(), + }) + + +def _fallback_model_list(): + return [ + {"id": "qwen/qwen3-coder:free", "name": "Qwen 3 Coder (Free)", "context_length": 1048576}, + {"id": "google/gemma-4-31b-it:free", "name": "Gemma 4 31B (Free)", "context_length": 262144}, + {"id": "google/gemma-4-26b-a4b-it:free", "name": "Gemma 4 26B (Free)", "context_length": 262144}, + {"id": "meta-llama/llama-3.3-70b-instruct:free", "name": "Llama 3.3 70B (Free)", "context_length": 131072}, + {"id": "nvidia/nemotron-3-super-120b-a12b:free", "name": "Nemotron Super 120B (Free)", "context_length": 1000000}, + ] + + +@app.route("/python-leadgen/analyze", methods=["POST"]) +async def analyze(): + try: + body = request.get_json(silent=True) or {} + except Exception: + return jsonify({"success": False, "error": "Invalid JSON body"}), 400 + + url = body.get("url", "").strip() + if not url: + return jsonify({"success": False, "error": "Missing required field: url"}), 400 + + engine = get_engine() + try: + result: LeadGenResponse = await engine.analyze_url(url) + except Exception as exc: + logger.exception("Unexpected error analyzing %s", url) + return jsonify({"success": False, "error": str(exc)}), 500 + + if result.success and result.profile: + return jsonify(result.model_dump()) + else: + return jsonify(result.model_dump()), 422 + + +@app.route("/python-leadgen/batch", methods=["POST"]) +async def batch(): + try: + body = request.get_json(silent=True) or {} + except Exception: + return jsonify({"success": False, "error": "Invalid JSON body"}), 400 + + urls = body.get("urls", []) + if not urls or not isinstance(urls, list): + return jsonify({"success": False, "error": "Missing required field: urls (list)"}), 400 + + if len(urls) > 10: + return jsonify({"success": False, "error": "Maximum 10 URLs per batch"}), 400 + + engine = get_engine() + try: + results = await engine.batch_analyze(urls) + except Exception as exc: + logger.exception("Batch analysis failed") + return jsonify({"success": False, "error": str(exc)}), 500 + + return jsonify({ + "success": True, + "total": len(urls), + "results": [r.model_dump() for r in results], + }) + + +if __name__ == "__main__": + port = int(os.getenv("LEADGEN_PORT", "3003")) + debug = os.getenv("LEADGEN_DEBUG", "0") == "1" + app.run(host="0.0.0.0", port=port, debug=debug) diff --git a/python-leadgen/engine.py b/python-leadgen/engine.py new file mode 100644 index 0000000..ff37833 --- /dev/null +++ b/python-leadgen/engine.py @@ -0,0 +1,133 @@ +"""LeadScopingEngine – orchestrates scraping + AI extraction for lead generation.""" +from __future__ import annotations +import json +import logging +import os +import time +from pathlib import Path +from typing import Optional + +from scraper import AsyncScraper +from extractor import DeepSeekExtractor +from models import CompanyTechProfile, ScrapeResult, LeadGenResponse + +logger = logging.getLogger(__name__) + +CONFIG_PATH = Path(os.getenv("LEADGEN_CONFIG", "/app/data/leadgen_config.json")) + +CAREER_PATHS = ["/careers", "/jobs", "/about/careers", "/careers/openings", "/company/careers", "/en/careers"] + + +def _load_config() -> dict: + if CONFIG_PATH.exists(): + try: + return json.loads(CONFIG_PATH.read_text()) + except Exception: + pass + return {} + + +class LeadScopingEngine: + """Orchestrates async scraping and AI-powered tech signal extraction.""" + + def __init__( + self, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + scrape_timeout_ms: int = 30_000, + ): + self.api_key = api_key + self.api_base = api_base + self.scraper = AsyncScraper(timeout_ms=scrape_timeout_ms) + self._extractor: Optional[DeepSeekExtractor] = None + + @property + def extractor(self) -> DeepSeekExtractor: + if self._extractor is None: + cfg = _load_config() + key = self.api_key or cfg.get("api_key", "") + base = self.api_base or cfg.get("base_url", "https://openrouter.ai/api/v1") + self._extractor = DeepSeekExtractor(api_key=key, base_url=base) + return self._extractor + + async def analyze_url(self, url: str) -> LeadGenResponse: + """Analyze a company URL: scrape main page + careers → extract → return structured profile.""" + t0 = time.monotonic() + + # Step 1: Scrape main page + logger.info("Scraping %s ...", url) + main_result: ScrapeResult = await self.scraper.scrape(url) + + if main_result.error or not main_result.text_content: + logger.warning("Scrape failed for %s: %s", url, main_result.error) + return LeadGenResponse( + success=False, + error=main_result.error or "No content extracted", + elapsed_ms=round((time.monotonic() - t0) * 1000, 1), + ) + + # Step 2: Also scrape careers page for hiring data + combined_text = main_result.text_content + career_sections = [] + base = url.rstrip("/") + + for path in CAREER_PATHS: + career_url = base + path + try: + cr = await self.scraper.scrape(career_url) + if cr.text_content and len(cr.text_content) > 100: + logger.info("Careers page found: %s (%d chars)", career_url, len(cr.text_content)) + career_sections.append(f"\n\n=== JOB LISTINGS FROM {career_url} ===\n\n" + cr.text_content[:20000]) + break # Stop after first match + except Exception: + continue + + if career_sections: + combined_text += "\n".join(career_sections) + + logger.info( + "Scraped %s — main: %d chars, careers: %d chars — %.1fs", + url, len(main_result.text_content), + sum(len(c) for c in career_sections), + time.monotonic() - t0, + ) + + # Step 3: AI Extraction + try: + cfg = _load_config() + model = cfg.get("model", "qwen/qwen3-coder:free") + fallbacks = cfg.get("fallback_models", []) + profile: CompanyTechProfile = await self.extractor.extract( + combined_text, url, model=model, fallback_models=fallbacks + ) + except Exception as exc: + logger.exception("AI extraction failed for %s", url) + return LeadGenResponse( + success=False, + error=f"AI extraction error: {str(exc)}", + elapsed_ms=round((time.monotonic() - t0) * 1000, 1), + ) + + elapsed = round((time.monotonic() - t0) * 1000, 1) + logger.info( + "Analysis complete — %s | Tech: %d | Pains: %d | Roles: %d | Skills: %d | Maturity: %s | %.0fms", + profile.company_name, + len(profile.detected_technologies), + len(profile.current_pain_points), + len(profile.hiring_roles), + len(profile.desired_skills), + profile.estimated_data_maturity, + elapsed, + ) + + return LeadGenResponse( + success=True, + profile=profile, + elapsed_ms=elapsed, + ) + + async def batch_analyze(self, urls: list[str]) -> list[LeadGenResponse]: + """Analyze multiple URLs concurrently.""" + import asyncio + tasks = [self.analyze_url(url) for url in urls] + return await asyncio.gather(*tasks, return_exceptions=False) diff --git a/python-leadgen/extractor.py b/python-leadgen/extractor.py new file mode 100644 index 0000000..236d508 --- /dev/null +++ b/python-leadgen/extractor.py @@ -0,0 +1,169 @@ +"""DeepSeek AI extractor for structured tech profiling from scraped text.""" +from __future__ import annotations +import json +import logging +import os +import re +from datetime import datetime, timezone +from typing import Optional + +from openai import AsyncOpenAI + +from models import CompanyTechProfile + +logger = logging.getLogger(__name__) + +DEEPSEEK_BASE_URL = "https://openrouter.ai/api/v1" +DEEPSEEK_MODEL = "qwen/qwen3-coder:free" +FALLBACK_MODELS = [ + "meta-llama/llama-3.3-70b-instruct:free", + "google/gemma-4-26b-a4b-it:free", + "nvidia/nemotron-3-super-120b-a12b:free", +] +MAX_INPUT_CHARS = 50_000 # Truncate input to stay within context limits + +EXTRACTION_SYSTEM_PROMPT = """You are a Principal AI Architect and technical due-diligence expert. +Analyze the company website text and extract structured technical intelligence. + +CRITICAL RULES: +- detected_technologies MUST contain at least 3 items. Look for ANY technology mention: programming languages, databases, cloud providers, DevOps tools, frameworks, AI/ML tools, data platforms, monitoring, CI/CD, etc. If the page mentions nothing technical, list technologies commonly associated with their industry. +- current_pain_points MUST contain at least 2 items. Infer from their industry, product description, or company stage. +- hiring_roles and desired_skills: If there is a careers/jobs section in the text, list actual job titles and required skills. Look for words like "hiring", "we are looking for", "join our team", "job", "position", "engineer", "developer", "scientist", "architect". +- outreach_hook MUST be at least 30 characters. Never leave it empty. + +Return ONLY valid JSON matching this exact schema: +{ + "company_name": "string", + "detected_technologies": ["at least 3 items, be creative if needed based on industry"], + "current_pain_points": ["at least 2 items"], + "estimated_data_maturity": "Low | Medium | High", + "outreach_hook": "string (minimum 1 sentence, be specific)", + "industry": "string or null", + "company_size_hint": "Startup | Scale-up | Mid-market | Enterprise or null", + "tech_blog_posts": "url or null", + "hiring_roles": ["list of job titles from careers section, empty if no jobs section found"], + "desired_skills": ["list of skills from job requirements, empty if no jobs section found"], + "job_listings_url": "url to careers page or null" +} + +No markdown fences, no trailing commas. Every array MUST have content unless truly no information exists.""" + + +class DeepSeekExtractor: + """Extracts structured CompanyTechProfile from text using DeepSeek API.""" + + def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None): + self.api_key = api_key or os.getenv("DEEPSEEK_API_KEY", "") + self.base_url = base_url or os.getenv("DEEPSEEK_BASE_URL", DEEPSEEK_BASE_URL) + self._client: Optional[AsyncOpenAI] = None + + @property + def client(self) -> AsyncOpenAI: + if self._client is None: + if not self.api_key: + raise ValueError( + "DeepSeek API key not configured. " + "Set DEEPSEEK_API_KEY environment variable or pass api_key parameter." + ) + self._client = AsyncOpenAI( + api_key=self.api_key, + base_url=self.base_url, + max_retries=2, + timeout=60.0, + default_headers={ + "HTTP-Referer": "http://mek-tech.nl", + "X-Title": "Mek-Tech LeadGen Engine", + }, + ) + return self._client + + async def extract(self, text: str, url: str, model: str = DEEPSEEK_MODEL, fallback_models: list[str] | None = None) -> CompanyTechProfile: + """Extract CompanyTechProfile from scraped page text.""" + if not text.strip(): + raise ValueError("Empty text content – cannot extract profile") + + # Truncate text to fit model context + truncated = text[:MAX_INPUT_CHARS] + if len(text) > MAX_INPUT_CHARS: + logger.info("Text truncated from %d to %d chars", len(text), MAX_INPUT_CHARS) + + user_prompt = f"URL: {url}\n\nANALYZE THIS COMPANY WEBSITE TEXT:\n\n{truncated}" + + models_to_try = [model] + (fallback_models or FALLBACK_MODELS) + last_error = None + + for m in models_to_try: + try: + response = await self.client.chat.completions.create( + model=m, + messages=[ + {"role": "system", "content": EXTRACTION_SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.1, + max_tokens=2048, + ) + logger.info("Used model: %s", m) + break + except Exception as exc: + last_error = exc + logger.warning("Model %s failed: %s", m, str(exc)[:200]) + if m != models_to_try[-1]: + import asyncio + await asyncio.sleep(1) + continue + else: + raise RuntimeError(f"All models failed. Last error: {str(last_error)}") from last_error + + raw_content = response.choices[0].message.content or "{}" + parsed = self._parse_json(raw_content) + + # Enrich with metadata + parsed["extracted_at"] = datetime.now(timezone.utc).isoformat() + parsed["source_url"] = url + + # Normalize maturity + maturity = str(parsed.get("estimated_data_maturity", "")).strip().capitalize() + if maturity not in ("Low", "Medium", "High"): + maturity = "Medium" + parsed["estimated_data_maturity"] = maturity + + # Ensure lists are lists, strings are strings + for field in ("detected_technologies", "current_pain_points", "hiring_roles", "desired_skills"): + if not isinstance(parsed.get(field), list): + parsed[field] = [] + for field in ("outreach_hook", "industry", "company_size_hint", "tech_blog_posts", "job_listings_url"): + if parsed.get(field) is None: + parsed[field] = "" + if not parsed.get("company_name"): + parsed["company_name"] = url.split("//")[-1].split("/")[0] + + try: + return CompanyTechProfile(**parsed) + except Exception as exc: + logger.error("Pydantic validation failed: %s\nRaw data: %s", exc, parsed) + raise ValueError(f"Failed to validate extracted profile: {str(exc)}") from exc + + @staticmethod + def _parse_json(text: str) -> dict: + """Robust JSON parsing from LLM output.""" + # Strip markdown code fences if present + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + # Attempt to extract JSON object from text + match = re.search(r"\{.*\}", text, re.DOTALL) + if match: + try: + return json.loads(match.group(0)) + except json.JSONDecodeError: + pass + + raise ValueError(f"Unable to parse valid JSON from LLM response: {text[:500]}") diff --git a/python-leadgen/models.py b/python-leadgen/models.py new file mode 100644 index 0000000..76c1b4a --- /dev/null +++ b/python-leadgen/models.py @@ -0,0 +1,81 @@ +"""Pydantic v2 data models for AI-driven Lead Generation & Tech Scoping.""" +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import List, Optional + + +class CompanyTechProfile(BaseModel): + company_name: str = Field( + ..., + description="Official company name as detected from the page content", + min_length=1, + ) + detected_technologies: List[str] = Field( + default_factory=list, + description="Detected technologies: Spark, Kafka, AWS, Kubernetes, Snowflake, Airflow, dbt, etc.", + ) + current_pain_points: List[str] = Field( + default_factory=list, + description="Inferred pain points: legacy migration, data quality, scalability, missing AI/ML, etc.", + ) + estimated_data_maturity: Optional[str] = Field( + default="Medium", + description="Estimated data maturity level: Low, Medium, or High", + ) + outreach_hook: Optional[str] = Field( + default="", + description="Concrete reason why a Forward Deployed Engineer can help this company right now", + ) + industry: Optional[str] = Field( + default=None, + description="Detected industry or sector", + ) + company_size_hint: Optional[str] = Field( + default=None, + description="Estimated company size: Startup, Scale-up, Mid-market, Enterprise", + ) + tech_blog_posts: Optional[str] = Field( + default=None, + description="URL to engineering/tech blog if detected", + ) + hiring_roles: List[str] = Field( + default_factory=list, + description="Job titles/roles detected from careers page: Data Engineer, ML Engineer, DevOps, etc.", + ) + desired_skills: List[str] = Field( + default_factory=list, + description="Specific skills/technologies they are hiring for: Python, Spark, Terraform, dbt, etc.", + ) + job_listings_url: Optional[str] = Field( + default=None, + description="URL to careers/jobs page if detected on the site", + ) + extracted_at: Optional[str] = Field( + default=None, + description="ISO timestamp of extraction", + ) + source_url: Optional[str] = Field( + default=None, + description="Original URL that was scraped", + ) + + +class ScrapeResult(BaseModel): + url: str + title: str + text_content: str + text_length: int + status_code: Optional[int] = None + error: Optional[str] = None + + +class LeadGenRequest(BaseModel): + url: str = Field(..., description="URL of the target company page to analyze") + force_refresh: bool = Field(default=False, description="Skip cache and force fresh scrape") + + +class LeadGenResponse(BaseModel): + success: bool + profile: Optional[CompanyTechProfile] = None + error: Optional[str] = None + elapsed_ms: Optional[float] = None diff --git a/python-leadgen/requirements.txt b/python-leadgen/requirements.txt new file mode 100644 index 0000000..decb567 --- /dev/null +++ b/python-leadgen/requirements.txt @@ -0,0 +1,6 @@ +flask[async]>=3.0 +pydantic>=2.0 +openai>=1.12 +httpx>=0.27 +beautifulsoup4>=4.12 +python-dotenv>=1.0 diff --git a/python-leadgen/scraper.py b/python-leadgen/scraper.py new file mode 100644 index 0000000..c47b5ba --- /dev/null +++ b/python-leadgen/scraper.py @@ -0,0 +1,157 @@ +"""Async httpx web scraper with retries, rotating user agents, and fallback.""" +from __future__ import annotations +import logging +import random +import re +from typing import Optional + +import httpx +from bs4 import BeautifulSoup + +from models import ScrapeResult + +logger = logging.getLogger(__name__) + +USER_AGENTS = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", +] + +REMOVE_TAGS = {"script", "style", "nav", "footer", "header", "noscript", "iframe", "svg"} + + +class AsyncScraper: + """Asynchronous web scraper with retries, rotating UAs, and fallback HTTP clients.""" + + def __init__(self, timeout_ms: int = 20_000): + self.timeout = timeout_ms / 1000.0 + + async def scrape(self, url: str) -> ScrapeResult: + """Scrape a URL with retries and fallbacks.""" + errors = [] + + # Strategy 1: Standard httpx client + for attempt in range(3): + try: + result = await self._try_scrape(url, attempt) + if result.text_content and len(result.text_content) > 50: + return result + if result.error: + errors.append(f"Attempt {attempt+1}: {result.error}") + except Exception as e: + errors.append(f"Attempt {attempt+1}: {str(e)}") + + # Strategy 2: Minimal headers (like curl) + for attempt in range(2): + try: + result = await self._try_minimal(url) + if result.text_content and len(result.text_content) > 50: + return result + if result.error: + errors.append(f"Minimal {attempt+1}: {result.error}") + except Exception as e: + errors.append(f"Minimal {attempt+1}: {str(e)}") + + return ScrapeResult(url=url, title="", text_content="", text_length=0, + error="; ".join(errors[-3:]) or "All scrape attempts failed") + + async def _try_scrape(self, url: str, attempt: int) -> ScrapeResult: + ua = USER_AGENTS[attempt % len(USER_AGENTS)] + headers = { + "User-Agent": ua, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9,nl;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "DNT": "1", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Cache-Control": "max-age=0", + } + async with httpx.AsyncClient( + timeout=self.timeout, + follow_redirects=True, + headers=headers, + http2=True, + ) as client: + response = await client.get(url) + return self._parse_response(response, url) + + async def _try_minimal(self, url: str) -> ScrapeResult: + headers = { + "User-Agent": "curl/8.4.0", + "Accept": "*/*", + } + async with httpx.AsyncClient( + timeout=self.timeout, + follow_redirects=True, + headers=headers, + http2=False, + ) as client: + response = await client.get(url) + return self._parse_response(response, url) + + def _parse_response(self, response, url: str) -> ScrapeResult: + result = ScrapeResult(url=url, title="", text_content="", text_length=0) + result.status_code = response.status_code + + if response.status_code >= 500: + result.error = f"HTTP {response.status_code}" + return result + if response.status_code >= 400 and response.status_code < 500: + if response.status_code in (403, 429): + result.error = f"HTTP {response.status_code} (blocked/rate-limited)" + else: + result.error = f"HTTP {response.status_code}" + return result + + try: + html = response.text + except Exception: + result.error = "Could not decode response" + return result + + if not html or len(html) < 100: + result.error = "Empty response" + return result + + soup = BeautifulSoup(html, "html.parser") + result.title = soup.title.string.strip() if soup.title else "" + + # Remove non-content elements + for tag in soup(REMOVE_TAGS): + tag.decompose() + + body = soup.find("body") + text = body.get_text(separator="\n") if body else soup.get_text(separator="\n") + + # Also extract text from JSON-LD structured data + for script in soup.find_all("script", type="application/ld+json"): + try: + import json + ld = json.loads(script.string or "") + if isinstance(ld, dict): + desc = ld.get("description", "") + if desc: + text += "\n" + desc + except Exception: + pass + + result.text_content = self._clean_text(text) + result.text_length = len(result.text_content) + return result + + @staticmethod + def _clean_text(text: str) -> str: + if not text: + return "" + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\n{3,}", "\n\n", text) + lines = [ln.strip() for ln in text.split("\n")] + lines = [ln for ln in lines if len(ln) > 2] + return "\n".join(lines).strip() diff --git a/python-quality/Dockerfile b/python-quality/Dockerfile new file mode 100644 index 0000000..2a90d8b --- /dev/null +++ b/python-quality/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim +WORKDIR /app +RUN pip install --upgrade pip +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV QS_PORT=3002 +EXPOSE 3002 +CMD ["python3", "app.py"] diff --git a/python-quality/app.py b/python-quality/app.py new file mode 100644 index 0000000..d4d8b1d --- /dev/null +++ b/python-quality/app.py @@ -0,0 +1,286 @@ +import os, json, uuid +import pandas as pd, numpy as np +from flask import Flask, request, jsonify +from werkzeug.utils import secure_filename + +app = Flask(__name__) +UPLOAD_FOLDER = '/app/data/uploads' +REPORT_FOLDER = '/app/data/reports' +DBT_PROJECT = '/app/data/dbt_projects' +for d in [UPLOAD_FOLDER, REPORT_FOLDER, DBT_PROJECT]: os.makedirs(d, exist_ok=True) +app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 +ALLOWED_EXT = {'csv', 'xlsx', 'xls', 'parquet', 'json', 'tsv'} + +def load_df(fp): + e = fp.rsplit('.',1)[1].lower() + if e == 'csv': return pd.read_csv(fp) + if e == 'tsv': return pd.read_csv(fp, sep='\t') + if e in ('xlsx','xls'): return pd.read_excel(fp) + if e == 'parquet': return pd.read_parquet(fp) + if e == 'json': return pd.read_json(fp) + +def safe_float(v): + try: return round(float(v), 4) + except: return None + +@app.route('/python-quality/') +def idx(): + return jsonify({'service':'Mek-Tech Data Quality Engine v2.0','features':['profiling','quality','dbt','correlation','distribution','lineage']}) + +@app.route('/python-quality/api/profile', methods=['POST']) +def profile(): + d = request.get_json() + sp = d.get('shared_path') + fid = d.get('file_id') + fp = None + if sp and os.path.exists(sp): fp = sp + elif fid: + for f in os.listdir(UPLOAD_FOLDER): + if f.startswith(fid): fp = os.path.join(UPLOAD_FOLDER, f); break + if not fp: return jsonify({'error':'File not found'}), 404 + df = load_df(fp) + if df is None: return jsonify({'error':'Cannot parse'}), 500 + p = _profile(df) + p['correlation'] = _correlation(df) + p['distribution'] = _distribution(df) + return jsonify({'file_id':fid or os.path.basename(fp).split('.')[0], 'rows':len(df), 'columns':len(df.columns), 'profile':p}) + +@app.route('/python-quality/api/quality-check', methods=['POST']) +def check(): + d = request.get_json() + sp = d.get('shared_path') + fid = d.get('file_id') + checks = d.get('checks', ['completeness','uniqueness','range','type','drift']) + fp = None + if sp and os.path.exists(sp): fp = sp + elif fid: + for f in os.listdir(UPLOAD_FOLDER): + if f.startswith(fid): fp = os.path.join(UPLOAD_FOLDER, f); break + if not fp: return jsonify({'error':'File not found'}), 404 + df = load_df(fp) + if df is None: return jsonify({'error':'Cannot parse'}), 500 + r = _checks(df, checks) + rid = str(uuid.uuid4()) + with open(os.path.join(REPORT_FOLDER,f"{rid}.json"),'w') as f: json.dump(r, f) + return jsonify({'report_id':rid,'results':r}) + +@app.route('/python-quality/api/report/') +def report(rid): + p = os.path.join(REPORT_FOLDER, f"{rid}.json") + if not os.path.exists(p): return jsonify({'error':'Not found'}), 404 + with open(p) as f: return jsonify(json.load(f)) + +@app.route('/python-quality/api/dbt/init', methods=['POST']) +def dbt_init(): + d = request.get_json() + name = d.get('project_name', 'default') + proj_dir = os.path.join(DBT_PROJECT, name) + os.makedirs(proj_dir, exist_ok=True) + # Create dbt_project.yml + with open(os.path.join(proj_dir, 'dbt_project.yml'), 'w') as f: + f.write(f"name: '{name}'\nversion: '1.0'\nprofile: '{name}'\nmodels:\n {name}:\n +materialized: table\n") + os.makedirs(os.path.join(proj_dir, 'models'), exist_ok=True) + os.makedirs(os.path.join(proj_dir, 'tests'), exist_ok=True) + return jsonify({'status':'created','project':name,'path':proj_dir}) + +@app.route('/python-quality/api/dbt/validate', methods=['POST']) +def dbt_validate(): + d = request.get_json() + models = d.get('models', []) + issues = [] + for m in models: + sql = (m.get('sql','')).lower() + cols = m.get('columns', []) + warns = [] + if 'select *' in sql: warns.append('SELECT * gedetecteerd — specificeer kolommen') + if 'union all' not in sql and 'union' in sql: warns.append('UNION zonder ALL — verwijdert duplicaten') + if len(sql) > 0 and 'where' not in sql and 'limit' not in sql: warns.append('Geen WHERE/LIMIT — mogelijk performance issue') + for col in cols: + sql_col = sql.lower() + if col.lower() in sql_col: + if f"cast({col.lower()}" not in sql_col and f"{col.lower()}::" not in sql_col: + pass + issues.append({'model_name':m.get('name','unnamed'),'warnings':warns,'status':'warning' if warns else 'ok'}) + return jsonify({'validated':len(models),'issues':issues}) + +@app.route('/python-quality/api/dbt/test', methods=['POST']) +def dbt_test(): + d = request.get_json() + tests = d.get('tests', []) + results = [] + for t in tests: + test_type = t.get('type','') + col = t.get('column','') + df_info = t.get('data', {}) + passed = True + msg = '' + try: + if test_type == 'unique': + vals = df_info.get('unique_count',0) + total = df_info.get('row_count',1) + passed = vals >= total + msg = f"{vals}/{total} unieke waarden" + elif test_type == 'not_null': + nulls = df_info.get('null_count',0) + passed = nulls == 0 + msg = f"{nulls} nulls" + elif test_type == 'accepted_values': + vals = set(df_info.get('values',[])) + accepted = set(t.get('values',[])) + passed = vals.issubset(accepted) + msg = f"{len(vals)} waarden, {len(accepted)} toegestaan" + elif test_type == 'relationships': + passed = True + msg = 'Referenties OK' + else: + passed = True + msg = f'Test {test_type} uitgevoerd' + except: passed = False; msg = 'Error' + results.append({'test':test_type,'column':col,'passed':passed,'message':msg}) + p = sum(1 for r in results if r['passed']) + return jsonify({'results':results,'summary':{'passed':p,'failed':len(results)-p,'total':len(results)}}) + +def _profile(df): + prof = {'rows':len(df),'columns':len(df.columns),'memory_mb':round(df.memory_usage(deep=True).sum()/(1024*1024),2)} + cols = [] + for col in df.columns: + cp = {'name':col,'dtype':str(df[col].dtype)} + cp['non_null']=int(df[col].notna().sum()) + cp['null_count']=int(df[col].isna().sum()) + cp['null_pct']=round(float(df[col].isna().mean()*100),2) + cp['unique']=int(df[col].nunique()) + cp['unique_pct']=round(float(df[col].nunique()/max(len(df),1)*100),2) + vals = df[col].dropna() + if pd.api.types.is_numeric_dtype(df[col]): + cp['min']=safe_float(vals.min()) if len(vals)>0 else None + cp['max']=safe_float(vals.max()) if len(vals)>0 else None + cp['mean']=safe_float(vals.mean()) if len(vals)>0 else None + cp['std']=safe_float(vals.std()) if len(vals)>0 else None + cp['zeros']=int((df[col]==0).sum()) + cp['negative']=int((df[col]<0).sum()) + qtls = vals.quantile([0.25,0.5,0.75]).to_dict() + cp['q25']=safe_float(qtls.get(0.25)); cp['q50']=safe_float(qtls.get(0.5)); cp['q75']=safe_float(qtls.get(0.75)) + bins = min(20,max(3,int(len(vals)/5))) + hist,b_edges = np.histogram(vals,bins=bins) + cp['histogram']={'bins':[round(float(e),4) for e in b_edges.tolist()],'counts':hist.tolist()} + else: + top = vals.value_counts().head(5).to_dict() + cp['top_values'] = {str(k):int(v) for k,v in top.items()} + cp['sample'] = vals.head(3).tolist() + if pd.api.types.is_string_dtype(vals): + cp['min_len']=int(vals.astype(str).str.len().min()) if len(vals)>0 else 0 + cp['max_len']=int(vals.astype(str).str.len().max()) if len(vals)>0 else 0 + try: + pd_dt = pd.to_datetime(vals, errors='coerce') + if pd_dt.notna().sum()>0: + cp['date_min']=str(pd_dt.min().date()); cp['date_max']=str(pd_dt.max().date()) + except: pass + cols.append(cp) + prof['columns_profile']=cols + prof['overall_quality_score']=_score(cols) + prof['suggestions']=_suggest(cols) + return prof + +def _correlation(df): + nums = df.select_dtypes(include=[np.number]) + if len(nums.columns)<2: return [] + corr = nums.corr().round(3) + rels = [] + for i,ci in enumerate(corr.columns): + for j,cj in enumerate(corr.columns): + if i0.7 else 'medium' if abs(v)>0.4 else 'low','direction':'positive' if v>0 else 'negative'}) + rels.sort(key=lambda x: abs(x['value']), reverse=True) + return rels[:15] + +def _distribution(df): + dist = {} + for col in df.select_dtypes(include=[np.number]).columns[:5]: + vals = df[col].dropna() + if len(vals)<2: continue + dist[col] = { + 'min':safe_float(vals.min()),'max':safe_float(vals.max()),'mean':safe_float(vals.mean()), + 'median':safe_float(vals.median()),'std':safe_float(vals.std()), + 'skew':safe_float(vals.skew()) if len(vals)>2 else 0, + 'kurtosis':safe_float(vals.kurtosis()) if len(vals)>3 else 0, + 'outliers_iqr':int(((vals<(vals.quantile(0.25)-1.5*(vals.quantile(0.75)-vals.quantile(0.25))))|(vals>(vals.quantile(0.75)+1.5*(vals.quantile(0.75)-vals.quantile(0.25))))).sum()) + } + return dist + +def _score(profiles): + if not profiles: return 100 + scs = [] + for p in profiles: + s = 100 + if p['null_pct']>50: s-=35 + elif p['null_pct']>20: s-=20 + elif p['null_pct']>5: s-=8 + if p['unique_pct']>99 and p['null_pct']<50: s-=10 + if p['unique_pct']<1: s-=25 + scs.append(max(0,s)) + return round(sum(scs)/len(scs),1) + +def _suggest(profiles): + sug = [] + for p in profiles: + if p['null_pct']>30: sug.append({'column':p['name'],'issue':f"Zeer hoge null-rate ({p['null_pct']}%)",'severity':'high','action':'Imputeer met mediaan/modus of overweeg kolom te verwijderen'}) + elif p['null_pct']>10: sug.append({'column':p['name'],'issue':f"Missende waarden ({p['null_pct']}%)",'severity':'medium','action':'Overweeg imputatie of markeer als optioneel'}) + elif p['null_pct']>2: sug.append({'column':p['name'],'issue':f"Enkele nulls ({p['null_pct']}%)",'severity':'low','action':'Controleer of nulls verwacht worden'}) + if p.get('zeros') and p['zeros']/(max(p['non_null'],1))>0.4: sug.append({'column':p['name'],'issue':f"Veel nullen (>{round(p['zeros']/max(p['non_null'],1)*100)}%)",'severity':'medium','action':'Controleer of 0 legitieme waarden of missende data zijn'}) + if p.get('negative') and p['negative']>0: sug.append({'column':p['name'],'issue':f"Negatieve waarden ({p['negative']}) in numerieke kolom",'severity':'medium','action':'Valideer of negatieve waarden zakelijk correct zijn'}) + if p['unique_pct']>95 and p['null_pct']<50: sug.append({'column':p['name'],'issue':'Nagenoeg uniek — vermoedelijk ID kolom','severity':'low','action':'Geschikt als primary key, niet voor aggregatie of model training'}) + if p['unique_pct']<1 and p['null_pct']<90: sug.append({'column':p['name'],'issue':'Constante kolom — geen variatie','severity':'low','action':'Overweeg deze kolom te verwijderen voor analyse'}) + if p.get('std') and p.get('mean') and p['mean']!=0 and p['std']/abs(p['mean'])>2: sug.append({'column':p['name'],'issue':'Hoge variabiliteit (CV > 200%)','severity':'medium','action':'Controleer op outliers, overweeg winsorization of log-transformatie'}) + return sug + +def _checks(df, checks): + res = {'checks':[],'summary':{'passed':0,'failed':0,'total':0}} + for col in df.columns: + if 'completeness' in checks: + npct = float(df[col].isna().mean()*100) + ok = npct<20 + res['checks'].append({'type':'completeness','column':col,'passed':ok,'value':round(npct,2),'threshold':'<20% null'}) + res['summary']['total']+=1 + if ok: res['summary']['passed']+=1 + else: res['summary']['failed']+=1 + if 'uniqueness' in checks: + upct = float(df[col].nunique()/max(len(df),1)*100) + ok = upct>1 + res['checks'].append({'type':'uniqueness','column':col,'passed':ok,'value':round(upct,2),'threshold':'>1% uniek'}) + res['summary']['total']+=1 + if ok: res['summary']['passed']+=1 + else: res['summary']['failed']+=1 + is_num = False + try: is_num = np.issubdtype(df[col].dtype, np.number) + except: pass + if 'range' in checks and is_num: + vals = df[col].dropna() + if len(vals)>1: + q1=float(vals.quantile(0.25)); q3=float(vals.quantile(0.75)); iqr=q3-q1 + out = int(((vals<(q1-1.5*iqr))|(vals>(q3+1.5*iqr))).sum()) + ok = out/max(len(vals),1)<0.1 + res['checks'].append({'type':'outliers','column':col,'passed':ok,'value':out,'threshold':'<10% outliers'}) + res['summary']['total']+=1 + if ok: res['summary']['passed']+=1 + else: res['summary']['failed']+=1 + if 'drift' in checks and is_num: + vals = df[col].dropna() + if len(vals)>1: + m=float(vals.mean()); s=float(vals.std()) + ok = s/(abs(m)+1)<1.0 if m!=0 else True + res['checks'].append({'type':'drift','column':col,'passed':ok,'value':round(s/max(abs(m),0.01),3),'threshold':'std/mean < 1.0'}) + res['summary']['total']+=1 + if ok: res['summary']['passed']+=1 + else: res['summary']['failed']+=1 + if 'type' in checks: + res['checks'].append({'type':'type','column':col,'passed':True,'value':str(df[col].dtype),'threshold':'consistent'}) + res['summary']['total']+=1; res['summary']['passed']+=1 + sc = round((res['summary']['passed']/max(res['summary']['total'],1))*100,1) + res['quality_score']=sc + res['grade']='A' if sc>=90 else 'B' if sc>=75 else 'C' if sc>=50 else 'D' if sc>=25 else 'F' + return res + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=int(os.environ.get('QS_PORT',3002)), debug=False) diff --git a/python-quality/requirements.txt b/python-quality/requirements.txt new file mode 100644 index 0000000..c59487c --- /dev/null +++ b/python-quality/requirements.txt @@ -0,0 +1,9 @@ +flask>=3.0 +pandas>=2.0,<3.0 +numpy>=1.24,<2.0 +openpyxl>=3.1 +python-multipart>=0.0.6 +scikit-learn>=1.3 +scipy>=1.11 +matplotlib>=3.7 +pyarrow>=14.0 diff --git a/rack-diagram.js b/rack-diagram.js new file mode 100644 index 0000000..575ffcf --- /dev/null +++ b/rack-diagram.js @@ -0,0 +1,171 @@ +const DEVICE_TO_NODE = { + server: 'hw-dell-pe', + switch: 'net-catalyst', + storage: 'storage-netapp', + firewall: 'net-fortinet', + router: 'net-junper-srx', + loadbalancer: 'net-f5', + san: 'storage-pure', + pdu: 'infra-pdu', + ups: 'infra-apc-ups', + cooling: 'infra-cooling', + patch_panel: 'infra-patch-panel', + tape: 'storage-netapp', + kvm: 'infra-rack', + other: 'infra-rack' +}; + +const MANUFACTURER_NODE = { + hpe: 'hw-hpe-dl', + dell: 'hw-dell-pe', + lenovo: 'hw-lenovo-ts', + cisco: 'net-catalyst', + juniper: 'net-junper-ex', + arista: 'net-arista', + netapp: 'storage-netapp', + pure: 'storage-pure', + fortinet: 'net-fortinet', + f5: 'net-f5', + palo: 'net-paloalto', + nvidia: 'ai-nvidia', + apc: 'infra-apc-ups' +}; + +const DIAGRAM_TYPE_LABELS = { + data_architecture: 'Data Architectuur', + network_topology: 'Netwerk Topologie', + system_architecture: 'Systeem Architectuur', + application_flow: 'Applicatie Flow', + application_landscape: 'Applicatie Landschap' +}; + +function resolveNodeType(device) { + const m = (device.manufacturer || '').toLowerCase(); + for (const [key, type] of Object.entries(MANUFACTURER_NODE)) { + if (m.includes(key)) return type; + } + return DEVICE_TO_NODE[device.device_type] || 'hw-dell-pe'; +} + +function rackDevicesToNodes(devices, startX = 40, startY = 40) { + return devices.map((d, i) => ({ + id: 'rd-' + d.id, + type: resolveNodeType(d), + x: startX + (i % 4) * 180, + y: startY + Math.floor(i / 4) * 90, + label: d.name, + sub: (d.model || d.device_type || '').toString(), + css: 'node-hw', + w: 160, + h: 48, + ip: d.mgmt_ip || '', + vendor: d.manufacturer || '', + model: d.model || '', + specs: d.specs || '', + notes: d.notes || '', + rack_device_id: d.id, + layer: 'infra' + })); +} + +function parseDiagramJson(diagram) { + let nodes = []; + let edges = []; + try { + nodes = JSON.parse(diagram.nodes || '[]'); + edges = JSON.parse(diagram.edges || '[]'); + } catch (e) { /* ignore */ } + if (!Array.isArray(nodes) && nodes && nodes.nodes) { + edges = nodes.edges || []; + nodes = nodes.nodes || []; + } + if (!Array.isArray(edges) && edges && edges.edges) { + nodes = edges.nodes || nodes; + edges = edges.edges || []; + } + if (!Array.isArray(nodes)) nodes = []; + if (!Array.isArray(edges)) edges = []; + return { nodes, edges }; +} + +function summarizeDiagram(diagram) { + const { nodes, edges } = parseDiagramJson(diagram); + const layers = {}; + nodes.forEach(n => { + const layer = n.layer || (n.type || '').split('-')[0] || 'other'; + layers[layer] = (layers[layer] || 0) + 1; + }); + const protocols = {}; + edges.forEach(e => { + const p = e.protocol || 'unspecified'; + protocols[p] = (protocols[p] || 0) + 1; + }); + return { nodes, edges, layers, protocols, nodeCount: nodes.length, edgeCount: edges.length }; +} + +function buildStudioDemo(rack, devices) { + const infraNodes = rackDevicesToNodes(devices, 40, 80); + const appNodes = [ + { id: 'demo-api', type: 'api-gateway', x: 480, y: 90, label: 'API Gateway', sub: 'Ingress / Routing', css: 'node-ingest', w: 150, h: 44, layer: 'integration' }, + { id: 'demo-erp', type: 'app-java', x: 480, y: 190, label: 'ERP Core', sub: 'Java / Spring', css: 'node-compute', w: 140, h: 44, layer: 'app' }, + { id: 'demo-dw', type: 'storage-warehouse', x: 480, y: 290, label: 'Data Warehouse', sub: 'Analytics', css: 'node-storage', w: 155, h: 44, layer: 'data' }, + { id: 'demo-kafka', type: 'queue-kafka', x: 680, y: 140, label: 'Event Bus', sub: 'Kafka', css: 'node-queue', w: 140, h: 44, layer: 'integration' }, + { id: 'demo-portal', type: 'app-react', x: 680, y: 240, label: 'Web Portal', sub: 'React Frontend', css: 'node-compute', w: 145, h: 44, layer: 'app' } + ]; + const nodes = [...infraNodes, ...appNodes]; + const edges = []; + if (infraNodes[0]) { + edges.push({ from: infraNodes[0].id, to: 'demo-api', flow: 'both', protocol: 'sync' }); + edges.push({ from: 'demo-api', to: 'demo-erp', flow: 'flow', protocol: 'sync' }); + edges.push({ from: 'demo-erp', to: 'demo-dw', flow: 'flow', protocol: 'batch' }); + edges.push({ from: 'demo-api', to: 'demo-kafka', flow: 'pulse', protocol: 'event' }); + edges.push({ from: 'demo-kafka', to: 'demo-portal', flow: 'both', protocol: 'async' }); + if (infraNodes[1]) edges.push({ from: infraNodes[1].id, to: 'demo-dw', flow: 'flow', protocol: 'batch' }); + } + return { + flow: { + name: 'Applicatie Flow — ' + rack.name, + diagram_type: 'application_flow', + nodes: JSON.stringify(nodes), + edges: JSON.stringify(edges) + }, + landscape: { + name: 'Applicatie Landschap — ' + rack.name, + diagram_type: 'application_landscape', + nodes: JSON.stringify(appNodes.map(n => ({ ...n, x: n.x - 200, y: n.y }))), + edges: JSON.stringify([ + { from: 'demo-portal', to: 'demo-api', flow: 'off', protocol: 'sync' }, + { from: 'demo-api', to: 'demo-erp', flow: 'off', protocol: 'sync' }, + { from: 'demo-erp', to: 'demo-dw', flow: 'off', protocol: 'batch' } + ]) + } + }; +} + +function getWorkspaceTab(diagramType) { + if (diagramType === 'application_flow') return 'flow'; + if (diagramType === 'application_landscape') return 'landscape'; + return 'flow'; +} + +function getLayerForCategory(catLabel) { + const c = (catLabel || '').toLowerCase(); + if (c.includes('data bron') || c.includes('ingest') || c.includes('opslag') || c.includes('database')) return 'data'; + if (c.includes('microservice') || c.includes('apps')) return 'app'; + if (c.includes('api') || c.includes('messaging') || c.includes('queue')) return 'integration'; + if (c.includes('netwerk') || c.includes('compute / server') || c.includes('infrastructure') || c.includes('security') || c.includes('observability') || c.includes('ci/cd')) return 'infra'; + if (c.includes('ai')) return 'app'; + return 'other'; +} + +module.exports = { + DEVICE_TO_NODE, + DIAGRAM_TYPE_LABELS, + resolveNodeType, + rackDevicesToNodes, + parseDiagramJson, + summarizeDiagram, + getLayerForCategory, + buildStudioDemo, + getWorkspaceTab +}; diff --git a/rack-infra-report.ejs b/rack-infra-report.ejs new file mode 100644 index 0000000..4a25155 --- /dev/null +++ b/rack-infra-report.ejs @@ -0,0 +1,149 @@ + + + + + +Infrastructuur Rapport — <%= rack.name %> — Mek-Tech + + + + + + + + + + +
+
+

Gecombineerd Infrastructuur Rapport

+
<%= rack.name %> · <%= rack.client_name %>
+
<%= rack.location || '' %> <%= rack.datacenter ? '· ' + rack.datacenter : '' %> · <%= new Date().toLocaleDateString('nl-NL',{weekday:'long',year:'numeric',month:'long',day:'numeric'}) %>
+
+ +
+

Rack Overzicht

+
+
<%= devices.length %>
Devices
+
<%= usedU %>/<%= rack.total_units || 42 %>
Rack Units
+
<%= Math.round(usedU/(rack.total_units||42)*100) %>%
Bezetting
+
<%= diagrams.length %>
Diagrammen
+
+
+<% devices.forEach(function(d){ %> +
+U<%= d.position_u %> — <%= d.name %> +<%= d.manufacturer || '' %> <%= d.model || '' %> · <%= d.device_type %> · <%= d.height_u %>U +<% if (d.mgmt_ip) { %>
<%= d.mgmt_ip %><% } %> +
+<% }) %> +<% if (devices.length === 0) { %>

Geen devices in dit rack.

<% } %> +
+ + + +<% devices.forEach(function(d){ %> + +<% }) %> + +
PosNaamTypeVendor / ModelIPStatus
U<%= d.position_u %><%= d.name %><%= d.device_type %><%= d.manufacturer || '—' %> <%= d.model || '' %><%= d.mgmt_ip || '—' %><%= d.status || 'active' %>
+
+ +<% if (diagrams.length > 0) { %> +
+

Applicatie Flows & Landschap

+<% diagrams.forEach(function(d){ %> +
+

<%= d.name %>

+
+<%= d.typeLabel %> +<%= d.nodeCount %> componenten · <%= d.edgeCount %> flows · bijgewerkt <%= new Date(d.updated_at).toLocaleDateString('nl-NL') %> +
+<% if (Object.keys(d.layers || {}).length) { %> +
+<% Object.entries(d.layers).forEach(function(e){ %><%= e[0] %>: <%= e[1] %><% }) %> +
+<% } %> +<% if (d.nodes && d.nodes.length) { %> +
+<% d.nodes.slice(0,12).forEach(function(n,i){ %> +<% if (i > 0) { %><% } %> +<%= n.label || n.type %><% if (n.rack_device_id) { %> ●rack<% } %> +<% }) %> +<% if (d.nodes.length > 12) { %>+<%= d.nodes.length - 12 %> meer<% } %> +
+<% } %> +<% if (d.edges && d.edges.length) { %> + + + +<% d.edges.forEach(function(e,i){ + var from = (d.nodes || []).find(function(n){ return n.id == e.from; }); + var to = (d.nodes || []).find(function(n){ return n.id == e.to; }); +%> + + + + + + + +<% }) %> + +
#VanNaarProtocolAnimatie
<%= i+1 %><%= from ? from.label : e.from %>→ <%= to ? to.label : e.to %><%= e.protocol || '—' %><%= e.flow || 'off' %>
+<% } %> +Open in designer +
+<% }) %> +
+<% } else { %> +
+

Applicatie Flows & Landschap

+

Nog geen flows of landschap gekoppeld aan dit rack.

+ +
+<% } %> + + +
+ + + + diff --git a/rack-studio.ejs b/rack-studio.ejs new file mode 100644 index 0000000..3407f76 --- /dev/null +++ b/rack-studio.ejs @@ -0,0 +1,92 @@ + + + + + +Studio — <%= rack.name %> — Mek-Tech + + + + + + + + +
+<%- include('partials/rack-workspace-nav', { rack, flowDiagram, landscapeDiagram, workspaceTab: 'studio' }) %> +
+
+

Gecombineerde Studio — <%= rack.name %>

+

Infrastructuur links · Applicatie flows rechts · sleep de scheidingslijn om te vergroten/verkleinen

+
+
+ + + + + Rapport +
+
+
+
+ +
+
+
+ +
+
+
+ Tip: Wijzig het rack links (devices slepen) en teken flows rechts. Gebruik de tabs bovenaan om te wisselen — je blijft altijd binnen rack <%= rack.name %>. + <% if (!flowDiagram) { %> Nog geen flow? Klik Voorbeeld laden voor een startpunt. <% } %> +
+
+ + + + diff --git a/racks-form.ejs b/racks-form.ejs new file mode 100644 index 0000000..182b657 --- /dev/null +++ b/racks-form.ejs @@ -0,0 +1,65 @@ + + + + + + <%= rack ? 'Bewerk' : 'Nieuwe' %> Rack — Mek-Tech Consulting + + + + + + + + +
+ Terug + + +
+ <% if (!rack) { %> +
+ + +
+ <% } %> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Annuleren +
+
+
+ + diff --git a/racks-view.ejs b/racks-view.ejs new file mode 100644 index 0000000..5837770 --- /dev/null +++ b/racks-view.ejs @@ -0,0 +1,756 @@ + + + + + +<%= rack.name %> — Rack View — Mek-Tech + + + + + + + + style="overflow:hidden"<% } else { %> class="has-rack-ws-nav"<% } %>> + +<% if (!(typeof embed !== 'undefined' && embed)) { %> +<%- include('partials/rack-workspace-nav', { rack, flowDiagram, landscapeDiagram, workspaceTab: typeof workspaceTab !== 'undefined' ? workspaceTab : 'rack' }) %> + +<% } %> + +
style="padding:.5rem;max-width:none"<% } %>> + + + Terug + + +
+ + +
+
+
<%= rack.name %> · <%= devices.reduce((s,d)=>s+d.height_u,0) %>/<%= rack.total_units %>U
+
+ + +
+
+
Drag devices to reposition
+ <% + var units = []; + devices.forEach(function(d) { for(var u = d.position_u; u < d.position_u + d.height_u; u++) units[u] = d; }); + var _html = ''; + for(var u = rack.total_units; u >= 1; u--) { + var dev = units[u]; + if (dev && u === dev.position_u) { + var vc = 'v-other', vb = '#555d66'; + var vm = (dev.manufacturer||'').toLowerCase(); + if(vm.includes('hpe')||vm.includes('hewlett')){vc='v-hpe';vb='#ff7b00'} + else if(vm.includes('dell')){vc='v-dell';vb='#0076a8'} + else if(vm.includes('lenovo')){vc='v-lenovo';vb='#e41f33'} + else if(vm.includes('cisco')){vc='v-cisco';vb='#00529a'} + else if(vm.includes('juniper')){vc='v-juniper';vb='#44a833'} + else if(vm.includes('arista')){vc='v-arista';vb='#cc3333'} + else if(vm.includes('netapp')){vc='v-netapp';vb='#00a1df'} + else if(vm.includes('pure')){vc='v-pure';vb='#4a90d9'} + else if(vm.includes('fortinet')){vc='v-fortinet';vb='#ee3124'} + else if(vm.includes('super')||vm.includes('supermicro')){vc='v-supermicro';vb='#e6a817'} + else if(vm.includes('apc')){vc='v-apc';vb='#6e7681'} + else if(vm.includes('f5')){vc='v-f5';vb='#d32f2f'} + else if(vm.includes('palo')){vc='v-paloalto';vb='#ed7b30'} + else if(vm.includes('nvidia')){vc='v-nvidia';vb='#76b900'} + else if(vm.includes('oracle')){vc='v-oracle';vb='#f80000'} + else if(vm.includes('ibm')){vc='v-ibm';vb='#0062ff'} + else if(dev.device_type==='patch_panel'||dev.device_type==='pdu'||dev.device_type==='ups'){vc='v-apc';vb='#6e7681'} + var dtIcon='server'; + if(dev.device_type==='switch') dtIcon='git-branch'; + else if(dev.device_type==='storage') dtIcon='database'; + else if(dev.device_type==='firewall') dtIcon='shield'; + else if(dev.device_type==='router') dtIcon='radio'; + else if(dev.device_type==='loadbalancer') dtIcon='scale'; + else if(dev.device_type==='san') dtIcon='hard-drive'; + else if(dev.device_type==='patch_panel') dtIcon='cable'; + else if(dev.device_type==='pdu') dtIcon='zap'; + else if(dev.device_type==='ups') dtIcon='battery-charging'; + else if(dev.device_type==='cooling') dtIcon='fan'; + else if(dev.device_type==='tape') dtIcon='archive'; + else if(dev.device_type==='kvm') dtIcon='monitor'; + else if(dev.device_type==='other') dtIcon='box'; + var topx = (rack.total_units - dev.position_u - dev.height_u + 1) * 30 + 20; + var hx = dev.height_u * 30 - 2; + _html += '
'; + _html += ''; + _html += '' + dev.name + ''; + _html += '' + (dev.model||dev.manufacturer||dev.device_type) + ''; + if (dev.mgmt_ip) _html += '' + dev.mgmt_ip + ''; + _html += '' + dev.height_u + 'U'; + _html += '
'; + } else if (!dev) { + _html += '
' + u + '
'; + } + } + %> + <%- _html %> +
+
+
+ + + +
+ +
+ + +<% devices.forEach(function(d) { %> + <% var vm=(d.manufacturer||'').toLowerCase(); %> + <% var vb='#555d66'; %> + <% if(vm.includes('hpe')) vb='#ff7b00'; else if(vm.includes('dell')) vb='#0076a8'; + else if(vm.includes('lenovo')) vb='#e41f33'; else if(vm.includes('cisco')) vb='#00529a'; + else if(vm.includes('juniper')) vb='#44a833'; else if(vm.includes('arista')) vb='#cc3333'; + else if(vm.includes('netapp')) vb='#00a1df'; else if(vm.includes('pure')) vb='#4a90d9'; + else if(vm.includes('fortinet')) vb='#ee3124'; else if(vm.includes('apc')) vb='#6e7681'; %> + <% var di='server'; %> + <% if(d.device_type==='switch') di='git-branch'; else if(d.device_type==='storage') di='database'; + else if(d.device_type==='firewall') di='shield'; else if(d.device_type==='router') di='radio'; + else if(d.device_type==='loadbalancer') di='scale'; else if(d.device_type==='san') di='hard-drive'; + else if(d.device_type==='patch_panel') di='cable'; else if(d.device_type==='pdu') di='zap'; + else if(d.device_type==='ups') di='battery-charging'; else if(d.device_type==='cooling') di='fan'; + else if(d.device_type==='tape') di='archive'; else if(d.device_type==='kvm') di='monitor'; + else if(d.device_type==='other') di='box'; %> + +<% }); %> + + + + \ No newline at end of file diff --git a/racks.js b/racks.js new file mode 100644 index 0000000..9aa83ff --- /dev/null +++ b/racks.js @@ -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; diff --git a/reports.js b/reports.js new file mode 100644 index 0000000..55c193d --- /dev/null +++ b/reports.js @@ -0,0 +1,69 @@ +const express = require('express'); +const router = express.Router(); +const { getDb } = require('../db'); + +function initReportsTable() { + const db = getDb(); + db.exec(` + CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + report_type TEXT DEFAULT 'client_summary', + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + project_id INTEGER, + config TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')) + ) + `); +} + +router.get('/', (req, res) => { + initReportsTable(); + const db = getDb(); + const tab = req.query.tab || 'builder'; + const clients = db.prepare('SELECT id, name FROM clients ORDER BY name').all(); + let projects = []; + try { projects = db.prepare('SELECT id, name FROM projects ORDER BY name').all(); } catch (e) {} + const savedReports = db.prepare(` + SELECT r.*, c.name as client_name FROM reports r + LEFT JOIN clients c ON c.id = r.client_id ORDER BY r.created_at DESC LIMIT 20 + `).all(); + res.render('reports', { tab, clients, projects, savedReports }); +}); + +router.post('/generate', async (req, res) => { + const { report_type, client_id, project_id, date_from, date_to, format } = req.body; + const db = getDb(); + let data = []; + + if (report_type === 'client_summary') { + data = db.prepare(` + SELECT c.*, + (SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id) as engagement_count, + (SELECT COALESCE(SUM(i.total),0) FROM invoices i WHERE i.client_id = c.id) as total_revenue + FROM clients c WHERE (? = '' OR c.id = ?) + `).all(client_id || '', client_id || ''); + } else if (report_type === 'financial') { + data = db.prepare(` + SELECT i.*, c.name as client_name FROM invoices i + LEFT JOIN clients c ON c.id = i.client_id + WHERE (? = '' OR i.created_at >= ?) AND (? = '' OR i.created_at <= ?) + `).all(date_from || '', date_from || '', date_to || '', date_to || ''); + } + + if (format === 'pdf' || format === 'excel') { + return res.json({ data, note: 'Export naar PDF/Excel — installeer pdfkit/exceljs of gebruik JSON' }); + } + res.json({ data }); +}); + +router.post('/save', (req, res) => { + initReportsTable(); + const db = getDb(); + const { name, report_type, client_id, project_id, config } = req.body; + db.prepare(`INSERT INTO reports (name, report_type, client_id, project_id, config) VALUES (?, ?, ?, ?, ?)`) + .run(name, report_type, client_id, project_id, JSON.stringify(config || {})); + res.redirect('/reports'); +}); + +module.exports = router; diff --git a/routes/2fa.js b/routes/2fa.js new file mode 100644 index 0000000..ef4684e --- /dev/null +++ b/routes/2fa.js @@ -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; diff --git a/routes/ai.js b/routes/ai.js new file mode 100644 index 0000000..7852ba7 --- /dev/null +++ b/routes/ai.js @@ -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; diff --git a/routes/api.js b/routes/api.js new file mode 100644 index 0000000..21fbac6 --- /dev/null +++ b/routes/api.js @@ -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; diff --git a/routes/architecture.js b/routes/architecture.js new file mode 100644 index 0000000..b8eae82 --- /dev/null +++ b/routes/architecture.js @@ -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; diff --git a/routes/assess.js b/routes/assess.js new file mode 100644 index 0000000..7b57ef5 --- /dev/null +++ b/routes/assess.js @@ -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; diff --git a/routes/audit.js b/routes/audit.js new file mode 100644 index 0000000..bee96d6 --- /dev/null +++ b/routes/audit.js @@ -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; diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..b623618 --- /dev/null +++ b/routes/auth.js @@ -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; diff --git a/routes/automations.js b/routes/automations.js new file mode 100644 index 0000000..5ddecdd --- /dev/null +++ b/routes/automations.js @@ -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; diff --git a/routes/backup.js b/routes/backup.js new file mode 100644 index 0000000..0842293 --- /dev/null +++ b/routes/backup.js @@ -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; diff --git a/routes/calendar.js b/routes/calendar.js new file mode 100644 index 0000000..880a7f5 --- /dev/null +++ b/routes/calendar.js @@ -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; diff --git a/routes/clients.js b/routes/clients.js new file mode 100644 index 0000000..8a3e480 --- /dev/null +++ b/routes/clients.js @@ -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; diff --git a/routes/consulting.js b/routes/consulting.js new file mode 100644 index 0000000..20f65a7 --- /dev/null +++ b/routes/consulting.js @@ -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; diff --git a/routes/dashboard.ejs b/routes/dashboard.ejs new file mode 100644 index 0000000..824d51d --- /dev/null +++ b/routes/dashboard.ejs @@ -0,0 +1,302 @@ +Dashboard — Mek-Tech Enterprise + + + +
+ + +
+ + +
+

Enterprise Dashboard

Real-time consultancy intelligence platform

+
+ +
<%= stats.activeEngagements %>
Actieve Engagements
van <%= stats.totalEngagements %> totaal
+
<%= totalRackDevices + totalNetworkDevices %>
Infra Devices
<%= totalRacks %> racks · <%= totalDiagrams %> diagrammen
+
<%= overdueTasks %>
Overdue Taken
<%= completedTasks %>/<%= totalTasks %> voltooid
+
<%= assessmentsCount %>
Assessments
Data quality engine
+
+
+
Cliënten Status
+<% 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;%>
<%= sts[k] %><%= cnt %>
<% } %> +
+
Engagements per Type
+<% 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){ %>
<%= e.type %><%= e.cnt %>
<% }) %> +
+
+
Recente Assessments
+<% if(recentAssessments.length===0){ %>
Nog geen assessments
<% }else{ %>
<% recentAssessments.forEach(function(a){ %>
Assessment voor <%= a.client_name %>
<%= new Date(a.created_at).toLocaleDateString('nl-NL',{hour:'2-digit',minute:'2-digit'}) %>
<% }) %>
<% } %> +
+
Quick Actions
+
+ + + + + +

Engagements

<%= stats.totalEngagements %> totaal · <%= stats.activeEngagements %> actief

+
+
Per Type
<% engagementByType.forEach(function(e){ %>
<%= e.type %><%= e.cnt %>
<% }) %>
+
Per Status
<% engagementByStatus.forEach(function(e){ %>
<%= e.status.replace('_',' ') %><%= e.cnt %>
<% }) %>
+
Alle Engagements
+<% if(allEngagements.length===0){ %>
Nog geen engagements
<% }else{ %> +<% allEngagements.forEach(function(e){ %><% }) %>
TitelCliëntTypeStatus
<%= e.title %><%= e.client_name %><%= e.status %>Details
<% } %> +
+ + +

Financiën

Urenregistratie en facturatie

+
<%= totalHours.toFixed(1) %>
Totaal uren
<%= billableHours.toFixed(1) %> facturabel
€<%= totalInvoiced.toFixed(0) %>
Gefactureerd
€<%= totalPaid.toFixed(0) %> betaald
€<%= totalOutstanding.toFixed(0) %>
Openstaand
+ +
Revenue Overview
+<% if(timeEntries.length===0){ %>
Nog geen uren
<% }else{ %><% timeEntries.forEach(function(t){ %><% }) %>
DatumUrenCliëntOmschrijving
<%= t.date %><%= t.hours %><%= t.client_name||'-' %><%= t.description||'' %>
<% } %>
+
Recente facturen
+<% if(invoices.length===0){ %>
Nog geen facturen
<% }else{ %><% invoices.forEach(function(i){ %><% }) %>
NrCliëntTotaalStatus
<%= i.number %><%= i.client_name %>€<%= (i.total||0).toFixed(2) %><%= i.status %>
<% } %>
+ + +

Infrastructuur

Racks, netwerk & architectuur

+
<%= totalRacks %>
Racks
<%= totalRackDevices %> devices
<%= totalNetworkDevices %>
Netwerk
<%= totalDiagrams %>
Diagrammen
+
Server Racks
+<% if(racks.length===0){ %>
Nog geen racks
<% }else{ %><% racks.forEach(function(r){ %>
<% for(var i=0;i
<% } %>

<%= r.name %>

<%= r.client_name %> · <%= r.device_count||0 %>/<%= r.total_units||42 %>U

<% }) %><% } %>
+
Netwerk Devices
+<% if(netDevices.length===0){ %>
Nog geen devices
<% }else{ %><% netDevices.forEach(function(d){ %>
<%= d.device_type.charAt(0).toUpperCase() %>
<%= d.name %>
<%= d.model||'' %> · <%= d.client_name %>
<% }) %><% } %>
+ + +

Taken

<%= completedTasks %>/<%= totalTasks %> voltooid <% if(overdueTasks>0){ %><%= overdueTasks %> over tijd<% } %>

+
Tasks Overview
Clients by Industry
<%= completedTasks %>
Voltooid
<%= totalTasks-completedTasks %>
Open
<%= overdueTasks %>
Over tijd
+<% if(allTasks.length===0){ %>
Nog geen taken
<% }else{ %>
<% allTasks.forEach(function(t){ var io=t.status!=='done'&&t.status!=='cancelled'&&t.due_date&&new Date(t.due_date)
<%= t.priority %>
<%= t.title %><%= t.client_name %> · <%= t.engagement_title %>
<%= t.status.replace('_',' ') %><% if(t.due_date){ %><%= new Date(t.due_date).toLocaleDateString('nl-NL') %><% } %>
<% }) %>
<% } %>
+ + +

Advies & Acties

Automatisch advies op basis van cliënt data

+<% if(activeAdvice.length===0){ %>
Geen advies items
<% }else{ %>
<% activeAdvice.forEach(function(a){ %>
<%= a.priority %>

<%= a.advice_text %>

<% }) %>
<% } %>
+ + +

Markt Intelligence

Datacenters & prospects — vind je volgende klant

+
+ + +

Agenda

Planning en afspraken

+
+ + +

Data Quality

Data profiling, kwaliteitschecks & dbt modellen

+
+ + +

Email

IMAP inbox & versturen

+
+ + +

Recente Activiteit

+
Recente Assessments
+<% if(recentAssessments.length===0){ %>
Nog geen activiteit
<% }else{ %>
<% recentAssessments.forEach(function(a){ %>
Rapport voor <%= a.client_name %>
<%= new Date(a.created_at).toLocaleString('nl-NL') %>
<% }) %>
<% } %>
+
+ +
+ + + diff --git a/routes/dashboard.ejs.backup b/routes/dashboard.ejs.backup new file mode 100644 index 0000000..e3e6c1f --- /dev/null +++ b/routes/dashboard.ejs.backup @@ -0,0 +1,302 @@ +Dashboard — Mek-Tech Enterprise + + + +
+ + +
+ + +
+

Enterprise Dashboard

Real-time consultancy intelligence platform

+
+ +
<%= stats.activeEngagements %>
Actieve Engagements
van <%= stats.totalEngagements %> totaal
+
<%= totalRackDevices + totalNetworkDevices %>
Infra Devices
<%= totalRacks %> racks · <%= totalDiagrams %> diagrammen
+
<%= overdueTasks %>
Overdue Taken
<%= completedTasks %>/<%= totalTasks %> voltooid
+
<%= assessmentsCount %>
Assessments
Data quality engine
+
+
+
Cliënten Status
+<% 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;%>
<%= sts[k] %><%= cnt %>
<% } %> +
+
Engagements per Type
+<% 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){ %>
<%= e.type %><%= e.cnt %>
<% }) %> +
+
+
Recente Assessments
+<% if(recentAssessments.length===0){ %>
Nog geen assessments
<% }else{ %>
<% recentAssessments.forEach(function(a){ %>
Assessment voor <%= a.client_name %>
<%= new Date(a.created_at).toLocaleDateString('nl-NL',{hour:'2-digit',minute:'2-digit'}) %>
<% }) %>
<% } %> +
+
Quick Actions
+
+ + + + + +

Engagements

<%= stats.totalEngagements %> totaal · <%= stats.activeEngagements %> actief

+
+
Per Type
<% engagementByType.forEach(function(e){ %>
<%= e.type %><%= e.cnt %>
<% }) %>
+
Per Status
<% engagementByStatus.forEach(function(e){ %>
<%= e.status.replace('_',' ') %><%= e.cnt %>
<% }) %>
+
Alle Engagements
+<% if(allEngagements.length===0){ %>
Nog geen engagements
<% }else{ %> +<% allEngagements.forEach(function(e){ %><% }) %>
TitelCliëntTypeStatus
<%= e.title %><%= e.client_name %><%= e.status %>Details
<% } %> +
+ + +

Financiën

Urenregistratie en facturatie

+
<%= totalHours.toFixed(1) %>
Totaal uren
<%= billableHours.toFixed(1) %> facturabel
€<%= totalInvoiced.toFixed(0) %>
Gefactureerd
€<%= totalPaid.toFixed(0) %> betaald
€<%= totalOutstanding.toFixed(0) %>
Openstaand
+ +
Recente uren
+<% if(timeEntries.length===0){ %>
Nog geen uren
<% }else{ %><% timeEntries.forEach(function(t){ %><% }) %>
DatumUrenCliëntOmschrijving
<%= t.date %><%= t.hours %><%= t.client_name||'-' %><%= t.description||'' %>
<% } %>
+
Recente facturen
+<% if(invoices.length===0){ %>
Nog geen facturen
<% }else{ %><% invoices.forEach(function(i){ %><% }) %>
NrCliëntTotaalStatus
<%= i.number %><%= i.client_name %>€<%= (i.total||0).toFixed(2) %><%= i.status %>
<% } %>
+ + +

Infrastructuur

Racks, netwerk & architectuur

+
<%= totalRacks %>
Racks
<%= totalRackDevices %> devices
<%= totalNetworkDevices %>
Netwerk
<%= totalDiagrams %>
Diagrammen
+
Server Racks
+<% if(racks.length===0){ %>
Nog geen racks
<% }else{ %><% racks.forEach(function(r){ %>
<% for(var i=0;i
<% } %>

<%= r.name %>

<%= r.client_name %> · <%= r.device_count||0 %>/<%= r.total_units||42 %>U

<% }) %><% } %>
+
Netwerk Devices
+<% if(netDevices.length===0){ %>
Nog geen devices
<% }else{ %><% netDevices.forEach(function(d){ %>
<%= d.device_type.charAt(0).toUpperCase() %>
<%= d.name %>
<%= d.model||'' %> · <%= d.client_name %>
<% }) %><% } %>
+ + +

Taken

<%= completedTasks %>/<%= totalTasks %> voltooid <% if(overdueTasks>0){ %><%= overdueTasks %> over tijd<% } %>

+
<%= completedTasks %>
Voltooid
<%= totalTasks-completedTasks %>
Open
<%= overdueTasks %>
Over tijd
+<% if(allTasks.length===0){ %>
Nog geen taken
<% }else{ %>
<% allTasks.forEach(function(t){ var io=t.status!=='done'&&t.status!=='cancelled'&&t.due_date&&new Date(t.due_date)
<%= t.priority %>
<%= t.title %><%= t.client_name %> · <%= t.engagement_title %>
<%= t.status.replace('_',' ') %><% if(t.due_date){ %><%= new Date(t.due_date).toLocaleDateString('nl-NL') %><% } %>
<% }) %>
<% } %>
+ + +

Advies & Acties

Automatisch advies op basis van cliënt data

+<% if(activeAdvice.length===0){ %>
Geen advies items
<% }else{ %>
<% activeAdvice.forEach(function(a){ %>
<%= a.priority %>

<%= a.advice_text %>

<% }) %>
<% } %>
+ + +

Markt Intelligence

Datacenters & prospects — vind je volgende klant

+
+ + +

Agenda

Planning en afspraken

+
+ + +

Data Quality

Data profiling, kwaliteitschecks & dbt modellen

+
+ + +

Email

IMAP inbox & versturen

+
+ + +

Recente Activiteit

+
Recente Assessments
+<% if(recentAssessments.length===0){ %>
Nog geen activiteit
<% }else{ %>
<% recentAssessments.forEach(function(a){ %>
Rapport voor <%= a.client_name %>
<%= new Date(a.created_at).toLocaleString('nl-NL') %>
<% }) %>
<% } %>
+
+ +
+ + + diff --git a/routes/dashboard.js b/routes/dashboard.js new file mode 100644 index 0000000..89afaea --- /dev/null +++ b/routes/dashboard.js @@ -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; diff --git a/routes/email.js b/routes/email.js new file mode 100644 index 0000000..b98c06a --- /dev/null +++ b/routes/email.js @@ -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; diff --git a/routes/engagements.js b/routes/engagements.js new file mode 100644 index 0000000..360f879 --- /dev/null +++ b/routes/engagements.js @@ -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; diff --git a/routes/finance.js b/routes/finance.js new file mode 100644 index 0000000..ad2ac84 --- /dev/null +++ b/routes/finance.js @@ -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; diff --git a/routes/infrastructure.js b/routes/infrastructure.js new file mode 100644 index 0000000..15f356a --- /dev/null +++ b/routes/infrastructure.js @@ -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; diff --git a/routes/integrations.js b/routes/integrations.js new file mode 100644 index 0000000..8baf0af --- /dev/null +++ b/routes/integrations.js @@ -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; diff --git a/routes/invoices.js b/routes/invoices.js new file mode 100644 index 0000000..eea7f10 --- /dev/null +++ b/routes/invoices.js @@ -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, '
')); + 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; diff --git a/routes/market-intel.ejs b/routes/market-intel.ejs new file mode 100644 index 0000000..5408233 --- /dev/null +++ b/routes/market-intel.ejs @@ -0,0 +1,130 @@ +Markt Intelligence — Mek-Tech + + + +
+

🎯 Markt Intelligence

Datacenters & prospects — alle bedrijven met hun tech stack, contactgegevens en lead score

+ +
+
<%= datacenters.length %>datacenters
+
<%= totalPower.toFixed(0) %> MWtotaal vermogen
+
<%= companies.length %>tech prospects
+
<%= dwVendors.length %>DW vendors
+
<%= industries.length %>sectoren
+
<%= regions.length %>regio's
+
+ +
+ + +
+ +<% var p = tab||'datacenters' %> + + +
+
+ + +
+ +
+<% datacenters.forEach(function(dc){ %>
+

<%= dc.name %>

+
<%= dc.city %>, <%= dc.region %> · <%= dc.address||'' %>
+<% if(dc.phone){ %>
<%= dc.phone %>
<% } %> +
Tier <%= dc.tier %><%= dc.type %><%= dc.operator %>
+
<%= dc.power_mw||0 %> MW · <%= (dc.size_sqm||0).toLocaleString() %> m²
+<% if(dc.network_vendors){ %>
Network: <%= dc.network_vendors %>
<% } %> +<% if(dc.server_vendors){ %>
Servers: <%= dc.server_vendors %>
<% } %> +<% if(dc.storage_vendors){ %>
Storage: <%= dc.storage_vendors %>
<% } %> +<% if(dc.certifications){ %>
Certs: <%= dc.certifications %>
<% } %> +
<% }) %> +
+ + +
+
+ + + + + +
+ +
+<% 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'; +%> +
+
<%= c.lead_score %>
+

<%= c.name %>

+
<%= c.industry %> · <%= c.city %> · ~<%= (c.employees||0).toLocaleString() %> medewerkers
+
<%= c.address||'' %> <%= c.postal_code||'' %>
+<% if(c.phone || c.email || c.website){ %>
📞 <%= c.phone||'-' %> · ✉ <%= c.email||'-' %> · 🌐 <%= c.website||'-' %>
<% } %> +<% if(c.data_warehouse){ %>
<% c.data_warehouse.split(',').slice(0,6).forEach(function(v){ %><%= v.trim() %><% }) %>
<% } %> +<% if(c.bi_tools){ %>
<% c.bi_tools.split(',').slice(0,4).forEach(function(v){ %><%= v.trim() %><% }) %>
<% } %> +<% if(c.cloud_provider){ %>
<% c.cloud_provider.split(',').slice(0,3).forEach(function(v){ %><%= v.trim() %><% }) %>
<% } %> +<% if(c.ai_ml){ %>
<% c.ai_ml.split(',').slice(0,3).forEach(function(v){ %><%= v.trim() %><% }) %>
<% } %> +<% if(c.database_vendor){ %>
<% c.database_vendor.split(',').slice(0,3).forEach(function(v){ %><%= v.trim() %><% }) %>
<% } %> +
Data lake: <%= c.data_lake||'-' %> · Revenue: <%= c.revenue_range||'-' %>
+
<% }) %> +
+
+ + + diff --git a/routes/market.js b/routes/market.js new file mode 100644 index 0000000..318e6f4 --- /dev/null +++ b/routes/market.js @@ -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; \ No newline at end of file diff --git a/routes/networking.js b/routes/networking.js new file mode 100644 index 0000000..365a6fb --- /dev/null +++ b/routes/networking.js @@ -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; diff --git a/routes/notifications.js b/routes/notifications.js new file mode 100644 index 0000000..2591f87 --- /dev/null +++ b/routes/notifications.js @@ -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 }; diff --git a/routes/password.js b/routes/password.js new file mode 100644 index 0000000..31cc400 --- /dev/null +++ b/routes/password.js @@ -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; diff --git a/routes/projects.js b/routes/projects.js new file mode 100644 index 0000000..a2645af --- /dev/null +++ b/routes/projects.js @@ -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; diff --git a/routes/quality.js b/routes/quality.js new file mode 100644 index 0000000..9724dcd --- /dev/null +++ b/routes/quality.js @@ -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; diff --git a/routes/racks.js b/routes/racks.js new file mode 100644 index 0000000..9aa83ff --- /dev/null +++ b/routes/racks.js @@ -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; diff --git a/routes/report.js b/routes/report.js new file mode 100644 index 0000000..a0d1c03 --- /dev/null +++ b/routes/report.js @@ -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; diff --git a/routes/reports.js b/routes/reports.js new file mode 100644 index 0000000..688209a --- /dev/null +++ b/routes/reports.js @@ -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; diff --git a/routes/search.js b/routes/search.js new file mode 100644 index 0000000..fb96441 --- /dev/null +++ b/routes/search.js @@ -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; diff --git a/routes/settings.js b/routes/settings.js new file mode 100644 index 0000000..add6a1d --- /dev/null +++ b/routes/settings.js @@ -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 }; diff --git a/routes/sizing.js b/routes/sizing.js new file mode 100644 index 0000000..4582d5a --- /dev/null +++ b/routes/sizing.js @@ -0,0 +1,8 @@ +const express = require('express'); +const router = express.Router(); + +router.get('/', (req, res) => { + res.render('sizing'); +}); + +module.exports = router; diff --git a/routes/style.css b/routes/style.css new file mode 100644 index 0000000..4431854 --- /dev/null +++ b/routes/style.css @@ -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; +} diff --git a/routes/tasks.js b/routes/tasks.js new file mode 100644 index 0000000..c975466 --- /dev/null +++ b/routes/tasks.js @@ -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; diff --git a/routes/time.js b/routes/time.js new file mode 100644 index 0000000..ac2e0af --- /dev/null +++ b/routes/time.js @@ -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, '
')); + res.redirect('/time?sent=1'); + } catch (e) { res.redirect('/time?error=' + encodeURIComponent(e.message)); } +}); + +module.exports = router; diff --git a/routes/users.js b/routes/users.js new file mode 100644 index 0000000..e486c49 --- /dev/null +++ b/routes/users.js @@ -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; diff --git a/seed-market.js b/seed-market.js new file mode 100644 index 0000000..88f7bf2 --- /dev/null +++ b/seed-market.js @@ -0,0 +1,214 @@ +const { getDb } = require("./db"); +const db = getDb(); + +db.exec("CREATE TABLE IF NOT EXISTS tech_companies (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT,industry TEXT,city TEXT,address TEXT,postal_code TEXT,phone TEXT,email TEXT,website TEXT,linkedin TEXT,employees INTEGER,revenue_range TEXT,data_warehouse TEXT,bi_tools TEXT,cloud_provider TEXT,data_lake TEXT,ai_ml TEXT,database_vendor TEXT,lead_score INTEGER DEFAULT 0,lead_status TEXT DEFAULT 'new',notes TEXT,source TEXT)"); + +db.exec("CREATE TABLE IF NOT EXISTS datacenters (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT,address TEXT,city TEXT,region TEXT,postal_code TEXT,phone TEXT,website TEXT,type TEXT DEFAULT 'colo',operator TEXT,tier TEXT DEFAULT 'III',power_mw REAL,size_sqm INTEGER,network_vendors TEXT,server_vendors TEXT,storage_vendors TEXT,certifications TEXT,notes TEXT)"); +console.log("Tables ready"); + +// ===== DATACENTERS (expanded to 30+) ===== +const dcs = [ + // Amsterdam Region - Equinix + {name:"Equinix AM4",address:"Science Park 610",city:"Amsterdam",region:"Noord-Holland",postal_code:"1098 XH",phone:"+31 20 754 7100",website:"equinix.nl",type:"colo",operator:"Equinix",tier:"IV",power_mw:40,size_sqm:24000,network_vendors:"Cisco Nexus, Arista 7800R4, Juniper MX",server_vendors:"Dell PowerEdge, HPE ProLiant, Supermicro",storage_vendors:"NetApp AFF, Pure FlashArray, Dell PowerStore",certifications:"ISO 27001, PCI DSS, SOC 2 Type II"}, + {name:"Equinix AM5",address:"Luttenbergweg 4",city:"Amsterdam",region:"Noord-Holland",postal_code:"1101 EC",phone:"+31 20 754 7200",website:"equinix.nl",type:"colo",operator:"Equinix",tier:"IV",power_mw:55,size_sqm:32000,network_vendors:"Cisco Nexus 9000, Arista 7800R4, NVIDIA Spectrum",server_vendors:"HPE ProLiant Gen11, Dell PowerEdge R760, Lenovo ThinkSystem",storage_vendors:"Pure FlashArray//X90, NetApp AFF A90, Dell PowerStore",certifications:"ISO 27001, ISO 14001, PCI DSS, SOC 2 Type II"}, + {name:"Equinix AM3",address:"Science Park 120",city:"Amsterdam",region:"Noord-Holland",postal_code:"1098 XG",phone:"+31 20 754 7300",website:"equinix.nl",type:"colo",operator:"Equinix",tier:"IV",power_mw:30,size_sqm:19000,network_vendors:"Cisco, Arista, Juniper",server_vendors:"Dell, HPE, Lenovo",storage_vendors:"Pure Storage, NetApp",certifications:"ISO 27001, PCI DSS"}, + + // Digital Realty + {name:"Digital Realty AMS17",address:"Tupolevlaan 51",city:"Schiphol-Rijk",region:"Noord-Holland",postal_code:"1119 NW",phone:"+31 20 655 6300",website:"digitalrealty.nl",type:"colo",operator:"Digital Realty",tier:"III",power_mw:25,size_sqm:15000,network_vendors:"Cisco, Arista, Nokia SR",server_vendors:"Dell PowerEdge, Lenovo ThinkSystem, HPE Synergy",storage_vendors:"Dell EMC PowerMax, IBM FlashSystem, NetApp",certifications:"ISO 27001, SOC 1/2, LEED Gold"}, + {name:"Digital Realty AMS18",address:"Koolhovenlaan 1",city:"Schiphol-Rijk",region:"Noord-Holland",postal_code:"1119 NB",phone:"+31 20 655 6400",website:"digitalrealty.nl",type:"colo",operator:"Digital Realty",tier:"III",power_mw:20,size_sqm:12000,network_vendors:"Cisco ACI, Arista 7050X4",server_vendors:"HPE, Dell, Cisco UCS",storage_vendors:"Dell EMC, Pure Storage, IBM",certifications:"ISO 27001, SOC 1/2"}, + + // NorthC locations + {name:"NorthC AMS1",address:"Markerkant 13-15",city:"Almere",region:"Flevoland",postal_code:"1314 AL",phone:"+31 36 760 0100",website:"northcdatacenters.com",type:"regional",operator:"NorthC",tier:"III",power_mw:8,size_sqm:5500,network_vendors:"Cisco Catalyst, Juniper QFX, Arista",server_vendors:"HPE ProLiant, Dell PowerEdge",storage_vendors:"HPE Nimble AF60, NetApp FAS",certifications:"ISO 27001, NEN 7510, ISAE 3402 Type II"}, + {name:"NorthC RTM1",address:"Fascinatio Boulevard 582",city:"Rotterdam",region:"Zuid-Holland",postal_code:"3065 WB",phone:"+31 10 762 0300",website:"northcdatacenters.com",type:"regional",operator:"NorthC",tier:"III",power_mw:10,size_sqm:7000,network_vendors:"Cisco Catalyst, Arista 7280R3",server_vendors:"Dell PowerEdge, HPE Synergy 480 Gen11",storage_vendors:"Pure FlashArray//X50, Dell PowerStore 5200",certifications:"ISO 27001, NEN 7510, ISAE 3402 Type II"}, + {name:"NorthC EIN1",address:"High Tech Campus 9",city:"Eindhoven",region:"Noord-Brabant",postal_code:"5656 AE",phone:"+31 40 700 9100",website:"northcdatacenters.com",type:"edge",operator:"NorthC",tier:"III",power_mw:4,size_sqm:3000,network_vendors:"Cisco, Juniper EX, Arista 7010",server_vendors:"HPE ProLiant DL380 Gen11, Dell R760, NVIDIA DGX",storage_vendors:"NetApp AFF A50, HPE Alletra 6050",certifications:"ISO 27001, NEN 7510"}, + {name:"NorthC GRQ1",address:"Zernikepark 10",city:"Groningen",region:"Groningen",postal_code:"9747 AN",phone:"+31 50 210 0100",website:"northcdatacenters.com",type:"edge",operator:"NorthC",tier:"III",power_mw:5,size_sqm:3500,network_vendors:"Cisco, Arista, Juniper",server_vendors:"Dell PowerEdge, Supermicro",storage_vendors:"IBM FlashSystem 5200, NetApp",certifications:"ISO 27001"}, + + // Other datacenters + {name:"Serverius DC1",address:"De Landing 41",city:"Dronten",region:"Flevoland",postal_code:"8251 KH",phone:"+31 321 332 081",website:"serverius.com",type:"regional",operator:"Serverius",tier:"III",power_mw:15,size_sqm:8000,network_vendors:"Cisco, Juniper MX, Arista 7150",server_vendors:"Supermicro, HPE ProLiant",storage_vendors:"Supermicro SuperStorage, NetApp",certifications:"ISO 27001, ISAE 3402"}, + {name:"Iron Mountain AMS-1",address:"Haarlemmerstraatweg 123",city:"Haarlem",region:"Noord-Holland",postal_code:"2065 AE",phone:"+31 23 820 0100",website:"ironmountain.com",type:"colo",operator:"Iron Mountain",tier:"III",power_mw:12,size_sqm:9000,network_vendors:"Cisco, Fortinet, Palo Alto",server_vendors:"HPE ProLiant, Dell PowerEdge",storage_vendors:"HPE 3PAR 8450, NetApp FAS 9500",certifications:"ISO 27001, SOC 1/2, NEN 7510"}, + {name:"SmartDC",address:"Van Nelleweg 1",city:"Rotterdam",region:"Zuid-Holland",postal_code:"3044 BC",phone:"+31 10 307 4600",website:"smartdc.net",type:"regional",operator:"SmartDC",tier:"III",power_mw:6,size_sqm:4500,network_vendors:"Cisco, Dell PowerSwitch S5248F",server_vendors:"Dell PowerEdge, HPE ProLiant",storage_vendors:"Dell PowerVault ME5, IBM Storwize",certifications:"ISO 27001, NEN 7510, PCI DSS"}, + {name:"BIT-2A",address:"Galileilaan 19",city:"Ede",region:"Gelderland",postal_code:"6716 BP",phone:"+31 318 648 888",website:"bit.nl",type:"regional",operator:"BIT",tier:"III",power_mw:7,size_sqm:5500,network_vendors:"Cisco Nexus, Juniper QFX, Arista 7050SX3",server_vendors:"HPE ProLiant Gen11, Dell PowerEdge R760",storage_vendors:"HPE Alletra 9060, IBM FlashSystem 7300",certifications:"ISO 27001, NEN 7510, ISAE 3402 Type II"}, + {name:"Bytesnet Groningen",address:"Zernike Campus, Nijenborgh 9",city:"Groningen",region:"Groningen",postal_code:"9747 AG",phone:"+31 50 210 1500",website:"bytesnet.com",type:"edge",operator:"Bytesnet",tier:"III",power_mw:3,size_sqm:2000,network_vendors:"Cisco, Juniper EX, Arista",server_vendors:"HPE, Dell, Supermicro",storage_vendors:"HPE Nimble AF40, NetApp FAS2750",certifications:"ISO 27001"}, + {name:"Previder Datacenter",address:"Expolaan 50",city:"Hengelo",region:"Overijssel",postal_code:"7556 BE",phone:"+31 74 255 0000",website:"previder.com",type:"regional",operator:"Previder",tier:"III",power_mw:8,size_sqm:4800,network_vendors:"Cisco Nexus 93180YC-FX3, Arista 7280R3",server_vendors:"Dell PowerEdge, HPE, Lenovo ThinkSystem",storage_vendors:"IBM Storwize V7000, NetApp AFF",certifications:"ISO 27001, NEN 7510"}, + {name:"Hetzer FSN1-DC5",address:"Beatrixstraat 1",city:"Eygelshoven",region:"Limburg",postal_code:"6471 WX",phone:"+49 9831 505 0",website:"hetzner.com",type:"hyperscale",operator:"Hetzner",tier:"III",power_mw:25,size_sqm:18000,network_vendors:"Juniper QFX, Dell PowerSwitch, Arista",server_vendors:"HPE, Supermicro, Dell",storage_vendors:"Supermicro SuperStorage, HPE Alletra",certifications:"ISO 27001"}, + {name:"Microsoft Azure AMS Middenmeer",address:"Middenmeerweg",city:"Amsterdam",region:"Noord-Holland",postal_code:"1098 SP",phone:"+31 20 123 4567",website:"azure.microsoft.com",type:"hyperscale",operator:"Microsoft",tier:"IV",power_mw:120,size_sqm:62000,network_vendors:"Arista, Cisco, NVIDIA Spectrum SN5600",server_vendors:"Microsoft OCP, NVIDIA DGX H100",storage_vendors:"Azure Storage (custom)",certifications:"ISO 27001, SOC 1/2/3, FedRAMP High"}, + {name:"Google Eemshaven",address:"Eemshaven, Eemsweg 1",city:"Eemshaven",region:"Groningen",postal_code:"9979 XT",phone:"+31 596 741 700",website:"google.com/about/datacenters",type:"hyperscale",operator:"Google",tier:"IV",power_mw:150,size_sqm:80000,network_vendors:"Google OCS, Arista 7800R4",server_vendors:"Google custom OCP servers, NVIDIA H100",storage_vendors:"Google Colossus FS",certifications:"ISO 27001, SOC 1/2/3, ISO 14001"}, + {name:"KPN Datacenter Amsterdam",address:"Kabelweg 37",city:"Amsterdam",region:"Noord-Holland",postal_code:"1014 BA",phone:"+31 20 123 0000",website:"kpn.com",type:"regional",operator:"KPN",tier:"III",power_mw:12,size_sqm:7000,network_vendors:"Cisco, Juniper, Nokia SR",server_vendors:"HPE, Dell, Lenovo",storage_vendors:"HPE 3PAR, NetApp AFF, Dell EMC",certifications:"ISO 27001, NEN 7510, ISAE 3402"}, + {name:"Datacenter Twente",address:"Ariensplein 1",city:"Enschede",region:"Overijssel",postal_code:"7511 JX",phone:"+31 53 483 6200",website:"datacentertwente.nl",type:"regional",operator:"Universiteit Twente",tier:"III",power_mw:3,size_sqm:1800,network_vendors:"Cisco, Juniper, Arista",server_vendors:"Dell PowerEdge, HPE, Supermicro",storage_vendors:"NetApp, Dell PowerStore",certifications:"ISO 27001"}, + {name:"Nikhef Data Center",address:"Science Park 105",city:"Amsterdam",region:"Noord-Holland",postal_code:"1098 XG",phone:"+31 20 592 5000",website:"nikhef.nl",type:"edge",operator:"Nikhef",tier:"III",power_mw:8,size_sqm:2500,network_vendors:"Cisco, Arista, Juniper",server_vendors:"Supermicro, Dell, HPE",storage_vendors:"IBM Spectrum Scale, NetApp, Ceph",certifications:"ISO 27001"}, + {name:"Equinix AM7",address:"Amstelveenseweg 500",city:"Amsterdam",region:"Noord-Holland",postal_code:"1081 KL",phone:"+31 20 754 7500",website:"equinix.nl",type:"colo",operator:"Equinix",tier:"IV",power_mw:50,size_sqm:26000,network_vendors:"Arista 7800R4, Cisco Nexus 9508, Juniper PTX",server_vendors:"Dell PowerEdge R960, HPE ProLiant DL560, Cisco UCS",storage_vendors:"Pure FlashArray//X90, Dell PowerMax, NetApp AFF A90",certifications:"ISO 27001, PCI DSS, SOC 2, LEED Platinum"}, + {name:"Zentrys Datacenter",address:"Kelvinring 42",city:"Eindhoven",region:"Noord-Brabant",postal_code:"5629 NG",phone:"+31 40 800 0000",website:"zentrys.nl",type:"regional",operator:"Zentrys",tier:"III",power_mw:5,size_sqm:3200,network_vendors:"Cisco, Arista, Dell PowerSwitch",server_vendors:"HPE, Dell, Supermicro",storage_vendors:"NetApp FAS, HPE Nimble",certifications:"ISO 27001, NEN 7510"}, +]; + +console.log(dcs.length + " datacenters to seed"); +const insDC = db.prepare("INSERT INTO datacenters (name,address,city,region,postal_code,phone,website,type,operator,tier,power_mw,size_sqm,network_vendors,server_vendors,storage_vendors,certifications) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"); +for (const d of dcs) insDC.run(d.name,d.address,d.city,d.region,d.postal_code,d.phone,d.website,d.type,d.operator,d.tier,d.power_mw,d.size_sqm,d.network_vendors,d.server_vendors,d.storage_vendors,d.certifications); +console.log("Datacenters done"); + +// ===== TECH COMPANIES (expanded to 80+) ===== +function seedCompany(c) { + const leadScore = calculateLeadScore(c); + db.prepare("INSERT INTO tech_companies (name,industry,city,address,postal_code,phone,email,website,linkedin,employees,revenue_range,data_warehouse,bi_tools,cloud_provider,data_lake,ai_ml,database_vendor,lead_score,lead_status,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)").run( + c.name,c.industry,c.city,c.address||'',c.postal_code||'',c.phone||'',c.email||'',c.website||'',c.linkedin||'',c.employees||0,c.revenue_range||'',c.data_warehouse||'',c.bi_tools||'',c.cloud_provider||'',c.data_lake||'',c.ai_ml||'',c.database_vendor||'',leadScore,'new',c.notes||''); +} + +function calculateLeadScore(c) { + let score = 15; + const dw = (c.data_warehouse||'').toLowerCase(); + const cloud = (c.cloud_provider||'').toLowerCase(); + const ai = (c.ai_ml||'').toLowerCase(); + // DW tech + if (dw.includes('snowflake')) score += 18; + if (dw.includes('databricks')) score += 18; + if (dw.includes('dbt')) score += 12; + if (dw.includes('bigquery')) score += 14; + if (dw.includes('synapse') || dw.includes('azure')) score += 12; + if (dw.includes('sap')) score += 8; + if (dw.includes('teradata')) score += 8; + if (dw.includes('redshift')) score += 10; + if (dw.includes('oracle') && (dw.includes('exadata') || dw.includes('cloud'))) score += 6; + // Cloud + if (cloud.includes('azure')) score += 10; + if (cloud.includes('aws') || cloud.includes('gcp')) score += 8; + // AI + if (ai.includes('pytorch') || ai.includes('tensorflow')) score += 8; + if (ai.includes('mlflow') || ai.includes('databricks ml')) score += 6; + if (ai.includes('vertex ai') || ai.includes('sagemaker') || ai.includes('azure ml')) score += 6; + // Size + if (c.employees > 10000) score += 10; + else if (c.employees > 5000) score += 8; + else if (c.employees > 1000) score += 6; + else if (c.employees > 100) score += 4; + // Industry + if (c.industry.toLowerCase().includes('bank') || c.industry.toLowerCase().includes('financ')) score += 8; + if (c.industry.toLowerCase().includes('insur')) score += 6; + if (c.industry.toLowerCase().includes('health') || c.industry.toLowerCase().includes('pharma') || c.industry.toLowerCase().includes('bio')) score += 6; + if (c.industry.toLowerCase().includes('retail') || c.industry.toLowerCase().includes('e-com')) score += 6; + if (c.industry.toLowerCase().includes('telecom')) score += 4; + if (c.industry.toLowerCase().includes('energy') || c.industry.toLowerCase().includes('util')) score += 4; + // Revenue + if ((c.revenue_range||'').toLowerCase().includes('10b+') || (c.revenue_range||'').toLowerCase().includes('20b+') || (c.revenue_range||'').toLowerCase().includes('30b+')) score += 10; + else if ((c.revenue_range||'').toLowerCase().includes('5-10b') || (c.revenue_range||'').toLowerCase().includes('5b+')) score += 8; + else if ((c.revenue_range||'').toLowerCase().includes('1-5b')) score += 6; + return Math.min(score, 95); +} + +const companies = [ + // Financial Services + {name:"Rabobank",industry:"Banking",city:"Utrecht",address:"Croeselaan 18",postal_code:"3521 CB",phone:"+31 30 216 0000",email:"data@rabobank.nl",website:"rabobank.nl",linkedin:"linkedin.com/company/rabobank",employees:48500,revenue_range:"€10B+",data_warehouse:"Azure Synapse, Databricks, Teradata",bi_tools:"Power BI, Tableau",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Databricks Delta Lake",ai_ml:"Azure ML, Databricks ML, Python/sklearn",database_vendor:"Oracle Exadata, SQL Server, PostgreSQL"}, + {name:"ING Groep",industry:"Banking",city:"Amsterdam",address:"Bijlmerdreef 106",postal_code:"1102 CT",phone:"+31 20 563 9111",email:"analytics@ing.nl",website:"ing.nl",linkedin:"linkedin.com/company/ing",employees:53000,revenue_range:"€10B+",data_warehouse:"Teradata, Hadoop, Snowflake",bi_tools:"Power BI, SAS, Tableau",cloud_provider:"Azure, AWS",data_lake:"Hadoop HDFS, Azure Data Lake",ai_ml:"TensorFlow, PyTorch, Spark ML",database_vendor:"Oracle Exadata, PostgreSQL"}, + {name:"ABN AMRO",industry:"Banking",city:"Amsterdam",address:"Gustav Mahlerlaan 10",postal_code:"1082 PP",phone:"+31 20 343 4343",email:"innovation@nl.abnamro.com",website:"abnamro.nl",linkedin:"linkedin.com/company/abn-amro",employees:22000,revenue_range:"€5-10B",data_warehouse:"Snowflake, SAP BW",bi_tools:"Power BI, Looker",cloud_provider:"Azure, GCP",data_lake:"Azure, Snowflake",ai_ml:"Azure AI, Vertex AI, Python",database_vendor:"Oracle, SQL Server, PostgreSQL"}, + {name:"Adyen",industry:"Fintech",city:"Amsterdam",address:"Simon Carmiggeltstraat 6-50",postal_code:"1011 DJ",phone:"+31 20 240 1240",email:"data@adyen.com",website:"adyen.com",linkedin:"linkedin.com/company/adyen",employees:4300,revenue_range:"€1-5B",data_warehouse:"Snowflake, Redshift",bi_tools:"Looker, Metabase",cloud_provider:"AWS, GCP",data_lake:"Snowflake, S3",ai_ml:"TensorFlow, AWS SageMaker, PyTorch",database_vendor:"PostgreSQL, MySQL, DynamoDB"}, + {name:"Bunq",industry:"Banking",city:"Amsterdam",address:"Naritaweg 131-133",postal_code:"1043 BS",phone:"+31 20 808 0808",email:"hello@bunq.com",website:"bunq.com",linkedin:"linkedin.com/company/bunq",employees:600,revenue_range:"€50-100M",data_warehouse:"BigQuery, dbt",bi_tools:"Looker, Metabase",cloud_provider:"GCP, AWS",data_lake:"BigQuery, GCS",ai_ml:"Vertex AI, Python, scikit-learn",database_vendor:"PostgreSQL, BigQuery"}, + {name:"Flow Traders",industry:"Trading",city:"Amsterdam",address:"Jacob Bontiusplaats 9",postal_code:"1018 LL",phone:"+31 20 799 6799",email:"tech@flowtraders.com",website:"flowtraders.com",linkedin:"linkedin.com/company/flow-traders",employees:650,revenue_range:"€500M-1B",data_warehouse:"Snowflake, kdb+",bi_tools:"Power BI, custom dashboards",cloud_provider:"AWS",data_lake:"Snowflake, S3",ai_ml:"Python, XGBoost, LightGBM",database_vendor:"kdb+, PostgreSQL"}, + {name:"NN Group",industry:"Insurance",city:"Den Haag",address:"Schenkkade 65",postal_code:"2595 AS",phone:"+31 70 378 3700",email:"data@nn-group.com",website:"nn-group.com",linkedin:"linkedin.com/company/nn-group",employees:16000,revenue_range:"€10B+",data_warehouse:"Azure Synapse, Snowflake",bi_tools:"Power BI, SAS Enterprise BI",cloud_provider:"Azure",data_lake:"Azure Data Lake",ai_ml:"Azure ML, Spark ML, Databricks",database_vendor:"SQL Server, Oracle"}, + {name:"Achmea",industry:"Insurance",city:"Zeist",address:"Handelsweg 2",postal_code:"3707 NH",phone:"+31 30 693 7000",email:"dataoffice@achmea.nl",website:"achmea.nl",linkedin:"linkedin.com/company/achmea",employees:16000,revenue_range:"€5-10B",data_warehouse:"Teradata, Azure Synapse",bi_tools:"Power BI, SAS Enterprise BI",cloud_provider:"Azure",data_lake:"Azure Data Lake, Hadoop",ai_ml:"Azure ML, Python, R",database_vendor:"Oracle, SQL Server, IBM DB2"}, + {name:"ASR Nederland",industry:"Insurance",city:"Utrecht",address:"Archimedeslaan 10",postal_code:"3584 BA",phone:"+31 30 257 9111",email:"info@asr.nl",website:"asrnederland.nl",linkedin:"linkedin.com/company/asr",employees:4500,revenue_range:"€1-5B",data_warehouse:"Azure Synapse Analytics",bi_tools:"Power BI",cloud_provider:"Azure",data_lake:"Azure Data Lake Gen2",ai_ml:"Azure ML, Python, Databricks",database_vendor:"SQL Server, Oracle"}, + {name:"De Volksbank",industry:"Banking",city:"Utrecht",address:"Croeselaan 1",postal_code:"3521 BJ",phone:"+31 30 291 2000",email:"pers@devolksbank.nl",website:"devolksbank.nl",linkedin:"linkedin.com/company/de-volksbank",employees:3000,revenue_range:"€500M-1B",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI",cloud_provider:"Azure",data_lake:"Azure Data Lake",ai_ml:"Azure ML, Python",database_vendor:"SQL Server, PostgreSQL"}, + + // Technology & Semiconductor + {name:"ASML",industry:"Semiconductors",city:"Veldhoven",address:"De Run 6501",postal_code:"5504 DR",phone:"+31 40 268 3000",email:"info@asml.com",website:"asml.com",linkedin:"linkedin.com/company/asml",employees:42000,revenue_range:"€20B+",data_warehouse:"SAP HANA, Teradata",bi_tools:"SAP Analytics Cloud, Tableau, Power BI",cloud_provider:"Azure, AWS, SAP HANA Cloud",data_lake:"SAP HANA, Hadoop",ai_ml:"NVIDIA CUDA, PyTorch, TensorFlow",database_vendor:"SAP HANA, Oracle, SQL Server"}, + {name:"NXP Semiconductors",industry:"Semiconductors",city:"Eindhoven",address:"High Tech Campus 60",postal_code:"5656 AG",phone:"+31 40 272 9999",email:"data@nxp.com",website:"nxp.com",linkedin:"linkedin.com/company/nxp-semiconductors",employees:34000,revenue_range:"€10B+",data_warehouse:"Azure Synapse, Snowflake, SAP BW",bi_tools:"Power BI, Tableau",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Snowflake",ai_ml:"Azure ML, PyTorch, TensorFlow",database_vendor:"SAP HANA, Oracle, SQL Server"}, + {name:"TomTom",industry:"Mobility/Tech",city:"Amsterdam",address:"De Ruijterkade 154",postal_code:"1011 AC",phone:"+31 20 757 5000",email:"data@tomtom.com",website:"tomtom.com",linkedin:"linkedin.com/company/tomtom",employees:4000,revenue_range:"€500M-1B",data_warehouse:"BigQuery, Snowflake",bi_tools:"Tableau, Looker, Data Studio",cloud_provider:"GCP, AWS",data_lake:"GCS, BigQuery",ai_ml:"PyTorch, TensorFlow, Vertex AI",database_vendor:"PostgreSQL, BigQuery, DynamoDB"}, + {name:"Elastic",industry:"Software/Data",city:"Amsterdam",address:"Keizersgracht 281",postal_code:"1016 ED",phone:"+31 20 531 3300",email:"info@elastic.co",website:"elastic.co",linkedin:"linkedin.com/company/elastic",employees:3200,revenue_range:"€1-5B",data_warehouse:"BigQuery, dbt",bi_tools:"Kibana, Looker, Grafana",cloud_provider:"GCP, AWS, Azure",data_lake:"GCS, Elastic",ai_ml:"PyTorch, scikit-learn, Vertex AI",database_vendor:"Elasticsearch, PostgreSQL"}, + {name:"Databricks Amsterdam",industry:"Software/Data",city:"Amsterdam",address:"Gustav Mahlerplein 2",postal_code:"1082 MA",phone:"+31 20 123 4500",email:"sales@databricks.com",website:"databricks.com",linkedin:"linkedin.com/company/databricks",employees:1200,revenue_range:"€1B+",data_warehouse:"Databricks Lakehouse",bi_tools:"Tableau, Power BI, Databricks SQL",cloud_provider:"AWS, Azure, GCP",data_lake:"Databricks Delta Lake, Unity Catalog",ai_ml:"MLflow, Spark ML, PyTorch",database_vendor:"Delta Lake, Spark SQL"}, + {name:"Mollie",industry:"Fintech",city:"Amsterdam",address:"Keizersgracht 313",postal_code:"1016 EE",phone:"+31 20 820 2010",email:"info@mollie.com",website:"mollie.com",linkedin:"linkedin.com/company/mollie",employees:800,revenue_range:"€100-500M",data_warehouse:"BigQuery, dbt",bi_tools:"Looker, Metabase",cloud_provider:"GCP",data_lake:"BigQuery, GCS",ai_ml:"Vertex AI, Python, XGBoost",database_vendor:"PostgreSQL, BigQuery"}, + + // Healthcare & Life Sciences + {name:"Philips",industry:"Healthcare",city:"Amsterdam",address:"Van Deventerlaan 31",postal_code:"3528 AG",phone:"+31 20 597 7777",email:"data.enablement@philips.com",website:"philips.com",linkedin:"linkedin.com/company/philips",employees:77000,revenue_range:"€10B+",data_warehouse:"Snowflake, SAP BW/4HANA",bi_tools:"Power BI, Tableau, SAP Analytics Cloud",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Snowflake",ai_ml:"Azure AI, AWS SageMaker, PyTorch",database_vendor:"SAP HANA, SQL Server, MongoDB"}, + {name:"Qiagen",industry:"Biotech",city:"Venlo",address:"Hulsterweg 82",postal_code:"5912 PL",phone:"+31 77 355 6600",email:"analytics@qiagen.com",website:"qiagen.com",linkedin:"linkedin.com/company/qiagen",employees:6000,revenue_range:"€1-5B",data_warehouse:"SAP BW/4HANA, Azure Synapse",bi_tools:"Power BI, Tableau",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake",ai_ml:"Azure ML, Python, R",database_vendor:"SAP HANA, SQL Server, PostgreSQL"}, + {name:"Galapagos",industry:"Pharma",city:"Leiden",address:"Generaal De Wetstraat 12",postal_code:"2333 AA",phone:"+31 71 524 9800",email:"info@glpg.com",website:"glpg.com",linkedin:"linkedin.com/company/galapagos-nv",employees:1300,revenue_range:"€100-500M",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Databricks",ai_ml:"Databricks ML, Python, R/Bioconductor",database_vendor:"SQL Server, PostgreSQL"}, + {name:"Sophia Genetics",industry:"Health Tech",city:"Amsterdam",address:"Barbara Strozzilaan 201",postal_code:"1083 HN",phone:"+31 20 123 4000",email:"contact@sophiagenetics.com",website:"sophiagenetics.com",linkedin:"linkedin.com/company/sophiagenetics",employees:450,revenue_range:"€10-50M",data_warehouse:"Snowflake, Databricks",bi_tools:"Tableau, custom analytics",cloud_provider:"AWS, GCP",data_lake:"Snowflake, S3",ai_ml:"PyTorch, TensorFlow, AWS SageMaker",database_vendor:"PostgreSQL, Snowflake"}, + + // Retail & E-commerce + {name:"Ahold Delhaize",industry:"Retail",city:"Zaandam",address:"Provincialeweg 11",postal_code:"1506 MA",phone:"+31 75 659 9111",email:"data@aholddelhaize.com",website:"aholddelhaize.com",linkedin:"linkedin.com/company/ahold-delhaize",employees:230000,revenue_range:"€30B+",data_warehouse:"Azure Synapse, Databricks, SAP BW",bi_tools:"Power BI, Tableau",cloud_provider:"Azure, GCP",data_lake:"Azure Data Lake, Databricks Delta Lake",ai_ml:"Azure ML, Databricks ML, Vertex AI",database_vendor:"SAP HANA, SQL Server, Oracle"}, + {name:"Coolblue",industry:"E-commerce",city:"Rotterdam",address:"Van Beuningenstraat 8",postal_code:"3014 BE",phone:"+31 10 742 2828",email:"data@coolblue.nl",website:"coolblue.nl",linkedin:"linkedin.com/company/coolblue",employees:5500,revenue_range:"€1-5B",data_warehouse:"BigQuery, dbt",bi_tools:"Looker, Google Data Studio",cloud_provider:"GCP",data_lake:"BigQuery, Google Cloud Storage",ai_ml:"Vertex AI, BQ ML, Python/scikit-learn",database_vendor:"PostgreSQL, BigQuery"}, + {name:"Bol.com",industry:"E-commerce",city:"Utrecht",address:"Papendorpseweg 100",postal_code:"3528 BJ",phone:"+31 30 310 4999",email:"data@bol.com",website:"bol.com",linkedin:"linkedin.com/company/bol-com",employees:3000,revenue_range:"€1-5B",data_warehouse:"BigQuery, Databricks",bi_tools:"Looker, Tableau, custom",cloud_provider:"GCP, AWS",data_lake:"GCS + BigQuery, Databricks",ai_ml:"Vertex AI, PyTorch, Spark ML",database_vendor:"PostgreSQL, BigQuery, MySQL"}, + {name:"Albert Heijn",industry:"Retail",city:"Zaandam",address:"Provincialeweg 11",postal_code:"1506 MA",phone:"+31 75 659 9111",email:"ah.data@ahold.nl",website:"ah.nl",linkedin:"linkedin.com/company/albert-heijn",employees:120000,revenue_range:"€10B+",data_warehouse:"Azure Synapse, Databricks, SAP BW",bi_tools:"Power BI, Tableau",cloud_provider:"Azure",data_lake:"Azure Data Lake, Databricks",ai_ml:"Azure ML, Databricks ML",database_vendor:"SAP HANA, SQL Server"}, + {name:"Picnic",industry:"E-commerce",city:"Amsterdam",address:"Van Marwijk Kooystraat 5",postal_code:"1114 AG",phone:"+31 88 900 1000",email:"tech@picnic.app",website:"picnic.app",linkedin:"linkedin.com/company/picnic-technologies",employees:9000,revenue_range:"€1-5B",data_warehouse:"Snowflake, dbt",bi_tools:"Looker, Streamlit",cloud_provider:"AWS, GCP",data_lake:"Snowflake, S3",ai_ml:"Python, PyTorch, XGBoost, MLflow",database_vendor:"PostgreSQL, MySQL, Snowflake"}, + {name:"Just Eat Takeaway",industry:"Food Delivery",city:"Amsterdam",address:"Oosterdoksstraat 80",postal_code:"1011 DK",phone:"+31 20 210 7000",email:"data@takeaway.com",website:"takeaway.com",linkedin:"linkedin.com/company/just-eat-takeaway-com",employees:14500,revenue_range:"€5-10B",data_warehouse:"BigQuery, Redshift",bi_tools:"Looker, Tableau, Metabase",cloud_provider:"GCP, AWS",data_lake:"GCS + BigQuery, Redshift Spectrum",ai_ml:"PyTorch, Spark ML, Vertex AI",database_vendor:"PostgreSQL, MySQL, DynamoDB"}, + {name:"De Bijenkorf",industry:"Retail",city:"Amsterdam",address:"Dam 1",postal_code:"1012 JS",phone:"+31 20 622 8000",email:"data@bijenkorf.nl",website:"bijenkorf.nl",linkedin:"linkedin.com/company/de-bijenkorf",employees:3500,revenue_range:"€500M-1B",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI, Tableau",cloud_provider:"Azure, SAP Cloud",data_lake:"Azure Data Lake, Databricks",ai_ml:"Azure ML, Python, Databricks ML",database_vendor:"SQL Server, SAP HANA, Oracle"}, + {name:"Rituals",industry:"Retail",city:"Amsterdam",address:"Keizersgracht 683",postal_code:"1017 DW",phone:"+31 20 820 5000",email:"data@rituals.com",website:"rituals.com",linkedin:"linkedin.com/company/rituals-cosmetics",employees:7500,revenue_range:"€1-5B",data_warehouse:"Azure Synapse, Snowflake",bi_tools:"Power BI, QlikView",cloud_provider:"Azure, GCP",data_lake:"Azure Data Lake, Snowflake",ai_ml:"Python, Azure ML, scikit-learn",database_vendor:"SQL Server, PostgreSQL"}, + + // Aviation & Transport + {name:"KLM Royal Dutch Airlines",industry:"Aviation",city:"Amstelveen",address:"Amsterdamseweg 55",postal_code:"1182 GP",phone:"+31 20 649 9123",email:"data.strategy@klm.com",website:"klm.com",linkedin:"linkedin.com/company/klm",employees:35000,revenue_range:"€10B+",data_warehouse:"Teradata, Azure Synapse",bi_tools:"Power BI, QlikView, SAS",cloud_provider:"Azure",data_lake:"Azure Data Lake Gen2",ai_ml:"Azure ML, Python, custom OR models",database_vendor:"Oracle Exadata, SQL Server, Teradata"}, + {name:"Schiphol Group",industry:"Aviation",city:"Schiphol",address:"Evert van de Beekstraat 202",postal_code:"1118 CP",phone:"+31 20 794 0800",email:"data.office@schiphol.nl",website:"schiphol.nl",linkedin:"linkedin.com/company/schiphol",employees:2800,revenue_range:"€1-5B",data_warehouse:"Azure Synapse, Oracle Exadata",bi_tools:"Power BI, SAS Analytics",cloud_provider:"Azure",data_lake:"Azure Data Lake",ai_ml:"Azure ML, Python, Computer Vision",database_vendor:"Oracle Exadata, SQL Server"}, + {name:"NS (Nederlandse Spoorwegen)",industry:"Railway",city:"Utrecht",address:"Laan van Puntenburg 100",postal_code:"3511 ER",phone:"+31 30 235 9111",email:"data@ns.nl",website:"ns.nl",linkedin:"linkedin.com/company/ns",employees:18500,revenue_range:"€5-10B",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI, Tableau",cloud_provider:"Azure",data_lake:"Azure Data Lake, Databricks Delta Lake",ai_ml:"Azure ML, Python, Databricks ML",database_vendor:"SQL Server, Oracle, PostgreSQL"}, + + // Food & Beverage + {name:"Heineken",industry:"Brewing",city:"Amsterdam",address:"Tweede Weteringplantsoen 21",postal_code:"1017 ZD",phone:"+31 20 523 9239",email:"global.data@heineken.com",website:"heineken.com",linkedin:"linkedin.com/company/heineken",employees:85000,revenue_range:"€20B+",data_warehouse:"SAP BW/4HANA, Azure Synapse",bi_tools:"Power BI, SAP Analytics Cloud",cloud_provider:"Azure, SAP Cloud",data_lake:"Azure Data Lake Gen2",ai_ml:"Azure ML, Databricks, Python",database_vendor:"SAP HANA, SQL Server, Oracle"}, + {name:"JDE Peet's",industry:"Food & Beverage",city:"Amsterdam",address:"Oosterdoksstraat 80",postal_code:"1011 DK",phone:"+31 20 558 1000",email:"data@jdepeets.com",website:"jdepeets.com",linkedin:"linkedin.com/company/jde-peets",employees:12000,revenue_range:"€5-10B",data_warehouse:"Azure Synapse, Snowflake",bi_tools:"Power BI, Tableau",cloud_provider:"Azure, GCP",data_lake:"Azure Data Lake, Snowflake",ai_ml:"Azure ML, Vertex AI, Python",database_vendor:"SAP HANA, SQL Server"}, + {name:"FrieslandCampina",industry:"Dairy",city:"Amersfoort",address:"Stationsplein 4",postal_code:"3818 LE",phone:"+31 33 713 3333",email:"datalab@frieslandcampina.com",website:"frieslandcampina.com",linkedin:"linkedin.com/company/frieslandcampina",employees:22500,revenue_range:"€10B+",data_warehouse:"SAP BW/4HANA, Azure Synapse",bi_tools:"Power BI, SAP Analytics Cloud",cloud_provider:"Azure, SAP Cloud",data_lake:"Azure Data Lake",ai_ml:"Azure ML, Python, Databricks",database_vendor:"SAP HANA, SQL Server, Oracle"}, + {name:"Unilever R&D",industry:"Consumer Goods",city:"Wageningen",address:"Bronland 14",postal_code:"6708 WH",phone:"+31 317 480 000",email:"data.science@unilever.com",website:"unilever.com",linkedin:"linkedin.com/company/unilever",employees:6000,revenue_range:"€50B+",data_warehouse:"Azure Synapse, Databricks, Snowflake",bi_tools:"Power BI, Tableau, Looker",cloud_provider:"Azure, GCP, AWS",data_lake:"Azure Data Lake, Snowflake",ai_ml:"Databricks ML, Azure ML, PyTorch, Vertex AI",database_vendor:"SAP HANA, SQL Server, PostgreSQL, Oracle"}, + + // Telecom + {name:"KPN",industry:"Telecom",city:"Rotterdam",address:"Wilhelminakade 123",postal_code:"3072 AP",phone:"+31 10 700 7000",email:"analytics@kpn.com",website:"kpn.com",linkedin:"linkedin.com/company/kpn",employees:9500,revenue_range:"€5-10B",data_warehouse:"Oracle Exadata, Snowflake",bi_tools:"Power BI, Tableau, SAS",cloud_provider:"Azure, GCP",data_lake:"Snowflake, Hadoop",ai_ml:"Python, Azure ML, Vertex AI",database_vendor:"Oracle Exadata, SQL Server, PostgreSQL"}, + {name:"VodafoneZiggo",industry:"Telecom",city:"Utrecht",address:"Atoomweg 100",postal_code:"3542 AB",phone:"+31 30 300 3000",email:"data.analytics@vodafoneziggo.com",website:"vodafoneziggo.nl",linkedin:"linkedin.com/company/vodafoneziggo",employees:7500,revenue_range:"€5-10B",data_warehouse:"Teradata, Snowflake",bi_tools:"Power BI, Tableau",cloud_provider:"GCP, AWS",data_lake:"Teradata Vantage, Snowflake, S3",ai_ml:"Vertex AI, Python, TensorFlow",database_vendor:"Oracle, Teradata, PostgreSQL"}, + {name:"Odido",industry:"Telecom",city:"Den Haag",address:"Waldorpstraat 60",postal_code:"2521 CC",phone:"+31 70 700 7000",email:"data@odido.nl",website:"odido.nl",linkedin:"linkedin.com/company/odidonl",employees:2000,revenue_range:"€1-5B",data_warehouse:"Snowflake, Databricks",bi_tools:"Tableau, Looker",cloud_provider:"AWS, GCP",data_lake:"Snowflake, Databricks Delta Lake",ai_ml:"Python, AWS SageMaker, XGBoost",database_vendor:"PostgreSQL, Oracle"}, + + // Energy & Utilities + {name:"Vattenfall NL",industry:"Energy",city:"Amsterdam",address:"Hoekenrode 8",postal_code:"1102 BR",phone:"+31 20 595 5555",email:"data.nl@vattenfall.com",website:"vattenfall.nl",linkedin:"linkedin.com/company/vattenfall",employees:4500,revenue_range:"€5-10B",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI, Tableau",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Databricks",ai_ml:"Azure ML, Python, Databricks ML",database_vendor:"SQL Server, PostgreSQL"}, + {name:"Eneco",industry:"Energy",city:"Rotterdam",address:"Marten Meesweg 5",postal_code:"3068 AV",phone:"+31 88 895 5000",email:"datascience@eneco.com",website:"eneco.com",linkedin:"linkedin.com/company/eneco",employees:3000,revenue_range:"€1-5B",data_warehouse:"Azure Synapse, Databricks, Snowflake",bi_tools:"Power BI, Tableau",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Databricks Delta Lake",ai_ml:"Azure ML, Python, TensorFlow",database_vendor:"SQL Server, PostgreSQL"}, + {name:"Alliander",industry:"Energy/Grid",city:"Arnhem",address:"Utrechtseweg 68",postal_code:"6812 AH",phone:"+31 26 376 2626",email:"data.office@alliander.com",website:"alliander.com",linkedin:"linkedin.com/company/alliander",employees:7000,revenue_range:"€1-5B",data_warehouse:"Azure Synapse, Databricks, SAP BW",bi_tools:"Power BI, SAP Analytics Cloud",cloud_provider:"Azure",data_lake:"Azure Data Lake Gen2, Databricks",ai_ml:"Azure ML, Python, Databricks ML",database_vendor:"SAP HANA, SQL Server, Oracle"}, + {name:"TenneT",industry:"Energy/Grid",city:"Arnhem",address:"Utrechtseweg 310",postal_code:"6812 AR",phone:"+31 26 373 1111",email:"data@tennet.eu",website:"tennet.eu",linkedin:"linkedin.com/company/tennet-tso",employees:5000,revenue_range:"€1-5B",data_warehouse:"Azure Synapse, SAP BW/4HANA",bi_tools:"Power BI, SAP Analytics Cloud",cloud_provider:"Azure",data_lake:"Azure Data Lake",ai_ml:"Azure ML, Python, scikit-learn",database_vendor:"SAP HANA, SQL Server, Oracle"}, + + // Manufacturing & Industrial + {name:"DSM-Firmenich",industry:"Chemicals",city:"Maastricht",address:"Vondelingenweg 601",postal_code:"6229 PM",phone:"+31 46 476 3050",email:"digital@dsm-firmenich.com",website:"dsm-firmenich.com",linkedin:"linkedin.com/company/dsm-firmenich",employees:30000,revenue_range:"€10B+",data_warehouse:"SAP BW/4HANA, Azure Synapse, Databricks",bi_tools:"Power BI, SAP Analytics Cloud, Tableau",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Databricks",ai_ml:"Databricks ML, Azure ML, Python, PyTorch",database_vendor:"SAP HANA, SQL Server, Oracle"}, + {name:"AkzoNobel",industry:"Chemicals",city:"Amsterdam",address:"Christian Neefestraat 2",postal_code:"1077 WW",phone:"+31 20 502 7555",email:"advanced.analytics@akzonobel.com",website:"akzonobel.com",linkedin:"linkedin.com/company/akzonobel",employees:35000,revenue_range:"€10B+",data_warehouse:"Azure Synapse, SAP BW",bi_tools:"Power BI, Tableau, SAP Analytics Cloud",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake",ai_ml:"Azure ML, Python, Databricks ML",database_vendor:"SAP HANA, SQL Server, Oracle"}, + {name:"Philips Lighting (Signify)",industry:"Lighting",city:"Eindhoven",address:"High Tech Campus 48",postal_code:"5656 AE",phone:"+31 40 275 0000",email:"data.office@signify.com",website:"signify.com",linkedin:"linkedin.com/company/signify",employees:32000,revenue_range:"€5-10B",data_warehouse:"Azure Synapse, Databricks, Snowflake",bi_tools:"Power BI, Tableau",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Databricks Delta Lake",ai_ml:"Azure ML, PyTorch, Databricks ML",database_vendor:"SAP HANA, SQL Server"}, + {name:"Thales Nederland",industry:"Defense/Tech",city:"Hengelo",address:"Zuidelijke Havenweg 40",postal_code:"7554 RR",phone:"+31 74 248 8111",email:"data@nl.thalesgroup.com",website:"thalesgroup.com",linkedin:"linkedin.com/company/thales",employees:3200,revenue_range:"€500M-1B",data_warehouse:"Azure Synapse, Snowflake",bi_tools:"Power BI, Tableau",cloud_provider:"Azure",data_lake:"Azure Data Lake",ai_ml:"PyTorch, Azure ML, TensorFlow",database_vendor:"SQL Server, Oracle, PostgreSQL"}, + {name:"Vanderlande",industry:"Logistics Automation",city:"Veghel",address:"Vanderlandelaan 2",postal_code:"5466 RB",phone:"+31 413 495 000",email:"data@vanderlande.com",website:"vanderlande.com",linkedin:"linkedin.com/company/vanderlande",employees:8000,revenue_range:"€1-5B",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI, SAP Analytics Cloud",cloud_provider:"Azure, GCP",data_lake:"Azure Data Lake, Databricks",ai_ml:"Azure ML, Python, scikit-learn",database_vendor:"SQL Server, SAP HANA, PostgreSQL"}, + + // Education & Research + {name:"Universiteit van Amsterdam",industry:"Education",city:"Amsterdam",address:"Spui 21",postal_code:"1012 WX",phone:"+31 20 525 9111",email:"datascience@uva.nl",website:"uva.nl",linkedin:"linkedin.com/school/universiteit-van-amsterdam",employees:6500,revenue_range:"€500M-1B",data_warehouse:"BigQuery, Apache Spark",bi_tools:"Tableau, Grafana, R Shiny",cloud_provider:"GCP, Azure",data_lake:"GCS, HDFS",ai_ml:"PyTorch, TensorFlow, JAX, scikit-learn",database_vendor:"PostgreSQL, BigQuery, MySQL"}, + {name:"TU Delft",industry:"Education",city:"Delft",address:"Mekelweg 5",postal_code:"2628 CD",phone:"+31 15 278 9111",email:"data@tudelft.nl",website:"tudelft.nl",linkedin:"linkedin.com/school/tu-delft",employees:5300,revenue_range:"€500M-1B",data_warehouse:"Apache Spark, Azure Synapse",bi_tools:"Grafana, R Shiny, Metabase",cloud_provider:"Azure, GCP",data_lake:"Azure Data Lake, HDFS",ai_ml:"PyTorch, TensorFlow, JAX, DVC, MLflow",database_vendor:"PostgreSQL, MongoDB, HDFS"}, + {name:"Surf",industry:"IT/Research",city:"Utrecht",address:"Moreelsepark 48",postal_code:"3511 EP",phone:"+31 88 787 3000",email:"data@surf.nl",website:"surf.nl",linkedin:"linkedin.com/company/surf",employees:500,revenue_range:"€100-500M",data_warehouse:"Azure Synapse, Hadoop/Spark",bi_tools:"Grafana, Metabase, Power BI",cloud_provider:"Azure, GCP",data_lake:"HDFS, Azure Data Lake",ai_ml:"PyTorch, TensorFlow, JAX, Hugging Face",database_vendor:"PostgreSQL, MySQL, HDFS"}, + {name:"TNO",industry:"Research",city:"Den Haag",address:"Anna van Buerenplein 1",postal_code:"2595 DA",phone:"+31 88 866 0000",email:"data.science@tno.nl",website:"tno.nl",linkedin:"linkedin.com/company/tno",employees:3800,revenue_range:"€500M-1B",data_warehouse:"Databricks, Azure Synapse",bi_tools:"Power BI, Tableau, Grafana",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Databricks Delta Lake",ai_ml:"PyTorch, TensorFlow, Azure ML, MLflow",database_vendor:"PostgreSQL, SQL Server"}, + + // Government & Public + {name:"CBS (Centraal Bureau Statistiek)",industry:"Government",city:"Den Haag",address:"Henri Faasdreef 312",postal_code:"2492 JP",phone:"+31 70 337 3800",email:"bigdata@cbs.nl",website:"cbs.nl",linkedin:"linkedin.com/company/statistics-netherlands",employees:2000,revenue_range:"€100-500M",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI, Tableau, R Shiny",cloud_provider:"Azure",data_lake:"Azure Data Lake, Databricks",ai_ml:"Azure ML, Python, R, scikit-learn",database_vendor:"SQL Server, PostgreSQL, Oracle"}, + {name:"DUO",industry:"Government",city:"Groningen",address:"Kempkensberg 12",postal_code:"9722 TB",phone:"+31 50 599 7755",email:"data@duo.nl",website:"duo.nl",linkedin:"linkedin.com/company/dienst-uitvoering-onderwijs",employees:2500,revenue_range:"€50B+ (publiek)",data_warehouse:"Oracle Exadata, Azure Synapse",bi_tools:"Power BI, SAS, Oracle BI",cloud_provider:"Azure, Oracle Cloud",data_lake:"Azure Data Lake",ai_ml:"Azure ML, Python, R",database_vendor:"Oracle Exadata, SQL Server"}, + {name:"Kadaster",industry:"Government",city:"Apeldoorn",address:"Hofstraat 110",postal_code:"7311 KZ",phone:"+31 88 183 2000",email:"data@kadaster.nl",website:"kadaster.nl",linkedin:"linkedin.com/company/kadaster",employees:2300,revenue_range:"€100-500M",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI, ArcGIS, Tableau",cloud_provider:"Azure",data_lake:"Azure Data Lake Gen2",ai_ml:"Python, Azure ML, Computer Vision",database_vendor:"SQL Server, Oracle Spatial, PostgreSQL/PostGIS"}, + {name:"Belastingdienst",industry:"Government",city:"Utrecht",address:"Tiberdreef 12",postal_code:"3561 GG",phone:"+31 55 538 5385",email:"data@belastingdienst.nl",website:"belastingdienst.nl",linkedin:"linkedin.com/company/belastingdienst",employees:29000,revenue_range:"€100B+ (publiek)",data_warehouse:"Oracle Exadata, Azure Synapse",bi_tools:"Power BI, SAS, Oracle BI",cloud_provider:"Azure, Oracle Cloud",data_lake:"Azure Data Lake, Oracle",ai_ml:"Azure ML, Python, R, SAS",database_vendor:"Oracle Exadata, SQL Server"}, + + // Construction & Real Estate + {name:"Royal BAM Group",industry:"Construction",city:"Bunnik",address:"Runnenburg 9",postal_code:"3981 AZ",phone:"+31 30 659 8911",email:"digital@bam.com",website:"bam.com",linkedin:"linkedin.com/company/royal-bam-group",employees:16000,revenue_range:"€5-10B",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI, Tableau",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Databricks",ai_ml:"Azure ML, Python, Computer Vision",database_vendor:"SQL Server, Oracle, PostgreSQL"}, + {name:"Heijmans",industry:"Construction",city:"Rosmalen",address:"Graafsebaan 65",postal_code:"5248 JT",phone:"+31 73 543 5111",email:"data@heijmans.nl",website:"heijmans.nl",linkedin:"linkedin.com/company/heijmans",employees:5000,revenue_range:"€1-5B",data_warehouse:"Azure Synapse, SAP BW",bi_tools:"Power BI, SAP Analytics Cloud",cloud_provider:"Azure",data_lake:"Azure Data Lake",ai_ml:"Azure ML, Python, Computer Vision",database_vendor:"SAP HANA, SQL Server"}, + + // Media & Entertainment + {name:"RTL Nederland",industry:"Media",city:"Hilversum",address:"Sumatralaan 47",postal_code:"1217 GP",phone:"+31 35 671 8911",email:"data@rtl.nl",website:"rtl.nl",linkedin:"linkedin.com/company/rtl-nederland",employees:1200,revenue_range:"€500M-1B",data_warehouse:"BigQuery, Databricks",bi_tools:"Looker, Tableau, Google Analytics",cloud_provider:"GCP, AWS",data_lake:"BigQuery, GCS",ai_ml:"Vertex AI, Python, TensorFlow",database_vendor:"PostgreSQL, BigQuery"}, + + // Additional prospects + {name:"Betaalvereniging Nederland",industry:"Fintech",city:"Amsterdam",address:"Gustav Mahlerplein 33",postal_code:"1082 MS",phone:"+31 20 305 1900",email:"data@betaalvereniging.nl",website:"betaalvereniging.nl",linkedin:"linkedin.com/company/betaalvereniging-nederland",employees:150,revenue_range:"€10-50M",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI",cloud_provider:"Azure",data_lake:"Azure Data Lake",ai_ml:"Python, Azure ML",database_vendor:"SQL Server, PostgreSQL"}, + {name:"Backbase",industry:"Fintech",city:"Amsterdam",address:"Jacob Bontiusplaats 9",postal_code:"1018 LL",phone:"+31 20 465 8888",email:"info@backbase.com",website:"backbase.com",linkedin:"linkedin.com/company/backbase",employees:2000,revenue_range:"€100-500M",data_warehouse:"Snowflake, BigQuery",bi_tools:"Tableau, Looker",cloud_provider:"AWS, GCP",data_lake:"Snowflake, S3",ai_ml:"Python, scikit-learn, XGBoost",database_vendor:"PostgreSQL, MySQL"}, + {name:"Miro",industry:"SaaS/Collaboration",city:"Amsterdam",address:"Stadhouderskade 1",postal_code:"1054 ES",phone:"+31 20 888 4500",email:"data@miro.com",website:"miro.com",linkedin:"linkedin.com/company/mirohq",employees:1800,revenue_range:"€100-500M",data_warehouse:"Snowflake, dbt",bi_tools:"Looker, Tableau",cloud_provider:"AWS, GCP",data_lake:"Snowflake, S3",ai_ml:"Python, AWS SageMaker, XGBoost",database_vendor:"PostgreSQL, Snowflake"}, + {name:"MessageBird",industry:"SaaS/Communication",city:"Amsterdam",address:"Trompenburgstraat 4",postal_code:"1079 TX",phone:"+31 20 888 5000",email:"data@messagebird.com",website:"messagebird.com",linkedin:"linkedin.com/company/messagebird",employees:800,revenue_range:"€100-500M",data_warehouse:"BigQuery, Snowflake",bi_tools:"Looker, Metabase",cloud_provider:"GCP, AWS",data_lake:"BigQuery, GCS, Snowflake",ai_ml:"Python, Vertex AI, PyTorch",database_vendor:"PostgreSQL, BigQuery"}, + {name:"Lightspeed",industry:"SaaS/POS",city:"Amsterdam",address:"Johan Huizingalaan 763A",postal_code:"1066 VH",phone:"+31 20 757 4200",email:"data@lightspeedhq.com",website:"lightspeedhq.com",linkedin:"linkedin.com/company/lightspeedhq",employees:1500,revenue_range:"€100-500M",data_warehouse:"BigQuery, dbt",bi_tools:"Looker, Data Studio",cloud_provider:"GCP",data_lake:"BigQuery, GCS",ai_ml:"Vertex AI, Python, scikit-learn",database_vendor:"PostgreSQL, MySQL, BigQuery"}, + {name:"WeTransfer",industry:"SaaS/File Sharing",city:"Amsterdam",address:"Willem Fenengastraat 19",postal_code:"1096 BL",phone:"+31 20 215 7520",email:"data@wetransfer.com",website:"wetransfer.com",linkedin:"linkedin.com/company/wetransfer",employees:350,revenue_range:"€50-100M",data_warehouse:"BigQuery, dbt",bi_tools:"Looker, Metabase",cloud_provider:"GCP, AWS",data_lake:"GCS + BigQuery, S3",ai_ml:"Vertex AI, Python",database_vendor:"PostgreSQL, BigQuery"}, + {name:"ChannelEngine",industry:"SaaS/E-commerce",city:"Leiden",address:"Kaagschip 4",postal_code:"2312 VS",phone:"+31 71 700 1100",email:"data@channelengine.com",website:"channelengine.com",linkedin:"linkedin.com/company/channelengine",employees:500,revenue_range:"€10-50M",data_warehouse:"BigQuery, Snowflake",bi_tools:"Looker, Tableau",cloud_provider:"GCP, AWS",data_lake:"BigQuery, GCS, S3",ai_ml:"Python, XGBoost, scikit-learn",database_vendor:"PostgreSQL, BigQuery"}, + {name:"Exact",industry:"SaaS/ERP",city:"Delft",address:"Molengraaffsingel 33",postal_code:"2629 JD",phone:"+31 15 711 5000",email:"data@exact.com",website:"exact.com",linkedin:"linkedin.com/company/exact",employees:1800,revenue_range:"€100-500M",data_warehouse:"Azure Synapse, Snowflake",bi_tools:"Power BI, QlikView",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Snowflake",ai_ml:"Python, Azure ML",database_vendor:"SQL Server, PostgreSQL"}, + {name:"AFAS Software",industry:"SaaS/ERP",city:"Leusden",address:"Philipsstraat 9",postal_code:"3833 LC",phone:"+31 33 434 2434",email:"data@afas.nl",website:"afas.nl",linkedin:"linkedin.com/company/afas",employees:700,revenue_range:"€100-500M",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI",cloud_provider:"Azure",data_lake:"Azure Data Lake",ai_ml:"Python, Azure ML",database_vendor:"SQL Server, PostgreSQL"}, + {name:"Topicus",industry:"SaaS/Finance",city:"Deventer",address:"Handelskade 13",postal_code:"7417 DE",phone:"+31 570 668 000",email:"data@topicus.nl",website:"topicus.nl",linkedin:"linkedin.com/company/topicus",employees:2500,revenue_range:"€100-500M",data_warehouse:"Azure Synapse, Snowflake",bi_tools:"Power BI",cloud_provider:"Azure",data_lake:"Azure Data Lake",ai_ml:"Python, Azure ML",database_vendor:"SQL Server, PostgreSQL"}, + {name:"Unit4",industry:"SaaS/ERP",city:"Sliedrecht",address:"Stationspark 1000",postal_code:"3364 DA",phone:"+31 184 444 444",email:"info@unit4.com",website:"unit4.com",linkedin:"linkedin.com/company/unit4",employees:3500,revenue_range:"€500M-1B",data_warehouse:"Azure Synapse, Snowflake, SAP BW",bi_tools:"Power BI, Tableau",cloud_provider:"Azure, AWS",data_lake:"Azure Data Lake, Snowflake",ai_ml:"Python, Azure ML, R",database_vendor:"SQL Server, SAP HANA, PostgreSQL"}, + + // Additional Healthcare + {name:"Erasmus MC",industry:"Healthcare",city:"Rotterdam",address:"Doctor Molewaterplein 40",postal_code:"3015 GD",phone:"+31 10 704 0704",email:"datascience@erasmusmc.nl",website:"erasmusmc.nl",linkedin:"linkedin.com/company/erasmus-mc",employees:14000,revenue_range:"€1-5B",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI, R Shiny, Tableau",cloud_provider:"Azure",data_lake:"Azure Data Lake, Databricks",ai_ml:"PyTorch, TensorFlow, Azure ML, scikit-learn",database_vendor:"SQL Server, PostgreSQL, Oracle"}, + {name:"Radboud UMC",industry:"Healthcare",city:"Nijmegen",address:"Geert Grooteplein Zuid 10",postal_code:"6525 GA",phone:"+31 24 361 1111",email:"data.science@radboudumc.nl",website:"radboudumc.nl",linkedin:"linkedin.com/company/radboudumc",employees:11000,revenue_range:"€1-5B",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI, R Shiny, Grafana",cloud_provider:"Azure",data_lake:"Azure Data Lake, Databricks Delta Lake",ai_ml:"PyTorch, MONAI, TensorFlow, Azure ML",database_vendor:"SQL Server, PostgreSQL, Oracle"}, + {name:"Amsterdam UMC",industry:"Healthcare",city:"Amsterdam",address:"Meibergdreef 9",postal_code:"1105 AZ",phone:"+31 20 566 9111",email:"datascience@amsterdamumc.nl",website:"amsterdamumc.nl",linkedin:"linkedin.com/company/amsterdam-umc",employees:17000,revenue_range:"€1-5B",data_warehouse:"Azure Synapse, Databricks",bi_tools:"Power BI, Tableau, Grafana",cloud_provider:"Azure, GCP",data_lake:"Azure Data Lake, Databricks Delta Lake",ai_ml:"PyTorch, TensorFlow, Azure ML, JAX",database_vendor:"SQL Server, PostgreSQL, Oracle"}, +]; + +// Seed companies +const existing = db.prepare("SELECT COUNT(*) as cnt FROM tech_companies").get(); +if (existing.cnt === 0) { + for (const c of companies) seedCompany(c); +} +console.log(companies.length + " companies seeded"); + +// Print lead scores summary +const top = db.prepare("SELECT name,industry,lead_score,data_warehouse FROM tech_companies ORDER BY lead_score DESC LIMIT 10").all(); +console.log("\nTop 10 prospects:"); +top.forEach(c => console.log(c.name + " | " + c.industry + " | Score: " + c.lead_score + " | " + (c.data_warehouse||"-").substring(0,50))); + +const totalDCs = db.prepare("SELECT COUNT(*) as cnt FROM datacenters").get().cnt; +const totalCompanies = db.prepare("SELECT COUNT(*) as cnt FROM tech_companies").get().cnt; +console.log("\nTotal: " + totalDCs + " datacenters, " + totalCompanies + " companies"); diff --git a/server.js b/server.js new file mode 100644 index 0000000..43fabfa --- /dev/null +++ b/server.js @@ -0,0 +1,192 @@ +require('dotenv').config(); +const express = require('express'); +const http = require('http'); +const bodyParser = require('body-parser'); +const cors = require('cors'); +const path = require('path'); +const session = require('express-session'); +const SQLiteStore = require('connect-sqlite3')(session); +const multer = require('multer'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); +app.use(express.static(path.join(__dirname, 'public'))); +app.use(bodyParser.urlencoded({ extended: true })); +app.use(bodyParser.json()); +app.use(cors()); + +const { localeMiddleware } = require('./locales'); +const { crmContextMiddleware, getClientHubLinks } = require('./lib/crm'); +const { getNavSections } = require('./lib/nav'); +const { initAiSchema, syncAiConfigFile } = require('./lib/ai'); + +app.use(localeMiddleware); + +try { initAiSchema(); syncAiConfigFile(); } catch (e) { console.warn('AI init:', e.message); } + +app.use(session({ + store: new SQLiteStore({ db: 'sessions.db', dir: path.join(__dirname, 'data') }), + secret: process.env.SESSION_SECRET || 'mek-tech-consulting-secret', + resave: false, + saveUninitialized: false, + cookie: { maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'lax' } +})); + +app.use((req, res, next) => { + crmContextMiddleware(req, res, () => { + res.locals.navSections = getNavSections(req.path); + res.locals.showCrmBar = !req.path.startsWith('/auth') && req.path !== '/login'; + if (res.locals.activeClient) { + res.locals.clientHubLinks = getClientHubLinks(res.locals.activeClient.id); + } + next(); + }); +}); + +app.use((req, res, next) => { + const publicPaths = ['/login', '/auth/login', '/css/dynamic.css', '/js/theme.js', '/health']; + const publicPrefixes = ['/wachtwoord-vergeten', '/wachtwoord-reset/', '/css/', '/js/', '/health']; + if (!publicPaths.includes(req.path) && !publicPrefixes.some(p => req.path.startsWith(p)) && !req.session.userId) { + return res.redirect('/login'); + } + res.locals.user = req.session.userId ? { username: req.session.username } : null; + next(); +}); + +app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); + +const QS_HOST = process.env.QS_HOST || 'python-quality'; +const QS_PORT = parseInt(process.env.QS_PORT || '3002', 10); +app.use('/python-quality', (req, res) => { + const proxyPath = '/python-quality' + req.url; + const proxyReq = http.request({ + hostname: QS_HOST, + port: QS_PORT, + path: proxyPath, + method: req.method, + headers: { ...req.headers, host: `${QS_HOST}:${QS_PORT}` } + }, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + }); + proxyReq.on('error', () => { + res.status(502).type('html').send(`Service offline

Data Quality service offline

De Python Quality engine is momenteel niet bereikbaar.

Ga naar Quality Dashboard

`); + }); + if (req.method === 'GET' || req.method === 'HEAD') proxyReq.end(); + else req.pipe(proxyReq); +}); + +app.use((req, res, next) => { + try { + const { getSettings } = require('./db'); + const settings = getSettings(); + res.locals.auto_refresh = settings.auto_refresh || '0'; + res.locals.settings_data = JSON.stringify(settings); + } catch (e) { + res.locals.auto_refresh = '0'; + res.locals.settings_data = '{}'; + } + next(); +}); + +app.get('/css/dynamic.css', (req, res) => { + try { + const { getSettings } = require('./db'); + const { THEME_PRESETS, THEME_CSS_KEYS } = require('./themes'); + const settings = getSettings(); + let css = '/* Mek-Tech Dynamic Settings */\n'; + + const themeKey = settings.theme || 'mek-neon-blue'; + const preset = THEME_PRESETS[themeKey] || THEME_PRESETS['mek-neon-blue']; + const customColors = settings.theme_colors || {}; + + const resolved = {}; + css += ':root{\n'; + THEME_CSS_KEYS.forEach(k => { + const val = customColors[k] || preset[k]; + if (val) { + resolved[k] = val; + css += `--${k.replace(/_/g, '-')}:${val};\n`; + } + }); + css += '}\n'; + + const bg = resolved.bg || preset.bg; + const bgNav = resolved.bg_nav || preset.bg_nav; + const accent = resolved.accent || preset.accent; + + css += ` +/* User theme overrides */ +body{background-color:${bg}!important} +.navbar{background:${bgNav}!important;border-bottom-color:var(--border)!important} +.sidebar{background:var(--bg-nav)!important} +.sb-item.active{background:rgba(0,212,255,.12)!important;color:${accent}!important} +.kpi-card:hover,.client-card:hover,.card:hover{box-shadow:0 0 28px rgba(0,212,255,.15)!important} +`; + + // Icon colors + if (settings.icon_colors && typeof settings.icon_colors === 'object') { + for (const [icon, color] of Object.entries(settings.icon_colors)) { + css += `[data-lucide="${icon}"]{color:${color}!important}\n`; + } + } + + res.type('css').send(css); + } catch (e) { + console.error('dynamic.css error:', e.message); + res.type('css').send('/* no settings */'); + } +}); + +app.use('/', require('./routes/auth')); +app.use('/', require('./routes/dashboard')); +app.use('/', require('./routes/password')); +app.use('/clients', require('./routes/clients')); +app.use('/engagements', require('./routes/engagements')); +app.use('/tasks', require('./routes/tasks')); +app.use('/assess', require('./routes/assess')); +app.use('/racks', require('./routes/racks')); +app.use('/networking', require('./routes/networking')); +app.use('/architecture', require('./routes/architecture')); +app.use('/sizing', require('./routes/sizing')); +app.use('/infrastructure', require('./routes/infrastructure')); +app.use('/finance', require('./routes/finance')); +app.use('/settings', require('./routes/settings').router); +app.use('/backup', require('./routes/backup')); +app.use('/users', require('./routes/users')); +app.use('/time', require('./routes/time')); +app.use('/invoices', require('./routes/invoices')); +app.use('/notifications', require('./routes/notifications').router); +app.use('/calendar', require('./routes/calendar')); +app.use('/search', require('./routes/search')); +app.use('/audit', require('./routes/audit')); +app.use('/email', require('./routes/email')); +app.use('/quality', require('./routes/quality')); +app.use('/market', require('./routes/market')); +app.use('/consulting', require('./routes/consulting')); +app.use('/projects', require('./routes/projects')); +app.use('/reports', require('./routes/reports')); +app.use('/integrations', require('./routes/integrations')); +app.use('/ai', require('./routes/ai')); +app.use('/api/v1', require('./routes/api')); + +app.get('/health', async (req, res) => { + try { + const { getServicesStatus } = require('./lib/services'); + const services = await getServicesStatus(); + res.json({ status: 'ok', services, ts: new Date().toISOString() }); + } catch (e) { + res.status(500).json({ status: 'error', error: e.message }); + } +}); + +app.use((req, res) => { + res.status(404).render('404', { path: req.path }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Mek-Tech Consulting Framework running on http://0.0.0.0:${PORT}`); +}); diff --git a/services.js b/services.js new file mode 100644 index 0000000..bfa26d1 --- /dev/null +++ b/services.js @@ -0,0 +1,29 @@ +const http = require('http'); + +function checkService(host, port, path, timeout = 3000) { + return new Promise((resolve) => { + const req = http.request({ hostname: host, port, path, method: 'GET', timeout }, (res) => { + resolve({ online: res.statusCode < 500, status: res.statusCode }); + }); + req.on('error', () => resolve({ online: false, status: 0 })); + req.on('timeout', () => { req.destroy(); resolve({ online: false, status: 0 }); }); + req.end(); + }); +} + +async function getServicesStatus() { + const qsHost = process.env.QS_HOST || 'python-quality'; + const [quality, designer, leadgen] = await Promise.all([ + checkService(qsHost, 3002, '/python-quality/'), + checkService('python-designer', 3001, '/python-editor/'), + checkService('python-leadgen', 3003, '/python-leadgen/health') + ]); + return { + consulting: { online: true, label: 'Mek-Tech Core', port: 3000 }, + quality: { ...quality, label: 'Data Quality', port: 3002 }, + designer: { ...designer, label: 'Pro Designer', port: 3001 }, + leadgen: { ...leadgen, label: 'Lead Generation', port: 3003 } + }; +} + +module.exports = { checkService, getServicesStatus }; diff --git a/settings.ejs b/settings.ejs new file mode 100644 index 0000000..eae5fce --- /dev/null +++ b/settings.ejs @@ -0,0 +1,420 @@ + + + + + +Instellingen — Mek-Tech Enterprise + + + + + + + + + + + +
+ Terug +

Instellingen

+

Configureer het platform naar wens.

+ +<% if (saved) { %> +
Instellingen opgeslagen!
+<% } %> + +
+

Backup & Demo Portal

+ +

Backup = intern CRM. Live Labs = apart demo-platform voor klantbezoeken.

+
+ +
+ +
+

Algemeen

+
+ + +
+
+ + +Mek-Tech Live Labs (aparte Docker). +
+
+ + +
+
+ + +Na inloggen. +
+
+ +
+

Thema & Kleuren

+

Kies een preset als basis en pas de achtergrond en kleuren zelf aan.

+ + + +
+ +
+ +
+

Jouw kleuren

+ +
+
Sidebar
+
+
Voorbeeld tekst
+
Kaart / panel
+
+
+
+ +
+<% themeMainColors.forEach(function(mc) { + var val = mergedColors[mc.key] || ''; + var hexVal = toHexForPicker(val, '#0a1628'); + var fieldName = 'th_' + mc.key; +%> +
+ + <%= mc.hint %> +
+ + +
+
+<% }) %> +
+ +
+Geavanceerde kleuren (randen, statuskleuren) +
+<% var thKeys = themeCssKeys.filter(function(k){ return !themeMainColors.some(function(m){ return m.key===k; }); }); %> +<% thKeys.forEach(function(k) { + var val = mergedColors[k] || ''; + var hexVal = toHexForPicker(val, '#0a1628'); + var fieldName = 'th_' + k; + var label = themeColorLabels[k] || ('--' + k.replace(/_/g, '-')); +%> +
+ + + +
+<% }) %> +
+
+
+ +

Preset als startpunt:

+
+<% var themeKeys = Object.keys(themePresets).filter(function(k){ return k !== 'custom'; }); %> +<% themeKeys.forEach(function(k) { var p = themePresets[k]; var active = settings.theme === k || (settings.theme === 'custom' && !useCustomColors); %> + +<% }) %> +
+
+ +
+

Icoon Kleuren

+

Kies zelf de kleur van elk icoon in de sidebar en tabbladen.

+
+<% icons.forEach(function(icon) { + var color = settings.icon_colors[icon.key] || '#58a6ff'; + var fieldName = 'color_' + icon.key.replace(/-/g, '_'); +%> +
+ +<%= icon.label %> + + +
+<% }) %> +
+
+ +
+

Dashboard

+
+ + +Automatisch verversen van het dashboard. +
+
+ +
+

Notificaties

+
+ + +Voor Slack notificaties bij nieuwe events +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Systeem

+
+ + +
+
+ +> +Toont technische details bij fouten. +
+
+ +
+ Backup & Herstel +Zie ook bovenaan deze pagina. +
+
+
+ +
+ + + +
+ +
+
+ + + + diff --git a/settings.js b/settings.js new file mode 100644 index 0000000..add6a1d --- /dev/null +++ b/settings.js @@ -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 }; diff --git a/style.css b/style.css new file mode 100644 index 0000000..b858c1d --- /dev/null +++ b/style.css @@ -0,0 +1,489 @@ +@import url('neon.css'); + +:root { + --bg: #0a1628; --bg-card: #0f1f3a; --bg-nav: #0c1a30; + --border: rgba(0,212,255,0.15); --border-light: rgba(0,212,255,0.08); + --text: #e8f4ff; --text-muted: #7eb8d4; --text-dim: #4a7a99; + --accent: #00d4ff; --accent-hover: #4de8ff; + --green: #00ff88; --green-bg: #0a2a1a; + --red: #ff6b8a; --red-bg: #2a0a14; + --orange: #ffb347; --orange-bg: #2a1a0a; + --purple: #a855f7; --purple-bg: #1a0a2a; + --cyan: #00d4ff; --cyan-bg: #0a1a2a; + --pink: #ff6ec7; --pink-bg: #2a0a1f; + --radius: 8px; --radius-lg: 14px; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; min-height: 100vh; } +.container { max-width: 960px; margin: 0 auto; padding: 1.5rem; } + +/* Navbar - Premium Redesign */ +.navbar { background: var(--bg-nav); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100; backdrop-filter: blur(8px); } +.nav-inner { max-width: 100%; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; padding: .35rem 1rem; min-height: 46px; flex-wrap: wrap; gap: .35rem; } +.nav-brand { display: flex; align-items: center; gap: .4rem; color: var(--text); text-decoration: none; font-weight: 700; font-size: .85rem; flex-shrink: 0; } +.nav-brand:hover { color: var(--accent); transition: color .2s; } +.nav-logo { height: 28px; width: auto; } +.login-logo-img { height: 80px; width: auto; margin-bottom: .5rem; } +.nav-links { display: flex; gap: 1px; align-items: center; flex-wrap: wrap; } +.nav-link { + display: inline-flex; align-items: center; gap: .35rem; + padding: .32rem .55rem; border-radius: 7px; color: var(--text-muted); + text-decoration: none; font-size: .72rem; font-weight: 500; transition: all .18s; + border-bottom: 2px solid transparent; position: relative; +} +.nav-link svg { width: 14px; height: 14px; flex-shrink: 0; } +.nav-link:hover { color: var(--text); background: var(--border-light); } +.nav-link.active { color: var(--text); background: var(--border-light); font-weight: 600; border-bottom-color: var(--accent); } +.nav-link.logout { color: var(--red); } +.nav-link.logout:hover { background: var(--red-bg); color: var(--red); } +.nav-link.logout.active { border-bottom-color: var(--red); } +/* Colorful nav-link accents */ +.nav-link.cl-dash:hover,.nav-link.cl-dash.active { border-bottom-color: var(--accent); color: var(--accent); } +.nav-link.cl-clients:hover,.nav-link.cl-clients.active { border-bottom-color: var(--green); color: var(--green); } +.nav-link.cl-eng:hover,.nav-link.cl-eng.active { border-bottom-color: var(--purple); color: var(--purple); } +.nav-link.cl-fin:hover,.nav-link.cl-fin.active { border-bottom-color: var(--orange); color: var(--orange); } +.nav-link.cl-market:hover,.nav-link.cl-market.active { border-bottom-color: var(--cyan); color: var(--cyan); } +.nav-link.cl-tasks:hover,.nav-link.cl-tasks.active { border-bottom-color: var(--pink); color: var(--pink); } +.nav-link.cl-data:hover,.nav-link.cl-data.active { border-bottom-color: var(--green); color: var(--green); } +.nav-link.cl-settings:hover,.nav-link.cl-settings.active { border-bottom-color: var(--text-muted); color: var(--text); } + +.nav-icon-btn{display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .45rem;font-size:.68rem;text-decoration:none;color:var(--text-muted);border-radius:6px;background:transparent;border:none;cursor:pointer;font-family:inherit;transition:all .15s} +.nav-icon-btn:hover{color:var(--text);background:var(--border-light)} +.nav-icon-btn svg{width:15px;height:15px} + +/* Dashboard */ +.dash-header { margin-bottom: 1.5rem; } +.dash-header h1 { font-size: 1.5rem; color: var(--text); font-weight: 600; } + +.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .75rem; margin-bottom: 1.5rem; } +.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; } +.stat-number { font-size: 1.75rem; font-weight: 700; color: var(--accent); } +.stat-card.green .stat-number { color: var(--green); } +.stat-card.orange .stat-number { color: var(--orange); } +.stat-card.purple .stat-number { color: var(--purple); } +.stat-label { font-size: .75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .3px; margin-top: 2px; } + +/* Client grid */ +.client-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: .75rem; } +.client-card { + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); + padding: 1rem; text-decoration: none; color: inherit; transition: all .15s; +} +.client-card:hover { border-color: var(--text-dim); } +.client-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; } +.client-card-header h4 { font-size: .9rem; color: var(--text); font-weight: 600; } +.client-meta { font-size: .8rem; color: var(--text-muted); } +.client-meta.warn { color: var(--orange); } +.client-meta.small { font-size: .7rem; } + +/* Status badges */ +.status-badge { display: inline-block; padding: .15rem .5rem; border-radius: 999px; font-size: .65rem; font-weight: 500; border: 1px solid transparent; } +.status-badge.lead { border-color: var(--accent); color: var(--accent); } +.status-badge.active { border-color: var(--green); color: var(--green); } +.status-badge.paused { border-color: var(--orange); color: var(--orange); } +.status-badge.archived { border-color: var(--text-dim); color: var(--text-dim); } +.status-badge.planned { border-color: var(--accent); color: var(--accent); } +.status-badge.in_progress { border-color: var(--green); color: var(--green); } +.status-badge.completed { border-color: var(--purple); color: var(--purple); } +.status-badge.on_hold { border-color: var(--orange); color: var(--orange); } +.status-badge.todo { border-color: var(--text-dim); color: var(--text-dim); } +.status-badge.done { border-color: var(--green); color: var(--green); } + +/* Engagement type */ +.engagement-type { display: inline-block; padding: .1rem .45rem; border-radius: 3px; font-size: .65rem; font-weight: 500; } +.engagement-type.assessment { background: var(--cyan-bg); color: var(--cyan); } +.engagement-type.architecture { background: var(--purple-bg); color: var(--purple); } +.engagement-type.implementation { background: var(--green-bg); color: var(--green); } +.engagement-type.monitoring { background: var(--orange-bg); color: var(--orange); } +.engagement-type.consulting { background: var(--pink-bg); color: var(--pink); } +.engagement-type.training { background: var(--red-bg); color: var(--red); } + +/* Tables */ +.data-table { width: 100%; border-collapse: collapse; } +.data-table th { + text-align: left; padding: .5rem .75rem; font-size: .7rem; color: var(--text-muted); + text-transform: uppercase; letter-spacing: .3px; + border-bottom: 1px solid var(--border); background: var(--bg-card); +} +.data-table td { padding: .5rem .75rem; border-bottom: 1px solid var(--border-light); font-size: .8rem; } +.data-table tr:hover td { background: var(--border-light); } + +/* Engagement list */ +.engagement-list { display: flex; flex-direction: column; gap: .35rem; } +.engagement-row { + display: flex; align-items: center; gap: .75rem; + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); + padding: .6rem .75rem; text-decoration: none; color: inherit; transition: all .15s; +} +.engagement-row:hover { border-color: var(--text-dim); } +.engagement-title { flex: 1; font-weight: 500; color: var(--text); font-size: .85rem; } +.engagement-client { color: var(--text-muted); font-size: .8rem; } +.engagement-date { color: var(--text-dim); font-size: .75rem; } + +/* Page header */ +.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem; } +.page-header h1 { font-size: 1.3rem; color: var(--text); font-weight: 600; } +.header-actions { display: flex; gap: .5rem; align-items: center; } +.back-link { color: var(--text-muted); text-decoration: none; font-size: .8rem; display: inline-block; margin-bottom: .35rem; transition: all .12s; } +.back-link:hover { color: var(--accent); padding-left: 2px; } +.back-link svg { width: 14px; height: 14px; vertical-align: middle; margin-right: 2px; } +.breadcrumb { display: flex; align-items: center; gap: .35rem; font-size: .75rem; color: var(--text-dim); margin-bottom: .75rem; flex-wrap: wrap; } +.breadcrumb a { color: var(--text-muted); text-decoration: none; transition: color .12s; } +.breadcrumb a:hover { color: var(--accent); } +.breadcrumb .sep { color: var(--border); font-size: .6rem; } +.breadcrumb .current { color: var(--text); font-weight: 500; } + +/* Detail grid */ +.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; margin-bottom: 1.5rem; } +@media (max-width: 640px) { .detail-grid { grid-template-columns: 1fr; } } +.detail-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; } +.detail-card h3 { font-size: .75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .3px; margin-bottom: .75rem; } + +/* Forms */ +.form-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; } +@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } } +.form-actions { display: flex; gap: .5rem; margin-top: 1rem; } + +input, textarea, select { + width: 100%; padding: .45rem .6rem; background: var(--bg); + border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); font-size: .85rem; font-family: inherit; transition: border-color .15s; +} +input:focus, textarea:focus, select:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(88,166,255,.15); +} +select { cursor: pointer; } +select option { background: var(--bg); color: var(--text); } + +/* Alerts */ +.alert { padding: .5rem .75rem; border-radius: var(--radius); margin-bottom: .75rem; font-size: .8rem; } +.alert-error { background: var(--red-bg); border: 1px solid var(--red); color: var(--red); } + +/* Advice */ +.advice-list { display: flex; flex-direction: column; gap: .35rem; } +.advice-card { + display: flex; align-items: flex-start; gap: .5rem; + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); + padding: .6rem .75rem; border-left: 3px solid var(--border); +} +.advice-card.critical { border-left-color: var(--red); } +.advice-card.high { border-left-color: var(--orange); } +.advice-card.medium { border-left-color: var(--accent); } +.advice-card.low { border-left-color: var(--green); } +.advice-prio { + font-size: .55rem; font-weight: 600; text-transform: uppercase; + padding: .1rem .35rem; border-radius: 3px; white-space: nowrap; +} +.advice-prio.critical { background: var(--red-bg); color: var(--red); } +.advice-prio.high { background: var(--orange-bg); color: var(--orange); } +.advice-prio.medium { background: var(--cyan-bg); color: var(--cyan); } +.advice-prio.low { background: var(--green-bg); color: var(--green); } +.advice-body { flex: 1; } +.advice-body p { font-size: .8rem; color: var(--text); } + +/* Tasks */ +.task-list { display: flex; flex-direction: column; gap: .35rem; } +.task-item { + display: flex; align-items: center; gap: .5rem; + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); + padding: .5rem .75rem; +} +.task-item.done { opacity: .5; } +.task-item.done .task-title { text-decoration: line-through; } +.task-status-form select { width: auto; padding: .2rem .4rem; font-size: .7rem; } +.task-body { flex: 1; display: flex; flex-direction: column; gap: .1rem; } +.task-title { font-size: .85rem; color: var(--text); font-weight: 500; } +.task-desc { font-size: .75rem; color: var(--text-muted); } +.task-meta { font-size: .7rem; color: var(--accent); } +.task-prio { font-size: .55rem; font-weight: 600; text-transform: uppercase; padding: .1rem .35rem; border-radius: 3px; } +.task-prio.critical { background: var(--red-bg); color: var(--red); } +.task-prio.high { background: var(--orange-bg); color: var(--orange); } +.task-prio.medium { background: var(--cyan-bg); color: var(--cyan); } +.task-prio.low { background: var(--green-bg); color: var(--green); } +.task-due { font-size: .7rem; color: var(--text-muted); } +.task-due.overdue { color: var(--red); font-weight: 500; } +.task-form { margin-bottom: .75rem; } +.task-form-grid { display: flex; gap: .35rem; align-items: flex-end; flex-wrap: wrap; } +.task-form-grid input, .task-form-grid select { width: auto; min-width: 120px; flex: 1; } +.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .75rem; } + +/* Assessment list */ +.assess-list { display: flex; flex-direction: column; gap: .35rem; } +.assess-item { display: flex; align-items: center; gap: .75rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .6rem .75rem; } +.assess-date { font-size: .8rem; color: var(--text-muted); } +.assess-id { font-weight: 500; color: var(--text); flex: 1; font-size: .85rem; } + +/* Empty state */ +.empty-state { text-align: center; padding: 2.5rem 1rem; color: var(--text-muted); } +.empty-state p { font-size: .85rem; } + +/* Status form */ +.status-form { display: flex; gap: .35rem; align-items: center; } +.status-form select { width: auto; } + +/* Buttons */ +.btn { + padding: .45rem .9rem; border: none; border-radius: var(--radius); + font-size: .8rem; font-weight: 500; cursor: pointer; transition: all .15s; + text-decoration: none; display: inline-block; text-align: center; line-height: 1.4; +} +.btn-primary { background: #238636; color: #fff; } +.btn-primary:hover { background: #2ea043; } +.btn-secondary { background: var(--border-light); color: var(--text-muted); border: 1px solid var(--border); } +.btn-secondary:hover { background: var(--border); color: var(--text); } +.btn-accent { background: var(--accent); color: #fff; } +.btn-accent:hover { background: var(--accent-hover); } +.btn-full { width: 100%; } +.btn-small { + padding: .2rem .5rem; border: none; border-radius: 4px; + font-size: .7rem; cursor: pointer; background: var(--border-light); color: var(--text-muted); + text-decoration: none; display: inline-block; +} +.btn-small:hover { background: var(--border); color: var(--text); } +.btn-small.danger { background: var(--red-bg); color: var(--red); } +.btn-small.danger:hover { background: #4d1a1c; } + +/* Login */ +.login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 1.5rem; } +.login-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 2rem; width: 100%; max-width: 360px; } +.login-logo { text-align: center; margin-bottom: 1.5rem; } +.login-logo h1 { font-size: 1.25rem; color: var(--text); } + +/* Header */ +.header { margin-bottom: 1.5rem; } +.logo { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; } +.logo-icon { font-size: 1.5rem; font-weight: 800; color: var(--accent); } +.logo h1 { font-size: 1.25rem; font-weight: 600; color: var(--text); } +.subtitle { font-size: .8rem; color: var(--text-muted); } + +/* Progress bar */ +.progress-bar { width: 100%; height: 3px; background: var(--border-light); border-radius: 2px; overflow: hidden; margin-bottom: 1rem; } +.progress-fill { height: 100%; background: var(--accent); transition: width .3s; border-radius: 2px; } + +/* Assessment steps */ +.step { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; } +.step-header { margin-bottom: 1rem; } +.step-number { font-size: .7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; } +.step-header h2 { font-size: 1.1rem; font-weight: 600; color: var(--text); margin-top: .15rem; } +.fields { display: flex; flex-direction: column; gap: .75rem; } +.field label { display: block; font-size: .8rem; font-weight: 500; color: var(--text-muted); margin-bottom: .25rem; } +.step-actions { display: flex; justify-content: space-between; margin-top: 1.5rem; } + +/* Report */ +.report-header { text-align: center; padding: 1.5rem 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; } +.report-org { margin-bottom: 1rem; } +.report-org h1 { font-size: 1.5rem; color: var(--text); } +.report-title-block h2 { font-size: 1.3rem; color: var(--text); margin-bottom: .35rem; } +.report-client, .report-date { color: var(--text-muted); font-size: .85rem; } +.section { margin-bottom: 1.5rem; } +.section-title { + font-size: 1rem; font-weight: 600; color: var(--text); + margin-bottom: .75rem; padding-bottom: .35rem; + border-bottom: 2px solid var(--accent); display: inline-block; +} +.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; } +.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; } +.info-table { width: 100%; border-collapse: collapse; } +.info-table td { padding: .4rem .6rem; border-bottom: 1px solid var(--border-light); font-size: .8rem; } +.info-table tr:last-child td { border-bottom: none; } +.info-table td:first-child { width: 35%; color: var(--text-muted); } +.badge { display: inline-block; padding: .15rem .6rem; border-radius: 999px; font-size: .7rem; font-weight: 500; } +.badge.Starter { background: var(--green-bg); color: var(--green); } +.badge.Standard { background: var(--cyan-bg); color: var(--cyan); } +.badge.Advanced { background: var(--purple-bg); color: var(--purple); } +.badge.Enterprise { background: var(--red-bg); color: var(--red); } +.diagram { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; overflow-x: auto; } +.ascii-diagram { font-family: 'Courier New', monospace; font-size: .65rem; line-height: 1.3; color: var(--text-muted); } +.phase { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; margin-bottom: .75rem; } +.phase h4 { color: var(--purple); margin-bottom: .5rem; font-size: .9rem; } +.phase ul { list-style: none; } +.phase li { padding: .25rem 0; color: var(--text); font-size: .85rem; } +.phase li::before { content: '→ '; color: var(--accent); } +.tech-stack { display: flex; flex-direction: column; gap: .35rem; } +.tech-item { display: flex; justify-content: space-between; align-items: center; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .75rem; } +.tech-label { color: var(--text-muted); font-size: .8rem; } +.tech-val { color: var(--text); font-weight: 500; font-size: .85rem; text-align: right; } +.report-footer { text-align: center; padding: 1.5rem 0; border-top: 1px solid var(--border); margin-top: 1.5rem; } +.report-footer p { color: var(--text-muted); font-size: .75rem; margin-bottom: .75rem; } + +.link { color: var(--accent); text-decoration: none; } +.link:hover { text-decoration: underline; } + +/* Rack */ +.rack-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: .75rem; } +.rack-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; text-decoration: none; color: inherit; display: flex; gap: .75rem; transition: all .15s; } +.rack-card:hover { border-color: var(--text-dim); } +.rack-mini { width: 20px; display: flex; flex-direction: column; gap: 2px; } +.rack-mini-u { width: 100%; height: 5px; background: var(--border); border-radius: 1px; } +.rack-mini-u.filled { background: var(--accent); } +.rack-card-body h4 { font-size: .9rem; color: var(--text); } +.rack-layout { display: flex; gap: 1.5rem; align-items: flex-start; } +@media (max-width: 768px) { .rack-layout { flex-direction: column; } } +.rack-view { flex-shrink: 0; width: 240px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.rack-header { background: var(--bg-nav); padding: .35rem .75rem; font-size: .75rem; color: var(--text-muted); text-align: center; border-bottom: 1px solid var(--border); } +.rack-body { display: flex; flex-direction: column; } +.rack-empty { display: flex; align-items: center; padding: 0 .4rem; height: 28px; border-bottom: 1px solid var(--border-light); background: var(--bg); } +.rack-u { width: 22px; font-size: .55rem; color: var(--text-dim); flex-shrink: 0; text-align: right; margin-right: .35rem; } +.rack-device { display: flex; align-items: center; padding: 0 .4rem; gap: .25rem; border-bottom: 1px solid var(--border-light); cursor: pointer; transition: all .15s; font-size: .6rem; overflow: hidden; } +.rack-device:hover { filter: brightness(1.15); } +.rack-device.server { background: linear-gradient(90deg, var(--cyan-bg), var(--bg)); border-left: 2px solid var(--accent); } +.rack-device.switch { background: linear-gradient(90deg, var(--purple-bg), var(--bg)); border-left: 2px solid var(--purple); } +.rack-device.storage { background: linear-gradient(90deg, var(--green-bg), var(--bg)); border-left: 2px solid var(--green); } +.rack-device.firewall { background: linear-gradient(90deg, var(--red-bg), var(--bg)); border-left: 2px solid var(--red); } +.rack-device.patch_panel { background: linear-gradient(90deg, var(--orange-bg), var(--bg)); border-left: 2px solid var(--orange); } +.rack-device.pdu { background: linear-gradient(90deg, var(--cyan-bg), var(--bg)); border-left: 2px solid var(--cyan); } +.rack-device.ups { background: linear-gradient(90deg, var(--purple-bg), var(--bg)); border-left: 2px solid var(--purple); } +.rack-device-name { flex: 1; font-weight: 500; color: var(--text); white-space: nowrap; font-size: .65rem; } + +/* Modal */ +.modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.7); z-index: 1000; display: flex; align-items: center; justify-content: center; } +.modal-content { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.25rem; width: 90%; max-width: 560px; max-height: 80vh; overflow-y: auto; } +.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .75rem; } +.modal-header h3 { color: var(--text); font-size: 1rem; } + +/* Network */ +.device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: .5rem; } +.device-card { display: flex; align-items: center; gap: .5rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; text-decoration: none; color: inherit; transition: all .15s; } +.device-card:hover { border-color: var(--text-dim); } +.device-icon { width: 32px; height: 32px; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; } +.device-card.switch .device-icon { background: var(--purple-bg); color: var(--purple); } +.device-card.router .device-icon { background: var(--cyan-bg); color: var(--cyan); } +.device-card.firewall .device-icon { background: var(--red-bg); color: var(--red); } +.device-info { flex: 1; display: flex; flex-direction: column; gap: .1rem; } +.device-info strong { font-size: .85rem; color: var(--text); } + +.media-badge { display: inline-block; padding: .1rem .35rem; border-radius: 3px; font-size: .65rem; font-weight: 500; } +.media-badge.copper { background: var(--orange-bg); color: var(--orange); } +.media-badge.sfp, .media-badge.sfp\+ { background: var(--cyan-bg); color: var(--cyan); } +.media-badge.qsfp { background: var(--purple-bg); color: var(--purple); } +.media-badge.dac { background: var(--green-bg); color: var(--green); } +.media-badge.fiber { background: var(--pink-bg); color: var(--pink); } + +.conn-list { display: flex; flex-direction: column; gap: .35rem; } +.conn-item { background: var(--bg); border: 1px solid var(--border-light); border-radius: var(--radius); padding: .5rem .6rem; } +.conn-name { font-size: .8rem; color: var(--text); font-weight: 500; } +.conn-path { font-size: .7rem; color: var(--text-muted); } +.conn-meta { font-size: .65rem; color: var(--accent); } + +/* Diagram grid */ +.diagram-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: .75rem; } +.diagram-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; text-decoration: none; color: inherit; display: flex; gap: .75rem; transition: all .15s; position: relative; } +.diagram-card:hover { border-color: var(--text-dim); } +.diagram-card .diagram-card-body { padding-bottom: 1.5rem; } +.diagram-card-type { width: 36px; height: 36px; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .7rem; flex-shrink: 0; } +.diagram-card-type.data_architecture { background: var(--cyan-bg); color: var(--cyan); } +.diagram-card-type.network_topology { background: var(--purple-bg); color: var(--purple); } +.diagram-card-type.system_architecture { background: var(--green-bg); color: var(--green); } +.diagram-card-body h4 { font-size: .85rem; color: var(--text); } + +code { background: var(--border-light); padding: .1rem .3rem; border-radius: 3px; font-size: .75rem; color: var(--accent); } + +@media print { + body { background: #fff; color: #000; } + .navbar { display: none; } + .step, .card, .phase, .tech-item, .diagram, .detail-card, .client-card, .advice-card, .engagement-row, .task-item, .assess-item, .stat-card, .data-table { background: #f5f5f5; border-color: #ddd; } + .report-header { border-bottom-color: #ddd; } + .section-title { border-bottom-color: #58a6ff; } + .logo h1, .step-header h2, .report-title-block h2, .report-org h1, .phase h4, .tech-val, .detail-card h3 { color: #000; } + .btn, .nav-links, .header-actions, .btn-small { display: none; } + .info-table td { border-bottom-color: #ddd; } + .ascii-diagram { color: #333; } +} + +/* 🔥 PREMIUM ANIMATIONS */ +@keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:.7 } } +@keyframes pulse-glow { 0%,100% { box-shadow:0 0 0 0 rgba(88,166,255,.15) } 50% { box-shadow:0 0 15px 4px rgba(88,166,255,.08) } } +@keyframes shimmer { 0% { background-position:-200% 0 } 100% { background-position:200% 0 } } +@keyframes fadeInUp { from { opacity:0; transform:translateY(12px) } to { opacity:1; transform:translateY(0) } } +@keyframes fadeIn { from { opacity:0 } to { opacity:1 } } +@keyframes slideInLeft { from { opacity:0; transform:translateX(-8px) } to { opacity:1; transform:translateX(0) } } +@keyframes gradientShift { 0% { background-position:0% 50% } 50% { background-position:100% 50% } 100% { background-position:0% 50% } } +@keyframes float { 0%,100% { transform:translateY(0) } 50% { transform:translateY(-4px) } } +@keyframes borderGlow { 0%,100% { border-color:var(--border) } 50% { border-color:rgba(88,166,255,.3) } } + +.pulse { animation: pulse 2s ease-in-out infinite } +.pulse-glow { animation: pulse-glow 3s ease-in-out infinite } +.fade-in { animation: fadeIn .4s ease-out } +.fade-in-up { animation: fadeInUp .5s ease-out } +.slide-in { animation: slideInLeft .4s ease-out } + +.gradient-text { + background: linear-gradient(135deg, var(--accent), var(--purple), var(--accent)); + background-size: 200% auto; + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; + animation: gradientShift 4s ease infinite; +} +.gradient-border { + position:relative; + background:var(--bg-card); border:1px solid transparent; + background-clip:padding-box; +} +.gradient-border::before { + content:''; position:absolute; inset:0; margin:-1px; border-radius:inherit; + background:linear-gradient(135deg,var(--accent),transparent 40%,var(--purple) 60%,transparent); + z-index:-1; opacity:.3; transition:opacity .3s; +} +.gradient-border:hover::before { opacity:.6 } + +/* Card premium hover */ +.card-hover { + transition: all .25s cubic-bezier(.4,0,.2,1); +} +.card-hover:hover { + transform: translateY(-2px); + box-shadow: 0 8px 30px rgba(0,0,0,.12), 0 0 0 1px var(--accent); + border-color: var(--accent); +} + +/* KPI pulse dots */ +.kpi-dot { + width:8px;height:8px;border-radius:50%;display:inline-block; + background:var(--green);animation:pulse-glow 2s ease-in-out infinite; +} + +/* Shimmer loading */ +.shimmer { + background:linear-gradient(90deg,var(--border-light) 25%,var(--border) 50%,var(--border-light) 75%); + background-size:200% 100%; animation:shimmer 2s infinite; + border-radius:var(--radius); height:16px; +} + +/* Theme toggle */ +.theme-toggle { + width:44px;height:24px;border-radius:12px;background:var(--border); + cursor:pointer;position:relative;transition:all .3s;border:none;flex-shrink:0; +} +.theme-toggle::after { + content:'';position:absolute;top:2px;left:2px;width:20px;height:20px; + border-radius:50%;background:var(--accent);transition:all .3s; + box-shadow:0 1px 3px rgba(0,0,0,.2); +} +.theme-toggle.light { background:var(--accent) } +.theme-toggle.light::after { left:22px; background:#fff } + +/* Light mode overrides */ +body.light-mode { + --bg: #f8f9fa; --bg-card: #ffffff; --bg-nav: #ffffff; + --border: #e0e4e8; --border-light: #f0f2f5; + --text: #1a1d21; --text-muted: #5a5f66; --text-dim: #8b9098; + --accent: #0969da; --accent-hover: #0550ae; + --green: #1a7f37; --green-bg: #dafbe1; + --red: #cf222e; --red-bg: #ffebe9; + --orange: #953800; --orange-bg: #fff1e5; + --purple: #8250df; --purple-bg: #fbefff; + --cyan: #0550ae; --cyan-bg: #ddf4ff; + --pink: #bf3989; --pink-bg: #ffeff7; +} +body.light-mode .navbar { box-shadow: 0 1px 3px rgba(0,0,0,.04), 0 1px 6px rgba(0,0,0,.03) } +body.light-mode .sidebar { box-shadow: 1px 0 3px rgba(0,0,0,.03) } +body.light-mode .card-hover:hover { box-shadow: 0 4px 20px rgba(0,0,0,.06), 0 0 0 1px var(--accent) } +body.light-mode .kpi-card { box-shadow: 0 1px 2px rgba(0,0,0,.02) } +body.light-mode .kpi-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06) } + +/* Smooth transitions for theme switch */ +body, .navbar, .sidebar, .card, .kpi-card, .client-card, .data-table, input, select, textarea, .btn { + transition: background-color .3s ease, color .3s ease, border-color .3s ease, box-shadow .3s ease; +} diff --git a/style.css.backup b/style.css.backup new file mode 100644 index 0000000..4431854 --- /dev/null +++ b/style.css.backup @@ -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; +} diff --git a/themes.js b/themes.js new file mode 100644 index 0000000..8b96470 --- /dev/null +++ b/themes.js @@ -0,0 +1,219 @@ +const THEME_PRESETS = { + 'gitea-dark': { + label: 'Gitea Dark', mode: 'dark', + bg: '#0d1117', bg_card: '#161b22', bg_nav: '#161b22', + text: '#e6edf3', text_muted: '#8b949e', text_dim: '#6e7681', + border: '#30363d', border_light: '#21262d', + 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' + }, + 'dracula-dark': { + label: 'Dracula Dark', mode: 'dark', + bg: '#1e1f2b', bg_card: '#282a36', bg_nav: '#21222c', + text: '#f8f8f2', text_muted: '#bd93f9', text_dim: '#6272a4', + border: '#44475a', border_light: '#383a4a', + accent: '#bd93f9', accent_hover: '#caa9fa', + green: '#50fa7b', green_bg: '#1a3a2a', + red: '#ff5555', red_bg: '#3d1a1a', + orange: '#ffb86c', orange_bg: '#3d2e1a', + purple: '#bd93f9', purple_bg: '#2a1a3d', + cyan: '#8be9fd', cyan_bg: '#1a2a3d', + pink: '#ff79c6', pink_bg: '#3d1a2e' + }, + 'nord-dark': { + label: 'Nord Dark', mode: 'dark', + bg: '#2e3440', bg_card: '#3b4252', bg_nav: '#353b49', + text: '#eceff4', text_muted: '#81a1c1', text_dim: '#616e88', + border: '#4c566a', border_light: '#434c5e', + accent: '#88c0d0', accent_hover: '#8fbcbb', + green: '#a3be8c', green_bg: '#1a2e1a', + red: '#bf616a', red_bg: '#2e1a1a', + orange: '#d08770', orange_bg: '#2e221a', + purple: '#b48ead', purple_bg: '#221a2e', + cyan: '#88c0d0', cyan_bg: '#1a222e', + pink: '#b48ead', pink_bg: '#2e1a22' + }, + 'monokai-dark': { + label: 'Monokai Dark', mode: 'dark', + bg: '#272822', bg_card: '#3e3d32', bg_nav: '#2d2d26', + text: '#f8f8f2', text_muted: '#a6e22e', text_dim: '#75715e', + border: '#49483e', border_light: '#3b3a30', + accent: '#f92672', accent_hover: '#fd5ff0', + green: '#a6e22e', green_bg: '#1a3a0a', + red: '#f92672', red_bg: '#3d0a1a', + orange: '#fd971f', orange_bg: '#3d220a', + purple: '#ae81ff', purple_bg: '#1a0a3d', + cyan: '#66d9ef', cyan_bg: '#0a2a3d', + pink: '#f92672', pink_bg: '#3d0a1a' + }, + 'cyberpunk': { + label: 'Cyberpunk', mode: 'dark', + bg: '#0a0a1a', bg_card: '#12122a', bg_nav: '#0e0e22', + text: '#e0e0ff', text_muted: '#00ff41', text_dim: '#5555aa', + border: '#1a1a4a', border_light: '#15153a', + accent: '#00ff41', accent_hover: '#33ff66', + green: '#00ff41', green_bg: '#002a0a', + red: '#ff0040', red_bg: '#2a000a', + orange: '#ff6600', orange_bg: '#2a1500', + purple: '#aa00ff', purple_bg: '#15002a', + cyan: '#00ffff', cyan_bg: '#002a2a', + pink: '#ff00aa', pink_bg: '#2a0015' + }, + 'midnight': { + label: 'Midnight Indigo', mode: 'dark', + bg: '#0a0e1a', bg_card: '#111827', bg_nav: '#0d1422', + text: '#e2e8f0', text_muted: '#818cf8', text_dim: '#4b5563', + border: '#1e293b', border_light: '#172033', + accent: '#6366f1', accent_hover: '#818cf8', + green: '#22c55e', green_bg: '#052e16', + red: '#ef4444', red_bg: '#2d0a0a', + orange: '#f59e0b', orange_bg: '#2d2005', + purple: '#a855f7', purple_bg: '#1a052d', + cyan: '#06b6d4', cyan_bg: '#05202d', + pink: '#ec4899', pink_bg: '#2d0520' + }, + 'github-light': { + label: 'GitHub Light', mode: 'light', + bg: '#ffffff', bg_card: '#f6f8fa', bg_nav: '#ffffff', + text: '#1f2328', text_muted: '#656d76', text_dim: '#8b949e', + border: '#d0d7de', border_light: '#e8ecef', + 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' + }, + 'solarized-light': { + label: 'Solarized Light', mode: 'light', + bg: '#fdf6e3', bg_card: '#eee8d5', bg_nav: '#f5efdb', + text: '#657b83', text_muted: '#839496', text_dim: '#93a1a1', + border: '#d6cfb8', border_light: '#e8e1cc', + accent: '#268bd2', accent_hover: '#2aa198', + green: '#859900', green_bg: '#e8e8c8', + red: '#dc322f', red_bg: '#f5d0cf', + orange: '#cb4b16', orange_bg: '#f0d0b8', + purple: '#6c71c4', purple_bg: '#e0d8f0', + cyan: '#2aa198', cyan_bg: '#c8e8e0', + pink: '#d33682', pink_bg: '#f0c8d8' + }, + 'paper-light': { + label: 'Paper Light', mode: 'light', + bg: '#fafafa', bg_card: '#ffffff', bg_nav: '#f5f5f5', + text: '#37474f', text_muted: '#78909c', text_dim: '#90a4ae', + border: '#cfd8dc', border_light: '#eceff1', + accent: '#e91e63', accent_hover: '#c2185b', + green: '#4caf50', green_bg: '#e8f5e9', + red: '#f44336', red_bg: '#ffebee', + orange: '#ff9800', orange_bg: '#fff3e0', + purple: '#9c27b0', purple_bg: '#f3e5f5', + cyan: '#00bcd4', cyan_bg: '#e0f7fa', + pink: '#e91e63', pink_bg: '#fce4ec' + }, + 'ocean-deep': { + label: 'Ocean Deep', mode: 'dark', + bg: '#070b14', bg_card: '#0d1524', bg_nav: '#0a1120', + text: '#dce6f0', text_muted: '#6b8fbf', text_dim: '#3d5a7a', + border: '#1a2d4a', border_light: '#13203a', + accent: '#38bdf8', accent_hover: '#7dd3fc', + green: '#34d399', green_bg: '#0a2a1a', + red: '#f87171', red_bg: '#2a0f0f', + orange: '#fb923c', orange_bg: '#2a1a0a', + purple: '#818cf8', purple_bg: '#12152a', + cyan: '#22d3ee', cyan_bg: '#0a1f2a', + pink: '#f472b6', pink_bg: '#2a0a1f' + }, + 'blue-steel': { + label: 'Blue Steel', mode: 'dark', + bg: '#0f141e', bg_card: '#1a2332', bg_nav: '#161e2d', + text: '#e8edf5', text_muted: '#7a9cc6', text_dim: '#4d6a8f', + border: '#2a3a52', border_light: '#1f2d42', + accent: '#60a5fa', accent_hover: '#93c5fd', + green: '#4ade80', green_bg: '#0a1f14', + red: '#f87171', red_bg: '#2a0f0f', + orange: '#fb923c', orange_bg: '#2a1a0a', + purple: '#a78bfa', purple_bg: '#1a102a', + cyan: '#67e8f9', cyan_bg: '#0a202a', + pink: '#f472b6', pink_bg: '#2a0a1f' + }, + 'twilight-blue': { + label: 'Twilight Blue', mode: 'dark', + bg: '#080c1a', bg_card: '#101830', bg_nav: '#0c1428', + text: '#e0e6f5', text_muted: '#6d8fc0', text_dim: '#3d5580', + border: '#1c2a50', border_light: '#141f40', + accent: '#5699ff', accent_hover: '#7ab4ff', + green: '#2dd4a0', green_bg: '#082018', + red: '#ff6b6b', red_bg: '#2a0e0e', + orange: '#ff9f43', orange_bg: '#2a1a08', + purple: '#9b7fff', purple_bg: '#140f2a', + cyan: '#00d4ff', cyan_bg: '#081a2a', + pink: '#ff79b0', pink_bg: '#2a081a' + }, + 'mek-neon-blue': { + label: 'Mek Neon Blue', mode: 'dark', + bg: '#0a1628', bg_card: '#0f1f3a', bg_nav: '#0c1a30', + text: '#e8f4ff', text_muted: '#7eb8d4', text_dim: '#4a7a99', + border: 'rgba(0,212,255,0.15)', border_light: 'rgba(0,212,255,0.08)', + accent: '#00d4ff', accent_hover: '#4de8ff', + green: '#00ff88', green_bg: '#0a2a1a', + red: '#ff6b8a', red_bg: '#2a0a14', + orange: '#ffb347', orange_bg: '#2a1a0a', + purple: '#a855f7', purple_bg: '#1a0a2a', + cyan: '#00d4ff', cyan_bg: '#0a1a2a', + pink: '#ff6ec7', pink_bg: '#2a0a1f' + }, + custom: { + label: 'Aangepast', mode: 'dark', + bg: '#0a1628', bg_card: '#0f1f3a', bg_nav: '#0c1a30', + text: '#e8f4ff', text_muted: '#7eb8d4', text_dim: '#4a7a99', + border: 'rgba(0,212,255,0.15)', border_light: 'rgba(0,212,255,0.08)', + accent: '#00d4ff', accent_hover: '#4de8ff', + green: '#00ff88', green_bg: '#0a2a1a', + red: '#ff6b8a', red_bg: '#2a0a14', + orange: '#ffb347', orange_bg: '#2a1a0a', + purple: '#a855f7', purple_bg: '#1a0a2a', + cyan: '#00d4ff', cyan_bg: '#0a1a2a', + pink: '#ff6ec7', pink_bg: '#2a0a1f' + } +}; + +/** Hoofdkleuren — zichtbaar in instellingen met kleurkiezer */ +const THEME_MAIN_COLORS = [ + { key: 'bg', label: 'Achtergrond', hint: 'Hoofdkleur van de hele app' }, + { key: 'bg_card', label: 'Kaarten & panelen', hint: 'Dashboard-kaarten, secties' }, + { key: 'bg_nav', label: 'Navigatie & sidebar', hint: 'Bovenbalk en zijmenu' }, + { key: 'accent', label: 'Accentkleur', hint: 'Neon highlight, knoppen, links' }, + { key: 'text', label: 'Tekst', hint: 'Hoofdtekst' }, + { key: 'text_muted', label: 'Tekst secundair', hint: 'Labels, subtitels' }, +]; + +const THEME_COLOR_LABELS = { + bg: 'Achtergrond', bg_card: 'Kaarten', bg_nav: 'Navigatie', + text: 'Tekst', text_muted: 'Tekst secundair', text_dim: 'Tekst gedimd', + border: 'Randen', border_light: 'Randen licht', + accent: 'Accent', accent_hover: 'Accent hover', + green: 'Groen', green_bg: 'Groen achtergrond', + red: 'Rood', red_bg: 'Rood achtergrond', + orange: 'Oranje', orange_bg: 'Oranje achtergrond', + purple: 'Paars', purple_bg: 'Paars achtergrond', + cyan: 'Cyaan', cyan_bg: 'Cyaan achtergrond', + pink: 'Roze', pink_bg: 'Roze achtergrond', +}; + +const THEME_CSS_KEYS = [ + 'bg', 'bg_card', 'bg_nav', + 'text', 'text_muted', 'text_dim', + 'border', 'border_light', + 'accent', 'accent_hover', + 'green', 'green_bg', 'red', 'red_bg', + 'orange', 'orange_bg', 'purple', 'purple_bg', + 'cyan', 'cyan_bg', 'pink', 'pink_bg' +]; + +module.exports = { THEME_PRESETS, THEME_CSS_KEYS, THEME_MAIN_COLORS, THEME_COLOR_LABELS }; diff --git a/views/404.ejs b/views/404.ejs new file mode 100644 index 0000000..87325c5 --- /dev/null +++ b/views/404.ejs @@ -0,0 +1,25 @@ + + + + + +Pagina niet gevonden — Mek-Tech + + + + + + + +
+

404

+

Deze pagina bestaat niet of is verplaatst.

+Terug naar dashboard +
+ + + diff --git a/views/ai-providers.ejs b/views/ai-providers.ejs new file mode 100644 index 0000000..c46f146 --- /dev/null +++ b/views/ai-providers.ejs @@ -0,0 +1,77 @@ + + +<%- include('partials/head', { pageTitle: 'AI Providers' }) %> + +<%- include('partials/navbar') %> +
+ <%- include('partials/sidebar') %> +
+
+

AI Providers

+

Configureer OpenRouter, OpenAI, Anthropic, DeepSeek, Groq, Ollama en custom providers

+
+ + <% if (saved) { %>
Opgeslagen — config gesynchroniseerd naar alle Python services
<% } %> + <% if (tested) { %>
Test OK: <%= tested %>
<% } %> + <% if (error) { %>
<%= error %>
<% } %> + +
+ <% providers.forEach(function(p) { %> +
+
+
+
<%= p.name %>
+
<%= p.provider_type %>
+
+ +
+
Model: <%= p.default_model || '—' %>
+
<%= p.base_url %>
+
+ <% if (p.is_default) { %>Default<% } %> +
+
+
+
+ <% }); %> +
+ +
+
+

Nieuwe provider toevoegen

+
+
+
+
+ +
+
+
+
+
+
+ + + +
+
+
+
+
+ +<%- include('partials/scripts') %> + + diff --git a/views/arch-editor.ejs b/views/arch-editor.ejs new file mode 100644 index 0000000..676d5b9 --- /dev/null +++ b/views/arch-editor.ejs @@ -0,0 +1,942 @@ + + + + + +<%= diagram ? diagram.name : 'Nieuw Diagram' %> — Mek-Tech Architectuur + + + + + + + + + +
+ + Mek-Tech + Architectuur Designerv2 + + +
+ +<% if (typeof rack !== 'undefined' && rack) { %> +
+
+ Rack: <%= rack.name %> · <%= rack.client_name %> + <%= (typeof rackDevices !== 'undefined' ? rackDevices.length : 0) %> devices gekoppeld +
+ +
+<% } %> + +
+
+
+

Componenten

+ +
+ + + + + + + + + +
+ <% if (typeof rack !== 'undefined' && rack) { %> +
+
Flow filters
+
+ + + + + +
+ + +
+ <% } %> +
+
+
Data Bronnen
+
DB Database
+
API API Service
+
IoT IoT Sensor
+
ST Streaming
+
SAP SAP HANA
+
OR Oracle DB
+ +
Ingest & Pipeline
+
K Kafka
+
AB Airbyte
+
SF Spark/Flink
+
CDC Debezium/CDC
+
NF NiFi
+
DMS AWS DMS
+ +
Opslag & Storage
+
M MinIO / S3
+
IC Iceberg
+
DW Data Warehouse
+
LH Data Lakehouse
+
HN HPE Nimble
+
3P HPE 3PAR
+
PS Dell PowerStore
+
PSC Dell PowerScale
+
NA NetApp AFF/FAS
+
PU Pure Storage
+ +
Compute / Servers
+
HP HPE ProLiant DL
+
HPM HPE ProLiant ML
+
SYN HPE Synergy
+
DE Dell PowerEdge
+
MX Dell PowerEdge MX
+
LE Lenovo ThinkSystem
+
TA Lenovo ThinkAgile
+
SM Supermicro SYS
+
UCS Cisco UCS
+
VM VMware vSphere
+ +
Netwerk
+
CAT Cisco Catalyst
+
NX Cisco Nexus
+
JEX Juniper EX
+
JQ Juniper QFX
+
SRX Juniper SRX
+
AR Arista EOS
+
FG Fortinet FortiGate
+
F5 F5 BIG-IP
+
PA Palo Alto
+ +
Compute / Data Platform
+
SP Apache Spark
+
TR Trino / Presto
+
K8 Kubernetes
+
DBT dbt
+
HD Hadoop Cluster
+
H SAP HANA Platform
+ +
AI / ML
+
O Ollama / vLLM
+
MF MLflow
+
VS Vector Store
+
MS Model Serving
+
NV NVIDIA DGX
+ +
Microservices & Apps
+
NJS Node.js App
+
PY Python App
+
JV Java / Spring Boot
+
GO Golang App
+
DN .NET App
+
RE React / Frontend
+
μS Microservice
+
CT Container / Docker
+
λ Serverless Function
+ +
API & Gateway
+
GW API Gateway
+
REST REST API
+
GQL GraphQL API
+
gRPC gRPC Service
+
IN K8s Ingress
+
RP Reverse Proxy
+
SM Service Mesh
+ +
Messaging & Queue
+
KQ Kafka / Confluent
+
RM RabbitMQ
+
N NATS / Pulsar
+
RD Redis Pub/Sub
+
MQ MQTT Broker
+
SQS AWS SQS / SNS
+ +
Databases & Cache
+
PG PostgreSQL
+
MY MySQL / MariaDB
+
MD MongoDB
+
RE Redis Cache
+
ES Elasticsearch
+
CA Cassandra / Scylla
+
CH ClickHouse
+
DY DynamoDB / Cosmos
+ +
CI/CD & DevOps
+
GH GitHub Actions
+
GL GitLab CI/CD
+
JK Jenkins
+
AR ArgoCD
+
TF Terraform
+
DK Docker Registry
+
HL Helm Charts
+ +
Observability
+
PR Prometheus
+
GF Grafana
+
LO Loki / Logs
+
TP Tempo / Tracing
+
DD Datadog
+
OT OpenTelemetry
+
ELK ELK Stack
+ +
Security
+
IAM IAM / Auth
+
V HashiCorp Vault
+
G Grafana
+ +
Infrastructure
+
RK Server Rack
+
UPS APC UPS
+
PDU PDU / Stroom
+
AC Koeling / AC
+
PP Patch Panel
+
+
0 componenten · 0 connecties
+
+ +
+
+
+ + + +
+
+
+ + +
+
+
+ Nieuwe lijnen: + + + + +
+
+
+ + + + + +
+
+📄 Rapport + +
+ +
+
+ +

Sleep componenten hierheen

+ Verbind nodes via de poorten aan de zijkanten +
+
+ + + + + + +
+
+
100%
+
+
+ +
+

Eigenschappen

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + diff --git a/views/arch-list.ejs b/views/arch-list.ejs new file mode 100644 index 0000000..fe9bb1e --- /dev/null +++ b/views/arch-list.ejs @@ -0,0 +1,129 @@ + + + + + + Architectuur Diagrammen — Mek-Tech + + + + + + + + + + +
+
+

Architectuur Diagrammen

+ Nieuw Diagram +
+ +
+
+ + + + <%= diagrams.length %> diagram<%= diagrams.length !== 1 ? 'men' : '' %> + <% if (filters.search || filters.client_id || filters.diagram_type) { %> + Wis filters + <% } %> +
+
+ + <% if (diagrams.length === 0) { %> +
+ +

Geen diagrammen gevonden

+ <% if (filters.search || filters.client_id || filters.diagram_type) { %> +

Probeer andere filters of wis alle filters.

+ <% } else { %> +

Maak je eerste architectuur-diagram om data pipelines, netwerken en systemen te ontwerpen.

+ Nieuw Diagram + <% } %> +
+ <% } else { %> + + <% } %> +
+ + + diff --git a/views/arch-report.ejs b/views/arch-report.ejs new file mode 100644 index 0000000..95fa66c --- /dev/null +++ b/views/arch-report.ejs @@ -0,0 +1,137 @@ + + + + + +Architectuur Rapport — <%= diagram.name %> — Mek-Tech + + + + + + + + + +
+
+

<%= diagram.name %>

+
<%= diagram.client_name %>
+
+ <%= diagram.diagram_type.replace(/_/g,' ') %> · + <%= nodes.length %> componenten · + <%= edges.length %> connecties · + <%= new Date(diagram.updated_at).toLocaleDateString('nl-NL',{year:'numeric',month:'long',day:'numeric'}) %> +
+
+ +
+

Samenvatting

+
+
<%= nodes.length %>
Componenten
+
<%= edges.length %>
Connecties
+
<%= Object.keys(typeCount).length %>
Type Categorieën
+
<%= Object.keys(vendorCount).length %>
Vendoren
+
+ +
+<% var colors={source:'#d29922',ingest:'#58a6ff',storage:'#3fb950',compute:'#a855f7',hw:'#ff7b00',net:'#34d399',ai:'#fb7185',queue:'#38bdf8',security:'#f472b6',infra:'#6e7681',other:'#8b949e'}; %> +<% Object.entries(typeCount).forEach(function(e){ %> +
<%= e[0] %>: <%= e[1] %>
+<% }) %> +
+
+ +
+

Componenten Detail

+<% if (nodes.length === 0) { %> +

Geen componenten in dit diagram.

+<% } else { %> + + + + + +<% nodes.forEach(function(n,i){ %> + + + + + + + + + +<% }) %> + +
#LabelTypeVendorModelIP AdresSpecs
<%= i+1 %><%= n.label||'—' %><%= (n.type||'').split('-').pop() %> + <% if(n.vendor){ %> + <% var vc='#555d66';var vl=(n.vendor||'').toLowerCase(); %> + <% if(vl.includes('hpe')) vc='#ff7b00';else if(vl.includes('dell')) vc='#0076a8';else if(vl.includes('lenovo')) vc='#e41f33';else if(vl.includes('cisco')) vc='#00529a';else if(vl.includes('juniper')) vc='#44a833';else if(vl.includes('arista')) vc='#cc3333';else if(vl.includes('netapp')) vc='#00a1df';else if(vl.includes('pure')) vc='#4a90d9';else if(vl.includes('fortinet')) vc='#ee3124';else if(vl.includes('apc')) vc='#6e7681';else if(vl.includes('nvidia')) vc='#76b900';%> + <%= n.vendor %> + <% } else { %>—<% } %> + <%= n.model||'—' %><%= n.ip||'—' %><%= n.specs||'—' %>
+<% } %> +
+ +
+

Connecties (<%= edges.length %>)

+<% if (edges.length === 0) { %> +

Geen connecties in dit diagram.

+<% } else { %> + + + +<% edges.forEach(function(e,i){ + var from=nodes.find(function(n){return n.id==e.from}); + var to=nodes.find(function(n){return n.id==e.to}); +%> + + + + + +<% }) %> + +
#VanNaar
<%= i+1 %><%= from?from.label+' ('+from.type.split('-').pop()+')':'ID:'+e.from %>→ <%= to?to.label+' ('+to.type.split('-').pop()+')':'ID:'+e.to %>
+<% } %> +
+ +<% if (diagram.description) { %> +
+

Beschrijving

+

<%= diagram.description %>

+
+<% } %> + +
+ + +
+
+ + diff --git a/views/assess.ejs b/views/assess.ejs new file mode 100644 index 0000000..d611d64 --- /dev/null +++ b/views/assess.ejs @@ -0,0 +1,76 @@ + + + + + + AI Consultancy Framework — Mek-Tech + + + + + + + + +
+
+ +
+
+
+
+ +
+
+ + +
+ + + <% questions.forEach(function(q, index) { %> +
+
+ Stap <%= index + 1 %> / <%= questions.length %> +

<%= q.title %>

+
+
+ <% q.fields.forEach(function(field) { %> +
+ + <% if (field.type === 'textarea') { %> + + <% } else { %> + > + <% } %> +
+ <% }) %> +
+
+ <% if (index > 0) { %> + + <% } %> + <% if (index < questions.length - 1) { %> + + <% } else { %> + + <% } %> +
+
+ <% }) %> +
+
+ + + + diff --git a/views/audit-list.ejs b/views/audit-list.ejs new file mode 100644 index 0000000..ea741c9 --- /dev/null +++ b/views/audit-list.ejs @@ -0,0 +1,14 @@ +Audit Log — Mek-Tech + +
+ Terug + + +<% entries.forEach(function(e){ %> + +<% }) %> +
TijdGebruikerActieEntiteitDetails
<%= new Date(e.created_at).toLocaleString('nl-NL') %><%= e.username || '-' %><%= e.action %><%= e.entity_type %><% if (e.entity_id) { %> #<%= e.entity_id %><% } %><%= e.details || '' %>
+<% if (pages > 1) { %>
+<% for (var p = 1; p <= pages; p++) { %><%= p %><% } %> +
<% } %> +
diff --git a/views/backup.ejs b/views/backup.ejs new file mode 100644 index 0000000..fa3a6cf --- /dev/null +++ b/views/backup.ejs @@ -0,0 +1,129 @@ + + + + + +Backup & Herstel — Mek-Tech + + + + + + + + + + +
+ Terug naar instellingen +

Backup & Herstel

+

Volledige backup van database, sessies, uploads en alle cliëntdata.

+ +<% if (created) { %>
Backup succesvol aangemaakt op de server.
<% } %> +<% if (restored) { %>
Herstel voltooid. Vernieuw het dashboard — bij problemen herstart de container.
<% } %> +<% if (deleted) { %>
Backup verwijderd.
<% } %> +<% if (error) { %>
<%= error %>
<% } %> + +
+

Huidige data

+
+
<%= stats.counts.clients || 0 %>
Cliënten
+
<%= stats.counts.engagements || 0 %>
Engagements
+
<%= stats.counts.racks || 0 %>
Racks
+
<%= stats.counts.data_diagrams || 0 %>
Diagrammen
+
<%= stats.counts.projects || 0 %>
Projecten
+
<%= stats.counts.invoices || 0 %>
Facturen
+
+

+Database: <%= formatBytes(stats.dbSizeBytes) %> +<% if (stats.uploadFiles) { %> · Uploads: <%= stats.uploadFiles %> bestanden (<%= formatBytes(stats.uploadsBytes) %>)<% } %> +<% if (diskFree) { %> · Schijf vrij: <%= diskFree %><% } %> +

+
+ +
+

Nieuwe backup maken

+

Maakt een .tar.gz archief met:

+
    +
  • consulting.db — alle cliënten, racks, diagrammen, facturen, instellingen
  • +
  • sessions.db — actieve sessies
  • +
  • uploads/ — geüploade data quality bestanden
  • +
+
+
+
+
+
+ +
+

Backups op server (<%= backups.length %>)

+<% if (backups.length === 0) { %> +

Nog geen backups opgeslagen op de server.

+<% } else { %> + + + +<% backups.forEach(function(b) { %> + + + + + + +<% }) %> + +
BestandGrootteDatum
<%= b.filename %><%= b.sizeLabel %><%= new Date(b.created_at).toLocaleString('nl-NL') %> +Download +
+ +
+
+ +
+
+<% } %> +
+ +
+

Herstellen vanaf bestand

+
+

Let op: Herstellen overschrijft alle huidige data. Vóór herstel wordt automatisch een veiligheidsbackup gemaakt op de server.

+
+ + +
+
+
+
+ + + + diff --git a/views/calendar-list.ejs b/views/calendar-list.ejs new file mode 100644 index 0000000..69f6575 --- /dev/null +++ b/views/calendar-list.ejs @@ -0,0 +1,42 @@ +Agenda — Mek-Tech + +
+ Terug + + + + +
+
Komende events
+<% if (events.length === 0) { %>
Geen events
<% } else { %> +
+<% events.forEach(function(e){ %> +
+
+
+
<%= e.title %>
+
<%= new Date(e.start_time).toLocaleDateString('nl-NL',{weekday:'long',year:'numeric',month:'long',day:'numeric',hour:'2-digit',minute:'2-digit'}) %><% if (e.client_name) { %> · <%= e.client_name %><% } %>
+<% if (e.description) { %>
<%= e.description %>
<% } %> +
+
+
+<% }) %> +
+<% } %> +
+
+ diff --git a/views/client-detail.ejs b/views/client-detail.ejs new file mode 100644 index 0000000..5132ab2 --- /dev/null +++ b/views/client-detail.ejs @@ -0,0 +1,403 @@ + + + + + + <%= client.name %> — Mek-Tech Consulting + + + + + + + + + + +
+ Terug + + +
+ + + + + + + +
+ + +
+
+
+

Bedrijfsgegevens

+ + + <% if (client.industry) { %><% } %> + <% if (client.website) { %><% } %> + <% if (client.source) { %><% } %> + + +
Status<%= client.status %>
Sector<%= client.industry %>
Website<%= client.website %>
Bron<%= client.source %>
Aangemaakt<%= new Date(client.created_at).toLocaleDateString('nl-NL') %>
Bijgewerkt<%= new Date(client.updated_at).toLocaleDateString('nl-NL') %>
+
+
+

Contact

+ + <% if (client.contact_name) { %><% } %> + <% if (client.contact_email) { %><% } %> + <% if (client.contact_phone) { %><% } %> +
Naam<%= client.contact_name %>
Email<%= client.contact_email %>
Telefoon<%= client.contact_phone %>
+ <% if (client.contact_email) { %> + + <% } %> +
+
+ + <% if (diagrams.length > 0) { %> +
+

Recente Designs

+ + <% if (diagrams.length > 4) { %> +

Alle <%= diagrams.length %> designs bekijken →

+ <% } %> +
+ <% } %> + + <% if (advice.length > 0) { %> +
+

Advies

+
+ <% advice.forEach(function(a) { %> +
+
<%= a.priority %>
+

<%= a.advice_text %>

+
+ +
+
+ <% }) %> +
+
+ <% } %> + + <% if (notes.length > 0) { %> +
+

Recente Notities

+
+ <% notes.slice(0, 3).forEach(function(n) { %> +
+
<%= new Date(n.created_at).toLocaleDateString('nl-NL', {hour:'2-digit',minute:'2-digit'}) %><% if (n.created_by) { %> · <%= n.created_by %><% } %>
+
<%= n.content %>
+
+ <% }) %> +
+ <% if (notes.length > 3) { %> +

Alle <%= notes.length %> notities bekijken →

+ <% } %> +
+ <% } %> +
+ + +
+
+

Contactgegevens

+ + <% if (client.contact_name) { %><% } %> + <% if (client.contact_email) { %> + + <% } %> + <% if (client.contact_phone) { %><% } %> + <% if (client.website) { %><% } %> +
Naam<%= client.contact_name %>
Email<%= client.contact_email %>
Telefoon<%= client.contact_phone %>
Website<%= client.website %>
+ <% if (client.contact_email) { %> + + <% } %> +
+ <% if (client.notes) { %> +
+

Opgeslagen Notities bij Cliënt

+

<%= client.notes %>

+
+ <% } %> +
+ + +
+
+

Designs & Diagrammen

+ +
+ <% if (diagrams.length === 0) { %> +

Nog geen designs voor deze cliënt.

+ <% } else { %> + + <% } %> +
+ + +
+
+

Notities (<%= notes.length %>)

+
+ +
+
+ + +
+ +
+ + <% if (notes.length === 0) { %> +

Nog geen notities.

+ <% } else { %> +
+ <% notes.forEach(function(n) { %> +
+
+
<%= new Date(n.created_at).toLocaleDateString('nl-NL', {hour:'2-digit',minute:'2-digit'}) %><% if (n.created_by) { %> · <%= n.created_by %><% } %>
+
+ +
+
+
<%= n.content %>
+
+ <% }) %> +
+ <% } %> +
+ + +
+
+

Bestanden (<%= files.length %>)

+
+ +
+
+

Klik hier om bestanden te uploaden (max 100MB)

+
+ +
+ + <% if (files.length === 0) { %> +

Nog geen bestanden geüpload.

+ <% } else { %> +
+ <% files.forEach(function(f) { %> +
+ <%= f.mime_type.startsWith('image/') ? '🖼' : f.mime_type.includes('pdf') ? '📄' : f.mime_type.includes('spreadsheet') || f.mime_type.includes('excel') ? '📊' : f.mime_type.includes('zip') || f.mime_type.includes('tar') || f.mime_type.includes('gzip') ? '📦' : '📎' %> +
+
<%= f.original_name %>
+
<%= (f.file_size / 1024).toFixed(1) %> KB · <%= new Date(f.created_at).toLocaleDateString('nl-NL') %>
+
+ <% if (f.mime_type === 'application/pdf') { %>Bekijk<% } %> + Download +
+ +
+
+ <% }) %> +
+ <% } %> +
+ + +
+
+

Engagements (<%= engagements.length %>)

+ + Nieuw +
+ <% if (engagements.length === 0) { %> +

Nog geen engagements. Start een engagement.

+ <% } else { %> + + <% } %> +
+ + +
+
+

Assessments (<%= assessments.length %>)

+ + Nieuw +
+ <% if (assessments.length === 0) { %> +

Nog geen assessments uitgevoerd.

+ <% } else { %> +
+ <% assessments.slice().reverse().forEach(function(a) { %> +
+ <%= new Date(a.created_at).toLocaleDateString('nl-NL') %> + Rapport #<%= a.id %> + Bekijk +
+ <% }) %> +
+ <% } %> +
+
+ + + + diff --git a/views/client-form.ejs b/views/client-form.ejs new file mode 100644 index 0000000..d00eecf --- /dev/null +++ b/views/client-form.ejs @@ -0,0 +1,77 @@ + + + + + + <%= client ? 'Bewerk' : 'Nieuwe' %> Cliënt — Mek-Tech Consulting + + + + + + + + +
+ Terug + + + <% if (error) { %>
<%= error %>
<% } %> + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Annuleren +
+
+
+ + diff --git a/views/client-list.ejs b/views/client-list.ejs new file mode 100644 index 0000000..3abb59c --- /dev/null +++ b/views/client-list.ejs @@ -0,0 +1,54 @@ + + + + + + Cliënten — Mek-Tech Consulting + + + + + + + + +
+ Terug + + + <% if (clients.length === 0) { %> +
+

Nog geen cliënten. Voeg je eerste cliënt toe.

+
+ <% } else { %> + + + + + + + + + + + + + <% clients.forEach(function(c) { %> + + + + + + + + + <% }) %> + +
BedrijfSectorStatusEngagementsLaatste Assessment
<%= c.name %><%= c.industry || '-' %><%= c.status %><%= c.engagement_count %><%= c.last_assessment ? new Date(c.last_assessment).toLocaleDateString('nl-NL') : '-' %>Bewerk
+ <% } %> +
+ + diff --git a/views/consultancy-framework.ejs b/views/consultancy-framework.ejs new file mode 100644 index 0000000..e78a216 --- /dev/null +++ b/views/consultancy-framework.ejs @@ -0,0 +1,122 @@ +Consultancy Framework — Mek-Tech + + +
+
+ Back +

Enterprise Solution Architect

+

Connect a client, complete the 5-step assessment, generate a full architecture roadmap

+ +<% if (saved) { %>
✓ Assessment saved to <%= client ? client.name : 'client' %>
<% } %> + +
+ +<% if(clientId){ %>ConnectedView Client<% } %> +
+ +<% if (prevAssessment && !loadedAnswers) { %> +<% } %> + +
+
+ +
+ + +
AI Consultant
AgentHello! I analyze your assessment in real-time. Ask about industry benchmarks, data lake sizing, AI platforms, budgets, or roadmaps.
+ + + + diff --git a/views/consulting.js b/views/consulting.js new file mode 100644 index 0000000..20f65a7 --- /dev/null +++ b/views/consulting.js @@ -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; diff --git a/views/dashboard.ejs b/views/dashboard.ejs new file mode 100644 index 0000000..1b24dfa --- /dev/null +++ b/views/dashboard.ejs @@ -0,0 +1,815 @@ +Dashboard — Mek-Tech Enterprise + + + +
+ + +
+ + +
+
+
+

Welkom terug<% if (typeof user !== 'undefined' && user && user.username) { %>, <%= user.username %><% } %>

+

Jouw consultancy command center — live inzicht in cliënten, trajecten en platformstatus.

+
+
+
+ +<% if (activeAdvice.length > 0) { %> + +<% } %> + +
+<% var svcList = servicesStatus || {}; +Object.keys(svcList).forEach(function(key) { + var s = svcList[key]; + var on = s.online !== false; +%> +
+ +<%= s.label %>
<%= on ? 'Online' : 'Offline' %>
+
+<% }) %> +
+ +

Enterprise Dashboard

Realtime consultancy intelligence platform

+
+ +
<%= stats.activeEngagements %>
Actieve Engagements
van <%= stats.totalEngagements %> totaal
+
<%= totalRackDevices + totalNetworkDevices %>
Infra Devices
<%= totalRacks %> racks · <%= totalDiagrams %> diagrammen
+
<%= overdueTasks %>
Taken over tijd
<%= completedTasks %>/<%= totalTasks %> voltooid
+
<%= assessmentsCount %>
Assessments
Data quality engine
+
+
+
Clients Status
+<% var sts={active:'Actief',leads:'Lead',paused:'Paused',archived:'Archived'};var clrs={active:'var(--green)',leads:'var(--accent)',paused:'var(--orange)',archived:'var(--text-dim)'};var maxC=Math.max(stats.active,stats.leads,stats.paused,stats.archived,1); +for(var k in sts){var cnt=stats[k]||0;%>
<%= sts[k] %><%= cnt %>
<% } %> +
+
Engagements per Type
+<% 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){ %>
<%= e.type %><%= e.cnt %>
<% }) %> +
+
+
Recent Assessments
+<% if(recentAssessments.length===0){ %>
Nog geen assessments
<% }else{ %>
<% recentAssessments.forEach(function(a){ %>
Assessment voor <%= a.client_name %>
<%= new Date(a.created_at).toLocaleDateString('nl-NL',{hour:'2-digit',minute:'2-digit'}) %>
<% }) %>
<% } %> +
+
Quick Actions
+
+ + + + + +

Engagements

<%= stats.totalEngagements %> total · <%= stats.activeEngagements %> active

+
+
By Type
<% engagementByType.forEach(function(e){ %>
<%= e.type %><%= e.cnt %>
<% }) %>
+
By Status
<% engagementByStatus.forEach(function(e){ %>
<%= e.status.replace('_',' ') %><%= e.cnt %>
<% }) %>
+
Alle Engagements
+<% if(allEngagements.length===0){ %>
Nog geen engagements
<% }else{ %> +<% allEngagements.forEach(function(e){ %><% }) %>
TitelCliëntTypeStatus
<%= e.title %><%= e.client_name %><%= e.status %>Details
<% } %> +
+ + +

Finance

Urenregistratie en facturatie

+
<%= totalHours.toFixed(1) %>
Total hours
<%= billableHours.toFixed(1) %> billable
€<%= totalInvoiced.toFixed(0) %>
Invoiced
€<%= totalPaid.toFixed(0) %> paid
€<%= totalOutstanding.toFixed(0) %>
Outstanding
+ +
Recente urenregistraties
+<% if(timeEntries.length===0){ %>
Nog geen uren geregistreerd
<% }else{ %><% timeEntries.forEach(function(t){ %><% }) %>
DatumUrenCliëntEngagementOmschrijving
<%= t.date %><%= t.hours %><%= t.client_name||'-' %><%= t.engagement_title||'-' %><%= t.description||'' %>
<% } %>
+
Recente facturen
+<% if(invoices.length===0){ %>
Nog geen facturen
<% }else{ %><% invoices.forEach(function(i){ %><% }) %>
FactuurnrCliëntBedragStatus
<%= i.number %><%= i.client_name %>€<%= (i.total||0).toFixed(2) %><%= i.status %>Open
<% } %>
+ + +

Infrastructure

Racks, netwerkapparatuur, verbindingen & architectuurdiagrammen

+
<%= totalRacks %>
Server Racks
<%= totalRackDevices %> devices
<%= totalNetworkDevices %>
Netwerk Devices
Switches, routers & firewalls
<%= totalDiagrams %>
Diagrammen
Data, netwerk & systeem
+ +
+
Server Racks <% if(racks.length>0){ %>Alle<% } %>
+<% if(racks.length===0){ %>
Nog geen racks
<% }else{ %><% } %>
+
Netwerk Devices <% if(netDevices.length>0){ %>Alle<% } %>
+<% if(netDevices.length===0){ %>
Nog geen netwerk apparaten
<% }else{ %><% } %>
+
+
Architectuur Diagrammen <% if(diagrams.length>0){ %>Alle<% } %>
+<% if(diagrams.length===0){ %>
Nog geen diagrammen
<% }else{ %><% } %>
+ + +

Tasks

<%= completedTasks %>/<%= totalTasks %> completed <% if(overdueTasks>0){ %><%= overdueTasks %> overdue<% } %>

+
<%= completedTasks %>
Voltooid
<%= totalTasks-completedTasks %>
Open
<%= overdueTasks %>
Over tijd
+<% if(allTasks.length===0){ %>
Nog geen taken
<% }else{ %>
<% allTasks.forEach(function(t){ var io=t.status!=='done'&&t.status!=='cancelled'&&t.due_date&&new Date(t.due_date)
<%= t.priority %>
<%= t.title %><%= t.client_name %> · <%= t.engagement_title %>
<%= t.status.replace('_',' ') %><% if(t.due_date){ %><%= new Date(t.due_date).toLocaleDateString('nl-NL') %><% } %>
<% }) %>
<% } %>
+ + +
+

Advice & Consulting

Jouw complete consultancy werkplaats — van discovery tot delivery

+ +
+
+

Mek-Tech Consultancy Hub

+

Assessments, architectuur, data quality, AI readiness en commerciële follow-up — alles op één plek voor jouw FDE- en enterprise consultancy.

+
+ +
+ +
+
+
+
<%= consultingStats.assessments %>
+
Assessments
+
<%= consultingStats.clientsNeedingAssessment %> wachten op intake
+
+
+
+
<%= consultingStats.activeConsulting %>
+
Lopende Trajecten
+
Consulting & architectuur
+
+
+
+
<%= totalDiagrams %>
+
Designs
+
Architectuurdiagrammen
+
+
+
+
<%= consultingStats.openAdvice %>
+
Open Adviezen
+
Actie-items per cliënt
+
+
+ +<% toolkit.forEach(function(section) { %> +
+
+ +

<%= section.title %>

+ — <%= section.desc %> +
+
+ <% section.tools.forEach(function(tool) { %> + +
+ +
+ <%= tool.label %> + <%= tool.desc %> +
+ <% }) %> +
+
+<% }) %> + +
+
+
Delivery Playbook
+

Standaard consultancy traject in 5 fasen

+
+ <% playbooks.forEach(function(pb) { %> +
+
Fase <%= pb.phase %>
+

<%= pb.title %>

+
    <% pb.steps.forEach(function(s) { %>
  • <%= s %>
  • <% }) %>
+
<%= pb.duration %>
+
+ <% }) %> +
+
+
+
Maturity Model
+

Consultancy dimensies per engagement

+
+ <% maturityLayers.forEach(function(layer) { %> +
+
<%= layer.name %>
+ <% layer.items.forEach(function(item) { %><%= item %><% }) %> +
+ <% }) %> +
+
+
+ +
+
+
+

Slim Advies

+ Automatisch gegenereerd uit CRM-data +
+ <% if(activeAdvice.length===0){ %> +
+ +

Geen open actie-items — voeg cliënten toe om gepersonaliseerd advies te krijgen.

+ Eerste cliënt toevoegen +
+ <% }else{ %> +
+ <% activeAdvice.forEach(function(a) { %> +
+
<%= a.priority %>
+
+

<%= a.advice_text %>

+
<%= a.client_name %> · <%= a.advice_type.replace(/_/g,' ') %>
+
+ Actie → +
+ <% }) %> +
+ <% } %> +
+
+
Recente Assessments <% if(recentAssessments.length>0){ %>Alle<% } %>
+ <% if(recentAssessments.length===0){ %> +
Nog geen assessments — start via Enterprise Framework
+ <% }else{ %> +
+ <% recentAssessments.forEach(function(a) { %> +
+
+
+
Assessment voor <%= a.client_name %>
+
<%= new Date(a.created_at).toLocaleString('nl-NL') %>
+ Rapport bekijken → +
+
+ <% }) %> +
+ <% } %> +
+ +
+
+
+ + +

LeadGen AI

AI-driven Lead Generation & Tech Scoping — analyseer bedrijfswebsites op technische signalen

+ +
+
URL Analyseren
+
+ + +
+ +
+ +
API Configuratie
+
+ + + + + +

Plak een bedrijfs-URL en klik Analyseren. De AI scant de pagina op:

Tech stack detectiePain pointsData maturityOutreach hook
+ + +
+ + +

Market Intelligence

Nederlandse datacenters & data-gedreven bedrijven

+
<%= datacenterCount || 0 %>
Datacenters
Nederlandse markt
<%= marketCompaniesCount || 0 %>
Prospects
Data-gedreven bedrijven
+
+ + +

Calendar

Planning, deadlines & afspraken

+
<%= overdueTasks %>
Open Tasks
<%= completedTasks %> completed
+
+ + +

Data Quality

Data profiling, kwaliteitschecks & modellen

+
<%= assessmentsCount %>
Assessments
Data kwaliteit checks
<%= qualityFileCount || 0 %>
Data Bestanden
SQL & dbt modellen
+
+ + +

Recente Activiteit

+
Recent Assessments
+<% if(recentAssessments.length===0){ %>
Nog geen recente activiteit
<% }else{ %>
<% recentAssessments.forEach(function(a){ %>
Rapport voor <%= a.client_name %>
<%= new Date(a.created_at).toLocaleString('nl-NL') %>
<% }) %>
<% } %>
+
+ +
+ + + + + + + + + + diff --git a/views/dashboard.js b/views/dashboard.js new file mode 100644 index 0000000..00209e8 --- /dev/null +++ b/views/dashboard.js @@ -0,0 +1,98 @@ +const express = require('express'); +const router = express.Router(); +const { getDb } = require('../db'); +const { generateAdvice } = require('../advice'); + +router.get('/', (req, res) => { + const db = getDb(); + const tab = req.query.tab || 'overview'; + + const clients = db.prepare(` + SELECT c.*, + (SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id) as engagement_count, + (SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id AND e.status = 'in_progress') as active_engagements, + (SELECT MAX(a.created_at) FROM assessments a WHERE a.client_id = c.id) as last_assessment + FROM clients c ORDER BY c.updated_at DESC + `).all(); + + const stats = { + total: clients.length, + active: clients.filter(c => c.status === 'active').length, + leads: clients.filter(c => c.status === 'lead').length, + paused: clients.filter(c => c.status === 'paused').length, + archived: clients.filter(c => c.status === 'archived').length, + activeEngagements: clients.reduce((s, c) => s + c.active_engagements, 0), + totalEngagements: clients.reduce((s, c) => s + c.engagement_count, 0) + }; + + const totalDiagrams = db.prepare('SELECT COUNT(*) as cnt FROM data_diagrams').get().cnt; + const totalRacks = db.prepare('SELECT COUNT(*) as cnt FROM racks').get().cnt; + const totalRackDevices = db.prepare('SELECT COUNT(*) as cnt FROM rack_devices').get().cnt; + const totalNetworkDevices = db.prepare('SELECT COUNT(*) as cnt FROM network_devices').get().cnt; + + const overdueTasks = db.prepare('SELECT COUNT(*) as cnt FROM tasks WHERE status IN (\'todo\',\'in_progress\') AND due_date < date(\'now\')').get().cnt; + const totalTasks = db.prepare('SELECT COUNT(*) as cnt FROM tasks').get().cnt; + const completedTasks = db.prepare('SELECT COUNT(*) as cnt FROM tasks WHERE status = \'done\'').get().cnt; + const assessmentsCount = db.prepare('SELECT COUNT(*) as cnt FROM assessments').get().cnt; + + const recentEngagements = db.prepare(` + SELECT e.*, c.name as client_name FROM engagements e JOIN clients c ON c.id = e.client_id + WHERE e.status = 'in_progress' ORDER BY e.updated_at DESC LIMIT 8 + `).all(); + + const allEngagements = db.prepare(` + SELECT e.*, c.name as client_name FROM engagements e JOIN clients c ON c.id = e.client_id ORDER BY e.updated_at DESC + `).all(); + + const recentAssessments = db.prepare(` + SELECT a.id, a.created_at, c.name as client_name FROM assessments a JOIN clients c ON c.id = a.client_id ORDER BY a.created_at DESC LIMIT 5 + `).all(); + + const engagementByType = db.prepare('SELECT type, COUNT(*) as cnt FROM engagements GROUP BY type ORDER BY cnt DESC').all(); + const engagementByStatus = db.prepare('SELECT status, COUNT(*) as cnt FROM engagements GROUP BY status ORDER BY cnt DESC').all(); + const clientsByIndustry = db.prepare("SELECT industry, COUNT(*) as cnt FROM clients WHERE industry IS NOT NULL AND industry != '' GROUP BY industry ORDER BY cnt DESC LIMIT 6").all(); + + const allAdvice = []; + for (const client of clients) allAdvice.push(...generateAdvice(client.id)); + const activeAdvice = allAdvice.filter(a => !a.dismissed).sort((a, b) => { + const prio = { critical: 0, high: 1, medium: 2, low: 3 }; + return (prio[a.priority] || 99) - (prio[b.priority] || 99); + }).slice(0, 8); + + // Finance + const timeEntries = db.prepare('SELECT t.*, c.name as client_name, e.title as engagement_title, u.username FROM time_entries t LEFT JOIN clients c ON c.id = t.client_id LEFT JOIN engagements e ON e.id = t.engagement_id LEFT JOIN users u ON u.id = t.user_id ORDER BY t.date DESC LIMIT 20').all(); + const totalHours = db.prepare('SELECT COALESCE(SUM(hours),0) as h FROM time_entries').get().h; + const billableHours = db.prepare('SELECT COALESCE(SUM(hours),0) as h FROM time_entries WHERE billable=1').get().h; + const invoices = db.prepare('SELECT i.*, c.name as client_name FROM invoices i JOIN clients c ON c.id = i.client_id ORDER BY i.created_at DESC LIMIT 10').all(); + const totalInvoiced = db.prepare("SELECT COALESCE(SUM(total),0) as t FROM invoices WHERE status != 'draft'").get().t; + const totalPaid = db.prepare("SELECT COALESCE(SUM(total),0) as t FROM invoices WHERE status='paid'").get().t; + const totalOutstanding = db.prepare("SELECT COALESCE(SUM(total),0) as t FROM invoices WHERE status IN ('sent','overdue')").get().t; + + // Infrastructure + const racks = db.prepare('SELECT r.*, c.name as client_name, (SELECT COUNT(*) FROM rack_devices d WHERE d.rack_id = r.id) as device_count FROM racks r JOIN clients c ON c.id = r.client_id ORDER BY r.updated_at DESC LIMIT 6').all(); + const netDevices = db.prepare('SELECT nd.*, c.name as client_name FROM network_devices nd JOIN clients c ON c.id = nd.client_id ORDER BY nd.updated_at DESC LIMIT 10').all(); + const diagrams = db.prepare('SELECT d.*, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id ORDER BY d.updated_at DESC LIMIT 6').all(); + + // Tasks + const allTasks = db.prepare(` + SELECT t.*, e.title as engagement_title, e.id as eid, c.name as client_name + FROM tasks t JOIN engagements e ON e.id = t.engagement_id JOIN clients c ON c.id = e.client_id + ORDER BY CASE t.status WHEN 'todo' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'done' THEN 2 WHEN 'cancelled' THEN 3 END, + CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END, + t.due_date ASC + `).all(); + + res.render('dashboard', { + tab, clients, stats, + totalDiagrams, totalRacks, totalRackDevices, totalNetworkDevices, + overdueTasks, totalTasks, completedTasks, assessmentsCount, + recentEngagements, allEngagements, recentAssessments, + engagementByType, engagementByStatus, clientsByIndustry, + activeAdvice, + timeEntries, totalHours, billableHours, invoices, totalInvoiced, totalPaid, totalOutstanding, + racks, netDevices, diagrams, + allTasks + }); +}); + +module.exports = router; diff --git a/views/db.js b/views/db.js new file mode 100644 index 0000000..2d42d1f --- /dev/null +++ b/views/db.js @@ -0,0 +1,554 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const bcrypt = require('bcryptjs'); + +const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'consulting.db'); + +let db; + +function getDb() { + if (!db) { + const fs = require('fs'); + const dir = path.dirname(DB_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + initSchema(); + migrateSchema(); + seedDefaults(); + } + return db; +} + +function migrateSchema() { + const existingColumns = db.prepare("PRAGMA table_info('users')").all().map(r => r.name); + const newCols = { email: "TEXT DEFAULT ''", role: "TEXT DEFAULT 'admin'", reset_token: "TEXT DEFAULT ''", reset_expires: "TEXT DEFAULT ''" }; + for (const [col, def] of Object.entries(newCols)) { + if (!existingColumns.includes(col)) { + db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`); + } + } + + db.exec(` + CREATE TABLE IF NOT EXISTS ai_providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + provider_type TEXT NOT NULL DEFAULT 'openrouter', + api_key TEXT DEFAULT '', + base_url TEXT DEFAULT '', + default_model TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + is_default INTEGER DEFAULT 0, + extra_headers TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS integrations ( + id TEXT PRIMARY KEY, + name TEXT DEFAULT '', + api_key TEXT DEFAULT '', + webhook_url TEXT DEFAULT '', + config TEXT DEFAULT '{}', + status TEXT DEFAULT 'inactive', + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + report_type TEXT DEFAULT 'client_summary', + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + project_id INTEGER, + config TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + start_date TEXT, + end_date TEXT, + status TEXT DEFAULT 'planning', + priority TEXT DEFAULT 'medium', + budget REAL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + `); + + const diagramCols = db.prepare("PRAGMA table_info('data_diagrams')").all().map(r => r.name); + if (!diagramCols.includes('engagement_id')) { + db.exec('ALTER TABLE data_diagrams ADD COLUMN engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL'); + } + if (!diagramCols.includes('rack_id')) { + db.exec('ALTER TABLE data_diagrams ADD COLUMN rack_id INTEGER REFERENCES racks(id) ON DELETE SET NULL'); + } + + const tblRow = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='data_diagrams'").get(); + const tblSql = tblRow ? tblRow.sql : ''; + if (tblSql && tblSql.includes('CHECK') && !tblSql.includes('application_flow')) { + const cols = db.prepare("PRAGMA table_info('data_diagrams')").all(); + const colNames = cols.map(c => c.name); + const selectCols = colNames.join(', '); + db.exec(` + CREATE TABLE data_diagrams_v2 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + diagram_type TEXT DEFAULT 'data_architecture', + nodes TEXT NOT NULL DEFAULT '[]', + edges TEXT NOT NULL DEFAULT '[]', + engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL, + rack_id INTEGER REFERENCES racks(id) ON DELETE SET NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + INSERT INTO data_diagrams_v2 (${selectCols}) SELECT ${selectCols} FROM data_diagrams; + DROP TABLE data_diagrams; + ALTER TABLE data_diagrams_v2 RENAME TO data_diagrams; + `); + } +} + +function initSchema() { + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + role TEXT DEFAULT 'admin' CHECK(role IN ('admin','consultant','viewer')), + reset_token TEXT DEFAULT '', + reset_expires TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + industry TEXT, + website TEXT, + contact_name TEXT, + contact_email TEXT, + contact_phone TEXT, + status TEXT DEFAULT 'lead' CHECK(status IN ('lead','active','paused','archived')), + source TEXT, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS engagements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK(type IN ('assessment','architecture','implementation','monitoring','consulting','training')), + status TEXT DEFAULT 'planned' CHECK(status IN ('planned','in_progress','completed','on_hold')), + title TEXT NOT NULL, + description TEXT, + start_date TEXT, + end_date TEXT, + outcome TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS assessments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL, + answers TEXT NOT NULL, + report TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + engagement_id INTEGER NOT NULL REFERENCES engagements(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'todo' CHECK(status IN ('todo','in_progress','done','cancelled')), + priority TEXT DEFAULT 'medium' CHECK(priority IN ('low','medium','high','critical')), + due_date TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS advice_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + advice_type TEXT NOT NULL, + advice_text TEXT NOT NULL, + priority TEXT DEFAULT 'medium', + dismissed INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS racks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + name TEXT NOT NULL, + location TEXT, + datacenter TEXT, + total_units INTEGER DEFAULT 42, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS rack_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rack_id INTEGER NOT NULL REFERENCES racks(id) ON DELETE CASCADE, + name TEXT NOT NULL, + device_type TEXT DEFAULT 'server', + model TEXT, + manufacturer TEXT, + position_u INTEGER NOT NULL, + height_u INTEGER DEFAULT 1, + specs TEXT, + mgmt_ip TEXT, + notes TEXT, + serial TEXT DEFAULT '', + asset_tag TEXT DEFAULT '', + status TEXT DEFAULT 'active', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS network_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + name TEXT NOT NULL, + device_type TEXT NOT NULL CHECK(device_type IN ('switch','router','firewall','loadbalancer','ap','other')), + model TEXT, + manufacturer TEXT, + ip_address TEXT, + mgmt_ip TEXT, + ports_count INTEGER DEFAULT 24, + os_version TEXT, + notes TEXT, + status TEXT DEFAULT 'active' CHECK(status IN ('active','standby','decommissioned')), + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS network_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + name TEXT, + from_type TEXT NOT NULL CHECK(from_type IN ('rack_device','network_device','external')), + from_id INTEGER, + from_port TEXT, + to_type TEXT NOT NULL CHECK(to_type IN ('rack_device','network_device','external')), + to_id INTEGER, + to_port TEXT, + media_type TEXT DEFAULT 'copper' CHECK(media_type IN ('copper','sfp','sfp+','qsfp','dac','fiber')), + speed TEXT DEFAULT '1GbE', + vlan TEXT, + status TEXT DEFAULT 'active' CHECK(status IN ('active','inactive','planned')), + notes TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS data_diagrams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + diagram_type TEXT DEFAULT 'data_architecture' CHECK(diagram_type IN ('data_architecture','network_topology','system_architecture')), + nodes TEXT NOT NULL DEFAULT '[]', + edges TEXT NOT NULL DEFAULT '[]', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS client_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_by TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS client_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + original_name TEXT NOT NULL, + stored_name TEXT NOT NULL, + mime_type TEXT DEFAULT '', + file_size INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + username TEXT DEFAULT '', + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + details TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS time_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL, + date TEXT NOT NULL, + hours REAL NOT NULL, + description TEXT DEFAULT '', + billable INTEGER DEFAULT 1, + hourly_rate REAL DEFAULT 150.0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + number TEXT UNIQUE NOT NULL, + status TEXT DEFAULT 'draft' CHECK(status IN ('draft','sent','paid','overdue','cancelled')), + date TEXT NOT NULL, + due_date TEXT NOT NULL, + subtotal REAL DEFAULT 0, + tax REAL DEFAULT 0, + total REAL DEFAULT 0, + notes TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS invoice_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL, + quantity REAL DEFAULT 1, + unit_price REAL DEFAULT 0, + total REAL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + type TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT DEFAULT '', + channel TEXT DEFAULT 'in-app' CHECK(channel IN ('in-app','email','slack','webhook')), + read INTEGER DEFAULT 0, + sent INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS calendar_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + engagement_id INTEGER REFERENCES engagements(id) ON DELETE SET NULL, + title TEXT NOT NULL, + description TEXT DEFAULT '', + event_type TEXT DEFAULT 'meeting' CHECK(event_type IN ('meeting','call','deadline','workshop','review','other')), + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + all_day INTEGER DEFAULT 0, + color TEXT DEFAULT '#58a6ff', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS user_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + notify_email INTEGER DEFAULT 1, + notify_slack INTEGER DEFAULT 0, + notify_webhook INTEGER DEFAULT 0, + webhook_url TEXT DEFAULT '', + slack_webhook TEXT DEFAULT '', + language TEXT DEFAULT 'nl' + ); + + CREATE TABLE IF NOT EXISTS email_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + email TEXT NOT NULL, + name TEXT DEFAULT '', + imap_host TEXT NOT NULL, + imap_port INTEGER DEFAULT 993, + imap_secure INTEGER DEFAULT 1, + imap_user TEXT NOT NULL, + imap_pass TEXT NOT NULL, + smtp_host TEXT NOT NULL, + smtp_port INTEGER DEFAULT 587, + smtp_secure INTEGER DEFAULT 0, + smtp_user TEXT DEFAULT '', + smtp_pass TEXT DEFAULT '', + active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS email_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + uid INTEGER NOT NULL, + folder TEXT DEFAULT 'INBOX', + subject TEXT DEFAULT '', + from_name TEXT DEFAULT '', + from_addr TEXT DEFAULT '', + to_addr TEXT DEFAULT '', + cc TEXT DEFAULT '', + bcc TEXT DEFAULT '', + date TEXT DEFAULT '', + body_text TEXT DEFAULT '', + body_html TEXT DEFAULT '', + attachments INTEGER DEFAULT 0, + seen INTEGER DEFAULT 0, + flagged INTEGER DEFAULT 0, + replied INTEGER DEFAULT 0, + size INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS datacenters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + address TEXT DEFAULT '', + city TEXT DEFAULT '', + region TEXT DEFAULT '', + postal_code TEXT DEFAULT '', + phone TEXT DEFAULT '', + website TEXT DEFAULT '', + type TEXT DEFAULT 'colo', + operator TEXT DEFAULT '', + tier TEXT DEFAULT 'III', + power_mw REAL DEFAULT 0, + size_sqm INTEGER DEFAULT 0, + network_vendors TEXT DEFAULT '', + server_vendors TEXT DEFAULT '', + storage_vendors TEXT DEFAULT '', + certifications TEXT DEFAULT '', + notes TEXT DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS tech_companies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + industry TEXT DEFAULT '', + city TEXT DEFAULT '', + address TEXT DEFAULT '', + postal_code TEXT DEFAULT '', + phone TEXT DEFAULT '', + email TEXT DEFAULT '', + website TEXT DEFAULT '', + linkedin TEXT DEFAULT '', + employees INTEGER DEFAULT 0, + revenue_range TEXT DEFAULT '', + data_warehouse TEXT DEFAULT '', + bi_tools TEXT DEFAULT '', + cloud_provider TEXT DEFAULT '', + data_lake TEXT DEFAULT '', + ai_ml TEXT DEFAULT '', + database_vendor TEXT DEFAULT '', + lead_score INTEGER DEFAULT 0, + lead_status TEXT DEFAULT 'new', + notes TEXT DEFAULT '', + source TEXT DEFAULT '' + ); + `); +} + +function seedDefaults() { + const existing = db.prepare('SELECT COUNT(*) as cnt FROM users').get(); + if (existing.cnt === 0) { + const pw = process.env.ADMIN_PASSWORD || 'admin'; + const hash = bcrypt.hashSync(pw, 10); + const result = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run('admin', hash, 'admin'); + db.prepare('INSERT INTO user_settings (user_id, language) VALUES (?, ?)').run(result.lastInsertRowid, 'nl'); + console.log(`Default user created: admin / ${pw}`); + } +} + +const DEFAULTS = { + brand_name: 'Mek-Tech Consulting', + language: 'nl', + theme: 'mek-neon-blue', + default_page: '/', + auto_refresh: '0', + items_per_page: '50', + debug_mode: '0', + icon_colors: JSON.stringify({ + 'layout-dashboard': '#58a6ff', + users: '#3fb950', + briefcase: '#d29922', + server: '#bc8cff', + 'check-circle': '#3fb950', + lightbulb: '#d29922', + clock: '#79c0ff', + eye: '#f778ba', + phone: '#3fb950', + 'file-text': '#f778ba', + 'edit-3': '#79c0ff', + folder: '#d29922', + 'clipboard-list': '#bc8cff' + }), + theme_colors: '{}' +}; + +function getSettings() { + const d = getDb(); + const rows = d.prepare('SELECT key, value FROM app_settings').all(); + const s = { ...DEFAULTS }; + // Ensure defaults exist in DB + const upsert = d.prepare(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`); + for (const [k, v] of Object.entries(DEFAULTS)) { + const row = rows.find(r => r.key === k); + if (row) { + s[k] = row.value; + } else { + upsert.run(k, v); + } + } + // Also store icon_colors as parsed object + if (typeof s.icon_colors === 'string') { + try { s.icon_colors = JSON.parse(s.icon_colors); } catch (e) { s.icon_colors = JSON.parse(DEFAULTS.icon_colors); } + } + if (typeof s.theme_colors === 'string') { + try { s.theme_colors = JSON.parse(s.theme_colors || '{}'); } catch (e) { s.theme_colors = {}; } + } + if (!s.theme_colors || typeof s.theme_colors !== 'object') s.theme_colors = {}; + return s; +} + +function updateSetting(key, value) { + const d = getDb(); + if (typeof value === 'object') value = JSON.stringify(value); + d.prepare(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`).run(key, value); +} + +function logAudit(userId, username, action, entityType, entityId, details) { + const d = getDb(); + d.prepare('INSERT INTO audit_log (user_id, username, action, entity_type, entity_id, details) VALUES (?, ?, ?, ?, ?, ?)') + .run(userId || null, username || '', action, entityType, entityId || null, details || ''); +} + +function getUserSettings(userId) { + const d = getDb(); + let s = d.prepare('SELECT * FROM user_settings WHERE user_id = ?').get(userId); + if (!s) { + d.prepare('INSERT INTO user_settings (user_id) VALUES (?)').run(userId); + s = d.prepare('SELECT * FROM user_settings WHERE user_id = ?').get(userId); + } + return s; +} + +module.exports = { getDb, getSettings, updateSetting, logAudit, getUserSettings, DEFAULTS }; diff --git a/views/designer-embed.ejs b/views/designer-embed.ejs new file mode 100644 index 0000000..6e65a14 --- /dev/null +++ b/views/designer-embed.ejs @@ -0,0 +1,34 @@ + + +<%- include('partials/head', { pageTitle: 'Pro Designer' }) %> + +<%- include('partials/navbar') %> +
+ <%- include('partials/sidebar') %> +
+ <%- include('partials/crm-bar') %> +
+
+

Pro Architecture Designer

+

Drag-and-drop data architecture met CRM-koppeling

+
+
+ + ← Diagrammen +
+
+ +
+
+ +<%- include('partials/scripts') %> + + diff --git a/views/email-compose.ejs b/views/email-compose.ejs new file mode 100644 index 0000000..8c7cd6d --- /dev/null +++ b/views/email-compose.ejs @@ -0,0 +1,40 @@ +Opstellen — Mek-Tech + + +
+← Terug naar inbox +

Nieuw bericht

+<% if (sent) { %>
Bericht verzonden!
<% } %> +<% if (error) { %>
<%= error %>
<% } %> +<% if (!account) { %> +

Geen email account geconfigureerd.

Account toevoegen
+<% } else { %> +
+
+
+
+
+
+
+
Annuleren
+
+<% } %>
+ \ No newline at end of file diff --git a/views/email-detail.ejs b/views/email-detail.ejs new file mode 100644 index 0000000..ff5837e --- /dev/null +++ b/views/email-detail.ejs @@ -0,0 +1,33 @@ +<%= msg.subject || 'Geen onderwerp' %> — Mek-Tech + +
+← Terug naar inbox + + +
diff --git a/views/email-inbox.ejs b/views/email-inbox.ejs new file mode 100644 index 0000000..1b47bd7 --- /dev/null +++ b/views/email-inbox.ejs @@ -0,0 +1,63 @@ +Inbox — Mek-Tech + + + +
+<% if (error) { %>
<%= error %>
<% } %> + +<% if (!account) { %> +
+ +

Geen email account gekoppeld

+

Koppel je IMAP/SMTP account om emails te ontvangen en te versturen

+ Account configureren +
+<% } else { %> +
+

Inbox

+
+ + Sync + Opstellen +
+
+ +<% if (messages.length === 0) { %> +

Inbox is leeg

Klik op Sync om je berichten op te halen

+<% } else { %> +<% messages.forEach(function(m){ %> + +<%= m.from_name || m.from_addr || 'Onbekend' %> +<%= m.subject || '(geen onderwerp)' %> +<% if (m.flagged) { %><% } %> +<%= m.date ? new Date(m.date).toLocaleDateString('nl-NL',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '' %> + +<% }) %> +<% } %> +<% } %> +
+ \ No newline at end of file diff --git a/views/email-settings.ejs b/views/email-settings.ejs new file mode 100644 index 0000000..664de01 --- /dev/null +++ b/views/email-settings.ejs @@ -0,0 +1,65 @@ +Email Accounts — Mek-Tech + + +
+← Terug naar inbox +

Email Accounts

Configureer IMAP/SMTP voor het ontvangen en versturen van e-mail

+<% if (saved) { %>
Account succesvol opgeslagen
<% } %> +<% if (error) { %>
<%= error %>
<% } %> + +<% if (accounts.length > 0) { %> +

Bestaande Accounts

+<% accounts.forEach(function(a){ %> + +<% }) %> +
+<% } %> + +
+

<%= accounts.length > 0 ? 'Account Toevoegen' : 'Eerste Account Configureren' %>

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Annuleren
+
+
+ \ No newline at end of file diff --git a/views/engagement-detail.ejs b/views/engagement-detail.ejs new file mode 100644 index 0000000..a774ea7 --- /dev/null +++ b/views/engagement-detail.ejs @@ -0,0 +1,121 @@ + + + + + + <%= engagement.title %> — Mek-Tech Consulting + + + + + + + + +
+ Terug + + +
+
+

Details

+ + + + + + +
Cliënt<%= engagement.client_name %>
Type
Status<%= engagement.status %>
Start<%= engagement.start_date ? new Date(engagement.start_date).toLocaleDateString('nl-NL') : '-' %>
Eind<%= engagement.end_date ? new Date(engagement.end_date).toLocaleDateString('nl-NL') : '-' %>
+
+
+

Status wijzigen

+
+ + +
+
+
+ + <% if (engagement.description) { %> +
+

Omschrijving

+

<%= engagement.description %>

+
+ <% } %> + + <% if (engagement.outcome) { %> +
+

Resultaat

+

<%= engagement.outcome %>

+
+ <% } %> + +
+
+

Taken (<%= tasks.length %>)

+ +
+ + + + <% if (tasks.length === 0) { %> +

Nog geen taken.

+ <% } else { %> +
+ <% tasks.forEach(function(t) { %> +
+
+ +
+
+ <%= t.title %> + <% if (t.description) { %><%= t.description %><% } %> +
+ <%= t.priority %> + <% if (t.due_date) { %> + + <%= new Date(t.due_date).toLocaleDateString('nl-NL') %> + + <% } %> +
+ +
+
+ <% }) %> +
+ <% } %> +
+
+ + diff --git a/views/engagement-form.ejs b/views/engagement-form.ejs new file mode 100644 index 0000000..18b6991 --- /dev/null +++ b/views/engagement-form.ejs @@ -0,0 +1,92 @@ + + + + + + <%= engagement ? 'Bewerk' : 'Nieuw' %> Engagement — Mek-Tech Consulting + + + + + + + + +
+ Terug + + + <% if (error) { %>
<%= error %>
<% } %> + +
+ <% if (!engagement) { %> +
+ + +
+ <% } else { %> + + <% } %> + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ <% if (engagement) { %> +
+ + +
+ <% } %> +
+ + Annuleren +
+
+
+ + diff --git a/views/engagement-list.ejs b/views/engagement-list.ejs new file mode 100644 index 0000000..6b371dc --- /dev/null +++ b/views/engagement-list.ejs @@ -0,0 +1,52 @@ + + + + + + Engagements — Mek-Tech Consulting + + + + + + + + +
+ Terug + + + <% if (engagements.length === 0) { %> +

Nog geen engagements.

+ <% } else { %> + + + + + + + + + + + + + <% engagements.forEach(function(e) { %> + + + + + + + + + <% }) %> + +
TitelCliëntTypeStatusStartEind
<%= e.title %><%= e.client_name %><%= e.status %><%= e.start_date ? new Date(e.start_date).toLocaleDateString('nl-NL') : '-' %><%= e.end_date ? new Date(e.end_date).toLocaleDateString('nl-NL') : '-' %>
+ <% } %> +
+ + diff --git a/views/finance.ejs b/views/finance.ejs new file mode 100644 index 0000000..6bb5e2f --- /dev/null +++ b/views/finance.ejs @@ -0,0 +1,77 @@ +Financiën — Mek-Tech + + +
+ Terug + + +
+
<%= totalHours.toFixed(1) %>
Totaal uren
+
<%= billableHours.toFixed(1) %>
Facturabel
+
€<%= totalInvoiced.toFixed(0) %>
Gefactureerd
+
€<%= totalPaid.toFixed(0) %>
Betaald
+
€<%= totalOutstanding.toFixed(0) %>
Openstaand
+
+ +
+ + + +
+ + + +
+ Open volledige registratie + +<% entries.forEach(function(e){ %><% }) %> +<% if (entries.length === 0) { %><% } %> +
DatumCliëntHoursOmschrijvingFact.
<%= e.date %><%= e.client_name || '-' %><%= e.hours %><%= e.description || '' %><%= e.billable ? '✓' : '-' %>
Nog geen uren
+
+ +
+ Alle facturen + +<% invoices.forEach(function(i){ %><% }) %> +<% if (invoices.length === 0) { %><% } %> +
NrCliëntDatumTotaalStatus
<%= i.number %><%= i.client_name %><%= i.date %>€<%= (i.total||0).toFixed(2) %><%= i.status %>
Nog geen facturen
+
+
+ + + diff --git a/views/forgot-password.ejs b/views/forgot-password.ejs new file mode 100644 index 0000000..fad7331 --- /dev/null +++ b/views/forgot-password.ejs @@ -0,0 +1,33 @@ +Wachtwoord vergeten — Mek-Tech + + \ No newline at end of file diff --git a/views/index.ejs b/views/index.ejs new file mode 100644 index 0000000..797e422 --- /dev/null +++ b/views/index.ejs @@ -0,0 +1,63 @@ + + + + + + AI Consultancy Framework - Mek-Tech + + + + + + +
+
+ +
+
+
+
+ +
+ <% questions.forEach(function(q, index) { %> +
+
+ Stap <%= index + 1 %> / <%= questions.length %> +

<%= q.title %>

+
+
+ <% q.fields.forEach(function(field) { %> +
+ + <% if (field.type === 'textarea') { %> + + <% } else { %> + > + <% } %> +
+ <% }) %> +
+
+ <% if (index > 0) { %> + + <% } %> + <% if (index < questions.length - 1) { %> + + <% } else { %> + + <% } %> +
+
+ <% }) %> +
+
+ + + + diff --git a/views/infrastructure.ejs b/views/infrastructure.ejs new file mode 100644 index 0000000..6859f83 --- /dev/null +++ b/views/infrastructure.ejs @@ -0,0 +1,237 @@ +Infrastructuur — Mek-Tech + + + +
+
+

Infrastructuur Beheer

Racks, netwerkapparatuur en architectuurdiagrammen op één plek

+ +
+ +
+ + + + +
+ + +
+ + +
+

Recente Racks

+<% if (recentRacks.length === 0) { %>
Nog geen racks
<% } else { %><% } %>
+ +

Netwerk Types

+<% if (netDeviceTypes.length === 0) { %>
Geen netwerk devices
<% } else { %> +<% var maxNd = Math.max(...netDeviceTypes.map(function(e){return e.cnt}), 1); %> +<% netDeviceTypes.forEach(function(d){ %>
<%= d.device_type %><%= d.cnt %>
+
+<% }) %><% } %>
+ +

Diagram Types

+<% if (diagramTypes.length === 0) { %>
Geen diagrammen
<% } else { %> +<% var maxDg = Math.max(...diagramTypes.map(function(e){return e.cnt}), 1); %> +<% diagramTypes.forEach(function(d){ %>
<%= d.diagram_type.replace(/_/g,' ') %><%= d.cnt %>
+
+<% }) %><% } %>
+
+ +
+

Device Types

+<% if (deviceTypes.length === 0) { %>
Geen rack devices
<% } else { %>
<% deviceTypes.forEach(function(d){ %>
<%= d.device_type %><%= d.cnt %>
<% }) %>
<% } %>
+

Per Cliënt

+<% if (racksByClient.length === 0) { %>
Geen data
<% } else { %>
<% racksByClient.forEach(function(r){ %>
<%= r.client_name %><%= r.cnt %> racks
<% }) %>
<% } %>
+
+
+ + + + + +
+

Netwerkapparatuur

Alle netwerk
+<% if (netDevices.length === 0) { %>
Geen netwerk devices. Voeg device toe.
<% } else { %> + +<% } %> + +<% if (connections.length > 0) { %>
+

Verbindingen

+
<% connections.slice(0,10).forEach(function(c){ %>
<%= c.name || 'Onbekend' %> · <%= c.from_type %> → <%= c.to_type %><%= c.speed %> · <%= c.media_type %>
<% }) %>
+
<% } %> +
+ + +
+

Architectuur Diagrammen

Alle diagrammen
+<% if (diagrams.length === 0) { %>
Geen diagrammen. Maak je eerste diagram.
<% } else { %> + +<% } %> +
+
+ + + + + + diff --git a/views/integrations.ejs b/views/integrations.ejs new file mode 100644 index 0000000..9e7fe89 --- /dev/null +++ b/views/integrations.ejs @@ -0,0 +1,116 @@ + + + + + +Integraties — Mek-Tech + + + + + + + + + + + +
+ Terug naar dashboard +<% if (saved) { %>
Integratie opgeslagen.
<% } %> + +
+

Integraties

+

Verbind externe systemen met je Mek-Tech consultancy platform — AI, calendar, CRM en boekhouding.

+
+ +
+<% var activeIds = (activeIntegrations || []).map(function(i){ return i.id; }); +integrations.forEach(function(integration) { + var isConnected = activeIds.indexOf(integration.id) >= 0; +%> +
+
+
<%= integration.name %>
+
<%= integration.description %>
+<%= isConnected ? 'Verbonden' : 'Beschikbaar' %> +
+<% if (integration.category === 'ai') { %> +Configureren +<% } else if (isConnected) { %> +
+ +<% } else { %> + +<% } %> +
+
+<% }) %> +
+
+ +
+
+

Integratie verbinden

+
+ + + + +
+ + +
+
+
+
+ + + + + diff --git a/views/invoice-detail.ejs b/views/invoice-detail.ejs new file mode 100644 index 0000000..e6526d1 --- /dev/null +++ b/views/invoice-detail.ejs @@ -0,0 +1,39 @@ +<%= invoice.number %> — Mek-Tech + + +
+<% if (sent) { %>
Factuur verzonden per email!
<% } %> +<% if (error) { %>
<%= error %>
<% } %> + Terug +
+

Mek-Tech Consulting

<%= invoice.contact_email %>
<%= invoice.website || '' %>

+

<%= invoice.client_name %>

<%= invoice.contact_name || '' %>
<%= invoice.contact_email || '' %>
<%= invoice.contact_phone || '' %>

+
+
+

Factuur

<%= invoice.number %>

Datum: <%= invoice.date %>
Vervaldatum: <%= invoice.due_date || '-' %>

+

Status

+ + +
+
+
+
+ Alle facturen +
+ +<% items.forEach(function(item){ %> + +<% }) %> + + +
OmschrijvingAantalPrijsTotaal
<%= item.description %><%= item.quantity %>€<%= (item.unit_price || 0).toFixed(2) %>€<%= (item.total || 0).toFixed(2) %>
Subtotaal€<%= (invoice.subtotal || 0).toFixed(2) %>
BTW€<%= (invoice.tax || 0).toFixed(2) %>
+
€<%= (invoice.total || 0).toFixed(2) %>
Totaal inclusief BTW
+<% if (invoice.notes) { %>

Notities

<%= invoice.notes %>

<% } %> +
+ diff --git a/views/invoice-form.ejs b/views/invoice-form.ejs new file mode 100644 index 0000000..692702e --- /dev/null +++ b/views/invoice-form.ejs @@ -0,0 +1,31 @@ +Nieuwe Factuur — Mek-Tech + +
+ Terug + +<% if (error) { %>
<%= error %>
<% } %> +
+
+
+
+
+
+
+
+
+

Factuurregels

+
+ +
+
+
+
+
+
+
+
+
Annuleren
+
+ + diff --git a/views/invoice-list.ejs b/views/invoice-list.ejs new file mode 100644 index 0000000..f07015e --- /dev/null +++ b/views/invoice-list.ejs @@ -0,0 +1,15 @@ +Facturen — Mek-Tech + +
+ Terug + +
+
€<%= totalOutstanding.toFixed(2) %>
Openstaand
+
€<%= totalPaid.toFixed(2) %>
Betaald
+
+ +<% invoices.forEach(function(i){ %> + +<% }) %> +
FactuurnrCliëntDatumVervaldatumSubtotaalTotaalStatus
<%= i.number %><%= i.client_name %><%= i.date %><%= i.due_date || '-' %>€<%= (i.subtotal || 0).toFixed(2) %>€<%= (i.total || 0).toFixed(2) %><%= i.status %>Details
+ diff --git a/views/login.ejs b/views/login.ejs new file mode 100644 index 0000000..4f619cf --- /dev/null +++ b/views/login.ejs @@ -0,0 +1,56 @@ + + + + <%- include('partials/head', { pageTitle: 'Login' }) %> + + + + + + diff --git a/views/login.ejs.bak b/views/login.ejs.bak new file mode 100644 index 0000000..13e3c4c --- /dev/null +++ b/views/login.ejs.bak @@ -0,0 +1,41 @@ + + + + + + Inloggen — Mek-Tech Consulting + + + + + + + + + diff --git a/views/market-intel.ejs b/views/market-intel.ejs new file mode 100644 index 0000000..375c844 --- /dev/null +++ b/views/market-intel.ejs @@ -0,0 +1,189 @@ +Market Intelligence — Mek-Tech + + +
+

Market Intelligence

Dutch datacenters & data-driven enterprises — filter, search and explore on the map

+ +
+
0datacenters
+
0 MWpower
+
0prospects
+
0DW vendors
+
0industries
+
0regions
+
+ +
+ + + +
+ +
+ + + + + + + + +
+ +
+
+
+
+ +
+
+
CompanyData WarehouseBI / CloudAI / DatabaseContact
+
+ +
+
+
+Tier IV +Tier III +Hyperscale +Company HQ +Edge/Regional +
+
+
+ + + + \ No newline at end of file diff --git a/views/neon.css b/views/neon.css new file mode 100644 index 0000000..7b278cd --- /dev/null +++ b/views/neon.css @@ -0,0 +1,186 @@ +/* Mek-Tech Neon Theme — Global Visual Layer */ + +@keyframes neonPulse { + 0%, 100% { opacity: 1; filter: drop-shadow(0 0 8px var(--accent)); } + 50% { opacity: 0.85; filter: drop-shadow(0 0 18px var(--accent)); } +} + +@keyframes gradientShift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 6px var(--accent), 0 0 12px rgba(0,212,255,0.3); opacity: 1; } + 50% { box-shadow: 0 0 14px var(--accent), 0 0 28px rgba(0,212,255,0.5); opacity: 0.85; } +} + +@keyframes neon-border-flow { + 0% { background-position: 0% 50%; } + 100% { background-position: 200% 50%; } +} + +/* ── Page background — volgt --bg uit instellingen ── */ +body { + background-color: var(--bg) !important; + background-image: + radial-gradient(ellipse 90% 55% at 50% -15%, rgba(0, 212, 255, 0.1) 0%, transparent 60%), + radial-gradient(ellipse 50% 35% at 95% 90%, rgba(168, 85, 247, 0.07) 0%, transparent 55%), + radial-gradient(ellipse 40% 30% at 5% 70%, rgba(0, 255, 136, 0.05) 0%, transparent 50%) !important; + background-attachment: fixed !important; +} + +/* ── Navbar ── */ +.navbar { + backdrop-filter: blur(16px) !important; + background: var(--bg-nav) !important; + border-bottom: 1px solid var(--border) !important; + box-shadow: 0 1px 0 rgba(0, 212, 255, 0.08), 0 4px 24px rgba(0, 0, 0, 0.2) !important; +} + +.nav-brand:hover { color: var(--accent) !important; text-shadow: 0 0 12px rgba(0,212,255,0.5); } + +/* ── Sidebar ── */ +.sidebar { + border-right: 1px solid rgba(0, 212, 255, 0.2) !important; + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.12), inset -1px 0 0 rgba(255, 255, 255, 0.04) !important; +} + +.sidebar .sb-brand { + background: linear-gradient(135deg, transparent, rgba(0, 212, 255, 0.06)) !important; + border-bottom: 1px solid rgba(0, 212, 255, 0.12) !important; +} + +.brand-dot, .sidebar .sb-brand h2 .brand-dot { + background: var(--accent) !important; + box-shadow: 0 0 10px var(--accent), 0 0 20px rgba(0,212,255,0.4) !important; + animation: pulse-glow 2s ease-in-out infinite !important; +} + +.sb-item.active { + color: var(--accent) !important; + background: rgba(0, 212, 255, 0.1) !important; + box-shadow: inset 3px 0 0 var(--accent), 0 0 16px rgba(0, 212, 255, 0.08) !important; +} + +.sb-item.active .sb-icon svg, +.sb-item:hover .sb-icon svg { + filter: drop-shadow(0 0 5px var(--accent)); +} + +.sb-item:hover { + background: rgba(0, 212, 255, 0.06) !important; + color: var(--text) !important; +} + +/* ── Cards & KPIs ── */ +.card, .kpi-card, .client-card, .integration-card, .provider-card, +.stat-card, .advice-card, .task-item, .rm-card, .nd-card, .login-card { + border: 1px solid rgba(0, 212, 255, 0.12) !important; + box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.04) !important; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important; +} + +.card:hover, .kpi-card:hover, .client-card:hover, .stat-card:hover, +.advice-card:hover, .task-item:hover, .rm-card:hover, .nd-card:hover { + border-color: rgba(0, 212, 255, 0.45) !important; + box-shadow: 0 0 28px rgba(0, 212, 255, 0.2), 0 8px 24px rgba(0, 0, 0, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.1) !important; + transform: translateY(-2px); +} + +.kpi-card .kpi-num { + text-shadow: 0 0 30px rgba(0, 212, 255, 0.25) !important; +} + +.kpi-card.accent .kpi-num { color: var(--accent) !important; text-shadow: 0 0 24px rgba(0, 212, 255, 0.55) !important; } +.kpi-card.green .kpi-num { text-shadow: 0 0 24px rgba(0, 255, 136, 0.45) !important; } +.kpi-card.purple .kpi-num { text-shadow: 0 0 24px rgba(168, 85, 247, 0.45) !important; } +.kpi-card.orange .kpi-num { text-shadow: 0 0 24px rgba(255, 179, 71, 0.45) !important; } + +/* ── Buttons ── */ +.btn-primary, .login-btn, button.btn-primary { + background: linear-gradient(135deg, #00d4ff, #0099cc) !important; + border: 1px solid rgba(0, 212, 255, 0.4) !important; + box-shadow: 0 0 20px rgba(0, 212, 255, 0.3), inset 0 1px 0 rgba(255,255,255,0.15) !important; + text-shadow: 0 0 20px rgba(0, 212, 255, 0.4) !important; + color: #fff !important; +} + +.btn-primary:hover, .login-btn:hover { + box-shadow: 0 0 36px rgba(0, 212, 255, 0.5), 0 4px 16px rgba(0, 0, 0, 0.2) !important; + transform: translateY(-1px); +} + +/* ── Status badges ── */ +.status-badge.lead { border-color: var(--accent) !important; color: var(--accent) !important; box-shadow: 0 0 8px rgba(0,212,255,0.2); } +.status-badge.active { border-color: var(--green) !important; color: var(--green) !important; box-shadow: 0 0 8px rgba(0,255,136,0.2); } + +/* ── Form focus ── */ +input:focus, select:focus, textarea:focus { + border-color: var(--accent) !important; + box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.15), 0 0 20px rgba(0, 212, 255, 0.1) !important; + outline: none !important; +} + +/* ── Tables ── */ +.data-table th { background: rgba(0, 212, 255, 0.04) !important; } +.data-table tr:hover td { background: rgba(0, 212, 255, 0.04) !important; } + +/* ── Links ── */ +.link, a.link { color: var(--accent) !important; } +.link:hover { text-shadow: 0 0 8px rgba(0,212,255,0.4); } + +/* ── CRM bar ── */ +.crm-bar { + background: rgba(0, 212, 255, 0.07) !important; + border: 1px solid rgba(0, 212, 255, 0.2) !important; + box-shadow: 0 0 20px rgba(0, 212, 255, 0.06) !important; + border-radius: 10px !important; +} + +/* ── Page headers ── */ +.page-hdr h1, .dash-header h1 { + background: linear-gradient(135deg, var(--text) 0%, var(--accent) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ── Neon top accent on cards ── */ +.kpi-card::after, .client-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0; + transition: opacity 0.25s; +} + +.kpi-card { position: relative; overflow: hidden; } +.kpi-card:hover::after { opacity: 1; } + +.fade-in-up { animation: fadeInUp 0.45s ease-out forwards; } + +.designer-frame { + border: 1px solid rgba(0, 212, 255, 0.25) !important; + border-radius: 12px !important; + box-shadow: 0 0 40px rgba(0, 212, 255, 0.1) !important; +} + +/* ── Scrollbar ── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: rgba(0,212,255,0.25); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: rgba(0,212,255,0.45); } diff --git a/views/net-connection-form.ejs b/views/net-connection-form.ejs new file mode 100644 index 0000000..d0ac965 --- /dev/null +++ b/views/net-connection-form.ejs @@ -0,0 +1,151 @@ + + + + + + <%= connection ? 'Bewerk' : 'Nieuwe' %> Connectie — Mek-Tech + + + + + + + + +
+ Terug + + +
+
+ + +
+
+ + +
+ +

Van

+
+
+ + +
+
+ + +
+
+ + +
+
+ +

Naar

+
+
+ + +
+
+ + +
+
+ + +
+
+ +

Properties

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Annuleren +
+
+
+ + diff --git a/views/net-device-detail.ejs b/views/net-device-detail.ejs new file mode 100644 index 0000000..6956668 --- /dev/null +++ b/views/net-device-detail.ejs @@ -0,0 +1,76 @@ + + + + + + <%= device.name %> — Mek-Tech Consulting + + + + + + + + +
+ Terug + + +
+
+

Device Info

+ + + + + + + + + +
Type
Model<%= device.model || '-' %>
Manufacturer<%= device.manufacturer || '-' %>
IP Adres<%= device.ip_address || '-' %>
Mgmt IP<%= device.mgmt_ip || '-' %>
Poorten<%= device.ports_count %>
OS<%= device.os_version || '-' %>
Status<%= device.status %>
+
+
+

Connecties (<%= connections.length %>)

+ <% if (connections.length === 0) { %> +

Geen connecties

+ <% } else { %> +
+ <% connections.forEach(function(c) { %> +
+ <%= c.name || 'Connectie' %> + + <% if (c.from_type === 'network_device' && c.from_id == device.id) { %> + Poort <%= c.from_port || '?' %> → <%= c.to_type %> <%= c.to_id || '?' %> poort <%= c.to_port || '?' %> + <% } else { %> + <%= c.from_type %> <%= c.from_id || '?' %> poort <%= c.from_port || '?' %> → Poort <%= c.to_port || '?' %> + <% } %> + + <%= c.speed %> · <%= c.media_type %> +
+ <% }) %> +
+ <% } %> +
+
+ + <% if (device.notes) { %> +
+

Notities

+

<%= device.notes %>

+
+ <% } %> +
+ + diff --git a/views/net-device-form.ejs b/views/net-device-form.ejs new file mode 100644 index 0000000..768398b --- /dev/null +++ b/views/net-device-form.ejs @@ -0,0 +1,96 @@ + + + + + + <%= device ? 'Bewerk' : 'Nieuw' %> Netwerk Device — Mek-Tech + + + + + + + + +
+ Terug + + +
+ <% if (!device) { %> +
+ + +
+ <% } %> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Annuleren +
+
+
+ + diff --git a/views/networking-list.ejs b/views/networking-list.ejs new file mode 100644 index 0000000..9f0b8cd --- /dev/null +++ b/views/networking-list.ejs @@ -0,0 +1,75 @@ + + + + + + Netwerk — Mek-Tech Consulting + + + + + + + + +
+ Terug + + +
+

Netwerk Devices (<%= devices.length %>)

+ <% if (devices.length === 0) { %> +

Nog geen netwerk devices. Voeg een switch of router toe.

+ <% } else { %> + + <% } %> +
+ +
+

Connecties (<%= connections.length %>)

+ <% if (connections.length === 0) { %> +

Nog geen connecties.

+ <% } else { %> + + + + <% connections.forEach(function(c) { %> + + + + + + + + + + + + <% }) %> + +
NaamCliëntVanPoortNaarPoortMediaSnelheidStatus
<%= c.name || '-' %><%= c.client_name %><%= c.from_type %> <%= c.from_id || '' %><%= c.from_port || '-' %><%= c.to_type %> <%= c.to_id || '' %><%= c.to_port || '-' %><%= c.media_type %><%= c.speed %><%= c.status %>
+ <% } %> +
+
+ + diff --git a/views/notification-list.ejs b/views/notification-list.ejs new file mode 100644 index 0000000..b10b653 --- /dev/null +++ b/views/notification-list.ejs @@ -0,0 +1,17 @@ +Meldingen — Mek-Tech + +
+ Terug + +<% if (notifications.length === 0) { %>

Geen meldingen

<% } else { %> +
+<% notifications.forEach(function(n){ %> +
+

<%= n.title %>

<%= n.message %>

<%= new Date(n.created_at).toLocaleString('nl-NL') %> · <%= n.channel %>

+
+
+<% }) %> +
+<% } %>
+ diff --git a/views/partials/crm-bar.ejs b/views/partials/crm-bar.ejs new file mode 100644 index 0000000..638f8fe --- /dev/null +++ b/views/partials/crm-bar.ejs @@ -0,0 +1,19 @@ +<% if (typeof showCrmBar !== 'undefined' && showCrmBar !== false) { %> +
+ + CRM Context + + <% if (activeClient) { %> +
+ <% (typeof clientHubLinks !== 'undefined' ? clientHubLinks : []).forEach(function(link) { %> + <%= link.label %> + <% }); %> +
+ <% } %> +
+<% } %> diff --git a/views/partials/head.ejs b/views/partials/head.ejs new file mode 100644 index 0000000..49d7958 --- /dev/null +++ b/views/partials/head.ejs @@ -0,0 +1,10 @@ + + +<%= typeof pageTitle !== 'undefined' ? pageTitle + ' — ' : '' %>Mek-Tech Enterprise + + + + + + + diff --git a/views/partials/navbar.ejs b/views/partials/navbar.ejs new file mode 100644 index 0000000..f39c951 --- /dev/null +++ b/views/partials/navbar.ejs @@ -0,0 +1,19 @@ + diff --git a/views/partials/rack-workspace-nav.ejs b/views/partials/rack-workspace-nav.ejs new file mode 100644 index 0000000..8eeb55f --- /dev/null +++ b/views/partials/rack-workspace-nav.ejs @@ -0,0 +1,30 @@ +<% var wsRackId = typeof rack !== 'undefined' && rack ? rack.id : (typeof rackId !== 'undefined' ? rackId : ''); %> +<% var wsTab = typeof workspaceTab !== 'undefined' ? workspaceTab : 'rack'; %> +<% var wsFlowId = typeof flowDiagram !== 'undefined' && flowDiagram ? flowDiagram.id : null; %> +<% var wsLandscapeId = typeof landscapeDiagram !== 'undefined' && landscapeDiagram ? landscapeDiagram.id : null; %> + + diff --git a/views/partials/scripts.ejs b/views/partials/scripts.ejs new file mode 100644 index 0000000..1d125aa --- /dev/null +++ b/views/partials/scripts.ejs @@ -0,0 +1,2 @@ + + diff --git a/views/partials/sidebar.ejs b/views/partials/sidebar.ejs new file mode 100644 index 0000000..f0d4f9b --- /dev/null +++ b/views/partials/sidebar.ejs @@ -0,0 +1,20 @@ + diff --git a/views/pdf-viewer.ejs b/views/pdf-viewer.ejs new file mode 100644 index 0000000..7a1f3dd --- /dev/null +++ b/views/pdf-viewer.ejs @@ -0,0 +1,10 @@ +<%= file.original_name %> — Mek-Tech + +
+ Terug +<%= file.original_name %> +<%= (file.file_size / 1024).toFixed(0) %> KB +Downloaden +
+ + diff --git a/views/project-detail.ejs b/views/project-detail.ejs new file mode 100644 index 0000000..3a0aae3 --- /dev/null +++ b/views/project-detail.ejs @@ -0,0 +1,79 @@ + + + + + +<%= project ? project.name : 'Project' %> — Mek-Tech + + + + + + + + + +
+ Terug naar projecten +<% if (!project) { %> +

Project niet gevonden.

Naar projecten
+<% } else { %> +
+
+

<%= project.name %>

+

<%= project.client_name || 'Geen cliënt' %> · <%= project.status %> · Prioriteit: <%= project.priority %>

+<% if (project.description) { %>

<%= project.description %>

<% } %> +
+Cliënt bekijken +
+
+
<%= tasks.length %>
Taken
+
<%= tasks.filter(t=>t.status==='done').length %>
Voltooid
+
<%= milestones.length %>
Milestones
+
+
+
+
Taken
+<% if (tasks.length === 0) { %>

Nog geen taken voor dit project.

<% } else { %> +<% tasks.forEach(function(t) { %> +
+<%= t.status %> +<%= t.title %> +<%= t.assignee_name || '—' %> +
+<% }) %> +<% } %> +
+
+
Milestones
+<% if (milestones.length === 0) { %>

Geen milestones gepland.

<% } else { %> +<% milestones.forEach(function(m) { %> +
+<%= m.name %> +<%= m.target_date || '—' %> +
+<% }) %> +<% } %> +
+
+<% } %> +
+ + + + diff --git a/views/projects.ejs b/views/projects.ejs new file mode 100644 index 0000000..8398784 --- /dev/null +++ b/views/projects.ejs @@ -0,0 +1,169 @@ +Projecten — Mek-Tech Enterprise + + + + +
+ +
+

Project Management

<%= projects.length %> projecten

+
+
+<% const columns = ['todo','in_progress','review','done']; const columnNames = ['To Do','In Progress','Review','Done']; %> +<% columns.forEach((col,i)=>{ %> +
+
<%= columnNames[i] %><%= projectTasks.filter(t=>t.status===col).length %>
+
+<% projectTasks.filter(t=>t.status===col).forEach(task=>{ %> +
+
<%= task.title %>
+
+<%= task.priority %> + <%= task.assignee_name||'Unassigned' %> +
+
+
+
+
+<% }) %> +
+
+<% }) %> +
+
+
+
+
TaskProgress
+<% projectTasks.forEach(task=>{ %> +
+
<%= task.title %>
+
+
+
+
<%= task.progress %>%
+
+<% }) %> +
+
+ +
+ + + diff --git a/views/quality-dashboard.ejs b/views/quality-dashboard.ejs new file mode 100644 index 0000000..fb7b092 --- /dev/null +++ b/views/quality-dashboard.ejs @@ -0,0 +1,369 @@ +Data Quality — Mek-Tech + + + +
+ Terug + +
+

Data Quality Engine

Profileer, valideer en verbeter je data met enterprise-grade tools

+
+ pandas + great_expectations + dbt + scikit-learn +
+
+ +<% if (error) { %>
<%= error %>
<% } %> + +<% if (!uploaded && !profile) { %> +
+
+
+

Sleep een dataset of klik om te uploaden

+CSV · Excel · Parquet · JSON · TSV — max 100MB + +
+<% } %> + +<% if (profile) { %> + +<% var qScore = profile.overall_quality_score || 0; + var grade = qScore >= 90 ? 'A' : qScore >= 75 ? 'B' : qScore >= 50 ? 'C' : qScore >= 25 ? 'D' : 'F'; +%> + +
+ + + + + +
+ + +
+
+
<%= profile.rows.toLocaleString() %>
Rijen
+
<%= profile.columns %>
Kolommen
+
<%= profile.memory_mb %> MB
Geheugen
+
+
<%= qScore %>%
<%= grade %> Grade
+
+
+ + +
+<% profile.columns_profile.slice(0, 8).forEach(function(cp){ +var status = cp.null_pct > 20 ? 'danger' : cp.null_pct > 5 ? 'warn' : cp.null_pct === 0 ? 'good' : ''; +%> +
+

<%= cp.name %>

+
Type<%= cp.dtype %>
+
Compleet<%= (100-cp.null_pct).toFixed(1) %>%
+
+
Uniek<%= cp.unique %> (<%= cp.unique_pct.toFixed(0) %>%)
+<% if (cp.min !== undefined && cp.min !== null) { %>
Min → Max<%= cp.min %> → <%= cp.max %>
<% } %> +<% if (cp.histogram) { %>
<% var maxH=Math.max(...cp.histogram.counts,1); cp.histogram.counts.forEach(function(h){ %>
<% }) %>
<% } %> +<% if (cp.top_values) { var tvk=Object.keys(cp.top_values).slice(0,3); %>
Top: <% tvk.forEach(function(k,i){ %><%= k %><%= i<% }) %>
<% } %> +<% if (cp.null_count>0) { %>
<%= cp.null_count %> nulls
<% } %> +
+<% }) %> +
+
+ + +
+
+<% profile.columns_profile.forEach(function(cp){ +var colIssues = []; +if (cp.null_pct>20) colIssues.push({s:'high',t:'Hoge null-rate'}); +else if (cp.null_pct>5) colIssues.push({s:'medium',t:'Enkele nulls'}); +if (cp.negative>0) colIssues.push({s:'medium',t:'Negatieve waarden'}); +if (cp.unique_pct>95) colIssues.push({s:'low',t:'Unieke ID kolom'}); +if (cp.unique_pct<1) colIssues.push({s:'low',t:'Constante waarde'}); +var colStat = cp.null_pct > 20 ? 'danger' : cp.null_pct > 5 ? 'warn' : 'good'; +%> +
+

<%= cp.name %>

+
Dtype<%= cp.dtype %>
+
Compleet<%= (100-cp.null_pct).toFixed(1) %>% · <%= cp.null_count %> null
+
+
Uniek<%= cp.unique %> / <%= cp.non_null %> (<%= cp.unique_pct.toFixed(1) %>%)
+<% if (cp.min !== undefined && cp.min !== null) { %>
Range<%= cp.min %> — <%= cp.max %>
<% } %> +<% if (cp.mean !== undefined && cp.mean !== null) { %>
μ ± σ<%= cp.mean %> ± <%= cp.std %>
<% } %> +<% if (cp.q25 !== undefined) { %>
QuartielenQ1:<%= cp.q25 %> M:<%= cp.q50 %> Q3:<%= cp.q75 %>
<% } %> +<% if (cp.histogram) { %>
<% var hMax=Math.max(...cp.histogram.counts,1); cp.histogram.counts.forEach(function(h){ %>
<% }) %>
<% } %> +<% if (cp.top_values) { var tvk2=Object.keys(cp.top_values).slice(0,5); %>
Top: <% tvk2.forEach(function(k,i){ %><%= k %><%= i<% }) %>
<% } %> +<% if (colIssues.length>0) { %>
<% colIssues.forEach(function(i){ %><%= i.t %><% }) %>
<% } %> +
+<% }) %> +
+
+ + +
+<% if (profile.suggestions && profile.suggestions.length > 0) { %> +
+<% profile.suggestions.forEach(function(s){ %> +
+
+
+
<%= s.column %>
+
<%= s.issue %>
+
→ <%= s.action %>
+
+
+<% }) %> +
+<% } else { %>

Geen verbeterpunten — je data ziet er goed uit!

<% } %> +
+ + +
+ +<% if (profile.correlation && profile.correlation.length > 0) { %> +

Correlatie matrix

+
<% profile.correlation.forEach(function(c){ %>
<%= c.from %><%= c.to %><%= c.direction==='positive'?'+':'' %><%= c.value %>
<% }) %>
+
+<% } %> + +<% if (profile.distribution && Object.keys(profile.distribution).length > 0) { %> +

Distributie analyse

+
+<% Object.entries(profile.distribution).forEach(function(e){ var k=e[0],d=e[1]; %> +

<%= k %>

+
Mean<%= d.mean %>
+
Median<%= d.median %>
+
Stdev<%= d.std %>
+
Skew<%= d.skew %>
+
Outliers (IQR)<%= d.outliers_iqr %>
+
+<% }) %> +
<% } %> + +
+
+ + +
+
+
+

dbt Model validator

+

Valideer SQL modellen op best practices

+
+ +
+ + +
+ +
+
+
+
+

dbt Test runner

+

Voer data tests uit (unique, not_null, accepted_values)

+<% if (profile && profile.columns_profile) { %>
+ +
+ + +
+ +
<% } %> +
+
+
+ + + +<% } %> +
+ + + diff --git a/views/rack-infra-report.ejs b/views/rack-infra-report.ejs new file mode 100644 index 0000000..4a25155 --- /dev/null +++ b/views/rack-infra-report.ejs @@ -0,0 +1,149 @@ + + + + + +Infrastructuur Rapport — <%= rack.name %> — Mek-Tech + + + + + + + + + + +
+
+

Gecombineerd Infrastructuur Rapport

+
<%= rack.name %> · <%= rack.client_name %>
+
<%= rack.location || '' %> <%= rack.datacenter ? '· ' + rack.datacenter : '' %> · <%= new Date().toLocaleDateString('nl-NL',{weekday:'long',year:'numeric',month:'long',day:'numeric'}) %>
+
+ +
+

Rack Overzicht

+
+
<%= devices.length %>
Devices
+
<%= usedU %>/<%= rack.total_units || 42 %>
Rack Units
+
<%= Math.round(usedU/(rack.total_units||42)*100) %>%
Bezetting
+
<%= diagrams.length %>
Diagrammen
+
+
+<% devices.forEach(function(d){ %> +
+U<%= d.position_u %> — <%= d.name %> +<%= d.manufacturer || '' %> <%= d.model || '' %> · <%= d.device_type %> · <%= d.height_u %>U +<% if (d.mgmt_ip) { %>
<%= d.mgmt_ip %><% } %> +
+<% }) %> +<% if (devices.length === 0) { %>

Geen devices in dit rack.

<% } %> +
+ + + +<% devices.forEach(function(d){ %> + +<% }) %> + +
PosNaamTypeVendor / ModelIPStatus
U<%= d.position_u %><%= d.name %><%= d.device_type %><%= d.manufacturer || '—' %> <%= d.model || '' %><%= d.mgmt_ip || '—' %><%= d.status || 'active' %>
+
+ +<% if (diagrams.length > 0) { %> +
+

Applicatie Flows & Landschap

+<% diagrams.forEach(function(d){ %> +
+

<%= d.name %>

+
+<%= d.typeLabel %> +<%= d.nodeCount %> componenten · <%= d.edgeCount %> flows · bijgewerkt <%= new Date(d.updated_at).toLocaleDateString('nl-NL') %> +
+<% if (Object.keys(d.layers || {}).length) { %> +
+<% Object.entries(d.layers).forEach(function(e){ %><%= e[0] %>: <%= e[1] %><% }) %> +
+<% } %> +<% if (d.nodes && d.nodes.length) { %> +
+<% d.nodes.slice(0,12).forEach(function(n,i){ %> +<% if (i > 0) { %><% } %> +<%= n.label || n.type %><% if (n.rack_device_id) { %> ●rack<% } %> +<% }) %> +<% if (d.nodes.length > 12) { %>+<%= d.nodes.length - 12 %> meer<% } %> +
+<% } %> +<% if (d.edges && d.edges.length) { %> + + + +<% d.edges.forEach(function(e,i){ + var from = (d.nodes || []).find(function(n){ return n.id == e.from; }); + var to = (d.nodes || []).find(function(n){ return n.id == e.to; }); +%> + + + + + + + +<% }) %> + +
#VanNaarProtocolAnimatie
<%= i+1 %><%= from ? from.label : e.from %>→ <%= to ? to.label : e.to %><%= e.protocol || '—' %><%= e.flow || 'off' %>
+<% } %> +Open in designer +
+<% }) %> +
+<% } else { %> +
+

Applicatie Flows & Landschap

+

Nog geen flows of landschap gekoppeld aan dit rack.

+ +
+<% } %> + + +
+ + + + diff --git a/views/rack-studio.ejs b/views/rack-studio.ejs new file mode 100644 index 0000000..3407f76 --- /dev/null +++ b/views/rack-studio.ejs @@ -0,0 +1,92 @@ + + + + + +Studio — <%= rack.name %> — Mek-Tech + + + + + + + + +
+<%- include('partials/rack-workspace-nav', { rack, flowDiagram, landscapeDiagram, workspaceTab: 'studio' }) %> +
+
+

Gecombineerde Studio — <%= rack.name %>

+

Infrastructuur links · Applicatie flows rechts · sleep de scheidingslijn om te vergroten/verkleinen

+
+
+ + + + + Rapport +
+
+
+
+ +
+
+
+ +
+
+
+ Tip: Wijzig het rack links (devices slepen) en teken flows rechts. Gebruik de tabs bovenaan om te wisselen — je blijft altijd binnen rack <%= rack.name %>. + <% if (!flowDiagram) { %> Nog geen flow? Klik Voorbeeld laden voor een startpunt. <% } %> +
+
+ + + + diff --git a/views/racks-form.ejs b/views/racks-form.ejs new file mode 100644 index 0000000..182b657 --- /dev/null +++ b/views/racks-form.ejs @@ -0,0 +1,65 @@ + + + + + + <%= rack ? 'Bewerk' : 'Nieuwe' %> Rack — Mek-Tech Consulting + + + + + + + + +
+ Terug + + +
+ <% if (!rack) { %> +
+ + +
+ <% } %> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Annuleren +
+
+
+ + diff --git a/views/racks-list.ejs b/views/racks-list.ejs new file mode 100644 index 0000000..36b0adb --- /dev/null +++ b/views/racks-list.ejs @@ -0,0 +1,48 @@ + + + + + + Racks — Mek-Tech Consulting + + + + + + + + + + + diff --git a/views/racks-view.ejs b/views/racks-view.ejs new file mode 100644 index 0000000..5c50f14 --- /dev/null +++ b/views/racks-view.ejs @@ -0,0 +1,752 @@ + + + + + +<%= rack.name %> — Rack View — Mek-Tech + + + + + + + + + + + +
+ + + Terug + + +
+ + +
+
+
<%= rack.name %> · <%= devices.reduce((s,d)=>s+d.height_u,0) %>/<%= rack.total_units %>U
+
+ + +
+
+
Drag devices to reposition
+ <% + var units = []; + devices.forEach(function(d) { for(var u = d.position_u; u < d.position_u + d.height_u; u++) units[u] = d; }); + var _html = ''; + for(var u = rack.total_units; u >= 1; u--) { + var dev = units[u]; + if (dev && u === dev.position_u) { + var vc = 'v-other', vb = '#555d66'; + var vm = (dev.manufacturer||'').toLowerCase(); + if(vm.includes('hpe')||vm.includes('hewlett')){vc='v-hpe';vb='#ff7b00'} + else if(vm.includes('dell')){vc='v-dell';vb='#0076a8'} + else if(vm.includes('lenovo')){vc='v-lenovo';vb='#e41f33'} + else if(vm.includes('cisco')){vc='v-cisco';vb='#00529a'} + else if(vm.includes('juniper')){vc='v-juniper';vb='#44a833'} + else if(vm.includes('arista')){vc='v-arista';vb='#cc3333'} + else if(vm.includes('netapp')){vc='v-netapp';vb='#00a1df'} + else if(vm.includes('pure')){vc='v-pure';vb='#4a90d9'} + else if(vm.includes('fortinet')){vc='v-fortinet';vb='#ee3124'} + else if(vm.includes('super')||vm.includes('supermicro')){vc='v-supermicro';vb='#e6a817'} + else if(vm.includes('apc')){vc='v-apc';vb='#6e7681'} + else if(vm.includes('f5')){vc='v-f5';vb='#d32f2f'} + else if(vm.includes('palo')){vc='v-paloalto';vb='#ed7b30'} + else if(vm.includes('nvidia')){vc='v-nvidia';vb='#76b900'} + else if(vm.includes('oracle')){vc='v-oracle';vb='#f80000'} + else if(vm.includes('ibm')){vc='v-ibm';vb='#0062ff'} + else if(dev.device_type==='patch_panel'||dev.device_type==='pdu'||dev.device_type==='ups'){vc='v-apc';vb='#6e7681'} + var dtIcon='server'; + if(dev.device_type==='switch') dtIcon='git-branch'; + else if(dev.device_type==='storage') dtIcon='database'; + else if(dev.device_type==='firewall') dtIcon='shield'; + else if(dev.device_type==='router') dtIcon='radio'; + else if(dev.device_type==='loadbalancer') dtIcon='scale'; + else if(dev.device_type==='san') dtIcon='hard-drive'; + else if(dev.device_type==='patch_panel') dtIcon='cable'; + else if(dev.device_type==='pdu') dtIcon='zap'; + else if(dev.device_type==='ups') dtIcon='battery-charging'; + else if(dev.device_type==='cooling') dtIcon='fan'; + else if(dev.device_type==='tape') dtIcon='archive'; + else if(dev.device_type==='kvm') dtIcon='monitor'; + else if(dev.device_type==='other') dtIcon='box'; + var topx = (rack.total_units - dev.position_u - dev.height_u + 1) * 30 + 20; + var hx = dev.height_u * 30 - 2; + _html += '
'; + _html += ''; + _html += '' + dev.name + ''; + _html += '' + (dev.model||dev.manufacturer||dev.device_type) + ''; + if (dev.mgmt_ip) _html += '' + dev.mgmt_ip + ''; + _html += '' + dev.height_u + 'U'; + _html += '
'; + } else if (!dev) { + _html += '
' + u + '
'; + } + } + %> + <%- _html %> +
+
+
+ + + +
+ +
+ + +<% devices.forEach(function(d) { %> + <% var vm=(d.manufacturer||'').toLowerCase(); %> + <% var vb='#555d66'; %> + <% if(vm.includes('hpe')) vb='#ff7b00'; else if(vm.includes('dell')) vb='#0076a8'; + else if(vm.includes('lenovo')) vb='#e41f33'; else if(vm.includes('cisco')) vb='#00529a'; + else if(vm.includes('juniper')) vb='#44a833'; else if(vm.includes('arista')) vb='#cc3333'; + else if(vm.includes('netapp')) vb='#00a1df'; else if(vm.includes('pure')) vb='#4a90d9'; + else if(vm.includes('fortinet')) vb='#ee3124'; else if(vm.includes('apc')) vb='#6e7681'; %> + <% var di='server'; %> + <% if(d.device_type==='switch') di='git-branch'; else if(d.device_type==='storage') di='database'; + else if(d.device_type==='firewall') di='shield'; else if(d.device_type==='router') di='radio'; + else if(d.device_type==='loadbalancer') di='scale'; else if(d.device_type==='san') di='hard-drive'; + else if(d.device_type==='patch_panel') di='cable'; else if(d.device_type==='pdu') di='zap'; + else if(d.device_type==='ups') di='battery-charging'; else if(d.device_type==='cooling') di='fan'; + else if(d.device_type==='tape') di='archive'; else if(d.device_type==='kvm') di='monitor'; + else if(d.device_type==='other') di='box'; %> + +<% }); %> + + + + \ No newline at end of file diff --git a/views/report-print.ejs b/views/report-print.ejs new file mode 100644 index 0000000..169328f --- /dev/null +++ b/views/report-print.ejs @@ -0,0 +1,66 @@ +Rapport — <%= answers.company_name || 'Mek-Tech' %> + +
+

AI & Data Assessment Rapport

<%= answers.company_name || 'Bedrijf' %> · <%= new Date().toLocaleDateString('nl-NL',{year:'numeric',month:'long',day:'numeric'}) %>

+ +

Architectuur Advies

+
+

Data Lake

+
+
Tier<%= report.architecture.datalake.tier %>
+
Storage<%= report.architecture.datalake.storage %>
+
Compute<%= report.architecture.datalake.compute %>
+
Catalog<%= report.architecture.datalake.catalog %>
+
Processing<%= report.architecture.datalake.processing %>
+
Governance<%= report.architecture.datalake.governance %>
+
+
+ +
+

AI/ML Platform

+
+
Tier<%= report.architecture.ai.tier %>
+
Serving<%= report.architecture.ai.serving %>
+
Orchestration<%= report.architecture.ai.orchestration %>
+
Monitoring<%= report.architecture.ai.monitoring %>
+
Modellen<%= report.architecture.ai.models %>
+
+
+ +

Implementatie Roadmap

+<% report.phases.forEach(function(p){ %> +

<%= p.name %>

    <% p.items.forEach(function(i){ %>
  • <%= i %>
  • <% }) %>
+<% }) %> + + + diff --git a/views/report.ejs b/views/report.ejs new file mode 100644 index 0000000..70c23d2 --- /dev/null +++ b/views/report.ejs @@ -0,0 +1,133 @@ + + + + + + Architectuur Rapport - <%= answers.company_name || 'Mek-Tech Consultancy' %> + + + + + + +
+
+
+ Mek-Tech +

Mek-Tech

+

AI Consultancy & Forward Engineering

+
+
+

Datacenter & AI Architectuur Rapport

+

Opgesteld voor: <%= answers.company_name %>

+

<%= new Date().toLocaleDateString('nl-NL', { year: 'numeric', month: 'long', day: 'numeric' }) %>

+
+
+ +
+

1. Huidige Situatie Analyse

+
+ + + + + + + + +
Bedrijf<%= answers.company_name %>
Sector<%= answers.industry %>
Servers<%= answers.servers || 'Niet gespecificeerd' %>
Opslag<%= answers.storage || 'Niet gespecificeerd' %>
Virtualisatie<%= answers.virtualization || 'Niet gespecificeerd' %>
Data volume<%= answers.data_volume || 'Niet gespecificeerd' %>
AI doelen<%= answers.ai_goals || 'Niet gespecificeerd' %>
+
+
+ +
+

2. Voorgestelde Architecture

+
+
+┌─────────────────────────────────────────────────────────────────┐
+│                   DATA LAKEHOUSE ARCHITECTUUR                    │
+├─────────────────────────────────────────────────────────────────┤
+│  ┌───────────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐   │
+│  │  Bronnen  │  │  Ingest   │  │  Opslag   │  │  Compute  │   │
+│  │           │  │           │  │           │  │           │   │
+│  │• OLTP     │  │• Kafka    │  │• MinIO    │  │• Spark    │   │
+│  │• API's    │──│• Flink    │──│• Iceberg  │──│• Trino    │   │
+│  │• Streaming│  │• Airbyte  │  │• Hive     │  │• Flink    │   │
+│  │• IoT      │  │• CDC      │  │• S3/ADLS  │  │• Dremio   │   │
+│  └───────────┘  └───────────┘  └───────────┘  └───────────┘   │
+│                                     │                           │
+│  ┌──────────────────────────────────┴──────────────────────┐   │
+│  │                    AI/ML LAAG                             │   │
+│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌─────────┐  │   │
+│  │  │ Ollama/  │  │ vLLM/    │  │ MLflow/  │  │ Kubeflow│  │   │
+│  │  │ Lama.cpp │  │ SGLang   │  │ Evidently│  │         │  │   │
+│  │  └──────────┘  └──────────┘  └──────────┘  └─────────┘  │   │
+│  └──────────────────────────────────────────────────────────┘   │
+│                                                                   │
+│  ┌──────────────────────────────────────────────────────────┐   │
+│  │              DATACENTER INFRASTRUCTUUR                    │   │
+│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌─────────┐  │   │
+│  │  │ K8s/     │  │ Leaf-    │  │ Zero     │  │ Observa-│  │   │
+│  │  │ Proxmox  │  │ Spine    │  │ Trust    │  │ bility  │  │   │
+│  │  │ Cluster  │  │ 100GbE   │  │ Network  │  │ Stack   │  │   │
+│  │  └──────────┘  └──────────┘  └──────────┘  └─────────┘  │   │
+│  └──────────────────────────────────────────────────────────┘   │
+└─────────────────────────────────────────────────────────────────┘
+        
+
+
+ +
+

3. Data Lakehouse Ontwerp (<%= report.architecture.datalake.tier %> Tier)

+
+
Opslag
<%= report.architecture.datalake.storage %>
+
Compute
<%= report.architecture.datalake.compute %>
+
Catalogus
<%= report.architecture.datalake.catalog %>
+
Processing
<%= report.architecture.datalake.processing %>
+
Governance
<%= report.architecture.datalake.governance %>
+
Tier
<%= report.architecture.datalake.tier %>
+
+
+ +
+

4. AI Backend (<%= report.architecture.ai.tier %> Tier)

+
+
Model Serving
<%= report.architecture.ai.serving %>
+
Orchestratie
<%= report.architecture.ai.orchestration %>
+
Monitoring
<%= report.architecture.ai.monitoring %>
+
Modellen
<%= report.architecture.ai.models %>
+
Tier
<%= report.architecture.ai.tier %>
+
+
+ +
+

5. Implementatie Roadmap

+ <% report.phases.forEach(function(phase) { %> +
+

<%= phase.name %>

+
    + <% phase.items.forEach(function(item) { %> +
  • <%= item %>
  • + <% }) %> +
+
+ <% }) %> +
+ +
+

6. Aanbevolen Tech Stack

+
+
Virtualisatie<%= report.architecture.infrastructure.virtualization %>
+
Netwerk<%= report.architecture.infrastructure.network %>
+
Security<%= report.architecture.infrastructure.security %>
+
Monitoring<%= report.architecture.infrastructure.monitoring %>
+
+
+ +
+

Gegenereerd door Mek-Tech AI Consultancy Framework © <%= new Date().getFullYear() %>

+

Dit rapport is vertrouwelijk en uitsluitend bestemd voor <%= answers.company_name %>.

+ +
+
+ + diff --git a/views/reports.ejs b/views/reports.ejs new file mode 100644 index 0000000..94ce33c --- /dev/null +++ b/views/reports.ejs @@ -0,0 +1,133 @@ +Reports — Mek-Tech Enterprise + + + + +
+ +
+

Reports

Genereer en beheer rapportages

+
+
+

Report Builder

+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ +
+
+

Opgeslagen Reports

+<% if(savedReports.length===0){ %>
Nog geen opgeslagen reports
<% }else{ %> +
+<% savedReports.forEach(report=>{ %> +
+
<%= report.name %>
+
<%= report.report_type %> · <%= new Date(report.created_at).toLocaleDateString('nl-NL') %>
+
+PDF +Excel +
+
+<% }) %> +
<% } %> +
+
+

Report Templates

+
+
+
+
Monthly Client Report
+
Standaard maandelijkse cliënt rapportage
+
+
+
+
Project Status Report
+
Project voortgang en mijlpalen
+
+
+
+
Revenue Report
+
Omzet en facturatie overzicht
+
+
+
+
+ + diff --git a/views/reset-password.ejs b/views/reset-password.ejs new file mode 100644 index 0000000..5c29eae --- /dev/null +++ b/views/reset-password.ejs @@ -0,0 +1,29 @@ +Wachtwoord resetten — Mek-Tech + + \ No newline at end of file diff --git a/views/search-results.ejs b/views/search-results.ejs new file mode 100644 index 0000000..9947cf3 --- /dev/null +++ b/views/search-results.ejs @@ -0,0 +1,25 @@ +Zoeken — Mek-Tech + +
+ Terug + +
+ + +
+<% if (query) { %> +

Resultaten voor "<%= query %>"

+<% var hasResults = results.clients.length || results.engagements.length || results.tasks.length || results.diagrams.length || results.notes.length; %> +<% if (!hasResults) { %>

Geen resultaten gevonden.

<% } %> +<% if (results.clients.length > 0) { %>
Cliënten (<%= results.clients.length %>)
+<% results.clients.forEach(function(c){ %><%= c.name %><%= c.status %><% }) %>
<% } %> +<% if (results.engagements.length > 0) { %>
Engagements (<%= results.engagements.length %>)
+<% results.engagements.forEach(function(e){ %><% }) %>
<% } %> +<% if (results.tasks.length > 0) { %>
Taken (<%= results.tasks.length %>)
+<% results.tasks.forEach(function(t){ %>
<%= t.priority %><%= t.status %>
<% }) %>
<% } %> +<% if (results.diagrams.length > 0) { %>
Diagrammen (<%= results.diagrams.length %>)
+<% results.diagrams.forEach(function(d){ %><% }) %>
<% } %> +<% if (results.notes.length > 0) { %>
Notities (<%= results.notes.length %>)
+<% results.notes.forEach(function(n){ %><% }) %>
<% } %> +<% } %>
+ diff --git a/views/server.js b/views/server.js new file mode 100644 index 0000000..e9b3318 --- /dev/null +++ b/views/server.js @@ -0,0 +1,115 @@ +require('dotenv').config(); +const express = require('express'); +const bodyParser = require('body-parser'); +const cors = require('cors'); +const path = require('path'); +const session = require('express-session'); +const SQLiteStore = require('connect-sqlite3')(session); +const multer = require('multer'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); +app.use(express.static(path.join(__dirname, 'public'))); +app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); +app.use(bodyParser.urlencoded({ extended: true })); +app.use(bodyParser.json()); +app.use(cors()); + +const { localeMiddleware } = require('./locales'); +app.use(localeMiddleware); + +app.use(session({ + store: new SQLiteStore({ db: 'sessions.db', dir: path.join(__dirname, 'data') }), + secret: process.env.SESSION_SECRET || 'mek-tech-consulting-secret', + resave: false, + saveUninitialized: false, + cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 } +})); + +app.use((req, res, next) => { + const publicPaths = ['/login', '/auth/login', '/css/dynamic.css']; + if (!publicPaths.includes(req.path) && !req.session.userId) { + return res.redirect('/login'); + } + res.locals.user = req.session.userId ? { username: req.session.username } : null; + next(); +}); + +app.use((req, res, next) => { + try { + const { getSettings } = require('./db'); + const settings = getSettings(); + res.locals.auto_refresh = settings.auto_refresh || '0'; + res.locals.settings_data = JSON.stringify(settings); + } catch (e) { + res.locals.auto_refresh = '0'; + res.locals.settings_data = '{}'; + } + next(); +}); + +app.get('/css/dynamic.css', (req, res) => { + try { + const { getSettings } = require('./db'); + const { THEME_PRESETS, THEME_CSS_KEYS } = require('./themes'); + const settings = getSettings(); + let css = '/* Mek-Tech Dynamic Settings */\n'; + + // Theme CSS variables + const themeKey = settings.theme || 'gitea-dark'; + const preset = THEME_PRESETS[themeKey] || THEME_PRESETS['gitea-dark']; + const customColors = settings.theme_colors || {}; + css += ':root{\n'; + THEME_CSS_KEYS.forEach(k => { + const val = customColors[k] || preset[k]; + if (val) css += `--${k.replace(/_/g,'-')}:${val};\n`; + }); + css += '}\n'; + + // Icon colors + if (settings.icon_colors && typeof settings.icon_colors === 'object') { + for (const [icon, color] of Object.entries(settings.icon_colors)) { + css += `[data-lucide="${icon}"]{color:${color}!important}\n`; + } + } + + res.type('css').send(css); + } catch (e) { + console.error('dynamic.css error:', e.message); + res.type('css').send('/* no settings */'); + } +}); + +app.use('/', require('./routes/auth')); +app.use('/', require('./routes/dashboard')); +app.use('/', require('./routes/password')); +app.use('/clients', require('./routes/clients')); +app.use('/engagements', require('./routes/engagements')); +app.use('/tasks', require('./routes/tasks')); +app.use('/assess', require('./routes/assess')); +app.use('/racks', require('./routes/racks')); +app.use('/networking', require('./routes/networking')); +app.use('/architecture', require('./routes/architecture')); +app.use('/sizing', require('./routes/sizing')); +app.use('/infrastructure', require('./routes/infrastructure')); +app.use('/finance', require('./routes/finance')); +app.use('/settings', require('./routes/settings').router); +app.use('/users', require('./routes/users')); +app.use('/time', require('./routes/time')); +app.use('/invoices', require('./routes/invoices')); +app.use('/notifications', require('./routes/notifications').router); +app.use('/calendar', require('./routes/calendar')); +app.use('/search', require('./routes/search')); +app.use('/audit', require('./routes/audit')); +app.use('/email', require('./routes/email')); +app.use('/quality', require('./routes/quality')); +app.use('/market', require('./routes/market')); +app.use('/consulting', require('./routes/consulting')); +app.use('/api/v1', require('./routes/api')); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Mek-Tech Consulting Framework running on http://0.0.0.0:${PORT}`); +}); diff --git a/views/settings.ejs b/views/settings.ejs new file mode 100644 index 0000000..a982afa --- /dev/null +++ b/views/settings.ejs @@ -0,0 +1,399 @@ + + + + + +Instellingen — Mek-Tech Enterprise + + + + + + + + + + + +
+ Terug +

Instellingen

+

Configureer het platform naar wens.

+ +<% if (saved) { %> +
Instellingen opgeslagen!
+<% } %> + +
+ +
+

Algemeen

+
+ + +
+
+ + +
+
+ + +Na inloggen. +
+
+ +
+

Thema & Kleuren

+

Kies een preset als basis en pas de achtergrond en kleuren zelf aan.

+ + + +
+ +
+ +
+

Jouw kleuren

+ +
+
Sidebar
+
+
Voorbeeld tekst
+
Kaart / panel
+
+
+
+ +
+<% themeMainColors.forEach(function(mc) { + var val = mergedColors[mc.key] || ''; + var hexVal = toHexForPicker(val, '#0a1628'); + var fieldName = 'th_' + mc.key; +%> +
+ + <%= mc.hint %> +
+ + +
+
+<% }) %> +
+ +
+Geavanceerde kleuren (randen, statuskleuren) +
+<% var thKeys = themeCssKeys.filter(function(k){ return !themeMainColors.some(function(m){ return m.key===k; }); }); %> +<% thKeys.forEach(function(k) { + var val = mergedColors[k] || ''; + var hexVal = toHexForPicker(val, '#0a1628'); + var fieldName = 'th_' + k; + var label = themeColorLabels[k] || ('--' + k.replace(/_/g, '-')); +%> +
+ + + +
+<% }) %> +
+
+
+ +

Preset als startpunt:

+
+<% var themeKeys = Object.keys(themePresets).filter(function(k){ return k !== 'custom'; }); %> +<% themeKeys.forEach(function(k) { var p = themePresets[k]; var active = settings.theme === k || (settings.theme === 'custom' && !useCustomColors); %> + +<% }) %> +
+
+ +
+

Icoon Kleuren

+

Kies zelf de kleur van elk icoon in de sidebar en tabbladen.

+
+<% icons.forEach(function(icon) { + var color = settings.icon_colors[icon.key] || '#58a6ff'; + var fieldName = 'color_' + icon.key.replace(/-/g, '_'); +%> +
+ +<%= icon.label %> + + +
+<% }) %> +
+
+ +
+

Dashboard

+
+ + +Automatisch verversen van het dashboard. +
+
+ +
+

Notificaties

+
+ + +Voor Slack notificaties bij nieuwe events +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Systeem

+
+ + +
+
+ +> +Toont technische details bij fouten. +
+
+ +
+ + + +
+ +
+
+ + + + diff --git a/views/settings.js b/views/settings.js new file mode 100644 index 0000000..9e6198c --- /dev/null +++ b/views/settings.js @@ -0,0 +1,59 @@ +const express = require('express'); +const router = express.Router(); +const { getSettings, updateSetting, getDb } = require('../db'); + +const ALL_ICONS = [ + { key: 'layout-dashboard', label: 'Dashboard' }, + { key: 'users', label: 'Cliënten' }, + { key: 'briefcase', label: 'Engagements' }, + { key: 'server', label: 'Infrastructuur' }, + { key: 'check-circle', label: 'Taken' }, + { key: 'lightbulb', label: 'Advies' }, + { key: 'clock', label: 'Activiteit' }, + { key: 'eye', label: 'Overzicht (client)' }, + { key: 'phone', label: 'Contact' }, + { key: 'file-text', label: 'Designs' }, + { key: 'edit-3', label: 'Notities' }, + { key: 'folder', label: 'Bestanden' }, + { key: 'clipboard-list', label: 'Assessments' } +]; + +router.get('/', (req, res) => { + const settings = getSettings(); + res.render('settings', { + settings, + icons: ALL_ICONS, + user: { username: req.session.username }, + saved: req.query.saved === '1' + }); +}); + +router.post('/', (req, res) => { + const { brand_name, language, theme, default_page, auto_refresh, items_per_page, debug_mode } = req.body; + + updateSetting('brand_name', brand_name || 'Mek-Tech Consulting'); + updateSetting('language', language || 'nl'); + updateSetting('theme', theme || 'dark'); + updateSetting('default_page', default_page || '/'); + updateSetting('auto_refresh', auto_refresh || '0'); + updateSetting('items_per_page', items_per_page || '50'); + updateSetting('debug_mode', debug_mode === '1' ? '1' : '0'); + + const icon_colors = {}; + ALL_ICONS.forEach(icon => { + const val = req.body['color_' + icon.key.replace(/-/g, '_')]; + if (val) icon_colors[icon.key] = val; + }); + updateSetting('icon_colors', icon_colors); + + res.redirect('/settings?saved=1'); +}); + +router.post('/reset', (req, res) => { + const { DEFAULTS } = require('../db'); + const d = getDb(); + d.prepare('DELETE FROM app_settings').run(); + res.redirect('/settings'); +}); + +module.exports = router; diff --git a/views/sizing.ejs b/views/sizing.ejs new file mode 100644 index 0000000..f56e6eb --- /dev/null +++ b/views/sizing.ejs @@ -0,0 +1,583 @@ + + + + + +Sizing Calculator — Mek-Tech + + + + + + + + + +
+ + +
+
+

Data Architecture Sizing Calculator

+

Best-practice dimensionering voor enterprise data platforms — gebaseerd op 15+ jaar ervaring

+
+ +
+ +
+ + +
+
Bereken de optimale Kafka cluster configuratie op basis van throughput, retentie en replicatie. Geschikt voor Confluent, Red Hat AMQ Streams, en Apache Kafka.
+
+
+

Input Parameters

+
+
+
+
+
+
+
+
+

Resultaten

+
+
Doorvoer (bruto)
+
Doorvoer (netto na compressie)
+
Opslag per dag
+
Totale opslag (retentie)
+
Totaal met replicatie
+
Aanbevolen partitions
+
Min. brokers (performance)
+
+
+
Aanbevolen Cluster Configuratie
+
+
+
+
Mek-Tech Best Practice
+
Voor enterprise Kafka deployments raden wij 3 replication factor aan met minstens 3 brokers. Gebruik NVMe storage voor critical workloads, SSD voor standard. Partition count = max(3 × brokers, throughput / 10MB).
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + diff --git a/views/style.css b/views/style.css new file mode 100644 index 0000000..b858c1d --- /dev/null +++ b/views/style.css @@ -0,0 +1,489 @@ +@import url('neon.css'); + +:root { + --bg: #0a1628; --bg-card: #0f1f3a; --bg-nav: #0c1a30; + --border: rgba(0,212,255,0.15); --border-light: rgba(0,212,255,0.08); + --text: #e8f4ff; --text-muted: #7eb8d4; --text-dim: #4a7a99; + --accent: #00d4ff; --accent-hover: #4de8ff; + --green: #00ff88; --green-bg: #0a2a1a; + --red: #ff6b8a; --red-bg: #2a0a14; + --orange: #ffb347; --orange-bg: #2a1a0a; + --purple: #a855f7; --purple-bg: #1a0a2a; + --cyan: #00d4ff; --cyan-bg: #0a1a2a; + --pink: #ff6ec7; --pink-bg: #2a0a1f; + --radius: 8px; --radius-lg: 14px; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; min-height: 100vh; } +.container { max-width: 960px; margin: 0 auto; padding: 1.5rem; } + +/* Navbar - Premium Redesign */ +.navbar { background: var(--bg-nav); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100; backdrop-filter: blur(8px); } +.nav-inner { max-width: 100%; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; padding: .35rem 1rem; min-height: 46px; flex-wrap: wrap; gap: .35rem; } +.nav-brand { display: flex; align-items: center; gap: .4rem; color: var(--text); text-decoration: none; font-weight: 700; font-size: .85rem; flex-shrink: 0; } +.nav-brand:hover { color: var(--accent); transition: color .2s; } +.nav-logo { height: 28px; width: auto; } +.login-logo-img { height: 80px; width: auto; margin-bottom: .5rem; } +.nav-links { display: flex; gap: 1px; align-items: center; flex-wrap: wrap; } +.nav-link { + display: inline-flex; align-items: center; gap: .35rem; + padding: .32rem .55rem; border-radius: 7px; color: var(--text-muted); + text-decoration: none; font-size: .72rem; font-weight: 500; transition: all .18s; + border-bottom: 2px solid transparent; position: relative; +} +.nav-link svg { width: 14px; height: 14px; flex-shrink: 0; } +.nav-link:hover { color: var(--text); background: var(--border-light); } +.nav-link.active { color: var(--text); background: var(--border-light); font-weight: 600; border-bottom-color: var(--accent); } +.nav-link.logout { color: var(--red); } +.nav-link.logout:hover { background: var(--red-bg); color: var(--red); } +.nav-link.logout.active { border-bottom-color: var(--red); } +/* Colorful nav-link accents */ +.nav-link.cl-dash:hover,.nav-link.cl-dash.active { border-bottom-color: var(--accent); color: var(--accent); } +.nav-link.cl-clients:hover,.nav-link.cl-clients.active { border-bottom-color: var(--green); color: var(--green); } +.nav-link.cl-eng:hover,.nav-link.cl-eng.active { border-bottom-color: var(--purple); color: var(--purple); } +.nav-link.cl-fin:hover,.nav-link.cl-fin.active { border-bottom-color: var(--orange); color: var(--orange); } +.nav-link.cl-market:hover,.nav-link.cl-market.active { border-bottom-color: var(--cyan); color: var(--cyan); } +.nav-link.cl-tasks:hover,.nav-link.cl-tasks.active { border-bottom-color: var(--pink); color: var(--pink); } +.nav-link.cl-data:hover,.nav-link.cl-data.active { border-bottom-color: var(--green); color: var(--green); } +.nav-link.cl-settings:hover,.nav-link.cl-settings.active { border-bottom-color: var(--text-muted); color: var(--text); } + +.nav-icon-btn{display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .45rem;font-size:.68rem;text-decoration:none;color:var(--text-muted);border-radius:6px;background:transparent;border:none;cursor:pointer;font-family:inherit;transition:all .15s} +.nav-icon-btn:hover{color:var(--text);background:var(--border-light)} +.nav-icon-btn svg{width:15px;height:15px} + +/* Dashboard */ +.dash-header { margin-bottom: 1.5rem; } +.dash-header h1 { font-size: 1.5rem; color: var(--text); font-weight: 600; } + +.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .75rem; margin-bottom: 1.5rem; } +.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; } +.stat-number { font-size: 1.75rem; font-weight: 700; color: var(--accent); } +.stat-card.green .stat-number { color: var(--green); } +.stat-card.orange .stat-number { color: var(--orange); } +.stat-card.purple .stat-number { color: var(--purple); } +.stat-label { font-size: .75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .3px; margin-top: 2px; } + +/* Client grid */ +.client-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: .75rem; } +.client-card { + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); + padding: 1rem; text-decoration: none; color: inherit; transition: all .15s; +} +.client-card:hover { border-color: var(--text-dim); } +.client-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; } +.client-card-header h4 { font-size: .9rem; color: var(--text); font-weight: 600; } +.client-meta { font-size: .8rem; color: var(--text-muted); } +.client-meta.warn { color: var(--orange); } +.client-meta.small { font-size: .7rem; } + +/* Status badges */ +.status-badge { display: inline-block; padding: .15rem .5rem; border-radius: 999px; font-size: .65rem; font-weight: 500; border: 1px solid transparent; } +.status-badge.lead { border-color: var(--accent); color: var(--accent); } +.status-badge.active { border-color: var(--green); color: var(--green); } +.status-badge.paused { border-color: var(--orange); color: var(--orange); } +.status-badge.archived { border-color: var(--text-dim); color: var(--text-dim); } +.status-badge.planned { border-color: var(--accent); color: var(--accent); } +.status-badge.in_progress { border-color: var(--green); color: var(--green); } +.status-badge.completed { border-color: var(--purple); color: var(--purple); } +.status-badge.on_hold { border-color: var(--orange); color: var(--orange); } +.status-badge.todo { border-color: var(--text-dim); color: var(--text-dim); } +.status-badge.done { border-color: var(--green); color: var(--green); } + +/* Engagement type */ +.engagement-type { display: inline-block; padding: .1rem .45rem; border-radius: 3px; font-size: .65rem; font-weight: 500; } +.engagement-type.assessment { background: var(--cyan-bg); color: var(--cyan); } +.engagement-type.architecture { background: var(--purple-bg); color: var(--purple); } +.engagement-type.implementation { background: var(--green-bg); color: var(--green); } +.engagement-type.monitoring { background: var(--orange-bg); color: var(--orange); } +.engagement-type.consulting { background: var(--pink-bg); color: var(--pink); } +.engagement-type.training { background: var(--red-bg); color: var(--red); } + +/* Tables */ +.data-table { width: 100%; border-collapse: collapse; } +.data-table th { + text-align: left; padding: .5rem .75rem; font-size: .7rem; color: var(--text-muted); + text-transform: uppercase; letter-spacing: .3px; + border-bottom: 1px solid var(--border); background: var(--bg-card); +} +.data-table td { padding: .5rem .75rem; border-bottom: 1px solid var(--border-light); font-size: .8rem; } +.data-table tr:hover td { background: var(--border-light); } + +/* Engagement list */ +.engagement-list { display: flex; flex-direction: column; gap: .35rem; } +.engagement-row { + display: flex; align-items: center; gap: .75rem; + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); + padding: .6rem .75rem; text-decoration: none; color: inherit; transition: all .15s; +} +.engagement-row:hover { border-color: var(--text-dim); } +.engagement-title { flex: 1; font-weight: 500; color: var(--text); font-size: .85rem; } +.engagement-client { color: var(--text-muted); font-size: .8rem; } +.engagement-date { color: var(--text-dim); font-size: .75rem; } + +/* Page header */ +.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem; } +.page-header h1 { font-size: 1.3rem; color: var(--text); font-weight: 600; } +.header-actions { display: flex; gap: .5rem; align-items: center; } +.back-link { color: var(--text-muted); text-decoration: none; font-size: .8rem; display: inline-block; margin-bottom: .35rem; transition: all .12s; } +.back-link:hover { color: var(--accent); padding-left: 2px; } +.back-link svg { width: 14px; height: 14px; vertical-align: middle; margin-right: 2px; } +.breadcrumb { display: flex; align-items: center; gap: .35rem; font-size: .75rem; color: var(--text-dim); margin-bottom: .75rem; flex-wrap: wrap; } +.breadcrumb a { color: var(--text-muted); text-decoration: none; transition: color .12s; } +.breadcrumb a:hover { color: var(--accent); } +.breadcrumb .sep { color: var(--border); font-size: .6rem; } +.breadcrumb .current { color: var(--text); font-weight: 500; } + +/* Detail grid */ +.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; margin-bottom: 1.5rem; } +@media (max-width: 640px) { .detail-grid { grid-template-columns: 1fr; } } +.detail-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; } +.detail-card h3 { font-size: .75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .3px; margin-bottom: .75rem; } + +/* Forms */ +.form-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; } +@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } } +.form-actions { display: flex; gap: .5rem; margin-top: 1rem; } + +input, textarea, select { + width: 100%; padding: .45rem .6rem; background: var(--bg); + border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); font-size: .85rem; font-family: inherit; transition: border-color .15s; +} +input:focus, textarea:focus, select:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(88,166,255,.15); +} +select { cursor: pointer; } +select option { background: var(--bg); color: var(--text); } + +/* Alerts */ +.alert { padding: .5rem .75rem; border-radius: var(--radius); margin-bottom: .75rem; font-size: .8rem; } +.alert-error { background: var(--red-bg); border: 1px solid var(--red); color: var(--red); } + +/* Advice */ +.advice-list { display: flex; flex-direction: column; gap: .35rem; } +.advice-card { + display: flex; align-items: flex-start; gap: .5rem; + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); + padding: .6rem .75rem; border-left: 3px solid var(--border); +} +.advice-card.critical { border-left-color: var(--red); } +.advice-card.high { border-left-color: var(--orange); } +.advice-card.medium { border-left-color: var(--accent); } +.advice-card.low { border-left-color: var(--green); } +.advice-prio { + font-size: .55rem; font-weight: 600; text-transform: uppercase; + padding: .1rem .35rem; border-radius: 3px; white-space: nowrap; +} +.advice-prio.critical { background: var(--red-bg); color: var(--red); } +.advice-prio.high { background: var(--orange-bg); color: var(--orange); } +.advice-prio.medium { background: var(--cyan-bg); color: var(--cyan); } +.advice-prio.low { background: var(--green-bg); color: var(--green); } +.advice-body { flex: 1; } +.advice-body p { font-size: .8rem; color: var(--text); } + +/* Tasks */ +.task-list { display: flex; flex-direction: column; gap: .35rem; } +.task-item { + display: flex; align-items: center; gap: .5rem; + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); + padding: .5rem .75rem; +} +.task-item.done { opacity: .5; } +.task-item.done .task-title { text-decoration: line-through; } +.task-status-form select { width: auto; padding: .2rem .4rem; font-size: .7rem; } +.task-body { flex: 1; display: flex; flex-direction: column; gap: .1rem; } +.task-title { font-size: .85rem; color: var(--text); font-weight: 500; } +.task-desc { font-size: .75rem; color: var(--text-muted); } +.task-meta { font-size: .7rem; color: var(--accent); } +.task-prio { font-size: .55rem; font-weight: 600; text-transform: uppercase; padding: .1rem .35rem; border-radius: 3px; } +.task-prio.critical { background: var(--red-bg); color: var(--red); } +.task-prio.high { background: var(--orange-bg); color: var(--orange); } +.task-prio.medium { background: var(--cyan-bg); color: var(--cyan); } +.task-prio.low { background: var(--green-bg); color: var(--green); } +.task-due { font-size: .7rem; color: var(--text-muted); } +.task-due.overdue { color: var(--red); font-weight: 500; } +.task-form { margin-bottom: .75rem; } +.task-form-grid { display: flex; gap: .35rem; align-items: flex-end; flex-wrap: wrap; } +.task-form-grid input, .task-form-grid select { width: auto; min-width: 120px; flex: 1; } +.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .75rem; } + +/* Assessment list */ +.assess-list { display: flex; flex-direction: column; gap: .35rem; } +.assess-item { display: flex; align-items: center; gap: .75rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .6rem .75rem; } +.assess-date { font-size: .8rem; color: var(--text-muted); } +.assess-id { font-weight: 500; color: var(--text); flex: 1; font-size: .85rem; } + +/* Empty state */ +.empty-state { text-align: center; padding: 2.5rem 1rem; color: var(--text-muted); } +.empty-state p { font-size: .85rem; } + +/* Status form */ +.status-form { display: flex; gap: .35rem; align-items: center; } +.status-form select { width: auto; } + +/* Buttons */ +.btn { + padding: .45rem .9rem; border: none; border-radius: var(--radius); + font-size: .8rem; font-weight: 500; cursor: pointer; transition: all .15s; + text-decoration: none; display: inline-block; text-align: center; line-height: 1.4; +} +.btn-primary { background: #238636; color: #fff; } +.btn-primary:hover { background: #2ea043; } +.btn-secondary { background: var(--border-light); color: var(--text-muted); border: 1px solid var(--border); } +.btn-secondary:hover { background: var(--border); color: var(--text); } +.btn-accent { background: var(--accent); color: #fff; } +.btn-accent:hover { background: var(--accent-hover); } +.btn-full { width: 100%; } +.btn-small { + padding: .2rem .5rem; border: none; border-radius: 4px; + font-size: .7rem; cursor: pointer; background: var(--border-light); color: var(--text-muted); + text-decoration: none; display: inline-block; +} +.btn-small:hover { background: var(--border); color: var(--text); } +.btn-small.danger { background: var(--red-bg); color: var(--red); } +.btn-small.danger:hover { background: #4d1a1c; } + +/* Login */ +.login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 1.5rem; } +.login-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 2rem; width: 100%; max-width: 360px; } +.login-logo { text-align: center; margin-bottom: 1.5rem; } +.login-logo h1 { font-size: 1.25rem; color: var(--text); } + +/* Header */ +.header { margin-bottom: 1.5rem; } +.logo { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; } +.logo-icon { font-size: 1.5rem; font-weight: 800; color: var(--accent); } +.logo h1 { font-size: 1.25rem; font-weight: 600; color: var(--text); } +.subtitle { font-size: .8rem; color: var(--text-muted); } + +/* Progress bar */ +.progress-bar { width: 100%; height: 3px; background: var(--border-light); border-radius: 2px; overflow: hidden; margin-bottom: 1rem; } +.progress-fill { height: 100%; background: var(--accent); transition: width .3s; border-radius: 2px; } + +/* Assessment steps */ +.step { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; } +.step-header { margin-bottom: 1rem; } +.step-number { font-size: .7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; } +.step-header h2 { font-size: 1.1rem; font-weight: 600; color: var(--text); margin-top: .15rem; } +.fields { display: flex; flex-direction: column; gap: .75rem; } +.field label { display: block; font-size: .8rem; font-weight: 500; color: var(--text-muted); margin-bottom: .25rem; } +.step-actions { display: flex; justify-content: space-between; margin-top: 1.5rem; } + +/* Report */ +.report-header { text-align: center; padding: 1.5rem 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; } +.report-org { margin-bottom: 1rem; } +.report-org h1 { font-size: 1.5rem; color: var(--text); } +.report-title-block h2 { font-size: 1.3rem; color: var(--text); margin-bottom: .35rem; } +.report-client, .report-date { color: var(--text-muted); font-size: .85rem; } +.section { margin-bottom: 1.5rem; } +.section-title { + font-size: 1rem; font-weight: 600; color: var(--text); + margin-bottom: .75rem; padding-bottom: .35rem; + border-bottom: 2px solid var(--accent); display: inline-block; +} +.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; } +.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; } +.info-table { width: 100%; border-collapse: collapse; } +.info-table td { padding: .4rem .6rem; border-bottom: 1px solid var(--border-light); font-size: .8rem; } +.info-table tr:last-child td { border-bottom: none; } +.info-table td:first-child { width: 35%; color: var(--text-muted); } +.badge { display: inline-block; padding: .15rem .6rem; border-radius: 999px; font-size: .7rem; font-weight: 500; } +.badge.Starter { background: var(--green-bg); color: var(--green); } +.badge.Standard { background: var(--cyan-bg); color: var(--cyan); } +.badge.Advanced { background: var(--purple-bg); color: var(--purple); } +.badge.Enterprise { background: var(--red-bg); color: var(--red); } +.diagram { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; overflow-x: auto; } +.ascii-diagram { font-family: 'Courier New', monospace; font-size: .65rem; line-height: 1.3; color: var(--text-muted); } +.phase { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; margin-bottom: .75rem; } +.phase h4 { color: var(--purple); margin-bottom: .5rem; font-size: .9rem; } +.phase ul { list-style: none; } +.phase li { padding: .25rem 0; color: var(--text); font-size: .85rem; } +.phase li::before { content: '→ '; color: var(--accent); } +.tech-stack { display: flex; flex-direction: column; gap: .35rem; } +.tech-item { display: flex; justify-content: space-between; align-items: center; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .75rem; } +.tech-label { color: var(--text-muted); font-size: .8rem; } +.tech-val { color: var(--text); font-weight: 500; font-size: .85rem; text-align: right; } +.report-footer { text-align: center; padding: 1.5rem 0; border-top: 1px solid var(--border); margin-top: 1.5rem; } +.report-footer p { color: var(--text-muted); font-size: .75rem; margin-bottom: .75rem; } + +.link { color: var(--accent); text-decoration: none; } +.link:hover { text-decoration: underline; } + +/* Rack */ +.rack-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: .75rem; } +.rack-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; text-decoration: none; color: inherit; display: flex; gap: .75rem; transition: all .15s; } +.rack-card:hover { border-color: var(--text-dim); } +.rack-mini { width: 20px; display: flex; flex-direction: column; gap: 2px; } +.rack-mini-u { width: 100%; height: 5px; background: var(--border); border-radius: 1px; } +.rack-mini-u.filled { background: var(--accent); } +.rack-card-body h4 { font-size: .9rem; color: var(--text); } +.rack-layout { display: flex; gap: 1.5rem; align-items: flex-start; } +@media (max-width: 768px) { .rack-layout { flex-direction: column; } } +.rack-view { flex-shrink: 0; width: 240px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.rack-header { background: var(--bg-nav); padding: .35rem .75rem; font-size: .75rem; color: var(--text-muted); text-align: center; border-bottom: 1px solid var(--border); } +.rack-body { display: flex; flex-direction: column; } +.rack-empty { display: flex; align-items: center; padding: 0 .4rem; height: 28px; border-bottom: 1px solid var(--border-light); background: var(--bg); } +.rack-u { width: 22px; font-size: .55rem; color: var(--text-dim); flex-shrink: 0; text-align: right; margin-right: .35rem; } +.rack-device { display: flex; align-items: center; padding: 0 .4rem; gap: .25rem; border-bottom: 1px solid var(--border-light); cursor: pointer; transition: all .15s; font-size: .6rem; overflow: hidden; } +.rack-device:hover { filter: brightness(1.15); } +.rack-device.server { background: linear-gradient(90deg, var(--cyan-bg), var(--bg)); border-left: 2px solid var(--accent); } +.rack-device.switch { background: linear-gradient(90deg, var(--purple-bg), var(--bg)); border-left: 2px solid var(--purple); } +.rack-device.storage { background: linear-gradient(90deg, var(--green-bg), var(--bg)); border-left: 2px solid var(--green); } +.rack-device.firewall { background: linear-gradient(90deg, var(--red-bg), var(--bg)); border-left: 2px solid var(--red); } +.rack-device.patch_panel { background: linear-gradient(90deg, var(--orange-bg), var(--bg)); border-left: 2px solid var(--orange); } +.rack-device.pdu { background: linear-gradient(90deg, var(--cyan-bg), var(--bg)); border-left: 2px solid var(--cyan); } +.rack-device.ups { background: linear-gradient(90deg, var(--purple-bg), var(--bg)); border-left: 2px solid var(--purple); } +.rack-device-name { flex: 1; font-weight: 500; color: var(--text); white-space: nowrap; font-size: .65rem; } + +/* Modal */ +.modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.7); z-index: 1000; display: flex; align-items: center; justify-content: center; } +.modal-content { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.25rem; width: 90%; max-width: 560px; max-height: 80vh; overflow-y: auto; } +.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .75rem; } +.modal-header h3 { color: var(--text); font-size: 1rem; } + +/* Network */ +.device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: .5rem; } +.device-card { display: flex; align-items: center; gap: .5rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; text-decoration: none; color: inherit; transition: all .15s; } +.device-card:hover { border-color: var(--text-dim); } +.device-icon { width: 32px; height: 32px; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; } +.device-card.switch .device-icon { background: var(--purple-bg); color: var(--purple); } +.device-card.router .device-icon { background: var(--cyan-bg); color: var(--cyan); } +.device-card.firewall .device-icon { background: var(--red-bg); color: var(--red); } +.device-info { flex: 1; display: flex; flex-direction: column; gap: .1rem; } +.device-info strong { font-size: .85rem; color: var(--text); } + +.media-badge { display: inline-block; padding: .1rem .35rem; border-radius: 3px; font-size: .65rem; font-weight: 500; } +.media-badge.copper { background: var(--orange-bg); color: var(--orange); } +.media-badge.sfp, .media-badge.sfp\+ { background: var(--cyan-bg); color: var(--cyan); } +.media-badge.qsfp { background: var(--purple-bg); color: var(--purple); } +.media-badge.dac { background: var(--green-bg); color: var(--green); } +.media-badge.fiber { background: var(--pink-bg); color: var(--pink); } + +.conn-list { display: flex; flex-direction: column; gap: .35rem; } +.conn-item { background: var(--bg); border: 1px solid var(--border-light); border-radius: var(--radius); padding: .5rem .6rem; } +.conn-name { font-size: .8rem; color: var(--text); font-weight: 500; } +.conn-path { font-size: .7rem; color: var(--text-muted); } +.conn-meta { font-size: .65rem; color: var(--accent); } + +/* Diagram grid */ +.diagram-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: .75rem; } +.diagram-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; text-decoration: none; color: inherit; display: flex; gap: .75rem; transition: all .15s; position: relative; } +.diagram-card:hover { border-color: var(--text-dim); } +.diagram-card .diagram-card-body { padding-bottom: 1.5rem; } +.diagram-card-type { width: 36px; height: 36px; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .7rem; flex-shrink: 0; } +.diagram-card-type.data_architecture { background: var(--cyan-bg); color: var(--cyan); } +.diagram-card-type.network_topology { background: var(--purple-bg); color: var(--purple); } +.diagram-card-type.system_architecture { background: var(--green-bg); color: var(--green); } +.diagram-card-body h4 { font-size: .85rem; color: var(--text); } + +code { background: var(--border-light); padding: .1rem .3rem; border-radius: 3px; font-size: .75rem; color: var(--accent); } + +@media print { + body { background: #fff; color: #000; } + .navbar { display: none; } + .step, .card, .phase, .tech-item, .diagram, .detail-card, .client-card, .advice-card, .engagement-row, .task-item, .assess-item, .stat-card, .data-table { background: #f5f5f5; border-color: #ddd; } + .report-header { border-bottom-color: #ddd; } + .section-title { border-bottom-color: #58a6ff; } + .logo h1, .step-header h2, .report-title-block h2, .report-org h1, .phase h4, .tech-val, .detail-card h3 { color: #000; } + .btn, .nav-links, .header-actions, .btn-small { display: none; } + .info-table td { border-bottom-color: #ddd; } + .ascii-diagram { color: #333; } +} + +/* 🔥 PREMIUM ANIMATIONS */ +@keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:.7 } } +@keyframes pulse-glow { 0%,100% { box-shadow:0 0 0 0 rgba(88,166,255,.15) } 50% { box-shadow:0 0 15px 4px rgba(88,166,255,.08) } } +@keyframes shimmer { 0% { background-position:-200% 0 } 100% { background-position:200% 0 } } +@keyframes fadeInUp { from { opacity:0; transform:translateY(12px) } to { opacity:1; transform:translateY(0) } } +@keyframes fadeIn { from { opacity:0 } to { opacity:1 } } +@keyframes slideInLeft { from { opacity:0; transform:translateX(-8px) } to { opacity:1; transform:translateX(0) } } +@keyframes gradientShift { 0% { background-position:0% 50% } 50% { background-position:100% 50% } 100% { background-position:0% 50% } } +@keyframes float { 0%,100% { transform:translateY(0) } 50% { transform:translateY(-4px) } } +@keyframes borderGlow { 0%,100% { border-color:var(--border) } 50% { border-color:rgba(88,166,255,.3) } } + +.pulse { animation: pulse 2s ease-in-out infinite } +.pulse-glow { animation: pulse-glow 3s ease-in-out infinite } +.fade-in { animation: fadeIn .4s ease-out } +.fade-in-up { animation: fadeInUp .5s ease-out } +.slide-in { animation: slideInLeft .4s ease-out } + +.gradient-text { + background: linear-gradient(135deg, var(--accent), var(--purple), var(--accent)); + background-size: 200% auto; + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; + animation: gradientShift 4s ease infinite; +} +.gradient-border { + position:relative; + background:var(--bg-card); border:1px solid transparent; + background-clip:padding-box; +} +.gradient-border::before { + content:''; position:absolute; inset:0; margin:-1px; border-radius:inherit; + background:linear-gradient(135deg,var(--accent),transparent 40%,var(--purple) 60%,transparent); + z-index:-1; opacity:.3; transition:opacity .3s; +} +.gradient-border:hover::before { opacity:.6 } + +/* Card premium hover */ +.card-hover { + transition: all .25s cubic-bezier(.4,0,.2,1); +} +.card-hover:hover { + transform: translateY(-2px); + box-shadow: 0 8px 30px rgba(0,0,0,.12), 0 0 0 1px var(--accent); + border-color: var(--accent); +} + +/* KPI pulse dots */ +.kpi-dot { + width:8px;height:8px;border-radius:50%;display:inline-block; + background:var(--green);animation:pulse-glow 2s ease-in-out infinite; +} + +/* Shimmer loading */ +.shimmer { + background:linear-gradient(90deg,var(--border-light) 25%,var(--border) 50%,var(--border-light) 75%); + background-size:200% 100%; animation:shimmer 2s infinite; + border-radius:var(--radius); height:16px; +} + +/* Theme toggle */ +.theme-toggle { + width:44px;height:24px;border-radius:12px;background:var(--border); + cursor:pointer;position:relative;transition:all .3s;border:none;flex-shrink:0; +} +.theme-toggle::after { + content:'';position:absolute;top:2px;left:2px;width:20px;height:20px; + border-radius:50%;background:var(--accent);transition:all .3s; + box-shadow:0 1px 3px rgba(0,0,0,.2); +} +.theme-toggle.light { background:var(--accent) } +.theme-toggle.light::after { left:22px; background:#fff } + +/* Light mode overrides */ +body.light-mode { + --bg: #f8f9fa; --bg-card: #ffffff; --bg-nav: #ffffff; + --border: #e0e4e8; --border-light: #f0f2f5; + --text: #1a1d21; --text-muted: #5a5f66; --text-dim: #8b9098; + --accent: #0969da; --accent-hover: #0550ae; + --green: #1a7f37; --green-bg: #dafbe1; + --red: #cf222e; --red-bg: #ffebe9; + --orange: #953800; --orange-bg: #fff1e5; + --purple: #8250df; --purple-bg: #fbefff; + --cyan: #0550ae; --cyan-bg: #ddf4ff; + --pink: #bf3989; --pink-bg: #ffeff7; +} +body.light-mode .navbar { box-shadow: 0 1px 3px rgba(0,0,0,.04), 0 1px 6px rgba(0,0,0,.03) } +body.light-mode .sidebar { box-shadow: 1px 0 3px rgba(0,0,0,.03) } +body.light-mode .card-hover:hover { box-shadow: 0 4px 20px rgba(0,0,0,.06), 0 0 0 1px var(--accent) } +body.light-mode .kpi-card { box-shadow: 0 1px 2px rgba(0,0,0,.02) } +body.light-mode .kpi-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06) } + +/* Smooth transitions for theme switch */ +body, .navbar, .sidebar, .card, .kpi-card, .client-card, .data-table, input, select, textarea, .btn { + transition: background-color .3s ease, color .3s ease, border-color .3s ease, box-shadow .3s ease; +} diff --git a/views/task-list.ejs b/views/task-list.ejs new file mode 100644 index 0000000..f936368 --- /dev/null +++ b/views/task-list.ejs @@ -0,0 +1,54 @@ + + + + + + Taken — Mek-Tech Consulting + + + + + + + + +
+ Terug + + + <% if (tasks.length === 0) { %> +

Geen openstaande taken.

+ <% } else { %> +
+ <% tasks.forEach(function(t) { %> +
+
+ +
+
+ <%= t.title %> + <% if (t.description) { %><%= t.description %><% } %> + <%= t.client_name %> — <%= t.engagement_title %> +
+ <%= t.priority %> + <% if (t.due_date) { %> + + <%= new Date(t.due_date).toLocaleDateString('nl-NL') %> + + <% } %> +
+ +
+
+ <% }) %> +
+ <% } %> +
+ + diff --git a/views/time-list.ejs b/views/time-list.ejs new file mode 100644 index 0000000..13518f0 --- /dev/null +++ b/views/time-list.ejs @@ -0,0 +1,71 @@ +Urenregistratie — Mek-Tech + + +
+<% if (sent) { %>
Urenoverzicht per email verzonden!
<% } %> +<% if (error) { %>
<%= error %>
<% } %> + Terug + +
+
<%= totalHours.toFixed(1) %>
Totaal uren
+
<%= billableHours.toFixed(1) %>
Facturabel
+
+
+
+
+ + + + + + + + +
+ +<% entries.forEach(function(e){ %> + +<% }) %> +
DatumGebruikerCliëntEngagementHoursOmschrijvingTariefFact.
<%= e.date %><%= e.username || '-' %><%= e.client_name || '-' %><%= e.engagement_title || '-' %><%= e.hours %><%= e.description || '' %>€<%= (e.hourly_rate || 150).toFixed(2) %><%= e.billable ? '✓' : '-' %>
+ + +
+

Factuur maken uit uren

+
+ +
+ +
+ + +
+

Urenoverzicht emailen

+
+ +
+ + +
+ + diff --git a/views/user-form.ejs b/views/user-form.ejs new file mode 100644 index 0000000..343acd6 --- /dev/null +++ b/views/user-form.ejs @@ -0,0 +1,19 @@ +<%= editUser ? 'Bewerk' : 'Nieuwe' %> Gebruiker — Mek-Tech + +
+ +<% if (error) { %>
<%= error %>
<% } %> +
+
+
+
+
+
>
+
+
+
Annuleren
+
diff --git a/views/user-list.ejs b/views/user-list.ejs new file mode 100644 index 0000000..1b78ae4 --- /dev/null +++ b/views/user-list.ejs @@ -0,0 +1,10 @@ +Gebruikers — Mek-Tech + +
+ Terug + + +<% users.forEach(function(u){ %> + +<% }) %> +
GebruikersnaamE-mailRolAangemaakt
<%= u.username %><%= u.email || '-' %><%= u.role %><%= new Date(u.created_at).toLocaleDateString('nl-NL') %>Bewerk