Files
perinco-Pulse/src/components/settings/SsoDirectory.tsx
T
schakal00783andClaude Opus 5 e962be41ea fix(historie): Uebernahme alter Zeiten, Rollenzone Externe, Reportfehler
Die Uebernahme der Harvest-Historie ist an drei Stellen haengen geblieben:

- resolvePot hat abgelaufene Toepfe grundsaetzlich gesperrt. Fuer eine
  historische Buchung ist das falsch - dort zaehlt, ob der Topf am
  Leistungstag lief. Der Importweg setzt jetzt `historic`, das Volumen
  wird weiter geprueft, damit Ueberbuchungen auffallen.
- In der SSO-Verwaltung fehlte die Ablagezone fuer die Rolle "Externe";
  Konten liessen sich dort nicht zuordnen.
- In buildProjectDetail und der Kundenauswertung stand ein Rest der
  Auslastungsaenderung (bisHeute/capTo) ohne Bezug im Scope. Beide
  Reportseiten liefen dadurch auf 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:56:09 +02:00

335 lines
16 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, useMemo, useState } from 'react';
import { Avatar, Badge, Button, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
import { Icon, type IconName } from '../ui/Icon';
import { useAuth } from '../../lib/auth';
import { directoryApi, usersApi, type AdUser } from '../../lib/api';
import { useLoader } from '../../lib/hooks';
import { relativeTime } from '../../lib/format';
import { useToast } from '../Toast';
import { ROLE_LABELS, ROLE_TONE, type DirectoryUser, type Role } from '../../types';
type ImportRole = Exclude<Role, 'admin'>;
const ZONES: { role: ImportRole; hint: string; icon: IconName; tone: string }[] = [
{ role: 'user', hint: 'Bucht Zeiten auf zugewiesene Projekte', icon: 'user', tone: 'border-ink-300 bg-ink-50 text-ink-700' },
// Gleiche Rechte wie Mitarbeiter; die Rolle trennt nur, wer zum Haus gehoert.
{ role: 'external', hint: 'Wie Mitarbeiter, gilt als externe Kraft', icon: 'globe', tone: 'border-amber-200 bg-amber-50 text-amber-700' },
{ role: 'lead', hint: 'Sieht und verwaltet sein Team', icon: 'team', tone: 'border-sky-200 bg-sky-50 text-sky-700' },
{ role: 'management', hint: 'Sieht alles, verwaltet SSO', icon: 'shield', tone: 'border-violet-200 bg-violet-50 text-violet-700' },
];
const DRAG_TYPE = 'application/x-pulse-ad-users';
/**
* SSO-Verwaltung: links alle zulaessigen AD-Konten, rechts die Rollen. Ziehen
* auf eine Rolle uebernimmt das Konto nach Pulse bzw. aendert seine Rolle.
* Fuer Touch und Tastatur gibt es dasselbe ueber Auswahl + Knopf.
*/
export const SsoDirectory: React.FC = () => {
const { user } = useAuth();
const { addToast } = useToast();
const [refresh, setRefresh] = useState(0);
const { data, error, loading, reload } = useLoader(() => directoryApi.list(refresh > 0), [refresh]);
const [managers, setManagers] = useState<DirectoryUser[]>([]);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<'open' | 'imported' | 'all'>('open');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [managerId, setManagerId] = useState<number | ''>('');
const [dragOver, setDragOver] = useState<ImportRole | null>(null);
const [busy, setBusy] = useState<ImportRole | 'sync' | null>(null);
const loadManagers = () =>
usersApi
.directory()
.then((list) => setManagers(list.filter((u) => u.is_active && (u.role === 'lead' || u.role === 'management'))))
.catch(() => undefined);
useEffect(() => {
loadManagers();
}, []);
const users = data?.users ?? [];
const visible = useMemo(() => {
const q = search.trim().toLowerCase();
return users.filter((u) => {
if (filter === 'open' && u.pulse_user_id) return false;
if (filter === 'imported' && !u.pulse_user_id) return false;
return !q || `${u.name} ${u.email} ${u.job_title ?? ''} ${u.department ?? ''}`.toLowerCase().includes(q);
});
}, [users, search, filter]);
const byRole = (role: ImportRole) => users.filter((u) => u.pulse_user_id && u.pulse_role === role);
const importedCount = users.filter((u) => u.pulse_user_id).length;
const selectable = visible.filter((u) => u.pulse_role !== 'admin');
const allSelected = selectable.length > 0 && selectable.every((u) => selected.has(u.id));
const toggle = (id: string) =>
setSelected((cur) => {
const next = new Set(cur);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const assign = async (ids: string[], role: ImportRole) => {
const list = ids.filter((id) => users.find((u) => u.id === id)?.pulse_role !== 'admin');
if (!list.length) return;
setBusy(role);
try {
const r = await directoryApi.import(list, role, managerId || null);
const parts = [];
if (r.created.length) parts.push(`${r.created.length} übernommen`);
if (r.linked.length) parts.push(`${r.linked.length} aktualisiert`);
if (r.skipped.length) parts.push(`${r.skipped.length} nicht mehr im AD`);
addToast(`${ROLE_LABELS[role]}: ${parts.join(', ') || 'keine Änderung'}.`, 'success');
setSelected(new Set());
await reload();
if (role !== 'user' && role !== 'external') loadManagers();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
} finally {
setBusy(null);
}
};
const sync = async () => {
setBusy('sync');
try {
const r = await directoryApi.sync();
addToast(`Abgleich: ${r.updated} aktualisiert, ${r.deactivated} deaktiviert (${r.eligible} zulässige von ${r.total} AD-Konten).`, 'success');
setRefresh((n) => n + 1);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error');
} finally {
setBusy(null);
}
};
const onDragStart = (event: React.DragEvent, u: AdUser) => {
// Gezogene Zeile gehoert zur Auswahl? Dann die ganze Auswahl mitnehmen.
const ids = selected.has(u.id) ? [...selected] : [u.id];
event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(ids));
event.dataTransfer.setData('text/plain', ids.length > 1 ? `${ids.length} Benutzer` : u.name);
event.dataTransfer.effectAllowed = 'copyMove';
};
const onDrop = (event: React.DragEvent, role: ImportRole) => {
event.preventDefault();
setDragOver(null);
try {
const ids = JSON.parse(event.dataTransfer.getData(DRAG_TYPE) || '[]');
if (Array.isArray(ids) && ids.length) assign(ids, role);
} catch {
/* fremdes Drag-Objekt */
}
};
if (error && !data) return <ErrorBox message={error} onRetry={reload} />;
if (!data) return <Spinner label="Lade Active Directory…" />;
if (!data.configured) {
return (
<EmptyState
icon="key"
title="SSO ist noch nicht eingerichtet"
text={
user?.role === 'admin'
? 'Trage zuerst Tenant-ID, Client-ID und Client Secret unter „SSO / Entra ID“ ein.'
: 'Ein Administrator muss zuerst die Zugangsdaten zu Microsoft Entra ID hinterlegen.'
}
/>
);
}
return (
<div className="space-y-4">
<Card className="animate-rise flex flex-wrap items-center gap-3 p-4">
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
<Icon name="key" className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-extrabold text-ink-900">
{users.length} Mitarbeiterkonten im AD · {importedCount} in Pulse
</p>
<p className="text-xs text-ink-400">
Nur vorname.nachname@{data.domains.join(' / @')} – ohne Admin-Konten, freigegebene Postfächer und Gäste
{data.fetched_at && ` · Stand ${relativeTime(data.fetched_at)}`}
</p>
</div>
<Button size="sm" variant="secondary" icon="refresh" busy={loading && refresh > 0} onClick={() => setRefresh((n) => n + 1)}>
Neu laden
</Button>
<Button size="sm" variant="secondary" icon="refresh" busy={busy === 'sync'} onClick={sync} title="Namen/Mail aktualisieren, im AD deaktivierte Konten sperren">
Abgleich
</Button>
</Card>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
{/* Active Directory */}
<Card className="animate-rise flex min-h-[480px] flex-col overflow-hidden" style={{ '--i': 1 } as React.CSSProperties}>
<div className="space-y-3 border-b border-ink-100 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 whitespace-nowrap text-base text-ink-900">
<Icon name="globe" className="h-4 w-4 text-brand-600" />
Active Directory
</h3>
<Segmented
size="sm"
value={filter}
onChange={(v) => {
setFilter(v);
setSelected(new Set());
}}
options={[
{ value: 'open', label: 'Nicht übernommen' },
{ value: 'imported', label: 'In Pulse' },
{ value: 'all', label: 'Alle' },
]}
/>
</div>
<div className="relative">
<Icon name="search" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" />
<input className={`${inputClass} pl-9`} placeholder="Name, E-Mail, Abteilung…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-xs font-bold text-ink-500">
<input
type="checkbox"
checked={allSelected}
onChange={() => setSelected(allSelected ? new Set() : new Set(selectable.map((u) => u.id)))}
className="h-4 w-4 accent-[var(--color-brand-500)]"
/>
Alle {visible.length} auswählen
<span className="ml-auto hidden font-semibold text-ink-400 sm:inline">Ziehen auf eine Rolle →</span>
</label>
</div>
<ul className="scrollbar-slim max-h-[620px] flex-1 divide-y divide-ink-100 overflow-y-auto">
{visible.length === 0 && <li className="p-8 text-center text-sm text-ink-400">Keine Konten für diesen Filter.</li>}
{visible.map((u, i) => {
const isAdmin = u.pulse_role === 'admin';
const checked = selected.has(u.id);
return (
<li
key={u.id}
draggable={!isAdmin}
onDragStart={(e) => onDragStart(e, u)}
className={`animate-rise flex items-center gap-3 px-4 py-2.5 transition ${isAdmin ? 'opacity-60' : 'cursor-grab active:cursor-grabbing'} ${
checked ? 'bg-brand-50' : 'hover:bg-ink-50'
}`}
style={{ '--i': Math.min(i, 12) } as React.CSSProperties}
>
<input type="checkbox" disabled={isAdmin} checked={checked} onChange={() => toggle(u.id)} className="h-4 w-4 shrink-0 accent-[var(--color-brand-500)]" aria-label={`${u.name} auswählen`} />
<Avatar name={u.name} size={34} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-ink-900">{u.name}</p>
<p className="truncate text-xs text-ink-400">
{u.email}
{(u.job_title || u.department) && ` · ${[u.job_title, u.department].filter(Boolean).join(', ')}`}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{u.pulse_role && <Badge tone={ROLE_TONE[u.pulse_role]}>{ROLE_LABELS[u.pulse_role]}</Badge>}
{u.pulse_active === false && <Badge tone="red">inaktiv</Badge>}
{!u.pulse_user_id && u.local_match_id && (
<Badge tone="amber" title="Ein lokales Konto mit dieser Mailadresse wird beim Übernehmen verknüpft">
lokal vorhanden
</Badge>
)}
</div>
{!isAdmin && <Icon name="more" className="hidden h-4 w-4 shrink-0 rotate-90 text-ink-300 sm:block" />}
</li>
);
})}
</ul>
{selected.size > 0 && (
<div className="animate-sheet safe-bottom flex flex-wrap items-center gap-2 border-t border-ink-200 bg-surface p-3">
<span className="text-xs font-extrabold text-ink-700">{selected.size} ausgewählt – übernehmen als</span>
{ZONES.map((z) => (
<Button key={z.role} size="sm" variant="secondary" icon={z.icon} busy={busy === z.role} onClick={() => assign([...selected], z.role)}>
{ROLE_LABELS[z.role]}
</Button>
))}
</div>
)}
</Card>
{/* Rollen */}
<div className="animate-rise space-y-3" style={{ '--i': 2 } as React.CSSProperties}>
<Card className="flex flex-wrap items-center gap-3 p-4">
<Icon name="team" className="h-5 w-5 text-ink-400" />
<label className="min-w-0 flex-1 text-xs font-bold text-ink-500" htmlFor="sso-manager">
Vorgesetzter für übernommene Benutzer
</label>
<select id="sso-manager" className={`${inputClass} !w-auto max-w-[240px]`} value={managerId} onChange={(e) => setManagerId(e.target.value ? Number(e.target.value) : '')}>
<option value="">– nicht ändern –</option>
{managers.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({ROLE_LABELS[m.role]})
</option>
))}
</select>
</Card>
{ZONES.map((zone) => {
const members = byRole(zone.role);
const over = dragOver === zone.role;
return (
<div
key={zone.role}
onDragOver={(e) => {
if (!e.dataTransfer.types.includes(DRAG_TYPE)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
if (dragOver !== zone.role) setDragOver(zone.role);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOver(null);
}}
onDrop={(e) => onDrop(e, zone.role)}
className={`rounded-2xl border-2 border-dashed p-4 transition-all duration-200 ${
over ? 'scale-[1.01] border-brand-400 bg-brand-50 shadow-lift' : 'border-ink-200 bg-surface'
}`}
>
<div className="flex items-center gap-3">
<span className={`flex h-10 w-10 items-center justify-center rounded-xl border ${zone.tone}`}>
<Icon name={zone.icon} className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-extrabold text-ink-900">
{ROLE_LABELS[zone.role]} <span className="text-ink-400">({members.length})</span>
</p>
<p className="text-xs text-ink-400">{over ? 'Loslassen zum Übernehmen' : zone.hint}</p>
</div>
{busy === zone.role && <span className="h-5 w-5 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />}
</div>
{members.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{members.map((m) => (
<span
key={m.id}
draggable
onDragStart={(e) => onDragStart(e, m)}
title="Auf eine andere Rolle ziehen, um sie zu ändern"
className={`flex cursor-grab items-center gap-1.5 rounded-full border border-ink-200 bg-surface py-0.5 pl-0.5 pr-2.5 text-xs font-bold text-ink-700 active:cursor-grabbing ${
m.pulse_active === false ? 'opacity-50' : ''
}`}
>
<Avatar name={m.name} size={22} />
{m.name}
</span>
))}
</div>
)}
</div>
);
})}
<p className="px-1 text-xs text-ink-400">
Anmelden per Microsoft können sich nur übernommene Konten. Rollen werden ausschließlich hier bzw. unter Team vergeben – AD-Gruppen spielen keine Rolle.
Hierarchie, Wochenstunden und Deaktivieren unter <b>Team</b>.
</p>
</div>
</div>
</div>
);
};