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:
co-authored by
Claude Opus 5
parent
994f39daff
commit
532b15ce99
@@ -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>
|
||||
);
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user