Files
perinco-Pulse/src/components/settings/Settings.tsx
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

441 lines
22 KiB
TypeScript
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 React, { useEffect, useState } from 'react';
import { Button, Card, ErrorBox, Field, Spinner, Toggle, inputClass } from '../ui/controls';
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' | '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 },
];
export const Settings: React.FC = () => {
const { route, navigate } = useRouter();
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const tabs = TABS.filter((t) => isAdmin || !t.adminOnly);
const tab = (tabs.find((t) => t.key === route.params[0])?.key ?? tabs[0].key) as Tab;
const { data, error, reload, setData } = useLoader(() => (isAdmin ? settingsApi.all() : Promise.resolve(null)), [isAdmin]);
return (
<div className="mx-auto grid max-w-[1400px] gap-4 lg:grid-cols-[240px_1fr]">
<nav className="no-scrollbar flex gap-1 overflow-x-auto lg:flex-col">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => navigate(`einstellungen/${t.key}`)}
className={`flex shrink-0 items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-bold transition ${
tab === t.key ? 'bg-surface text-brand-700 shadow-card' : 'text-ink-500 hover:bg-surface hover:text-ink-800'
}`}
>
<Icon name={t.icon} className="h-4 w-4" />
{t.label}
</button>
))}
</nav>
<div className="min-w-0">
{tab === 'sso-verwaltung' ? (
<SsoDirectory />
) : tab === 'benutzer' ? (
<Team embedded />
) : error && !data ? (
<ErrorBox message={error} onRetry={reload} />
) : !data ? (
<Spinner />
) : (
<div key={tab} className="animate-fade">
{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>
</div>
);
};
function useSaver() {
const { addToast } = useToast();
const { reloadSettings } = useAuth();
const [busy, setBusy] = useState(false);
const save = async (changes: Parameters<typeof settingsApi.save>[0], after?: () => void) => {
setBusy(true);
try {
await settingsApi.save(changes);
addToast('Einstellungen gespeichert.', 'success');
reloadSettings();
after?.();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
return { busy, save };
}
const Section: React.FC<{ title: string; hint?: string; children: React.ReactNode; footer?: React.ReactNode }> = ({ title, hint, children, footer }) => (
<Card className="animate-rise overflow-hidden">
<div className="border-b border-ink-100 px-5 py-4">
<h2 className="text-base text-ink-900">{title}</h2>
{hint && <p className="mt-0.5 text-xs text-ink-400">{hint}</p>}
</div>
<div className="space-y-4 p-5">{children}</div>
{footer && <div className="flex justify-end gap-2 border-t border-ink-100 bg-ink-50/60 px-5 py-3">{footer}</div>}
</Card>
);
const General: React.FC<{ settings: AdminSettings; onSaved: (s: AdminSettings) => void }> = ({ settings, onSaved }) => {
const [form, setForm] = useState({
weekly_hours: String(settings.weekly_hours),
hours_per_day: String(settings.hours_per_day),
budget_warn_percent: String(settings.budget_warn_percent),
budget_warn_days: String(settings.budget_warn_days),
company_name: settings.company_name,
project_code_pattern: settings.project_code_pattern,
project_name_pattern: settings.project_name_pattern,
});
const { busy, save } = useSaver();
const set = (patch: Partial<typeof form>) => setForm({ ...form, ...patch });
const n = (v: string) => Number(v.replace(',', '.'));
const example = form.project_code_pattern.replaceAll('{KUERZEL}', 'KUKA').replaceAll('{JAHR}', '2026').replaceAll('{JJ}', '26').replaceAll('{NR}', '003');
const exampleName = form.project_name_pattern.replaceAll('{KUNDE}', 'KUKA').replaceAll('{KUNDENNAME}', 'KUKA AG').replaceAll('{PROJEKT}', 'PMO').replaceAll('{CODE}', example);
const submit = () => {
const changes = {
weekly_hours: n(form.weekly_hours),
hours_per_day: n(form.hours_per_day),
budget_warn_percent: n(form.budget_warn_percent),
budget_warn_days: n(form.budget_warn_days),
company_name: form.company_name,
project_code_pattern: form.project_code_pattern,
project_name_pattern: form.project_name_pattern,
};
save(changes, () => onSaved({ ...settings, ...changes }));
};
return (
<div className="space-y-4">
<Section title="Arbeitszeit" hint="Grundlage für Soll-Stunden, Auslastung und die Umrechnung Stunden ↔ Tage">
<div className="grid gap-4 sm:grid-cols-3">
<Field label="Arbeitsstunden pro Woche" hint="Standard für alle, pro Benutzer überschreibbar">
<input inputMode="decimal" className={inputClass} value={form.weekly_hours} onChange={(e) => set({ weekly_hours: e.target.value })} />
</Field>
<Field label="Stunden pro Arbeitstag" hint="Für Tagessätze und Budget in Tagen">
<input inputMode="decimal" className={inputClass} value={form.hours_per_day} onChange={(e) => set({ hours_per_day: e.target.value })} />
</Field>
<Field label="Firmenname">
<input className={inputClass} value={form.company_name} onChange={(e) => set({ company_name: e.target.value })} />
</Field>
</div>
</Section>
<Section title="Budgetwarnungen" hint="Ab wann Pulse beim Buchen und in der Projektübersicht warnt. Gesperrt wird immer erst bei 100 % oder nach Ablauf.">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Warnen ab Verbrauch (%)">
<input inputMode="numeric" className={inputClass} value={form.budget_warn_percent} onChange={(e) => set({ budget_warn_percent: e.target.value })} />
</Field>
<Field label="Warnen ab Tagen vor Ablauf">
<input inputMode="numeric" className={inputClass} value={form.budget_warn_days} onChange={(e) => set({ budget_warn_days: e.target.value })} />
</Field>
</div>
</Section>
<Section
title="Projektbezeichnungen"
hint="Muster für den automatisch vergebenen Projektcode und den Anzeigenamen"
footer={
<Button icon="check" busy={busy} onClick={submit}>
Speichern
</Button>
}
>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Projektcode" hint={<>Platzhalter: {'{KUERZEL} {JAHR} {JJ} {NR}'} · Beispiel: <b className="text-ink-700">{example}</b></>}>
<input className={`${inputClass} tabular`} value={form.project_code_pattern} onChange={(e) => set({ project_code_pattern: e.target.value })} />
</Field>
<Field label="Anzeigename" hint={<>Platzhalter: {'{KUNDE} {KUNDENNAME} {PROJEKT} {CODE}'} · Beispiel: <b className="text-ink-700">{exampleName}</b></>}>
<input className={inputClass} value={form.project_name_pattern} onChange={(e) => set({ project_name_pattern: e.target.value })} />
</Field>
</div>
</Section>
</div>
);
};
const ExportSettings: React.FC<{ settings: AdminSettings; onSaved: (s: AdminSettings) => void }> = ({ settings, onSaved }) => {
const [ex, setEx] = useState(settings.export);
const { busy, save } = useSaver();
const { addToast } = useToast();
const set = (patch: Partial<typeof ex>) => setEx({ ...ex, ...patch });
const onLogo = (file: File | undefined) => {
if (!file) return;
if (!/image\/(png|jpe?g)/.test(file.type)) return addToast('Bitte PNG oder JPG wählen.', 'error');
if (file.size > 1_800_000) return addToast('Das Logo ist zu groß (max. 1,8 MB).', 'error');
const reader = new FileReader();
reader.onload = () => set({ logo: String(reader.result) });
reader.readAsDataURL(file);
};
const preview = async (format: 'pdf' | 'xlsx') => {
try {
await download(`/settings/export-preview?format=${format}`, `Vorschau.${format}`, format === 'pdf');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen.', 'error');
}
};
const text = (key: keyof typeof ex, label: string, hint?: string) => (
<Field label={label} hint={hint}>
<input className={inputClass} value={String(ex[key] ?? '')} onChange={(e) => set({ [key]: e.target.value })} />
</Field>
);
return (
<div className="grid gap-4 2xl:grid-cols-[1fr_520px]">
<Section
title="Kopf und Fuß der Exporte"
hint="Gilt für Stundennachweise und Reports in PDF und Excel"
footer={
<>
<Button variant="secondary" icon="document" onClick={() => preview('pdf')}>
PDF-Vorschau
</Button>
<Button variant="secondary" icon="grid" onClick={() => preview('xlsx')}>
Excel-Vorschau
</Button>
<Button icon="check" busy={busy} onClick={() => save({ export: ex }, () => onSaved({ ...settings, export: ex }))}>
Speichern
</Button>
</>
}
>
<p className="text-xs text-ink-400">Die Vorschau zeigt den zuletzt gespeicherten Stand.</p>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Logo</span>
<div className="flex items-center gap-3">
<div className="flex h-16 w-40 items-center justify-center rounded-xl border border-ink-200 bg-white p-2">
<img src={ex.logo || '/logo.png'} alt="Logo" className="max-h-full max-w-full object-contain" />
</div>
<div className="space-y-1">
<label className="block cursor-pointer text-xs font-bold text-brand-700 underline">
Hochladen
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={(e) => onLogo(e.target.files?.[0])} />
</label>
{ex.logo && (
<button className="block text-xs font-bold text-ink-400 underline" onClick={() => set({ logo: null })}>
perinco Logo verwenden
</button>
)}
</div>
</div>
</div>
<Field label="Akzentfarbe">
<div className="flex items-center gap-2">
<input type="color" className="h-10 w-14 cursor-pointer rounded-lg border border-ink-200 bg-surface" value={ex.accent} onChange={(e) => set({ accent: e.target.value })} />
<input className={`${inputClass} tabular`} value={ex.accent} onChange={(e) => set({ accent: e.target.value })} />
</div>
</Field>
{text('title', 'Titel', 'z. B. Stundennachweis, Leistungsnachweis')}
{text('subtitle', 'Kopfzeile rechts (1. Zeile)')}
{text('header_right', 'Kopfzeile rechts (2. Zeile)')}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Field label="Fuß links">
<textarea className={`${inputClass} h-20`} value={ex.footer_left} onChange={(e) => set({ footer_left: e.target.value })} placeholder={'perinco GmbH\nStraße 1 · 12345 Ort'} />
</Field>
<Field label="Fuß Mitte">
<textarea className={`${inputClass} h-20`} value={ex.footer_center} onChange={(e) => set({ footer_center: e.target.value })} placeholder={'Geschäftsführer …\nHRB …'} />
</Field>
<Field label="Fuß rechts">
<textarea className={`${inputClass} h-20`} value={ex.footer_right} onChange={(e) => set({ footer_right: e.target.value })} placeholder={'www.perinco.de\nBank · IBAN'} />
</Field>
</div>
<Toggle checked={ex.show_signature} onChange={(v) => set({ show_signature: v })} label="Unterschriftenfelder" hint="Am Ende des Stundennachweises – zur Abzeichnung durch den Kunden." />
{ex.show_signature && (
<div className="grid gap-4 sm:grid-cols-2">
{text('signature_left', 'Unterschrift links')}
{text('signature_right', 'Unterschrift rechts')}
</div>
)}
</Section>
<Card className="animate-rise h-fit p-4">
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Live-Skizze (A4)</p>
<div className="mx-auto aspect-[210/297] w-full max-w-[420px] rounded-md bg-white p-[6%] text-[#1f1f1f] shadow-lift" style={{ fontFamily: 'Helvetica, Arial, sans-serif' }}>
<div className="flex items-start justify-between">
<img src={ex.logo || '/logo.png'} alt="" className="h-8 w-auto max-w-[35%] object-contain" />
<div className="text-right text-[7px] leading-tight text-[#6e6e6e]">
<div>{ex.subtitle}</div>
<div>{ex.header_right}</div>
</div>
</div>
<div className="mt-2 h-[1.5px]" style={{ background: ex.accent }} />
<p className="mt-3 text-[13px] font-bold">{ex.title || 'Stundennachweis'}</p>
<p className="text-[8px]" style={{ color: ex.accent }}>
September 2026
</p>
<div className="mt-2 grid grid-cols-4 gap-1">
{['72,50', '72:30', '11', '34,00'].map((v) => (
<div key={v} className="rounded-sm border-l-2 bg-[#f4f7f1] px-1 py-1 text-[7px] font-bold" style={{ borderColor: ex.accent }}>
{v}
</div>
))}
</div>
<div className="mt-2 h-2.5 rounded-[1px]" style={{ background: ex.accent }} />
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className={`h-2.5 border-b border-[#eee] ${i % 2 ? 'bg-[#f4f7f1]' : ''}`} />
))}
{ex.show_signature && (
<div className="mt-6 grid grid-cols-2 gap-4 text-[6px] text-[#6e6e6e]">
<div className="border-t border-[#9a9a9a] pt-0.5">{ex.signature_left}</div>
<div className="border-t border-[#9a9a9a] pt-0.5">{ex.signature_right}</div>
</div>
)}
<div className="mt-auto" />
<div className="mt-8 grid grid-cols-3 border-t border-[#ddd] pt-1 text-[6px] leading-tight text-[#6e6e6e]">
<div className="whitespace-pre-line">{ex.footer_left}</div>
<div className="whitespace-pre-line text-center">{ex.footer_center}</div>
<div className="whitespace-pre-line text-right">
{ex.footer_right}
{'\n'}Seite 1 von 1
</div>
</div>
</div>
</Card>
</div>
);
};
const SsoSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = ({ settings, onSaved }) => {
const [sso, setSso] = useState({ ...settings.sso, client_secret: '' });
const { busy, save } = useSaver();
const { addToast } = useToast();
const { navigate } = useRouter();
const [testing, setTesting] = useState(false);
const set = (patch: Partial<typeof sso>) => setSso({ ...sso, ...patch });
useEffect(() => setSso({ ...settings.sso, client_secret: '' }), [settings.sso]);
const test = async () => {
setTesting(true);
try {
const r = await settingsApi.ssoTest();
addToast(`Verbindung ok – ${r.eligible} von ${r.checked} geprüften Konten zulässig. Beispiel: ${r.sample.join(', ') || '–'}`, 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Test fehlgeschlagen.', 'error');
} finally {
setTesting(false);
}
};
const text = (key: 'tenant_id' | 'client_id' | 'redirect_uri' | 'button_label', label: string, hint?: React.ReactNode, placeholder?: string) => (
<Field label={label} hint={hint}>
<input
name={`pulse-sso-${key}`}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
className={`${inputClass} font-mono text-xs`}
value={sso[key]}
placeholder={placeholder}
onChange={(e) => set({ [key]: e.target.value })}
/>
</Field>
);
return (
<div className="space-y-4">
<Section
title="Microsoft Entra ID (M365)"
hint="Anmeldung per OpenID Connect, Mitarbeiterliste über Microsoft Graph"
footer={
<>
<Button variant="secondary" icon="refresh" busy={testing} onClick={test} disabled={!settings.sso.configured}>
Verbindung testen
</Button>
<Button icon="check" busy={busy} onClick={() => save({ sso: { ...sso, client_secret: sso.client_secret || undefined } }, onSaved)}>
Speichern
</Button>
</>
}
>
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold ${settings.sso.enabled ? 'bg-brand-50 text-brand-800' : 'bg-ink-50 text-ink-600'}`}>
<Icon name={settings.sso.enabled ? 'check' : 'info'} className="h-5 w-5" />
{settings.sso.enabled
? 'SSO ist aktiv – der Anmeldebildschirm zeigt „Mit Microsoft anmelden“.'
: settings.sso.configured
? 'Zugangsdaten hinterlegt, SSO ist noch nicht eingeschaltet.'
: 'Noch nicht eingerichtet – bis dahin melden sich alle lokal an.'}
</div>
<div className="grid gap-4 sm:grid-cols-2">
{text('tenant_id', 'Tenant-ID (Verzeichnis-ID)', undefined, '00000000-0000-0000-0000-000000000000')}
{text('client_id', 'Client-ID (Anwendungs-ID)', undefined, '00000000-0000-0000-0000-000000000000')}
<Field label="Client Secret (Wert)" hint={settings.sso.client_secret_set ? 'Hinterlegt (verschlüsselt). Leer lassen = unverändert.' : 'Der „Wert“, nicht die Secret-ID. Wird verschlüsselt gespeichert und nie wieder angezeigt.'}>
{/* Kein type="password" - Passwortmanager (Brave) sollen hier nicht anspringen. */}
<input
name="pulse-sso-client-credential"
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
className={`${inputClass} font-mono text-xs`}
style={{ WebkitTextSecurity: 'disc' } as React.CSSProperties}
value={sso.client_secret}
onChange={(e) => set({ client_secret: e.target.value })}
placeholder={settings.sso.client_secret_set ? '••••••••••••' : ''}
/>
</Field>
{text('redirect_uri', 'Redirect-URI', 'In der App-Registrierung als Web-Plattform eintragen')}
{text('button_label', 'Beschriftung des Anmeldeknopfs')}
</div>
<div className="space-y-3 rounded-2xl border border-ink-200 p-4">
<Toggle checked={sso.enabled} onChange={(v) => set({ enabled: v })} label="SSO aktivieren" hint="Zeigt den Microsoft-Anmeldeknopf. Anmelden können sich nur Konten, die in der SSO-Verwaltung übernommen wurden." />
<Toggle
checked={sso.local_login}
onChange={(v) => set({ local_login: v })}
label="Lokale Anmeldung direkt anzeigen"
hint="Aus: das Formular für externe Benutzer und lokale Konten liegt hinter einem Link. Externe melden sich immer lokal an."
/>
</div>
</Section>
<Section title="Mitarbeiter übernehmen">
<div className="flex flex-wrap items-center gap-3">
<p className="min-w-0 flex-1 text-sm text-ink-600">
Angezeigt werden nur Konten im Format <code className="rounded bg-ink-100 px-1 text-xs">vorname.nachname@perinco.de</code> bzw.{' '}
<code className="rounded bg-ink-100 px-1 text-xs">@icon-experts.de</code> – ohne Admin-Konten, freigegebene Postfächer und Gäste. Übernehmen und Rollen vergeben per Drag & Drop, auch für Manager.
</p>
<Button icon="globe" onClick={() => navigate('einstellungen/sso-verwaltung')}>
Zur SSO-Verwaltung
</Button>
</div>
</Section>
<Section title="Einrichtung in Entra ID">
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-ink-600">
<li>entra.microsoft.com → App-Registrierungen → Neue Registrierung, Name „Pulse“, Kontotyp „Nur Konten in diesem Organisationsverzeichnis“.</li>
<li>
Umleitungs-URI: Plattform „Web“, <code className="rounded bg-ink-100 px-1 text-xs">{sso.redirect_uri}</code>
</li>
<li>Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel → den <b>Wert</b> hier eintragen (Ablaufdatum notieren, max. 24 Monate).</li>
<li>API-Berechtigungen → Microsoft Graph: delegiert <code className="rounded bg-ink-100 px-1 text-xs">openid profile email</code>, Anwendung <code className="rounded bg-ink-100 px-1 text-xs">User.Read.All</code> → „Administratorzustimmung erteilen“.</li>
<li>Tenant- und Client-ID aus der Übersicht kopieren, speichern, „Verbindung testen“, Mitarbeiter in der SSO-Verwaltung übernehmen, dann SSO aktivieren.</li>
</ol>
</Section>
</div>
);
};