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:
@@ -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 '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user