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>
This commit is contained in:
schakal00783
2026-09-21 14:50:54 +02:00
co-authored by Claude Opus 5
parent 994f39daff
commit 532b15ce99
18 changed files with 1236 additions and 6 deletions
+256
View File
@@ -0,0 +1,256 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Field, Spinner, Toggle, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { hrworksApi, settingsApi, type AdminSettings, type HrworksReport, type HrworksStatus } from '../../lib/api';
import { formatDate, formatHours } from '../../lib/format';
import { useToast } from '../Toast';
/*
* Abwesenheiten aus HRworks.
*
* Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks; Pulse holt sie
* einmal taeglich und bucht sie auf ein nicht abrechenbares Projekt. Hier
* stehen die Zugangsdaten, die Zuordnung der Arten auf Tasks und der Knopf fuer
* einen Probelauf.
*/
export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = ({ settings, onSaved }) => {
const { addToast } = useToast();
const [form, setForm] = useState({ ...settings.hrworks, secret_access_key: '' });
const [status, setStatus] = useState<HrworksStatus | null>(null);
const [report, setReport] = useState<HrworksReport | null>(null);
const [busy, setBusy] = useState<'save' | 'test' | 'dry' | 'apply' | null>(null);
const set = (patch: Partial<typeof form>) => setForm({ ...form, ...patch });
useEffect(() => setForm({ ...settings.hrworks, secret_access_key: '' }), [settings.hrworks]);
useEffect(() => {
hrworksApi.status().then(setStatus).catch(() => undefined);
}, [settings.hrworks]);
const run = async (was: 'save' | 'test' | 'dry' | 'apply') => {
setBusy(was);
try {
if (was === 'save') {
await settingsApi.save({ hrworks: { ...form, secret_access_key: form.secret_access_key || undefined } });
addToast('Gespeichert.', 'success');
onSaved();
} else if (was === 'test') {
const r = await hrworksApi.test();
addToast(
`Verbindung steht: ${r.persons} Personen in HRworks, ${r.persons_with_pulse_account} mit Pulse-Konto.`,
'success'
);
} else {
const r = await hrworksApi.sync(was === 'apply');
setReport(r);
addToast(
was === 'apply'
? `Übernommen: ${r.created} neu, ${r.updated ?? 0} geändert, ${r.removed ?? 0} entfernt.`
: `Probelauf: ${r.planned_create} neu, ${r.planned_update} geändert, ${r.planned_delete} zu entfernen.`,
'success'
);
hrworksApi.status().then(setStatus).catch(() => undefined);
}
} catch (err) {
addToast(err instanceof Error ? err.message : 'Fehlgeschlagen.', 'error');
} finally {
setBusy(null);
}
};
const eintraege = Object.entries(form.type_map ?? {});
return (
<div className="space-y-4">
<Card className="animate-rise p-5 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-base text-ink-900">Abwesenheiten aus HRworks</h2>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" icon="refresh" busy={busy === 'test'} onClick={() => run('test')} disabled={!settings.hrworks.configured}>
Verbindung testen
</Button>
<Button icon="check" busy={busy === 'save'} onClick={() => run('save')}>
Speichern
</Button>
</div>
</div>
<div
className={`mb-5 flex items-start gap-3 rounded-xl px-4 py-3 text-sm font-semibold ${
settings.hrworks.enabled ? 'bg-brand-50 text-brand-800' : 'bg-ink-50 text-ink-600'
}`}
>
<Icon name={settings.hrworks.enabled ? 'check' : 'info'} className="mt-0.5 h-5 w-5 shrink-0" />
<span>
{settings.hrworks.enabled
? 'Der Abgleich läuft täglich um 5:30 Uhr und holt die letzten Tage nach.'
: settings.hrworks.configured
? 'Zugangsdaten hinterlegt, der Abgleich ist noch nicht eingeschaltet.'
: 'Noch keine Zugangsdaten. Schlüsselpaar in HRworks unter Grundlagen → Integrationen → HRworks-API erzeugen.'}
{status && status.entries > 0 && (
<>
{' '}
Übernommen sind {status.entries} Buchungen mit {formatHours(status.hours * 60)} Stunden
{status.first_date && status.last_date ? ` (${formatDate(status.first_date)} – ${formatDate(status.last_date)})` : ''}.
</>
)}
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Access Key">
<input className={inputClass} value={form.access_key} onChange={(e) => set({ access_key: e.target.value.trim() })} />
</Field>
<Field
label="Secret Access Key"
hint={settings.hrworks.secret_set ? 'Hinterlegt (verschlüsselt). Leer lassen = unverändert.' : 'Wird verschlüsselt gespeichert und nie wieder angezeigt.'}
>
<input
className={inputClass}
autoComplete="off"
value={form.secret_access_key}
placeholder={settings.hrworks.secret_set ? '••••••••••••' : ''}
onChange={(e) => set({ secret_access_key: e.target.value.trim() })}
/>
</Field>
<Field label="Projekt (Code)" hint="Nicht abrechenbares Projekt, auf das gebucht wird.">
<input className={inputClass} value={form.project_code} onChange={(e) => set({ project_code: e.target.value.trim() })} />
</Field>
<Field label="Fenster (Tage)" hint="So viele Tage prüft jeder Lauf rückwärts – Änderungen und Stornos werden so nachgezogen.">
<input className={inputClass} type="number" min={1} max={365} value={form.window_days} onChange={(e) => set({ window_days: Number(e.target.value) })} />
</Field>
<Field label="Task für Feiertage" hint="Leer = Feiertage nicht buchen.">
<input className={inputClass} value={form.holiday_task} onChange={(e) => set({ holiday_task: e.target.value })} />
</Field>
<Field label="Halber Tag (Stunden)" hint="0 = Stunden aus HRworks übernehmen.">
<input className={inputClass} type="number" min={0} max={12} step={0.5} value={form.half_day_hours} onChange={(e) => set({ half_day_hours: Number(e.target.value) })} />
</Field>
</div>
<div className="mt-4">
<Toggle
checked={form.enabled}
onChange={(v) => set({ enabled: v })}
label="Täglichen Abgleich aktivieren"
hint="Ohne Haken läuft nichts – auch der nächtliche Lauf nicht."
/>
</div>
{status?.project && (
<p className="mt-4 text-xs text-ink-400">
Zielprojekt: <b className="text-ink-600">{status.project.code}</b> – {status.project.name} ({status.project.type}), ab{' '}
{formatDate(status.project.start_date)}. Alle aktiven Mitarbeiter werden beim Abgleich zugewiesen.
</p>
)}
</Card>
<Card className="animate-rise p-5 sm:p-6" style={{ '--i': 1 } as React.CSSProperties}>
<h3 className="mb-1 text-sm text-ink-900">Zuordnung der Arten</h3>
<p className="mb-3 text-xs leading-snug text-ink-400">
Links der Schlüssel aus HRworks (<code className="rounded bg-ink-100 px-1">absence:</code> oder{' '}
<code className="rounded bg-ink-100 px-1">sick:</code>), rechts der Taskname in Pulse. Fehlt ein Eintrag, sucht Pulse
einen Task mit dem HRworks-Namen der Art – findet es keinen, meldet der Probelauf ihn als fehlend.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{eintraege.map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<span className="tabular w-28 shrink-0 text-xs font-bold text-ink-500">{key}</span>
<input
className={`${inputClass} py-2`}
value={value}
onChange={(e) => set({ type_map: { ...form.type_map, [key]: e.target.value } })}
/>
</div>
))}
</div>
</Card>
<Card className="animate-rise p-5 sm:p-6" style={{ '--i': 2 } as React.CSSProperties}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm text-ink-900">Abgleich jetzt</h3>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" icon="refresh" busy={busy === 'dry'} onClick={() => run('dry')} disabled={!settings.hrworks.configured}>
Probelauf
</Button>
<Button icon="download" busy={busy === 'apply'} onClick={() => run('apply')} disabled={!settings.hrworks.configured}>
Jetzt übernehmen
</Button>
</div>
</div>
{busy === 'dry' || busy === 'apply' ? (
<Spinner label="Hole Daten aus HRworks…" />
) : report ? (
<Bericht report={report} />
) : (
<p className="text-xs text-ink-400">
Der Probelauf schreibt nichts. Er zeigt, welche Buchungen entstehen, geändert oder entfernt würden.
</p>
)}
{status?.last_run && (
<p className="mt-4 border-t border-ink-100 pt-3 text-xs text-ink-400">
Letzter Lauf: {new Date(status.last_run).toLocaleString('de-DE')}
{status.last_result
? ` – ${status.last_result.created} neu, ${status.last_result.updated} geändert, ${status.last_result.removed} entfernt, ${status.last_result.unchanged} unverändert.`
: ''}
</p>
)}
</Card>
</div>
);
};
const Bericht: React.FC<{ report: HrworksReport }> = ({ report }) => (
<div className="space-y-3 text-sm">
<p className="font-semibold text-ink-700">
{formatDate(report.from)} – {formatDate(report.to)}:{' '}
{report.dry_run
? `${report.planned_create} neu, ${report.planned_update} geändert, ${report.planned_delete} zu entfernen, ${report.unchanged} unverändert`
: `${report.created} neu, ${report.updated ?? 0} geändert, ${report.removed ?? 0} entfernt, ${report.unchanged} unverändert`}{' '}
· {formatHours(report.hours * 60)} Stunden
</p>
{report.missing_tasks.length > 0 && (
<p className="rounded-xl bg-amber-50 px-3 py-2 text-xs font-semibold text-amber-700">
Diese Tasks fehlen im Projekt: {report.missing_tasks.join(', ')}
</p>
)}
{report.unmatched_persons.length > 0 && (
<p className="rounded-xl bg-ink-50 px-3 py-2 text-xs text-ink-500">
Ohne Pulse-Konto: {report.unmatched_persons.map((p) => `${p.name || p.person_id}`).join(', ')}
</p>
)}
{report.failed.length > 0 && (
<ul className="space-y-1 rounded-xl bg-red-50 px-3 py-2 text-xs text-red-700">
{report.failed.slice(0, 8).map((f) => (
<li key={f.ref}>
{f.ref}: {f.error}
</li>
))}
</ul>
)}
{Object.keys(report.per_person).length > 0 && (
<div className="scrollbar-slim max-h-72 overflow-y-auto rounded-xl border border-ink-100">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-ink-50 text-[10px] uppercase tracking-wider text-ink-400">
<tr>
<th className="px-3 py-2 text-left">Mitarbeiter</th>
<th className="px-3 py-2 text-left">Art</th>
<th className="px-3 py-2 text-right">Tage</th>
<th className="px-3 py-2 text-right">Stunden</th>
</tr>
</thead>
<tbody>
{Object.entries(report.per_person).flatMap(([person, arten]) =>
Object.entries(arten).map(([art, wert], i) => (
<tr key={`${person}-${art}`} className="border-t border-ink-100">
<td className="px-3 py-1.5 font-semibold text-ink-700">{i === 0 ? person : ''}</td>
<td className="px-3 py-1.5 text-ink-600">{art}</td>
<td className="tabular px-3 py-1.5 text-right text-ink-600">{wert.days}</td>
<td className="tabular px-3 py-1.5 text-right font-bold text-ink-900">{formatHours(wert.hours * 60)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
);
+4 -1
View File
@@ -3,17 +3,19 @@ import { Button, Card, ErrorBox, Field, Spinner, Toggle, inputClass } from '../u
import { Icon, type IconName } from '../ui/Icon';
import { Team } from '../team/Team';
import { SsoDirectory } from './SsoDirectory';
import { HrworksSettings } from './HrworksSettings';
import { useAuth } from '../../lib/auth';
import { download, settingsApi, type AdminSettings } from '../../lib/api';
import { useLoader } from '../../lib/hooks';
import { useRouter } from '../../lib/router';
import { useToast } from '../Toast';
type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'benutzer';
type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'hrworks' | 'benutzer';
const TABS: { key: Tab; label: string; icon: IconName; adminOnly: boolean }[] = [
{ key: 'allgemein', label: 'Allgemein', icon: 'settings', adminOnly: true },
{ key: 'export', label: 'Export: Kopf & Fuß', icon: 'document', adminOnly: true },
{ key: 'sso', label: 'SSO / Entra ID', icon: 'key', adminOnly: true },
{ key: 'hrworks', label: 'Abwesenheiten (HRworks)', icon: 'calendar', adminOnly: true },
// Management sieht in den Einstellungen nur diesen Reiter.
{ key: 'sso-verwaltung', label: 'SSO-Verwaltung', icon: 'globe', adminOnly: false },
{ key: 'benutzer', label: 'Benutzerverwaltung', icon: 'team', adminOnly: true },
@@ -57,6 +59,7 @@ export const Settings: React.FC = () => {
{tab === 'allgemein' && <General settings={data} onSaved={(s) => setData(s)} />}
{tab === 'export' && <ExportSettings settings={data} onSaved={(s) => setData(s)} />}
{tab === 'sso' && <SsoSettings settings={data} onSaved={reload} />}
{tab === 'hrworks' && <HrworksSettings settings={data} onSaved={reload} />}
</div>
)}
</div>
+66 -1
View File
@@ -162,13 +162,78 @@ export interface SsoSettings {
redirect_uri: string;
local_login: boolean;
}
export interface HrworksSettings {
enabled: boolean;
access_key: string;
secret_access_key: string;
secret_set?: boolean;
configured?: boolean;
project_code: string;
window_days: number;
type_map: Record<string, string>;
holiday_task: string;
half_day_hours: number;
last_run: string | null;
last_result: { created: number; updated: number; removed: number; unchanged: number; hours: number; failed: number } | null;
}
export interface HrworksStatus {
enabled: boolean;
configured: boolean;
project: { id: number; code: string; name: string; type: string; start_date: string } | null;
project_code: string;
window_days: number;
holiday_task: string;
half_day_hours: number;
type_map: Record<string, string>;
last_run: string | null;
last_result: HrworksSettings['last_result'];
entries: number;
hours: number;
first_date: string | null;
last_date: string | null;
}
export interface HrworksReport {
dry_run: boolean;
from: string;
to: string;
created: number;
updated?: number;
removed?: number;
unchanged: number;
planned_create: number;
planned_update: number;
planned_delete: number;
hours: number;
failed: { ref: string; error: string }[];
unmatched_persons: { person_id: string; name: string; email: string | null }[];
missing_tasks: string[];
per_person: Record<string, Record<string, { hours: number; days: number }>>;
}
export const hrworksApi = {
status: () => get<HrworksStatus>('/hrworks/status'),
test: () =>
send<{ ok: true; persons: number; persons_with_pulse_account: number; absence_types: { key: string; name: string }[] }>(
'POST',
'/hrworks/test',
{}
),
sync: (apply = false) => send<HrworksReport>('POST', '/hrworks/sync', { apply }),
entries: () => get<{ count: number; entries: unknown[] }>('/hrworks/entries'),
removeAll: () => send<{ deleted: number }>('DELETE', '/hrworks/entries'),
};
export interface AdminSettings extends PublicSettings {
export: ExportSettings;
sso: SsoSettings;
hrworks: HrworksSettings;
}
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso'> & {
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso' | 'hrworks'> & {
export: Partial<ExportSettings>;
sso: Partial<SsoSettings> & { clear_secret?: boolean };
hrworks: Partial<HrworksSettings> & { clear_secret?: boolean };
};
export interface AdUser {