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
+101
View File
@@ -0,0 +1,101 @@
import crypto from 'node:crypto';
import { pool } from '../db.js';
/*
* Einstellungen liegen als JSON je Schluessel in pulse_settings. Fehlt ein
* Schluessel, gilt der Standard von hier - so muss beim Hinzufuegen neuer
* Einstellungen nichts migriert werden.
*/
export const DEFAULTS = {
weekly_hours: 40,
hours_per_day: 8,
// Platzhalter: {KUERZEL} Kundenkuerzel, {JAHR} vierstellig, {NR} laufende
// Nummer je Kunde und Jahr (dreistellig).
project_code_pattern: '{KUERZEL}-{JAHR}-{NR}',
// Anzeigename der Projekte: {KUNDE} Kundenkuerzel oder -name, {PROJEKT} Name, {CODE}.
project_name_pattern: '{KUNDE} - {PROJEKT}',
budget_warn_percent: 80,
budget_warn_days: 14,
company_name: 'perinco GmbH',
export: {
title: 'Stundennachweis',
subtitle: 'Performance Intelligence Consulting',
accent: '#6BA04A',
logo: null, // Data-URL; null = perinco Logo
header_right: 'www.perinco.de',
footer_left: 'perinco GmbH',
footer_center: '',
footer_right: 'www.perinco.de',
show_signature: true,
signature_left: 'Datum, Unterschrift Berater',
signature_right: 'Datum, Unterschrift Kunde',
},
sso: {
enabled: false,
button_label: 'Mit Microsoft anmelden',
tenant_id: '',
client_id: '',
client_secret: '',
redirect_uri: 'https://pulse.perinco.de/api/auth/sso/callback',
// Uebernommen wird nur von Hand in der SSO-Verwaltung; Rollen vergibt Pulse.
local_login: true,
},
};
export async function getSettings() {
const [rows] = await pool.query('SELECT k, v FROM pulse_settings');
const stored = {};
for (const row of rows) {
try {
stored[row.k] = JSON.parse(row.v);
} catch {
/* kaputter Wert - Standard gilt */
}
}
const result = {};
for (const [key, def] of Object.entries(DEFAULTS)) {
const value = stored[key];
if (def && typeof def === 'object' && !Array.isArray(def)) {
result[key] = { ...def, ...(value && typeof value === 'object' ? value : {}) };
} else {
result[key] = value ?? def;
}
}
if (result.sso.client_secret) result.sso.client_secret = decrypt(result.sso.client_secret);
return result;
}
export async function saveSetting(key, value) {
if (!(key in DEFAULTS)) throw new Error(`Unbekannte Einstellung: ${key}`);
let stored = value;
if (key === 'sso' && value.client_secret) stored = { ...value, client_secret: encrypt(value.client_secret) };
await pool.query(
'INSERT INTO pulse_settings (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)',
[key, JSON.stringify(stored)]
);
}
/* Client Secret verschluesselt ablegen (AES-256-GCM, Schluessel aus SETTINGS_KEY). */
function key() {
const base = process.env.SETTINGS_KEY || process.env.JWT_SECRET || '';
return crypto.createHash('sha256').update(base).digest();
}
function encrypt(plain) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
const data = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
return `enc:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${data.toString('base64')}`;
}
function decrypt(value) {
if (typeof value !== 'string' || !value.startsWith('enc:')) return value;
try {
const [, iv, tag, data] = value.split(':');
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(iv, 'base64'));
decipher.setAuthTag(Buffer.from(tag, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf8');
} catch {
return '';
}
}