feat: perinco Pulse - Zeiterfassung fuer perinco
Erste Fassung, live unter https://pulse.perinco.de. Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV. Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM. Fachlich: - Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5 - Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder aufgebrauchtem Budget, Buchung nur auf den passenden Topf - Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte - Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend) - SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
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, 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' },
|
||||
{ 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') 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={u.pulse_role === 'user' ? 'neutral' : u.pulse_role === 'lead' ? 'sky' : u.pulse_role === 'management' ? 'violet' : 'brand'}>{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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user