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>
335 lines
16 KiB
TypeScript
335 lines
16 KiB
TypeScript
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>
|
||
);
|
||
};
|