Files
perinco-Pulse/server/src/routes/settings.js
T
schakal00783andClaude Opus 5 532b15ce99 feat(hrworks): Abwesenheiten taeglich aus HRworks uebernehmen
Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks. Pulse holt
sie einmal taeglich und bucht sie auf das nicht abrechenbare Projekt
"Abwesenheiten". Nach HRworks geht nichts zurueck, es wird nur gelesen.

Die Stunden rechnet HRworks selbst: available-working-hours liefert je
Person und Tag das Soll und die verfuegbaren Stunden, die Differenz sind
die Abwesenheitsstunden. Damit stimmen Teilzeit und halbe Tage ohne
eigenen Kalender. relatedEvents nennt den Auslöser; Vorrang hat der
Feiertag, dann Krankheit, dann Abwesenheit.

Abgesprochene Regeln: nur genehmigte Abwesenheiten (validatedOk), bei
Krankmeldungen auch offene Bescheinigungen; nur bis heute buchen, der
Rest waechst taeglich nach; halbe Tage feste 4 Stunden; jeder aktive
Mitarbeiter wird dem Projekt zugewiesen, auch neue.

Jede Buchung traegt hrworks_ref = "<Nummer>@<Tag>" (unique). Ein zweiter
Lauf aendert nichts, Stornos und Verschiebungen verschwinden im naechsten
Lauf, DELETE /api/hrworks/entries entfernt genau diese Buchungen.

Nicht abrechenbare Projekte brauchen keinen Budgettopf mehr. Ein Topf
traegt Volumen, Laufzeit und Satz - das hat nur bei Billable und
Festpreis Sinn. Am Abwesenheitsprojekt war er nur ein Ablaufdatum, das
irgendwann Buchungen blockiert haette.

Taeglicher Lauf ueber systemd (perinco-pulse-hrworks.timer, 5:30 Uhr
Berliner Zeit, Persistent). Bedienung unter Einstellungen ->
Abwesenheiten (HRworks) mit Verbindungstest und Probelauf; der geheime
Schluessel liegt AES-verschluesselt in der Datenbank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 14:50:54 +02:00

225 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
// Geheimnisse werden nie ausgeliefert, nur die Information, dass sie stehen.
res.json({
...s,
sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) },
hrworks: {
...s.hrworks,
secret_access_key: '',
secret_set: Boolean(s.hrworks.secret_access_key),
configured: Boolean(s.hrworks.access_key && s.hrworks.secret_access_key),
},
});
} 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.hrworks) {
const next_ = { ...current.hrworks };
for (const key of ['access_key', 'project_code', 'holiday_task']) {
if (body.hrworks[key] !== undefined) next_[key] = text(body.hrworks[key], 200).trim();
}
if (body.hrworks.enabled !== undefined) next_.enabled = Boolean(body.hrworks.enabled);
if (body.hrworks.window_days !== undefined) {
const days = Number(body.hrworks.window_days);
if (days >= 1 && days <= 365) next_.window_days = Math.round(days);
else errors.push('HRworks: Fenster zwischen 1 und 365 Tagen.');
}
if (body.hrworks.half_day_hours !== undefined) {
const hours = Number(body.hrworks.half_day_hours);
if (hours >= 0 && hours <= 12) next_.half_day_hours = hours;
else errors.push('HRworks: Stunden für einen halben Tag zwischen 0 und 12.');
}
if (body.hrworks.type_map && typeof body.hrworks.type_map === 'object') {
const map = {};
for (const [key, value] of Object.entries(body.hrworks.type_map)) {
if (/^(absence|sick):[A-Za-z0-9_-]{1,20}$/.test(key)) map[key] = text(value, 160).trim();
}
next_.type_map = map;
}
// Leer = unveraendert; der geheime Teil wird nie ausgeliefert.
if (body.hrworks.secret_access_key) next_.secret_access_key = text(body.hrworks.secret_access_key, 500).trim();
if (body.hrworks.clear_secret) next_.secret_access_key = '';
if (next_.enabled && !(next_.access_key && next_.secret_access_key)) {
errors.push('HRworks kann erst aktiviert werden, wenn Access Key und Secret gesetzt sind.');
}
if (!errors.length) await saveSetting('hrworks', 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;