- Heatmap matrix: pillar × market colour-coded table in Compare tab - Radar/spider chart: pure SVG chart in entity detail view - Sort controls: sort by overall score, pillar, or group on Markets tab - Group toggle: cluster entity cards by group (Leroy Merlin / Obramat etc.) - About tab: home screen tab explaining scoring levels, pillars, and features - /api/clients now returns pillars, scoring, about for About tab rendering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
243 lines
10 KiB
JavaScript
243 lines
10 KiB
JavaScript
'use strict';
|
|
const express = require('express');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { execFile } = require('child_process');
|
|
const multer = require('multer');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3102;
|
|
const ROOT = path.join(__dirname, '..');
|
|
const CLIENT_DIR = path.join(ROOT, 'clients');
|
|
const TMP_DIR = path.join(ROOT, 'tmp_uploads');
|
|
|
|
// Ensure tmp upload dir exists
|
|
if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
|
|
|
|
// Multer — disk storage preserving original extension
|
|
const upload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: TMP_DIR,
|
|
filename: (_req, file, cb) => {
|
|
const ext = path.extname(file.originalname).toLowerCase() || '.csv';
|
|
cb(null, `upload_${Date.now()}_${Math.random().toString(36).slice(2)}${ext}`);
|
|
},
|
|
}),
|
|
limits: { fileSize: 30 * 1024 * 1024 },
|
|
fileFilter: (_req, file, cb) => {
|
|
const ok = ['.csv', '.xlsx', '.xls'].includes(path.extname(file.originalname).toLowerCase());
|
|
cb(ok ? null : new Error('Only CSV and XLSX files are supported'), ok);
|
|
},
|
|
});
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
function listClients() {
|
|
if (!fs.existsSync(CLIENT_DIR)) return [];
|
|
return fs.readdirSync(CLIENT_DIR).filter(id => {
|
|
const cfg = path.join(CLIENT_DIR, id, 'config.json');
|
|
const dat = path.join(CLIENT_DIR, id, 'data.json');
|
|
return fs.existsSync(cfg) && fs.existsSync(dat);
|
|
});
|
|
}
|
|
|
|
// Health
|
|
app.get('/api/health', (_req, res) => {
|
|
const clients = listClients();
|
|
res.json({ status: 'ok', clients });
|
|
});
|
|
|
|
// List clients
|
|
app.get('/api/clients', (_req, res) => {
|
|
const clients = listClients().map(id => {
|
|
const cfg = readJson(path.join(CLIENT_DIR, id, 'config.json'));
|
|
const dat = readJson(path.join(CLIENT_DIR, id, 'data.json'));
|
|
return {
|
|
id: cfg.id,
|
|
name: cfg.name,
|
|
description: cfg.description,
|
|
accent_color: cfg.accent_color,
|
|
entity_label: cfg.entity_label,
|
|
logo: cfg.logo || null,
|
|
entity_count: dat.entities ? dat.entities.length : 0,
|
|
generated: dat.generated,
|
|
pillars: cfg.pillars || [],
|
|
scoring: cfg.scoring || {},
|
|
about: cfg.about || null,
|
|
};
|
|
});
|
|
res.json(clients);
|
|
});
|
|
|
|
// Client config
|
|
app.get('/api/clients/:id/config', (req, res) => {
|
|
const cfgPath = path.join(CLIENT_DIR, req.params.id, 'config.json');
|
|
if (!fs.existsSync(cfgPath)) return res.status(404).json({ error: 'Client not found' });
|
|
res.json(readJson(cfgPath));
|
|
});
|
|
|
|
// Client data
|
|
app.get('/api/clients/:id/data', (req, res) => {
|
|
const datPath = path.join(CLIENT_DIR, req.params.id, 'data.json');
|
|
if (!fs.existsSync(datPath)) return res.status(404).json({ error: 'Data not found — run: python3 convert_data.py ' + req.params.id });
|
|
res.json(readJson(datPath));
|
|
});
|
|
|
|
// Save updated client data (score edits)
|
|
app.use(express.json({ limit: '10mb' }));
|
|
app.post('/api/clients/:id/data', (req, res) => {
|
|
const datPath = path.join(CLIENT_DIR, req.params.id, 'data.json');
|
|
if (!fs.existsSync(datPath)) return res.status(404).json({ error: 'Client not found' });
|
|
try {
|
|
const payload = req.body;
|
|
if (!payload || !Array.isArray(payload.entities)) {
|
|
return res.status(400).json({ error: 'Invalid payload — expected { entities: [...] }' });
|
|
}
|
|
fs.writeFileSync(datPath, JSON.stringify(payload, null, 2), 'utf8');
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ error: 'Failed to save: ' + e.message });
|
|
}
|
|
});
|
|
|
|
// Create a new client (writes config.json + empty data.json)
|
|
app.post('/api/clients', (req, res) => {
|
|
const config = req.body;
|
|
if (!config || !config.name) return res.status(400).json({ error: 'name is required' });
|
|
const id = String(config.id || config.name)
|
|
.toLowerCase().replace(/[^a-z0-9\s-]/g, '').trim()
|
|
.replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
|
if (!id) return res.status(400).json({ error: 'Could not derive a valid client ID' });
|
|
const clientDir = path.join(CLIENT_DIR, id);
|
|
if (fs.existsSync(clientDir)) return res.status(409).json({ error: `Client "${id}" already exists` });
|
|
fs.mkdirSync(clientDir, { recursive: true });
|
|
const finalConfig = { ...config, id };
|
|
fs.writeFileSync(path.join(clientDir, 'config.json'), JSON.stringify(finalConfig, null, 2), 'utf8');
|
|
const emptyData = { generated: new Date().toISOString().slice(0, 10), client_id: id, entities: [] };
|
|
fs.writeFileSync(path.join(clientDir, 'data.json'), JSON.stringify(emptyData, null, 2), 'utf8');
|
|
res.json({ ok: true, id });
|
|
});
|
|
|
|
// Deliverables manifest (deliverables.json — optional per client)
|
|
app.get('/api/clients/:id/deliverables', (req, res) => {
|
|
const delPath = path.join(CLIENT_DIR, req.params.id, 'deliverables.json');
|
|
if (!fs.existsSync(delPath)) return res.json({});
|
|
res.json(readJson(delPath));
|
|
});
|
|
|
|
// Stream a single deliverable file (pdf or xlsx)
|
|
app.get('/api/clients/:id/deliverables/:entityId/:type', (req, res) => {
|
|
const { id, entityId, type } = req.params;
|
|
if (!['pdf', 'xlsx'].includes(type)) return res.status(400).json({ error: 'type must be pdf or xlsx' });
|
|
const delPath = path.join(CLIENT_DIR, id, 'deliverables.json');
|
|
if (!fs.existsSync(delPath)) return res.status(404).json({ error: 'No deliverables configured for this client' });
|
|
const map = readJson(delPath);
|
|
const entry = map[entityId];
|
|
if (!entry || !entry[type]) return res.status(404).json({ error: `No ${type} deliverable configured for ${entityId}` });
|
|
const filePath = entry[type];
|
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found on disk: ' + path.basename(filePath) });
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const mime = ext === '.pdf' ? 'application/pdf'
|
|
: ext === '.xlsx' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
: 'text/csv';
|
|
res.setHeader('Content-Type', mime);
|
|
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
|
|
fs.createReadStream(filePath).pipe(res);
|
|
});
|
|
|
|
// Box sync — re-runs convert_data.py for this client (all entities or a single one)
|
|
app.post('/api/clients/:id/sync', (req, res) => {
|
|
const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const cfgPath = path.join(CLIENT_DIR, id, 'config.json');
|
|
if (!fs.existsSync(cfgPath)) return res.status(404).json({ error: 'Client not found' });
|
|
const script = path.join(ROOT, 'convert_data.py');
|
|
const entityId = (req.body && req.body.entity_id) ? String(req.body.entity_id).replace(/[^a-zA-Z0-9_-]/g, '') : null;
|
|
const args = entityId ? [script, id, '--entity', entityId] : [script, id];
|
|
execFile('python3', args, { cwd: ROOT, timeout: 120000 }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
console.error('Sync failed:', stderr);
|
|
return res.status(500).json({ error: 'Sync failed', detail: stderr, log: stdout });
|
|
}
|
|
res.json({ ok: true, log: stdout });
|
|
});
|
|
});
|
|
|
|
// File import — parse an uploaded CSV/XLSX and merge into an entity
|
|
app.post('/api/clients/:id/import/file', upload.single('file'), (req, res) => {
|
|
const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const entityId = (req.body.entity_id || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const datPath = path.join(CLIENT_DIR, id, 'data.json');
|
|
|
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
|
if (!entityId) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'entity_id required' }); }
|
|
if (!fs.existsSync(datPath)) { fs.unlinkSync(req.file.path); return res.status(404).json({ error: 'Client not found' }); }
|
|
|
|
const script = path.join(ROOT, 'import_file.py');
|
|
execFile('python3', [script, id, entityId, req.file.path], { cwd: ROOT, timeout: 30000 }, (err, stdout, stderr) => {
|
|
try { fs.unlinkSync(req.file.path); } catch (_) {}
|
|
if (err) {
|
|
console.error('Import failed:', stderr);
|
|
return res.status(500).json({ error: 'Import failed', detail: stderr });
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(stdout.trim());
|
|
} catch (e) {
|
|
return res.status(500).json({ error: 'Could not parse import output', detail: stdout });
|
|
}
|
|
if (parsed.error) return res.status(400).json(parsed);
|
|
|
|
const data = readJson(datPath);
|
|
const idx = data.entities.findIndex(e => e.id === entityId);
|
|
if (idx < 0) return res.status(404).json({ error: `Entity '${entityId}' not found in data.json` });
|
|
|
|
// Preserve identity fields, replace scores
|
|
const existing = data.entities[idx];
|
|
data.entities[idx] = {
|
|
id: existing.id,
|
|
label: existing.label,
|
|
short: existing.short,
|
|
group: existing.group,
|
|
overall_score: parsed.overall_score,
|
|
overall_level: parsed.overall_level,
|
|
overall_label: parsed.overall_label,
|
|
pillars: parsed.pillars,
|
|
};
|
|
data.generated = new Date().toISOString().slice(0, 10);
|
|
data.entities.sort((a, b) => b.overall_score - a.overall_score);
|
|
fs.writeFileSync(datPath, JSON.stringify(data, null, 2), 'utf8');
|
|
res.json({ ok: true, entity: data.entities.find(e => e.id === entityId) });
|
|
});
|
|
});
|
|
|
|
// PDF export — generates via Python/ReportLab and streams back
|
|
app.get('/api/clients/:id/export/pdf/:entityId', (req, res) => {
|
|
const { id, entityId } = req.params;
|
|
const datPath = path.join(CLIENT_DIR, id, 'data.json');
|
|
const cfgPath = path.join(CLIENT_DIR, id, 'config.json');
|
|
if (!fs.existsSync(datPath) || !fs.existsSync(cfgPath)) {
|
|
return res.status(404).json({ error: 'Client not found' });
|
|
}
|
|
const script = path.join(ROOT, 'pdf_generator.py');
|
|
execFile('python3', [script, id, entityId], { cwd: ROOT }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
console.error('PDF generation failed:', stderr);
|
|
return res.status(500).json({ error: 'PDF generation failed', detail: stderr });
|
|
}
|
|
const pdfPath = path.join(CLIENT_DIR, id, 'exports', `${entityId}_Maturity_Report.pdf`);
|
|
if (!fs.existsSync(pdfPath)) {
|
|
return res.status(500).json({ error: 'PDF not found after generation' });
|
|
}
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${entityId}_Maturity_Report.pdf"`);
|
|
fs.createReadStream(pdfPath).pipe(res);
|
|
});
|
|
});
|
|
|
|
// Static files
|
|
app.use(express.static(ROOT, { index: 'index.html' }));
|
|
app.get('*', (_req, res) => res.sendFile(path.join(ROOT, 'index.html')));
|
|
|
|
app.listen(PORT, () => console.log(`Maturity Tool running on http://localhost:${PORT}`));
|