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
+78
View File
@@ -0,0 +1,78 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
type ToastKind = 'success' | 'error' | 'info';
interface Toast {
id: number;
message: string;
kind: ToastKind;
}
interface ToastContextValue {
addToast: (message: string, kind?: ToastKind) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
let nextId = 1;
const STYLES: Record<ToastKind, string> = {
success: 'border-brand-200 bg-brand-50 text-brand-800',
error: 'border-red-200 bg-red-50 text-red-700',
info: 'border-ink-200 bg-surface text-ink-800',
};
const ICONS: Record<ToastKind, string> = {
success: 'M5 13l4 4L19 7',
error: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z',
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, kind: ToastKind = 'info') => {
const id = nextId++;
setToasts((prev) => [...prev, { id, message, kind }]);
window.setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4500);
}, []);
const value = useMemo(() => ({ addToast }), [addToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed bottom-24 left-4 right-4 z-[200] flex max-w-sm sm:bottom-6 sm:left-auto sm:right-6 sm:w-full flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto flex items-start gap-3 rounded-xl border px-4 py-3 shadow-lift ${STYLES[toast.kind]}`}
role={toast.kind === 'error' ? 'alert' : 'status'}
>
<svg className="mt-0.5 h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={ICONS[toast.kind]} />
</svg>
<p className="text-sm font-semibold leading-snug">{toast.message}</p>
<button
onClick={() => setToasts((prev) => prev.filter((t) => t.id !== toast.id))}
className="ml-auto shrink-0 opacity-50 transition hover:opacity-100"
aria-label="Meldung schließen"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast muss innerhalb von <ToastProvider> verwendet werden.');
return ctx;
}
+305
View File
@@ -0,0 +1,305 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Avatar, Button, Card, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { Donut } from '../ui/charts';
import { EntryModal } from './EntryModal';
import { ExportDialog } from './ExportDialog';
import { WeekView } from './WeekView';
import { MonthView } from './MonthView';
import { YearView } from './YearView';
import { useAuth } from '../../lib/auth';
import { entriesApi, projectsApi, usersApi } from '../../lib/api';
import { useIsMobile, useLoader, usePersistentState, useCountUp } from '../../lib/hooks';
import {
MONTHS,
addDays,
addMonths,
formatDate,
formatDuration,
isoWeek,
mondayOf,
monthEnd,
monthStart,
todayIso,
weekdayIndex,
} from '../../lib/format';
import { visibleTeam } from '../team/hierarchy';
import { atLeast, type DirectoryUser, type Project, type TimeEntry } from '../../types';
export type BookingMode = 'week' | 'month' | 'year';
function rangeFor(mode: BookingMode, anchor: string) {
if (mode === 'week') {
const from = mondayOf(anchor);
return { from, to: addDays(from, 6) };
}
if (mode === 'month') return { from: monthStart(anchor), to: monthEnd(anchor) };
return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31` };
}
/** Arbeitstage Mo-Fr im Intervall, fuer das Soll. */
function workdays(from: string, to: string) {
let count = 0;
for (let d = from; d <= to; d = addDays(d, 1)) if (weekdayIndex(d) < 5) count++;
return count;
}
export const Booking: React.FC = () => {
const { user, settings } = useAuth();
const isMobile = useIsMobile();
const [mode, setMode] = usePersistentState<BookingMode>('pulse_booking_mode', 'week');
const [anchor, setAnchor] = useState(todayIso());
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null);
const [exportOpen, setExportOpen] = useState(false);
const [viewUserId, setViewUserId] = useState<number>(user!.id);
const isLead = atLeast(user, 'lead');
const readOnly = viewUserId !== user!.id;
const range = rangeFor(mode, anchor);
const entries = useLoader(() => entriesApi.list(range.from, range.to, viewUserId), [range.from, range.to, viewUserId]);
const projects = useLoader(() => projectsApi.list('mine'), []);
const [team, setTeam] = useState<DirectoryUser[]>([]);
const [allProjects, setAllProjects] = useState<Project[] | null>(null);
useEffect(() => {
if (!isLead) return;
usersApi
.directory()
.then((list) => setTeam(visibleTeam(list, user!)))
.catch(() => undefined);
}, [isLead, user]);
const list = entries.data ?? [];
const total = list.reduce((s, e) => s + e.minutes, 0);
const billable = list.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
const viewedUser = team.find((t) => t.id === viewUserId);
const weekly = (readOnly ? null : user!.weekly_hours) ?? settings.weekly_hours;
const today = todayIso();
// Soll bis heute (bzw. ganzer Zeitraum, wenn er vorbei ist).
const targetTo = range.to < today ? range.to : today < range.from ? range.from : today;
const target = today < range.from ? 0 : (weekly / 5) * workdays(range.from, targetTo) * 60;
const shift = (step: number) => {
if (mode === 'week') setAnchor(addDays(anchor, 7 * step));
else if (mode === 'month') setAnchor(addMonths(anchor, step));
else setAnchor(`${Number(anchor.slice(0, 4)) + step}-${anchor.slice(5)}`);
};
const label =
mode === 'week'
? `KW ${isoWeek(range.from)} · ${formatDate(range.from).slice(0, 6)} – ${formatDate(range.to)}`
: mode === 'month'
? `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}`
: anchor.slice(0, 4);
const openNew = (date?: string) => {
if (readOnly) return;
setModal({ date: date ?? (today >= range.from && today <= range.to ? today : range.from) });
};
const openExport = () => {
if (isLead && !allProjects) projectsApi.list().then(setAllProjects).catch(() => undefined);
setExportOpen(true);
};
// Tastenkuerzel: N = neue Buchung, Pfeile = blaettern.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (modal || exportOpen) return;
const target = e.target as HTMLElement;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
if (e.key === 'n' || e.key === '+') {
e.preventDefault();
openNew();
}
if (e.key === 'ArrowLeft') shift(-1);
if (e.key === 'ArrowRight') shift(1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
const warnings = useMemo(
() => (projects.data ?? []).flatMap((p) => p.pots.filter((pot) => pot.warning && pot.status !== 'expired' && pot.valid_to >= today).map((pot) => ({ project: p, pot }))),
[projects.data, today]
);
return (
<div className="mx-auto max-w-[1500px]">
{/* Werkzeugleiste */}
<div className="mb-4 flex flex-wrap items-center gap-2 sm:gap-3">
<div className="flex items-center rounded-xl border border-ink-200 bg-surface shadow-card">
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Zurück">
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
</button>
<button onClick={() => setAnchor(todayIso())} className="border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 hover:bg-ink-50">
Heute
</button>
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Weiter">
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
</button>
</div>
<h2 className="tabular min-w-0 flex-1 truncate text-base text-ink-900 sm:flex-none sm:text-lg">{label}</h2>
<div className="order-last flex w-full items-center gap-2 sm:order-none sm:ml-auto sm:w-auto">
<Segmented
value={mode}
onChange={setMode}
className="flex-1 sm:flex-none"
options={[
{ value: 'week', label: 'Woche' },
{ value: 'month', label: 'Monat' },
{ value: 'year', label: 'Jahr' },
]}
/>
{isLead && team.length > 1 && (
<select className={`${inputClass} w-auto max-w-[180px] py-2 text-xs font-bold`} value={viewUserId} onChange={(e) => setViewUserId(Number(e.target.value))} title="Zeiten ansehen von">
{team.map((t) => (
<option key={t.id} value={t.id}>
{t.id === user!.id ? 'Meine Zeiten' : t.name}
</option>
))}
</select>
)}
<Button variant="secondary" icon="download" onClick={openExport} className="py-2">
<span className="hidden sm:inline">Export</span>
</Button>
{!isMobile && !readOnly && (
<button
onClick={() => openNew()}
title="Zeit buchen (N)"
aria-label="Zeit buchen"
className="animate-glow flex h-11 w-11 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition hover:scale-105 hover:bg-brand-600 active:scale-95"
>
<Icon name="plus" className="h-6 w-6" strokeWidth={2.6} />
</button>
)}
</div>
</div>
{readOnly && viewedUser && (
<div className="animate-fade mb-4 flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm font-semibold text-sky-700">
<Avatar name={viewedUser.name} color={viewedUser.color} id={viewedUser.id} size={28} />
Du siehst die Zeiten von {viewedUser.name} (nur lesen).
<button className="ml-auto text-xs font-bold underline" onClick={() => setViewUserId(user!.id)}>
Zurück zu meinen Zeiten
</button>
</div>
)}
{!readOnly && warnings.length > 0 && (
<div className="animate-fade mb-4 space-y-2">
{warnings.slice(0, 3).map(({ project, pot }) => (
<div key={pot.id} className="flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs font-semibold text-amber-700">
<Icon name="warning" className="mt-0.5 h-4 w-4 shrink-0" />
<span>
<b>{project.display_name}</b> – {pot.warning}
{pot.remaining_hours != null && ` Noch ${pot.remaining_hours.toLocaleString('de-DE')} h frei.`}
</span>
</div>
))}
</div>
)}
<SummaryStrip total={total} billable={billable} target={target} weekly={weekly} mode={mode} count={list.length} />
{entries.error && <ErrorBox message={entries.error} onRetry={entries.reload} />}
{entries.loading && !entries.data ? (
<Spinner />
) : (
<div key={`${mode}-${range.from}`} className="animate-fade">
{mode === 'week' && (
<WeekView from={range.from} entries={list} readOnly={readOnly} dailyTarget={(weekly / 5) * 60} onAdd={openNew} onEdit={(entry) => !readOnly && setModal({ date: entry.entry_date, entry })} />
)}
{mode === 'month' && (
<MonthView
month={range.from}
entries={list}
dailyTarget={(weekly / 5) * 60}
onOpenDay={(date) => {
setAnchor(date);
setMode('week');
}}
onAdd={openNew}
readOnly={readOnly}
/>
)}
{mode === 'year' && (
<YearView
year={anchor.slice(0, 4)}
entries={list}
weeklyTarget={weekly}
onOpenMonth={(month) => {
setAnchor(`${month}-01`);
setMode('month');
}}
/>
)}
</div>
)}
{isMobile && !readOnly && (
<button
onClick={() => openNew()}
aria-label="Zeit buchen"
className="animate-glow fixed bottom-24 right-5 z-40 flex h-16 w-16 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition active:scale-95"
>
<Icon name="plus" className="h-8 w-8" strokeWidth={2.6} />
</button>
)}
{modal && projects.data && (
<EntryModal projects={projects.data} date={modal.date} entry={modal.entry} onClose={() => setModal(null)} onSaved={() => { entries.reload(); projects.reload(); }} />
)}
{exportOpen && (
<ExportDialog
month={anchor.slice(0, 7)}
projects={isLead ? allProjects ?? projects.data ?? [] : projects.data ?? []}
team={team}
viewUserId={viewUserId}
onClose={() => setExportOpen(false)}
/>
)}
</div>
);
};
const SummaryStrip: React.FC<{ total: number; billable: number; target: number; weekly: number; mode: BookingMode; count: number }> = ({
total,
billable,
target,
weekly,
mode,
count,
}) => {
const shown = useCountUp(total);
const percent = target > 0 ? Math.round((total / target) * 100) : 0;
const diff = total - target;
return (
<Card className="animate-rise mb-4 flex flex-wrap items-center gap-4 p-4 sm:gap-6 sm:p-5">
<Donut percent={Math.min(100, percent)} size={isFinite(percent) ? 76 : 76} stroke={9} track="var(--color-ink-100)" label="Soll" />
<div className="min-w-[120px]">
<p className="text-[11px] font-extrabold uppercase tracking-[0.14em] text-ink-400">
Gebucht {mode === 'week' ? 'diese Woche' : mode === 'month' ? 'im Monat' : 'im Jahr'}
</p>
<p className="tabular text-3xl font-extrabold text-ink-900">
{formatDuration(shown)}
<span className="ml-1 text-base text-ink-400">h</span>
</p>
</div>
<div className="grid w-full grid-cols-3 gap-2 sm:w-auto sm:max-w-xl sm:flex-1 sm:gap-3">
<Mini label="Soll bis heute" value={`${formatDuration(target)} h`} hint={`${weekly} h/Woche`} />
<Mini label="Differenz" value={`${diff >= 0 ? '+' : '−'}${formatDuration(Math.abs(diff))} h`} tone={diff >= 0 ? 'good' : 'bad'} />
<Mini label="Billable" value={total ? `${Math.round((billable / total) * 100)} %` : '–'} hint={`${count} Buchungen`} />
</div>
</Card>
);
};
const Mini: React.FC<{ label: string; value: string; hint?: string; tone?: 'good' | 'bad' }> = ({ label, value, hint, tone }) => (
<div className="min-w-0 rounded-xl bg-ink-50 px-3 py-2">
<p className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-400">{label}</p>
<p className={`tabular truncate text-sm font-extrabold sm:text-base ${tone === 'good' ? 'text-brand-700' : tone === 'bad' ? 'text-red-600' : 'text-ink-900'}`}>{value}</p>
{hint && <p className="truncate text-[10px] text-ink-400">{hint}</p>}
</div>
);
+319
View File
@@ -0,0 +1,319 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Modal, ConfirmDialog } from '../ui/Modal';
import { BudgetBar, Button, Field, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { useAuth } from '../../lib/auth';
import { entriesApi } from '../../lib/api';
import { formatDate, formatDuration, parseDuration } from '../../lib/format';
import { useToast } from '../Toast';
import { checkPot } from './budget';
import { PROJECT_TYPE_LABELS, type Project, type TimeEntry } from '../../types';
interface Props {
projects: Project[];
date: string;
entry?: TimeEntry | null;
onClose: () => void;
onSaved: () => void;
}
/** Reihenfolge wie in der Auswahlliste: nach Kunde gruppiert, innerhalb in API-Reihenfolge. */
function inListOrder(projects: Project[]) {
const map = new Map<string, Project[]>();
for (const p of projects) map.set(p.customer.name, [...(map.get(p.customer.name) || []), p]);
return [...map.values()].flat();
}
/**
* Vorauswahl fuer neue Buchungen: das erste billable Projekt der Liste -
* bevorzugt eines, auf das am Datum auch gebucht werden kann (Topf-Laufzeit,
* Budget). Gibt es kein billable Projekt, bleibt nur ein einziges Projekt
* vorausgewaehlt.
*/
function defaultProject(projects: Project[], date: string, settings: { hours_per_day: number; budget_warn_percent: number; budget_warn_days: number }): number | '' {
const ordered = inListOrder(projects);
const billable = ordered.filter((p) => p.type === 'billable');
const bookableNow = billable.find(
(p) => checkPot(p, date, 0, settings.hours_per_day, settings.budget_warn_percent, settings.budget_warn_days).ok
);
if (bookableNow) return bookableNow.id;
if (billable.length) return billable[0].id;
return ordered.length === 1 ? ordered[0].id : '';
}
/** Erster aktiver Task in Listenreihenfolge; ohne Tasks bleibt "Ohne Task". */
function firstTask(project: Project | undefined): number | '' {
return project?.tasks.find((t) => t.is_active)?.id ?? '';
}
/**
* Buchungsdialog: oben Projekt, darunter Task, darunter Freitext mit der Zeit
* rechts daneben. Die Zeit versteht "1:23" und "1,5".
*/
export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry, onClose, onSaved }) => {
const { settings } = useAuth();
const { addToast } = useToast();
const bookable = useMemo(() => projects.filter((p) => p.is_active || p.id === entry?.project_id), [projects, entry]);
const [projectId, setProjectId] = useState<number | ''>(() => entry?.project_id ?? defaultProject(bookable, initialDate, settings));
// Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts.
const [taskId, setTaskId] = useState<number | ''>(() =>
entry ? entry.task_id ?? '' : firstTask(bookable.find((p) => p.id === projectId))
);
const [note, setNote] = useState(entry?.note ?? '');
const [date, setDate] = useState(entry?.entry_date ?? initialDate);
const [timeText, setTimeText] = useState(entry ? formatDuration(entry.minutes) : '0:00');
const [busy, setBusy] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const timeRef = useRef<HTMLInputElement>(null);
const projectRef = useRef<HTMLSelectElement>(null);
/*
* Schnell buchen: ist ein Projekt vorausgewaehlt, liegt der Fokus sofort auf
* der Zeit (Inhalt markiert, also direkt ueberschreiben). Sonst zuerst das
* Projekt waehlen. Kurz verzoegert, damit die Dialog-Animation nicht stoert.
*/
useEffect(() => {
const timer = setTimeout(() => {
if (projectId) {
timeRef.current?.focus();
timeRef.current?.select();
} else {
projectRef.current?.focus();
}
}, 60);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const project = bookable.find((p) => p.id === projectId) ?? null;
const tasks = project?.tasks.filter((t) => t.is_active || t.id === entry?.task_id) ?? [];
const minutes = parseDuration(timeText);
const validTime = minutes != null && minutes > 0 && minutes <= 24 * 60;
// Projekt gewechselt: passt der Task nicht mehr, den ersten Task des neuen Projekts nehmen.
useEffect(() => {
if (taskId && tasks.some((t) => t.id === taskId)) return;
if (!taskId && entry && entry.project_id === projectId) return; // bewusst ohne Task gebucht
setTaskId(firstTask(project ?? undefined));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
const task = tasks.find((t) => t.id === taskId);
const billable = project ? project.type !== 'non_billable' && (task ? task.billable : true) : true;
const check = project
? checkPot(
project,
date,
validTime ? minutes! : 0,
settings.hours_per_day,
settings.budget_warn_percent,
settings.budget_warn_days,
entry ? { potId: entry.pot_id, minutes: project.type === 'billable' && !entry.billable ? 0 : entry.minutes } : null,
billable
)
: null;
// Gruppiert nach Kunde fuer die Auswahl.
const groups = useMemo(() => {
const map = new Map<string, Project[]>();
for (const p of bookable) {
const key = p.customer.name;
map.set(key, [...(map.get(key) || []), p]);
}
return [...map.entries()];
}, [bookable]);
const save = async () => {
if (!project) return addToast('Bitte ein Projekt wählen.', 'error');
if (!validTime) {
timeRef.current?.focus();
return addToast('Bitte eine Zeit eingeben, z. B. 1:30 oder 1,5.', 'error');
}
setBusy(true);
try {
const payload = { project_id: project.id, task_id: taskId || null, entry_date: date, minutes: minutes!, note };
const result = entry ? await entriesApi.update(entry.id, payload) : await entriesApi.create(payload);
addToast(`${formatDuration(minutes!)} h auf ${project.display_name} ${entry ? 'aktualisiert' : 'gebucht'}.`, 'success');
if (result.warning) addToast(result.warning, 'info');
onSaved();
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
const remove = async () => {
if (!entry) return;
setBusy(true);
try {
await entriesApi.remove(entry.id);
addToast('Buchung gelöscht.', 'success');
onSaved();
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
setBusy(false);
}
};
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
save();
}
};
const pot = check?.pot;
return (
<>
<Modal
title={entry ? 'Buchung bearbeiten' : 'Zeit buchen'}
subtitle={formatDate(date, 'long')}
onClose={onClose}
size="md"
footer={
<div className="flex items-center justify-between gap-3">
{entry ? (
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)}>
Löschen
</Button>
) : (
<span className="hidden text-[11px] text-ink-400 sm:block">Enter im Zeitfeld bucht · sonst Strg + Enter</span>
)}
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="check" busy={busy} onClick={save} disabled={!project || !validTime || check?.ok === false}>
{entry ? 'Speichern' : 'Buchen'}
</Button>
</div>
</div>
}
>
{bookable.length === 0 ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm font-semibold text-amber-700">
Dir ist noch kein Projekt zugewiesen. Bitte deinen Vorgesetzten, dich einem Projekt zuzuordnen.
</div>
) : (
<div className="space-y-4" onKeyDown={onKeyDown}>
<Field label="Projekt" required>
<select className={inputClass} value={projectId} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : '')} ref={projectRef}>
<option value="">Projekt wählen…</option>
{groups.map(([customer, list]) => (
<optgroup key={customer} label={customer}>
{list.map((p) => (
<option key={p.id} value={p.id}>
{p.display_name} · {p.code}
</option>
))}
</optgroup>
))}
</select>
</Field>
<Field label="Task">
<select className={inputClass} value={taskId} onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : '')} disabled={!project || tasks.length === 0}>
<option value="">{project && tasks.length === 0 ? 'Keine Tasks angelegt' : 'Ohne Task'}</option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
{project?.type !== 'non_billable' && !t.billable ? ' (nicht abrechenbar)' : ''}
</option>
))}
</select>
</Field>
<div className="grid grid-cols-[1fr_7.5rem] gap-3 sm:grid-cols-[1fr_9rem]">
<Field label="Beschreibung (optional)">
<textarea
className={`${inputClass} h-[92px] resize-none`}
value={note}
maxLength={1000}
placeholder="Was wurde gemacht?"
onChange={(e) => setNote(e.target.value)}
/>
</Field>
<Field label="Zeit" required>
<input
ref={timeRef}
inputMode="decimal"
className={`${inputClass} tabular h-[92px] !px-2 text-center !text-3xl font-extrabold ${timeText && !validTime && timeText !== '0:00' ? 'border-red-300 text-red-600' : ''}`}
value={timeText}
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
// Im Zeitfeld reicht Enter zum Buchen.
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
save();
}
}}
onChange={(e) => setTimeText(e.target.value)}
onBlur={() => validTime && setTimeText(formatDuration(minutes!))}
aria-describedby="time-hint"
/>
</Field>
</div>
<p id="time-hint" className="-mt-2 text-right text-[11px] text-ink-400">
{validTime ? (
<span className="font-bold text-brand-700">
= {Math.floor(minutes! / 60)} Std. {minutes! % 60} Min. · {(minutes! / 60).toLocaleString('de-DE', { maximumFractionDigits: 2 })} h
</span>
) : (
'Format 1:30 oder 1,5'
)}
</p>
<Field label="Datum">
<input type="date" className={inputClass} value={date} onChange={(e) => e.target.value && setDate(e.target.value)} />
</Field>
{project && (
<div
className={`animate-fade rounded-2xl border p-4 ${
check?.level === 'error' ? 'border-red-200 bg-red-50' : check?.level === 'warn' ? 'border-amber-200 bg-amber-50' : 'border-ink-200 bg-ink-50/60'
}`}
>
<div className="flex items-start gap-3">
<Icon
name={check?.level === 'error' ? 'warning' : check?.level === 'warn' ? 'warning' : 'pot'}
className={`mt-0.5 h-5 w-5 shrink-0 ${check?.level === 'error' ? 'text-red-600' : check?.level === 'warn' ? 'text-amber-600' : 'text-brand-600'}`}
/>
<div className="min-w-0 flex-1">
<p className="text-xs font-extrabold uppercase tracking-wider text-ink-500">
{PROJECT_TYPE_LABELS[project.type]} · {project.code}
</p>
<p className={`mt-1 text-sm font-semibold ${check?.level === 'error' ? 'text-red-700' : check?.level === 'warn' ? 'text-amber-700' : 'text-ink-700'}`}>
{check?.message ?? 'Freies Projekt ohne Budgettopf.'}
</p>
{pot && pot.budget_hours != null && (
<div className="mt-3">
<BudgetBar percent={pot.percent_used} warnAt={settings.budget_warn_percent} />
<p className="tabular mt-1 flex justify-between text-[11px] font-bold text-ink-500">
<span>{formatDuration(pot.used_minutes)} h gebucht</span>
<span>{pot.budget_hours.toLocaleString('de-DE')} h Budget</span>
</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
)}
</Modal>
{confirmDelete && (
<ConfirmDialog
title="Buchung löschen?"
message={`${formatDuration(entry!.minutes)} h auf ${entry!.project_display} am ${formatDate(entry!.entry_date)}.`}
onConfirm={remove}
onCancel={() => setConfirmDelete(false)}
/>
)}
</>
);
};
+170
View File
@@ -0,0 +1,170 @@
import React, { useMemo, useState } from 'react';
import { Modal } from '../ui/Modal';
import { Button, Field, inputClass, Toggle } from '../ui/controls';
import { Icon, type IconName } from '../ui/Icon';
import { useAuth } from '../../lib/auth';
import { download, qs } from '../../lib/api';
import { MONTHS } from '../../lib/format';
import { useToast } from '../Toast';
import { atLeast, type DirectoryUser, type Project } from '../../types';
const FORMATS: { value: 'pdf' | 'xlsx' | 'csv'; label: string; hint: string; icon: IconName }[] = [
{ value: 'pdf', label: 'PDF', hint: 'Stundenzettel mit Logo, Kopf und Fuß', icon: 'document' },
{ value: 'xlsx', label: 'Excel', hint: 'Zum Weiterrechnen, mit Summenformeln', icon: 'grid' },
{ value: 'csv', label: 'CSV', hint: 'Rohdaten, Semikolon, UTF-8', icon: 'list' },
];
/** Monatsexport des Stundennachweises - Vorlage fuer Kunden und Rechnung. */
export const ExportDialog: React.FC<{
month: string;
projects: Project[];
team?: DirectoryUser[];
viewUserId?: number;
onClose: () => void;
}> = ({ month: initialMonth, projects, team = [], viewUserId, onClose }) => {
const { user } = useAuth();
const { addToast } = useToast();
const [month, setMonth] = useState(initialMonth);
const [format, setFormat] = useState<'pdf' | 'xlsx' | 'csv'>('pdf');
const [customerId, setCustomerId] = useState<number | ''>('');
const [projectId, setProjectId] = useState<number | ''>('');
const [userId, setUserId] = useState<string>(String(viewUserId ?? user?.id ?? ''));
const [amounts, setAmounts] = useState(false);
const [busy, setBusy] = useState(false);
const isLead = atLeast(user, 'lead');
const customers = useMemo(() => {
const map = new Map<number, string>();
projects.forEach((p) => map.set(p.customer.id, p.customer.name));
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'de'));
}, [projects]);
const projectOptions = projects.filter((p) => !customerId || p.customer_id === customerId);
// Monate: 18 zurueck, 2 voraus.
const months = useMemo(() => {
const list: string[] = [];
const now = new Date();
for (let i = -2; i < 18; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
list.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
}
if (!list.includes(initialMonth)) list.push(initialMonth);
return list;
}, [initialMonth]);
const run = async () => {
setBusy(true);
try {
await download(
`/exports/timesheet${qs({
month,
format,
user_id: userId,
customer_id: customerId || undefined,
project_id: projectId || undefined,
amounts: amounts ? 1 : undefined,
})}`,
`Stundennachweis-${month}.${format}`
);
addToast('Export erstellt.', 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Export fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
return (
<Modal
title="Stundennachweis exportieren"
subtitle="Monatliche Vorlage für Kunden und Rechnungsstellung"
onClose={onClose}
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Schließen
</Button>
<Button icon="download" busy={busy} onClick={run}>
Exportieren
</Button>
</div>
}
>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-2">
{FORMATS.map((f) => (
<button
key={f.value}
onClick={() => setFormat(f.value)}
className={`flex flex-col items-center gap-1 rounded-2xl border p-3 text-center transition ${
format === f.value ? 'border-brand-400 bg-brand-50 text-brand-700 shadow-card' : 'border-ink-200 text-ink-600 hover:border-brand-200'
}`}
>
<Icon name={f.icon} className="h-6 w-6" />
<span className="text-sm font-extrabold">{f.label}</span>
<span className="hidden text-[10px] leading-tight text-ink-400 sm:block">{f.hint}</span>
</button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Monat">
<select className={inputClass} value={month} onChange={(e) => setMonth(e.target.value)}>
{months.map((m) => (
<option key={m} value={m}>
{MONTHS[Number(m.slice(5)) - 1]} {m.slice(0, 4)}
</option>
))}
</select>
</Field>
{isLead && (
<Field label="Mitarbeiter">
<select className={inputClass} value={userId} onChange={(e) => setUserId(e.target.value)}>
<option value={String(user!.id)}>Ich ({user!.name})</option>
{team
.filter((t) => t.id !== user!.id)
.map((t) => (
<option key={t.id} value={String(t.id)}>
{t.name}
{t.is_external ? ' (extern)' : ''}
</option>
))}
<option value="all">Alle meine Mitarbeiter</option>
</select>
</Field>
)}
<Field label="Kunde">
<select
className={inputClass}
value={customerId}
onChange={(e) => {
setCustomerId(e.target.value ? Number(e.target.value) : '');
setProjectId('');
}}
>
<option value="">Alle Kunden</option>
{customers.map(([id, name]) => (
<option key={id} value={id}>
{name}
</option>
))}
</select>
</Field>
<Field label="Projekt">
<select className={inputClass} value={projectId} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : '')}>
<option value="">Alle Projekte</option>
{projectOptions.map((p) => (
<option key={p.id} value={p.id}>
{p.display_name}
</option>
))}
</select>
</Field>
</div>
{isLead && (
<Toggle checked={amounts} onChange={setAmounts} label="Stundensätze und Beträge ausweisen" hint="Für die Rechnungsstellung. Im Kundennachweis meist ausgeschaltet." />
)}
</div>
</Modal>
);
};
+66
View File
@@ -0,0 +1,66 @@
import React from 'react';
import { WEEKDAYS_SHORT, addDays, colorFor, formatDuration, fromIso, mondayOf, monthEnd, todayIso } from '../../lib/format';
import { useIsMobile } from '../../lib/hooks';
import type { TimeEntry } from '../../types';
/** Monatskalender: Stunden je Tag als Fuellstand, Projekte als farbige Punkte. */
export const MonthView: React.FC<{
month: string;
entries: TimeEntry[];
dailyTarget: number;
onOpenDay: (date: string) => void;
onAdd: (date: string) => void;
readOnly: boolean;
}> = ({ month, entries, dailyTarget, onOpenDay }) => {
const isMobile = useIsMobile();
const today = todayIso();
const start = mondayOf(month);
const end = monthEnd(month);
const weeks = Math.ceil((((fromIso(end).getTime() - fromIso(start).getTime()) / 86400000) + 1) / 7);
const cells = Array.from({ length: weeks * 7 }, (_, i) => addDays(start, i));
return (
<div className="rounded-2xl border border-ink-200 bg-surface p-2 shadow-card sm:p-3">
<div className="mb-1 grid grid-cols-7 gap-1 sm:gap-2">
{WEEKDAYS_SHORT.map((d) => (
<p key={d} className="py-1 text-center text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
{d}
</p>
))}
</div>
<div className="grid grid-cols-7 gap-1 sm:gap-2">
{cells.map((date, i) => {
const inMonth = date.slice(0, 7) === month.slice(0, 7);
const list = entries.filter((e) => e.entry_date === date);
const minutes = list.reduce((s, e) => s + e.minutes, 0);
const fill = dailyTarget ? Math.min(100, (minutes / dailyTarget) * 100) : 0;
const projects = [...new Map(list.map((e) => [e.project_id, e])).values()];
const weekend = i % 7 >= 5;
return (
<button
key={date}
onClick={() => onOpenDay(date)}
title={list.map((e) => `${formatDuration(e.minutes)} ${e.project_display}`).join('\n') || 'Keine Buchungen'}
className={`animate-rise group relative flex min-h-[64px] flex-col overflow-hidden rounded-xl border p-1.5 text-left transition hover:border-brand-300 hover:shadow-card sm:min-h-[104px] sm:p-2.5 ${
!inMonth ? 'border-transparent opacity-35' : date === today ? 'border-brand-400' : weekend ? 'border-ink-100 bg-ink-50/60' : 'border-ink-200'
}`}
style={{ '--i': Math.floor(i / 7) } as React.CSSProperties}
>
{/* Fuellstand von unten */}
<span aria-hidden className="absolute inset-x-0 bottom-0 bg-brand-100 transition-[height] duration-700" style={{ height: `${fill}%`, opacity: 0.6 }} />
<span className={`relative text-xs font-extrabold sm:text-sm ${date === today ? 'text-brand-700' : 'text-ink-700'}`}>{fromIso(date).getDate()}</span>
{minutes > 0 && <span className="tabular relative mt-auto text-[11px] font-extrabold text-ink-900 sm:text-base">{formatDuration(minutes)}</span>}
{!isMobile && projects.length > 0 && (
<span className="relative mt-1 flex flex-wrap gap-1">
{projects.slice(0, 5).map((e) => (
<span key={e.project_id} className="h-1.5 w-4 rounded-full" style={{ background: colorFor(e.customer_id, e.customer_color) }} />
))}
</span>
)}
</button>
);
})}
</div>
</div>
);
};
+164
View File
@@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react';
import { Icon } from '../ui/Icon';
import { useIsMobile, useGrow } from '../../lib/hooks';
import { WEEKDAYS_LONG, WEEKDAYS_SHORT, addDays, colorFor, formatDuration, fromIso, todayIso } from '../../lib/format';
import type { TimeEntry } from '../../types';
interface Props {
from: string;
entries: TimeEntry[];
readOnly: boolean;
dailyTarget: number;
onAdd: (date: string) => void;
onEdit: (entry: TimeEntry) => void;
}
/**
* Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung
* als Karte mit Kundenfarbe. Auf dem Handy ein Tagesstreifen mit Liste.
*/
export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget, onAdd, onEdit }) => {
const isMobile = useIsMobile();
const today = todayIso();
const days = Array.from({ length: 7 }, (_, i) => addDays(from, i));
const byDay = (date: string) => entries.filter((e) => e.entry_date === date);
const [selected, setSelected] = useState(() => (today >= from && today <= days[6] ? today : from));
useEffect(() => {
setSelected(today >= from && today <= addDays(from, 6) ? today : from);
}, [from, today]);
if (isMobile) {
const list = byDay(selected);
const total = list.reduce((s, e) => s + e.minutes, 0);
return (
<div>
<div className="mb-3 grid grid-cols-7 gap-1">
{days.map((date, i) => {
const minutes = byDay(date).reduce((s, e) => s + e.minutes, 0);
const active = date === selected;
return (
<button
key={date}
onClick={() => setSelected(date)}
className={`flex flex-col items-center rounded-2xl border py-2 transition ${
active ? 'border-brand-400 bg-brand-500 text-white shadow-card' : date === today ? 'border-brand-200 bg-brand-50 text-brand-700' : 'border-ink-200 bg-surface text-ink-600'
}`}
>
<span className="text-[10px] font-bold uppercase">{WEEKDAYS_SHORT[i]}</span>
<span className="text-lg font-extrabold leading-tight">{fromIso(date).getDate()}</span>
<span className={`tabular text-[10px] font-bold ${active ? 'text-white/85' : minutes ? 'text-ink-500' : 'text-ink-300'}`}>{minutes ? formatDuration(minutes) : '–'}</span>
</button>
);
})}
</div>
<div className="mb-2 flex items-baseline justify-between px-1">
<p className="text-sm font-extrabold text-ink-900">{WEEKDAYS_LONG[days.indexOf(selected)]}</p>
<p className="tabular text-sm font-extrabold text-ink-600">{formatDuration(total)} h</p>
</div>
<div key={selected} className="space-y-2">
{list.map((entry, i) => (
<EntryCard key={entry.id} entry={entry} index={i} onClick={() => onEdit(entry)} readOnly={readOnly} large />
))}
{list.length === 0 && (
<button
disabled={readOnly}
onClick={() => onAdd(selected)}
className="flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-ink-200 py-10 text-sm font-bold text-ink-400"
>
<Icon name="plus" className="h-6 w-6" />
{readOnly ? 'Keine Buchungen' : 'Zeit für diesen Tag buchen'}
</button>
)}
</div>
</div>
);
}
return (
<div className="grid grid-cols-7 gap-2 xl:gap-3">
{days.map((date, i) => {
const list = byDay(date);
const minutes = list.reduce((s, e) => s + e.minutes, 0);
const weekend = i >= 5;
const isToday = date === today;
return (
<div
key={date}
className={`animate-rise group flex min-h-[340px] min-w-0 flex-col rounded-2xl border ${
isToday ? 'border-brand-300 bg-brand-50/50' : weekend ? 'border-ink-200 bg-ink-50/60' : 'border-ink-200 bg-surface'
} shadow-card`}
style={{ '--i': i } as React.CSSProperties}
>
<div className="flex items-start justify-between gap-1 border-b border-ink-100 px-3 pb-2 pt-3">
<div className="min-w-0">
<p className={`text-[11px] font-extrabold uppercase tracking-wider ${isToday ? 'text-brand-600' : 'text-ink-400'}`}>{WEEKDAYS_SHORT[i]}</p>
<p className={`text-2xl font-extrabold leading-none ${isToday ? 'text-brand-700' : 'text-ink-900'}`}>{fromIso(date).getDate()}.</p>
</div>
<div className="text-right">
<p className="tabular text-sm font-extrabold text-ink-800">{minutes ? formatDuration(minutes) : ''}</p>
{!weekend && <DayMeter minutes={minutes} target={dailyTarget} />}
</div>
</div>
<div className="flex flex-1 flex-col gap-2 p-2">
{list.map((entry, j) => (
<EntryCard key={entry.id} entry={entry} index={j} onClick={() => onEdit(entry)} readOnly={readOnly} />
))}
{!readOnly && (
<button
onClick={() => onAdd(date)}
className={`mt-auto flex items-center justify-center gap-1 rounded-xl border border-dashed border-ink-200 py-2 text-xs font-bold text-ink-400 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 ${
list.length ? 'opacity-0 group-hover:opacity-100 focus:opacity-100' : ''
}`}
>
<Icon name="plus" className="h-3.5 w-3.5" strokeWidth={2.4} />
Buchen
</button>
)}
</div>
</div>
);
})}
</div>
);
};
const DayMeter: React.FC<{ minutes: number; target: number }> = ({ minutes, target }) => {
const grown = useGrow(minutes);
const p = target ? Math.min(100, (minutes / target) * 100) : 0;
return (
<div className="mt-1 h-1 w-12 overflow-hidden rounded-full bg-ink-100" title={`${Math.round(p)} % vom Tagessoll`}>
<div className={`h-full rounded-full transition-[width] duration-700 ${p >= 100 ? 'bg-brand-500' : 'bg-brand-300'}`} style={{ width: grown ? `${p}%` : 0 }} />
</div>
);
};
export const EntryCard: React.FC<{ entry: TimeEntry; index: number; onClick: () => void; readOnly: boolean; large?: boolean }> = ({
entry,
index,
onClick,
readOnly,
large,
}) => {
const color = colorFor(entry.customer_id, entry.customer_color);
return (
<button
onClick={onClick}
disabled={readOnly && !large}
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
style={{ '--i': index } as React.CSSProperties}
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `\n${entry.note}` : ''}`}
>
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
<div className="flex items-start justify-between gap-2">
<p className={`min-w-0 font-extrabold text-ink-900 ${large ? 'text-sm' : 'line-clamp-2 text-xs leading-snug'}`}>{entry.project_display}</p>
<span className={`tabular shrink-0 font-extrabold text-ink-800 ${large ? 'text-lg' : 'text-xs'}`}>{formatDuration(entry.minutes)}</span>
</div>
{entry.task_name && <p className={`truncate font-semibold text-ink-500 ${large ? 'text-xs' : 'text-[11px]'}`}>{entry.task_name}</p>}
{entry.note && <p className={`mt-0.5 text-ink-400 ${large ? 'text-xs' : 'line-clamp-2 text-[11px] leading-snug'}`}>{entry.note}</p>}
{!entry.billable && <span className="mt-1 inline-block rounded-full bg-ink-100 px-1.5 text-[9px] font-bold uppercase tracking-wide text-ink-500">non-billable</span>}
</button>
);
};
+95
View File
@@ -0,0 +1,95 @@
import React from 'react';
import { MONTHS, addDays, formatDuration, fromIso, mondayOf, todayIso, weekdayIndex } from '../../lib/format';
import { useGrow } from '../../lib/hooks';
import { BudgetBar, Card } from '../ui/controls';
import type { TimeEntry } from '../../types';
/** Jahresansicht: Monatskacheln mit Soll/Ist und eine Heatmap aller Tage. */
export const YearView: React.FC<{ year: string; entries: TimeEntry[]; weeklyTarget: number; onOpenMonth: (month: string) => void }> = ({
year,
entries,
weeklyTarget,
onOpenMonth,
}) => {
const today = todayIso();
const perDay = new Map<string, number>();
for (const e of entries) perDay.set(e.entry_date, (perDay.get(e.entry_date) || 0) + e.minutes);
const grown = useGrow(year);
const months = MONTHS.map((name, i) => {
const key = `${year}-${String(i + 1).padStart(2, '0')}`;
const list = entries.filter((e) => e.entry_date.startsWith(key));
const minutes = list.reduce((s, e) => s + e.minutes, 0);
const billable = list.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
let workdays = 0;
for (let d = `${key}-01`; d.startsWith(key); d = addDays(d, 1)) {
if (d > today) break;
if (weekdayIndex(d) < 5) workdays++;
}
const target = (weeklyTarget / 5) * workdays * 60;
return { key, name, minutes, billable, target };
});
const max = Math.max(1, ...months.map((m) => m.minutes));
// Heatmap: Wochen als Spalten, Mo-So als Zeilen.
const first = mondayOf(`${year}-01-01`);
const weeks: string[][] = [];
for (let d = first; d <= `${year}-12-31`; d = addDays(d, 7)) {
weeks.push(Array.from({ length: 7 }, (_, i) => addDays(d, i)));
}
const level = (minutes: number) => (minutes === 0 ? 0 : minutes < 180 ? 1 : minutes < 360 ? 2 : minutes < 480 ? 3 : 4);
const LEVELS = ['var(--color-ink-100)', 'var(--color-brand-200)', 'var(--color-brand-300)', 'var(--color-brand-500)', 'var(--color-brand-700)'];
return (
<div className="space-y-4">
<Card className="animate-rise overflow-x-auto p-4">
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-[0.14em] text-ink-400">Buchungsdichte {year}</p>
<div className="flex gap-[3px]" style={{ minWidth: weeks.length * 14 }}>
{weeks.map((week, w) => (
<div key={week[0]} className="flex flex-col gap-[3px]">
{week.map((date) => {
const minutes = perDay.get(date) || 0;
const out = !date.startsWith(year);
return (
<span
key={date}
title={`${fromIso(date).toLocaleDateString('de-DE')}: ${formatDuration(minutes)} h`}
className="h-[11px] w-[11px] rounded-[3px] transition-opacity duration-500"
style={{ background: out ? 'transparent' : LEVELS[level(minutes)], opacity: grown ? 1 : 0, transitionDelay: `${w * 8}ms` }}
/>
);
})}
</div>
))}
</div>
<div className="mt-3 flex items-center justify-end gap-1 text-[10px] font-bold text-ink-400">
weniger
{LEVELS.map((c) => (
<span key={c} className="h-[11px] w-[11px] rounded-[3px]" style={{ background: c }} />
))}
mehr
</div>
</Card>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{months.map((m, i) => (
<button
key={m.key}
onClick={() => onOpenMonth(m.key)}
className="animate-rise rounded-2xl border border-ink-200 bg-surface p-4 text-left shadow-card transition hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift"
style={{ '--i': i } as React.CSSProperties}
>
<p className={`text-xs font-extrabold uppercase tracking-wider ${m.key === today.slice(0, 7) ? 'text-brand-600' : 'text-ink-400'}`}>{m.name}</p>
<p className="tabular mt-1 text-2xl font-extrabold text-ink-900">{formatDuration(m.minutes)}</p>
<div className="mt-3 flex h-12 items-end gap-1">
<div className="flex-1 rounded-t-md bg-brand-500 transition-[height] duration-700" style={{ height: grown ? `${(m.billable / max) * 100}%` : 0 }} title="billable" />
<div className="flex-1 rounded-t-md bg-brand-200 transition-[height] duration-700" style={{ height: grown ? `${((m.minutes - m.billable) / max) * 100}%` : 0 }} title="non-billable" />
</div>
<BudgetBar className="mt-3" height="h-1.5" percent={m.target ? (m.minutes / m.target) * 100 : null} goal />
<p className="tabular mt-1 text-[10px] font-bold text-ink-400">Soll {m.target ? formatDuration(m.target) : '–'} h</p>
</button>
))}
</div>
</div>
);
};
+78
View File
@@ -0,0 +1,78 @@
import type { BudgetPot, Project } from '../../types';
import { formatDate, formatDuration, todayIso } from '../../lib/format';
export interface PotCheck {
ok: boolean;
pot: BudgetPot | null;
level: 'ok' | 'warn' | 'error' | 'none';
message: string | null;
/** Freie Minuten im Topf (null = unbegrenzt). */
freeMinutes: number | null;
}
/**
* Spiegelt die Budgetregeln des Backends fuer die Vorschau im Buchungsdialog.
* Das Backend prueft verbindlich - hier geht es nur darum, VOR dem Speichern
* zu sagen, was passieren wird.
*/
export function checkPot(
project: Project,
date: string,
minutes: number,
hoursPerDay: number,
warnPercent: number,
warnDays: number,
editing?: { potId: number | null; minutes: number } | null,
billable = true
): PotCheck {
// Wie im Backend: nicht abrechenbare Stunden in Billable-Projekten verbrauchen kein Volumen.
const onlyBillable = project.type === 'billable';
if (onlyBillable && !billable) minutes = 0;
if (date < project.start_date) {
return { ok: false, pot: null, level: 'error', message: `Das Projekt beginnt erst am ${formatDate(project.start_date)}.`, freeMinutes: 0 };
}
if (project.end_date && date > project.end_date) {
return { ok: false, pot: null, level: 'error', message: `Das Projekt ist am ${formatDate(project.end_date)} beendet.`, freeMinutes: 0 };
}
if (project.pots.length === 0) return { ok: true, pot: null, level: 'none', message: null, freeMinutes: null };
const covering = project.pots.filter((p) => p.valid_from <= date && p.valid_to >= date).sort((a, b) => a.valid_to.localeCompare(b.valid_to));
if (covering.length === 0) {
const ranges = project.pots.map((p) => `${p.label}: ${formatDate(p.valid_from)}–${formatDate(p.valid_to)}`).join(' · ');
return { ok: false, pot: null, level: 'error', message: `Kein Budgettopf deckt dieses Datum ab. Laufzeiten: ${ranges}`, freeMinutes: 0 };
}
const today = todayIso();
let last: PotCheck | null = null;
for (const pot of covering) {
if (pot.valid_to < today) {
last = { ok: false, pot, level: 'error', message: `Budgettopf „${pot.label}“ ist seit ${formatDate(pot.valid_to)} abgelaufen – nicht mehr buchbar.`, freeMinutes: 0 };
continue;
}
const credit = editing && editing.potId === pot.id ? editing.minutes : 0;
const free = pot.budget_hours == null ? null : pot.budget_hours * 60 - pot.used_minutes + credit;
if (free != null && (free <= 0 || minutes > free)) {
last = {
ok: false,
pot,
level: 'error',
message: free <= 0 ? `Budgettopf „${pot.label}“ ist aufgebraucht – nicht mehr buchbar.` : `Budgettopf „${pot.label}“ hat nur noch ${formatDuration(free)} h frei.`,
freeMinutes: Math.max(0, free),
};
continue;
}
const after = free == null ? null : free - minutes;
const percentAfter = pot.budget_hours ? ((pot.budget_hours * 60 - (after ?? 0)) / (pot.budget_hours * 60)) * 100 : null;
const hints: string[] = [];
if (after != null) {
const days = after / 60 / hoursPerDay;
hints.push(`Danach noch ${formatDuration(after)} h (${days.toLocaleString('de-DE', { maximumFractionDigits: 1 })} Tage) frei`);
} else {
hints.push('Unbegrenztes Volumen');
}
hints.push(`läuft bis ${formatDate(pot.valid_to)}${pot.days_left <= warnDays ? ` (noch ${Math.max(0, pot.days_left)} Tage)` : ''}`);
const warn = (percentAfter != null && percentAfter >= warnPercent) || pot.days_left <= warnDays;
return { ok: true, pot, level: warn ? 'warn' : 'ok', message: `${pot.label}: ${hints.join(', ')}.`, freeMinutes: free };
}
return last!;
}
+120
View File
@@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { Button, Field, Toggle, inputClass } from '../ui/controls';
import { customersApi } from '../../lib/api';
import { useToast } from '../Toast';
import type { Customer } from '../../types';
const COLORS = ['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899', '#64748b'];
export const CustomerForm: React.FC<{ customer?: Customer | null; onClose: () => void; onSaved: (customer: Customer) => void }> = ({
customer,
onClose,
onSaved,
}) => {
const { addToast } = useToast();
const [form, setForm] = useState<Partial<Customer>>(
customer ?? { name: '', short_name: '', color: COLORS[Math.floor(Math.random() * COLORS.length)], is_active: true }
);
const [busy, setBusy] = useState(false);
const set = (patch: Partial<Customer>) => setForm({ ...form, ...patch });
// Kuerzel aus dem Namen vorschlagen, solange es niemand angefasst hat.
const suggest = (name: string) =>
name
.replace(/\b(GmbH|AG|SE|KG|Co\.?|mbH|e\.V\.|Inc\.?|Ltd\.?)\b/gi, '')
.trim()
.split(/\s+/)[0]
?.toUpperCase()
.replace(/[^A-Z0-9ÄÖÜ]/g, '')
.slice(0, 10) ?? '';
const save = async () => {
setBusy(true);
try {
const saved = customer ? await customersApi.update(customer.id, form) : await customersApi.create(form);
addToast(customer ? 'Kunde gespeichert.' : `Kunde ${saved.name} angelegt.`, 'success');
onSaved(saved);
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
const text = (key: keyof Customer, label: string, props: React.InputHTMLAttributes<HTMLInputElement> = {}) => (
<Field label={label}>
<input className={inputClass} value={(form[key] as string) ?? ''} onChange={(e) => set({ [key]: e.target.value })} {...props} />
</Field>
);
return (
<Modal
title={customer ? 'Kunde bearbeiten' : 'Neuer Kunde'}
subtitle={customer ? customer.name : 'Kunden stehen über den Projekten'}
onClose={onClose}
size="lg"
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="check" busy={busy} onClick={save}>
Speichern
</Button>
</div>
}
>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-[1fr_10rem]">
<Field label="Name" required>
<input
className={inputClass}
value={form.name ?? ''}
autoFocus
onChange={(e) => {
const name = e.target.value;
const auto = !customer && (form.short_name === '' || form.short_name === suggest(form.name ?? ''));
set({ name, ...(auto ? { short_name: suggest(name) } : {}) });
}}
/>
</Field>
<Field label="Kürzel" required hint="Steht im Projektcode">
<input className={`${inputClass} uppercase`} maxLength={16} value={form.short_name ?? ''} onChange={(e) => set({ short_name: e.target.value.toUpperCase() })} />
</Field>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{text('contact_person', 'Ansprechpartner')}
{text('email', 'E-Mail', { type: 'email' })}
{text('phone', 'Telefon', { type: 'tel' })}
</div>
<div className="grid gap-4 sm:grid-cols-[2fr_1fr_2fr_1fr]">
{text('street', 'Straße')}
{text('zip', 'PLZ')}
{text('city', 'Ort')}
{text('country', 'Land')}
</div>
<Field label="Notizen">
<textarea className={`${inputClass} h-20`} value={form.notes ?? ''} onChange={(e) => set({ notes: e.target.value })} />
</Field>
<div>
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Farbe</span>
<div className="flex flex-wrap gap-2">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => set({ color: c })}
aria-label={`Farbe ${c}`}
className={`h-8 w-8 rounded-full transition hover:scale-110 ${form.color === c ? 'ring-2 ring-ink-800 ring-offset-2 ring-offset-surface' : ''}`}
style={{ background: c }}
/>
))}
</div>
</div>
{customer && <Toggle checked={Boolean(form.is_active)} onChange={(v) => set({ is_active: v })} label="Aktiv" hint="Inaktive Kunden erscheinen nicht mehr bei neuen Projekten." />}
</div>
</Modal>
);
};
+319
View File
@@ -0,0 +1,319 @@
import React, { useMemo, useState } from 'react';
import { Avatar, AvatarStack, Badge, Button, Card, EmptyState, ErrorBox, IconButton, Panel, Segmented, Spinner, Stat, inputClass } from '../ui/controls';
import { ConfirmDialog } from '../ui/Modal';
import { Icon } from '../ui/Icon';
import { CountUp, SegmentRing, Sparkline } from '../ui/charts';
import { CustomerForm } from './CustomerForm';
import { customersApi, type CustomerOverview } from '../../lib/api';
import { useGrow, useLoader } from '../../lib/hooks';
import { MONTHS, colorFor, compactEuro, formatEuro, formatHours } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { useToast } from '../Toast';
import { TYPE_TONE } from '../projects/Projects';
import { PROJECT_TYPE_LABELS, type Customer } from '../../types';
export const Customers: React.FC = () => {
const { data, error, reload } = useLoader(() => customersApi.overview(), []);
const { navigate } = useRouter();
const { addToast } = useToast();
const [search, setSearch] = useState('');
const [status, setStatus] = useState<'active' | 'all'>('active');
const [form, setForm] = useState<{ customer: Customer | null } | null>(null);
const [remove, setRemove] = useState<CustomerOverview | null>(null);
const [expanded, setExpanded] = useState<number | null>(null);
const customers = useMemo(() => {
const q = search.trim().toLowerCase();
return (data?.customers ?? [])
.filter((c) => (status === 'all' || c.is_active) && (!q || `${c.name} ${c.short_name} ${c.city ?? ''}`.toLowerCase().includes(q)))
.sort((a, b) => b.earned + b.open - (a.earned + a.open) || a.name.localeCompare(b.name, 'de'));
}, [data, search, status]);
if (error && !data) return <ErrorBox message={error} onRetry={reload} />;
if (!data) return <Spinner label="Lade Kunden…" />;
const all = data.customers;
const earned = all.reduce((s, c) => s + c.earned, 0);
const open = all.reduce((s, c) => s + c.open, 0);
const minutes = all.reduce((s, c) => s + c.used_minutes, 0);
const activeProjects = all.reduce((s, c) => s + c.projects.filter((p) => p.is_active).length, 0);
const doRemove = async () => {
const target = remove!;
setRemove(null);
try {
await customersApi.remove(target.id);
addToast(`${target.name} gelöscht.`, 'success');
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
}
};
return (
<div className="mx-auto max-w-[1500px] space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Stat index={0} label="Umsatz erbracht" icon="euro" value={earned} format={(v) => compactEuro(v)} hint="über alle Budgettöpfe" />
<Stat index={1} label="Umsatz offen" icon="pot" value={open} format={(v) => compactEuro(v)} hint="in laufenden Töpfen" accent="var(--color-brand-300)" />
<Stat index={2} label="Gebuchte Stunden" icon="clock" value={minutes / 60} format={(v) => Math.round(v).toLocaleString('de-DE')} accent="var(--color-sky-700)" />
<Stat index={3} label="Aktive Projekte" icon="folder" value={activeProjects} format={(v) => String(Math.round(v))} hint={`${all.filter((c) => c.is_active).length} aktive Kunden`} accent="var(--color-violet-700)" />
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(300px,1fr)]">
<Panel index={4} title="Umsatz je Kunde" hint="Erbracht und noch offen – Balken wachsen beim Laden">
<RevenueBars customers={customers.filter((c) => c.earned + c.open > 0)} onOpen={(id) => navigate(`reports/kunde/${id}`)} />
</Panel>
<Panel index={5} title="Anteil am Umsatz" hint="Erbrachter Umsatz nach Kunde">
<ShareRing customers={all} total={earned} />
</Panel>
</div>
<div className="flex flex-wrap items-center gap-2 pt-2">
<div className="relative min-w-[200px] flex-1 sm:max-w-xs">
<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="Kunde suchen…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Segmented size="sm" value={status} onChange={setStatus} options={[{ value: 'active', label: 'Aktiv' }, { value: 'all', label: 'Alle' }]} />
<Button icon="plus" className="ml-auto" onClick={() => setForm({ customer: null })}>
Neuer Kunde
</Button>
</div>
{customers.length === 0 ? (
<EmptyState icon="building" title="Keine Kunden gefunden" action={<Button icon="plus" onClick={() => setForm({ customer: null })}>Neuer Kunde</Button>} />
) : (
<div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
{customers.map((c, i) => (
<CustomerCard
key={c.id}
customer={c}
months={data.months}
index={i}
expanded={expanded === c.id}
onToggle={() => setExpanded(expanded === c.id ? null : c.id)}
onEdit={() => setForm({ customer: c })}
onDelete={() => setRemove(c)}
onReport={() => navigate(`reports/kunde/${c.id}`)}
onProject={(id) => navigate(`projekte/${id}`)}
/>
))}
</div>
)}
{form && <CustomerForm customer={form.customer} onClose={() => setForm(null)} onSaved={reload} />}
{remove && (
<ConfirmDialog
title={`${remove.name} löschen?`}
message={remove.projects.length ? 'Der Kunde hat Projekte und kann nur deaktiviert werden.' : 'Der Kunde wird endgültig entfernt.'}
onConfirm={doRemove}
onCancel={() => setRemove(null)}
/>
)}
</div>
);
};
const RevenueBars: React.FC<{ customers: CustomerOverview[]; onOpen: (id: number) => void }> = ({ customers, onOpen }) => {
const grown = useGrow(customers.map((c) => c.id).join());
const max = Math.max(1, ...customers.map((c) => c.earned + c.open));
if (!customers.length) return <p className="py-10 text-center text-xs text-ink-400">Noch kein Umsatz erfasst.</p>;
return (
<ul className="space-y-3">
{customers.slice(0, 10).map((c, i) => {
const color = colorFor(c.id, c.color);
return (
<li key={c.id}>
<button onClick={() => onOpen(c.id)} className="group w-full text-left">
<div className="mb-1 flex items-baseline justify-between gap-3 text-sm">
<span className="flex min-w-0 items-center gap-2 font-bold text-ink-800 group-hover:text-brand-700">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: color }} />
<span className="truncate">{c.name}</span>
</span>
<span className="tabular shrink-0 text-xs">
<b className="text-ink-900">{compactEuro(c.earned)}</b>
<span className="text-ink-400"> + {compactEuro(c.open)} offen</span>
</span>
</div>
<div className="flex h-4 overflow-hidden rounded-full bg-ink-100">
<div
className="h-full transition-[width] duration-1000 ease-out"
style={{ width: grown ? `${(c.earned / max) * 100}%` : 0, background: color, transitionDelay: `${i * 70}ms` }}
/>
<div
className="h-full transition-[width] duration-1000 ease-out"
style={{
width: grown ? `${(c.open / max) * 100}%` : 0,
background: `repeating-linear-gradient(-45deg, ${color}55, ${color}55 5px, ${color}22 5px, ${color}22 10px)`,
transitionDelay: `${i * 70 + 200}ms`,
}}
/>
</div>
</button>
</li>
);
})}
</ul>
);
};
const ShareRing: React.FC<{ customers: CustomerOverview[]; total: number }> = ({ customers, total }) => {
const top = [...customers].filter((c) => c.earned > 0).sort((a, b) => b.earned - a.earned);
const segments = top.slice(0, 6).map((c) => ({ label: c.name, value: c.earned, color: colorFor(c.id, c.color) }));
const rest = top.slice(6).reduce((s, c) => s + c.earned, 0);
if (rest > 0) segments.push({ label: 'Weitere', value: rest, color: 'var(--color-ink-300)' });
if (!segments.length) return <p className="py-10 text-center text-xs text-ink-400">Noch kein Umsatz erfasst.</p>;
return (
<div className="flex flex-col items-center gap-4">
<SegmentRing
segments={segments}
size={180}
stroke={22}
center={
<>
<CountUp value={total} format={(v) => compactEuro(v)} className="text-xl font-extrabold text-ink-900" />
<span className="text-[10px] font-bold uppercase tracking-wider text-ink-400">erbracht</span>
</>
}
/>
<ul className="w-full space-y-1.5">
{segments.map((s) => (
<li key={s.label} className="flex items-center gap-2 text-xs">
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: s.color }} />
<span className="min-w-0 flex-1 truncate font-bold text-ink-700">{s.label}</span>
<span className="tabular font-extrabold text-ink-900">{Math.round((s.value / (total || 1)) * 100)} %</span>
</li>
))}
</ul>
</div>
);
};
const CustomerCard: React.FC<{
customer: CustomerOverview;
months: string[];
index: number;
expanded: boolean;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
onReport: () => void;
onProject: (id: number) => void;
}> = ({ customer: c, months, index, expanded, onToggle, onEdit, onDelete, onReport, onProject }) => {
const color = colorFor(c.id, c.color);
const total = c.earned + c.open;
const percent = total ? (c.earned / total) * 100 : 0;
const lastMonth = c.monthly[c.monthly.length - 1] ?? 0;
return (
<Card className={`animate-rise overflow-hidden transition hover:shadow-lift ${c.is_active ? '' : 'opacity-60'}`} style={{ '--i': Math.min(index, 10) } as React.CSSProperties}>
<div className="relative p-4">
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
<div className="flex items-start gap-3">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-sm font-extrabold text-white" style={{ background: color }}>
{c.short_name.slice(0, 2)}
</span>
<div className="min-w-0 flex-1">
<h3 className="truncate text-base text-ink-900">{c.name}</h3>
<p className="truncate text-xs text-ink-400">
{c.short_name}
{c.city && ` · ${c.city}`}
{c.contact_person && ` · ${c.contact_person}`}
</p>
</div>
<div className="flex shrink-0">
<IconButton icon="chart" label="Kundenreport" onClick={onReport} />
<IconButton icon="edit" label="Bearbeiten" onClick={onEdit} />
{c.projects.length === 0 && <IconButton icon="trash" tone="danger" label="Löschen" onClick={onDelete} />}
</div>
</div>
<div className="mt-4 flex items-center gap-4">
<MiniRing percent={percent} color={color} />
<div className="grid flex-1 grid-cols-2 gap-2">
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-400">Erbracht</p>
<CountUp value={c.earned} format={(v) => formatEuro(v)} className="text-base font-extrabold text-ink-900" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-400">Offen</p>
<CountUp value={c.open} format={(v) => formatEuro(v)} className="text-base font-extrabold text-ink-500" />
</div>
<div className="col-span-2 flex items-end justify-between">
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-400">Letzte 12 Monate</p>
<p className="tabular text-[11px] text-ink-500">
{MONTHS[Number(months[months.length - 1].slice(5)) - 1]}: {compactEuro(lastMonth)} · {formatHours(c.used_minutes, 0)} h gesamt
</p>
</div>
<Sparkline values={c.monthly} width={110} height={30} color={color} />
</div>
</div>
</div>
</div>
<button onClick={onToggle} className="flex w-full items-center justify-between border-t border-ink-100 bg-ink-50/60 px-4 py-2.5 text-xs font-bold text-ink-600 hover:bg-ink-100">
<span className="flex items-center gap-2">
<Icon name="folder" className="h-4 w-4" />
{c.projects.length} {c.projects.length === 1 ? 'Projekt' : 'Projekte'}
</span>
<span className="flex items-center gap-2">
<AvatarStack people={c.members} max={6} size={22} />
<Icon name="chevronDown" className={`h-4 w-4 transition-transform ${expanded ? 'rotate-180' : ''}`} />
</span>
</button>
{expanded && (
<ul className="animate-fade divide-y divide-ink-100">
{c.projects.length === 0 && <li className="px-4 py-3 text-xs text-ink-400">Noch keine Projekte.</li>}
{c.projects.map((p) => (
<li key={p.id} className="px-4 py-3">
<button onClick={() => onProject(p.id)} className="flex w-full items-center justify-between gap-2 text-left">
<span className="min-w-0">
<span className="block truncate text-sm font-bold text-ink-800 hover:text-brand-700">{p.display_name}</span>
<span className="tabular block text-[11px] text-ink-400">
{p.code} · {formatHours(p.used_minutes, 1)} h{p.budget_hours ? ` / ${p.budget_hours.toLocaleString('de-DE')} h` : ''} · {formatEuro(p.earned)} erbracht
</span>
</span>
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
</button>
<div className="mt-2 flex flex-wrap gap-1.5">
{p.members.map((m) => (
<span key={m.id} className="flex items-center gap-1.5 rounded-full bg-ink-50 py-0.5 pl-0.5 pr-2 text-[11px] font-semibold text-ink-600">
<Avatar id={m.id} name={m.name} color={m.color} size={18} external={m.is_external} />
{m.name}
</span>
))}
</div>
</li>
))}
</ul>
)}
</Card>
);
};
const MiniRing: React.FC<{ percent: number; color: string }> = ({ percent, color }) => {
const grown = useGrow(percent);
const size = 64;
const stroke = 8;
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
return (
<div className="relative shrink-0" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-ink-100)" strokeWidth={stroke} />
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={grown ? c * (1 - percent / 100) : c}
style={{ transition: 'stroke-dashoffset 1.2s cubic-bezier(0.22,1,0.36,1)' }}
/>
</svg>
<span className="tabular absolute inset-0 flex items-center justify-center text-xs font-extrabold text-ink-800">{Math.round(percent)}%</span>
</div>
);
};
+265
View File
@@ -0,0 +1,265 @@
import React, { useMemo, useState } from 'react';
import { Avatar, Badge, ErrorBox, Panel, Segmented, Spinner, Stat } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { BILLABLE, Donut, Legend, NON_BILLABLE, SegmentRing, SplitBar, StackedBars } from '../ui/charts';
import { useAuth } from '../../lib/auth';
import { dashboardApi, type DashboardData, type Granularity, type RankPeriod } from '../../lib/api';
import { useLoader, usePersistentState } from '../../lib/hooks';
import { colorFor, compactEuro, formatDate, formatDuration, formatHours, relativeTime } from '../../lib/format';
import { useRouter } from '../../lib/router';
const GRANULARITY: { value: Granularity; label: string }[] = [
{ value: 'day', label: 'Tag' },
{ value: 'week', label: 'Woche' },
{ value: 'month', label: 'Monat' },
{ value: 'quarter', label: 'Quartal' },
{ value: 'year', label: 'Jahr' },
];
const PERIOD_NOUN: Record<Granularity, string> = { day: 'heute', week: 'diese Woche', month: 'diesen Monat', quarter: 'dieses Quartal', year: 'dieses Jahr' };
export const Dashboard: React.FC = () => {
const { user } = useAuth();
const [granularity, setGranularity] = usePersistentState<Granularity>('pulse_dash_granularity', 'week');
const [rank, setRank] = usePersistentState<RankPeriod>('pulse_dash_rank', 'month');
const { data, error, loading, reload } = useLoader(() => dashboardApi.get(granularity, rank), [granularity, rank]);
if (error && !data) return <ErrorBox message={error} onRetry={reload} />;
if (!data) return <Spinner label="Lade Dashboard…" />;
const cur = data.kpis.current;
const prev = data.kpis.previous;
const delta = (a: number, b: number | undefined) => (b ? ((a - b) / b) * 100 : null);
const share = cur.minutes ? (cur.billable_minutes / cur.minutes) * 100 : 0;
const utilization = cur.capacity_minutes ? (cur.minutes / cur.capacity_minutes) * 100 : 0;
return (
<div className={`mx-auto max-w-[1500px] space-y-4 transition-opacity ${loading ? 'opacity-70' : ''}`}>
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0">
<p className="text-sm text-ink-500">
Hallo <b className="text-ink-900">{user!.name.split(' ')[0]}</b> – so sieht {PERIOD_NOUN[granularity]} aus.
</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge tone={data.scope === 'all' ? 'brand' : 'sky'}>
<Icon name="team" className="h-3 w-3" />
{data.scope === 'all' ? 'Gesamte Organisation' : 'Mein Team'} · {data.team_size} {data.team_size === 1 ? 'Person' : 'Personen'}
</Badge>
<Badge>{data.kpis.period_label}</Badge>
</div>
</div>
<Segmented className="ml-auto w-full sm:w-auto" value={granularity} onChange={setGranularity} options={GRANULARITY} />
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Stat index={0} label="Gebuchte Stunden" icon="clock" value={cur.minutes / 60} format={(v) => formatNumberDe(v, 1)} delta={delta(cur.minutes, prev?.minutes)} hint={prev ? `vs. ${data.kpis.previous_label}` : undefined} />
<Stat index={1} label="Billable" icon="check" value={cur.billable_minutes / 60} format={(v) => formatNumberDe(v, 1)} delta={delta(cur.billable_minutes, prev?.billable_minutes)} hint={`${Math.round(share)} % Anteil`} />
<Stat index={2} label="Billable Betrag" icon="euro" value={cur.amount} format={(v) => compactEuro(v)} delta={delta(cur.amount, prev?.amount)} accent="var(--color-sky-700)" />
<Stat index={3} label="Auslastung" icon="chart" value={utilization} format={(v) => `${Math.round(v)} %`} hint={`Kapazität ${formatNumberDe(cur.capacity_minutes / 60, 0)} h bis heute`} accent="var(--color-violet-700)" />
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
<Panel
index={4}
title="Gebuchte Zeiten"
hint={`Billable und non-billable je ${GRANULARITY.find((g) => g.value === granularity)?.label}`}
action={<Legend items={[{ color: BILLABLE, label: 'Billable' }, { color: NON_BILLABLE, label: 'Non-billable' }]} />}
>
<StackedBars
points={data.series.map((s, i) => ({ key: s.from, label: s.label, a: s.billable_minutes, b: s.non_billable_minutes, highlight: i === data.series.length - 1 }))}
format={(m) => `${formatHours(m, 1)} h`}
axisFormat={(m) => formatNumberDe(m / 60, 0)}
height={260}
scale={60}
/>
</Panel>
<Panel index={5} title="Verteilung" hint={`Projekte ${PERIOD_NOUN[granularity]}`}>
<Distribution data={data} share={share} />
</Panel>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,3fr)_minmax(320px,2fr)]">
<Ranking data={data} rank={rank} onRank={setRank} />
<RecentFeed data={data} />
</div>
</div>
);
};
function formatNumberDe(value: number, digits: number) {
return value.toLocaleString('de-DE', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
const Distribution: React.FC<{ data: DashboardData; share: number }> = ({ data, share }) => {
const total = data.projects.reduce((s, p) => s + p.minutes, 0);
const segments = data.projects.map((p) => ({ label: p.name, value: p.minutes, color: colorFor(p.project_id, p.customer_color) }));
if (!total) return <p className="py-10 text-center text-xs font-semibold text-ink-400">Noch keine Buchungen in diesem Zeitraum.</p>;
return (
<div>
<div className="flex items-center justify-center gap-6">
<SegmentRing
segments={segments}
size={150}
center={
<>
<span className="tabular text-2xl font-extrabold text-ink-900">{formatNumberDe(total / 60, 0)}</span>
<span className="text-[10px] font-bold uppercase tracking-wider text-ink-400">Stunden</span>
</>
}
/>
<div className="flex flex-col items-center gap-1">
<Donut percent={share} size={84} stroke={10} label="billable" />
</div>
</div>
<ul className="mt-5 space-y-2">
{data.projects.slice(0, 6).map((p, i) => (
<li key={p.project_id} className="animate-rise flex items-center gap-2 text-xs" style={{ '--i': i } as React.CSSProperties}>
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: segments[i].color }} />
<span className="min-w-0 flex-1 truncate font-bold text-ink-700">{p.name}</span>
<span className="tabular font-extrabold text-ink-900">{formatHours(p.minutes, 1)} h</span>
<span className="tabular w-10 text-right text-ink-400">{Math.round((p.minutes / total) * 100)} %</span>
</li>
))}
</ul>
</div>
);
};
type RankFilter = 'all' | 'billable' | 'non_billable';
const ROW = 60;
/**
* Mitarbeiterliste. Die Zeilen sind absolut positioniert und gleiten beim
* Umsortieren oder Filtern an ihren neuen Platz - man sieht, wer aufsteigt.
*/
const Ranking: React.FC<{ data: DashboardData; rank: RankPeriod; onRank: (r: RankPeriod) => void }> = ({ data, rank, onRank }) => {
const [filter, setFilter] = usePersistentState<RankFilter>('pulse_dash_filter', 'all');
const [order, setOrder] = usePersistentState<'desc' | 'asc'>('pulse_dash_order', 'desc');
const { navigate } = useRouter();
const value = (r: DashboardData['ranking'][number]) => (filter === 'billable' ? r.billable_minutes : filter === 'non_billable' ? r.non_billable_minutes : r.minutes);
const sorted = useMemo(
() => [...data.ranking].sort((a, b) => (order === 'desc' ? value(b) - value(a) : value(a) - value(b)) || a.name.localeCompare(b.name, 'de')),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data.ranking, filter, order]
);
const positions = new Map(sorted.map((r, i) => [r.user_id, i]));
const max = Math.max(1, ...data.ranking.map((r) => (filter === 'all' ? r.minutes : value(r))));
const sum = sorted.reduce((s, r) => s + value(r), 0);
return (
<Panel
index={6}
title="Buchungen je Mitarbeiter"
hint={`${formatDate(data.rank_range.from)} – ${formatDate(data.rank_range.to)} · ${formatHours(sum, 1)} h gesamt`}
action={
<div className="flex flex-wrap items-center gap-2">
<select
value={rank}
onChange={(e) => onRank(e.target.value as RankPeriod)}
className="rounded-xl border border-ink-200 bg-surface px-3 py-1.5 text-xs font-bold text-ink-600 outline-none focus:border-brand-500"
>
<option value="days">Letzte 7 Tage</option>
<option value="week">Diese Woche</option>
<option value="month">Dieser Monat</option>
<option value="quarter">Dieses Quartal</option>
<option value="year">Dieses Jahr</option>
</select>
<Segmented
size="sm"
value={filter}
onChange={setFilter}
options={[
{ value: 'all', label: 'Alle' },
{ value: 'billable', label: 'Billable' },
{ value: 'non_billable', label: 'Non-billable' },
]}
/>
<button
onClick={() => setOrder(order === 'desc' ? 'asc' : 'desc')}
className="flex items-center gap-1 rounded-xl border border-ink-200 px-2.5 py-1.5 text-xs font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
title="Sortierung umkehren"
>
<Icon name={order === 'desc' ? 'arrowDown' : 'arrowUp'} className="h-3.5 w-3.5" strokeWidth={2.4} />
{order === 'desc' ? 'Absteigend' : 'Aufsteigend'}
</button>
</div>
}
>
{data.ranking.length === 0 ? (
<p className="py-8 text-center text-xs text-ink-400">Keine Mitarbeiter im Team.</p>
) : (
<div className="relative" style={{ height: data.ranking.length * ROW }}>
{data.ranking.map((r) => {
const pos = positions.get(r.user_id) ?? 0;
const a = filter === 'non_billable' ? 0 : r.billable_minutes;
const b = filter === 'billable' ? 0 : r.non_billable_minutes;
const util = r.capacity_minutes ? Math.round((r.minutes / r.capacity_minutes) * 100) : null;
return (
<div
key={r.user_id}
className="absolute inset-x-0 flex items-center gap-3 rounded-xl px-1 transition-transform duration-500 ease-out hover:bg-ink-50"
style={{ height: ROW, transform: `translateY(${pos * ROW}px)` }}
>
<span className="tabular w-5 shrink-0 text-center text-xs font-extrabold text-ink-300">{pos + 1}</span>
<Avatar id={r.user_id} name={r.name} color={r.color} size={34} external={r.is_external} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<button onClick={() => navigate('reports')} className="truncate text-left text-sm font-bold text-ink-800 hover:text-brand-700" title="Zu den Reports">
{r.name}
</button>
<span className="tabular shrink-0 text-sm font-extrabold text-ink-900">{formatHours(value(r), 2)} h</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<SplitBar a={a} b={b} max={max} className="flex-1" />
{util != null && (
<span
className={`tabular w-12 shrink-0 text-right text-[11px] font-bold ${util > 110 ? 'text-red-600' : util >= 80 ? 'text-brand-700' : 'text-ink-400'}`}
title="Auslastung (alle Stunden / Kapazität)"
>
{util} %
</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</Panel>
);
};
const RecentFeed: React.FC<{ data: DashboardData }> = ({ data }) => (
<Panel index={7} title="Letzte Buchungen" hint="Neueste Einträge zuerst">
{data.recent.length === 0 ? (
<p className="py-8 text-center text-xs text-ink-400">Noch keine Buchungen.</p>
) : (
<ol className="relative space-y-1 before:absolute before:bottom-3 before:left-[17px] before:top-3 before:w-px before:bg-ink-100">
{data.recent.map((e, i) => (
<li key={e.id} className="animate-rise relative flex gap-3 rounded-xl p-1.5 hover:bg-ink-50" style={{ '--i': i } as React.CSSProperties}>
<Avatar id={e.user_id} name={e.user_name} color={e.user_color} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate text-sm font-bold text-ink-800">{e.user_name}</p>
<span className={`tabular shrink-0 rounded-full px-2 py-0.5 text-xs font-extrabold ${e.billable ? 'bg-brand-50 text-brand-700' : 'bg-ink-100 text-ink-600'}`}>
{formatDuration(e.minutes)}
</span>
</div>
<p className="truncate text-xs text-ink-500">
<span className="mr-1 inline-block h-2 w-2 rounded-full align-middle" style={{ background: colorFor(e.project_display, e.customer_color) }} />
{e.project_display}
{e.task_name && <span className="text-ink-400"> · {e.task_name}</span>}
</p>
<p className="text-[11px] text-ink-400">
für {formatDate(e.entry_date, 'day')} · {relativeTime(e.created_at)}
</p>
</div>
</li>
))}
</ol>
)}
</Panel>
);
+102
View File
@@ -0,0 +1,102 @@
import React, { useEffect, useRef } from 'react';
/*
* Sonnenstrahlen des Themes "Aura".
*
* Alle 16 bis 24 Sekunden fallen zwei oder drei Strahlen von der Sonne in der
* rechten oberen Ecke der Kopfleiste schräg über die Seite und verblassen
* wieder. Nachts bleibt es dunkel - dort steht der Mond, und Mondstrahlen über
* dem Bildschirm wären zu viel.
*
* Die Strahlen liegen über der Oberfläche, fangen keinen Klick ab und bleiben
* unter den Dialogen. Bei "Bewegung reduzieren" im Betriebssystem bleiben sie
* ganz aus; Himmel, Sonne und Wolken der Leiste sind reines CSS.
*/
const GAP_MIN_MS = 16000;
const GAP_MAX_MS = 24000;
const DURATION_MS = 4200;
/** Ursprung: so weit von der rechten Kante bzw. Oberkante der Leiste entfernt. */
const ORIGIN_RIGHT = 20;
const ORIGIN_TOP = 10;
function random(min: number, max: number) {
return min + Math.random() * (max - min);
}
function prefersReducedMotion() {
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
}
export const AuraSky: React.FC = () => {
const layerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const layer = layerRef.current;
if (!layer || prefersReducedMotion() || typeof layer.animate !== 'function') return;
let timer = 0;
let cancelled = false;
const schedule = (delay = random(GAP_MIN_MS, GAP_MAX_MS)) => {
if (cancelled) return;
timer = window.setTimeout(shine, delay);
};
const shine = () => {
/*
* Die Leiste wird bei jedem Durchgang neu vermessen: sie fehlt auf dem
* Anmeldeschirm und ändert ihre Breite mit der Seitenleiste. Im
* Dunkelmodus scheint nichts - dort steht der Mond.
*/
const header = document.querySelector<HTMLElement>('.app-header');
if (!header || document.hidden || document.documentElement.classList.contains('dark')) {
schedule();
return;
}
const rect = header.getBoundingClientRect();
const originX = rect.right - ORIGIN_RIGHT;
const originY = rect.top + ORIGIN_TOP;
// Zielpunkt ist die linke untere Ecke: daraus folgen Winkel und Länge.
const dx = 0 - originX;
const dy = window.innerHeight - originY;
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
const length = Math.hypot(dx, dy) * 1.15;
const count = Math.random() < 0.5 ? 2 : 3;
for (let i = 0; i < count; i++) {
const ray = document.createElement('div');
const thickness = random(70, 150);
// Die Strahlen liegen leicht gefächert übereinander.
const rotation = angle + (i - (count - 1) / 2) * random(5, 8);
ray.className = 'aura-ray';
ray.style.left = `${originX}px`;
ray.style.top = `${originY - thickness / 2}px`;
ray.style.width = `${length}px`;
ray.style.height = `${thickness}px`;
layer.appendChild(ray);
// Leichtes Wandern im Winkel, damit es nicht wie ein Standbild wirkt.
const animation = ray.animate(
[
{ opacity: 0, transform: `rotate(${rotation - 1.5}deg)` },
{ opacity: random(0.55, 0.85), offset: 0.4 },
{ opacity: 0, transform: `rotate(${rotation + 1.5}deg)` },
],
{ duration: DURATION_MS + random(-600, 600), delay: i * random(250, 500), easing: 'ease-in-out', fill: 'both' }
);
animation.onfinish = () => ray.remove();
}
schedule();
};
// Der erste Strahl kommt früher, damit man das Theme beim Umschalten erlebt.
schedule(random(2500, 4000));
return () => {
cancelled = true;
window.clearTimeout(timer);
layer.replaceChildren();
};
}, []);
return <div ref={layerRef} aria-hidden="true" className="pointer-events-none fixed inset-0 z-[90] overflow-hidden" />;
};
+68
View File
@@ -0,0 +1,68 @@
import React from 'react';
import { useTheme } from '../../lib/theme';
import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../../types';
import { Icon } from '../ui/Icon';
import { PulseMark } from './PulseMark';
interface HeaderProps {
title: string;
subtitle?: string;
onToggleSidebar: () => void;
isMobile: boolean;
}
export const Header: React.FC<HeaderProps> = ({ title, subtitle, onToggleSidebar, isMobile }) => {
const { theme, preference, toggle, useSystem, palette, setPalette } = useTheme();
const isDark = theme === 'dark';
return (
<header className="app-header relative flex shrink-0 items-center gap-3 border-b border-ink-200 bg-surface px-4 py-3 md:px-6 md:py-4">
{isMobile ? (
<PulseMark size={34} />
) : (
<button
onClick={onToggleSidebar}
className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800"
title="Menü ein-/ausklappen"
aria-label="Menü ein-/ausklappen"
>
<Icon name="menu" strokeWidth={2} />
</button>
)}
<div className="min-w-0 flex-1">
<h1 className="truncate text-lg leading-tight text-ink-900">{title}</h1>
{subtitle && <p className="truncate text-xs font-semibold text-ink-400">{subtitle}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
<label className="sr-only" htmlFor="theme-palette">
Design
</label>
<select
id="theme-palette"
value={palette}
onChange={(event) => setPalette(event.target.value as typeof palette)}
title={`Design: ${THEME_PALETTE_LABELS[palette].hint}`}
className="max-w-[110px] rounded-xl border border-ink-200 bg-surface px-2.5 py-2 text-xs font-bold text-ink-600 outline-none transition hover:border-brand-300 hover:text-brand-700 focus:border-brand-500 sm:max-w-none sm:px-3"
>
{THEME_PALETTES.map((option) => (
<option key={option} value={option}>
{THEME_PALETTE_LABELS[option].name}
</option>
))}
</select>
<button
onClick={toggle}
onDoubleClick={useSystem}
title={`${isDark ? 'Helles' : 'Dunkles'} Design${preference === null ? ' (folgt dem System)' : ' · Doppelklick: dem System folgen'}`}
aria-label={`${isDark ? 'Helles' : 'Dunkles'} Design einschalten`}
className="flex items-center gap-2 rounded-xl border border-ink-200 px-2.5 py-2 text-xs font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700 sm:px-3"
>
<Icon name={isDark ? 'sun' : 'moon'} className="h-4 w-4" />
<span className="hidden lg:inline">{isDark ? 'Hell' : 'Dunkel'}</span>
</button>
</div>
</header>
);
};
+250
View File
@@ -0,0 +1,250 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAuth } from '../../lib/auth';
import { authApi } from '../../lib/api';
import { PulseMark } from './PulseMark';
/**
* Knoten des dekorativen Netzwerks. Jeder Knoten driftet auf einer eigenen
* Lissajous-Bahn, damit sich das Muster nie exakt wiederholt.
*/
const NETWORK_NODES = [
{ x: 80, y: 300, r: 9, ax: 10, ay: 7, sx: 0.055, sy: 0.041, px: 0.0, py: 1.9 },
{ x: 150, y: 210, r: 13, ax: 8, ay: 11, sx: 0.037, sy: 0.049, px: 1.2, py: 0.4 },
{ x: 240, y: 250, r: 10, ax: 12, ay: 8, sx: 0.045, sy: 0.033, px: 2.7, py: 3.3 },
{ x: 310, y: 150, r: 12, ax: 9, ay: 12, sx: 0.031, sy: 0.052, px: 4.1, py: 1.1 },
{ x: 250, y: 80, r: 7, ax: 13, ay: 9, sx: 0.048, sy: 0.036, px: 5.4, py: 2.6 },
{ x: 160, y: 340, r: 6, ax: 11, ay: 10, sx: 0.042, sy: 0.057, px: 0.8, py: 4.8 },
{ x: 360, y: 240, r: 8, ax: 7, ay: 13, sx: 0.058, sy: 0.029, px: 3.5, py: 0.2 },
];
/** Verbindungen zwischen den Knoten (Indizes in NETWORK_NODES). */
const NETWORK_EDGES: Array<[number, number]> = [
[0, 1],
[1, 2],
[2, 3],
[3, 4],
[4, 1],
[0, 5],
[5, 2],
[3, 6],
[6, 2],
];
/** Dekoratives Netzwerk-Muster - greift das Knoten-Motiv des perinco Logos auf. */
const NetworkPattern: React.FC = () => {
const circleRefs = useRef<Array<SVGCircleElement | null>>([]);
const lineRefs = useRef<Array<SVGLineElement | null>>([]);
useEffect(() => {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
if (reduced.matches) return;
let frame = 0;
const start = performance.now();
const tick = (now: number) => {
const t = (now - start) / 1000;
const positions = NETWORK_NODES.map((node) => ({
x: node.x + Math.sin(t * node.sx * Math.PI * 2 + node.px) * node.ax,
y: node.y + Math.cos(t * node.sy * Math.PI * 2 + node.py) * node.ay,
}));
positions.forEach((pos, index) => {
const circle = circleRefs.current[index];
if (!circle) return;
circle.setAttribute('cx', pos.x.toFixed(2));
circle.setAttribute('cy', pos.y.toFixed(2));
});
NETWORK_EDGES.forEach(([from, to], index) => {
const line = lineRefs.current[index];
if (!line) return;
line.setAttribute('x1', positions[from].x.toFixed(2));
line.setAttribute('y1', positions[from].y.toFixed(2));
line.setAttribute('x2', positions[to].x.toFixed(2));
line.setAttribute('y2', positions[to].y.toFixed(2));
});
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, []);
return (
<svg
className="pointer-events-none absolute inset-0 h-full w-full opacity-20"
viewBox="0 0 400 400"
fill="none"
aria-hidden="true"
>
<g stroke="white" strokeWidth="1.2" strokeLinecap="round">
{NETWORK_EDGES.map(([from, to], index) => (
<line
key={`${from}-${to}`}
ref={(element) => {
lineRefs.current[index] = element;
}}
x1={NETWORK_NODES[from].x}
y1={NETWORK_NODES[from].y}
x2={NETWORK_NODES[to].x}
y2={NETWORK_NODES[to].y}
/>
))}
</g>
<g fill="white">
{NETWORK_NODES.map((node, index) => (
<circle
key={`${node.x}-${node.y}`}
ref={(element) => {
circleRefs.current[index] = element;
}}
cx={node.x}
cy={node.y}
r={node.r}
/>
))}
</g>
</svg>
);
};
export const LoginScreen: React.FC = () => {
const { login } = useAuth();
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [config, setConfig] = useState<{ sso_enabled: boolean; sso_label: string; local_login: boolean } | null>(null);
const [showLocal, setShowLocal] = useState(false);
useEffect(() => {
authApi
.config()
.then(setConfig)
.catch(() => setConfig({ sso_enabled: false, sso_label: '', local_login: true }));
// Fehler aus dem SSO-Rueckweg anzeigen und aus der Adresse entfernen.
const params = new URLSearchParams(window.location.search);
const ssoError = params.get('sso_error');
if (ssoError) {
setError(ssoError);
window.history.replaceState(null, '', window.location.pathname);
}
}, []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await login(identifier, password);
} catch (err) {
setError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen.');
} finally {
setIsSubmitting(false);
}
};
const localVisible = !config?.sso_enabled || config.local_login || showLocal;
const field =
'w-full rounded-xl border border-ink-200 bg-surface px-4 py-3 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25';
return (
<div className="flex min-h-[100dvh] bg-surface">
<div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-brand-500 p-12 lg:flex">
<NetworkPattern />
<img src="/logo.png" alt="perinco" className="relative h-12 w-auto self-start brightness-0 invert" />
<div className="relative max-w-md">
<div className="mb-6 flex items-center gap-3 text-white">
<svg viewBox="0 0 32 32" className="h-12 w-12" aria-hidden>
<path d="M2 17h6l3-7 5 14 4-10 2 3h8" fill="none" stroke="currentColor" strokeWidth={2.6} strokeLinecap="round" strokeLinejoin="round" className="draw-line" style={{ '--len': 60 } as React.CSSProperties} />
</svg>
<h1 className="text-5xl leading-none text-white">Pulse</h1>
</div>
<p className="text-lg font-light text-white/85">
Zeiten buchen, Budgets im Blick, Kunden sauber abrechnen &ndash; der Herzschlag deiner Projekte.
</p>
</div>
<p className="relative text-xs font-semibold uppercase tracking-[0.2em] text-white/60">Performance Intelligence Consulting</p>
</div>
<div className="flex w-full flex-col justify-center px-6 py-12 sm:px-12 lg:w-1/2 lg:px-20">
<div className="mx-auto w-full max-w-sm">
<div className="mb-10 flex items-center justify-between lg:hidden">
<img src="/logo.png" alt="perinco" className="logo-adaptive h-10 w-auto" />
<span className="flex items-center gap-2 text-2xl font-extrabold text-ink-900">
<PulseMark size={32} />
Pulse
</span>
</div>
<h2 className="text-2xl text-ink-900">Anmelden</h2>
<p className="mt-2 text-sm text-ink-500">
{config?.sso_enabled ? 'Mit deinem perinco-Konto oder als externer Benutzer.' : 'Mit Benutzername oder E-Mail-Adresse.'}
</p>
{config?.sso_enabled && (
<a
href="/api/auth/sso/start"
className="mt-8 flex w-full items-center justify-center gap-3 rounded-xl border border-ink-200 bg-surface px-4 py-3 text-sm font-bold text-ink-800 shadow-card transition hover:border-brand-300"
>
<svg viewBox="0 0 21 21" className="h-5 w-5" aria-hidden>
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
</svg>
{config.sso_label || 'Mit Microsoft anmelden'}
</a>
)}
{config?.sso_enabled && localVisible && (
<div className="my-6 flex items-center gap-3 text-[11px] font-bold uppercase tracking-wider text-ink-300">
<span className="h-px flex-1 bg-ink-200" />
oder lokal
<span className="h-px flex-1 bg-ink-200" />
</div>
)}
{localVisible ? (
<form onSubmit={handleSubmit} className={`space-y-5 ${config?.sso_enabled ? '' : 'mt-8'}`}>
<div>
<label htmlFor="identifier" className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-ink-500">
Benutzername oder E-Mail
</label>
<input id="identifier" type="text" autoComplete="username" autoCapitalize="none" autoFocus required value={identifier} onChange={(e) => setIdentifier(e.target.value)} className={field} />
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-ink-500">
Passwort
</label>
<input id="password" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} className={field} />
</div>
{error && (
<div className="animate-pop rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">{error}</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-brand-500 px-4 py-3 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
>
{isSubmitting && <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />}
{isSubmitting ? 'Anmelden…' : 'Anmelden'}
</button>
</form>
) : (
<>
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">{error}</div>}
<button onClick={() => setShowLocal(true)} className="mt-6 text-xs font-bold text-ink-400 underline hover:text-ink-700">
Anmeldung für externe Benutzer &amp; lokale Konten
</button>
</>
)}
<p className="mt-10 text-xs text-ink-400">Probleme bei der Anmeldung? Wende dich an einen Administrator.</p>
</div>
</div>
</div>
);
};
+92
View File
@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { Icon } from '../ui/Icon';
import { Avatar } from '../ui/controls';
import { VIEW_ICONS } from './Sidebar';
import { useAuth } from '../../lib/auth';
import { ROLE_LABELS, VIEW_LABELS, viewsFor, type ViewKey } from '../../types';
/**
* Untere Navigationsleiste auf dem Handy - mit dem Daumen erreichbar. Passen
* nicht alle Punkte hinein, sammelt "Mehr" den Rest samt Konto und Abmelden.
*/
export const MobileNav: React.FC<{ current: ViewKey; onNavigate: (view: ViewKey) => void; onAccount: () => void }> = ({
current,
onNavigate,
onAccount,
}) => {
const { user, logout } = useAuth();
const [moreOpen, setMoreOpen] = useState(false);
if (!user) return null;
const views = viewsFor(user.role);
const primary = views.length <= 4 ? views : views.slice(0, 4);
const rest = views.length <= 4 ? [] : views.slice(4);
const moreActive = rest.includes(current);
const go = (view: ViewKey) => {
setMoreOpen(false);
onNavigate(view);
};
return (
<>
{moreOpen && (
<div className="animate-fade fixed inset-0 z-40 bg-ink-950/40 backdrop-blur-sm md:hidden" onClick={() => setMoreOpen(false)}>
<div
className="animate-sheet absolute inset-x-0 bottom-0 rounded-t-3xl border-t border-ink-200 bg-surface px-4 pb-24 pt-3 shadow-lift"
onClick={(e) => e.stopPropagation()}
>
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-ink-200" />
<button onClick={() => { setMoreOpen(false); onAccount(); }} className="mb-3 flex w-full items-center gap-3 rounded-2xl bg-ink-50 p-3 text-left">
<Avatar name={user.name} color={user.color} id={user.id} size={40} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-900">{user.name}</span>
<span className="block text-xs text-ink-400">{ROLE_LABELS[user.role]} · Mein Konto</span>
</span>
<Icon name="chevronRight" className="h-4 w-4 text-ink-400" />
</button>
<div className="grid grid-cols-3 gap-2">
{rest.map((view) => (
<button
key={view}
onClick={() => go(view)}
className={`flex flex-col items-center gap-1.5 rounded-2xl border p-3 text-xs font-bold ${
current === view ? 'border-brand-300 bg-brand-50 text-brand-700' : 'border-ink-200 text-ink-600'
}`}
>
<Icon name={VIEW_ICONS[view]} className="h-6 w-6" />
{VIEW_LABELS[view]}
</button>
))}
<button onClick={logout} className="flex flex-col items-center gap-1.5 rounded-2xl border border-ink-200 p-3 text-xs font-bold text-red-600">
<Icon name="logout" className="h-6 w-6" />
Abmelden
</button>
</div>
</div>
</div>
)}
<nav className="safe-bottom fixed inset-x-0 bottom-0 z-50 border-t border-ink-200 bg-surface/95 backdrop-blur md:hidden">
<div className="grid" style={{ gridTemplateColumns: `repeat(${primary.length + 1}, minmax(0, 1fr))` }}>
{primary.map((view) => {
const active = current === view;
return (
<button key={view} onClick={() => go(view)} className={`relative flex flex-col items-center gap-0.5 py-2.5 text-[10px] font-bold ${active ? 'text-brand-600' : 'text-ink-400'}`}>
{active && <span className="animate-pop absolute top-0 h-0.5 w-8 rounded-full bg-brand-500" />}
<Icon name={VIEW_ICONS[view]} className="h-6 w-6" strokeWidth={active ? 2.2 : 1.7} />
{VIEW_LABELS[view]}
</button>
);
})}
<button
onClick={() => (rest.length ? setMoreOpen((o) => !o) : onAccount())}
className={`flex flex-col items-center gap-0.5 py-2.5 text-[10px] font-bold ${moreActive || moreOpen ? 'text-brand-600' : 'text-ink-400'}`}
>
<Icon name={rest.length ? 'more' : 'user'} className="h-6 w-6" />
{rest.length ? 'Mehr' : 'Konto'}
</button>
</div>
</nav>
</>
);
};
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
/**
* Bildmarke von Pulse: eine Herzschlag-Linie, deren Ausschlag die gruene
* Verbindung aus dem perinco Logo aufnimmt. `bare` ohne Kachel.
*/
export const PulseMark: React.FC<{ size?: number; bare?: boolean; animated?: boolean }> = ({ size = 32, bare, animated = true }) => {
const line = (
<svg viewBox="0 0 32 32" width={bare ? size : size * 0.62} height={bare ? size : size * 0.62} aria-hidden>
<path
d="M2 17h6l3-7 5 14 4-10 2 3h8"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
className={animated ? 'draw-line' : undefined}
style={{ '--len': 60 } as React.CSSProperties}
/>
</svg>
);
if (bare) return <span className="inline-flex text-brand-600">{line}</span>;
return (
<span
className={`inline-flex shrink-0 items-center justify-center rounded-xl bg-brand-500 text-white shadow-card ${animated ? 'animate-glow' : ''}`}
style={{ width: size, height: size }}
>
{line}
</span>
);
};
+290
View File
@@ -0,0 +1,290 @@
import React, { useEffect, useRef } from 'react';
/*
* Das Wetter des Themes "Rainy": Nieselregen ueber dem Bildschirm und
* Wetterleuchten in den Wolken der oberen Leiste.
*
* Beides liegt ueber der ganzen Oberflaeche, faengt aber keinen Klick ab
* (pointer-events: none) und bleibt unter den Dialogen (z-[100]) - ein Tropfen,
* der ueber ein Formular laeuft, ist Stimmung, einer, der den Speichern-Knopf
* verdeckt, waere ein Fehler.
*
* Bewusst sparsam: es sind nie mehr als eine Handvoll Tropfen gleichzeitig
* unterwegs, und es blitzt nur alle paar Sekunden. Ein CRM, in dem es schuettet,
* lenkt ab. Wer im Betriebssystem "Bewegung reduzieren" eingestellt hat, bekommt
* weder Regen noch Blitz; die Wolken in der Leiste bleiben als Grafik stehen.
*
* Der Regen ist ein Canvas und keine CSS-Animation: der Aufprall haengt davon
* ab, wo der Tropfen unten ankommt, und die Spritzer fliegen mit Schwerkraft -
* beides ist in Keyframes nicht sauber zu haben.
*/
interface Drop {
x: number;
y: number;
/** Geschwindigkeit in px/s */
vx: number;
vy: number;
length: number;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
/** Restlebenszeit in s */
life: number;
maxLife: number;
size: number;
}
interface Ripple {
x: number;
y: number;
life: number;
maxLife: number;
width: number;
}
/** Abstand zwischen zwei neuen Tropfen in ms - daraus folgt "ein paar". */
const SPAWN_MIN_MS = 220;
const SPAWN_MAX_MS = 650;
/** Obergrenze, falls der Tab lange im Hintergrund war. */
const MAX_DROPS = 14;
const GRAVITY = 1400;
/** Pause zwischen zwei Blitzen in ms. */
const FLASH_MIN_MS = 7000;
const FLASH_MAX_MS = 18000;
function random(min: number, max: number) {
return min + Math.random() * (max - min);
}
function prefersReducedMotion() {
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
}
/** Farbe aus index.css (--rain-color), damit hell und dunkel passen. */
function rainColor() {
const value = getComputedStyle(document.documentElement).getPropertyValue('--rain-color').trim();
return value || 'rgb(120 140 165 / 0.5)';
}
export const RainyWeather: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const flashRef = useRef<HTMLDivElement>(null);
// --- Regen -----------------------------------------------------------------
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx || prefersReducedMotion()) return;
let width = 0;
let height = 0;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
width = window.innerWidth;
height = window.innerHeight;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
window.addEventListener('resize', resize);
const drops: Drop[] = [];
const particles: Particle[] = [];
const ripples: Ripple[] = [];
let nextSpawn = 0;
let last = performance.now();
let frame = 0;
// Die Farbe haengt am Modus; sie wird gelegentlich nachgelesen statt in
// jedem Frame, getComputedStyle ist nicht umsonst.
let color = rainColor();
let colorReadAt = 0;
const spawn = () => {
if (drops.length >= MAX_DROPS) return;
// Leichter Wind von rechts: die Tropfen fallen etwas schraeg.
const vy = random(620, 900);
drops.push({
x: random(0, width + 60),
y: random(-80, -20),
vx: -vy * 0.08,
vy,
length: random(9, 17),
});
};
const splash = (x: number) => {
const count = Math.round(random(3, 6));
for (let i = 0; i < count; i++) {
const life = random(0.25, 0.45);
particles.push({
x,
y: height - 1,
vx: random(-110, 110),
vy: -random(110, 240),
life,
maxLife: life,
size: random(0.8, 1.6),
});
}
const life = random(0.3, 0.45);
ripples.push({ x, y: height - 1.5, life, maxLife: life, width: random(10, 18) });
};
const tick = (now: number) => {
// Nach einer Pause im Hintergrund nicht alles auf einmal nachholen.
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (now >= nextSpawn) {
spawn();
nextSpawn = now + random(SPAWN_MIN_MS, SPAWN_MAX_MS);
}
if (now - colorReadAt > 500) {
color = rainColor();
colorReadAt = now;
}
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineCap = 'round';
// Tropfen
ctx.lineWidth = 1.2;
for (let i = drops.length - 1; i >= 0; i--) {
const drop = drops[i];
drop.x += drop.vx * dt;
drop.y += drop.vy * dt;
if (drop.y >= height) {
drops.splice(i, 1);
if (drop.x >= 0 && drop.x <= width) splash(drop.x);
continue;
}
const scale = drop.length / drop.vy;
ctx.beginPath();
ctx.moveTo(drop.x, drop.y);
ctx.lineTo(drop.x - drop.vx * scale, drop.y - drop.vy * scale);
ctx.stroke();
}
// Spritzer
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= dt;
if (p.life <= 0) {
particles.splice(i, 1);
continue;
}
p.vy += GRAVITY * dt;
p.x += p.vx * dt;
p.y = Math.min(p.y + p.vy * dt, height - 0.5);
ctx.globalAlpha = p.life / p.maxLife;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
// Kleiner Ring am Boden, wo der Tropfen aufgekommen ist
ctx.lineWidth = 1;
for (let i = ripples.length - 1; i >= 0; i--) {
const r = ripples[i];
r.life -= dt;
if (r.life <= 0) {
ripples.splice(i, 1);
continue;
}
const progress = 1 - r.life / r.maxLife;
ctx.globalAlpha = (1 - progress) * 0.8;
ctx.beginPath();
ctx.ellipse(r.x, r.y, 2 + r.width * progress, 1 + 1.5 * progress, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', resize);
};
}, []);
// --- Wetterleuchten --------------------------------------------------------
useEffect(() => {
const flash = flashRef.current;
if (!flash || prefersReducedMotion() || typeof flash.animate !== 'function') return;
let timer = 0;
let cancelled = false;
const schedule = () => {
if (cancelled) return;
timer = window.setTimeout(strike, random(FLASH_MIN_MS, FLASH_MAX_MS));
};
const strike = () => {
/*
* Die Leiste wird bei jedem Blitz neu vermessen statt einmal: sie ist
* nicht immer da (Anmeldeseite) und aendert ihre Breite mit der
* Seitenleiste. Das Overlay bekommt exakt ihre Masse, damit die Maske
* (dieselbe SVG wie das Hintergrundbild, gleich skaliert) auf den Wolken
* sitzt.
*/
const header = document.querySelector<HTMLElement>('.app-header');
if (!header || document.hidden) {
schedule();
return;
}
const rect = header.getBoundingClientRect();
flash.style.left = `${rect.left}px`;
flash.style.top = `${rect.top}px`;
flash.style.width = `${rect.width}px`;
flash.style.height = `${rect.height}px`;
// Nur in der Mitte, wo der Schleier die Wolken durchlaesst - am Rand
// stuende der Blitz auf dem Titel bzw. den Schaltern.
flash.style.setProperty('--flash-x', `${random(28, 72)}%`);
// Kurz, zurueck, noch einmal heller, langes Ausklingen - so flackert
// Wetterleuchten.
const animation = flash.animate(
[
{ opacity: 0 },
{ opacity: 0.75, offset: 0.06 },
{ opacity: 0.15, offset: 0.14 },
{ opacity: 1, offset: 0.22 },
{ opacity: 0.35, offset: 0.45 },
{ opacity: 0 },
],
{ duration: 900, easing: 'ease-out' }
);
animation.onfinish = schedule;
};
// Der erste Blitz kommt frueher, damit man das Theme beim Umschalten erlebt.
timer = window.setTimeout(strike, random(1500, 3500));
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, []);
return (
<>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-[90] h-full w-full"
/>
<div ref={flashRef} aria-hidden="true" className="rainy-flash pointer-events-none fixed z-[90]" />
</>
);
};
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import { Icon, type IconName } from '../ui/Icon';
import { Avatar } from '../ui/controls';
import { PulseMark } from './PulseMark';
import { useAuth } from '../../lib/auth';
import { ROLE_LABELS, VIEW_LABELS, homeFor, viewsFor, type ViewKey } from '../../types';
export const VIEW_ICONS: Record<ViewKey, IconName> = {
dashboard: 'dashboard',
buchung: 'clock',
projekte: 'folder',
team: 'team',
kunden: 'building',
reports: 'chart',
einstellungen: 'settings',
};
interface SidebarProps {
current: ViewKey;
onNavigate: (view: ViewKey) => void;
collapsed: boolean;
onAccount: () => void;
}
/** Seitenleiste fuer Desktop/Tablet. Auf dem Handy uebernimmt die MobileNav. */
export const Sidebar: React.FC<SidebarProps> = ({ current, onNavigate, collapsed, onAccount }) => {
const { user, logout } = useAuth();
if (!user) return null;
const views = viewsFor(user.role);
const main = views.filter((v) => v !== 'einstellungen');
const home = homeFor(user.role);
const item = (view: ViewKey) => {
const active = current === view;
return (
<button
key={view}
onClick={() => onNavigate(view)}
title={collapsed ? VIEW_LABELS[view] : undefined}
aria-current={active ? 'page' : undefined}
className={`group relative flex w-full items-center rounded-xl transition ${collapsed ? 'justify-center py-2.5' : 'gap-3 px-3 py-2.5'} ${
active ? 'bg-brand-500 text-white shadow-card' : 'text-ink-600 hover:bg-brand-50 hover:text-brand-700'
}`}
>
<Icon name={VIEW_ICONS[view]} className="h-5 w-5 shrink-0" strokeWidth={active ? 2.2 : 1.7} />
{!collapsed && <span className="truncate text-sm font-bold">{VIEW_LABELS[view]}</span>}
</button>
);
};
return (
<aside
className={`app-sidebar hidden h-full shrink-0 flex-col overflow-hidden border-r border-ink-200 bg-surface transition-[width] duration-300 md:flex ${
collapsed ? 'w-[76px]' : 'w-64'
}`}
>
<button
onClick={() => onNavigate(home)}
className={`flex shrink-0 items-center border-b border-ink-100 transition hover:bg-ink-50 ${collapsed ? 'justify-center py-5' : 'gap-3 px-5 py-4'}`}
title="Zur Startseite"
>
{collapsed ? (
<PulseMark size={34} />
) : (
<div className="flex w-full items-center justify-between gap-2">
<img src="/logo.png" alt="perinco" className="logo-adaptive h-9 w-auto" />
<span className="flex items-center gap-1.5 rounded-full bg-brand-50 px-2.5 py-1 text-xs font-extrabold tracking-wide text-brand-700">
<PulseMark size={14} bare />
Pulse
</span>
</div>
)}
</button>
<nav className="scrollbar-slim flex-1 space-y-1 overflow-y-auto px-3 py-4">
{!collapsed && <p className="mb-2 px-3 text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400">Navigation</p>}
{main.map(item)}
</nav>
{views.includes('einstellungen') && <div className="shrink-0 border-t border-ink-100 px-3 py-3">{item('einstellungen')}</div>}
<div className={`shrink-0 border-t border-ink-100 ${collapsed ? 'flex flex-col items-center gap-2 p-3' : 'p-4'}`}>
<div className="flex w-full min-w-0 items-center justify-between gap-2">
<button onClick={onAccount} className="flex min-w-0 items-center gap-3 rounded-xl text-left transition hover:opacity-80" title="Mein Konto">
<Avatar name={user.name} color={user.color} id={user.id} size={36} external={user.is_external} />
{!collapsed && (
<span className="min-w-0">
<span className="block truncate text-sm font-bold text-ink-900">{user.name}</span>
<span className="block truncate text-xs text-ink-400">{ROLE_LABELS[user.role]}</span>
</span>
)}
</button>
{!collapsed && (
<button onClick={logout} title="Abmelden" className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600">
<Icon name="logout" className="h-4 w-4" strokeWidth={2} />
</button>
)}
</div>
{collapsed && (
<button onClick={logout} title="Abmelden" className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600">
<Icon name="logout" className="h-4 w-4" strokeWidth={2} />
</button>
)}
</div>
</aside>
);
};
+424
View File
@@ -0,0 +1,424 @@
import React, { useEffect, useRef } from 'react';
/*
* Das Wetter des Themes "Snow": Flocken fallen, sammeln sich am unteren Rand zu
* einem Haufen - und alle vier Minuten fährt ein Schneepflug durch und schiebt
* ihn zur Seite.
*
* Alles liegt über der Oberfläche, fängt aber keinen Klick ab
* (pointer-events: none) und bleibt unter den Dialogen (z-[90] gegen z-[100]).
* Wer im Betriebssystem "Bewegung reduzieren" eingestellt hat, bekommt gar
* keine Animation; die Winterlandschaft in den Leisten bleibt als Grafik stehen.
*
* Der Haufen ist ein Höhenfeld: je 3 px eine Spalte, in der die Höhe steht.
* Deshalb kann eine Flocke dort liegen bleiben, wo sie aufkommt, der Haufen
* fließt langsam flach (Glättung) und der Pflug kann ein Stück davon leer
* räumen.
*/
/** Breite einer Spalte des Höhenfelds in px. */
const COLUMN = 3;
/** Höchster Schnee in px - sonst wächst er irgendwann ins Bild. */
const MAX_PILE = 26;
/** Wie schnell der Haufen flach fließt. */
const SMOOTHING = 6;
/** Abstand zwischen zwei Flocken in ms. */
const SPAWN_MIN_MS = 70;
const SPAWN_MAX_MS = 160;
/** Obergrenze gleichzeitiger Flocken. */
const MAX_FLAKES = 70;
/** Pause zwischen zwei Durchfahrten des Pflugs. */
const PLOW_INTERVAL_MS = 240 * 1000;
/** Die erste Fahrt kommt früher, damit man das Theme beim Umschalten erlebt. */
const PLOW_FIRST_MS = 2500;
/** Fahrgeschwindigkeit in px/s. */
const PLOW_SPEED = 140;
/** Abstand Fahrzeugmitte zur Schaufel bzw. halbe Fahrzeuglänge. */
const PLOW_BLADE = 38;
const PLOW_HALF = 36;
interface Flake {
x: number;
y: number;
radius: number;
/** Fallgeschwindigkeit in px/s */
vy: number;
/** Phase und Weite des Hin- und Herwehens */
phase: number;
sway: number;
}
interface Spray {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
}
interface Plow {
x: number;
/** 1 = fährt nach rechts, -1 = nach links */
dir: 1 | -1;
/** Wie viel Schnee schon vor der Schaufel liegt */
pushed: number;
}
function random(min: number, max: number) {
return min + Math.random() * (max - min);
}
function prefersReducedMotion() {
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
}
/** Farben aus index.css (--snow-*), damit hell und dunkel passen. */
function snowColors() {
const style = getComputedStyle(document.documentElement);
const read = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback;
return {
flake: read('--snow-flake', '#ffffff'),
flakeEdge: read('--snow-flake-edge', 'rgb(120 145 170 / 0.35)'),
pile: read('--snow-pile', '#ffffff'),
pileShade: read('--snow-pile-shade', '#dbe6f1'),
pileEdge: read('--snow-pile-edge', 'rgb(120 145 170 / 0.5)'),
};
}
export const SnowWeather: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx || prefersReducedMotion()) return;
let width = 0;
let height = 0;
/** Höhe des Schnees je Spalte. */
let pile = new Float32Array(0);
const resize = () => {
const dpr = window.devicePixelRatio || 1;
width = window.innerWidth;
height = window.innerHeight;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Vorhandenen Schnee auf die neue Breite umlegen statt wegzuwerfen.
const next = new Float32Array(Math.ceil(width / COLUMN) + 1);
if (pile.length > 0) {
for (let i = 0; i < next.length; i++) {
next[i] = pile[Math.min(pile.length - 1, Math.floor((i / next.length) * pile.length))];
}
}
pile = next;
};
resize();
window.addEventListener('resize', resize);
const flakes: Flake[] = [];
const spray: Spray[] = [];
let nextSpawn = 0;
let last = performance.now();
let frame = 0;
let colors = snowColors();
let colorsReadAt = 0;
let plow: Plow | null = null;
let nextDir: 1 | -1 = 1;
let nextPlowAt = performance.now() + PLOW_FIRST_MS;
const spawn = () => {
if (flakes.length >= MAX_FLAKES) return;
const radius = random(1, 3);
flakes.push({
x: random(-20, width + 20),
y: -radius - random(0, 30),
radius,
// Große Flocken fallen etwas schneller.
vy: random(28, 45) + radius * 14,
phase: random(0, Math.PI * 2),
sway: random(8, 22),
});
};
/** Flocke bleibt liegen: verteilt ihr Volumen auf fünf Spalten. */
const land = (x: number, radius: number) => {
const center = Math.round(x / COLUMN);
const amount = radius * 5;
const add = (index: number, value: number) => {
if (index < 0 || index >= pile.length) return;
pile[index] = Math.min(MAX_PILE, pile[index] + value);
};
add(center, amount * 0.36);
add(center - 1, amount * 0.24);
add(center + 1, amount * 0.24);
add(center - 2, amount * 0.08);
add(center + 2, amount * 0.08);
};
let buffer = new Float32Array(0);
/** Der Haufen fließt langsam flach, sonst stehen dort Zacken. */
const smooth = (dt: number) => {
if (buffer.length !== pile.length) buffer = new Float32Array(pile.length);
const rate = Math.min(0.25, SMOOTHING * dt);
const last_ = pile.length - 1;
for (let i = 0; i <= last_; i++) {
const left = pile[i > 0 ? i - 1 : i];
const right = pile[i < last_ ? i + 1 : i];
buffer[i] = pile[i] + rate * (left + right - 2 * pile[i]);
}
pile.set(buffer);
};
const drawPile = () => {
let highest = 0;
for (let i = 0; i < pile.length; i++) highest = Math.max(highest, pile[i]);
if (highest < 0.3) return;
const outline = () => {
ctx.moveTo(0, height - pile[0]);
for (let i = 1; i < pile.length; i++) {
const from = (i - 1) * COLUMN;
const to = i * COLUMN;
ctx.quadraticCurveTo(from, height - pile[i - 1], (from + to) / 2, height - (pile[i - 1] + pile[i]) / 2);
}
};
ctx.beginPath();
ctx.moveTo(0, height);
outline();
ctx.lineTo(width, height - pile[pile.length - 1]);
ctx.lineTo(width, height);
ctx.closePath();
const gradient = ctx.createLinearGradient(0, height - highest, 0, height);
gradient.addColorStop(0, colors.pile);
gradient.addColorStop(1, colors.pileShade);
ctx.fillStyle = gradient;
ctx.fill();
ctx.beginPath();
outline();
ctx.strokeStyle = colors.pileEdge;
ctx.lineWidth = 1;
ctx.stroke();
};
/** Der Schneeberg, den die Schaufel vor sich herschiebt. */
const drawPushedHeap = (x: number, dir: number, pushed: number) => {
if (pushed < 1) return;
const heapHeight = Math.min(MAX_PILE + 8, 6 + Math.sqrt(pushed) * 1.2);
const heapWidth = heapHeight * 1.6;
ctx.beginPath();
ctx.moveTo(x - dir * 2, height);
ctx.lineTo(x - dir * 2, height - heapHeight);
ctx.quadraticCurveTo(x + dir * heapWidth * 0.55, height - heapHeight * 1.05, x + dir * heapWidth, height);
ctx.closePath();
const gradient = ctx.createLinearGradient(0, height - heapHeight, 0, height);
gradient.addColorStop(0, colors.pile);
gradient.addColorStop(1, colors.pileShade);
ctx.fillStyle = gradient;
ctx.fill();
ctx.strokeStyle = colors.pileEdge;
ctx.lineWidth = 1;
ctx.stroke();
};
/** Der Pflug selbst: Kabine, Schaufel und blinkendes Rundumlicht. */
const drawPlow = (x: number, dir: number, seconds: number) => {
ctx.save();
ctx.translate(x, height);
ctx.scale(dir, 1);
const blinkOn = Math.sin(seconds * 9) > 0;
if (blinkOn) {
const glow = ctx.createRadialGradient(-15, -41, 0, -15, -41, 16);
glow.addColorStop(0, 'rgb(255 170 40 / 0.7)');
glow.addColorStop(1, 'rgb(255 170 40 / 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(-15, -41, 16, 0, Math.PI * 2);
ctx.fill();
}
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgb(38 62 26 / 0.6)';
ctx.lineWidth = 1;
// Auspuff, Pritsche, Motorhaube, Kabine - in perinco-Grün.
ctx.fillStyle = '#5b6470';
ctx.fillRect(18, -15, 14, 4);
ctx.fillStyle = '#6ba04a';
ctx.beginPath();
ctx.roundRect(-PLOW_HALF, -21, 56, 14, 3);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.roundRect(-4, -28, 24, 10, [5, 3, 0, 0]);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.roundRect(-31, -38, 27, 19, [3, 3, 0, 0]);
ctx.fill();
ctx.stroke();
// Fensterscheibe, Kühlergrill, Schneekappe auf dem Dach
ctx.fillStyle = '#bfe3f5';
ctx.beginPath();
ctx.roundRect(-25, -35, 17, 10, 2);
ctx.fill();
ctx.fillStyle = '#2b2f36';
for (let i = 0; i < 4; i++) ctx.fillRect(-30 + i * 12, -12, 5, 3);
ctx.fillStyle = colors.pile;
ctx.beginPath();
ctx.ellipse(-19, -38, 11, 2.5, 0, Math.PI, Math.PI * 2);
ctx.fill();
// Rundumlicht
ctx.fillStyle = blinkOn ? '#ffb020' : '#b86e00';
ctx.beginPath();
ctx.roundRect(-18, -43, 6, 5, [2, 2, 0, 0]);
ctx.fill();
// Räder
for (const wheelX of [-22, 10]) {
ctx.fillStyle = '#2b2f36';
ctx.beginPath();
ctx.arc(wheelX, -6, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#9aa3ad';
ctx.beginPath();
ctx.arc(wheelX, -6, 2, 0, Math.PI * 2);
ctx.fill();
}
// Schaufel
ctx.fillStyle = '#c9d1d9';
ctx.beginPath();
ctx.moveTo(28, -24);
ctx.lineTo(33, -25);
ctx.lineTo(PLOW_BLADE, 0);
ctx.lineTo(29, 0);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
};
/** Fahrt starten, Strecke räumen, Schneestaub aufwirbeln. */
const movePlow = (now: number, dt: number) => {
if (!plow) {
if (now < nextPlowAt) return;
const dir = nextDir;
plow = { x: dir === 1 ? -PLOW_BLADE - 40 : width + PLOW_BLADE + 40, dir, pushed: 0 };
// Beim nächsten Mal aus der anderen Richtung.
nextDir = dir === 1 ? -1 : 1;
nextPlowAt = now + PLOW_INTERVAL_MS;
}
plow.x += plow.dir * PLOW_SPEED * dt;
const blade = plow.x + plow.dir * PLOW_BLADE;
const from = plow.dir === 1 ? plow.x - PLOW_HALF - COLUMN * 4 : blade;
const to = plow.dir === 1 ? blade : plow.x + PLOW_HALF + COLUMN * 4;
const firstColumn = Math.max(0, Math.ceil(from / COLUMN));
const lastColumn = Math.min(pile.length - 1, Math.floor(to / COLUMN));
let cleared = 0;
for (let i = firstColumn; i <= lastColumn; i++) {
cleared += pile[i];
pile[i] = 0;
}
plow.pushed += cleared;
if (plow.pushed > 5 && blade > 0 && blade < width && Math.random() < dt * 14) {
spray.push({
x: blade,
y: height - Math.min(20, 2 + Math.sqrt(plow.pushed)),
vx: plow.dir * random(40, 120),
vy: -random(60, 140),
radius: random(1.2, 2.6),
});
}
const gone = plow.dir === 1 ? plow.x - PLOW_HALF > width + 60 : plow.x + PLOW_HALF < -60;
if (gone) plow = null;
};
const drawSpray = (dt: number) => {
ctx.fillStyle = colors.flake;
ctx.strokeStyle = colors.flakeEdge;
ctx.lineWidth = 0.6;
for (let i = spray.length - 1; i >= 0; i--) {
const particle = spray[i];
particle.vy += 600 * dt;
particle.x += particle.vx * dt;
particle.y += particle.vy * dt;
if (particle.y >= height) {
spray.splice(i, 1);
continue;
}
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
};
const tick = (now: number) => {
// Nach einer Pause im Hintergrund nicht alles auf einmal nachholen.
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
// Die Farben hängen am Modus; getComputedStyle ist nicht umsonst.
if (now - colorsReadAt > 500) {
colors = snowColors();
colorsReadAt = now;
}
if (now >= nextSpawn) {
spawn();
nextSpawn = now + random(SPAWN_MIN_MS, SPAWN_MAX_MS);
}
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = colors.flake;
ctx.strokeStyle = colors.flakeEdge;
ctx.lineWidth = 0.6;
const seconds = now / 1000;
for (let i = flakes.length - 1; i >= 0; i--) {
const flake = flakes[i];
flake.y += flake.vy * dt;
const x = flake.x + Math.sin(seconds * 1.3 + flake.phase) * flake.sway;
const surface = height - (pile[Math.round(x / COLUMN)] ?? 0);
if (flake.y + flake.radius >= surface) {
flakes.splice(i, 1);
if (x >= 0 && x <= width) land(x, flake.radius);
continue;
}
ctx.beginPath();
ctx.arc(x, flake.y, flake.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
smooth(dt);
movePlow(now, dt);
drawPile();
if (plow) {
drawPushedHeap(plow.x + plow.dir * PLOW_BLADE, plow.dir, plow.pushed);
drawPlow(plow.x, plow.dir, seconds);
}
drawSpray(dt);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', resize);
};
}, []);
return <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none fixed inset-0 z-[90] h-full w-full" />;
};
+441
View File
@@ -0,0 +1,441 @@
import React, { useState } from 'react';
import { Avatar, Badge, BudgetBar, Button, Card, IconButton, inputClass } from '../ui/controls';
import { ConfirmDialog, Modal } from '../ui/Modal';
import { Icon } from '../ui/Icon';
import { PotFields, emptyPot, potPayload, type PotDraft } from './ProjectForm';
import { TYPE_TONE } from './Projects';
import { useAuth } from '../../lib/auth';
import { projectsApi } from '../../lib/api';
import { formatDate, formatEuro, formatHours, formatNumber } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { useToast } from '../Toast';
import { PROJECT_TYPE_LABELS, type BudgetPot, type DirectoryUser, type Project } from '../../types';
const POT_STATUS: Record<BudgetPot['status'], { label: string; tone: 'brand' | 'neutral' | 'amber' | 'red' | 'sky' }> = {
active: { label: 'aktiv', tone: 'brand' },
upcoming: { label: 'geplant', tone: 'sky' },
expired: { label: 'abgelaufen', tone: 'neutral' },
exhausted: { label: 'aufgebraucht', tone: 'red' },
};
interface Props {
project: Project;
canEdit: boolean;
users: DirectoryUser[];
onChanged: (project: Project) => void;
onEdit: () => void;
onDeleted: () => void;
onClose: () => void;
}
/** Aufgeklappte Projektansicht unter der Karte: Toepfe, Tasks, Team. */
export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onChanged, onEdit, onDeleted, onClose }) => {
const { settings } = useAuth();
const { addToast } = useToast();
const { navigate } = useRouter();
const [potDialog, setPotDialog] = useState<{ pot: BudgetPot | null } | null>(null);
const [membersOpen, setMembersOpen] = useState(false);
const [newTask, setNewTask] = useState('');
const [confirm, setConfirm] = useState<{ title: string; message: string; action: () => Promise<unknown>; label?: string } | null>(null);
const hpd = settings.hours_per_day;
const run = async (action: () => Promise<Project | { ok: true }>, success?: string) => {
try {
const result = await action();
if ('id' in result) onChanged(result);
if (success) addToast(success, 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Aktion fehlgeschlagen.', 'error');
}
};
const addTask = async () => {
if (!newTask.trim()) return;
await run(() => projectsApi.addTask(p.id, { name: newTask.trim(), billable: p.type !== 'non_billable' }));
setNewTask('');
};
const maxTask = Math.max(1, ...p.tasks.map((t) => t.used_minutes));
const totalPotHours = p.pots.reduce((s, pot) => s + (pot.budget_hours ?? 0), 0);
return (
<Card className="animate-pop overflow-hidden border-brand-200">
<div className="flex flex-wrap items-start gap-3 border-b border-ink-100 bg-brand-50/40 p-4 sm:p-5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={TYPE_TONE[p.type]}>
{PROJECT_TYPE_LABELS[p.type]}
{p.type === 'billable' && ` · pro ${p.billing_unit === 'day' ? 'Tag' : 'Stunde'}`}
</Badge>
<span className="tabular text-xs font-bold text-ink-400">{p.code}</span>
{p.tags.map((t) => (
<Badge key={t}>
<Icon name="tag" className="h-3 w-3" />
{t}
</Badge>
))}
</div>
<h2 className="mt-1 text-xl text-ink-900">{p.display_name}</h2>
<p className="mt-1 text-xs text-ink-500">
{p.customer.name} · {formatDate(p.start_date)} – {p.end_date ? formatDate(p.end_date) : 'offen'}
{p.owner_name && ` · verantwortlich: ${p.owner_name}`}
</p>
{p.notes && <p className="mt-2 whitespace-pre-line text-sm text-ink-600">{p.notes}</p>}
</div>
<div className="flex flex-wrap items-center gap-2">
{canEdit && (
<>
<Button size="sm" variant="secondary" icon="chart" onClick={() => navigate(`reports/projekt/${p.id}`)}>
Report
</Button>
<Button size="sm" variant="secondary" icon="edit" onClick={onEdit}>
Bearbeiten
</Button>
<Button
size="sm"
variant="secondary"
icon="archive"
onClick={() => run(() => projectsApi.update(p.id, { is_active: !p.is_active }), p.is_active ? 'Projekt archiviert.' : 'Projekt reaktiviert.')}
>
{p.is_active ? 'Archivieren' : 'Reaktivieren'}
</Button>
<IconButton
icon="trash"
tone="danger"
label="Projekt löschen"
onClick={() =>
setConfirm({
title: 'Projekt löschen?',
message: 'Nur möglich, solange keine Zeiten gebucht sind. Sonst bitte archivieren.',
action: async () => {
await projectsApi.remove(p.id);
addToast('Projekt gelöscht.', 'success');
onDeleted();
},
})
}
/>
</>
)}
<IconButton icon="close" label="Schließen" onClick={onClose} />
</div>
</div>
<div className="grid gap-0 lg:grid-cols-2">
{/* Budgettoepfe */}
<section className="border-b border-ink-100 p-4 sm:p-5 lg:border-b-0 lg:border-r">
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm text-ink-900">
<Icon name="pot" className="h-4 w-4 text-brand-600" />
Budgettöpfe
</h3>
{canEdit && (
<Button size="sm" variant="secondary" icon="plus" onClick={() => setPotDialog({ pot: null })}>
Topf
</Button>
)}
</div>
{p.pots.length > 1 && (
<div className="mb-3 rounded-xl bg-ink-50 p-3">
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Gesamt über alle Töpfe</p>
<div className="mt-1 flex items-baseline justify-between">
<span className="tabular text-sm font-extrabold text-ink-900">
{formatHours(p.used_minutes, 1)} h {totalPotHours ? `von ${formatNumber(totalPotHours, 0)} h` : ''}
</span>
{canEdit && p.budget_value ? (
<span className="tabular text-xs font-bold text-ink-500">
{formatEuro(p.earned)} erbracht · {formatEuro(p.open)} offen
</span>
) : null}
</div>
<BudgetBar className="mt-2" percent={totalPotHours ? (p.budget_used_minutes / 60 / totalPotHours) * 100 : null} warnAt={settings.budget_warn_percent} />
</div>
)}
<ul className="space-y-2">
{p.pots.map((pot, i) => (
<li key={pot.id} className="animate-rise rounded-xl border border-ink-200 p-3" style={{ '--i': i } as React.CSSProperties}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-2 text-sm font-extrabold text-ink-900">
{pot.label}
<Badge tone={POT_STATUS[pot.status].tone}>{POT_STATUS[pot.status].label}</Badge>
</p>
<p className="text-xs text-ink-400">
{formatDate(pot.valid_from)} – {formatDate(pot.valid_to)}
{pot.status === 'active' && ` · noch ${pot.days_left} Tage`}
</p>
</div>
{canEdit && (
<div className="flex shrink-0">
<IconButton icon="edit" label="Topf bearbeiten" onClick={() => setPotDialog({ pot })} />
<IconButton
icon="trash"
tone="danger"
label="Topf löschen"
onClick={() =>
setConfirm({
title: `Topf „${pot.label}“ löschen?`,
message: 'Nur möglich, wenn noch nichts darauf gebucht ist.',
action: () => run(() => projectsApi.removePot(p.id, pot.id), 'Topf gelöscht.'),
})
}
/>
</div>
)}
</div>
<BudgetBar className="mt-2" percent={pot.percent_used} warnAt={settings.budget_warn_percent} muted={pot.status === 'expired'} />
<div className="tabular mt-1.5 flex flex-wrap justify-between gap-x-3 text-[11px] font-bold text-ink-500">
<span>
{formatHours(pot.used_minutes, 1)} h / {formatNumber(pot.used_hours / hpd, 1)} T gebucht
</span>
<span>
{pot.budget_hours != null
? `${formatNumber(pot.remaining_hours ?? 0, 1)} h / ${formatNumber((pot.remaining_hours ?? 0) / hpd, 1)} T frei von ${formatNumber(pot.budget_hours, 0)} h`
: 'unbegrenzt'}
</span>
</div>
{canEdit && (pot.budget_value || pot.rate) && (
<div className="tabular mt-2 grid grid-cols-3 gap-2 text-[11px]">
<Kv label="Budget" value={formatEuro(pot.budget_value)} />
<Kv
label={p.type === 'fixed' ? 'virt. Stundensatz' : p.billing_unit === 'day' ? 'Tagessatz' : 'Stundensatz'}
value={p.type === 'fixed' ? (pot.virtual_rate ? formatEuro(pot.virtual_rate, 2) : '–') : formatEuro(pot.rate, 2)}
/>
<Kv label="Offen" value={formatEuro(pot.open)} />
</div>
)}
{pot.warning && pot.status !== 'expired' && (
<p className="mt-2 flex items-center gap-1.5 text-[11px] font-semibold text-amber-700">
<Icon name="warning" className="h-3.5 w-3.5" />
{pot.warning}
</p>
)}
</li>
))}
</ul>
</section>
{/* Tasks + Team */}
<section className="p-4 sm:p-5">
<h3 className="mb-3 flex items-center gap-2 text-sm text-ink-900">
<Icon name="list" className="h-4 w-4 text-brand-600" />
Tasks
</h3>
<ul className="space-y-1">
{p.tasks.length === 0 && <li className="text-xs text-ink-400">Noch keine Tasks – gebucht wird dann direkt aufs Projekt.</li>}
{p.tasks.map((t, i) => (
<li key={t.id} className={`animate-rise group flex items-center gap-3 rounded-xl px-2 py-1.5 hover:bg-ink-50 ${t.is_active ? '' : 'opacity-50'}`} style={{ '--i': i } as React.CSSProperties}>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-bold text-ink-800">
{t.name}
{!t.is_active && <span className="ml-1 text-[10px] text-ink-400">(inaktiv)</span>}
</span>
<span className="tabular shrink-0 text-xs font-extrabold text-ink-600">{formatHours(t.used_minutes, 1)} h</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-ink-100">
<div className="h-full rounded-full bg-brand-400 transition-[width] duration-700" style={{ width: `${(t.used_minutes / maxTask) * 100}%` }} />
</div>
</div>
{p.type !== 'non_billable' && (
<button
disabled={!canEdit}
onClick={() => run(() => projectsApi.updateTask(p.id, t.id, { billable: !t.billable }))}
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${t.billable ? 'bg-brand-50 text-brand-700' : 'bg-ink-100 text-ink-500'}`}
title={canEdit ? 'Abrechenbar umschalten' : undefined}
>
{t.billable ? 'billable' : 'non-billable'}
</button>
)}
{canEdit && (
<div className="flex shrink-0 opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
<IconButton
icon="edit"
label="Umbenennen"
onClick={() => {
const name = window.prompt('Neuer Taskname', t.name);
if (name && name.trim() !== t.name) run(() => projectsApi.updateTask(p.id, t.id, { name: name.trim() }));
}}
/>
{!t.is_active ? (
<IconButton icon="refresh" label="Reaktivieren" onClick={() => run(() => projectsApi.updateTask(p.id, t.id, { is_active: true }))} />
) : (
<IconButton icon="trash" tone="danger" label="Entfernen" onClick={() => run(() => projectsApi.removeTask(p.id, t.id), t.used_minutes ? 'Task deaktiviert (hat Buchungen).' : 'Task entfernt.')} />
)}
</div>
)}
</li>
))}
</ul>
{canEdit && (
<div className="mt-2 flex gap-2">
<input className={`${inputClass} py-2`} value={newTask} placeholder="Neuer Task, z. B. Administration Server" onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTask()} />
<Button size="sm" icon="plus" onClick={addTask} disabled={!newTask.trim()}>
Hinzufügen
</Button>
</div>
)}
<div className="mb-3 mt-6 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm text-ink-900">
<Icon name="team" className="h-4 w-4 text-brand-600" />
Zugewiesen ({p.members.length})
</h3>
{canEdit && (
<Button size="sm" variant="secondary" icon="edit" onClick={() => setMembersOpen(true)}>
Zuweisen
</Button>
)}
</div>
<div className="flex flex-wrap gap-2">
{p.members.map((m) => (
<span key={m.id} className="flex items-center gap-2 rounded-full border border-ink-200 py-1 pl-1 pr-3 text-xs font-bold text-ink-700">
<Avatar id={m.id} name={m.name} color={m.color} size={22} external={m.is_external} />
{m.name}
</span>
))}
{p.members.length === 0 && <p className="text-xs text-ink-400">Noch niemand zugewiesen – niemand kann buchen.</p>}
</div>
</section>
</div>
{potDialog && <PotDialog project={p} pot={potDialog.pot} onClose={() => setPotDialog(null)} onSaved={onChanged} />}
{membersOpen && <MembersDialog project={p} users={users} onClose={() => setMembersOpen(false)} onSaved={onChanged} />}
{confirm && (
<ConfirmDialog
title={confirm.title}
message={confirm.message}
confirmLabel={confirm.label}
onCancel={() => setConfirm(null)}
onConfirm={async () => {
const current = confirm;
setConfirm(null);
try {
await current.action();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Aktion fehlgeschlagen.', 'error');
}
}}
/>
)}
</Card>
);
};
const Kv: React.FC<{ label: string; value: string }> = ({ label, value }) => (
<div className="rounded-lg bg-ink-50 px-2 py-1">
<p className="text-[9px] font-bold uppercase tracking-wider text-ink-400">{label}</p>
<p className="font-extrabold text-ink-800">{value}</p>
</div>
);
const PotDialog: React.FC<{ project: Project; pot: BudgetPot | null; onClose: () => void; onSaved: (p: Project) => void }> = ({ project, pot, onClose, onSaved }) => {
const { settings } = useAuth();
const { addToast } = useToast();
const [draft, setDraft] = useState<PotDraft>(() =>
pot
? {
label: pot.label,
valid_from: pot.valid_from,
valid_to: pot.valid_to,
budget_amount: pot.budget_amount == null ? '' : String(pot.budget_amount).replace('.', ','),
rate: pot.rate == null ? '' : String(pot.rate).replace('.', ','),
budget_hours: pot.budget_hours == null ? '' : String(pot.budget_hours).replace('.', ','),
budget_unit: 'hours',
notes: pot.notes ?? '',
}
: emptyPot(project.pots.length ? project.pots[project.pots.length - 1].valid_to : project.start_date, `Budget ${project.pots.length + 1}`)
);
const [busy, setBusy] = useState(false);
const save = async () => {
setBusy(true);
try {
const payload = potPayload(draft, settings.hours_per_day);
const saved = pot ? await projectsApi.updatePot(project.id, pot.id, payload) : await projectsApi.addPot(project.id, payload);
addToast(pot ? 'Topf gespeichert – Buchungen neu zugeordnet.' : 'Topf angelegt.', 'success');
onSaved(saved);
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
return (
<Modal
title={pot ? 'Budgettopf bearbeiten' : 'Neuer Budgettopf'}
subtitle={project.display_name}
onClose={onClose}
size="lg"
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="check" busy={busy} onClick={save}>
Speichern
</Button>
</div>
}
>
<PotFields draft={draft} onChange={setDraft} type={project.type} unit={project.billing_unit} />
<p className="mt-3 text-xs text-ink-400">Gebucht wird immer auf den Topf, dessen Laufzeit das Leistungsdatum enthält. Überlappen Töpfe, zuerst der, der früher endet.</p>
</Modal>
);
};
const MembersDialog: React.FC<{ project: Project; users: DirectoryUser[]; onClose: () => void; onSaved: (p: Project) => void }> = ({ project, users, onClose, onSaved }) => {
const { addToast } = useToast();
const [ids, setIds] = useState<number[]>(project.members.map((m) => m.id));
const [filter, setFilter] = useState('');
const [busy, setBusy] = useState(false);
const list = users.filter((u) => u.role !== 'admin' && (u.is_active || ids.includes(u.id)) && (!filter || `${u.name} ${u.company ?? ''} ${u.job_title ?? ''}`.toLowerCase().includes(filter.toLowerCase())));
const save = async () => {
setBusy(true);
try {
onSaved(await projectsApi.setMembers(project.id, ids));
addToast('Zuweisung gespeichert.', 'success');
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
return (
<Modal
title="Mitarbeiter zuweisen"
subtitle={`${project.display_name} · ${ids.length} ausgewählt`}
onClose={onClose}
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="check" busy={busy} onClick={save}>
Speichern
</Button>
</div>
}
>
<input className={`${inputClass} mb-3`} placeholder="Suchen…" value={filter} onChange={(e) => setFilter(e.target.value)} autoFocus />
<div className="space-y-1">
{list.map((u) => {
const checked = ids.includes(u.id);
return (
<label key={u.id} className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-transparent hover:bg-ink-50'}`}>
<input type="checkbox" checked={checked} onChange={() => setIds(checked ? ids.filter((id) => id !== u.id) : [...ids, u.id])} />
<Avatar id={u.id} name={u.name} color={u.color} size={30} external={u.is_external} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-800">{u.name}</span>
<span className="block truncate text-xs text-ink-400">{u.is_external ? `Extern · ${u.company ?? ''}` : u.job_title ?? ''}</span>
</span>
</label>
);
})}
</div>
</Modal>
);
};
+405
View File
@@ -0,0 +1,405 @@
import React, { useMemo, useState } from 'react';
import { Modal } from '../ui/Modal';
import { Avatar, Button, Field, IconButton, Segmented, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { useAuth } from '../../lib/auth';
import { projectsApi, type PotInput } from '../../lib/api';
import { addDays, addMonths, formatNumber, todayIso } from '../../lib/format';
import { useToast } from '../Toast';
import { CustomerForm } from '../customers/CustomerForm';
import type { Customer, DirectoryUser, Project, ProjectType } from '../../types';
export interface PotDraft {
label: string;
valid_from: string;
valid_to: string;
budget_amount: string;
rate: string;
budget_hours: string;
budget_unit: 'hours' | 'days';
notes: string;
}
export function emptyPot(start = todayIso(), label = 'Budget 1'): PotDraft {
return { label, valid_from: start, valid_to: addDays(addMonths(start, 12), -1), budget_amount: '', rate: '', budget_hours: '', budget_unit: 'hours', notes: '' };
}
/** Deutsche und englische Schreibweise: "48.000,50", "48000.5", "110,5". */
export function num(value: string): number | null {
const v = value.trim().replace(/\s|€/g, '');
if (v === '') return null;
if (v.includes(',')) return Number(v.replace(/\./g, '').replace(',', '.'));
if (/^\d{1,3}(\.\d{3})+$/.test(v)) return Number(v.replace(/\./g, ''));
return Number(v);
}
export function potPayload(draft: PotDraft, hoursPerDay: number): PotInput {
const hours = num(draft.budget_hours);
return {
label: draft.label,
valid_from: draft.valid_from,
valid_to: draft.valid_to,
budget_amount: num(draft.budget_amount),
rate: num(draft.rate),
budget_hours: hours == null ? null : draft.budget_unit === 'days' ? hours * hoursPerDay : hours,
notes: draft.notes || null,
};
}
/** Vorschau des buchbaren Volumens - gleiche Rechnung wie im Backend. */
export function potVolume(draft: PotDraft, type: ProjectType, unit: 'hour' | 'day', hoursPerDay: number) {
const amount = num(draft.budget_amount);
const rate = num(draft.rate);
if (type === 'billable' && amount != null && rate) return (amount / rate) * (unit === 'day' ? hoursPerDay : 1);
const hours = num(draft.budget_hours);
if (hours == null) return null;
return draft.budget_unit === 'days' ? hours * hoursPerDay : hours;
}
/** Felder eines Budgettopfs - im Projektformular und im Topfdialog. */
export const PotFields: React.FC<{
draft: PotDraft;
onChange: (draft: PotDraft) => void;
type: ProjectType;
unit: 'hour' | 'day';
}> = ({ draft, onChange, type, unit }) => {
const { settings } = useAuth();
const set = (patch: Partial<PotDraft>) => onChange({ ...draft, ...patch });
const volume = potVolume(draft, type, unit, settings.hours_per_day);
const unitLabel = unit === 'day' ? 'Tagessatz' : 'Stundensatz';
return (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Bezeichnung" required className="sm:col-span-1">
<input className={inputClass} value={draft.label} onChange={(e) => set({ label: e.target.value })} placeholder="z. B. Bestellung 4500030022" />
</Field>
<Field label="Laufzeit von" required>
<input type="date" className={inputClass} value={draft.valid_from} onChange={(e) => set({ valid_from: e.target.value })} />
</Field>
<Field label="Laufzeit bis" required>
<input type="date" className={inputClass} value={draft.valid_to} onChange={(e) => set({ valid_to: e.target.value })} />
</Field>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{type !== 'non_billable' && (
<Field label={type === 'fixed' ? 'Festpreis (€)' : 'Budget (€)'} required={type === 'fixed'}>
<input inputMode="decimal" className={inputClass} value={draft.budget_amount} onChange={(e) => set({ budget_amount: e.target.value })} placeholder="z. B. 48000" />
</Field>
)}
{type === 'billable' && (
<Field label={`${unitLabel} (€)`} required>
<input inputMode="decimal" className={inputClass} value={draft.rate} onChange={(e) => set({ rate: e.target.value })} placeholder={unit === 'day' ? 'z. B. 880' : 'z. B. 110'} />
</Field>
)}
{(type !== 'billable' || !num(draft.budget_amount)) && (
<Field label={type === 'fixed' ? 'Kontingent (optional)' : type === 'billable' ? 'oder Kontingent' : 'Kontingent (leer = unbegrenzt)'}>
<div className="flex gap-2">
<input inputMode="decimal" className={inputClass} value={draft.budget_hours} onChange={(e) => set({ budget_hours: e.target.value })} placeholder="–" />
<select className={`${inputClass} w-24`} value={draft.budget_unit} onChange={(e) => set({ budget_unit: e.target.value as 'hours' | 'days' })}>
<option value="hours">Std.</option>
<option value="days">Tage</option>
</select>
</div>
</Field>
)}
</div>
<p className="flex items-center gap-2 rounded-xl bg-brand-50 px-3 py-2 text-xs font-semibold text-brand-800">
<Icon name="pot" className="h-4 w-4 shrink-0" />
{volume == null
? 'Unbegrenzt buchbar innerhalb der Laufzeit.'
: `Buchbar: ${formatNumber(volume, 2)} Std. = ${formatNumber(volume / settings.hours_per_day, 1)} Tage – danach gesperrt, ebenso nach Ablauf.`}
{type === 'fixed' && ' Virtueller Stundensatz = Festpreis ÷ gebuchte Stunden (intern).'}
</p>
</div>
);
};
interface Props {
project?: Project | null;
customers: Customer[];
users: DirectoryUser[];
onClose: () => void;
onSaved: (project: Project) => void;
onCustomerCreated: (customer: Customer) => void;
}
export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClose, onSaved, onCustomerCreated }) => {
const { user, settings } = useAuth();
const { addToast } = useToast();
const editing = Boolean(project);
const [customerId, setCustomerId] = useState<number | ''>(project?.customer_id ?? '');
const [name, setName] = useState(project?.name ?? '');
const [type, setType] = useState<ProjectType>(project?.type ?? 'billable');
const [unit, setUnit] = useState<'hour' | 'day'>(project?.billing_unit ?? 'hour');
const [start, setStart] = useState(project?.start_date ?? todayIso());
const [end, setEnd] = useState(project?.end_date ?? '');
const isAdmin = user!.role === 'admin';
const [ownerId, setOwnerId] = useState<number | ''>(project?.owner_id ?? (isAdmin ? '' : user!.id));
const [tags, setTags] = useState<string[]>(project?.tags ?? []);
const [tagText, setTagText] = useState('');
const [notes, setNotes] = useState(project?.notes ?? '');
const [members, setMembers] = useState<number[]>(project?.members.map((m) => m.id) ?? (isAdmin ? [] : [user!.id]));
const [memberFilter, setMemberFilter] = useState('');
const [tasks, setTasks] = useState<{ name: string; billable: boolean }[]>([{ name: '', billable: true }]);
const [pots, setPots] = useState<PotDraft[]>([emptyPot()]);
const [busy, setBusy] = useState(false);
const [newCustomer, setNewCustomer] = useState(false);
const customer = customers.find((c) => c.id === customerId);
const codePreview = customer
? settings.project_code_pattern.replaceAll('{KUERZEL}', customer.short_name).replaceAll('{JAHR}', start.slice(0, 4)).replaceAll('{JJ}', start.slice(2, 4)).replaceAll('{NR}', '###')
: '–';
const namePreview = customer && name
? settings.project_name_pattern.replaceAll('{KUNDE}', customer.short_name === 'PERINCO' ? 'perinco' : customer.short_name).replaceAll('{KUNDENNAME}', customer.name).replaceAll('{PROJEKT}', name).replaceAll('{CODE}', project?.code ?? codePreview)
: '';
const activeUsers = useMemo(
() => users.filter((u) => u.is_active && u.role !== 'admin' && (!memberFilter || u.name.toLowerCase().includes(memberFilter.toLowerCase()))),
[users, memberFilter]
);
const addTag = () => {
const parts = tagText.split(',').map((t) => t.trim()).filter(Boolean);
if (parts.length) setTags([...new Set([...tags, ...parts])]);
setTagText('');
};
const save = async () => {
setBusy(true);
try {
const base = {
customer_id: customerId,
name,
type,
billing_unit: unit,
start_date: start,
end_date: end || null,
owner_id: ownerId || null,
tags,
notes,
member_ids: members,
};
const saved = editing
? await projectsApi.update(project!.id, base)
: await projectsApi.create({
...base,
tasks: tasks.filter((t) => t.name.trim()),
pots: pots.map((p) => potPayload(p, settings.hours_per_day)),
});
addToast(editing ? 'Projekt gespeichert.' : `Projekt ${saved.code} angelegt.`, 'success');
onSaved(saved);
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
return (
<>
<Modal
size="xl"
title={editing ? `Projekt bearbeiten` : 'Neues Projekt'}
subtitle={editing ? `${project!.code} · ${project!.display_name}` : 'Projektcode wird automatisch vergeben'}
onClose={onClose}
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="check" busy={busy} onClick={save}>
{editing ? 'Speichern' : 'Projekt anlegen'}
</Button>
</div>
}
>
<div className="space-y-6">
<section className="grid gap-4 sm:grid-cols-2">
<Field label="Kunde" required>
<div className="flex gap-2">
<select className={inputClass} value={customerId} onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : '')}>
<option value="">Kunde wählen…</option>
{customers
.filter((c) => c.is_active || c.id === customerId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.short_name})
</option>
))}
</select>
<IconButton icon="plus" label="Neuen Kunden anlegen" type="button" onClick={() => setNewCustomer(true)} className="border border-ink-200" />
</div>
</Field>
<Field label="Projektname" required hint={namePreview ? <>Anzeige: <b className="text-ink-700">{namePreview}</b></> : `Muster: ${settings.project_name_pattern}`}>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="z. B. PMO" />
</Field>
<Field label="Projektcode">
<input className={`${inputClass} tabular`} value={project?.code ?? codePreview} disabled />
</Field>
<Field label="Projektverantwortlicher">
<select className={inputClass} value={ownerId} onChange={(e) => setOwnerId(e.target.value ? Number(e.target.value) : '')}>
<option value="">–</option>
{users
.filter((u) => u.is_active && (u.role === 'lead' || u.role === 'management'))
.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
</section>
<section className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Projekttyp</span>
<div className="grid gap-2 sm:grid-cols-3">
{(
[
['billable', 'Billable', 'Abrechnung nach Stunden oder Tagen'],
['non_billable', 'Non-billable', 'z. B. perinco intern'],
['fixed', 'Festpreis', 'Fixes Budget, virtueller Stundensatz'],
] as const
).map(([value, label, hint]) => (
<button
key={value}
type="button"
onClick={() => setType(value)}
className={`rounded-xl border p-3 text-left transition ${type === value ? 'border-brand-400 bg-brand-50 shadow-card' : 'border-ink-200 hover:border-brand-200'}`}
>
<span className={`block text-sm font-extrabold ${type === value ? 'text-brand-700' : 'text-ink-800'}`}>{label}</span>
<span className="block text-[11px] text-ink-400">{hint}</span>
</button>
))}
</div>
</div>
{type === 'billable' && (
<Field label="Abrechnung">
<Segmented value={unit} onChange={setUnit} options={[{ value: 'hour', label: 'Preis pro Stunde' }, { value: 'day', label: 'Preis pro Tag' }]} className="w-full" />
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Startdatum" required>
<input type="date" className={inputClass} value={start} onChange={(e) => setStart(e.target.value)} />
</Field>
<Field label="Enddatum">
<input type="date" className={inputClass} value={end} onChange={(e) => setEnd(e.target.value)} />
</Field>
</div>
</section>
{!editing && (
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm text-ink-900">Budgettöpfe</h3>
<Button size="sm" variant="secondary" icon="plus" onClick={() => setPots([...pots, emptyPot(start, `Budget ${pots.length + 1}`)])}>
Topf hinzufügen
</Button>
</div>
<div className="space-y-3">
{pots.map((pot, i) => (
<div key={i} className="relative rounded-2xl border border-ink-200 p-4">
{pots.length > 1 && (
<IconButton icon="trash" tone="danger" label="Topf entfernen" className="absolute right-2 top-2" onClick={() => setPots(pots.filter((_, j) => j !== i))} />
)}
<PotFields draft={pot} type={type} unit={unit} onChange={(next) => setPots(pots.map((p, j) => (j === i ? next : p)))} />
</div>
))}
</div>
</section>
)}
{!editing && (
<section>
<h3 className="mb-2 text-sm text-ink-900">Tasks</h3>
<div className="space-y-2">
{tasks.map((task, i) => (
<div key={i} className="flex items-center gap-2">
<input
className={inputClass}
value={task.name}
placeholder="z. B. Administration Server"
onChange={(e) => {
const next = tasks.map((t, j) => (j === i ? { ...t, name: e.target.value } : t));
if (i === tasks.length - 1 && e.target.value) next.push({ name: '', billable: true });
setTasks(next);
}}
/>
{type !== 'non_billable' && (
<label className="flex shrink-0 items-center gap-1.5 text-xs font-bold text-ink-500">
<input type="checkbox" checked={task.billable} onChange={(e) => setTasks(tasks.map((t, j) => (j === i ? { ...t, billable: e.target.checked } : t)))} />
billable
</label>
)}
</div>
))}
</div>
</section>
)}
<section>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm text-ink-900">
Zugewiesene Mitarbeiter <span className="text-ink-400">({members.length})</span>
</h3>
<input className={`${inputClass} w-48 py-1.5`} placeholder="Suchen…" value={memberFilter} onChange={(e) => setMemberFilter(e.target.value)} />
</div>
<div className="scrollbar-slim grid max-h-56 gap-1.5 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3">
{activeUsers.map((u) => {
const checked = members.includes(u.id);
return (
<label key={u.id} className={`flex cursor-pointer items-center gap-2 rounded-xl border px-2.5 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-ink-200 hover:bg-ink-50'}`}>
<input type="checkbox" className="accent-[var(--color-brand-500)]" checked={checked} onChange={() => setMembers(checked ? members.filter((id) => id !== u.id) : [...members, u.id])} />
<Avatar id={u.id} name={u.name} color={u.color} size={24} external={u.is_external} />
<span className="min-w-0 truncate text-xs font-bold text-ink-700">{u.name}</span>
</label>
);
})}
</div>
</section>
<section className="grid gap-4 sm:grid-cols-2">
<Field label="Tags">
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-ink-200 px-2 py-1.5">
{tags.map((t) => (
<span key={t} className="flex items-center gap-1 rounded-full bg-ink-100 px-2 py-0.5 text-xs font-bold text-ink-600">
{t}
<button type="button" onClick={() => setTags(tags.filter((x) => x !== t))} aria-label={`${t} entfernen`}>
<Icon name="close" className="h-3 w-3" />
</button>
</span>
))}
<input
className="min-w-[80px] flex-1 bg-transparent px-1 py-1 text-sm outline-none"
value={tagText}
placeholder="Tag + Enter"
onChange={(e) => setTagText(e.target.value)}
onBlur={addTag}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag();
}
}}
/>
</div>
</Field>
<Field label="Notizen">
<textarea className={`${inputClass} h-24 resize-y`} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="z. B. Rechnungen nur mit Belegnummer…" />
</Field>
</section>
</div>
</Modal>
{newCustomer && (
<CustomerForm
onClose={() => setNewCustomer(false)}
onSaved={(c) => {
onCustomerCreated(c);
setCustomerId(c.id);
}}
/>
)}
</>
);
};
+260
View File
@@ -0,0 +1,260 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { AvatarStack, Badge, BudgetBar, Button, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { ProjectForm } from './ProjectForm';
import { ProjectDetail } from './ProjectDetail';
import { useAuth } from '../../lib/auth';
import { customersApi, projectsApi, usersApi } from '../../lib/api';
import { useLoader, usePersistentState } from '../../lib/hooks';
import { colorFor, compactEuro, formatHours, formatNumber } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { PROJECT_TYPE_LABELS, atLeast, type Customer, type DirectoryUser, type Project } from '../../types';
export const TYPE_TONE = { billable: 'brand', non_billable: 'neutral', fixed: 'violet' } as const;
export const Projects: React.FC = () => {
const { user, settings } = useAuth();
const { route, navigate } = useRouter();
const canEdit = atLeast(user, 'lead');
const projects = useLoader(() => projectsApi.list(), []);
const [customers, setCustomers] = useState<Customer[]>([]);
const [users, setUsers] = useState<DirectoryUser[]>([]);
const [search, setSearch] = useState('');
const [customerFilter, setCustomerFilter] = useState<number | ''>('');
const [status, setStatus] = usePersistentState<'active' | 'archived' | 'all'>('pulse_projects_status', 'active');
const [form, setForm] = useState<{ project: Project | null } | null>(null);
const detailRef = useRef<HTMLDivElement>(null);
const selectedId = route.params[0] ? Number(route.params[0]) : null;
useEffect(() => {
if (!canEdit) return;
customersApi.list().then(setCustomers).catch(() => undefined);
usersApi.directory().then(setUsers).catch(() => undefined);
}, [canEdit]);
useEffect(() => {
if (selectedId) setTimeout(() => detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 120);
}, [selectedId]);
const list = projects.data ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return list.filter(
(p) =>
(status === 'all' || (status === 'active' ? p.is_active : !p.is_active)) &&
(!customerFilter || p.customer_id === customerFilter) &&
(!q || [p.display_name, p.code, p.customer.name, ...p.tags].some((s) => s.toLowerCase().includes(q)))
);
}, [list, search, customerFilter, status]);
const groups = useMemo(() => {
const map = new Map<number, { customer: Project['customer']; projects: Project[] }>();
for (const p of filtered) {
if (!map.has(p.customer_id)) map.set(p.customer_id, { customer: p.customer, projects: [] });
map.get(p.customer_id)!.projects.push(p);
}
return [...map.values()].sort((a, b) => a.customer.name.localeCompare(b.customer.name, 'de'));
}, [filtered]);
const selected = list.find((p) => p.id === selectedId) ?? null;
const customerOptions = useMemo(() => [...new Map(list.map((p) => [p.customer_id, p.customer])).values()].sort((a, b) => a.name.localeCompare(b.name, 'de')), [list]);
const replaceProject = (saved: Project) => {
projects.setData((current) => {
const rest = (current ?? []).filter((p) => p.id !== saved.id);
return [...rest, saved];
});
};
if (projects.error && !projects.data) return <ErrorBox message={projects.error} onRetry={projects.reload} />;
if (!projects.data) return <Spinner label="Lade Projekte…" />;
return (
<div className="mx-auto max-w-[1500px]">
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="relative min-w-[200px] flex-1 sm:max-w-xs">
<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="Projekt, Code, Tag…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{customerOptions.length > 1 && (
<select className={`${inputClass} !w-auto max-w-[220px]`} value={customerFilter} onChange={(e) => setCustomerFilter(e.target.value ? Number(e.target.value) : '')}>
<option value="">Alle Kunden</option>
{customerOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
{canEdit && (
<Segmented
size="sm"
value={status}
onChange={setStatus}
options={[
{ value: 'active', label: 'Aktiv' },
{ value: 'archived', label: 'Archiv' },
{ value: 'all', label: 'Alle' },
]}
/>
)}
{canEdit && (
<Button icon="plus" className="ml-auto" onClick={() => setForm({ project: null })}>
Neues Projekt
</Button>
)}
</div>
{groups.length === 0 ? (
<EmptyState
icon="folder"
title={list.length ? 'Keine Projekte zum Filter' : canEdit ? 'Noch keine Projekte' : 'Dir ist noch kein Projekt zugewiesen'}
text={canEdit ? 'Lege ein Projekt mit Kunde und Budgettopf an und weise Mitarbeiter zu.' : 'Dein Vorgesetzter weist dir Projekte zu – danach kannst du darauf buchen.'}
action={canEdit && !list.length ? <Button icon="plus" onClick={() => setForm({ project: null })}>Neues Projekt</Button> : undefined}
/>
) : (
<div className="space-y-6">
{groups.map((group, gi) => (
<section key={group.customer.id}>
<div className="mb-2 flex items-center gap-2 px-1">
<span className="h-3 w-3 rounded-full" style={{ background: colorFor(group.customer.id, group.customer.color) }} />
<h2 className="text-sm text-ink-900">{group.customer.name}</h2>
<span className="text-xs font-bold text-ink-400">{group.projects.length} {group.projects.length === 1 ? 'Projekt' : 'Projekte'}</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{group.projects
.sort((a, b) => a.name.localeCompare(b.name, 'de'))
.map((p, i) => (
<ProjectCard
key={p.id}
project={p}
index={gi + i}
selected={p.id === selectedId}
showAmounts={canEdit}
hoursPerDay={settings.hours_per_day}
warnAt={settings.budget_warn_percent}
onClick={() => navigate(p.id === selectedId ? 'projekte' : `projekte/${p.id}`)}
/>
))}
</div>
{selected && selected.customer_id === group.customer.id && (
<div ref={detailRef} className="mt-3 scroll-mt-4">
<ProjectDetail
key={selected.id}
project={selected}
canEdit={canEdit}
users={users}
onChanged={replaceProject}
onEdit={() => setForm({ project: selected })}
onDeleted={() => {
projects.setData((cur) => (cur ?? []).filter((p) => p.id !== selected.id));
navigate('projekte');
}}
onClose={() => navigate('projekte')}
/>
</div>
)}
</section>
))}
</div>
)}
{form && (
<ProjectForm
project={form.project}
customers={customers}
users={users}
onClose={() => setForm(null)}
onSaved={(saved) => {
replaceProject(saved);
navigate(`projekte/${saved.id}`);
}}
onCustomerCreated={(c) => setCustomers((cur) => [...cur, c])}
/>
)}
</div>
);
};
const ProjectCard: React.FC<{
project: Project;
index: number;
selected: boolean;
showAmounts: boolean;
hoursPerDay: number;
warnAt: number;
onClick: () => void;
}> = ({ project: p, index, selected, showAmounts, hoursPerDay, warnAt, onClick }) => {
const usedHours = p.used_minutes / 60;
const percent = p.budget_hours ? (p.budget_used_minutes / 60 / p.budget_hours) * 100 : null;
const warnings = p.pots.filter((pot) => pot.warning && pot.status !== 'expired');
const active = p.pots.filter((pot) => pot.status === 'active').length;
return (
<Card
className={`animate-rise group relative cursor-pointer overflow-hidden p-4 transition hover:-translate-y-0.5 hover:shadow-lift ${
selected ? 'ring-2 ring-brand-400' : ''
} ${p.is_active ? '' : 'opacity-70'}`}
style={{ '--i': index } as React.CSSProperties}
>
<button onClick={onClick} className="absolute inset-0 z-10" aria-label={`${p.display_name} öffnen`} />
<span aria-hidden className="absolute inset-x-0 top-0 h-1" style={{ background: colorFor(p.customer_id, p.customer.color) }} />
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="tabular text-[11px] font-bold text-ink-400">{p.code}</p>
<h3 className="truncate text-base text-ink-900">{p.display_name}</h3>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
{!p.is_active && <Badge>Archiv</Badge>}
</div>
</div>
<div className="mt-4 flex items-end justify-between gap-2">
<div>
<p className="tabular text-2xl font-extrabold text-ink-900">
{formatHours(p.used_minutes, 1)}
<span className="text-sm text-ink-400"> h</span>
</p>
<p className="tabular text-[11px] font-semibold text-ink-400">
{formatNumber(usedHours / hoursPerDay, 1)} Tage gebucht
</p>
</div>
<div className="text-right">
{p.budget_hours != null ? (
<>
<p className="tabular text-sm font-extrabold text-ink-800">{formatNumber(p.remaining_hours ?? 0, 1)} h frei</p>
<p className="tabular text-[11px] font-semibold text-ink-400">
von {formatNumber(p.budget_hours, 0)} h · {formatNumber((p.remaining_hours ?? 0) / hoursPerDay, 1)} Tage
</p>
</>
) : (
<p className="text-xs font-bold text-ink-400">ohne Stundenlimit</p>
)}
</div>
</div>
<BudgetBar className="mt-2" percent={percent} warnAt={warnAt} />
<div className="mt-3 flex items-center justify-between gap-2">
<AvatarStack people={p.members} max={5} size={24} />
<div className="flex items-center gap-2 text-[11px] font-bold text-ink-400">
{showAmounts && p.budget_value ? <span className="tabular">{compactEuro(p.open ?? 0)} offen</span> : null}
<span className="flex items-center gap-1" title={`${p.pots.length} Budgettöpfe, ${active} aktiv`}>
<Icon name="pot" className="h-3.5 w-3.5" />
{p.pots.length}
</span>
<span className="flex items-center gap-1" title="Tasks">
<Icon name="list" className="h-3.5 w-3.5" />
{p.tasks.filter((t) => t.is_active).length}
</span>
</div>
</div>
{warnings.length > 0 && (
<p className="mt-3 flex items-start gap-1.5 rounded-lg bg-amber-50 px-2 py-1.5 text-[11px] font-semibold text-amber-700">
<Icon name="warning" className="mt-px h-3.5 w-3.5 shrink-0" />
<span className="line-clamp-2">{warnings[0].warning}</span>
</p>
)}
</Card>
);
};
+185
View File
@@ -0,0 +1,185 @@
import React, { useMemo, useState } from 'react';
import { Badge, BudgetBar, Card, EmptyState, ErrorBox, Spinner, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { BILLABLE, CountUp, Legend, NON_BILLABLE, StackedBars } from '../ui/charts';
import { ReportTable } from './ReportTable';
import { ExportMenu } from './ExportMenu';
import { ReportKpi, ReportRow } from './ProjectReport';
import { useAuth } from '../../lib/auth';
import { customersApi, qs, reportsApi, type CustomerReport as CustomerReportData } from '../../lib/api';
import { useLoader } from '../../lib/hooks';
import { MONTHS, colorFor, formatEuro, formatHours, formatNumber, todayIso } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { TYPE_TONE } from '../projects/Projects';
import { PROJECT_TYPE_LABELS } from '../../types';
export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
const { navigate } = useRouter();
const customers = useLoader(() => customersApi.list(), []);
const report = useLoader(() => (id ? reportsApi.customer(id) : Promise.resolve(null)), [id]);
return (
<div className="space-y-4">
<select className={`${inputClass} !w-auto min-w-[260px] max-w-full`} value={id ?? ''} onChange={(e) => navigate(e.target.value ? `reports/kunde/${e.target.value}` : 'reports/kunde')}>
<option value="">Kunde wählen…</option>
{(customers.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{!id ? (
<EmptyState icon="building" title="Kunde auswählen" text="Alle Projekte eines Kunden mit Budget, Umsatz, Verlauf und Team." />
) : report.error ? (
<ErrorBox message={report.error} onRetry={report.reload} />
) : !report.data ? (
<Spinner label="Lade Kundenreport…" />
) : (
<Body key={id} data={report.data} />
)}
</div>
);
};
const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
const { settings } = useAuth();
const { navigate } = useRouter();
const [tab, setTab] = useState<'projects' | 'team' | 'tasks'>('projects');
const [months, setMonths] = useState(12);
const c = data.customer;
const t = data.totals;
const color = colorFor(c.id, c.color);
const month = todayIso().slice(0, 7);
const series = useMemo(() => data.series.slice(-months), [data.series, months]);
const exportOptions = [
{ label: 'PDF – Stundennachweis (Monat)', hint: 'Vorlage für den Kunden, alle Berater', path: `/exports/timesheet${qs({ month, format: 'pdf', user_id: 'all', customer_id: c.id })}`, filename: `${c.short_name}-${month}.pdf` },
{ label: 'Excel – Stundennachweis mit Beträgen', hint: 'Für die Rechnungsstellung', path: `/exports/timesheet${qs({ month, format: 'xlsx', user_id: 'all', customer_id: c.id, amounts: 1 })}`, filename: `${c.short_name}-${month}.xlsx` },
{ label: 'PDF – Projekte (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'projects', format: 'pdf', include_fixed: 1 })}`, filename: `${c.short_name}-projekte.pdf` },
{ label: 'CSV – alle Buchungen (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${c.short_name}.csv` },
];
return (
<div className="space-y-4">
<div className="animate-rise flex flex-wrap items-center gap-4">
<span className="flex h-14 w-14 items-center justify-center rounded-2xl text-lg font-extrabold text-white shadow-card" style={{ background: color }}>
{c.short_name.slice(0, 2)}
</span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-2xl text-ink-900 sm:text-3xl">{c.name}</h2>
<p className="text-sm text-ink-500">
{data.projects.length} Projekte · {data.team.length} Mitarbeiter
{c.contact_person && ` · Ansprechpartner: ${c.contact_person}`}
</p>
</div>
<ExportMenu options={exportOptions} />
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ReportKpi index={1} title="Gesamtstunden">
<CountUp value={t.minutes / 60} format={(v) => formatNumber(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="Billable" value={formatHours(t.billable_minutes)} />
<ReportRow label="Non-billable" value={formatHours(t.non_billable_minutes)} />
</ReportKpi>
<ReportKpi index={2} title="Umsatz erbracht">
<CountUp value={t.earned} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="davon aus Buchungen" value={formatEuro(t.amount, 2)} />
</ReportKpi>
<ReportKpi index={3} title="Umsatz offen">
<CountUp value={t.open} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="in laufenden Töpfen" value={`${data.projects.reduce((s, p) => s + p.project.pots.filter((x) => x.status === 'active').length, 0)}`} />
</ReportKpi>
<ReportKpi index={4} title="Budget gesamt">
<CountUp value={t.budget_value} format={(v) => formatEuro(v, 0)} className="text-3xl font-extrabold text-ink-900" />
<BudgetBar className="mt-3" percent={t.budget_value ? (t.earned / t.budget_value) * 100 : null} warnAt={settings.budget_warn_percent} />
</ReportKpi>
</div>
<Card className="animate-rise p-4 sm:p-5" style={{ '--i': 5 } as React.CSSProperties}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-base text-ink-900">Stunden je Monat</h3>
<Legend className="mt-1" items={[{ color: BILLABLE, label: 'Billable' }, { color: NON_BILLABLE, label: 'Non-billable' }]} />
</div>
<select className="rounded-xl border border-ink-200 bg-surface px-3 py-1.5 text-xs font-bold text-ink-600" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
<option value={6}>6 Monate</option>
<option value={12}>12 Monate</option>
<option value={24}>24 Monate</option>
</select>
</div>
{series.length ? (
<StackedBars
scale={60}
points={series.map((m) => ({ key: m.month, label: `${MONTHS[Number(m.month.slice(5)) - 1].slice(0, 3)} ${m.month.slice(2, 4)}`, a: m.billable_minutes, b: m.minutes - m.billable_minutes }))}
format={(v) => `${formatHours(v, 1)} h`}
axisFormat={(v) => formatNumber(v / 60, 0)}
height={220}
/>
) : (
<p className="py-10 text-center text-xs text-ink-400">Noch keine Buchungen.</p>
)}
</Card>
<Card className="animate-rise overflow-hidden" style={{ '--i': 6 } as React.CSSProperties}>
<div className="flex gap-1 border-b border-ink-100 px-4 pt-1">
{(
[
['projects', 'Projekte'],
['team', 'Team'],
['tasks', 'Tasks'],
] as const
).map(([key, label]) => (
<button key={key} onClick={() => setTab(key)} className={`relative px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
{label}
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
</button>
))}
</div>
{tab === 'team' && <ReportTable rows={data.team} kind="team" label="Name" />}
{tab === 'tasks' && <ReportTable rows={data.tasks} kind="tasks" label="Task" />}
{tab === 'projects' && (
<div className="divide-y divide-ink-100">
{data.projects.length === 0 && <p className="p-6 text-center text-sm text-ink-400">Keine Projekte.</p>}
{data.projects.map(({ project: p, totals, pots }, i) => (
<button key={p.id} onClick={() => navigate(`reports/projekt/${p.id}`)} className="animate-rise block w-full p-4 text-left hover:bg-ink-50" style={{ '--i': i } as React.CSSProperties}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="tabular text-[11px] font-bold text-ink-400">{p.code}</p>
<p className="flex items-center gap-2 font-extrabold text-ink-900">
{p.display_name}
<Icon name="chevronRight" className="h-4 w-4 text-ink-300" />
</p>
</div>
<div className="flex items-center gap-2">
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
{!p.is_active && <Badge>Archiv</Badge>}
</div>
</div>
<div className="tabular mt-2 grid grid-cols-2 gap-x-6 gap-y-1 text-xs sm:grid-cols-4">
<ReportRow label="Stunden" value={formatHours(totals.minutes)} />
<ReportRow label="Budget" value={p.budget_hours == null ? '∞' : `${formatNumber(p.budget_hours, 0)} h`} />
<ReportRow label="Erbracht" value={formatEuro(p.earned, 0)} />
<ReportRow label="Offen" value={formatEuro(p.open, 0)} />
</div>
<BudgetBar className="mt-2" percent={p.budget_hours ? (p.budget_used_minutes / 60 / p.budget_hours) * 100 : null} warnAt={settings.budget_warn_percent} />
{pots.length > 1 && (
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{pots.map((pot) => (
<div key={pot.id} className="rounded-xl border border-ink-200 p-2.5">
<p className="flex justify-between gap-2 text-xs font-bold text-ink-700">
<span className="truncate">{pot.label}</span>
<span className="tabular text-ink-500">{formatHours(pot.used_minutes, 1)} h</span>
</p>
<BudgetBar className="mt-1.5" height="h-1.5" percent={pot.percent_used} warnAt={settings.budget_warn_percent} muted={pot.status === 'expired'} />
</div>
))}
</div>
)}
</button>
))}
</div>
)}
</Card>
</div>
);
};
+116
View File
@@ -0,0 +1,116 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { download } from '../../lib/api';
import { useToast } from '../Toast';
export interface ExportOption {
label: string;
hint?: string;
path: string;
filename: string;
}
const WIDTH = 264;
/**
* Aufklappmenue "Export".
*
* Die Liste haengt per Portal am <body> und wird fest am Knopf positioniert.
* Ohne das legen sich die Karten darunter darueber: ihre Einblend-Animation
* macht aus jeder Karte einen eigenen Stapelkontext, gegen den ein z-index
* innerhalb der Kopfzeile nicht ankommt.
*/
export const ExportMenu: React.FC<{ options: ExportOption[]; label?: string }> = ({ options, label = 'Export' }) => {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const { addToast } = useToast();
const buttonRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const place = useCallback(() => {
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
// Nach links ausgerichtet an der Knopfkante, notfalls am Fensterrand geklemmt.
const left = Math.max(8, Math.min(rect.right - WIDTH, window.innerWidth - WIDTH - 8));
setPos({ top: rect.bottom + 8, left });
}, []);
useEffect(() => {
if (!open) return;
place();
const close = (event: MouseEvent) => {
if (buttonRef.current?.contains(event.target as Node) || menuRef.current?.contains(event.target as Node)) return;
setOpen(false);
};
const onKey = (event: KeyboardEvent) => event.key === 'Escape' && setOpen(false);
document.addEventListener('mousedown', close);
window.addEventListener('keydown', onKey);
// Beim Scrollen mitwandern (der Inhalt scrollt in einem eigenen Bereich).
window.addEventListener('resize', place);
document.addEventListener('scroll', place, true);
return () => {
document.removeEventListener('mousedown', close);
window.removeEventListener('keydown', onKey);
window.removeEventListener('resize', place);
document.removeEventListener('scroll', place, true);
};
}, [open, place]);
const run = async (option: ExportOption) => {
setBusy(option.label);
try {
await download(option.path, option.filename);
setOpen(false);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Export fehlgeschlagen.', 'error');
} finally {
setBusy(null);
}
};
return (
<div ref={buttonRef} className="relative">
<Button size="sm" variant="secondary" icon="download" onClick={() => setOpen(!open)} aria-expanded={open} aria-haspopup="menu">
{label}
<Icon name="chevronDown" className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} strokeWidth={2.2} />
</Button>
{open &&
pos &&
createPortal(
<div
ref={menuRef}
role="menu"
style={{ top: pos.top, left: pos.left, width: WIDTH }}
className="animate-pop fixed z-[300] max-h-[70dvh] overflow-y-auto rounded-2xl border border-ink-200 bg-surface p-1.5 shadow-lift"
>
{options.map((o) => (
<button
key={o.label}
role="menuitem"
onClick={() => run(o)}
disabled={Boolean(busy)}
className="flex w-full items-start gap-3 rounded-xl px-3 py-2 text-left hover:bg-ink-50 disabled:opacity-60"
>
{busy === o.label ? (
<span className="mt-0.5 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
) : (
<Icon
name={o.label.includes('PDF') ? 'document' : o.label.includes('Excel') ? 'grid' : 'list'}
className="mt-0.5 h-4 w-4 shrink-0 text-ink-400"
/>
)}
<span className="min-w-0">
<span className="block text-sm font-bold text-ink-800">{o.label}</span>
{o.hint && <span className="block text-[11px] text-ink-400">{o.hint}</span>}
</span>
</button>
))}
</div>,
document.body
)}
</div>
);
};
+248
View File
@@ -0,0 +1,248 @@
import React, { useMemo, useState } from 'react';
import { Badge, BudgetBar, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { CountUp, ProgressChart } from '../ui/charts';
import { ReportTable } from './ReportTable';
import { ExportMenu } from './ExportMenu';
import { useAuth } from '../../lib/auth';
import { projectsApi, qs, reportsApi, type ProjectDetail } from '../../lib/api';
import { useLoader } from '../../lib/hooks';
import { formatDate, formatEuro, formatHours, formatNumber, mondayOf, todayIso } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { TYPE_TONE } from '../projects/Projects';
import { PROJECT_TYPE_LABELS } from '../../types';
export const ProjectReport: React.FC<{ id: number | null }> = ({ id }) => {
const { navigate } = useRouter();
const projects = useLoader(() => projectsApi.list(), []);
const report = useLoader(() => (id ? reportsApi.project(id) : Promise.resolve(null)), [id]);
const options = useMemo(() => [...(projects.data ?? [])].sort((a, b) => a.display_name.localeCompare(b.display_name, 'de')), [projects.data]);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<select className={`${inputClass} !w-auto min-w-[260px] max-w-full`} value={id ?? ''} onChange={(e) => navigate(e.target.value ? `reports/projekt/${e.target.value}` : 'reports/projekt')}>
<option value="">Projekt wählen…</option>
{options.map((p) => (
<option key={p.id} value={p.id}>
{p.display_name} · {p.code}
</option>
))}
</select>
</div>
{!id ? (
<EmptyState icon="folder" title="Projekt auswählen" text="Verlauf, Budget, Tasks, Team und Budgettöpfe eines Projekts." />
) : report.error ? (
<ErrorBox message={report.error} onRetry={report.reload} />
) : !report.data ? (
<Spinner label="Lade Projektreport…" />
) : (
<ProjectReportBody key={id} detail={report.data} />
)}
</div>
);
};
export const ProjectReportBody: React.FC<{ detail: ProjectDetail; compact?: boolean }> = ({ detail, compact }) => {
const { settings } = useAuth();
const { navigate } = useRouter();
const [chart, setChart] = useState<'progress' | 'weekly'>('progress');
const [tab, setTab] = useState<'tasks' | 'team' | 'pots'>('tasks');
const [showNotes, setShowNotes] = useState(false);
const p = detail.project;
const t = detail.totals;
const budgetHours = p.budget_hours;
const remaining = budgetHours != null ? Math.max(0, budgetHours - p.budget_used_minutes / 60) : null;
const remainingPct = budgetHours ? Math.round(((remaining ?? 0) / budgetHours) * 100) : null;
const thisWeek = mondayOf(todayIso());
const virtualRate = p.type === 'fixed' && p.used_minutes ? (p.budget_value ?? 0) / (p.used_minutes / 60) : null;
const month = todayIso().slice(0, 7);
const exportOptions = [
{ label: 'PDF – Stundennachweis (Monat)', hint: 'Alle Mitarbeiter, aktueller Monat', path: `/exports/timesheet${qs({ month, format: 'pdf', user_id: 'all', project_id: p.id })}`, filename: `${p.code}.pdf` },
{ label: 'Excel – Stundennachweis (Monat)', path: `/exports/timesheet${qs({ month, format: 'xlsx', user_id: 'all', project_id: p.id, amounts: 1 })}`, filename: `${p.code}.xlsx` },
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ from: p.start_date, to: todayIso(), project_id: p.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${p.code}.csv` },
{ label: 'PDF – Tasks gesamt', path: `/exports/report${qs({ from: p.start_date, to: todayIso(), project_id: p.id, group: 'tasks', format: 'pdf', include_fixed: 1 })}`, filename: `${p.code}-tasks.pdf` },
];
return (
<div className="space-y-4">
{!compact && (
<div className="animate-rise">
<button onClick={() => navigate(`reports/kunde/${p.customer_id}`)} className="text-sm font-bold text-ink-500 hover:text-brand-700">
{p.customer.name}
</button>
<div className="flex flex-wrap items-start justify-between gap-3">
<h2 className="text-2xl leading-tight text-ink-900 sm:text-3xl">
<span className="tabular text-ink-400">[{p.code}]</span> {p.display_name}
</h2>
<div className="flex gap-2">
<button onClick={() => navigate(`projekte/${p.id}`)} className="flex items-center gap-1.5 rounded-xl border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 hover:border-brand-300 hover:text-brand-700">
<Icon name="edit" className="h-3.5 w-3.5" /> Projekt öffnen
</button>
<ExportMenu options={exportOptions} label="Aktionen" />
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-ink-500">
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
<span className="flex items-center gap-1">
<Icon name="calendar" className="h-4 w-4" />
Start: {formatDate(p.start_date)}
{p.end_date && ` · Ende: ${formatDate(p.end_date)}`}
</span>
</div>
{p.notes && (
<div className="mt-2 max-w-3xl text-sm text-ink-500">
<p className={showNotes ? 'whitespace-pre-line' : 'line-clamp-2'}>{p.notes}</p>
<button onClick={() => setShowNotes(!showNotes)} className="text-xs font-bold text-sky-700 underline">
{showNotes ? 'Weniger' : 'Mehr anzeigen'}
</button>
</div>
)}
</div>
)}
<Card className="animate-rise p-4 sm:p-5" style={{ '--i': 1 } as React.CSSProperties}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<Segmented
size="sm"
value={chart}
onChange={setChart}
options={[
{ value: 'progress', label: 'Projektverlauf' },
{ value: 'weekly', label: 'Stunden pro Woche' },
]}
/>
<span className="text-xs text-ink-400">Stunden · Start {formatDate(p.start_date)}</span>
</div>
<ProgressChart
mode={chart}
budget={budgetHours}
format={(v) => `${formatNumber(v, 0)} h`}
points={detail.weekly.map((w) => ({
label: w.label,
value: w.cumulative_minutes == null ? null : w.cumulative_minutes / 60,
bar: w.billable_minutes / 60,
barB: (w.minutes - w.billable_minutes) / 60,
current: w.week_start === thisWeek,
}))}
/>
</Card>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<Kpi index={2} title="Gesamtstunden">
<CountUp value={t.minutes / 60} format={(v) => formatNumber(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<Row label="Billable" value={formatHours(t.billable_minutes)} />
<Row label="Non-billable" value={formatHours(t.non_billable_minutes)} />
</Kpi>
<Kpi index={3} title={`Budget verbleibend${remainingPct != null ? ` (${remainingPct} %)` : ''}`}>
<p className="tabular text-3xl font-extrabold text-ink-900">{remaining == null ? '∞' : formatNumber(remaining, 2)}</p>
<Row label="Gesamtbudget" value={budgetHours == null ? 'unbegrenzt' : `${formatNumber(budgetHours, 2)} h`} />
<BudgetBar className="mt-2" percent={budgetHours ? (p.budget_used_minutes / 60 / budgetHours) * 100 : null} warnAt={settings.budget_warn_percent} />
</Kpi>
<Kpi index={4} title={p.type === 'fixed' ? 'Virtueller Stundensatz' : 'Budget in Tagen'}>
{p.type === 'fixed' ? (
<>
<p className="tabular text-3xl font-extrabold text-ink-900">{virtualRate ? formatEuro(virtualRate, 2) : 'n/a'}</p>
<Row label="Festpreis ÷ Stunden" value="nur intern" />
</>
) : (
<>
<p className="tabular text-3xl font-extrabold text-ink-900">{remaining == null ? '∞' : formatNumber(remaining / settings.hours_per_day, 1)}</p>
<Row label="Tage frei von" value={budgetHours == null ? '–' : formatNumber(budgetHours / settings.hours_per_day, 1)} />
</>
)}
</Kpi>
<Kpi index={5} title="Umsatz erbracht">
<CountUp value={p.earned ?? 0} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<Row label="Budget gesamt" value={formatEuro(p.budget_value, 2)} />
</Kpi>
<Kpi index={6} title="Umsatz offen">
<CountUp value={p.open ?? 0} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<Row label="Töpfe aktiv" value={`${p.pots.filter((x) => x.status === 'active').length} von ${p.pots.length}`} />
</Kpi>
</div>
<Card className="animate-rise overflow-hidden" style={{ '--i': 7 } as React.CSSProperties}>
<div className="flex gap-1 border-b border-ink-100 px-4 pt-1">
{(
[
['tasks', 'Tasks'],
['team', 'Team'],
['pots', 'Budgettöpfe'],
] as const
).map(([key, label]) => (
<button key={key} onClick={() => setTab(key)} className={`relative px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
{label}
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
</button>
))}
</div>
{tab === 'tasks' && <ReportTable rows={detail.tasks} kind="tasks" label="Task" />}
{tab === 'team' && <ReportTable rows={detail.team} kind="team" label="Name" />}
{tab === 'pots' && <PotsBreakdown detail={detail} />}
</Card>
</div>
);
};
/** Gesamtuebersicht aller Toepfe und darunter jeder Topf einzeln. */
const PotsBreakdown: React.FC<{ detail: ProjectDetail }> = ({ detail }) => {
const { settings } = useAuth();
const p = detail.project;
const totalBudget = p.pots.reduce((s, x) => s + (x.budget_hours ?? 0), 0);
return (
<div className="space-y-4 p-4">
<div className="rounded-2xl bg-ink-50 p-4">
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Gesamt · {p.pots.length} Töpfe</p>
<div className="tabular mt-1 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<Row label="Gebucht" value={`${formatHours(p.used_minutes)} h`} />
<Row label="Budget" value={totalBudget ? `${formatNumber(totalBudget, 2)} h` : 'unbegrenzt'} />
<Row label="Erbracht" value={formatEuro(p.earned, 2)} />
<Row label="Offen" value={formatEuro(p.open, 2)} />
</div>
<BudgetBar className="mt-3" percent={totalBudget ? (p.budget_used_minutes / 60 / totalBudget) * 100 : null} warnAt={settings.budget_warn_percent} />
</div>
{detail.pots.map((pot, i) => (
<div key={pot.id} className="animate-rise rounded-2xl border border-ink-200" style={{ '--i': i } as React.CSSProperties}>
<div className="flex flex-wrap items-center justify-between gap-2 p-4">
<div>
<p className="font-extrabold text-ink-900">{pot.label}</p>
<p className="text-xs text-ink-400">
{formatDate(pot.valid_from)} – {formatDate(pot.valid_to)} · {pot.status === 'active' ? 'aktiv' : pot.status === 'expired' ? 'abgelaufen' : pot.status === 'exhausted' ? 'aufgebraucht' : 'geplant'}
</p>
</div>
<div className="tabular flex flex-wrap gap-4 text-xs">
<Row label="Gebucht" value={`${formatHours(pot.used_minutes)} h`} />
<Row label="Frei" value={pot.remaining_hours == null ? '∞' : `${formatNumber(pot.remaining_hours, 2)} h`} />
<Row label="Erbracht" value={formatEuro(pot.earned, 2)} />
<Row label="Offen" value={formatEuro(pot.open, 2)} />
</div>
</div>
<BudgetBar className="mx-4 mb-3" percent={pot.percent_used} warnAt={settings.budget_warn_percent} muted={pot.status === 'expired'} />
<div className="grid border-t border-ink-100 lg:grid-cols-2">
<ReportTable rows={pot.tasks} kind="tasks" label="Task" totals={false} />
<ReportTable rows={pot.team} kind="team" label="Team" totals={false} />
</div>
</div>
))}
</div>
);
};
const Kpi: React.FC<{ title: string; index: number; children: React.ReactNode }> = ({ title, index, children }) => (
<Card className="animate-rise p-4" style={{ '--i': index } as React.CSSProperties}>
<p className="mb-1 text-sm font-semibold text-ink-500">{title}</p>
{children}
</Card>
);
const Row: React.FC<{ label: string; value: string }> = ({ label, value }) => (
<p className="tabular mt-1 flex items-center justify-between gap-3 text-sm">
<span className="text-ink-500">{label}</span>
<b className="text-ink-900">{value}</b>
</p>
);
export { Row as ReportRow, Kpi as ReportKpi };
+124
View File
@@ -0,0 +1,124 @@
import React, { useMemo, useState } from 'react';
import { Avatar } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { SplitBar } from '../ui/charts';
import { colorFor, formatEuro, formatHours } from '../../lib/format';
import type { GroupRow } from '../../lib/api';
type SortKey = 'name' | 'minutes' | 'billable_minutes' | 'amount' | 'utilization';
/**
* Tabelle wie im Harvest-Report: Name, Stunden mit Doppelbalken, Auslastung,
* abrechenbare Stunden mit Anteil, Betrag. Spaltenkoepfe sortieren.
*/
export const ReportTable: React.FC<{
rows: GroupRow[];
label: string;
kind: 'clients' | 'projects' | 'tasks' | 'users' | 'team';
showAmounts?: boolean;
onRowClick?: (row: GroupRow) => void;
totals?: boolean;
}> = ({ rows, label, kind, showAmounts = true, onRowClick, totals = true }) => {
const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: kind === 'users' ? 'name' : 'minutes', dir: kind === 'users' ? 1 : -1 });
const sorted = useMemo(
() =>
[...rows].sort((a, b) => {
const av = sort.key === 'name' ? a.name : (a[sort.key] ?? -1);
const bv = sort.key === 'name' ? b.name : (b[sort.key] ?? -1);
return (typeof av === 'string' ? av.localeCompare(bv as string, 'de') : (av as number) - (bv as number)) * sort.dir;
}),
[rows, sort]
);
const max = Math.max(1, ...rows.map((r) => r.minutes));
const showUtil = kind === 'users';
const sum = rows.reduce(
(s, r) => ({ minutes: s.minutes + r.minutes, billable: s.billable + r.billable_minutes, amount: s.amount + r.amount }),
{ minutes: 0, billable: 0, amount: 0 }
);
const head = (key: SortKey, text: string, align: 'left' | 'right' = 'right', className = '') => (
<th className={`px-3 py-3 ${align === 'right' ? 'text-right' : 'text-left'} ${className}`}>
<button
onClick={() => setSort({ key, dir: sort.key === key ? ((sort.dir * -1) as 1 | -1) : key === 'name' ? 1 : -1 })}
className={`inline-flex items-center gap-1 font-extrabold uppercase tracking-wider hover:text-ink-700 ${sort.key === key ? 'text-ink-700' : ''}`}
>
{text}
{sort.key === key && <Icon name={sort.dir === 1 ? 'chevronUp' : 'chevronDown'} className="h-3 w-3" strokeWidth={2.5} />}
</button>
</th>
);
if (rows.length === 0) return <p className="px-4 py-10 text-center text-sm text-ink-400">Keine Buchungen im Zeitraum.</p>;
return (
<div className="scrollbar-slim overflow-x-auto">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-[11px] text-ink-400">
{head('name', label, 'left', 'pl-4')}
{head('minutes', 'Stunden')}
<th className="w-[22%] px-3 py-3" />
{showUtil && head('utilization', 'Auslastung')}
{head('billable_minutes', 'Billable Std.')}
{showAmounts && head('amount', 'Billable Betrag', 'right', 'pr-4')}
</tr>
</thead>
<tbody>
{sorted.map((r, i) => {
const share = r.minutes ? Math.round((r.billable_minutes / r.minutes) * 100) : 0;
const clickable = Boolean(onRowClick);
return (
<tr
key={String(r.key)}
onClick={() => onRowClick?.(r)}
className={`animate-rise border-b border-ink-100 last:border-0 ${clickable ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-ink-50'}`}
style={{ '--i': Math.min(i, 15) } as React.CSSProperties}
>
<td className="py-3 pl-4 pr-3">
<div className="flex min-w-0 items-center gap-2.5">
{kind === 'users' || kind === 'team' ? (
<Avatar id={r.user_id} name={r.name} color={r.color} size={28} external={r.is_external} />
) : kind !== 'tasks' ? (
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(r.customer_id ?? r.project_id ?? r.name, r.color) }} />
) : null}
<span className="min-w-0">
<span className={`block truncate font-bold ${clickable ? 'text-ink-900 underline decoration-ink-200 underline-offset-4 group-hover:decoration-brand-400' : 'text-ink-800'}`}>{r.name}</span>
{r.customer_name && <span className="block truncate text-[11px] text-ink-400">{r.customer_name}{r.code ? ` · ${r.code}` : ''}</span>}
</span>
</div>
</td>
<td className="tabular px-3 py-3 text-right font-extrabold text-ink-900">{formatHours(r.minutes)}</td>
<td className="px-3 py-3">
<SplitBar a={r.billable_minutes} b={r.non_billable_minutes} max={max} delay={i * 30} />
</td>
{showUtil && (
<td className={`tabular px-3 py-3 text-right font-bold ${r.utilization == null ? 'text-ink-300' : r.utilization > 105 ? 'text-red-600' : r.utilization >= 75 ? 'text-brand-700' : 'text-ink-600'}`}>
{r.utilization == null ? '–' : `${r.utilization} %`}
</td>
)}
<td className="tabular px-3 py-3 text-right text-ink-700">
{formatHours(r.billable_minutes)} <span className="inline-block w-14 whitespace-nowrap text-ink-400">({share} %)</span>
</td>
{showAmounts && <td className="tabular py-3 pl-3 pr-4 text-right font-extrabold text-ink-900">{formatEuro(r.amount, 2)}</td>}
</tr>
);
})}
</tbody>
{totals && (
<tfoot>
<tr className="border-t-2 border-ink-200 bg-ink-50/60 font-extrabold text-ink-900">
<td className="py-3 pl-4 pr-3">Summe</td>
<td className="tabular px-3 py-3 text-right">{formatHours(sum.minutes)}</td>
<td />
{showUtil && <td />}
<td className="tabular px-3 py-3 text-right">
{formatHours(sum.billable)} <span className="inline-block w-14 whitespace-nowrap font-normal text-ink-400">({sum.minutes ? Math.round((sum.billable / sum.minutes) * 100) : 0} %)</span>
</td>
{showAmounts && <td className="tabular py-3 pl-3 pr-4 text-right">{formatEuro(sum.amount, 2)}</td>}
</tr>
</tfoot>
)}
</table>
</div>
);
};
+260
View File
@@ -0,0 +1,260 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
import { ReportTable } from './ReportTable';
import { ExportMenu } from './ExportMenu';
import { CustomerReport } from './CustomerReport';
import { ProjectReport } from './ProjectReport';
import { useAuth } from '../../lib/auth';
import { customersApi, qs, reportsApi, usersApi } from '../../lib/api';
import { useLoader, usePersistentState } from '../../lib/hooks';
import { MONTHS, addDays, addMonths, formatDate, formatEuro, formatHours, isoWeek, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { atLeast, type Customer, type DirectoryUser } from '../../types';
type Period = 'week' | 'month' | 'quarter' | 'year' | 'custom';
type Tab = 'clients' | 'projects' | 'tasks' | 'users';
function periodRange(period: Period, anchor: string, custom: { from: string; to: string }) {
if (period === 'week') {
const from = mondayOf(anchor);
return { from, to: addDays(from, 6), label: `KW ${isoWeek(from)} · ${formatDate(from)} – ${formatDate(addDays(from, 6))}` };
}
if (period === 'month') return { from: monthStart(anchor), to: monthEnd(anchor), label: `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}` };
if (period === 'quarter') {
const q = Math.floor((Number(anchor.slice(5, 7)) - 1) / 3);
const from = `${anchor.slice(0, 4)}-${String(q * 3 + 1).padStart(2, '0')}-01`;
return { from, to: monthEnd(addMonths(from, 2)), label: `Q${q + 1} ${anchor.slice(0, 4)}` };
}
if (period === 'year') return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31`, label: anchor.slice(0, 4) };
return { ...custom, label: `${formatDate(custom.from)} – ${formatDate(custom.to)}` };
}
export const Reports: React.FC = () => {
const { route, navigate } = useRouter();
const [kind, id] = route.params;
const view = kind === 'kunde' ? 'customer' : kind === 'projekt' ? 'project' : 'time';
return (
<div className="mx-auto max-w-[1500px]">
<div className="no-scrollbar mb-4 flex items-center gap-1 overflow-x-auto overflow-y-hidden border-b border-ink-200">
{(
[
['time', 'Zeitreport', 'reports'],
['customer', 'Kundenreport', 'reports/kunde'],
['project', 'Projektreport', 'reports/projekt'],
] as const
).map(([key, label, path]) => (
<button
key={key}
onClick={() => navigate(path)}
className={`relative whitespace-nowrap px-4 py-2.5 text-sm font-bold transition ${view === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}
>
{label}
{view === key && <span className="animate-pop absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-brand-500" />}
</button>
))}
</div>
{view === 'time' && <TimeReport />}
{view === 'customer' && <CustomerReport id={id ? Number(id) : null} />}
{view === 'project' && <ProjectReport id={id ? Number(id) : null} />}
</div>
);
};
const TimeReport: React.FC = () => {
const { user } = useAuth();
const { navigate } = useRouter();
const [period, setPeriod] = usePersistentState<Period>('pulse_report_period', 'month');
const [anchor, setAnchor] = useState(todayIso());
const [custom, setCustom] = useState({ from: monthStart(todayIso()), to: todayIso() });
const [tab, setTab] = usePersistentState<Tab>('pulse_report_tab', 'users');
const [customerId, setCustomerId] = useState<number | ''>('');
const [team, setTeam] = useState<number | ''>('');
const [includeFixed, setIncludeFixed] = useState(false);
const [customers, setCustomers] = useState<Customer[]>([]);
const [leads, setLeads] = useState<DirectoryUser[]>([]);
useEffect(() => {
customersApi.list().then(setCustomers).catch(() => undefined);
usersApi
.directory()
.then((list) => setLeads(list.filter((u) => (u.role === 'lead' || u.role === 'management') && u.is_active)))
.catch(() => undefined);
}, []);
const range = periodRange(period, anchor, custom);
const report = useLoader(
() => reportsApi.time({ from: range.from, to: range.to, customer_id: customerId || null, team: team || null, include_fixed: includeFixed }),
[range.from, range.to, customerId, team, includeFixed]
);
const shift = (step: number) => {
if (period === 'week') setAnchor(addDays(anchor, 7 * step));
if (period === 'month') setAnchor(addMonths(anchor, step));
if (period === 'quarter') setAnchor(addMonths(anchor, 3 * step));
if (period === 'year') setAnchor(`${Number(anchor.slice(0, 4)) + step}-01-01`);
};
const exportParams = { from: range.from, to: range.to, customer_id: customerId || undefined, team: team || undefined, include_fixed: includeFixed ? 1 : undefined };
const groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
const exportOptions = useMemo(
() => [
{ label: `PDF – ${groupName}`, hint: 'Mit Logo, Kopf und Fuß', path: `/exports/report${qs({ ...exportParams, group: tab, format: 'pdf' })}`, filename: 'Zeitreport.pdf' },
{ label: `Excel – ${groupName}`, hint: 'Mit Summenformeln', path: `/exports/report${qs({ ...exportParams, group: tab, format: 'xlsx' })}`, filename: 'Zeitreport.xlsx' },
{ label: `CSV – ${groupName}`, path: `/exports/report${qs({ ...exportParams, group: tab, format: 'csv' })}`, filename: 'Zeitreport.csv' },
{ label: 'Excel – alle Buchungen', hint: 'Detailliste für die Rechnung', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'xlsx' })}`, filename: 'Zeitreport-Detail.xlsx' },
{ label: 'PDF – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'pdf' })}`, filename: 'Zeitreport-Detail.pdf' },
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'csv' })}`, filename: 'Zeitreport-Detail.csv' },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[tab, range.from, range.to, customerId, team, includeFixed]
);
const data = report.data;
const s = data?.summary;
const share = s && s.minutes ? (s.billable_minutes / s.minutes) * 100 : 0;
const capacity = data?.users.reduce((sum, u) => sum + (u.capacity_minutes ?? 0), 0) ?? 0;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
{atLeast(user, 'management') && leads.length > 0 && (
<select className={`${inputClass} !w-auto max-w-[220px]`} value={team} onChange={(e) => setTeam(e.target.value ? Number(e.target.value) : '')}>
<option value="">Alle Teams</option>
{leads.map((l) => (
<option key={l.id} value={l.id}>
Team {l.name}
</option>
))}
</select>
)}
<select className={`${inputClass} !w-auto max-w-[220px]`} value={customerId} onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : '')}>
<option value="">Alle Kunden</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<select className={`${inputClass} !w-auto`} value={period} onChange={(e) => setPeriod(e.target.value as Period)}>
<option value="week">Woche</option>
<option value="month">Monat</option>
<option value="quarter">Quartal</option>
<option value="year">Jahr</option>
<option value="custom">Zeitraum</option>
</select>
{period === 'custom' ? (
<div className="flex items-center gap-2">
<input type="date" className={`${inputClass} !w-auto`} value={custom.from} onChange={(e) => setCustom({ ...custom, from: e.target.value })} />
<span className="text-ink-400">–</span>
<input type="date" className={`${inputClass} !w-auto`} value={custom.to} onChange={(e) => setCustom({ ...custom, to: e.target.value })} />
</div>
) : (
<div className="flex items-center rounded-xl border border-ink-200 bg-surface">
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Zurück">
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
</button>
<span className="tabular flex items-center gap-2 border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-700">
<Icon name="calendar" className="h-4 w-4 text-ink-400" />
{range.label}
</span>
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Weiter">
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
</button>
</div>
)}
</div>
{report.error && <ErrorBox message={report.error} onRetry={report.reload} />}
{!data ? (
<Spinner label="Lade Report…" />
) : (
<>
<Card className={`animate-rise grid gap-6 p-5 transition-opacity sm:p-6 lg:grid-cols-[1fr_1.2fr_1.3fr_1fr] ${report.loading ? 'opacity-60' : ''}`}>
<div>
<p className="text-sm font-semibold text-ink-500">Gesamtstunden</p>
<CountUp value={s!.minutes / 60} format={(v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} className="text-4xl font-extrabold text-ink-900" />
<p className="mt-1 text-xs text-ink-400">{s!.entries} Buchungen</p>
</div>
<div className="flex items-center gap-4">
<Donut percent={share} size={96} stroke={13} />
<div className="flex-1 space-y-1.5 text-sm">
<p className="flex items-center justify-between gap-3">
<span className="flex items-center gap-2 font-semibold text-ink-600">
<span className="h-3 w-3 rounded-sm" style={{ background: BILLABLE }} /> Billable
</span>
<b className="tabular text-ink-900">{formatHours(s!.billable_minutes)}</b>
</p>
<p className="flex items-center justify-between gap-3">
<span className="flex items-center gap-2 font-semibold text-ink-600">
<span className="h-3 w-3 rounded-sm" style={{ background: NON_BILLABLE }} /> Non-billable
</span>
<b className="tabular text-ink-900">{formatHours(s!.non_billable_minutes)}</b>
</p>
</div>
</div>
<div>
<p className="text-sm font-semibold text-ink-500">Billable Betrag</p>
<CountUp value={s!.amount} format={(v) => formatEuro(v, 2)} className="text-4xl font-extrabold text-ink-900" />
<label className="mt-2 flex cursor-pointer items-center gap-2 text-sm font-semibold text-ink-700">
<input type="checkbox" checked={includeFixed} onChange={(e) => setIncludeFixed(e.target.checked)} className="h-4 w-4 accent-[var(--color-brand-500)]" />
Festpreisprojekte einbeziehen
</label>
<p className="mt-1 text-[11px] leading-snug text-ink-400">
Festpreis-Beträge werden über den virtuellen Stundensatz (Budget ÷ gebuchte Stunden) verteilt – nur für den internen Gebrauch.
</p>
</div>
<div>
<p className="text-sm font-semibold text-ink-500">Auslastung</p>
<CountUp value={capacity ? (s!.minutes / capacity) * 100 : 0} format={(v) => `${Math.round(v)} %`} className="text-4xl font-extrabold text-ink-900" />
<p className="mt-1 text-xs text-ink-400">Kapazität {formatHours(capacity, 0)} h bis heute</p>
{!includeFixed && s!.fixed_amount > 0 && <p className="mt-2 text-[11px] text-ink-400">+ {formatEuro(s!.fixed_amount)} aus Festpreisprojekten</p>}
</div>
</Card>
<Card className="animate-rise overflow-hidden" style={{ '--i': 1 } as React.CSSProperties}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-ink-100 px-4 pt-2">
<div className="no-scrollbar flex gap-1 overflow-x-auto overflow-y-hidden">
{(
[
['clients', 'Kunden'],
['projects', 'Projekte'],
['tasks', 'Tasks'],
['users', 'Mitarbeiter'],
] as const
).map(([key, label]) => (
<button key={key} onClick={() => setTab(key)} className={`relative whitespace-nowrap px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
{label}
<span className="ml-1 text-[11px] text-ink-400">{data[key].length}</span>
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
</button>
))}
</div>
<div className="flex items-center gap-2 pb-2">
<ExportMenu options={exportOptions} />
<IconButton icon="printer" label="Drucken" onClick={() => window.print()} className="hidden border border-ink-200 sm:block" />
</div>
</div>
<ReportTable
key={tab}
rows={data[tab]}
kind={tab}
label={{ clients: 'Kunde', projects: 'Projekt', tasks: 'Task', users: 'Name' }[tab]}
onRowClick={
tab === 'clients'
? (row) => navigate(`reports/kunde/${row.customer_id}`)
: tab === 'projects'
? (row) => navigate(`reports/projekt/${row.project_id}`)
: undefined
}
/>
</Card>
</>
)}
</div>
);
};
export { periodRange };
+102
View File
@@ -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>
);
};
+437
View File
@@ -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>
);
};
+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>
);
};
+266
View File
@@ -0,0 +1,266 @@
import React, { useMemo, useState } from 'react';
import { Avatar, Badge, Button, Card, ErrorBox, IconButton, Segmented, Spinner, inputClass } from '../ui/controls';
import { ConfirmDialog } from '../ui/Modal';
import { Icon } from '../ui/Icon';
import { UserForm } from './UserForm';
import { buildTree, type TreeNode } from './hierarchy';
import { useAuth } from '../../lib/auth';
import { usersApi } from '../../lib/api';
import { useLoader, usePersistentState } from '../../lib/hooks';
import { relativeTime } from '../../lib/format';
import { useToast } from '../Toast';
import { ROLE_LABELS, type ManagedUser, type Role } from '../../types';
export const ROLE_TONE: Record<Role, 'neutral' | 'sky' | 'violet' | 'brand'> = { user: 'neutral', lead: 'sky', management: 'violet', admin: 'brand' };
/** Team: Organigramm und Liste. `embedded` fuer die Benutzerverwaltung in den Einstellungen. */
export const Team: React.FC<{ embedded?: boolean }> = ({ embedded }) => {
const { user: me } = useAuth();
const { addToast } = useToast();
const { data, error, reload } = useLoader(() => usersApi.list(), []);
const [mode, setMode] = usePersistentState<'tree' | 'list'>('pulse_team_mode', embedded ? 'list' : 'tree');
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<'all' | 'internal' | 'external' | 'inactive'>('all');
const [form, setForm] = useState<{ user: ManagedUser | null; manager?: number | null } | null>(null);
const [remove, setRemove] = useState<ManagedUser | null>(null);
const users = data?.users ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return users.filter((u) => {
if (filter === 'internal' && u.is_external) return false;
if (filter === 'external' && !u.is_external) return false;
if (filter === 'inactive' ? u.is_active : !u.is_active && filter !== 'all') return false;
return !q || `${u.name} ${u.username} ${u.email ?? ''} ${u.job_title ?? ''} ${u.company ?? ''}`.toLowerCase().includes(q);
});
}, [users, search, filter]);
if (error && !data) return <ErrorBox message={error} onRetry={reload} />;
if (!data) return <Spinner label="Lade Team…" />;
const counts = {
total: users.filter((u) => u.is_active && u.role !== 'admin').length,
leads: users.filter((u) => u.is_active && (u.role === 'lead' || u.role === 'management')).length,
external: users.filter((u) => u.is_active && u.is_external).length,
sso: users.filter((u) => u.source === 'sso').length,
};
const doRemove = async () => {
if (!remove) return;
const target = remove;
setRemove(null);
try {
await usersApi.remove(target.id);
addToast(`${target.name} gelöscht.`, 'success');
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
}
};
return (
<div className={embedded ? '' : 'mx-auto max-w-[1500px]'}>
{!embedded && (
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
{[
['Aktive Benutzer', counts.total, 'team'],
['Führungskräfte', counts.leads, 'shield'],
['Externe', counts.external, 'globe'],
['Aus dem AD (SSO)', counts.sso, 'key'],
].map(([label, value, icon], i) => (
<Card key={label as string} className="animate-rise flex items-center gap-3 p-4" style={{ '--i': i } as React.CSSProperties}>
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
<Icon name={icon as 'team'} className="h-5 w-5" />
</span>
<span>
<span className="tabular block text-2xl font-extrabold text-ink-900">{value as number}</span>
<span className="block text-[11px] font-bold uppercase tracking-wider text-ink-400">{label as string}</span>
</span>
</Card>
))}
</div>
)}
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="relative min-w-[200px] flex-1 sm:max-w-xs">
<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, Firma…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Segmented
size="sm"
value={filter}
onChange={setFilter}
options={[
{ value: 'all', label: 'Alle' },
{ value: 'internal', label: 'Intern' },
{ value: 'external', label: 'Extern' },
{ value: 'inactive', label: 'Inaktiv' },
]}
/>
{!embedded && (
<Segmented
size="sm"
value={mode}
onChange={setMode}
options={[
{ value: 'tree', label: 'Hierarchie' },
{ value: 'list', label: 'Liste' },
]}
/>
)}
<Button icon="plus" className="ml-auto" onClick={() => setForm({ user: null })}>
Neuer Benutzer
</Button>
</div>
{mode === 'tree' && !embedded ? (
<OrgChart users={users} visible={new Set(filtered.map((u) => u.id))} onEdit={(u) => setForm({ user: u })} onAdd={(managerId) => setForm({ user: null, manager: managerId })} meId={me!.id} />
) : (
<Card className="overflow-hidden">
<div className="scrollbar-slim overflow-x-auto">
<table className="w-full min-w-[760px] text-sm">
<thead>
<tr className="border-b border-ink-100 text-left text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Rolle</th>
<th className="px-4 py-3">Vorgesetzter</th>
<th className="px-4 py-3">Konto</th>
<th className="px-4 py-3 text-right">Projekte</th>
<th className="px-4 py-3">Letzte Anmeldung</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{filtered.map((u, i) => (
<tr key={u.id} className={`animate-rise border-b border-ink-100 last:border-0 hover:bg-ink-50 ${u.is_active ? '' : 'opacity-50'}`} style={{ '--i': Math.min(i, 12) } as React.CSSProperties}>
<td className="px-4 py-2.5">
<div className="flex items-center gap-3">
<Avatar id={u.id} name={u.name} color={u.color} size={34} external={u.is_external} />
<div className="min-w-0">
<p className="truncate font-bold text-ink-900">{u.name}</p>
<p className="truncate text-xs text-ink-400">{u.email || u.username}</p>
</div>
</div>
</td>
<td className="px-4 py-2.5">
<Badge tone={ROLE_TONE[u.role]}>{ROLE_LABELS[u.role]}</Badge>
</td>
<td className="px-4 py-2.5 text-xs text-ink-600">{users.find((m) => m.id === u.manager_id)?.name ?? '–'}</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
<Badge tone={u.source === 'sso' ? 'sky' : 'neutral'}>{u.source === 'sso' ? 'SSO' : 'lokal'}</Badge>
{u.is_external && <Badge tone="amber">extern{u.company ? ` · ${u.company}` : ''}</Badge>}
{!u.is_active && <Badge tone="red">inaktiv</Badge>}
</div>
</td>
<td className="tabular px-4 py-2.5 text-right font-bold text-ink-700">{u.project_count}</td>
<td className="px-4 py-2.5 text-xs text-ink-400">{u.last_login_at ? relativeTime(u.last_login_at) : 'nie'}</td>
<td className="px-4 py-2.5">
{u.can_manage && (
<div className="flex justify-end">
<IconButton icon="edit" label="Bearbeiten" onClick={() => setForm({ user: u })} />
{u.source === 'local' && u.id !== me!.id && <IconButton icon="trash" tone="danger" label="Löschen" onClick={() => setRemove(u)} />}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{form && (
<UserForm user={form.user} users={users} roles={data.assignable_roles} defaultManager={form.manager} onClose={() => setForm(null)} onSaved={reload} />
)}
{remove && (
<ConfirmDialog
title={`${remove.name} löschen?`}
message={remove.entry_count ? `Achtung: ${remove.entry_count} Buchungen – das Löschen wird abgelehnt. Bitte deaktivieren.` : 'Das Konto wird endgültig entfernt. Unterstellte rücken eine Ebene nach oben.'}
onConfirm={doRemove}
onCancel={() => setRemove(null)}
/>
)}
</div>
);
};
/** Organigramm: Karten mit Verbindungslinien, Ebenen aufklappbar. */
const OrgChart: React.FC<{
users: ManagedUser[];
visible: Set<number>;
onEdit: (u: ManagedUser) => void;
onAdd: (managerId: number) => void;
meId: number;
}> = ({ users, visible, onEdit, onAdd, meId }) => {
// Administratoren gehoeren nicht in die Mitarbeiterhierarchie (in der Liste bleiben sie).
const tree = useMemo(() => buildTree(users.filter((u) => u.role !== 'admin')), [users]);
// Ein Knoten bleibt sichtbar, wenn er selbst oder jemand darunter zum Filter passt.
const matches = (node: TreeNode<ManagedUser>): boolean => visible.has(node.user.id) || node.children.some(matches);
return (
<Card className="scrollbar-slim overflow-x-auto p-4 sm:p-6">
<div className="min-w-fit space-y-3">
{tree.filter(matches).map((node, i) => (
<OrgNode key={node.user.id} node={node} depth={0} index={i} matches={matches} onEdit={onEdit} onAdd={onAdd} meId={meId} />
))}
</div>
</Card>
);
};
const OrgNode: React.FC<{
node: TreeNode<ManagedUser>;
depth: number;
index: number;
matches: (n: TreeNode<ManagedUser>) => boolean;
onEdit: (u: ManagedUser) => void;
onAdd: (managerId: number) => void;
meId: number;
}> = ({ node, depth, index, matches, onEdit, onAdd, meId }) => {
const [open, setOpen] = useState(depth < 2);
const u = node.user;
const children = node.children.filter(matches);
return (
<div className="animate-rise" style={{ '--i': index } as React.CSSProperties}>
<div className="flex items-center gap-2">
{children.length > 0 ? (
<button onClick={() => setOpen(!open)} className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-ink-400 hover:bg-ink-100" aria-label={open ? 'Zuklappen' : 'Aufklappen'}>
<Icon name="chevronRight" className={`h-4 w-4 transition-transform ${open ? 'rotate-90' : ''}`} strokeWidth={2.2} />
</button>
) : (
<span className="w-6 shrink-0" />
)}
<div
className={`group flex min-w-[260px] items-center gap-3 rounded-2xl border bg-surface p-2.5 pr-3 shadow-card transition hover:shadow-lift ${
u.id === meId ? 'border-brand-300' : 'border-ink-200'
} ${u.is_active ? '' : 'opacity-50'}`}
>
<Avatar id={u.id} name={u.name} color={u.color} size={40} external={u.is_external} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-extrabold text-ink-900">
{u.name}
{u.id === meId && <span className="ml-1 text-[10px] font-bold text-brand-600">(du)</span>}
</p>
<p className="truncate text-xs text-ink-400">{u.is_external ? `Extern · ${u.company ?? ''}` : u.job_title || '–'}</p>
</div>
<Badge tone={ROLE_TONE[u.role]}>{ROLE_LABELS[u.role]}</Badge>
{children.length > 0 && <span className="tabular rounded-full bg-ink-100 px-2 py-0.5 text-[10px] font-extrabold text-ink-500">{children.length}</span>}
<div className="flex opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
{u.role !== 'user' && <IconButton icon="plus" label={`Mitarbeiter unter ${u.name} anlegen`} onClick={() => onAdd(u.id)} />}
{u.can_manage && <IconButton icon="edit" label="Bearbeiten" onClick={() => onEdit(u)} />}
</div>
</div>
</div>
{open && children.length > 0 && (
<div className="relative ml-[11px] mt-3 space-y-3 border-l-2 border-dashed border-ink-200 pl-6">
{children.map((child, i) => (
<div key={child.user.id} className="relative before:absolute before:-left-6 before:top-7 before:h-0.5 before:w-5 before:bg-ink-200">
<OrgNode node={child} depth={depth + 1} index={i} matches={matches} onEdit={onEdit} onAdd={onAdd} meId={meId} />
</div>
))}
</div>
)}
</div>
);
};
+211
View File
@@ -0,0 +1,211 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { Button, Field, Toggle, inputClass } from '../ui/controls';
import { useAuth } from '../../lib/auth';
import { usersApi } from '../../lib/api';
import { useToast } from '../Toast';
import { ROLE_LABELS, type ManagedUser, type Role } from '../../types';
import { subordinates } from './hierarchy';
/**
* Felder dieses Formulars gehoeren zu einem fremden Konto - Browser-Autofill
* und Passwortmanager (auch 1Password, LastPass, Bitwarden) sollen sie nicht
* als Anmelde- oder Registrierungsformular erkennen.
*/
function noAutofill(name: string) {
return {
name: `pulse-user-${name}`,
type: 'text',
autoComplete: 'off',
spellCheck: false,
'data-lpignore': 'true',
'data-1p-ignore': 'true',
'data-bwignore': 'true',
'data-form-type': 'other',
} as const;
}
const COLORS =['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899'];
export const UserForm: React.FC<{
user?: ManagedUser | null;
users: ManagedUser[];
roles: Role[];
defaultManager?: number | null;
onClose: () => void;
onSaved: () => void;
}> = ({ user, users, roles, defaultManager, onClose, onSaved }) => {
const { user: me, settings } = useAuth();
const { addToast } = useToast();
const editing = Boolean(user);
const sso = user?.source === 'sso';
const [form, setForm] = useState({
name: user?.name ?? '',
username: user?.username ?? '',
email: user?.email ?? '',
password: '',
role: (user?.role ?? 'user') as Role,
manager_id: user?.manager_id ?? defaultManager ?? (me?.role === 'lead' ? me.id : null),
job_title: user?.job_title ?? '',
is_external: user?.is_external ?? false,
company: user?.company ?? '',
weekly_hours: user?.weekly_hours == null ? '' : String(user.weekly_hours),
color: user?.color ?? COLORS[Math.floor(Math.random() * COLORS.length)],
is_active: user?.is_active ?? true,
});
const [busy, setBusy] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const set = (patch: Partial<typeof form>) => setForm({ ...form, ...patch });
// Als Vorgesetzte kommen nur Fuehrungsrollen in Frage - und nie man selbst
// oder jemand aus dem eigenen Unterbaum (sonst entsteht ein Kreis).
const blocked = new Set(user ? [user.id, ...subordinates(users, user.id).map((u) => u.id)] : []);
const managers = users.filter((u) => (u.role === 'lead' || u.role === 'management') && u.is_active && !blocked.has(u.id));
const roleOptions = user && !roles.includes(user.role) ? [user.role, ...roles] : roles;
const save = async () => {
setBusy(true);
try {
const payload: Record<string, unknown> = {
name: form.name,
role: form.role,
manager_id: form.manager_id,
job_title: form.job_title,
is_external: form.is_external,
company: form.is_external ? form.company : null,
weekly_hours: form.weekly_hours === '' ? null : Number(form.weekly_hours.replace(',', '.')),
color: form.color,
is_active: form.is_active,
};
if (!sso) {
payload.username = form.username;
payload.email = form.email || null;
}
if (form.password) payload.password = form.password;
if (editing) await usersApi.update(user!.id, payload);
else await usersApi.create(payload);
addToast(editing ? 'Benutzer gespeichert.' : `${form.name} angelegt.`, 'success');
onSaved();
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
return (
<Modal
title={editing ? 'Benutzer bearbeiten' : 'Neuer Benutzer'}
subtitle={editing ? `${user!.username} · ${sso ? 'aus dem AD (SSO)' : 'lokales Konto'}` : 'Lokales Konto – auch für externe Mitarbeiter'}
onClose={onClose}
size="lg"
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="check" busy={busy} onClick={save}>
Speichern
</Button>
</div>
}
>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Name" required>
<input {...noAutofill('name')} className={inputClass} value={form.name} autoFocus onChange={(e) => set({ name: e.target.value })} />
</Field>
<Field label="Jobtitel">
<input {...noAutofill('title')} className={inputClass} value={form.job_title} onChange={(e) => set({ job_title: e.target.value })} placeholder="z. B. Senior Consultant" />
</Field>
<Field label="Benutzername" required hint={sso ? 'Kommt aus dem AD' : undefined}>
<input {...noAutofill('login')} className={inputClass} value={form.username} disabled={sso} autoCapitalize="none" onChange={(e) => set({ username: e.target.value.trim() })} />
</Field>
<Field label="E-Mail">
<input {...noAutofill('mail')} inputMode="email" className={inputClass} value={form.email} disabled={sso} onChange={(e) => set({ email: e.target.value })} />
</Field>
{!sso && (
<Field label={editing ? 'Neues Passwort' : 'Passwort'} required={!editing} hint={editing ? 'Leer lassen = unverändert' : 'Mindestens 8 Zeichen'}>
<div className="relative">
{/*
Bewusst kein type="password": hier setzt ein Admin das Passwort
eines ANDEREN Benutzers. Ein echtes Passwortfeld laesst den
Passwortmanager des Browsers anspringen - Brave stuerzt dabei
(Autofill beim Tippen) komplett ab. Maskiert wird per CSS.
*/}
<input
{...noAutofill('initial-secret')}
className={`${inputClass} pr-10`}
style={showPassword ? undefined : ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)}
value={form.password}
onChange={(e) => set({ password: e.target.value })}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md px-1.5 py-1 text-[11px] font-bold text-ink-400 hover:text-ink-700"
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
>
{showPassword ? 'verbergen' : 'zeigen'}
</button>
</div>
</Field>
)}
<Field label="Rolle" required>
<select className={inputClass} value={form.role} disabled={user?.id === me?.id} onChange={(e) => set({ role: e.target.value as Role })}>
{roleOptions.map((r) => (
<option key={r} value={r} disabled={!roles.includes(r)}>
{ROLE_LABELS[r]}
</option>
))}
</select>
</Field>
<Field label="Vorgesetzter">
<select className={inputClass} value={form.manager_id ?? ''} onChange={(e) => set({ manager_id: e.target.value ? Number(e.target.value) : null })}>
{me?.role !== 'lead' && <option value="">– keiner (oberste Ebene) –</option>}
{managers.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({ROLE_LABELS[m.role]})
</option>
))}
</select>
</Field>
<Field label="Wochenstunden" hint={`Leer = Standard (${settings.weekly_hours} h)`}>
<input inputMode="decimal" className={inputClass} value={form.weekly_hours} placeholder={String(settings.weekly_hours)} onChange={(e) => set({ weekly_hours: e.target.value })} />
</Field>
</div>
<div className="rounded-2xl border border-ink-200 p-4">
<Toggle
checked={form.is_external}
onChange={(v) => set({ is_external: v })}
label="Externer Mitarbeiter"
hint="Freelancer oder Partner außerhalb der Organisation – meldet sich lokal an, sieht nur zugewiesene Projekte."
/>
{form.is_external && (
<Field label="Firma" className="mt-3">
<input className={inputClass} value={form.company} onChange={(e) => set({ company: e.target.value })} placeholder="z. B. Freelancer, Partner GmbH" />
</Field>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap gap-2">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => set({ color: c })}
aria-label={`Farbe ${c}`}
className={`h-7 w-7 rounded-full transition hover:scale-110 ${form.color === c ? 'ring-2 ring-ink-800 ring-offset-2 ring-offset-surface' : ''}`}
style={{ background: c }}
/>
))}
</div>
{editing && user!.id !== me?.id && <Toggle checked={form.is_active} onChange={(v) => set({ is_active: v })} label="Aktiv" />}
</div>
</div>
</Modal>
);
};
+54
View File
@@ -0,0 +1,54 @@
import { atLeast, type AppUser, type DirectoryUser } from '../../types';
/** Alle Unterstellten (rekursiv) eines Benutzers. */
export function subordinates<T extends { id: number; manager_id: number | null }>(users: T[], leadId: number): T[] {
const result: T[] = [];
const seen = new Set<number>([leadId]);
const queue = [leadId];
while (queue.length) {
const current = queue.shift()!;
for (const u of users) {
if (u.manager_id === current && !seen.has(u.id)) {
seen.add(u.id);
result.push(u);
queue.push(u.id);
}
}
}
return result;
}
/** Wen darf der Benutzer einsehen - sich selbst zuerst. */
export function visibleTeam(users: DirectoryUser[], me: AppUser): DirectoryUser[] {
const self = users.find((u) => u.id === me.id);
const others = atLeast(me, 'management')
? users.filter((u) => u.id !== me.id)
: me.role === 'lead'
? subordinates(users, me.id)
: [];
// Administratoren sind keine Mitarbeiter - weder in Team-Auswahlen noch in Exporten.
const staff = others.filter((u) => u.is_active && u.role !== 'admin').sort((a, b) => a.name.localeCompare(b.name, 'de'));
return [...(self && self.role !== 'admin' ? [self] : []), ...staff];
}
export interface TreeNode<T> {
user: T;
children: TreeNode<T>[];
}
/** Baum aus der manager_id-Kette. Verwaiste Knoten landen oben. */
export function buildTree<T extends { id: number; manager_id: number | null; name: string }>(users: T[]): TreeNode<T>[] {
const byId = new Map(users.map((u) => [u.id, { user: u, children: [] as TreeNode<T>[] }]));
const roots: TreeNode<T>[] = [];
for (const node of byId.values()) {
const parent = node.user.manager_id != null ? byId.get(node.user.manager_id) : undefined;
if (parent && parent !== node) parent.children.push(node);
else roots.push(node);
}
const sort = (nodes: TreeNode<T>[]) => {
nodes.sort((a, b) => b.children.length - a.children.length || a.user.name.localeCompare(b.user.name, 'de'));
nodes.forEach((n) => sort(n.children));
};
sort(roots);
return roots;
}
+68
View File
@@ -0,0 +1,68 @@
import React from 'react';
/* Umriss-Icons (Heroicons v1, wie im perinco CRM) an einer Stelle. */
const PATHS = {
dashboard:
'M4 5a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-3zm10-4a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z',
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
folder: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
team: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
building:
'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
chart: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
settings:
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065zM15 12a3 3 0 11-6 0 3 3 0 016 0z',
plus: 'M12 4v16m8-8H4',
close: 'M6 18L18 6M6 6l12 12',
edit: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z',
trash:
'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
chevronLeft: 'M15 19l-7-7 7-7',
chevronRight: 'M9 5l7 7-7 7',
chevronDown: 'M19 9l-7 7-7-7',
chevronUp: 'M5 15l7-7 7 7',
download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4',
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
check: 'M5 13l4 4L19 7',
calendar: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
logout: 'M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1',
menu: 'M4 6h16M4 12h16M4 18h16',
sun: 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z',
moon: 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z',
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
key: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
tag: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z',
euro: 'M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z',
pot: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
grid: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
arrowUp: 'M5 10l7-7m0 0l7 7m-7-7v18',
arrowDown: 'M19 14l-7 7m0 0l-7-7m7 7V3',
sort: 'M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4',
external: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14',
printer: 'M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z',
archive:
'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
shield: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
document: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
refresh: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15',
globe: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
more: 'M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z',
copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
} as const;
export type IconName = keyof typeof PATHS;
export const Icon: React.FC<{ name: IconName; className?: string; strokeWidth?: number; title?: string }> = ({
name,
className = 'h-5 w-5',
strokeWidth = 1.8,
title,
}) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden={title ? undefined : true}>
{title && <title>{title}</title>}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d={PATHS[name]} />
</svg>
);
+135
View File
@@ -0,0 +1,135 @@
import React, { useEffect, useRef } from 'react';
import { Icon } from './Icon';
interface ModalProps {
title: string;
subtitle?: React.ReactNode;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
const SIZES = { sm: 'sm:max-w-sm', md: 'sm:max-w-lg', lg: 'sm:max-w-2xl', xl: 'sm:max-w-4xl' };
/** Offene Dialoge in Oeffnungsreihenfolge - Escape schliesst nur den obersten. */
const openModals: symbol[] = [];
function useDismiss(onClose: () => void) {
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
const token = Symbol('modal');
openModals.push(token);
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && openModals[openModals.length - 1] === token) closeRef.current();
};
window.addEventListener('keydown', onKeyDown);
// Hintergrund nicht mitscrollen (wichtig auf dem Handy).
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKeyDown);
openModals.splice(openModals.indexOf(token), 1);
document.body.style.overflow = previous;
};
}, []);
// Nur ein Klick, der im Hintergrund beginnt und endet, schliesst.
const pressed = useRef(false);
return {
onMouseDown: (e: React.MouseEvent) => {
pressed.current = e.target === e.currentTarget;
},
onClick: (e: React.MouseEvent) => {
if (pressed.current && e.target === e.currentTarget) closeRef.current();
},
};
}
/**
* Dialog. Auf dem Handy als Sheet von unten ueber die volle Breite, auf dem
* Desktop zentriert.
*/
export const Modal: React.FC<ModalProps> = ({ title, subtitle, onClose, children, footer, size = 'md' }) => {
const dismiss = useDismiss(onClose);
return (
<div
{...dismiss}
className="animate-fade fixed inset-0 z-[100] flex items-end justify-center bg-ink-950/50 backdrop-blur-sm sm:items-center sm:p-4"
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className={`animate-sheet sm:animate-pop flex max-h-[94dvh] w-full flex-col overflow-hidden rounded-t-3xl border border-ink-200 bg-surface shadow-lift sm:rounded-2xl ${SIZES[size]}`}
>
<div className="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-ink-200 sm:hidden" />
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-ink-100 px-5 py-4 sm:px-6 sm:py-5">
<div className="min-w-0">
<h2 className="truncate text-lg text-ink-900 sm:text-xl">{title}</h2>
{subtitle && <div className="mt-0.5 text-xs font-semibold text-ink-400">{subtitle}</div>}
</div>
<button
onClick={onClose}
className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-ink-100 hover:text-ink-800"
aria-label="Dialog schließen"
>
<Icon name="close" className="h-5 w-5" />
</button>
</div>
<div className="scrollbar-slim flex-1 overflow-y-auto px-5 py-5 sm:px-6">{children}</div>
{footer && <div className="safe-bottom shrink-0 border-t border-ink-100 bg-ink-50/60 px-5 py-4 sm:px-6">{footer}</div>}
</div>
</div>
);
};
interface ConfirmProps {
title: string;
message: React.ReactNode;
confirmLabel?: string;
tone?: 'danger' | 'neutral';
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmDialog: React.FC<ConfirmProps> = ({
title,
message,
confirmLabel = 'Löschen',
tone = 'danger',
onConfirm,
onCancel,
}) => {
const dismiss = useDismiss(onCancel);
return (
<div {...dismiss} className="animate-fade fixed inset-0 z-[130] flex items-center justify-center bg-ink-950/50 p-4 backdrop-blur-sm">
<div className="animate-pop w-full max-w-sm rounded-2xl border border-ink-200 bg-surface p-7 text-center shadow-lift">
<div
className={`mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full ${
tone === 'danger' ? 'bg-red-50 text-red-600' : 'bg-brand-50 text-brand-700'
}`}
>
<Icon name={tone === 'danger' ? 'warning' : 'info'} className="h-7 w-7" />
</div>
<h3 className="text-lg text-ink-900">{title}</h3>
<div className="mt-2 text-sm text-ink-500">{message}</div>
<div className="mt-7 grid grid-cols-2 gap-3">
<button onClick={onCancel} className="rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100">
Abbrechen
</button>
<button
onClick={onConfirm}
className={`rounded-xl py-3 text-sm font-bold text-white transition ${
tone === 'danger' ? 'bg-red-600 hover:bg-red-700' : 'bg-brand-500 hover:bg-brand-600'
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
};
+420
View File
@@ -0,0 +1,420 @@
import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useCountUp, useGrow } from '../../lib/hooks';
/*
* Diagramme von Hand (SVG/CSS) - Farben kommen aus den Theme-Variablen, damit
* alle vier Designs in hell und dunkel ohne Sonderfall funktionieren.
*
* Billable ist durchgaengig die Markenfarbe, Non-billable ein helles Pendant
* derselben Farbe - so liest man "Anteil" statt "zwei Kategorien".
*/
export const BILLABLE = 'var(--color-brand-500)';
export const NON_BILLABLE = 'var(--color-brand-200)';
function niceMax(value: number, steps = 4) {
if (value <= 0) return steps;
const raw = value / steps;
const magnitude = 10 ** Math.floor(Math.log10(raw));
const factor = [1, 2, 2.5, 5, 10].find((f) => f * magnitude >= raw) ?? 10;
return factor * magnitude * steps;
}
export interface StackPoint {
key: string;
label: string;
a: number; // billable
b: number; // non-billable
highlight?: boolean;
}
/**
* Gestapelte Saeulen. Beim Wechsel wachsen die Saeulen neu aus der Grundlinie,
* Hover zeigt eine Karte mit beiden Werten und Anteil.
*/
export const StackedBars: React.FC<{
points: StackPoint[];
format: (value: number) => string;
axisFormat?: (value: number) => string;
height?: number;
labels?: [string, string];
/** Werte kommen in Minuten, die Achse soll in runden Stunden teilen: scale = 60. */
scale?: number;
}> = ({ points, format, axisFormat = format, height = 240, labels = ['Billable', 'Non-billable'], scale = 1 }) => {
const grown = useGrow(points.map((p) => `${p.key}${p.a}${p.b}`).join());
const [hover, setHover] = useState<number | null>(null);
const max = niceMax(Math.max(0, ...points.map((p) => p.a + p.b)) / scale) * scale;
return (
<div className="relative select-none">
<div className="flex" style={{ height }}>
<div className="relative w-10 shrink-0">
{[0, 1, 2, 3, 4].map((line) => (
<span
key={line}
className="tabular absolute right-2 -translate-y-1/2 text-[10px] font-bold text-ink-400"
style={{ top: `${(line / 4) * 100}%` }}
>
{axisFormat((max * (4 - line)) / 4)}
</span>
))}
</div>
<div className="relative flex-1">
{[0, 1, 2, 3, 4].map((line) => (
<div
key={line}
className={`absolute inset-x-0 border-t ${line === 4 ? 'border-ink-300' : 'border-dashed border-ink-100'}`}
style={{ top: `${(line / 4) * 100}%` }}
/>
))}
<div className="absolute inset-0 flex items-end gap-[6%] px-[2%] sm:gap-[3%]">
{points.map((p, i) => {
const total = p.a + p.b;
const h = grown ? (total / max) * 100 : 0;
return (
<div
key={p.key}
className="relative flex h-full min-w-0 flex-1 cursor-default items-end justify-center"
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
onTouchStart={() => setHover(i)}
>
<div
className={`relative flex w-full max-w-[44px] flex-col-reverse overflow-hidden rounded-t-md transition-all duration-700 ease-out ${
hover === i ? 'brightness-110' : ''
} ${p.highlight ? 'ring-2 ring-brand-300 ring-offset-2 ring-offset-surface' : ''}`}
style={{ height: `${h}%`, transitionDelay: `${i * 35}ms`, minHeight: total > 0 ? 3 : 0 }}
>
<div style={{ height: total ? `${(p.a / total) * 100}%` : 0, background: BILLABLE }} />
<div style={{ height: total ? `${(p.b / total) * 100}%` : 0, background: NON_BILLABLE }} />
</div>
{hover === i && (
<div className="animate-pop pointer-events-none absolute bottom-full z-20 mb-2 w-44 rounded-xl border border-ink-200 bg-surface p-3 text-xs shadow-lift">
<p className="mb-1.5 font-extrabold text-ink-900">{p.label}</p>
<Row color={BILLABLE} label={labels[0]} value={format(p.a)} />
<Row color={NON_BILLABLE} label={labels[1]} value={format(p.b)} />
<div className="mt-1.5 flex justify-between border-t border-ink-100 pt-1.5 font-bold text-ink-800">
<span>Gesamt</span>
<span className="tabular">{format(total)}</span>
</div>
{total > 0 && <p className="mt-1 text-[10px] text-ink-400">{Math.round((p.a / total) * 100)} % billable</p>}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
<div className="ml-10 mt-2 flex gap-[6%] px-[2%] sm:gap-[3%]">
{points.map((p) => (
<p key={p.key} className={`min-w-0 flex-1 truncate text-center text-[10px] font-bold ${p.highlight ? 'text-brand-700' : 'text-ink-400'}`}>
{p.label}
</p>
))}
</div>
</div>
);
};
const Row: React.FC<{ color: string; label: string; value: string }> = ({ color, label, value }) => (
<div className="flex items-center justify-between gap-3 py-0.5">
<span className="flex items-center gap-1.5 text-ink-500">
<span className="h-2 w-2 rounded-sm" style={{ background: color }} />
{label}
</span>
<span className="tabular font-bold text-ink-800">{value}</span>
</div>
);
export const Legend: React.FC<{ items: { color: string; label: string; value?: string }[]; className?: string }> = ({ items, className = '' }) => (
<div className={`flex flex-wrap items-center gap-x-4 gap-y-1 ${className}`}>
{items.map((item) => (
<span key={item.label} className="flex items-center gap-2 text-xs font-bold text-ink-600">
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: item.color }} />
{item.label}
{item.value && <span className="tabular text-ink-900">{item.value}</span>}
</span>
))}
</div>
);
/** Ring mit hochzaehlendem Prozentwert in der Mitte. */
export const Donut: React.FC<{ percent: number; size?: number; stroke?: number; color?: string; track?: string; label?: string }> = ({
percent,
size = 96,
stroke = 12,
color = BILLABLE,
track = NON_BILLABLE,
label,
}) => {
const grown = useGrow(percent);
const shown = useCountUp(percent);
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const p = Math.max(0, Math.min(100, percent));
return (
<div className="relative shrink-0" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={track} strokeWidth={stroke} />
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={grown ? c * (1 - p / 100) : c}
style={{ transition: 'stroke-dashoffset 1.1s cubic-bezier(0.22, 1, 0.36, 1)' }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="tabular font-extrabold text-ink-900" style={{ fontSize: size * 0.22 }}>
{Math.round(shown)}
<span style={{ fontSize: size * 0.12 }}>%</span>
</span>
{label && <span className="text-[9px] font-bold uppercase tracking-wider text-ink-400">{label}</span>}
</div>
</div>
);
};
/** Mehrsegment-Ring (Verteilung auf Projekte o. ae.). */
export const SegmentRing: React.FC<{ segments: { label: string; value: number; color: string }[]; size?: number; stroke?: number; center?: React.ReactNode }> = ({
segments,
size = 160,
stroke = 18,
center,
}) => {
const grown = useGrow(segments.map((s) => s.value).join());
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
let offset = 0;
return (
<div className="relative shrink-0" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-ink-100)" strokeWidth={stroke} />
{segments.map((segment, i) => {
const length = (segment.value / total) * c;
const dash = grown ? Math.max(0, length - 2) : 0;
const el = (
<circle
key={segment.label + i}
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={segment.color}
strokeWidth={stroke}
strokeDasharray={`${dash} ${c}`}
strokeDashoffset={-offset}
style={{ transition: `stroke-dasharray 0.9s cubic-bezier(0.22,1,0.36,1) ${i * 80}ms` }}
>
<title>{segment.label}</title>
</circle>
);
offset += length;
return el;
})}
</svg>
{center && <div className="absolute inset-0 flex flex-col items-center justify-center text-center">{center}</div>}
</div>
);
};
/** Sparkline mit weichem Verlauf darunter. */
export const Sparkline: React.FC<{ values: number[]; width?: number; height?: number; color?: string }> = ({
values,
width = 120,
height = 36,
color = 'var(--color-brand-500)',
}) => {
const id = useId().replace(/:/g, '');
const max = Math.max(1, ...values);
const step = values.length > 1 ? width / (values.length - 1) : width;
const pts = values.map((v, i) => [i * step, height - 3 - (v / max) * (height - 6)] as const);
const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
const area = `${line} L${width},${height} L0,${height} Z`;
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible">
<defs>
<linearGradient id={`spark-${id}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.35" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill={`url(#spark-${id})`} className="animate-fade" />
<path d={line} fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="draw-line" style={{ '--len': width * 2 } as React.CSSProperties} />
{pts.length > 0 && <circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r={2.8} fill={color} />}
</svg>
);
};
/**
* Projektverlauf wie in Harvest: kumulierte Stunden als Linie, Budget als
* waagerechte Linie, aktuelle Woche hinterlegt. Alternativ Stunden je Woche als Saeulen.
*/
export const ProgressChart: React.FC<{
points: { label: string; value: number | null; bar: number; barB?: number; current?: boolean }[];
budget: number | null;
mode: 'progress' | 'weekly';
format: (v: number) => string;
height?: number;
}> = ({ points, budget, mode, format, height = 260 }) => {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(640);
const [hover, setHover] = useState<number | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(280, entry.contentRect.width)));
observer.observe(el);
return () => observer.disconnect();
}, []);
const grown = useGrow(`${mode}${points.length}`);
const padL = 44;
const padR = 12;
const padT = 18;
const padB = 28;
const innerW = width - padL - padR;
const innerH = height - padT - padB;
const values = mode === 'progress' ? points.map((p) => p.value ?? 0) : points.map((p) => p.bar + (p.barB ?? 0));
const max = niceMax(Math.max(mode === 'progress' ? budget ?? 0 : 0, ...values) * 1.08);
const x = (i: number) => padL + (points.length <= 1 ? innerW / 2 : (i / (points.length - 1)) * innerW);
const y = (v: number) => padT + innerH - (v / max) * innerH;
const slot = innerW / Math.max(1, points.length);
const labelEvery = Math.ceil(points.length / Math.max(2, Math.floor(innerW / 70)));
const line = useMemo(
() =>
points
.map((p, i) => (p.value == null ? null : `${x(i).toFixed(1)},${y(p.value).toFixed(1)}`))
.filter(Boolean)
.map((pt, i) => `${i ? 'L' : 'M'}${pt}`)
.join(' '),
// eslint-disable-next-line react-hooks/exhaustive-deps
[points, width, max]
);
return (
<div ref={ref} className="relative w-full">
<svg width={width} height={height} className="block overflow-visible">
{[0, 1, 2, 3, 4].map((i) => {
const v = (max * i) / 4;
return (
<g key={i}>
<line x1={padL} x2={width - padR} y1={y(v)} y2={y(v)} stroke="var(--color-ink-100)" strokeDasharray={i ? '3 4' : undefined} />
<text x={padL - 8} y={y(v) + 3} textAnchor="end" className="tabular" fontSize="10" fontWeight="700" fill="var(--color-ink-400)">
{format(v)}
</text>
</g>
);
})}
{points.map((p, i) =>
p.current ? (
<g key={`cur-${i}`}>
<rect x={x(i) - slot / 2} y={padT} width={slot} height={innerH} fill="var(--color-sky-50)" opacity={0.9} />
<text x={x(i)} y={padT - 5} textAnchor="middle" fontSize="9" fontWeight="700" fill="var(--color-sky-700)">
Diese Woche
</text>
</g>
) : null
)}
{points.map((p, i) =>
i % labelEvery === 0 ? (
<text key={`l-${i}`} x={x(i)} y={height - 8} textAnchor="middle" fontSize="10" fontWeight="700" fill="var(--color-ink-400)">
{p.label}
</text>
) : null
)}
{mode === 'weekly' &&
points.map((p, i) => {
const bw = Math.max(3, Math.min(28, slot * 0.6));
const total = p.bar + (p.barB ?? 0);
const h = grown ? (total / max) * innerH : 0;
const ha = total ? (p.bar / total) * h : 0;
return (
<g key={`b-${i}`} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
<rect x={x(i) - slot / 2} y={padT} width={slot} height={innerH} fill="transparent" />
<rect x={x(i) - bw / 2} y={padT + innerH - h} width={bw} height={h - ha} rx={2} fill={NON_BILLABLE} style={{ transition: `all 0.7s ease-out ${i * 12}ms` }} />
<rect x={x(i) - bw / 2} y={padT + innerH - ha} width={bw} height={ha} fill={BILLABLE} style={{ transition: `all 0.7s ease-out ${i * 12}ms` }} />
</g>
);
})}
{mode === 'progress' && budget != null && budget > 0 && (
<g>
<line x1={padL} x2={width - padR} y1={y(budget)} y2={y(budget)} stroke="var(--color-ink-700)" strokeWidth={1.5} />
<rect x={padL + 4} y={y(budget) - 18} width={Math.max(92, format(budget).length * 7 + 56)} height={15} rx={4} fill="var(--color-ink-700)" />
<text x={padL + 10} y={y(budget) - 7.5} fontSize="9.5" fontWeight="700" fill="var(--color-surface)">
Budget: {format(budget)}
</text>
</g>
)}
{mode === 'progress' && line && (
<>
<path d={line} fill="none" stroke="var(--color-brand-500)" strokeWidth={2.4} strokeLinejoin="round" strokeLinecap="round" className="draw-line" style={{ '--len': width * 3 } as React.CSSProperties} key={`${width}-${points.length}`} />
{points.map((p, i) =>
p.value == null ? null : (
<g key={`d-${i}`} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
<rect x={x(i) - slot / 2} y={padT} width={slot} height={innerH} fill="transparent" />
<circle cx={x(i)} cy={y(p.value)} r={hover === i ? 5 : points.length > 40 ? 0 : 3} fill="var(--color-surface)" stroke="var(--color-brand-600)" strokeWidth={2} />
</g>
)
)}
</>
)}
</svg>
{hover != null && points[hover] && (
<div
className="animate-pop pointer-events-none absolute z-10 rounded-xl border border-ink-200 bg-surface px-3 py-2 text-xs shadow-lift"
style={{ left: Math.min(width - 150, Math.max(0, x(hover) - 70)), top: 0 }}
>
<p className="font-extrabold text-ink-900">{points[hover].label}</p>
{mode === 'progress' ? (
<p className="tabular text-ink-600">kumuliert {format(points[hover].value ?? 0)}</p>
) : (
<>
<p className="tabular text-ink-600">billable {format(points[hover].bar)}</p>
<p className="tabular text-ink-600">non-billable {format(points[hover].barB ?? 0)}</p>
</>
)}
</div>
)}
</div>
);
};
/** Waagerechter Doppelbalken (billable + non-billable) fuer Ranglisten und Tabellen. */
export const SplitBar: React.FC<{ a: number; b: number; max: number; height?: string; delay?: number; className?: string }> = ({
a,
b,
max,
height = 'h-2.5',
delay = 0,
className = '',
}) => {
const grown = useGrow(`${a}-${b}-${max}`);
const total = a + b;
return (
<div className={`flex ${height} overflow-hidden rounded-full bg-ink-100 ${className}`}>
<div
className="flex h-full overflow-hidden rounded-full transition-[width] duration-1000 ease-out"
style={{ width: grown && max ? `${(total / max) * 100}%` : '0%', transitionDelay: `${delay}ms` }}
>
<div style={{ width: total ? `${(a / total) * 100}%` : 0, background: BILLABLE }} />
<div style={{ width: total ? `${(b / total) * 100}%` : 0, background: NON_BILLABLE }} />
</div>
</div>
);
};
/** Hochzaehlender Text fuer grosse Zahlen. */
export const CountUp: React.FC<{ value: number; format: (v: number) => string; className?: string }> = ({ value, format, className }) => {
const shown = useCountUp(value);
return <span className={`tabular ${className ?? ''}`}>{format(shown)}</span>;
};
+324
View File
@@ -0,0 +1,324 @@
import React from 'react';
import { Icon, type IconName } from './Icon';
import { colorFor, initials } from '../../lib/format';
import { useCountUp, useGrow } from '../../lib/hooks';
export const inputClass =
'w-full rounded-xl border border-ink-200 bg-surface px-3.5 py-2.5 text-sm text-ink-900 outline-none transition placeholder:text-ink-300 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 disabled:bg-ink-50 disabled:text-ink-400';
export const labelClass = 'field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500';
export const Field: React.FC<{ label: string; hint?: React.ReactNode; children: React.ReactNode; className?: string; required?: boolean }> = ({
label,
hint,
children,
className = '',
required,
}) => (
<label className={`block ${className}`}>
<span className={labelClass}>
{label}
{required && <span className="text-brand-600"> *</span>}
</span>
{children}
{hint && <span className="mt-1 block text-xs text-ink-400">{hint}</span>}
</label>
);
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
const VARIANTS: Record<ButtonVariant, string> = {
primary: 'bg-brand-500 text-white shadow-card hover:bg-brand-600',
secondary: 'border border-ink-200 bg-surface text-ink-700 hover:border-brand-300 hover:text-brand-700',
ghost: 'text-ink-600 hover:bg-ink-100',
danger: 'bg-red-600 text-white hover:bg-red-700',
};
export const Button: React.FC<
React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; icon?: IconName; busy?: boolean; size?: 'sm' | 'md' }
> = ({ variant = 'primary', icon, busy, size = 'md', className = '', children, disabled, ...props }) => (
<button
{...props}
disabled={disabled || busy}
className={`inline-flex items-center justify-center gap-2 rounded-xl font-bold transition disabled:cursor-not-allowed disabled:opacity-60 ${
size === 'sm' ? 'px-3 py-1.5 text-xs' : 'px-4 py-2.5 text-sm'
} ${VARIANTS[variant]} ${className}`}
>
{busy ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent opacity-70" />
) : (
icon && <Icon name={icon} className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} strokeWidth={2} />
)}
{children}
</button>
);
export const IconButton: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement> & { icon: IconName; label: string; tone?: 'default' | 'danger' }> = ({
icon,
label,
tone = 'default',
className = '',
...props
}) => (
<button
{...props}
title={label}
aria-label={label}
className={`rounded-lg p-2 text-ink-400 transition ${
tone === 'danger' ? 'hover:bg-red-50 hover:text-red-600' : 'hover:bg-ink-100 hover:text-ink-800'
} ${className}`}
>
<Icon name={icon} className="h-4 w-4" strokeWidth={2} />
</button>
);
/** Segmentierter Umschalter (Woche/Monat/Jahr ...). Mit gleitendem Marker. */
export function Segmented<T extends string>({
value,
options,
onChange,
size = 'md',
className = '',
}: {
value: T;
options: { value: T; label: string }[];
onChange: (value: T) => void;
size?: 'sm' | 'md';
className?: string;
}) {
const index = Math.max(0, options.findIndex((o) => o.value === value));
return (
<div className={`relative inline-grid rounded-xl border border-ink-200 bg-ink-50 p-1 ${className}`} style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
<span
aria-hidden
className="absolute inset-y-1 left-1 rounded-lg bg-surface shadow-card transition-transform duration-300 ease-out"
style={{ width: `calc((100% - 0.5rem) / ${options.length})`, transform: `translateX(${index * 100}%)` }}
/>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
aria-pressed={option.value === value}
className={`relative z-10 whitespace-nowrap rounded-lg font-bold transition ${size === 'sm' ? 'px-2.5 py-1 text-[11px]' : 'px-3.5 py-1.5 text-xs'} ${
option.value === value ? 'text-brand-700' : 'text-ink-500 hover:text-ink-800'
}`}
>
{option.label}
</button>
))}
</div>
);
}
export const Badge: React.FC<{ tone?: 'brand' | 'neutral' | 'amber' | 'red' | 'sky' | 'violet'; children: React.ReactNode; className?: string; title?: string }> = ({
tone = 'neutral',
children,
className = '',
title,
}) => {
const tones = {
brand: 'border-brand-200 bg-brand-50 text-brand-700',
neutral: 'border-ink-200 bg-ink-50 text-ink-600',
amber: 'border-amber-200 bg-amber-50 text-amber-700',
red: 'border-red-200 bg-red-50 text-red-700',
sky: 'border-sky-200 bg-sky-50 text-sky-700',
violet: 'border-violet-200 bg-violet-50 text-violet-700',
};
return (
<span title={title} className={`inline-flex items-center gap-1 whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-bold ${tones[tone]} ${className}`}>
{children}
</span>
);
};
export const Card: React.FC<{ children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ children, className = '', style }) => (
<section style={style} className={`min-w-0 rounded-2xl border border-ink-200 bg-surface shadow-card ${className}`}>
{children}
</section>
);
export const Panel: React.FC<{
title: React.ReactNode;
hint?: React.ReactNode;
action?: React.ReactNode;
children: React.ReactNode;
className?: string;
index?: number;
}> = ({ title, hint, action, children, className = '', index = 0 }) => (
<Card className={`animate-rise p-4 sm:p-5 ${className}`} style={{ '--i': index } as React.CSSProperties}>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-base text-ink-900">{title}</h3>
{hint && <p className="mt-0.5 text-xs text-ink-400">{hint}</p>}
</div>
{action}
</div>
{children}
</Card>
);
export const EmptyState: React.FC<{ icon?: IconName; title: string; text?: React.ReactNode; action?: React.ReactNode }> = ({
icon = 'info',
title,
text,
action,
}) => (
<div className="animate-fade flex flex-col items-center justify-center rounded-2xl border border-dashed border-ink-200 px-6 py-12 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<Icon name={icon} className="h-6 w-6" />
</div>
<p className="text-sm font-bold text-ink-800">{title}</p>
{text && <p className="mt-1 max-w-sm text-xs text-ink-400">{text}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
export const Spinner: React.FC<{ label?: string }> = ({ label = 'Lade…' }) => (
<div className="flex items-center justify-center gap-3 py-16 text-xs font-bold uppercase tracking-[0.2em] text-ink-400">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
{label}
</div>
);
export const ErrorBox: React.FC<{ message: string; onRetry?: () => void }> = ({ message, onRetry }) => (
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
<Icon name="warning" className="mt-0.5 h-4 w-4 shrink-0" />
<span className="flex-1">{message}</span>
{onRetry && (
<button onClick={onRetry} className="text-xs font-bold underline">
Erneut versuchen
</button>
)}
</div>
);
export const Avatar: React.FC<{ name: string; color?: string | null; size?: number; id?: number; external?: boolean; className?: string }> = ({
name,
color,
size = 32,
id,
external,
className = '',
}) => (
<span
title={external ? `${name} (extern)` : name}
className={`relative inline-flex shrink-0 items-center justify-center rounded-full font-extrabold text-white ring-2 ring-surface ${className}`}
style={{ width: size, height: size, fontSize: Math.max(9, size * 0.36), backgroundColor: colorFor(id ?? name, color) }}
>
{initials(name)}
{external && (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-amber-400 text-[7px] text-white ring-2 ring-surface">
E
</span>
)}
</span>
);
export const AvatarStack: React.FC<{ people: { id: number; name: string; color: string | null; is_external?: boolean }[]; max?: number; size?: number }> = ({
people,
max = 5,
size = 26,
}) => (
<div className="flex items-center -space-x-1">
{people.slice(0, max).map((p) => (
<Avatar key={p.id} id={p.id} name={p.name} color={p.color} size={size} external={p.is_external} />
))}
{people.length > max && (
<span
className="inline-flex items-center justify-center rounded-full bg-ink-100 text-[10px] font-extrabold text-ink-600 ring-2 ring-surface"
style={{ width: size, height: size }}
>
+{people.length - max}
</span>
)}
</div>
);
/**
* Budgetbalken: verbrauchter Anteil waechst beim Einblenden, Farbe kippt ab
* Warnschwelle auf Bernstein und bei Ueberbuchung auf Rot.
*/
export const BudgetBar: React.FC<{ percent: number | null; warnAt?: number; height?: string; className?: string; muted?: boolean; goal?: boolean }> = ({
percent,
warnAt = 80,
height = 'h-2',
className = '',
muted,
goal,
}) => {
const grown = useGrow(percent);
if (percent == null) {
return <div className={`${height} rounded-full bg-[repeating-linear-gradient(45deg,var(--color-ink-100),var(--color-ink-100)_6px,var(--color-ink-50)_6px,var(--color-ink-50)_12px)] ${className}`} title="Unbegrenzt" />;
}
const color = muted ? 'bg-ink-300' : goal ? (percent >= 100 ? 'bg-brand-500' : 'bg-brand-300') : percent >= 100 ? 'bg-red-500' : percent >= warnAt ? 'bg-amber-500' : 'bg-brand-500';
return (
<div className={`${height} overflow-hidden rounded-full bg-ink-100 ${className}`}>
<div className={`h-full rounded-full ${color} transition-[width] duration-1000 ease-out`} style={{ width: grown ? `${Math.min(100, percent)}%` : '0%' }} />
</div>
);
};
/** Grosse Kennzahl mit hochzaehlender Zahl. */
export const Stat: React.FC<{
label: string;
value: number;
format: (value: number) => string;
hint?: React.ReactNode;
icon?: IconName;
accent?: string;
index?: number;
delta?: number | null;
}> = ({ label, value, format, hint, icon, accent = 'var(--color-brand-500)', index = 0, delta }) => {
const shown = useCountUp(value);
return (
<Card className="animate-rise relative overflow-hidden p-4 sm:p-5" style={{ '--i': index } as React.CSSProperties}>
<span aria-hidden className="absolute inset-y-0 left-0 w-1" style={{ background: accent }} />
<div className="flex items-start justify-between gap-2">
<p className="text-[11px] font-extrabold uppercase tracking-[0.14em] text-ink-400">{label}</p>
{icon && <Icon name={icon} className="h-5 w-5 shrink-0 text-ink-300" />}
</div>
<p className="tabular mt-2 text-2xl font-extrabold text-ink-900 sm:text-3xl">{format(shown)}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-ink-400">
{delta != null && Number.isFinite(delta) && (
<span className={`inline-flex items-center gap-0.5 font-bold ${delta >= 0 ? 'text-brand-600' : 'text-red-600'}`}>
<Icon name={delta >= 0 ? 'arrowUp' : 'arrowDown'} className="h-3 w-3" strokeWidth={2.5} />
{Math.abs(Math.round(delta))} %
</span>
)}
{hint}
</div>
</Card>
);
};
export const Toggle: React.FC<{ checked: boolean; onChange: (value: boolean) => void; label: React.ReactNode; hint?: React.ReactNode; disabled?: boolean }> = ({
checked,
onChange,
label,
hint,
disabled,
}) => (
<label className={`flex items-start gap-3 ${disabled ? 'opacity-60' : 'cursor-pointer'}`}>
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition ${checked ? 'bg-brand-500' : 'bg-ink-200'}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-all ${checked ? 'left-[22px]' : 'left-0.5'}`} />
</button>
<span>
<span className="block text-sm font-bold text-ink-800">{label}</span>
{hint && <span className="mt-0.5 block text-xs text-ink-400">{hint}</span>}
</span>
</label>
);
export const PageHeader: React.FC<{ title?: React.ReactNode; children?: React.ReactNode; className?: string }> = ({ title, children, className = '' }) => (
<div className={`mb-4 flex flex-wrap items-center justify-between gap-3 ${className}`}>
{title && <div className="min-w-0">{title}</div>}
<div className="flex flex-wrap items-center gap-2">{children}</div>
</div>
);