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,102 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Avatar, Button, Field, inputClass } from '../ui/controls';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { authApi } from '../../lib/api';
|
||||
import { useToast } from '../Toast';
|
||||
import { ROLE_LABELS } from '../../types';
|
||||
|
||||
const COLORS = ['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899'];
|
||||
|
||||
/** Mein Konto: Farbe, Passwort, Abmelden - fuer jede Rolle. */
|
||||
export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const { user, updateUser, logout, settings } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
const [current, setCurrent] = useState('');
|
||||
const [next, setNext] = useState('');
|
||||
const [repeat, setRepeat] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!user) return null;
|
||||
|
||||
const changeColor = async (color: string) => {
|
||||
try {
|
||||
const { user: saved } = await authApi.updateMe({ color });
|
||||
updateUser(saved);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (next !== repeat) return addToast('Die neuen Passwörter stimmen nicht überein.', 'error');
|
||||
setBusy(true);
|
||||
try {
|
||||
await authApi.changePassword(current, next);
|
||||
addToast('Passwort geändert.', 'success');
|
||||
setCurrent('');
|
||||
setNext('');
|
||||
setRepeat('');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Ändern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="Mein Konto" subtitle={`${ROLE_LABELS[user.role]} · ${user.username}`} onClose={onClose}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={user.name} color={user.color} id={user.id} size={56} external={user.is_external} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lg font-extrabold text-ink-900">{user.name}</p>
|
||||
<p className="truncate text-sm text-ink-500">{user.email || '–'}</p>
|
||||
<p className="text-xs text-ink-400">
|
||||
{user.is_external ? `Extern${user.company ? ` · ${user.company}` : ''}` : settings.company_name} · Soll {user.weekly_hours ?? settings.weekly_hours} h/Woche
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-[11px] font-bold uppercase tracking-wider text-ink-500">Meine Farbe</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => changeColor(color)}
|
||||
aria-label={`Farbe ${color}`}
|
||||
className={`h-8 w-8 rounded-full transition hover:scale-110 ${user.color === color ? 'ring-2 ring-ink-800 ring-offset-2 ring-offset-surface' : ''}`}
|
||||
style={{ background: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{user.source === 'local' ? (
|
||||
<form onSubmit={changePassword} className="mt-8 space-y-4 rounded-2xl border border-ink-200 p-4">
|
||||
<p className="text-sm font-extrabold text-ink-900">Passwort ändern</p>
|
||||
<Field label="Aktuelles Passwort">
|
||||
<input type="password" autoComplete="current-password" required className={inputClass} value={current} onChange={(e) => setCurrent(e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Neues Passwort" hint="Mindestens 8 Zeichen">
|
||||
<input type="password" autoComplete="new-password" minLength={8} required className={inputClass} value={next} onChange={(e) => setNext(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Wiederholen">
|
||||
<input type="password" autoComplete="new-password" minLength={8} required className={inputClass} value={repeat} onChange={(e) => setRepeat(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Button type="submit" busy={busy} icon="key">
|
||||
Passwort speichern
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="mt-8 rounded-xl bg-ink-50 p-4 text-sm text-ink-500">Dein Konto meldet sich über Microsoft (SSO) an – das Passwort verwaltest du dort.</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button variant="secondary" icon="logout" onClick={logout}>
|
||||
Abmelden
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,437 @@
|
||||
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 { 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' | '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 },
|
||||
// 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 (
|
||||
<div className="mx-auto grid max-w-[1400px] gap-4 lg:grid-cols-[240px_1fr]">
|
||||
<nav className="no-scrollbar flex gap-1 overflow-x-auto lg:flex-col">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<Icon name={t.icon} className="h-4 w-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="min-w-0">
|
||||
{tab === 'sso-verwaltung' ? (
|
||||
<SsoDirectory />
|
||||
) : tab === 'benutzer' ? (
|
||||
<Team embedded />
|
||||
) : error && !data ? (
|
||||
<ErrorBox message={error} onRetry={reload} />
|
||||
) : !data ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div key={tab} className="animate-fade">
|
||||
{tab === 'allgemein' && <General settings={data} onSaved={(s) => setData(s)} />}
|
||||
{tab === 'export' && <ExportSettings settings={data} onSaved={(s) => setData(s)} />}
|
||||
{tab === 'sso' && <SsoSettings settings={data} onSaved={reload} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useSaver() {
|
||||
const { addToast } = useToast();
|
||||
const { reloadSettings } = useAuth();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const save = async (changes: Parameters<typeof settingsApi.save>[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 }) => (
|
||||
<Card className="animate-rise overflow-hidden">
|
||||
<div className="border-b border-ink-100 px-5 py-4">
|
||||
<h2 className="text-base text-ink-900">{title}</h2>
|
||||
{hint && <p className="mt-0.5 text-xs text-ink-400">{hint}</p>}
|
||||
</div>
|
||||
<div className="space-y-4 p-5">{children}</div>
|
||||
{footer && <div className="flex justify-end gap-2 border-t border-ink-100 bg-ink-50/60 px-5 py-3">{footer}</div>}
|
||||
</Card>
|
||||
);
|
||||
|
||||
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<typeof form>) => 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 (
|
||||
<div className="space-y-4">
|
||||
<Section title="Arbeitszeit" hint="Grundlage für Soll-Stunden, Auslastung und die Umrechnung Stunden ↔ Tage">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label="Arbeitsstunden pro Woche" hint="Standard für alle, pro Benutzer überschreibbar">
|
||||
<input inputMode="decimal" className={inputClass} value={form.weekly_hours} onChange={(e) => set({ weekly_hours: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Stunden pro Arbeitstag" hint="Für Tagessätze und Budget in Tagen">
|
||||
<input inputMode="decimal" className={inputClass} value={form.hours_per_day} onChange={(e) => set({ hours_per_day: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Firmenname">
|
||||
<input className={inputClass} value={form.company_name} onChange={(e) => set({ company_name: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Budgetwarnungen" hint="Ab wann Pulse beim Buchen und in der Projektübersicht warnt. Gesperrt wird immer erst bei 100 % oder nach Ablauf.">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Warnen ab Verbrauch (%)">
|
||||
<input inputMode="numeric" className={inputClass} value={form.budget_warn_percent} onChange={(e) => set({ budget_warn_percent: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Warnen ab Tagen vor Ablauf">
|
||||
<input inputMode="numeric" className={inputClass} value={form.budget_warn_days} onChange={(e) => set({ budget_warn_days: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
<Section
|
||||
title="Projektbezeichnungen"
|
||||
hint="Muster für den automatisch vergebenen Projektcode und den Anzeigenamen"
|
||||
footer={
|
||||
<Button icon="check" busy={busy} onClick={submit}>
|
||||
Speichern
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Projektcode" hint={<>Platzhalter: {'{KUERZEL} {JAHR} {JJ} {NR}'} · Beispiel: <b className="text-ink-700">{example}</b></>}>
|
||||
<input className={`${inputClass} tabular`} value={form.project_code_pattern} onChange={(e) => set({ project_code_pattern: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Anzeigename" hint={<>Platzhalter: {'{KUNDE} {KUNDENNAME} {PROJEKT} {CODE}'} · Beispiel: <b className="text-ink-700">{exampleName}</b></>}>
|
||||
<input className={inputClass} value={form.project_name_pattern} onChange={(e) => set({ project_name_pattern: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<typeof ex>) => 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) => (
|
||||
<Field label={label} hint={hint}>
|
||||
<input className={inputClass} value={String(ex[key] ?? '')} onChange={(e) => set({ [key]: e.target.value })} />
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 2xl:grid-cols-[1fr_520px]">
|
||||
<Section
|
||||
title="Kopf und Fuß der Exporte"
|
||||
hint="Gilt für Stundennachweise und Reports in PDF und Excel"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" icon="document" onClick={() => preview('pdf')}>
|
||||
PDF-Vorschau
|
||||
</Button>
|
||||
<Button variant="secondary" icon="grid" onClick={() => preview('xlsx')}>
|
||||
Excel-Vorschau
|
||||
</Button>
|
||||
<Button icon="check" busy={busy} onClick={() => save({ export: ex }, () => onSaved({ ...settings, export: ex }))}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-ink-400">Die Vorschau zeigt den zuletzt gespeicherten Stand.</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Logo</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-40 items-center justify-center rounded-xl border border-ink-200 bg-white p-2">
|
||||
<img src={ex.logo || '/logo.png'} alt="Logo" className="max-h-full max-w-full object-contain" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block cursor-pointer text-xs font-bold text-brand-700 underline">
|
||||
Hochladen
|
||||
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={(e) => onLogo(e.target.files?.[0])} />
|
||||
</label>
|
||||
{ex.logo && (
|
||||
<button className="block text-xs font-bold text-ink-400 underline" onClick={() => set({ logo: null })}>
|
||||
perinco Logo verwenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Akzentfarbe">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" className="h-10 w-14 cursor-pointer rounded-lg border border-ink-200 bg-surface" value={ex.accent} onChange={(e) => set({ accent: e.target.value })} />
|
||||
<input className={`${inputClass} tabular`} value={ex.accent} onChange={(e) => set({ accent: e.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
{text('title', 'Titel', 'z. B. Stundennachweis, Leistungsnachweis')}
|
||||
{text('subtitle', 'Kopfzeile rechts (1. Zeile)')}
|
||||
{text('header_right', 'Kopfzeile rechts (2. Zeile)')}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label="Fuß links">
|
||||
<textarea className={`${inputClass} h-20`} value={ex.footer_left} onChange={(e) => set({ footer_left: e.target.value })} placeholder={'perinco GmbH\nStraße 1 · 12345 Ort'} />
|
||||
</Field>
|
||||
<Field label="Fuß Mitte">
|
||||
<textarea className={`${inputClass} h-20`} value={ex.footer_center} onChange={(e) => set({ footer_center: e.target.value })} placeholder={'Geschäftsführer …\nHRB …'} />
|
||||
</Field>
|
||||
<Field label="Fuß rechts">
|
||||
<textarea className={`${inputClass} h-20`} value={ex.footer_right} onChange={(e) => set({ footer_right: e.target.value })} placeholder={'www.perinco.de\nBank · IBAN'} />
|
||||
</Field>
|
||||
</div>
|
||||
<Toggle checked={ex.show_signature} onChange={(v) => set({ show_signature: v })} label="Unterschriftenfelder" hint="Am Ende des Stundennachweises – zur Abzeichnung durch den Kunden." />
|
||||
{ex.show_signature && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{text('signature_left', 'Unterschrift links')}
|
||||
{text('signature_right', 'Unterschrift rechts')}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Card className="animate-rise h-fit p-4">
|
||||
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Live-Skizze (A4)</p>
|
||||
<div className="mx-auto aspect-[210/297] w-full max-w-[420px] rounded-md bg-white p-[6%] text-[#1f1f1f] shadow-lift" style={{ fontFamily: 'Helvetica, Arial, sans-serif' }}>
|
||||
<div className="flex items-start justify-between">
|
||||
<img src={ex.logo || '/logo.png'} alt="" className="h-8 w-auto max-w-[35%] object-contain" />
|
||||
<div className="text-right text-[7px] leading-tight text-[#6e6e6e]">
|
||||
<div>{ex.subtitle}</div>
|
||||
<div>{ex.header_right}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-[1.5px]" style={{ background: ex.accent }} />
|
||||
<p className="mt-3 text-[13px] font-bold">{ex.title || 'Stundennachweis'}</p>
|
||||
<p className="text-[8px]" style={{ color: ex.accent }}>
|
||||
September 2026
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1">
|
||||
{['72,50', '72:30', '11', '34,00'].map((v) => (
|
||||
<div key={v} className="rounded-sm border-l-2 bg-[#f4f7f1] px-1 py-1 text-[7px] font-bold" style={{ borderColor: ex.accent }}>
|
||||
{v}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 h-2.5 rounded-[1px]" style={{ background: ex.accent }} />
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className={`h-2.5 border-b border-[#eee] ${i % 2 ? 'bg-[#f4f7f1]' : ''}`} />
|
||||
))}
|
||||
{ex.show_signature && (
|
||||
<div className="mt-6 grid grid-cols-2 gap-4 text-[6px] text-[#6e6e6e]">
|
||||
<div className="border-t border-[#9a9a9a] pt-0.5">{ex.signature_left}</div>
|
||||
<div className="border-t border-[#9a9a9a] pt-0.5">{ex.signature_right}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto" />
|
||||
<div className="mt-8 grid grid-cols-3 border-t border-[#ddd] pt-1 text-[6px] leading-tight text-[#6e6e6e]">
|
||||
<div className="whitespace-pre-line">{ex.footer_left}</div>
|
||||
<div className="whitespace-pre-line text-center">{ex.footer_center}</div>
|
||||
<div className="whitespace-pre-line text-right">
|
||||
{ex.footer_right}
|
||||
{'\n'}Seite 1 von 1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<typeof sso>) => 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) => (
|
||||
<Field label={label} hint={hint}>
|
||||
<input
|
||||
name={`pulse-sso-${key}`}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
className={`${inputClass} font-mono text-xs`}
|
||||
value={sso[key]}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => set({ [key]: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Section
|
||||
title="Microsoft Entra ID (M365)"
|
||||
hint="Anmeldung per OpenID Connect, Mitarbeiterliste über Microsoft Graph"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" icon="refresh" busy={testing} onClick={test} disabled={!settings.sso.configured}>
|
||||
Verbindung testen
|
||||
</Button>
|
||||
<Button icon="check" busy={busy} onClick={() => save({ sso: { ...sso, client_secret: sso.client_secret || undefined } }, onSaved)}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold ${settings.sso.enabled ? 'bg-brand-50 text-brand-800' : 'bg-ink-50 text-ink-600'}`}>
|
||||
<Icon name={settings.sso.enabled ? 'check' : 'info'} className="h-5 w-5" />
|
||||
{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.'}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{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')}
|
||||
<Field label="Client Secret (Wert)" hint={settings.sso.client_secret_set ? 'Hinterlegt (verschlüsselt). Leer lassen = unverändert.' : 'Der „Wert“, nicht die Secret-ID. Wird verschlüsselt gespeichert und nie wieder angezeigt.'}>
|
||||
{/* Kein type="password" - Passwortmanager (Brave) sollen hier nicht anspringen. */}
|
||||
<input
|
||||
name="pulse-sso-client-credential"
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
className={`${inputClass} font-mono text-xs`}
|
||||
style={{ WebkitTextSecurity: 'disc' } as React.CSSProperties}
|
||||
value={sso.client_secret}
|
||||
onChange={(e) => set({ client_secret: e.target.value })}
|
||||
placeholder={settings.sso.client_secret_set ? '••••••••••••' : ''}
|
||||
/>
|
||||
</Field>
|
||||
{text('redirect_uri', 'Redirect-URI', 'In der App-Registrierung als Web-Plattform eintragen')}
|
||||
{text('button_label', 'Beschriftung des Anmeldeknopfs')}
|
||||
</div>
|
||||
<div className="space-y-3 rounded-2xl border border-ink-200 p-4">
|
||||
<Toggle checked={sso.enabled} onChange={(v) => set({ enabled: v })} label="SSO aktivieren" hint="Zeigt den Microsoft-Anmeldeknopf. Anmelden können sich nur Konten, die in der SSO-Verwaltung übernommen wurden." />
|
||||
<Toggle
|
||||
checked={sso.local_login}
|
||||
onChange={(v) => 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."
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Mitarbeiter übernehmen">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="min-w-0 flex-1 text-sm text-ink-600">
|
||||
Angezeigt werden nur Konten im Format <code className="rounded bg-ink-100 px-1 text-xs">vorname.nachname@perinco.de</code> bzw.{' '}
|
||||
<code className="rounded bg-ink-100 px-1 text-xs">@icon-experts.de</code> – ohne Admin-Konten, freigegebene Postfächer und Gäste. Übernehmen und Rollen vergeben per Drag & Drop, auch für Manager.
|
||||
</p>
|
||||
<Button icon="globe" onClick={() => navigate('einstellungen/sso-verwaltung')}>
|
||||
Zur SSO-Verwaltung
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Einrichtung in Entra ID">
|
||||
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-ink-600">
|
||||
<li>entra.microsoft.com → App-Registrierungen → Neue Registrierung, Name „Pulse“, Kontotyp „Nur Konten in diesem Organisationsverzeichnis“.</li>
|
||||
<li>
|
||||
Umleitungs-URI: Plattform „Web“, <code className="rounded bg-ink-100 px-1 text-xs">{sso.redirect_uri}</code>
|
||||
</li>
|
||||
<li>Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel → den <b>Wert</b> hier eintragen (Ablaufdatum notieren, max. 24 Monate).</li>
|
||||
<li>API-Berechtigungen → Microsoft Graph: delegiert <code className="rounded bg-ink-100 px-1 text-xs">openid profile email</code>, Anwendung <code className="rounded bg-ink-100 px-1 text-xs">User.Read.All</code> → „Administratorzustimmung erteilen“.</li>
|
||||
<li>Tenant- und Client-ID aus der Übersicht kopieren, speichern, „Verbindung testen“, Mitarbeiter in der SSO-Verwaltung übernehmen, dann SSO aktivieren.</li>
|
||||
</ol>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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