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>
This commit is contained in:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
import express from 'express';
import { requireRole, ROLES } from '../middleware/auth.js';
import { DEFAULTS, getSettings, saveSetting } from '../lib/settings.js';
import { ssoConfigured, testConnection } from '../lib/sso.js';
import { sendDocument } from '../export/render.js';
const router = express.Router();
// Fuer alle Angemeldeten: was die Oberflaeche zum Rechnen braucht.
router.get('/public', async (req, res, next) => {
try {
const s = await getSettings();
res.json({
weekly_hours: Number(s.weekly_hours),
hours_per_day: Number(s.hours_per_day),
budget_warn_percent: Number(s.budget_warn_percent),
budget_warn_days: Number(s.budget_warn_days),
company_name: s.company_name,
project_code_pattern: s.project_code_pattern,
project_name_pattern: s.project_name_pattern,
});
} catch (err) {
next(err);
}
});
router.get('/', requireRole('admin'), async (req, res, next) => {
try {
const s = await getSettings();
res.json({ ...s, sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) } });
} catch (err) {
next(err);
}
});
const text = (v, max) => String(v ?? '').slice(0, max);
router.put('/', requireRole('admin'), async (req, res, next) => {
try {
const body = req.body || {};
const current = await getSettings();
const errors = [];
const numbers = {
weekly_hours: [1, 80],
hours_per_day: [1, 24],
budget_warn_percent: [1, 100],
budget_warn_days: [0, 365],
};
for (const [key, [min, max]] of Object.entries(numbers)) {
if (body[key] === undefined) continue;
const value = Number(body[key]);
if (!(value >= min && value <= max)) errors.push(`${key}: Wert zwischen ${min} und ${max}.`);
else await saveSetting(key, value);
}
if (body.company_name !== undefined) await saveSetting('company_name', text(body.company_name, 160));
if (body.project_code_pattern !== undefined) {
const pattern = text(body.project_code_pattern, 60).trim();
if (!pattern.includes('{NR}')) errors.push('Das Codemuster braucht {NR}, sonst sind Codes nicht eindeutig.');
else await saveSetting('project_code_pattern', pattern);
}
if (body.project_name_pattern !== undefined) {
const pattern = text(body.project_name_pattern, 60).trim();
if (!pattern.includes('{PROJEKT}')) errors.push('Das Namensmuster braucht {PROJEKT}.');
else await saveSetting('project_name_pattern', pattern);
}
if (body.export) {
const next_ = { ...current.export };
for (const key of Object.keys(DEFAULTS.export)) {
if (body.export[key] === undefined) continue;
if (key === 'show_signature') next_[key] = Boolean(body.export[key]);
else if (key === 'logo') {
const logo = body.export.logo;
if (logo === null || logo === '') next_.logo = null;
else if (/^data:image\/(png|jpe?g);base64,/.test(logo) && logo.length < 2_500_000) next_.logo = logo;
else errors.push('Logo: PNG oder JPG bis ca. 1,8 MB.');
} else if (key === 'accent') {
if (/^#[0-9a-f]{6}$/i.test(body.export.accent)) next_.accent = body.export.accent;
else errors.push('Akzentfarbe ist ungültig.');
} else next_[key] = text(body.export[key], 300);
}
await saveSetting('export', next_);
}
if (body.sso) {
const next_ = { ...current.sso };
for (const key of ['tenant_id', 'client_id', 'redirect_uri', 'button_label']) {
if (body.sso[key] !== undefined) next_[key] = text(body.sso[key], 300).trim();
}
for (const key of ['enabled', 'local_login']) {
if (body.sso[key] !== undefined) next_[key] = Boolean(body.sso[key]);
}
// Leer = unveraendert lassen; das Secret wird nie ausgeliefert.
if (body.sso.client_secret) next_.client_secret = text(body.sso.client_secret, 500).trim();
if (body.sso.clear_secret) next_.client_secret = '';
if (next_.enabled && !ssoConfigured(next_)) errors.push('SSO kann erst aktiviert werden, wenn Tenant, Client-ID, Secret und Redirect-URI gesetzt sind.');
if (next_.enabled && !ssoConfigured(next_)) next_.enabled = false;
if (!next_.enabled) next_.local_login = true; // ohne SSO braucht es die normale Anmeldung
await saveSetting('sso', next_);
}
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
router.post('/sso/test', requireRole('admin'), async (req, res) => {
try {
const { sso } = await getSettings();
if (!ssoConfigured(sso)) return res.status(400).json({ error: 'Tenant, Client-ID und Secret fehlen.' });
const result = await testConnection(sso);
res.json({ ok: true, ...result });
} catch (err) {
res.status(502).json({ error: `Verbindung fehlgeschlagen: ${err.message}` });
}
});
/** Beispiel-Stundennachweis mit Platzhalterdaten - zeigt Kopf und Fuss. */
router.get('/export-preview', requireRole('admin'), async (req, res, next) => {
try {
const settings = await getSettings();
const format = ['pdf', 'xlsx', 'csv'].includes(req.query.format) ? req.query.format : 'pdf';
const rows = [
['2026-09-01', 'Di', 'Projektleitung', 'Jour fixe und Statusbericht', 150],
['2026-09-02', 'Mi', 'Konzeption', 'Workshop Anforderungen', 420],
['2026-09-03', 'Do', 'Konzeption', 'Dokumentation Prozessmodell', 390],
['2026-09-04', 'Fr', 'Projektleitung', '', 240],
].map(([entry_date, weekday, task_name, note, minutes]) => ({
entry_date,
weekday,
project_display: 'Musterkunde - Beispielprojekt',
task_name,
note,
minutes,
duration: `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`,
}));
const total = rows.reduce((s, r) => s + r.minutes, 0);
await sendDocument(
res,
{
title: settings.export.title || 'Stundennachweis',
subtitle: 'Vorschau',
filename: 'Vorschau Stundennachweis',
info: [
['Berater', 'Max Mustermann'],
['Kunde', 'Musterkunde GmbH'],
['Zeitraum', '01.09.2026 – 30.09.2026'],
['Projekt', 'Musterkunde - Beispielprojekt'],
],
kpis: [
['Gesamtstunden', (total / 60).toLocaleString('de-DE', { minimumFractionDigits: 2 })],
['Tage mit Leistung', '4'],
],
tables: [
{
columns: [
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58 },
{ key: 'weekday', label: 'Tag', width: 26 },
{ key: 'project_display', label: 'Projekt', width: 120 },
{ key: 'task_name', label: 'Task', width: 80 },
{ key: 'note', label: 'Tätigkeit', type: 'text' },
{ key: 'duration', label: 'Dauer', align: 'right', width: 38 },
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 46 },
],
rows,
totals: { weekday: 'Summe', minutes: total },
},
],
signature: true,
},
format,
settings
);
} catch (err) {
next(err);
}
});
export default router;