Files
perinco-Pulse/server/src/routes/customers.js
T
schakal00783andClaude Opus 5 ef676a95f4 feat: perinco Pulse - Zeiterfassung fuer perinco
Erste Fassung, live unter https://pulse.perinco.de.

Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit
mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV.
Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM.

Fachlich:
- Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5
- Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder
  aufgebrauchtem Budget, Buchung nur auf den passenden Topf
- Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte
- Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend)
- SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:52:20 +02:00

162 lines
5.9 KiB
JavaScript

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;