import express from 'express'; import { pool } from '../db.js'; import { getSettings } from '../lib/settings.js'; import { loadProjects } from '../lib/projects.js'; import { loadEntries } from '../lib/entries.js'; import { addDays, today } from '../lib/dates.js'; const router = express.Router(); const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null); function normalize(body, creating) { const errors = []; const out = {}; if (creating || body.name !== undefined) { out.name = str(body.name, 190); if (!out.name) errors.push('Kundenname fehlt.'); } if (creating || body.short_name !== undefined) { out.short_name = str(body.short_name, 16)?.toUpperCase().replace(/[^A-Z0-9ÄÖÜ_-]/g, '') || null; if (!out.short_name) errors.push('Kürzel fehlt (z. B. KUKA) - es steht im Projektcode.'); } for (const [key, max] of [ ['contact_person', 160], ['email', 190], ['phone', 60], ['street', 190], ['zip', 20], ['city', 120], ['country', 80], ['notes', 5000], ]) { if (body[key] !== undefined) out[key] = str(body[key], max); } if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null; if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0; return { out, errors }; } router.get('/', async (req, res, next) => { try { const [rows] = await pool.query( `SELECT c.*, (SELECT COUNT(*) FROM pulse_projects p WHERE p.customer_id = c.id) AS project_count FROM pulse_customers c ORDER BY c.name` ); res.json(rows.map((r) => ({ ...r, is_active: Boolean(r.is_active), project_count: Number(r.project_count) }))); } catch (err) { next(err); } }); /** * Uebersicht: welche Kunden haben welche Projekte mit welchen Mitarbeitern, * wie viel Umsatz ist erbracht und wie viel noch offen - plus die letzten 12 * Monate Umsatz fuer die Verlaufslinie. */ router.get('/overview', async (req, res, next) => { try { const settings = await getSettings(); const [customers] = await pool.query('SELECT * FROM pulse_customers ORDER BY name'); const projects = await loadProjects(settings); const from = `${addDays(today(), -365).slice(0, 7)}-01`; const entries = await loadEntries(settings, { from }); const months = []; const cursor = new Date(`${from}T00:00:00Z`); for (let i = 0; i < 13; i++) { months.push(cursor.toISOString().slice(0, 7)); cursor.setUTCMonth(cursor.getUTCMonth() + 1); } const result = customers.map((customer) => { const own = projects.filter((p) => p.customer_id === customer.id); const memberMap = new Map(); for (const project of own) for (const m of project.members) memberMap.set(m.id, m); const monthly = months.map((month) => entries .filter((e) => e.customer_id === customer.id && e.entry_date.startsWith(month)) .reduce((sum, e) => sum + e.amount + e.fixed_amount, 0) ); return { ...customer, is_active: Boolean(customer.is_active), projects: own.map((p) => ({ id: p.id, code: p.code, name: p.name, display_name: p.display_name, type: p.type, is_active: p.is_active, used_minutes: p.used_minutes, budget_hours: p.budget_hours, earned: p.earned, open: p.open, budget_value: p.budget_value, members: p.members, end_date: p.end_date, })), members: [...memberMap.values()], used_minutes: own.reduce((s, p) => s + p.used_minutes, 0), earned: Math.round(own.reduce((s, p) => s + p.earned, 0) * 100) / 100, open: Math.round(own.reduce((s, p) => s + p.open, 0) * 100) / 100, monthly: monthly.map((v) => Math.round(v * 100) / 100), }; }); res.json({ months, customers: result }); } catch (err) { next(err); } }); router.post('/', async (req, res, next) => { const { out, errors } = normalize(req.body || {}, true); if (errors.length) return res.status(400).json({ error: errors.join(' ') }); try { const keys = Object.keys(out); const [result] = await pool.query( `INSERT INTO pulse_customers (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`, keys.map((k) => out[k]) ); const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [result.insertId]); res.status(201).json(row); } catch (err) { if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' }); next(err); } }); router.put('/:id', async (req, res, next) => { const { out, errors } = normalize(req.body || {}, false); if (errors.length) return res.status(400).json({ error: errors.join(' ') }); try { const keys = Object.keys(out); if (keys.length) { const [result] = await pool.query(`UPDATE pulse_customers SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [ ...keys.map((k) => out[k]), Number(req.params.id), ]); if (!result.affectedRows) return res.status(404).json({ error: 'Kunde nicht gefunden.' }); } const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [Number(req.params.id)]); res.json(row); } catch (err) { if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' }); next(err); } }); router.delete('/:id', async (req, res, next) => { try { const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_projects WHERE customer_id = ?', [Number(req.params.id)]); if (Number(n) > 0) { return res.status(409).json({ error: `Dem Kunden sind ${n} Projekte zugeordnet. Bitte deaktivieren statt löschen.` }); } await pool.query('DELETE FROM pulse_customers WHERE id = ?', [Number(req.params.id)]); res.json({ ok: true }); } catch (err) { next(err); } }); export default router;