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:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+332
View File
@@ -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>
);
};