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>
225 lines
9.1 KiB
JavaScript
225 lines
9.1 KiB
JavaScript
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;
|