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 (
{tabs.map((t) => (
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'
}`}
>
{t.label}
))}
{tab === 'sso-verwaltung' ? (
) : tab === 'benutzer' ? (
) : error && !data ? (
) : !data ? (
) : (
{tab === 'allgemein' && setData(s)} />}
{tab === 'export' && setData(s)} />}
{tab === 'sso' && }
{tab === 'hrworks' && }
)}
);
};
function useSaver() {
const { addToast } = useToast();
const { reloadSettings } = useAuth();
const [busy, setBusy] = useState(false);
const save = async (changes: Parameters[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 }) => (
{children}
{footer && {footer}
}
);
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) => 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 (
);
};
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) => 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) => (
set({ [key]: e.target.value })} />
);
return (
preview('pdf')}>
PDF-Vorschau
preview('xlsx')}>
Excel-Vorschau
save({ export: ex }, () => onSaved({ ...settings, export: ex }))}>
Speichern
>
}
>
Die Vorschau zeigt den zuletzt gespeicherten Stand.
set({ show_signature: v })} label="Unterschriftenfelder" hint="Am Ende des Stundennachweises – zur Abzeichnung durch den Kunden." />
{ex.show_signature && (
{text('signature_left', 'Unterschrift links')}
{text('signature_right', 'Unterschrift rechts')}
)}
Live-Skizze (A4)
{ex.subtitle}
{ex.header_right}
{ex.title || 'Stundennachweis'}
September 2026
{['72,50', '72:30', '11', '34,00'].map((v) => (
{v}
))}
{Array.from({ length: 9 }).map((_, i) => (
))}
{ex.show_signature && (
{ex.signature_left}
{ex.signature_right}
)}
{ex.footer_left}
{ex.footer_center}
{ex.footer_right}
{'\n'}Seite 1 von 1
);
};
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) => 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) => (
set({ [key]: e.target.value })}
/>
);
return (
Verbindung testen
save({ sso: { ...sso, client_secret: sso.client_secret || undefined } }, onSaved)}>
Speichern
>
}
>
{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.'}
{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')}
{/* Kein type="password" - Passwortmanager (Brave) sollen hier nicht anspringen. */}
set({ client_secret: e.target.value })}
placeholder={settings.sso.client_secret_set ? '••••••••••••' : ''}
/>
{text('redirect_uri', 'Redirect-URI', 'In der App-Registrierung als Web-Plattform eintragen')}
{text('button_label', 'Beschriftung des Anmeldeknopfs')}
set({ enabled: v })} label="SSO aktivieren" hint="Zeigt den Microsoft-Anmeldeknopf. Anmelden können sich nur Konten, die in der SSO-Verwaltung übernommen wurden." />
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."
/>
Angezeigt werden nur Konten im Format vorname.nachname@perinco.de bzw.{' '}
@icon-experts.de – ohne Admin-Konten, freigegebene Postfächer und Gäste. Übernehmen und Rollen vergeben per Drag & Drop, auch für Manager.
navigate('einstellungen/sso-verwaltung')}>
Zur SSO-Verwaltung
entra.microsoft.com → App-Registrierungen → Neue Registrierung, Name „Pulse“, Kontotyp „Nur Konten in diesem Organisationsverzeichnis“.
Umleitungs-URI: Plattform „Web“, {sso.redirect_uri}
Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel → den Wert hier eintragen (Ablaufdatum notieren, max. 24 Monate).
API-Berechtigungen → Microsoft Graph: delegiert openid profile email, Anwendung User.Read.All → „Administratorzustimmung erteilen“.
Tenant- und Client-ID aus der Übersicht kopieren, speichern, „Verbindung testen“, Mitarbeiter in der SSO-Verwaltung übernehmen, dann SSO aktivieren.
);
};